From 92f6782c21b3f7b4aeb93fc9cff2b1e24d6920ea Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 30 Mar 2024 13:47:05 +0100 Subject: [PATCH] payment --- src/controllers/paymentController.ts | 294 ++++++++++++++++++++------- src/controllers/userController.ts | 30 ++- src/logger/logger.ts | 14 +- src/models/user.ts | 24 +-- src/routes/paymentRoutes.ts | 4 + src/utils/constants.ts | 21 +- src/validator/validator.ts | 8 +- 7 files changed, 273 insertions(+), 122 deletions(-) diff --git a/src/controllers/paymentController.ts b/src/controllers/paymentController.ts index 95ae933..1ffcf8f 100644 --- a/src/controllers/paymentController.ts +++ b/src/controllers/paymentController.ts @@ -2,8 +2,8 @@ import { Request, Response } from "express"; import logger, { userLogger } from "../logger/logger"; import Stripe from "stripe"; import { - ACCOUNT_STATE, DASHBOARD_URL, + PAYMENT_PLAN, PAYMENT_PLAN_SETTINGS, } from "../utils/constants"; import UserPendingPayment from "../models/userPendingPayment"; @@ -12,10 +12,9 @@ import { getUserSession, stripe } 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 ZeitAdlerBasicMonthly = "za-basic-monthly"; +const ZeitAdlerBasicYearly = "za-basic-yearly"; +const lookupKeys = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly]; let cachedPrices = [] as any[]; @@ -28,12 +27,7 @@ export async function loadPrices() { // load prices from stripe const prices = await stripe.prices.list({ - lookup_keys: [ - ZeitAdlerBasicMonthly, - ZeitAdlerBasicYearly, - ZeitAdlerPremiumMonthly, - ZeitAdlerPremiumYearly, - ], + lookup_keys: lookupKeys, expand: ["data.product"], }); @@ -48,6 +42,66 @@ export async function loadPrices() { } } +export async function getUserPlanInfo(req: Request, res: Response) { + try { + const userSession = await getUserSession(req); + + if (userSession === null) { + return res.status(401).send({ err: "unauthorized" }); + } + + await loadPrices(); + + const user = await User.findOne({ + where: { + user_id: userSession.user_id, + }, + attributes: [ + "payment_plan", + "payment_plan_interval", + "payment_plan_trial_end", + "payment_plan_canceled_at", + ], + }); + + if (user === null) { + return res.status(400).send({ err: "invalid request" }); + } + + let resData = { + payment_plan: user.payment_plan, + payment_plan_interval: user.payment_plan_interval, + prices: cachedPrices + .filter( + (price) => + price.lookup_key === lookupKeys[0] || + price.lookup_key === lookupKeys[1] + ) + .map((price) => { + return { + lookup_key: price.lookup_key, + unit_amount: price.unit_amount, + }; + }), + } as any; + + if (user.payment_plan_trial_end !== null) { + resData["payment_plan_trial_end"] = user.payment_plan_trial_end; + } + + if (user.payment_plan_canceled_at !== null) { + resData["payment_plan_canceled_at"] = user.payment_plan_canceled_at; + } + + userLogger.info(userSession.user_id, "GetUserPlanInfo"); + + res.status(200).send(resData); + } catch (error) { + logger.error("getUserPlanInfo", error as string); + res.status(500).send({ err: "invalid request" }); + } +} + export async function getPriceId(paymentPlan: number, interval: number) { await loadPrices(); @@ -75,13 +129,7 @@ export async function GetPrices(req: Request, res: Response) { await loadPrices(); - let lookupKey: any[] = []; - - if (productId === "0") { - lookupKey = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly]; - } else if (productId === "1") { - lookupKey = [ZeitAdlerPremiumMonthly, ZeitAdlerPremiumYearly]; - } + let lookupKey = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly]; res.status(200).send({ prices: cachedPrices @@ -103,15 +151,88 @@ export async function GetPrices(req: Request, res: Response) { } } -export async function CreateCheckoutSession(priceId: string, userId: string) { +export async function CreateCheckoutSession(req: Request, res: Response) { try { - if (priceId === undefined || userId === undefined) { - logger.error("CreateCheckoutSession: invalid request"); - return ""; + const { lookupKey } = req.body; + + if (lookupKey === undefined) { + return res.status(400).send({ err: "invalid request" }); } await loadPrices(); + // check if lookupKey is valid + + const priceId = cachedPrices.find( + (price) => price.lookup_key === lookupKey + )?.id; + + if (priceId === undefined) { + return res.status(400).send({ err: "invalid request" }); + } + + 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: ["user_id", "payment_plan_trial_end", "stripe_customer_id"], + }); + + if (user === null) { + return res.status(400).send({ err: "invalid request" }); + } + + if (user.stripe_customer_id !== null && user.stripe_customer_id !== "") { + const subscriptions = await stripe.subscriptions.list({ + customer: user.stripe_customer_id, + limit: 1, + }); + + if ( + subscriptions.data.length > 0 && + subscriptions.data[0].id !== priceId + ) { + const latestSubscription = subscriptions.data[0]; + + await stripe.subscriptions.update(latestSubscription.id, { + items: [ + { + id: latestSubscription.items.data[0].id, + price: priceId, + }, + ], + }); + + // update user + + await User.update( + { + payment_plan_interval: lookupKey === ZeitAdlerBasicYearly ? 1 : 0, + }, + { + where: { + user_id: userSession.user_id, + }, + } + ); + + userLogger.info( + userSession.user_id, + `CreateCheckoutSession: update subscription ${latestSubscription.id} to ${priceId}` + ); + + return res.status(200).json({ status: "ok" }); + } + } + + // create checkout session + const session = await stripe.checkout.sessions.create({ payment_method_types: ["card"], line_items: [ @@ -123,31 +244,37 @@ export async function CreateCheckoutSession(priceId: string, userId: string) { billing_address_collection: "required", mode: "subscription", subscription_data: { - trial_period_days: 7, + trial_end: new Date(user.payment_plan_trial_end).getTime() / 1000, }, - success_url: `${DASHBOARD_URL}/checkout/success/{CHECKOUT_SESSION_ID}`, - cancel_url: `${DASHBOARD_URL}/checkout/canceled/{CHECKOUT_SESSION_ID}`, + success_url: `${DASHBOARD_URL}/payment-plan`, + cancel_url: `${DASHBOARD_URL}/payment-plan`, metadata: { - userId: userId, - }, + userId: userSession.user_id, + payment_plan_interval: lookupKey === ZeitAdlerBasicYearly ? 1 : 0, + } /* automatic_tax: { // see https://dashboard.stripe.com/settings/tax enabled: true, - }, + },*/, }); - + /* await UserPendingPayment.create({ payment_session_id: session.id, - user_id: userId, + user_id: userSession.user_id, payment_session_url: session.url as string, + }); */ + + userLogger.info( + userSession.user_id, + `CreateCheckoutSession: ${session.id}` + ); + + res.status(200).send({ + url: session.url, }); - - logger.info(`CreateCheckoutSession: ${session.id}`); - - return session.url; } catch (error) { logger.error("CreateCheckoutSession", error as string); - return ""; + res.status(500).send({ err: "invalid request" }); } } @@ -263,14 +390,22 @@ export async function StripeWebhook(req: Request, res: Response) { return res.status(400).send(`Webhook Error: ${error}`); } + let user; let userId; let customerId; - let user; switch (event.type) { case "checkout.session.completed": - userId = (event.data.object as Stripe.Checkout.Session)?.metadata - ?.userId; + const session = event.data.object as Stripe.Checkout.Session; + + if (session === undefined) { + logger.error( + "StripeWebhook: checkout.session.completed: session undefined" + ); + break; + } + + userId = session.metadata?.userId; if (userId === undefined) { logger.error( @@ -279,52 +414,51 @@ export async function StripeWebhook(req: Request, res: Response) { break; } - // delete user pending payment + const paymentPlanInterval = session.metadata?.payment_plan_interval; - const pendingPayment = await UserPendingPayment.findOne({ + if (paymentPlanInterval === undefined) { + logger.error( + "StripeWebhook: checkout.session.completed: paymentPlanInterval undefined" + ); + break; + } + + user = await User.findOne({ where: { - payment_session_id: (event.data.object as Stripe.Checkout.Session) - .id, + user_id: userId, }, }); - if (pendingPayment !== null) { - await pendingPayment.destroy(); - - let user = await User.findOne({ - where: { - user_id: userId, + if (user !== null) { + await User.update( + { + payment_plan: PAYMENT_PLAN.BASIC, + stripe_customer_id: (event.data.object as Stripe.Checkout.Session) + .customer as string, + payment_plan_interval: parseInt(paymentPlanInterval), }, - }); - - 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, }, - { - where: { - user_id: userId, - }, - } - ); + } + ); - userLogger.info( - userId as string, - "StripeWebhook: user state updated to ACTIVE" - ); - } + logger.info( + `StripeWebhook: checkout.session.completed: ${userId} ${JSON.stringify( + event.data.object + )}` + ); + } else { + logger.error( + `StripeWebhook: checkout.session.completed: user not found for userId: ${userId}` + ); } break; case "checkout.session.expired": logger.info( `StripeWebhook: checkout.session.expired: ${event.data.object.id}` ); - break; case "billing_portal.session.created": customerId = event.data.object.customer as string; @@ -375,21 +509,23 @@ export async function StripeWebhook(req: Request, res: Response) { let updateData: any = { payment_plan_status: status, - payment_plan_trial_end: trialEnd !== null ? trialEnd : null, - payment_plan_canceled_at: canceledAt !== null ? canceledAt : null, + /*payment_plan_trial_end: + trialEnd !== null ? new Date(trialEnd * 1000) : null, */ + payment_plan_canceled_at: + canceledAt !== null ? new Date(canceledAt * 1000) : null, }; - logger.info( - `StripeWebhook: customer.subscription.updated: updating user: ${ - user.user_id - } ${JSON.stringify(updateData)}` - ); - await User.update(updateData, { where: { stripe_customer_id: customerId, }, }); + + logger.info( + `StripeWebhook: customer.subscription.updated: updating user: ${ + user.user_id + } ${JSON.stringify(updateData)}` + ); break; default: logger.warn(`StripeWebhook: Ignoring unknown event: ${event.type}`); @@ -480,6 +616,8 @@ export async function GetBillingPortal(req: Request, res: Response) { return_url: `${DASHBOARD_URL}/payment-plan`, }); + userLogger.info(userSession.user_id, "GetBillingPortal"); + res.status(200).send({ url: session.url, }); diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index 868d756..50c1b7b 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -18,6 +18,7 @@ import { CALENDAR_MAX_SERVICE_DURATION, CALENDAR_MIN_EARLIEST_BOOKING_TIME, EMAIL_VERIFICATION_STATE, + PAYMENT_DEMO_TRAILING_DAYS, PAYMENT_PLAN, PAYMENT_PLAN_SETTINGS, Roles, @@ -132,7 +133,8 @@ 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[PAYMENT_PLAN.TRAILING] + .calendarMaxFutureBookingDays, calendar_min_earliest_booking_time: CALENDAR_MIN_EARLIEST_BOOKING_TIME, calendar_max_service_duration: CALENDAR_MAX_SERVICE_DURATION, address: companyAddress, @@ -164,9 +166,9 @@ export async function SignUp(req: Request, res: Response) { password: hashedPassword, language: language, analytics_enabled: USER_ANALYTICS_ENABLED_DEFAULT, - state: ACCOUNT_STATE.INIT_PAYMENT, - payment_plan: PAYMENT_PLAN.DEMO, - // payment_plan_status: PAYMENT_PLAN_STATUS.TRAILING, + state: ACCOUNT_STATE.ACTIVE, + payment_plan: PAYMENT_PLAN.TRAILING, + payment_plan_trial_end: new Date(Date.now() + PAYMENT_DEMO_TRAILING_DAYS), }); /* const checkoutSessionUrl = await CreateCheckoutSession( @@ -471,7 +473,6 @@ export async function GetUser(req: Request, res: Response) { "language", "analytics_enabled", "payment_plan", - "payment_plan_status", "payment_plan_trial_end", "payment_plan_canceled_at", "created_at", @@ -495,21 +496,29 @@ export async function GetUser(req: Request, res: Response) { let respData = { user: { - user_id: user.user_id, + // user_id: user.user_id, username: user.username, //store_id: user.store_id, language: user.language, analytics_enabled: user.analytics_enabled, payment_plan: user.payment_plan, - // payment_plan_status: user.payment_plan_status, - payment_plan_trial_end: user.payment_plan_trial_end, - payment_plan_canceled_at: user.payment_plan_canceled_at, }, stores: stores, // only temporary until we have a proper permissions system permissions: [] as string[], paymentPlanSettings: PAYMENT_PLAN_SETTINGS[user.payment_plan], - }; + } as any; + + if (user.payment_plan_canceled_at !== null) { + user["payment_plan_canceled_at"] = user.payment_plan_canceled_at; + } + + if ( + user.payment_plan === PAYMENT_PLAN.TRAILING || + user.payment_plan_canceled_at !== null + ) { + respData.user["payment_plan_trial_end"] = user.payment_plan_trial_end; + } // if user is not a store master, then check if user is a worker if (!stores || stores.length === 0) { @@ -528,7 +537,6 @@ export async function GetUser(req: Request, res: Response) { stores.push(store); respData.stores = stores; - respData.permissions.push("calendar"); } else { // user is a store owner diff --git a/src/logger/logger.ts b/src/logger/logger.ts index 933a51a..9b3f3f7 100644 --- a/src/logger/logger.ts +++ b/src/logger/logger.ts @@ -43,7 +43,19 @@ class MyTransport extends Transport { setImmediate(() => this.emit("logged", info)); const currentDate = new Date(); - const formattedDate = `${currentDate.getHours()}:${currentDate.getMinutes()}:${currentDate.getSeconds()}:${currentDate.getMilliseconds()}`; + const formattedDate = `${currentDate + .getHours() + .toString() + .padStart(2, "0")}:${currentDate + .getMinutes() + .toString() + .padStart(2, "0")}:${currentDate + .getSeconds() + .toString() + .padStart(2, "0")}:${currentDate + .getMilliseconds() + .toString() + .padStart(3, "0")}`; let logLevel; diff --git a/src/models/user.ts b/src/models/user.ts index 28d6f52..d21c599 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -18,9 +18,9 @@ interface UserAttributes { google_account_picture?: string; analytics_enabled: boolean; payment_plan: number; - // payment_plan_status?: string; - payment_plan_trial_end?: number; - payment_plan_canceled_at?: number; + payment_plan_interval?: number; + payment_plan_trial_end?: Date; + payment_plan_canceled_at?: Date; stripe_customer_id?: string; } @@ -40,9 +40,9 @@ class User extends Model implements UserAttributes { declare google_account_picture: string; declare analytics_enabled: boolean; declare payment_plan: number; - // declare payment_plan_status: string; - declare payment_plan_trial_end: number; - declare payment_plan_canceled_at: number; + declare payment_plan_interval: number; + declare payment_plan_trial_end: Date; + declare payment_plan_canceled_at: Date; declare stripe_customer_id: string; declare created_at: Date; } @@ -111,16 +111,16 @@ User.init( type: DataTypes.TINYINT, allowNull: false, }, - /*payment_plan_status: { - type: DataTypes.STRING, - allowNull: true, - }, */ - payment_plan_trial_end: { + payment_plan_interval: { type: DataTypes.INTEGER, allowNull: true, }, + payment_plan_trial_end: { + type: DataTypes.DATE, + allowNull: true, + }, payment_plan_canceled_at: { - type: DataTypes.INTEGER, + type: DataTypes.DATE, allowNull: true, }, stripe_customer_id: { diff --git a/src/routes/paymentRoutes.ts b/src/routes/paymentRoutes.ts index 6de99f0..ef7e7bb 100644 --- a/src/routes/paymentRoutes.ts +++ b/src/routes/paymentRoutes.ts @@ -2,10 +2,14 @@ import express from "express"; const router = express.Router(); import * as paymentController from "../controllers/paymentController"; +import { sessionProtection } from "../middleware/authMiddleware"; + +router.get("/", sessionProtection, paymentController.getUserPlanInfo); router.get("/prices/:productId", paymentController.GetPrices); // webhook is registered in server.ts as it needs registered before the body parser +router.post("/checkout", paymentController.CreateCheckoutSession); router.post("/checkout/success", paymentController.CheckoutSuccess); router.post("/checkout/canceled", paymentController.CheckoutCanceled); router.get("/portal", paymentController.GetBillingPortal); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index d1e66cb..7ff29df 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -59,7 +59,6 @@ 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,33 +87,29 @@ export const PASSPORT_SUCCESS_REDIRECT_URL = `${DASHBOARD_URL}/store/calendar/au export const ACCOUNT_EXPORT_URL = `${DASHBOARD_URL}/api/v1/user/profile/export/`; export const TERMIN_PLANNER_URL = process.env.TERMIN_PLANNER_URL; +export const PAYMENT_DEMO_TRAILING_DAYS = 7 * 24 * 60 * 60 * 1000; // 7 days + export enum PAYMENT_PLAN { - DEMO = 0, + TRAILING = 0, BASIC = 1, - PREMIUM = 2, } export const PAYMENT_PLAN_SETTINGS = [ { - id: "demo", // used in the backend for identifiying the stripe pricing product - name: "Demo", // used in the frontend + id: "trailing", // used in the backend for identifiying the stripe pricing product + name: "Trailing", // used in the frontend maxEmployees: 5, calendarMaxFutureBookingDays: 7, }, { id: "basic", // used in the backend for identifiying the stripe pricing product name: "Basic", // used in the frontend - maxEmployees: 15, - calendarMaxFutureBookingDays: 60, - }, - { - id: "premium", // used in the backend for identifiying the stripe pricing product - name: "Premium", // used in the frontend maxEmployees: 20, - calendarMaxFutureBookingDays: 90, + calendarMaxFutureBookingDays: 30, }, ]; - +/* export enum PAYMENT_PLAN_STATUS { TRAILING = "trailing", } + */ diff --git a/src/validator/validator.ts b/src/validator/validator.ts index 00d5371..e24af51 100644 --- a/src/validator/validator.ts +++ b/src/validator/validator.ts @@ -183,13 +183,7 @@ export function isCalendarMinEarliestBookingTimeValid( } export function isPaymentPlanValid(paymentPlan: number) { - return ( - paymentPlan >= PAYMENT_PLAN.BASIC && paymentPlan <= PAYMENT_PLAN.PREMIUM - ); -} - -export function isPaymentIntervalValid(paymentInterval: number) { - return paymentInterval === 0 || paymentInterval === 1; + return paymentPlan >= PAYMENT_PLAN.BASIC && paymentPlan <= PAYMENT_PLAN.BASIC; } export function isCompanyNameValid(companyName: string) {