From a41a0923da13385ab92f541ec4c13d93c5a65a93 Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 31 Mar 2024 13:30:45 +0200 Subject: [PATCH] change plan --- package-lock.json | 8 +- package.json | 2 +- src/controllers/paymentController.ts | 235 +++++++++++++++++++-------- src/controllers/userController.ts | 9 +- src/models/user.ts | 14 +- src/routes/paymentRoutes.ts | 4 +- src/validator/validator.ts | 4 +- 7 files changed, 192 insertions(+), 84 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6904e9e..59441e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "pino-pretty": "^10.3.1", "react-microsoft-clarity": "^1.2.0", "sequelize": "^6.35.2", - "stripe": "^14.16.0", + "stripe": "^14.23.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "uuid": "^9.0.1", @@ -2786,9 +2786,9 @@ } }, "node_modules/stripe": { - "version": "14.16.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.16.0.tgz", - "integrity": "sha512-1gOr2LzafWV84cPIO5Md/QPh4XVPLKULVuRpBVOV3Plq3seiHmg/eeOktX+hDl8jpNZuORHYaUJGrNqrABLwdg==", + "version": "14.23.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.23.0.tgz", + "integrity": "sha512-OPD7LqBmni6uDdqA05GGgMZyyRWxJOehONBNC9tYgY4Uh089EtXd6QLIgRGrqTDlQH3cA2BXo848nxwa/zsQzw==", "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" diff --git a/package.json b/package.json index 87f2ff4..e52fc5e 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "pino-pretty": "^10.3.1", "react-microsoft-clarity": "^1.2.0", "sequelize": "^6.35.2", - "stripe": "^14.16.0", + "stripe": "^14.23.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "uuid": "^9.0.1", diff --git a/src/controllers/paymentController.ts b/src/controllers/paymentController.ts index ee106da..4af2fb8 100644 --- a/src/controllers/paymentController.ts +++ b/src/controllers/paymentController.ts @@ -6,11 +6,9 @@ import { PAYMENT_PLAN, PAYMENT_PLAN_SETTINGS, } from "../utils/constants"; -import UserPendingPayment from "../models/userPendingPayment"; import User from "../models/user"; import { getUserSession, stripe } from "../utils/utils"; -import Store from "../models/store"; -import Session from "../models/session"; +import { telegramNotification } from "../utils/adminDashboard"; const ZeitAdlerBasicMonthly = "za-basic-monthly"; const ZeitAdlerBasicYearly = "za-basic-yearly"; @@ -198,17 +196,57 @@ export async function CreateCheckoutSession(req: Request, res: Response) { subscriptions.data.length > 0 && subscriptions.data[0].id !== priceId ) { - const latestSubscription = subscriptions.data[0]; + const currentSubscription = subscriptions.data[0]; + const currentItem = currentSubscription.items.data[0]; - await stripe.subscriptions.update(latestSubscription.id, { - items: [ - { - id: latestSubscription.items.data[0].id, - price: priceId, - }, - ], + // Calculate the prorated credit for the old subscription + const endCurrentSub = currentSubscription.current_period_end; + const now = Math.floor(Date.now() / 1000); + let proratedCredit = 0; + if (currentItem.price.unit_amount !== null) { + proratedCredit = Math.round( + ((endCurrentSub - now) / + (endCurrentSub - currentSubscription.current_period_start)) * + currentItem.price.unit_amount + ); + } + + // Create an invoice item for the prorated credit + /*await stripe.invoiceItems.create({ + customer: user.stripe_customer_id, + amount: -proratedCredit, + currency: currentItem.price.currency, + description: "Prorated credit for plan switch", + }); */ + + const updatedSubscription = await stripe.subscriptions.update( + currentSubscription.id, + { + cancel_at_period_end: false, + proration_behavior: "always_invoice", + items: [ + { + id: currentSubscription.items.data[0].id, + price: priceId, + }, + ], + } + ); + + // Attempt to pay any open invoices immediately, ensuring the customer pays for the new plan right away + const invoice = await stripe.invoices.create({ + customer: user.stripe_customer_id, + subscription: updatedSubscription.id, + auto_advance: true, // Automatically pay the invoice if the customer has a default payment method }); + // Finalize the invoice immediately to collect payment + if (invoice.status !== "paid") { + await stripe.invoices.finalizeInvoice(invoice.id, { + auto_advance: true, + }); + } + // update user await User.update( @@ -224,7 +262,12 @@ export async function CreateCheckoutSession(req: Request, res: Response) { userLogger.info( userSession.user_id, - `CreateCheckoutSession: update subscription ${latestSubscription.id} to ${priceId}` + `CreateCheckoutSession: update subscription ${currentSubscription.id} to ${priceId}` + ); + + telegramNotification( + 1, + `User ${userSession.user_id} updated subscription ${currentSubscription.id} to ${priceId}` ); return res.status(200).json({ status: "ok" }); @@ -233,7 +276,7 @@ export async function CreateCheckoutSession(req: Request, res: Response) { // create checkout session - const session = await stripe.checkout.sessions.create({ + let sessionData = { payment_method_types: ["card"], line_items: [ { @@ -243,20 +286,33 @@ export async function CreateCheckoutSession(req: Request, res: Response) { ], billing_address_collection: "required", mode: "subscription", - subscription_data: { - trial_end: new Date(user.payment_plan_trial_end).getTime() / 1000, - }, + success_url: `${DASHBOARD_URL}/payment-plan`, cancel_url: `${DASHBOARD_URL}/payment-plan`, metadata: { userId: userSession.user_id, payment_plan_interval: lookupKey === ZeitAdlerBasicYearly ? 1 : 0, - } /* + }, + /* automatic_tax: { // see https://dashboard.stripe.com/settings/tax enabled: true, - },*/, - }); + },*/ + } as any; + + if ( + user.payment_plan_trial_end !== null && + new Date(user.payment_plan_trial_end) > new Date() + ) { + sessionData = { + ...sessionData, + subscription_data: { + trial_end: new Date(user.payment_plan_trial_end).getTime() / 1000, + }, + }; + } + + const session = await stripe.checkout.sessions.create(sessionData); /* await UserPendingPayment.create({ payment_session_id: session.id, @@ -280,7 +336,7 @@ export async function CreateCheckoutSession(req: Request, res: Response) { // 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) { +/*export async function CheckoutSuccess(req: Request, res: Response) { try { const { sessionId } = req.body; @@ -365,7 +421,7 @@ export async function CheckoutCanceled(req: Request, res: Response) { logger.error("CheckoutCanceled", error as string); res.status(500).send({ err: "invalid request" }); } -} +}*/ export async function StripeWebhook(req: Request, res: Response) { try { @@ -449,6 +505,11 @@ export async function StripeWebhook(req: Request, res: Response) { event.data.object )}` ); + + telegramNotification( + 1, + `User ${userId} completed checkout session ${session.id}` + ); } else { logger.error( `StripeWebhook: checkout.session.completed: user not found for userId: ${userId}` @@ -511,6 +572,10 @@ export async function StripeWebhook(req: Request, res: Response) { payment_plan_status: status, /*payment_plan_trial_end: trialEnd !== null ? new Date(trialEnd * 1000) : null, */ + payment_plan_cancel_at: + subscription.cancel_at !== null + ? new Date(subscription.cancel_at * 1000) + : null, // this is if the user cancels the subscription after the trial period payment_plan_canceled_at: canceledAt !== null ? new Date(canceledAt * 1000) : null, }; @@ -528,57 +593,13 @@ export async function StripeWebhook(req: Request, res: Response) { ); break; default: - logger.warn(`StripeWebhook: Ignoring unknown event: ${event.type}`); + logger.warn( + `StripeWebhook: Ignoring unknown event: ${ + event.type + } data: ${JSON.stringify(event.data)}` + ); break; } - /* - 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 if (event.type === "checkout.session.expired") { - logger.info(`webhook checkout.session.expired: ${event.data.object.id}`); - } else { - logger.warn(`Ignoring unknown event: ${event.type}`); - } */ res.sendStatus(200); } catch (error) { @@ -599,7 +620,11 @@ export async function GetBillingPortal(req: Request, res: Response) { where: { user_id: userSession.user_id, }, - attributes: ["stripe_customer_id"], + attributes: [ + "payment_plan", + "payment_plan_interval", + "stripe_customer_id", + ], }); if (user === null || user.stripe_customer_id === null) { @@ -611,6 +636,74 @@ export async function GetBillingPortal(req: Request, res: Response) { return res.status(400).send({ err: "invalid request" }); } + // after the trial period, the subscribtion is deleted + // so we need to check if the user has a subscription or not + + const subscriptions = await stripe.subscriptions.list({ + customer: user.stripe_customer_id, + limit: 1, + }); + + console.log("subs", subscriptions.data.length, user.payment_plan); + + if (subscriptions.data.length === 0) { + let lookupKey = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly]; + + console.log("user", user.payment_plan_interval); + + await loadPrices(); + + let priceId = cachedPrices.find( + (price) => price.lookup_key === lookupKey[user.payment_plan_interval] + )?.id; + + if (priceId === undefined) { + return res.status(400).send({ err: "invalid request" }); + } + + console.log( + "priceId", + priceId, + user.payment_plan_interval, + user.payment_plan_trial_end + ); + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ["card"], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + billing_address_collection: "required", + mode: "subscription", + /*subscription_data: { + trial_end: new Date(user.payment_plan_trial_end).getTime() / 1000, + },*/ + success_url: `${DASHBOARD_URL}/payment-plan`, + cancel_url: `${DASHBOARD_URL}/payment-plan`, + metadata: { + userId: userSession.user_id, + payment_plan_interval: user.payment_plan_interval, + }, + /* + automatic_tax: { + // see https://dashboard.stripe.com/settings/tax + enabled: true, + },*/ + }); + + userLogger.info( + userSession.user_id, + `GetBillingPortal: create checkout session ${session.id}` + ); + + return res.status(200).send({ + url: session.url, + }); + } + const session = await stripe.billingPortal.sessions.create({ customer: user.stripe_customer_id, return_url: `${DASHBOARD_URL}/payment-plan`, diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index 50c1b7b..84969d7 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -474,6 +474,7 @@ export async function GetUser(req: Request, res: Response) { "analytics_enabled", "payment_plan", "payment_plan_trial_end", + "payment_plan_cancel_at", "payment_plan_canceled_at", "created_at", ], @@ -517,7 +518,11 @@ export async function GetUser(req: Request, res: Response) { 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.payment_plan_cancel_at !== null) { + respData.user["payment_plan_trial_end"] = user.payment_plan_cancel_at; + } else { + 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 @@ -557,6 +562,8 @@ export async function GetUser(req: Request, res: Response) { user.payment_plan }. Possible highest payment plan is ${PAYMENT_PLAN_SETTINGS.length - 1}` ); + + return res.status(400).send({ err: "invalid request" }); } // update user session last_used diff --git a/src/models/user.ts b/src/models/user.ts index d21c599..282f88f 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -17,10 +17,11 @@ interface UserAttributes { google_account_name?: string; google_account_picture?: string; analytics_enabled: boolean; - payment_plan: number; - payment_plan_interval?: number; - payment_plan_trial_end?: Date; - payment_plan_canceled_at?: Date; + payment_plan: number; // 0 trailing, 1 basic + payment_plan_interval?: number; // how often the payment plan is charged (e.g. 0 monthly, 1 yearly) + payment_plan_trial_end?: Date; // when the payment plan trial ends + payment_plan_cancel_at?: Date; // when the payment plan will be canceled (e.g. after trial) + payment_plan_canceled_at?: Date; // when the payment plan was canceled stripe_customer_id?: string; } @@ -42,6 +43,7 @@ class User extends Model implements UserAttributes { declare payment_plan: number; declare payment_plan_interval: number; declare payment_plan_trial_end: Date; + declare payment_plan_cancel_at: Date; declare payment_plan_canceled_at: Date; declare stripe_customer_id: string; declare created_at: Date; @@ -119,6 +121,10 @@ User.init( type: DataTypes.DATE, allowNull: true, }, + payment_plan_cancel_at: { + type: DataTypes.DATE, + allowNull: true, + }, payment_plan_canceled_at: { type: DataTypes.DATE, allowNull: true, diff --git a/src/routes/paymentRoutes.ts b/src/routes/paymentRoutes.ts index ef7e7bb..0971526 100644 --- a/src/routes/paymentRoutes.ts +++ b/src/routes/paymentRoutes.ts @@ -10,8 +10,8 @@ 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.post("/checkout/success", paymentController.CheckoutSuccess); +// router.post("/checkout/canceled", paymentController.CheckoutCanceled); router.get("/portal", paymentController.GetBillingPortal); export default router; diff --git a/src/validator/validator.ts b/src/validator/validator.ts index e24af51..de32eb4 100644 --- a/src/validator/validator.ts +++ b/src/validator/validator.ts @@ -183,7 +183,9 @@ export function isCalendarMinEarliestBookingTimeValid( } export function isPaymentPlanValid(paymentPlan: number) { - return paymentPlan >= PAYMENT_PLAN.BASIC && paymentPlan <= PAYMENT_PLAN.BASIC; + return ( + paymentPlan >= PAYMENT_PLAN.TRAILING && paymentPlan <= PAYMENT_PLAN.BASIC + ); } export function isCompanyNameValid(companyName: string) {