From 9a159bb87185a96aebb4d0434009ddd981175f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daphn=C3=A9=20Popin?= Date: Mon, 8 Jan 2024 13:07:53 +0100 Subject: [PATCH] Stripe: Handle payment invoices and notify user (#3093) * Stripe: Handle payment invoices and notify user * Update copy --- front/components/sparkle/AppLayout.tsx | 26 +++++ front/lib/auth.ts | 3 + front/lib/email.ts | 22 ++++ front/lib/models/plan.ts | 13 +-- front/lib/plans/subscription.ts | 2 + front/pages/api/stripe/webhook.ts | 112 ++++++++++++++++++++- front/pages/w/[wId]/subscription/index.tsx | 19 +++- types/src/front/plan.ts | 5 +- 8 files changed, 186 insertions(+), 16 deletions(-) diff --git a/front/components/sparkle/AppLayout.tsx b/front/components/sparkle/AppLayout.tsx index e64943c0b3f5..3decee9adf39 100644 --- a/front/components/sparkle/AppLayout.tsx +++ b/front/components/sparkle/AppLayout.tsx @@ -112,6 +112,7 @@ function NavigationBar({ {subscription.endDate && ( )} + {subscription.paymentFailingSince && } {nav.length > 1 && (
@@ -456,3 +457,28 @@ function SubscriptionEndBanner({ endDate }: { endDate: number }) {
); } + +function SubscriptionPastDueBanner() { + return ( +
+
Your payment has failed!
+
+
+ Please make sure to update your payment method in the Admin section to + maintain access to your workspace. We will retry in a few days. +
+
+ After 3 attempts, your workspace will be downgraded to the free plan. + Connections will be deleted and members will be revoked. Details{" "} + + here + + . +
+
+ ); +} diff --git a/front/lib/auth.ts b/front/lib/auth.ts index 6020c3c24a1a..7928f157a342 100644 --- a/front/lib/auth.ts +++ b/front/lib/auth.ts @@ -519,6 +519,7 @@ export async function subscriptionForWorkspace( "stripeCustomerId", "startDate", "endDate", + "paymentFailingSince", ], where: { workspaceId: w.id, status: "active" }, include: [ @@ -558,6 +559,8 @@ export async function subscriptionForWorkspace( stripeCustomerId: activeSubscription?.stripeCustomerId || null, startDate: startDate?.getTime() || null, endDate: endDate?.getTime() || null, + paymentFailingSince: + activeSubscription?.paymentFailingSince?.getTime() || null, plan: { code: plan.code, name: plan.name, diff --git a/front/lib/email.ts b/front/lib/email.ts index ce21d1685290..f2f1e0f86d30 100644 --- a/front/lib/email.ts +++ b/front/lib/email.ts @@ -161,3 +161,25 @@ export async function sendAdminDowngradeTooMuchDataEmail( }; return sendEmail(email, message); } + +export async function sendAdminSubscriptionPaymentFailedEmail( + email: string, + customerPortailUrl: string | null +): Promise { + const message = { + from: { + name: "Dust team", + email: "team@dust.tt", + }, + subject: `[Dust] Your payment has failed`, + html: `

Hello from Dust,

+

Your payment has failed. Please visit ${customerPortailUrl} to edit your payment information.

+

+ Please note: your workspace will be downgraded after 3 failed payment retries. This will trigger the removal of any feature attached to the paid plan you were on, and the permanent deletion of connections and the data associated with them. Any assistant that are linked to connections will also be removed. +

+

Please reply to this email if you have any questions.

+
+

The Dust team

`, + }; + return sendEmail(email, message); +} diff --git a/front/lib/models/plan.ts b/front/lib/models/plan.ts index 5257ceb1087a..dbaba7ab14da 100644 --- a/front/lib/models/plan.ts +++ b/front/lib/models/plan.ts @@ -3,9 +3,7 @@ import { FreeBillingType, PAID_BILLING_TYPES, PaidBillingType, - SUBSCRIPTION_PAYMENT_STATUSES, SUBSCRIPTION_STATUSES, - SubscriptionPaymentStatusType, SubscriptionStatusType, } from "@dust-tt/types"; import { @@ -150,7 +148,9 @@ export class Subscription extends Model< declare sId: string; // unique declare status: SubscriptionStatusType; - declare paymentStatus: SubscriptionPaymentStatusType | null; + declare paymentStatus: string | null; // to be removed + declare paymentFailingSince: Date | null; + declare startDate: Date; declare endDate: Date | null; @@ -194,9 +194,10 @@ Subscription.init( paymentStatus: { type: DataTypes.STRING, allowNull: true, - validate: { - isIn: [SUBSCRIPTION_PAYMENT_STATUSES], - }, + }, + paymentFailingSince: { + type: DataTypes.DATE, + allowNull: true, }, startDate: { type: DataTypes.DATE, diff --git a/front/lib/plans/subscription.ts b/front/lib/plans/subscription.ts index 3fd4162da252..50646b48a925 100644 --- a/front/lib/plans/subscription.ts +++ b/front/lib/plans/subscription.ts @@ -63,6 +63,7 @@ export const internalSubscribeWorkspaceToFreeTestPlan = async ({ stripeCustomerId: null, startDate: null, endDate: null, + paymentFailingSince: null, plan: { code: freeTestPlan.code, name: freeTestPlan.name, @@ -160,6 +161,7 @@ export const internalSubscribeWorkspaceToFreeUpgradedPlan = async ({ stripeCustomerId: newSubscription.stripeCustomerId, startDate: newSubscription.startDate.getTime(), endDate: newSubscription.endDate?.getTime() || null, + paymentFailingSince: null, plan: { code: plan.code, name: plan.name, diff --git a/front/pages/api/stripe/webhook.ts b/front/pages/api/stripe/webhook.ts index 1dc37ee079ea..e380693745b1 100644 --- a/front/pages/api/stripe/webhook.ts +++ b/front/pages/api/stripe/webhook.ts @@ -19,6 +19,7 @@ import { Authenticator } from "@app/lib/auth"; import { front_sequelize } from "@app/lib/databases"; import { sendAdminDowngradeTooMuchDataEmail, + sendAdminSubscriptionPaymentFailedEmail, sendCancelSubscriptionEmail, sendOpsDowngradeTooMuchDataEmail, sendReactivateSubscriptionEmail, @@ -31,6 +32,7 @@ import { Workspace, } from "@app/lib/models"; import { PlanInvitation } from "@app/lib/models/plan"; +import { createCustomerPortalSession } from "@app/lib/plans/stripe"; import { generateModelSId } from "@app/lib/utils"; import logger from "@app/logger/logger"; import { apiError, withLogging } from "@app/logger/withlogging"; @@ -94,7 +96,11 @@ async function handler( }, }); } + let subscription; let stripeSubscription; + let invoice; + const now = new Date(); + switch (event.type) { case "checkout.session.completed": // Payment is successful and the stripe subscription is created. @@ -159,7 +165,6 @@ async function handler( } await front_sequelize.transaction(async (t) => { - const now = new Date(); const activeSubscription = await Subscription.findOne({ where: { workspaceId: workspace.id, status: "active" }, include: [ @@ -308,20 +313,106 @@ async function handler( } case "invoice.paid": // This is what confirms the subscription is active and payments are being made. - // Should we store the last invoice date in the subscription? logger.info( { event }, "[Stripe Webhook] Received customer.invoice.paid event." ); + invoice = event.data.object as Stripe.Invoice; + if (typeof invoice.subscription !== "string") { + return _returnStripeApiError( + req, + res, + "invoice.paid", + "Subscription in event is not a string." + ); + } + // Setting subscription payment status to succeeded + subscription = await Subscription.findOne({ + where: { stripeSubscriptionId: invoice.subscription }, + include: [Workspace], + }); + if (!subscription) { + return _returnStripeApiError( + req, + res, + "invoice.paid", + "Subscription not found." + ); + } + await subscription.update({ paymentFailingSince: null }); break; case "invoice.payment_failed": // Occurs when payment failed or the user does not have a valid payment method. // The stripe subscription becomes "past_due". - // We keep active and email the user and us to manually manage those cases first? + // We log it on the Subscription to display a banner and email the admins. logger.warn( { event }, "[Stripe Webhook] Received invoice.payment_failed event." ); + invoice = event.data.object as Stripe.Invoice; + + // If the invoice is for a subscription creation, we don't need to do anything + if (invoice.billing_reason === "subscription_create") { + return res.status(200).json({ success: true }); + } + + if (typeof invoice.subscription !== "string") { + return _returnStripeApiError( + req, + res, + "invoice.payment_failed", + "Subscription in event is not a string." + ); + } + + // Logging that we have a failed payment + subscription = await Subscription.findOne({ + where: { stripeSubscriptionId: invoice.subscription }, + include: [Workspace], + }); + if (!subscription) { + return _returnStripeApiError( + req, + res, + "invoice.payment_failed", + "Subscription not found." + ); + } + if (subscription.paymentFailingSince === null) { + await subscription.update({ paymentFailingSince: now }); + } + + // Send email to admins + customer email who subscribed in Stripe + const auth = await Authenticator.internalAdminForWorkspace( + subscription.workspace.sId + ); + const owner = auth.workspace(); + const subscriptionType = auth.subscription(); + if (!owner || !subscriptionType) { + return _returnStripeApiError( + req, + res, + "invoice.payment_failed", + "Couldn't get owner or subscription from `auth`." + ); + } + const adminEmails = (await getMembers(auth, "admin")).map( + (u) => u.email + ); + const customerEmail = invoice.customer_email; + if (customerEmail && !adminEmails.includes(customerEmail)) { + adminEmails.push(customerEmail); + } + const portalUrl = await createCustomerPortalSession({ + owner, + subscription: subscriptionType, + }); + for (const adminEmail of adminEmails) { + await sendAdminSubscriptionPaymentFailedEmail( + adminEmail, + portalUrl + ); + } break; case "customer.subscription.updated": // Occurs when the subscription is updated: @@ -449,6 +540,21 @@ async function handler( } } +function _returnStripeApiError( + req: NextApiRequest, + res: NextApiResponse, + event: string, + message: string +) { + return apiError(req, res, { + status_code: 500, + api_error: { + type: "internal_server_error", + message: `[Stripe Webhook][${event}] ${message}`, + }, + }); +} + /** * Remove everybody except the most tenured admin * @param workspaceId diff --git a/front/pages/w/[wId]/subscription/index.tsx b/front/pages/w/[wId]/subscription/index.tsx index 69288480c427..47a0845bceac 100644 --- a/front/pages/w/[wId]/subscription/index.tsx +++ b/front/pages/w/[wId]/subscription/index.tsx @@ -1,4 +1,5 @@ import { + ArrowPathIcon, Button, Chip, ExternalLinkIcon, @@ -215,10 +216,22 @@ export default function Subscription({ Payment, invoicing & billing