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
2 changes: 2 additions & 0 deletions packages/billing/stripe-webhook-handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export type StripeInvoicePaymentSucceededWebhookEvent = StripeEvent & {
data: Array<{
amount: number;
description: string;
type?: string;
proration?: boolean;
price: {
product: string;
};
Expand Down
52 changes: 45 additions & 7 deletions packages/billing/stripe-webhook-handlers/payment-succeeded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => {};
30 changes: 28 additions & 2 deletions packages/realm-server/tests/billing-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,13 @@ module(basename(__filename), function () {
data: [
{
amount: 0,
type: 'subscription',
proration: false,
price: { product: 'prod_free' },
period: {
start: 1635873600,
end: 1638465600,
},
},
],
},
Expand Down Expand Up @@ -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 },
},
Expand Down Expand Up @@ -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 },
},
Expand Down Expand Up @@ -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 },
},
Expand Down Expand Up @@ -578,7 +592,13 @@ module(basename(__filename), function () {
data: [
{
amount: 0,
type: 'subscription',
proration: false,
price: { product: 'prod_free' },
period: {
start: 1635873600,
end: 1638465600,
},
},
],
},
Expand Down Expand Up @@ -703,7 +723,13 @@ module(basename(__filename), function () {
data: [
{
amount: 1200,
type: 'subscription',
proration: false,
price: { product: 'prod_creator' },
period: {
start: 20,
end: 30,
},
},
],
},
Expand Down Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
],
},
Expand Down Expand Up @@ -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 },
},
],
},
Expand Down Expand Up @@ -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 },
},
],
},
Expand Down Expand Up @@ -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 },
},
],
},
Expand Down