Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {},
});

Expand Down Expand Up @@ -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 || {},
});

Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/component/_generated/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
"mutation",
"internal",
{
cancelAt?: number;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: number;
metadata?: any;
Expand All @@ -131,9 +132,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
"mutation",
"internal",
{
cancelAt?: number;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: number;
metadata?: any;
priceId?: string;
quantity?: number;
status: string;
stripeSubscriptionId: string;
Expand Down Expand Up @@ -203,6 +206,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
"internal",
{ stripeSubscriptionId: string },
{
cancelAt?: number;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: number;
metadata?: any;
Expand All @@ -221,6 +225,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
"internal",
{ orgId: string },
{
cancelAt?: number;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: number;
metadata?: any;
Expand Down Expand Up @@ -341,6 +346,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
"internal",
{ stripeCustomerId: string },
Array<{
cancelAt?: number;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: number;
metadata?: any;
Expand All @@ -359,6 +365,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
"internal",
{ userId: string },
Array<{
cancelAt?: number;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: number;
metadata?: any;
Expand Down
2 changes: 1 addition & 1 deletion src/component/_generated/dataModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type Doc<TableName extends TableNames> = 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.
Expand Down
27 changes: 25 additions & 2 deletions src/component/private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,25 @@ 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(),
stripeCustomerId: v.string(),
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()),
Expand All @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -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 }),
Expand Down
58 changes: 58 additions & 0 deletions src/component/public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions src/component/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down