diff --git a/server.ts b/server.ts index 0ce9765..34f9fcc 100644 --- a/server.ts +++ b/server.ts @@ -23,6 +23,7 @@ import logger, { initLogHandler } from "./src/logger/logger"; import passport from "passport"; import rabbitmq from "./src/rabbitmq/rabbitmq"; import { GOOGLE_CALLBACK_URL } from "./src/utils/constants"; +import { StripeWebhook } from "./src/controllers/paymentController"; const app: Express = express(); const host = process.env.HOST || "localhost"; const port = Number(process.env.PORT) || 3000; @@ -110,6 +111,14 @@ app.use(cors()); app.use(cookieParser()); app.use(useragent.express()); +// we need to parse the body of the request to get the stripe signature +// it is important that the webhook is registered before the body parser +// see https://github.com/stripe/stripe-node/issues/341 +app.use( + "/api/v1/webhooks/stripe", + bodyParser.raw({ type: "application/json" }), + StripeWebhook +); app.use(bodyParser.json()); app.use("/api/v1/calendar", calendarRoutes); app.use("/api/v1/payment", paymentRoutes); @@ -122,7 +131,7 @@ const specs = swaggerJsDoc(options); app.use("/api-docs", swaggerUI.serve, swaggerUI.setup(specs)); app.use((req, res, next) => { - logger.warn(`reqnot found, path: ${req.path}`); + logger.warn(`req not found, path: ${req.path}`); res.status(404).send("not found"); }); diff --git a/src/controllers/paymentController.ts b/src/controllers/paymentController.ts index feed68c..09c6310 100644 --- a/src/controllers/paymentController.ts +++ b/src/controllers/paymentController.ts @@ -1,15 +1,340 @@ import { Request, Response } from "express"; +import logger from "../logger/logger"; import Stripe from "stripe"; +import { + ACCOUNT_STATE, + DASHBOARD_URL, + PAYMENT_PLAN_SETTINGS, +} from "../utils/constants"; +import UserPendingPayment from "../models/userPendingPayment"; +import User from "../models/user"; +import { getUserSession } from "../utils/utils"; +import Store from "../models/store"; +import Session from "../models/session"; + +const ZeitAdlerBasicMonthly = "zeitadler-basic-monthly"; +const ZeitAdlerBasicYearly = "zeitadler-basic-yearly"; +const ZeitAdlerPremiumMonthly = "zeitadler-premium-monthly"; +const ZeitAdlerPremiumYearly = "zeitadler-premium-yearly"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string); +let cachedPrices = [] as any[]; -export async function CreatePaymentIntent(req: Request, res: Response) { - const paymentIntent = await stripe.paymentIntents.create({ - amount: 1, - currency: "eur", +export async function loadPrices() { + if (cachedPrices.length > 0) { + return; + } + + logger.info("Loading prices from Stripe API"); + + // load prices from stripe + const prices = await stripe.prices.list({ + lookup_keys: [ + ZeitAdlerBasicMonthly, + ZeitAdlerBasicYearly, + ZeitAdlerPremiumMonthly, + ZeitAdlerPremiumYearly, + ], + expand: ["data.product"], }); - res.send({ - clientSecret: paymentIntent.client_secret, - }); + for (const price of prices.data) { + cachedPrices.push({ + id: price.id, + unit_amount: price.unit_amount, + lookup_key: price.lookup_key, + }); + } +} + +export async function getPriceId(paymentPlan: number, interval: number) { + await loadPrices(); + + const val = `zeitadler-${PAYMENT_PLAN_SETTINGS[paymentPlan].id}-${ + interval === 1 ? "yearly" : "monthly" + }`; + + const found = cachedPrices.find((price) => price.lookup_key === val); + + if (found === undefined) { + return null; + } + + return found.id; +} + +// used to get the prices for the frontend +export async function GetPrices(req: Request, res: Response) { + try { + const { productId } = req.params; + + if (productId === undefined || (productId !== "1" && productId !== "2")) { + return res.status(400).send({ err: "invalid request" }); + } + + await loadPrices(); + + let lookupKey: any[] = []; + + if (productId === "1") { + lookupKey = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly]; + } else if (productId === "2") { + lookupKey = [ZeitAdlerPremiumMonthly, ZeitAdlerPremiumYearly]; + } + + res.status(200).send({ + prices: cachedPrices + .filter( + (price) => + price.lookup_key === lookupKey[0] || + price.lookup_key === lookupKey[1] + ) + .map((price) => { + return { + lookup_key: price.lookup_key, + unit_amount: price.unit_amount, + }; + }), + }); + } catch (error) { + logger.error(error); + res.status(500).send({ err: "invalid request" }); + } +} + +export async function CreateCheckoutSession(priceId: string, userId: string) { + try { + if (priceId === undefined || userId === undefined) { + logger.error("CreateCheckoutSession: invalid request"); + return ""; + } + + if (cachedPrices.length === 0) { + logger.info("Loading prices from Stripe API"); + await loadPrices(); + } + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ["card"], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + customer_email: "hello@roese.dev", + mode: "subscription", + success_url: `${DASHBOARD_URL}/checkout/success/{CHECKOUT_SESSION_ID}`, + cancel_url: `${DASHBOARD_URL}/checkout/canceled/{CHECKOUT_SESSION_ID}`, + metadata: { + userId: userId, + }, + }); + + await UserPendingPayment.create({ + payment_session_id: session.id, + user_id: userId, + }); + + return session.url; + } catch (error) { + logger.error(error); + return ""; + } +} + +// user is redirected to dashboard checkout page after successful payment +// and then makes request to this endpoint to create a user session +export async function CheckoutSuccess(req: Request, res: Response) { + try { + const { sessionId } = req.body; + + logger.info("CheckoutSuccess", sessionId); + + if (sessionId === undefined) { + return res.status(400).send({ err: "invalid request" }); + } + + const userPendingPayment = await UserPendingPayment.findOne({ + where: { + payment_session_id: sessionId, + }, + }); + + if (userPendingPayment === null) { + logger.info("CheckoutSuccess payment successful"); + return res.status(200).send({ status: 1 }); + } + + logger.info("CheckoutSuccess pending payment"); + + res.status(200).send({ status: 0 }); + } catch (err) { + logger.error(err); + res.status(500).send({ err: "invalid request" }); + } +} + +export async function CheckoutCanceled(req: Request, res: Response) { + try { + const { sessionId } = req.body; + + if (sessionId === undefined) { + logger.error("CheckoutCanceled: invalid request"); + return res.status(400).send({ err: "invalid request" }); + } + + const userPendingPayment = await UserPendingPayment.findOne({ + where: { + payment_session_id: sessionId, + }, + }); + + if (userPendingPayment === null) { + console.log("CheckoutCanceled not found"); + return res.status(400).send({ err: "invalid request" }); + } + + // delete user + + await User.destroy({ + where: { + user_id: userPendingPayment.user_id, + }, + }); + + // delete store + + await Store.destroy({ + where: { + owner_user_id: userPendingPayment.user_id, + }, + }); + + // delete all user sessions + + await Session.destroy({ + where: { + user_id: userPendingPayment.user_id, + }, + }); + + // delete user pending payment + + await userPendingPayment.destroy(); + + res.status(200).send({ status: 1 }); + } catch (err) { + logger.error(err); + res.status(500).send({ err: "invalid request" }); + } +} + +export async function StripeWebhook(req: Request, res: Response) { + try { + if (req.method !== "POST") { + logger.error("StripeWebhook: invalid reques. Method", req.method); + return res.status(405).end(); // Method Not Allowed + } + + const payload = req.body; + const sig = req.headers["stripe-signature"] as string; + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + payload, + sig, + process.env.STRIPE_WEBHOOK_ENDPOINT_SECRET as string + ); + } catch (err) { + logger.error("Webhook-Fehler", err); + return res.status(400).send(`Webhook Error: ${err}`); + } + + if (event.type === "checkout.session.completed") { + const userId = (event.data.object as Stripe.Checkout.Session)?.metadata + ?.userId; + + // delete user pending payment + + logger.info( + "delete user pending payment", + (event.data.object as Stripe.Checkout.Session).id + ); + + const pendingPayment = await UserPendingPayment.findOne({ + where: { + payment_session_id: (event.data.object as Stripe.Checkout.Session).id, + }, + }); + + if (pendingPayment !== null) { + await pendingPayment.destroy(); + + const user = await User.findOne({ + where: { + user_id: userId, + }, + }); + + if (user !== null && user.state === ACCOUNT_STATE.INIT_PAYMENT) { + // update user state + await User.update( + { + state: ACCOUNT_STATE.ACTIVE, + stripe_customer_id: (event.data.object as Stripe.Checkout.Session) + .customer as string, + }, + { + where: { + user_id: userId, + }, + } + ); + } + } + } else { + logger.warn("Ignoring unknown event:", event.type); + } + + res.sendStatus(200); + } catch (error) { + logger.error(error); + res.status(500).send({ err: "invalid request" }); + } +} + +export async function GetBillingPortal(req: Request, res: Response) { + try { + const userSession = await getUserSession(req); + + if (userSession === null) { + return res.status(401).send({ err: "unauthorized" }); + } + + const user = await User.findOne({ + where: { + user_id: userSession.user_id, + }, + attributes: ["stripe_customer_id"], + }); + + if (user === null || user.stripe_customer_id === null) { + return res.status(400).send({ err: "invalid request" }); + } + + const session = await stripe.billingPortal.sessions.create({ + customer: user.stripe_customer_id, + return_url: `${DASHBOARD_URL}/payment-plan`, + }); + + res.status(200).send({ + url: session.url, + }); + } catch (error) { + logger.error(error); + res.status(500).send({ err: "invalid request" }); + } } diff --git a/src/controllers/storeController.ts b/src/controllers/storeController.ts index 979c77f..14f4078 100644 --- a/src/controllers/storeController.ts +++ b/src/controllers/storeController.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import Store from "../models/store"; import { getUserSession } from "../utils/utils"; import { isCompanyNameValid } from "../validator/validator"; -import { storeLogger, userLogger } from "../logger/logger"; +import logger, { storeLogger, userLogger } from "../logger/logger"; export async function GetStore(req: Request, res: Response) { try { @@ -46,6 +46,7 @@ export async function GetStore(req: Request, res: Response) { }, }); } catch (error) { + logger.error(error); res.status(500).send({ err: "invalid request" }); } } @@ -112,6 +113,7 @@ export async function UpdateStore(req: Request, res: Response) { res.status(200).send({ msg: "success" }); } catch (error) { + logger.error(error); res.status(500).send({ err: "invalid request" }); } } diff --git a/src/controllers/storeServicesController.ts b/src/controllers/storeServicesController.ts index c50007b..2a9a305 100644 --- a/src/controllers/storeServicesController.ts +++ b/src/controllers/storeServicesController.ts @@ -52,7 +52,7 @@ export async function GetStoreServices(req: Request, res: Response) { res.status(200).send({ services, users }); } catch (error) { - console.log(error); + logger.error(error); res.status(500).send({ err: "invalid request" }); } } @@ -100,7 +100,7 @@ export async function CreateStoreService(req: Request, res: Response) { res.status(200).send({ service }); } catch (error) { - console.log(error); + logger.error(error); res.status(500).send({ err: "invalid request" }); } } @@ -163,7 +163,7 @@ export async function UpdateStoreService(req: Request, res: Response) { res.status(200).send({ msg: "success" }); } catch (error) { - console.log(error); + logger.error(error); res.status(500).send({ err: "invalid request" }); } } @@ -248,7 +248,7 @@ export async function CreateStoreServiceActivity(req: Request, res: Response) { res.status(200).send({ activity }); } catch (error) { - console.log(error); + logger.error(error); res.status(500).send({ err: "invalid request" }); } } @@ -282,7 +282,7 @@ export async function GetStoreServiceActivities(req: Request, res: Response) { res.status(200).send({ activities: activities }); } catch (error) { - console.log(error); + logger.error(error); res.status(500).send({ err: "invalid request" }); } } @@ -418,7 +418,7 @@ export async function UpdateStoreServiceActivity(req: Request, res: Response) { res.status(200).send({ msg: "success" }); } catch (error) { - console.log(error); + logger.error(error); res.status(500).send({ err: "invalid request" }); } } @@ -497,7 +497,7 @@ export async function DeleteStoreServiceActivity(req: Request, res: Response) { res.status(200).send({ msg: "success" }); } catch (error) { - console.log(error); + logger.error(error); res.status(500).send({ err: "invalid request" }); } } @@ -583,7 +583,7 @@ export async function DeleteStoreService(req: Request, res: Response) { res.status(200).send({ msg: "success" }); } catch (error) { - console.log(error); + logger.error(error); res.status(500).send({ err: "invalid request" }); } } diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index 11e6b59..3470518 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -6,6 +6,8 @@ import { isEmailValid, isLanguageCodeValid, isPasswordValid, + isPaymentIntervalValid, + isPaymentPlanValid, isUsernameValid, } from "../validator/validator"; import { @@ -14,7 +16,6 @@ import { CALENDAR_MAX_SERVICE_DURATION, CALENDAR_MIN_EARLIEST_BOOKING_TIME, EMAIL_VERIFICATION_STATE, - PAYMENT_PLAN, PAYMENT_PLAN_SETTINGS, Roles, USER_ANALYTICS_ENABLED_DEFAULT, @@ -41,6 +42,7 @@ import rabbitmq from "../rabbitmq/rabbitmq"; import verifyCaptcha from "../utils/recaptcha"; import EmailVerification from "../models/emailVerification"; import UserPendingEmailChange from "../models/userPendingEmailChange"; +import { CreateCheckoutSession, getPriceId } from "./paymentController"; export async function SignUp(req: Request, res: Response) { try { @@ -52,6 +54,8 @@ export async function SignUp(req: Request, res: Response) { language, rememberMe, recaptcha, + paymentPlan, + paymentInterval, } = req.body; // validate request @@ -67,7 +71,11 @@ export async function SignUp(req: Request, res: Response) { !(await isEmailValid(email)) || !isLanguageCodeValid(language) || rememberMe === undefined || - !recaptcha + !recaptcha || + paymentPlan === undefined || + !isPaymentPlanValid(paymentPlan) || + paymentInterval === undefined || + !isPaymentIntervalValid(paymentInterval) ) { return res.status(400).send({ err: "invalid request" }); } @@ -118,14 +126,14 @@ export async function SignUp(req: Request, res: Response) { owner_user_id: userId, name: companyName, calendar_max_future_booking_days: - PAYMENT_PLAN_SETTINGS[PAYMENT_PLAN.DEMO].calendarMaxFutureBookingDays, + PAYMENT_PLAN_SETTINGS[paymentPlan].calendarMaxFutureBookingDays, calendar_min_earliest_booking_time: CALENDAR_MIN_EARLIEST_BOOKING_TIME, calendar_max_service_duration: CALENDAR_MAX_SERVICE_DURATION, address: "", }); // create email verification - + /* const emailVerificationId = newEmailVerificationId(); const state = EMAIL_VERIFICATION_STATE.PENDING_EMAIL_VERIFICATION; @@ -137,7 +145,7 @@ export async function SignUp(req: Request, res: Response) { rabbitmq.sendEmail(email, "dashboardSignUpEmailVerification", language, { emailVerificationUrl: getEmailVerificationUrl(state, emailVerificationId), - }); + }); */ // create user @@ -150,15 +158,26 @@ export async function SignUp(req: Request, res: Response) { password: hashedPassword, language: language, analytics_enabled: USER_ANALYTICS_ENABLED_DEFAULT, - state: ACCOUNT_STATE.PENDING_EMAIL_VERIFICATION, - payment_plan: PAYMENT_PLAN.DEMO, + state: ACCOUNT_STATE.INIT_PAYMENT, + payment_plan: paymentPlan, }); + const checkoutSessionUrl = await CreateCheckoutSession( + await getPriceId(paymentPlan, paymentInterval), + userId + ); + + if (!checkoutSessionUrl) { + return res.status(500).send({ err: "invalid request" }); + } + logger.info( `new user signed up: user_id: ${userId} email: ${email} language: ${language} company: ${companyName} username: ${username}` ); - res.status(200).send({ msg: "success" }); + saveSession(req, res, userId, true, { + redirectUrl: checkoutSessionUrl, + }); } catch (error) { logger.error(error); res.status(500).send({ err: "invalid request" }); @@ -272,7 +291,7 @@ export async function Login(req: Request, res: Response) { userLogger.info(user.user_id, "User logged in"); // create session - saveSession(req, res, user.user_id, user.username, rememberMe); + saveSession(req, res, user.user_id, rememberMe); } catch (error) { logger.error(error); res.status(500).send({ err: "invalid request" }); @@ -539,7 +558,7 @@ export async function GetUserProfileSettings(req: Request, res: Response) { userLogger.info(session.user_id, "GetUserProfileSettings"); - res.status(200).json(user); + res.status(200).send(user); } catch (error) { logger.error(error); res.status(500).send({ err: "invalid request" }); @@ -705,7 +724,7 @@ export async function GetUserProfileSessions(req: Request, res: Response) { userLogger.info(session.user_id, "GetUserProfileSessions"); - res.status(200).json({ + res.status(200).send({ sessions: sessionsList, currentSession: currentSession, }); @@ -899,7 +918,7 @@ export async function ExportUserAccount(req: Request, res: Response) { userLogger.info(session.user_id, "User requested account export"); - res.status(200).json({}); + res.status(200).send({}); } catch (error) { logger.error(error); res.status(500).send({ err: "invalid request" }); diff --git a/src/models/index.ts b/src/models/index.ts index a7da773..c173945 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -7,6 +7,7 @@ import StoreServiceActivity from "./storeServiceActivity"; import StoreServiceActivityUsers from "./storeServiceActivityUsers"; import User from "./user"; import UserPendingEmailChange from "./userPendingEmailChange"; +import UserPendingPayment from "./userPendingPayment"; function syncModels() { EmailVerification.sync(); @@ -18,6 +19,7 @@ function syncModels() { StoreServiceActivityUsers.sync(); Feedback.sync(); UserPendingEmailChange.sync(); + UserPendingPayment.sync(); // UserGoogleTokens.sync(); not needed as it is created by the calendar backend } diff --git a/src/models/user.ts b/src/models/user.ts index 36638d0..1fde9a7 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -18,6 +18,7 @@ interface UserAttributes { google_account_picture?: string; analytics_enabled: boolean; payment_plan: number; + stripe_customer_id?: string; } class User extends Model implements UserAttributes { @@ -36,6 +37,7 @@ class User extends Model implements UserAttributes { declare google_account_picture: string; declare analytics_enabled: boolean; declare payment_plan: number; + declare stripe_customer_id: string; declare created_at: Date; } @@ -103,6 +105,10 @@ User.init( type: DataTypes.TINYINT, allowNull: false, }, + stripe_customer_id: { + type: DataTypes.STRING, + allowNull: true, + }, }, { tableName: "users", diff --git a/src/models/userPendingPayment.ts b/src/models/userPendingPayment.ts new file mode 100644 index 0000000..6a81603 --- /dev/null +++ b/src/models/userPendingPayment.ts @@ -0,0 +1,37 @@ +import { DataTypes, Model } from "sequelize"; +import sequelize from "../database/database"; + +interface UserPendingPaymentAttributes { + payment_session_id: string; + user_id: string; +} + +class UserPendingPayment + extends Model + implements UserPendingPaymentAttributes +{ + declare payment_session_id: string; + declare user_id: string; +} + +UserPendingPayment.init( + { + payment_session_id: { + primaryKey: true, + type: DataTypes.STRING, + allowNull: false, + }, + user_id: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + sequelize, + modelName: "user_pending_payment", + createdAt: "created_at", + updatedAt: "updated_at", + } +); + +export default UserPendingPayment; diff --git a/src/routes/paymentRoutes.ts b/src/routes/paymentRoutes.ts index f6a54c8..6de99f0 100644 --- a/src/routes/paymentRoutes.ts +++ b/src/routes/paymentRoutes.ts @@ -3,6 +3,11 @@ const router = express.Router(); import * as paymentController from "../controllers/paymentController"; -router.post("/create-payment-intent", paymentController.CreatePaymentIntent); +router.get("/prices/:productId", paymentController.GetPrices); +// webhook is registered in server.ts as it needs registered before the body parser + +router.post("/checkout/success", paymentController.CheckoutSuccess); +router.post("/checkout/canceled", paymentController.CheckoutCanceled); +router.get("/portal", paymentController.GetBillingPortal); export default router; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 7c45a90..6cfb536 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -53,6 +53,7 @@ export enum ACCOUNT_STATE { INIT_LOGIN = 2, // account is created but password is not set yet BANNED = 3, // account is banned, cannot login PENDING_EMAIL_VERIFICATION = 4, // account is created but email is not verified yet + INIT_PAYMENT = 5, // account is created but payment is not set yet } export const FEEDBACK_MIN_LENGTH = 3; @@ -88,15 +89,25 @@ export const TERMIN_PLANNER_URL = process.env.TERMIN_PLANNER_URL; export enum PAYMENT_PLAN { DEMO = 0, BASIC = 1, + PREMIUM = 2, } export const PAYMENT_PLAN_SETTINGS = [ { + // demo maxEmployees: 5, calendarMaxFutureBookingDays: 14, }, { + // basic + id: "basic", maxEmployees: 15, calendarMaxFutureBookingDays: 60, }, + { + // premium + id: "premium", + maxEmployees: 20, + calendarMaxFutureBookingDays: 90, + }, ]; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 8c5afee..78f2577 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -67,8 +67,8 @@ export async function saveSession( req: Request, res: Response, userId: string, - username: string, - rememberMe: boolean + rememberMe: boolean, + data?: any ) { try { const userSession = newUserSession(); @@ -88,7 +88,7 @@ export async function saveSession( res.status(200).json({ XAuthorization: userSession, - Username: username, + ...data, }); } catch (err) { res.status(500).send({ err: "invalid request" }); diff --git a/src/validator/validator.ts b/src/validator/validator.ts index 85a9845..16cce20 100644 --- a/src/validator/validator.ts +++ b/src/validator/validator.ts @@ -24,6 +24,7 @@ import { PAYMENT_PLAN_SETTINGS, CALENDAR_MIN_EARLIEST_BOOKING_TIME, CALENDAR_MAX_EARLIEST_BOOKING_TIME, + PAYMENT_PLAN, } from "../utils/constants"; import User from "../models/user"; import UserPendingEmailChange from "../models/userPendingEmailChange"; @@ -183,3 +184,13 @@ export function isCalendarMinEarliestBookingTimeValid( calendarMinEarliestBookingTime <= CALENDAR_MAX_EARLIEST_BOOKING_TIME ); } + +export function isPaymentPlanValid(paymentPlan: number) { + return ( + paymentPlan !== PAYMENT_PLAN.DEMO && paymentPlan <= PAYMENT_PLAN.PREMIUM + ); +} + +export function isPaymentIntervalValid(paymentInterval: number) { + return paymentInterval === 0 || paymentInterval === 1; +}