From f6eba78f5e3e02f4098d802987052dd5102eeab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Sat, 27 Jan 2024 11:36:31 +0100 Subject: [PATCH] fix: cancel Stripe subscription when account is deleted Also fix typings for purchase in database --- apps/backend/src/database/models/Purchase.ts | 23 ++++++++-- .../graphql/__generated__/resolver-types.ts | 10 ++++- .../src/graphql/definitions/Account.ts | 8 ++++ apps/backend/src/graphql/services/account.ts | 7 ++++ apps/backend/src/stripe/index.ts | 42 +++++++++++++++---- .../src/synchronize/github/updatePurchase.ts | 2 +- apps/frontend/src/gql/graphql.ts | 10 ++++- 7 files changed, 87 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/database/models/Purchase.ts b/apps/backend/src/database/models/Purchase.ts index 5a05a06b7..71ecafa87 100644 --- a/apps/backend/src/database/models/Purchase.ts +++ b/apps/backend/src/database/models/Purchase.ts @@ -27,7 +27,16 @@ export class Purchase extends Model { }, status: { type: ["string"], - enum: ["active", "canceled", "trialing", "past_due"], + enum: [ + "active", + "canceled", + "trialing", + "past_due", + "incomplete", + "unpaid", + "incomplete_expired", + "paused", + ], }, endDate: { type: ["string", "null"] }, startDate: { type: ["string"] }, @@ -40,12 +49,20 @@ export class Purchase extends Model { planId!: string; purchaserId!: string | null; stripeSubscriptionId!: string | null; - source!: string; + source!: "github" | "stripe"; endDate!: string | null; startDate!: string; trialEndDate!: string | null; paymentMethodFilled!: boolean | null; - status!: string; + status!: + | "active" + | "canceled" + | "trialing" + | "past_due" + | "incomplete" + | "unpaid" + | "incomplete_expired" + | "paused"; static override get relationMappings(): RelationMappings { return { diff --git a/apps/backend/src/graphql/__generated__/resolver-types.ts b/apps/backend/src/graphql/__generated__/resolver-types.ts index a7d2af3b7..365543800 100644 --- a/apps/backend/src/graphql/__generated__/resolver-types.ts +++ b/apps/backend/src/graphql/__generated__/resolver-types.ts @@ -636,12 +636,20 @@ export enum IPurchaseStatus { Active = 'active', /** Post-cancelation date */ Canceled = 'canceled', + /** Incomplete */ + Incomplete = 'incomplete', + /** Incomplete expired */ + IncompleteExpired = 'incomplete_expired', /** No paid purchase */ Missing = 'missing', /** Payment due */ PastDue = 'past_due', + /** Paused */ + Paused = 'paused', /** Ongoing trial */ - Trialing = 'trialing' + Trialing = 'trialing', + /** Unpaid */ + Unpaid = 'unpaid' } export type IQuery = { diff --git a/apps/backend/src/graphql/definitions/Account.ts b/apps/backend/src/graphql/definitions/Account.ts index ca84c1721..c94ac0774 100644 --- a/apps/backend/src/graphql/definitions/Account.ts +++ b/apps/backend/src/graphql/definitions/Account.ts @@ -50,6 +50,14 @@ export const typeDefs = gql` past_due "Post-cancelation date" canceled + "Incomplete" + incomplete + "Incomplete expired" + incomplete_expired + "Unpaid" + unpaid + "Paused" + paused } enum TrialStatus { diff --git a/apps/backend/src/graphql/services/account.ts b/apps/backend/src/graphql/services/account.ts index 9614ae0b3..f5fb275d6 100644 --- a/apps/backend/src/graphql/services/account.ts +++ b/apps/backend/src/graphql/services/account.ts @@ -7,6 +7,7 @@ import { } from "@/database/models/index.js"; import { deleteProject } from "./project.js"; +import { cancelStripeSubscription } from "@/stripe/index.js"; export const getWritableAccount = async (args: { id: string; @@ -40,6 +41,12 @@ export const deleteAccount = async (args: { }); }), ); + const subscription = account.$getSubscription(); + const activePurchase = await subscription.getActivePurchase(); + // Cancel the Stripe subscription if it exists + if (activePurchase?.stripeSubscriptionId) { + await cancelStripeSubscription(activePurchase.stripeSubscriptionId); + } await Purchase.query().where("accountId", account.id).delete(); await account.$query().delete(); diff --git a/apps/backend/src/stripe/index.ts b/apps/backend/src/stripe/index.ts index 61fdc4fd9..f93f549a8 100644 --- a/apps/backend/src/stripe/index.ts +++ b/apps/backend/src/stripe/index.ts @@ -187,6 +187,14 @@ export const createPurchaseFromSubscription = async ({ }); }; +export async function cancelStripeSubscription(subscriptionId: string) { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + if (subscription.status === "canceled") { + return; + } + await stripe.subscriptions.cancel(subscriptionId); +} + const getPurchaseFromStripeSubscriptionId = async ( stripeSubscriptionId: string, ) => { @@ -194,13 +202,7 @@ const getPurchaseFromStripeSubscriptionId = async ( stripeSubscriptionId, }); - if (!purchase) { - throw new Error( - `Purchase not found for subscription ${stripeSubscriptionId}`, - ); - } - - return purchase; + return purchase ?? null; }; export const getStripePriceFromPlanOrThrow = async (plan: Plan) => { @@ -322,6 +324,11 @@ export const handleStripeEvent = async ({ const purchase = await getPurchaseFromStripeSubscriptionId( subscription.id, ); + if (!purchase) { + throw new Error( + `No purchase found for stripe subscription id ${subscription.id}`, + ); + } await updatePurchaseFromSubscription(purchase, subscription); } @@ -353,16 +360,33 @@ export const handleStripeEvent = async ({ return; } - case "customer.subscription.updated": - case "customer.subscription.deleted": { + case "customer.subscription.updated": { const subscription = data.object as Stripe.Subscription; const purchase = await getPurchaseFromStripeSubscriptionId( subscription.id, ); + if (!purchase) { + throw new Error( + `No purchase found for stripe subscription id ${subscription.id}`, + ); + } await updatePurchaseFromSubscription(purchase, subscription); return; } + case "customer.subscription.deleted": { + const subscription = data.object as Stripe.Subscription; + const purchase = await getPurchaseFromStripeSubscriptionId( + subscription.id, + ); + // Purchase can be null in case the team as been deleted + if (!purchase) { + return; + } + await purchase.$query().patch({ status: "canceled" }); + return; + } + default: console.log(`Unhandled event type ${type}`); } diff --git a/apps/backend/src/synchronize/github/updatePurchase.ts b/apps/backend/src/synchronize/github/updatePurchase.ts index 4df720016..d15a868d1 100644 --- a/apps/backend/src/synchronize/github/updatePurchase.ts +++ b/apps/backend/src/synchronize/github/updatePurchase.ts @@ -42,7 +42,7 @@ export const updatePurchase = async ( Purchase.query(trx) .patch({ endDate: effectiveDate, - status: new Date(effectiveDate) > new Date() ? "cancelled" : "active", + status: new Date(effectiveDate) > new Date() ? "canceled" : "active", }) .findById(activePurchase.id), Purchase.query(trx).insert({ diff --git a/apps/frontend/src/gql/graphql.ts b/apps/frontend/src/gql/graphql.ts index b70c4ed95..1bad8d917 100644 --- a/apps/frontend/src/gql/graphql.ts +++ b/apps/frontend/src/gql/graphql.ts @@ -630,12 +630,20 @@ export enum PurchaseStatus { Active = 'active', /** Post-cancelation date */ Canceled = 'canceled', + /** Incomplete */ + Incomplete = 'incomplete', + /** Incomplete expired */ + IncompleteExpired = 'incomplete_expired', /** No paid purchase */ Missing = 'missing', /** Payment due */ PastDue = 'past_due', + /** Paused */ + Paused = 'paused', /** Ongoing trial */ - Trialing = 'trialing' + Trialing = 'trialing', + /** Unpaid */ + Unpaid = 'unpaid' } export type Query = {