From cdfd28546f2bd879ed737c43b5658c07bc539dc3 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 26 Jan 2026 12:19:22 +0100 Subject: [PATCH 1/5] When inserting a new subscription cycle, read the actual subscription start/end period, not invoice period --- .../billing/stripe-webhook-handlers/index.ts | 2 + .../payment-succeeded.ts | 53 ++++++++++++++++--- packages/realm-server/tests/billing-test.ts | 10 +++- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/packages/billing/stripe-webhook-handlers/index.ts b/packages/billing/stripe-webhook-handlers/index.ts index 0ee1145f524..b3a36bc9b4d 100644 --- a/packages/billing/stripe-webhook-handlers/index.ts +++ b/packages/billing/stripe-webhook-handlers/index.ts @@ -37,6 +37,8 @@ export type StripeInvoicePaymentSucceededWebhookEvent = StripeEvent & { data: Array<{ amount: number; description: string; + type?: string; + proration?: boolean; price: { product: string; }; diff --git a/packages/billing/stripe-webhook-handlers/payment-succeeded.ts b/packages/billing/stripe-webhook-handlers/payment-succeeded.ts index 9b13df47112..167150d63bb 100644 --- a/packages/billing/stripe-webhook-handlers/payment-succeeded.ts +++ b/packages/billing/stripe-webhook-handlers/payment-succeeded.ts @@ -26,6 +26,33 @@ import type { PgAdapter } from '@cardstack/postgres'; import { TransactionManager } from '@cardstack/postgres'; import { ProrationCalculator } from '../proration-calculator'; +function getInvoiceSubscriptionPeriod( + event: StripeInvoicePaymentSucceededWebhookEvent, + planStripeId: string, +) { + let lineItem = event.data.object.lines.data.find( + (line) => + line.amount >= 0 && + line.type === 'subscription' && + line.proration !== true && + line.price?.product === planStripeId && + line.period?.start && + line.period?.end, + ); + + if (lineItem?.period) { + return { + periodStart: lineItem.period.start, + periodEnd: lineItem.period.end, + }; + } + + return { + periodStart: event.data.object.period_start, + periodEnd: event.data.object.period_end, + }; +} + export async function handlePaymentSucceeded( dbAdapter: DBAdapter, event: StripeInvoicePaymentSucceededWebhookEvent, @@ -49,6 +76,11 @@ export async function handlePaymentSucceeded( throw new Error(`No plan found for product id: ${productId}`); } + let { periodStart, periodEnd } = getInvoiceSubscriptionPeriod( + event, + plan.stripePlanId, + ); + // When user first signs up for a plan, our checkout.session.completed handler takes care of assigning the user a stripe customer id. // Stripe customer id is needed so that we can recognize the user when their subscription is renewed, or canceled. // The mentioned webhook should be sent before this one, but if there are any network or processing delays, @@ -75,12 +107,18 @@ export async function handlePaymentSucceeded( user, plan, creditAllowance: plan.creditsIncluded, - periodStart: event.data.object.period_start, - periodEnd: event.data.object.period_end, + periodStart, + periodEnd, event, }); } else if (billingReason === 'subscription_cycle') { - await createSubscriptionCycle(dbAdapter, user, plan, event); + await createSubscriptionCycle( + dbAdapter, + user, + plan, + periodStart, + periodEnd, + ); } else if (billingReason === 'subscription_update') { await updateSubscription(dbAdapter, user, plan, event); } @@ -193,8 +231,9 @@ async function updateSubscription( async function createSubscriptionCycle( dbAdapter: DBAdapter, user: { id: string }, - plan: { creditsIncluded: number }, - event: StripeInvoicePaymentSucceededWebhookEvent, + plan: Plan, + periodStart: number, + periodEnd: number, ) { let currentActiveSubscription = await getCurrentActiveSubscription( dbAdapter, @@ -226,8 +265,8 @@ async function createSubscriptionCycle( let newSubscriptionCycle = await insertSubscriptionCycle(dbAdapter, { subscriptionId: currentActiveSubscription.id, - periodStart: event.data.object.period_start, - periodEnd: event.data.object.period_end, + periodStart, + periodEnd, }); await addToCreditsLedger(dbAdapter, { diff --git a/packages/realm-server/tests/billing-test.ts b/packages/realm-server/tests/billing-test.ts index 30aae249122..cce6230d61d 100644 --- a/packages/realm-server/tests/billing-test.ts +++ b/packages/realm-server/tests/billing-test.ts @@ -703,7 +703,13 @@ module(basename(__filename), function () { data: [ { amount: 1200, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, + period: { + start: 20, + end: 30, + }, }, ], }, @@ -735,8 +741,8 @@ module(basename(__filename), function () { // Assert both subscription cycles have the correct period start and end assert.strictEqual(subscriptionCycles[0].periodStart, 1); assert.strictEqual(subscriptionCycles[0].periodEnd, 2); - assert.strictEqual(subscriptionCycles[1].periodStart, 2); - assert.strictEqual(subscriptionCycles[1].periodEnd, 3); + assert.strictEqual(subscriptionCycles[1].periodStart, 20); + assert.strictEqual(subscriptionCycles[1].periodEnd, 30); // Assert that the ledger has the correct sum of credits going in and out availableCredits = await sumUpCreditsLedger(dbAdapter, { From c1dff08ee7ba2665dcf966e59efc3c39905d032c Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Tue, 27 Jan 2026 13:37:11 +0100 Subject: [PATCH 2/5] Add a migration for fixing subscription cycle dates --- ...53_schema.sql => 1769517089459_schema.sql} | 0 ...9517089459_fix-subscription-cycle-dates.js | 45 +++++++++++++++++++ 2 files changed, 45 insertions(+) rename packages/host/config/schema/{1769006706253_schema.sql => 1769517089459_schema.sql} (100%) create mode 100644 packages/postgres/migrations/1769517089459_fix-subscription-cycle-dates.js diff --git a/packages/host/config/schema/1769006706253_schema.sql b/packages/host/config/schema/1769517089459_schema.sql similarity index 100% rename from packages/host/config/schema/1769006706253_schema.sql rename to packages/host/config/schema/1769517089459_schema.sql diff --git a/packages/postgres/migrations/1769517089459_fix-subscription-cycle-dates.js b/packages/postgres/migrations/1769517089459_fix-subscription-cycle-dates.js new file mode 100644 index 00000000000..47de480bd2a --- /dev/null +++ b/packages/postgres/migrations/1769517089459_fix-subscription-cycle-dates.js @@ -0,0 +1,45 @@ +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + WITH period_map AS ( + SELECT + sc.id AS subscription_cycle_id, + sc.period_start AS current_period_start, + sc.period_end AS current_period_end, + li.period_start AS new_period_start, + li.period_end AS new_period_end + FROM stripe_events se + JOIN subscriptions s + ON s.stripe_subscription_id = se.event_data->'object'->>'subscription' + JOIN subscription_cycles sc + ON sc.subscription_id = s.id + AND sc.period_start = (se.event_data->'object'->>'period_start')::int + AND sc.period_end = (se.event_data->'object'->>'period_end')::int + JOIN LATERAL ( + SELECT + (line->'period'->>'start')::int AS period_start, + (line->'period'->>'end')::int AS period_end + FROM jsonb_array_elements(se.event_data->'object'->'lines'->'data') AS line + WHERE (line->>'amount')::int >= 0 + AND line->>'type' = 'subscription' + AND COALESCE((line->>'proration')::boolean, false) = false + AND line->'period' ? 'start' + AND line->'period' ? 'end' + LIMIT 1 + ) li ON true + ) + UPDATE subscription_cycles sc + SET + period_start = pm.new_period_start, + period_end = pm.new_period_end + FROM period_map pm + WHERE sc.id = pm.subscription_cycle_id + AND ( + pm.current_period_start <> pm.new_period_start + OR pm.current_period_end <> pm.new_period_end + ); + `); +}; + +exports.down = (pgm) => {}; From b5b0f946816c64e35e6088636d12c371d0d1ccff Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Tue, 27 Jan 2026 14:28:42 +0100 Subject: [PATCH 3/5] Prefer an error over fallback to incorrect values --- .../billing/stripe-webhook-handlers/payment-succeeded.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/billing/stripe-webhook-handlers/payment-succeeded.ts b/packages/billing/stripe-webhook-handlers/payment-succeeded.ts index 167150d63bb..a284af3091c 100644 --- a/packages/billing/stripe-webhook-handlers/payment-succeeded.ts +++ b/packages/billing/stripe-webhook-handlers/payment-succeeded.ts @@ -47,10 +47,9 @@ function getInvoiceSubscriptionPeriod( }; } - return { - periodStart: event.data.object.period_start, - periodEnd: event.data.object.period_end, - }; + throw new Error( + 'Expected subscription period to be present in payment succeeded webhook event', + ); } export async function handlePaymentSucceeded( From 071854ff7318060fba11ed9adfff73d7a10053bc Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Wed, 28 Jan 2026 12:39:49 +0100 Subject: [PATCH 4/5] Adjust tests --- packages/realm-server/tests/billing-test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/realm-server/tests/billing-test.ts b/packages/realm-server/tests/billing-test.ts index cce6230d61d..7c61ace383b 100644 --- a/packages/realm-server/tests/billing-test.ts +++ b/packages/realm-server/tests/billing-test.ts @@ -147,7 +147,13 @@ module(basename(__filename), function () { data: [ { amount: 0, + type: 'subscription', + proration: false, price: { product: 'prod_free' }, + period: { + start: 1635873600, + end: 1638465600, + }, }, ], }, @@ -310,6 +316,8 @@ module(basename(__filename), function () { data: [ { amount: creatorPlan.monthlyPrice * 100, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, period: { start: 1, end: 2 }, }, @@ -405,12 +413,16 @@ module(basename(__filename), function () { { amount: -amountCreditedForUnusedTimeOnPreviousPlan, description: 'Unused time on Creator plan', + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, period: { start: 3, end: 4 }, }, { amount: amountCreditedForRemainingTimeOnNewPlan, description: 'Remaining time on Power User plan', + type: 'subscription', + proration: false, price: { product: 'prod_power_user' }, period: { start: 4, end: 5 }, }, @@ -500,6 +512,8 @@ module(basename(__filename), function () { data: [ { amount: creatorPlan.monthlyPrice * 100, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, period: { start: 5, end: 6 }, }, @@ -578,7 +592,13 @@ module(basename(__filename), function () { data: [ { amount: 0, + type: 'subscription', + proration: false, price: { product: 'prod_free' }, + period: { + start: 1635873600, + end: 1638465600, + }, }, ], }, From 52171b3d6249033f5efd933df1fa1cca308e2328 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Wed, 28 Jan 2026 13:07:14 +0100 Subject: [PATCH 5/5] Fix test --- .../tests/server-endpoints/stripe-webhook-test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/realm-server/tests/server-endpoints/stripe-webhook-test.ts b/packages/realm-server/tests/server-endpoints/stripe-webhook-test.ts index a34d556d2b9..e731aceceb3 100644 --- a/packages/realm-server/tests/server-endpoints/stripe-webhook-test.ts +++ b/packages/realm-server/tests/server-endpoints/stripe-webhook-test.ts @@ -153,7 +153,10 @@ module(`server-endpoints/${basename(__filename)}`, function () { data: [ { amount: 12, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, + period: { start: 1635873600, end: 1638465600 }, }, ], }, @@ -210,7 +213,10 @@ module(`server-endpoints/${basename(__filename)}`, function () { data: [ { amount: 0, + type: 'subscription', + proration: false, price: { product: 'prod_free' }, + period: { start: 1635873600, end: 1638465600 }, }, ], }, @@ -377,7 +383,10 @@ module(`server-endpoints/${basename(__filename)}`, function () { data: [ { amount: 12, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, + period: { start: 1635873600, end: 1638465600 }, }, ], }, @@ -561,7 +570,10 @@ module(`server-endpoints/${basename(__filename)}`, function () { data: [ { amount: 0, + type: 'subscription', + proration: false, price: { product: 'prod_free' }, + period: { start: 1635873600, end: 1638465600 }, }, ], },