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..a284af3091c 100644 --- a/packages/billing/stripe-webhook-handlers/payment-succeeded.ts +++ b/packages/billing/stripe-webhook-handlers/payment-succeeded.ts @@ -26,6 +26,32 @@ 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, + }; + } + + throw new Error( + 'Expected subscription period to be present in payment succeeded webhook event', + ); +} + export async function handlePaymentSucceeded( dbAdapter: DBAdapter, event: StripeInvoicePaymentSucceededWebhookEvent, @@ -49,6 +75,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 +106,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 +230,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 +264,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/host/config/schema/1769504850011_schema.sql b/packages/host/config/schema/1769517089459_schema.sql similarity index 100% rename from packages/host/config/schema/1769504850011_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) => {}; diff --git a/packages/realm-server/tests/billing-test.ts b/packages/realm-server/tests/billing-test.ts index 30aae249122..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, + }, }, ], }, @@ -703,7 +723,13 @@ module(basename(__filename), function () { data: [ { amount: 1200, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, + period: { + start: 20, + end: 30, + }, }, ], }, @@ -735,8 +761,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, { 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 }, }, ], },