Skip to content

Commit

Permalink
Stripe: Handle payment invoices and notify user (#3093)
Browse files Browse the repository at this point in the history
* Stripe: Handle payment invoices and notify user

* Update copy
  • Loading branch information
PopDaph authored Jan 8, 2024
1 parent 5765295 commit 9a159bb
Show file tree
Hide file tree
Showing 8 changed files with 186 additions and 16 deletions.
26 changes: 26 additions & 0 deletions front/components/sparkle/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ function NavigationBar({
{subscription.endDate && (
<SubscriptionEndBanner endDate={subscription.endDate} />
)}
{subscription.paymentFailingSince && <SubscriptionPastDueBanner />}
{nav.length > 1 && (
<div className="pt-2">
<Tab tabs={nav} />
Expand Down Expand Up @@ -456,3 +457,28 @@ function SubscriptionEndBanner({ endDate }: { endDate: number }) {
</div>
);
}

function SubscriptionPastDueBanner() {
return (
<div className="border-y border-warning-200 bg-warning-100 px-3 py-3 text-xs text-warning-900">
<div className="font-bold">Your payment has failed!</div>
<div className="font-normal">
<br />
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.
<br />
<br />
After 3 attempts, your workspace will be downgraded to the free plan.
Connections will be deleted and members will be revoked. Details{" "}
<Link
href="https://dust-tt.notion.site/What-happens-when-we-cancel-our-Dust-subscription-59aad3866dcc4bbdb26a54e1ce0d848a?pvs=4"
target="_blank"
className="underline"
>
here
</Link>
.
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions front/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ export async function subscriptionForWorkspace(
"stripeCustomerId",
"startDate",
"endDate",
"paymentFailingSince",
],
where: { workspaceId: w.id, status: "active" },
include: [
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions front/lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,25 @@ export async function sendAdminDowngradeTooMuchDataEmail(
};
return sendEmail(email, message);
}

export async function sendAdminSubscriptionPaymentFailedEmail(
email: string,
customerPortailUrl: string | null
): Promise<void> {
const message = {
from: {
name: "Dust team",
email: "[email protected]",
},
subject: `[Dust] Your payment has failed`,
html: `<p>Hello from Dust,</p>
<p>Your payment has failed. Please visit ${customerPortailUrl} to edit your payment information.</p>
<p>
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.
</p>
<p>Please reply to this email if you have any questions.</p>
<br />
<p>The Dust team</p>`,
};
return sendEmail(email, message);
}
13 changes: 7 additions & 6 deletions front/lib/models/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import {
FreeBillingType,
PAID_BILLING_TYPES,
PaidBillingType,
SUBSCRIPTION_PAYMENT_STATUSES,
SUBSCRIPTION_STATUSES,
SubscriptionPaymentStatusType,
SubscriptionStatusType,
} from "@dust-tt/types";
import {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions front/lib/plans/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const internalSubscribeWorkspaceToFreeTestPlan = async ({
stripeCustomerId: null,
startDate: null,
endDate: null,
paymentFailingSince: null,
plan: {
code: freeTestPlan.code,
name: freeTestPlan.name,
Expand Down Expand Up @@ -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,
Expand Down
112 changes: 109 additions & 3 deletions front/pages/api/stripe/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Authenticator } from "@app/lib/auth";
import { front_sequelize } from "@app/lib/databases";
import {
sendAdminDowngradeTooMuchDataEmail,
sendAdminSubscriptionPaymentFailedEmail,
sendCancelSubscriptionEmail,
sendOpsDowngradeTooMuchDataEmail,
sendReactivateSubscriptionEmail,
Expand All @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -449,6 +540,21 @@ async function handler(
}
}

function _returnStripeApiError(
req: NextApiRequest,
res: NextApiResponse<GetResponseBody | ReturnedAPIErrorType>,
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
Expand Down
19 changes: 16 additions & 3 deletions front/pages/w/[wId]/subscription/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ArrowPathIcon,
Button,
Chip,
ExternalLinkIcon,
Expand Down Expand Up @@ -215,10 +216,22 @@ export default function Subscription({
<Page.H variant="h5">Payment, invoicing & billing</Page.H>
<div className="pt-2">
<Button
icon={ExternalLinkIcon}
icon={
subscription.paymentFailingSince
? ArrowPathIcon
: ExternalLinkIcon
}
size="sm"
variant="secondary"
label="Visit Dust's dashboard on Stripe"
variant={
subscription.paymentFailingSince
? "secondaryWarning"
: "secondary"
}
label={
subscription.paymentFailingSince
? "Update your payment method"
: "Visit Dust's dashboard on Stripe"
}
disabled={isProcessing}
onClick={async () => await handleGoToStripePortal()}
/>
Expand Down
5 changes: 1 addition & 4 deletions types/src/front/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,6 @@ export type PaidBillingType = (typeof PAID_BILLING_TYPES)[number];
export const SUBSCRIPTION_STATUSES = ["active", "ended"] as const;
export type SubscriptionStatusType = (typeof SUBSCRIPTION_STATUSES)[number];

export const SUBSCRIPTION_PAYMENT_STATUSES = ["succeeded", "past_due"] as const;
export type SubscriptionPaymentStatusType =
(typeof SUBSCRIPTION_PAYMENT_STATUSES)[number];

export type PlanType = {
code: string;
name: string;
Expand All @@ -60,6 +56,7 @@ export type SubscriptionType = {
stripeCustomerId: string | null;
startDate: number | null;
endDate: number | null;
paymentFailingSince: number | null;
plan: PlanType;
};

Expand Down

0 comments on commit 9a159bb

Please sign in to comment.