From be95d73c59a818c7f1dd1478c2a6352e5415d20d Mon Sep 17 00:00:00 2001 From: Nizar Ljaljevic Lopez Date: Sun, 14 Dec 2025 14:00:28 +0100 Subject: [PATCH] fix: subscription sync on plan change and cancellation --- src/client/index.ts | 29 ++++++++++---- src/component/_generated/component.ts | 7 ++++ src/component/_generated/dataModel.ts | 2 +- src/component/private.ts | 27 ++++++++++++- src/component/public.test.ts | 58 +++++++++++++++++++++++++++ src/component/schema.ts | 1 + 6 files changed, 113 insertions(+), 11 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 5dedd24..c2946e4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -93,12 +93,15 @@ export class StripeSubscriptions { } // Update local database immediately (don't wait for webhook) + const item = subscription.items.data[0]; await ctx.runMutation(this.component.private.handleSubscriptionUpdated, { stripeSubscriptionId: subscription.id, status: subscription.status, - currentPeriodEnd: subscription.items.data[0]?.current_period_end || 0, + currentPeriodEnd: item?.current_period_end || 0, cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, - quantity: subscription.items.data[0]?.quantity ?? 1, + cancelAt: subscription.cancel_at || undefined, + quantity: item?.quantity ?? 1, + priceId: item?.price?.id || undefined, metadata: subscription.metadata || {}, }); @@ -126,12 +129,15 @@ export class StripeSubscriptions { ); // Update local database immediately + const item = subscription.items.data[0]; await ctx.runMutation(this.component.private.handleSubscriptionUpdated, { stripeSubscriptionId: subscription.id, status: subscription.status, - currentPeriodEnd: subscription.items.data[0]?.current_period_end || 0, + currentPeriodEnd: item?.current_period_end || 0, cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, - quantity: subscription.items.data[0]?.quantity ?? 1, + cancelAt: subscription.cancel_at || undefined, + quantity: item?.quantity ?? 1, + priceId: item?.price?.id || undefined, metadata: subscription.metadata || {}, }); @@ -459,27 +465,34 @@ async function processEvent( case "customer.subscription.created": { const subscription = event.data.object as StripeSDK.Subscription; + const item = subscription.items.data[0]; + await ctx.runMutation(component.private.handleSubscriptionCreated, { stripeSubscriptionId: subscription.id, stripeCustomerId: subscription.customer as string, status: subscription.status, - currentPeriodEnd: subscription.items.data[0]?.current_period_end || 0, + currentPeriodEnd: item?.current_period_end || 0, cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, + cancelAt: subscription.cancel_at || undefined, quantity: subscription.items.data[0]?.quantity ?? 1, - priceId: subscription.items.data[0]?.price.id || "", + priceId: item?.price?.id || "", metadata: subscription.metadata || {}, }); break; } case "customer.subscription.updated": { - const subscription = event.data.object as any; + const subscription = event.data.object as StripeSDK.Subscription; + const item = subscription.items.data[0]; + await ctx.runMutation(component.private.handleSubscriptionUpdated, { stripeSubscriptionId: subscription.id, status: subscription.status, - currentPeriodEnd: subscription.items.data[0]?.current_period_end || 0, + currentPeriodEnd: item?.current_period_end || 0, cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, + cancelAt: subscription.cancel_at || undefined, quantity: subscription.items.data[0]?.quantity ?? 1, + priceId: item?.price?.id || undefined, metadata: subscription.metadata || {}, }); break; diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts index 7a6ae0f..2a248b7 100644 --- a/src/component/_generated/component.ts +++ b/src/component/_generated/component.ts @@ -108,6 +108,7 @@ export type ComponentApi = "mutation", "internal", { + cancelAt?: number; cancelAtPeriodEnd: boolean; currentPeriodEnd: number; metadata?: any; @@ -131,9 +132,11 @@ export type ComponentApi = "mutation", "internal", { + cancelAt?: number; cancelAtPeriodEnd: boolean; currentPeriodEnd: number; metadata?: any; + priceId?: string; quantity?: number; status: string; stripeSubscriptionId: string; @@ -203,6 +206,7 @@ export type ComponentApi = "internal", { stripeSubscriptionId: string }, { + cancelAt?: number; cancelAtPeriodEnd: boolean; currentPeriodEnd: number; metadata?: any; @@ -221,6 +225,7 @@ export type ComponentApi = "internal", { orgId: string }, { + cancelAt?: number; cancelAtPeriodEnd: boolean; currentPeriodEnd: number; metadata?: any; @@ -341,6 +346,7 @@ export type ComponentApi = "internal", { stripeCustomerId: string }, Array<{ + cancelAt?: number; cancelAtPeriodEnd: boolean; currentPeriodEnd: number; metadata?: any; @@ -359,6 +365,7 @@ export type ComponentApi = "internal", { userId: string }, Array<{ + cancelAt?: number; cancelAtPeriodEnd: boolean; currentPeriodEnd: number; metadata?: any; diff --git a/src/component/_generated/dataModel.ts b/src/component/_generated/dataModel.ts index 8541f31..f97fd19 100644 --- a/src/component/_generated/dataModel.ts +++ b/src/component/_generated/dataModel.ts @@ -38,7 +38,7 @@ export type Doc = DocumentByName< * Convex documents are uniquely identified by their `Id`, which is accessible * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * - * Documents can be loaded using `db.get(id)` in query and mutation functions. + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. diff --git a/src/component/private.ts b/src/component/private.ts index d9cb395..e95926f 100644 --- a/src/component/private.ts +++ b/src/component/private.ts @@ -86,6 +86,17 @@ export const handleCustomerUpdated = mutation({ }, }); +function deriveCancelAtPeriodEnd( + cancelAt: number | undefined, + currentPeriodEnd: number, +): boolean { + const tolerance = 60 * 5; // 5 minutes + + if (typeof cancelAt !== "number") return false; + if (currentPeriodEnd <= 0) return false; + return Math.abs(cancelAt - currentPeriodEnd) <= tolerance; +} + export const handleSubscriptionCreated = mutation({ args: { stripeSubscriptionId: v.string(), @@ -93,6 +104,7 @@ export const handleSubscriptionCreated = mutation({ status: v.string(), currentPeriodEnd: v.number(), cancelAtPeriodEnd: v.boolean(), + cancelAt: v.optional(v.number()), quantity: v.optional(v.number()), priceId: v.string(), metadata: v.optional(v.any()), @@ -111,13 +123,17 @@ export const handleSubscriptionCreated = mutation({ const orgId = metadata.orgId as string | undefined; const userId = metadata.userId as string | undefined; + const cancelAtPeriodEnd = args.cancelAtPeriodEnd || + deriveCancelAtPeriodEnd(args.cancelAt, args.currentPeriodEnd); + if (!existing) { await ctx.db.insert("subscriptions", { stripeSubscriptionId: args.stripeSubscriptionId, stripeCustomerId: args.stripeCustomerId, status: args.status, currentPeriodEnd: args.currentPeriodEnd, - cancelAtPeriodEnd: args.cancelAtPeriodEnd, + cancelAtPeriodEnd: cancelAtPeriodEnd, + cancelAt: args.cancelAt || undefined, quantity: args.quantity, priceId: args.priceId, metadata: metadata, @@ -156,7 +172,9 @@ export const handleSubscriptionUpdated = mutation({ status: v.string(), currentPeriodEnd: v.number(), cancelAtPeriodEnd: v.boolean(), + cancelAt: v.optional(v.number()), quantity: v.optional(v.number()), + priceId: v.optional(v.string()), metadata: v.optional(v.any()), }, returns: v.null(), @@ -174,11 +192,16 @@ export const handleSubscriptionUpdated = mutation({ const orgId = metadata.orgId as string | undefined; const userId = metadata.userId as string | undefined; + const cancelAtPeriodEnd = args.cancelAtPeriodEnd || + deriveCancelAtPeriodEnd(args.cancelAt, args.currentPeriodEnd); + await ctx.db.patch(subscription._id, { status: args.status, currentPeriodEnd: args.currentPeriodEnd, - cancelAtPeriodEnd: args.cancelAtPeriodEnd, + cancelAtPeriodEnd: cancelAtPeriodEnd, + cancelAt: args.cancelAt || undefined, quantity: args.quantity, + ...(args.priceId !== undefined && { priceId: args.priceId }), // Only update metadata fields if provided ...(args.metadata !== undefined && { metadata }), ...(orgId !== undefined && { orgId }), diff --git a/src/component/public.test.ts b/src/component/public.test.ts index 7f17cb6..86b534f 100644 --- a/src/component/public.test.ts +++ b/src/component/public.test.ts @@ -191,6 +191,64 @@ test("subscription status update via webhook", async () => { expect(subscription?.status).toBe("past_due"); }); +test("subscription plan change updates priceId", async () => { + const t = convexTest(schema, modules); + + await t.mutation(api.private.handleSubscriptionCreated, { + stripeSubscriptionId: "sub_plan_change", + stripeCustomerId: "cus_test", + status: "active", + currentPeriodEnd: Date.now(), + cancelAtPeriodEnd: false, + priceId: "price_old", + }); + + await t.mutation(api.private.handleSubscriptionUpdated, { + stripeSubscriptionId: "sub_plan_change", + status: "active", + currentPeriodEnd: Date.now(), + cancelAtPeriodEnd: false, + priceId: "price_new", + }); + + const subscription = await t.query(api.public.getSubscription, { + stripeSubscriptionId: "sub_plan_change", + }); + + expect(subscription?.priceId).toBe("price_new"); +}); + +test("subscription cancel_at sets cancelAtPeriodEnd when it matches currentPeriodEnd", async () => { + const t = convexTest(schema, modules); + + const currentPeriodEnd = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; + + await t.mutation(api.private.handleSubscriptionCreated, { + stripeSubscriptionId: "sub_cancel_at", + stripeCustomerId: "cus_test", + status: "active", + currentPeriodEnd, + cancelAtPeriodEnd: false, + priceId: "price_test", + }); + + // Some Stripe flows set `cancel_at` but leave `cancel_at_period_end` false. + await t.mutation(api.private.handleSubscriptionUpdated, { + stripeSubscriptionId: "sub_cancel_at", + status: "active", + currentPeriodEnd, + cancelAtPeriodEnd: false, + cancelAt: currentPeriodEnd, + }); + + const subscription = await t.query(api.public.getSubscription, { + stripeSubscriptionId: "sub_cancel_at", + }); + + expect(subscription?.cancelAtPeriodEnd).toBe(true); + expect(subscription?.cancelAt).toBe(currentPeriodEnd); +}); + test("seat quantity update", async () => { const t = convexTest(schema, modules); diff --git a/src/component/schema.ts b/src/component/schema.ts index cf62912..c98228f 100644 --- a/src/component/schema.ts +++ b/src/component/schema.ts @@ -16,6 +16,7 @@ export default defineSchema({ status: v.string(), currentPeriodEnd: v.number(), cancelAtPeriodEnd: v.boolean(), + cancelAt: v.optional(v.number()), quantity: v.optional(v.number()), priceId: v.string(), metadata: v.optional(v.any()),