Skip to content

Commit 8eafcfb

Browse files
committed
scheduled_tier used for downgrade
1 parent f114adf commit 8eafcfb

File tree

9 files changed

+3202
-26
lines changed

9 files changed

+3202
-26
lines changed

packages/billing/src/subscription-webhooks.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,21 @@ export async function handleSubscriptionInvoicePaid(params: {
104104

105105
const status = mapStripeStatus(stripeSub.status)
106106

107-
// Upsert subscription row
107+
// Check for a pending scheduled tier change (downgrade)
108+
const existingSub = await db
109+
.select({
110+
tier: schema.subscription.tier,
111+
scheduled_tier: schema.subscription.scheduled_tier,
112+
})
113+
.from(schema.subscription)
114+
.where(eq(schema.subscription.stripe_subscription_id, subscriptionId))
115+
.limit(1)
116+
117+
const previousTier = existingSub[0]?.tier
118+
const hadScheduledTier = existingSub[0]?.scheduled_tier != null
119+
120+
// Upsert subscription row — always apply the Stripe tier and clear
121+
// scheduled_tier so pending downgrades take effect on renewal.
108122
await db
109123
.insert(schema.subscription)
110124
.values({
@@ -113,6 +127,7 @@ export async function handleSubscriptionInvoicePaid(params: {
113127
user_id: userId,
114128
stripe_price_id: priceId,
115129
tier,
130+
scheduled_tier: null,
116131
status,
117132
billing_period_start: new Date(stripeSub.current_period_start * 1000),
118133
billing_period_end: new Date(stripeSub.current_period_end * 1000),
@@ -125,6 +140,7 @@ export async function handleSubscriptionInvoicePaid(params: {
125140
user_id: userId,
126141
stripe_price_id: priceId,
127142
tier,
143+
scheduled_tier: null,
128144
billing_period_start: new Date(
129145
stripeSub.current_period_start * 1000,
130146
),
@@ -134,6 +150,16 @@ export async function handleSubscriptionInvoicePaid(params: {
134150
},
135151
})
136152

153+
// If a scheduled downgrade was applied, expire block grants so the user
154+
// gets new grants at the lower tier's limits.
155+
if (hadScheduledTier) {
156+
await expireActiveBlockGrants({ userId, subscriptionId, logger })
157+
logger.info(
158+
{ userId, subscriptionId, previousTier, tier },
159+
'Applied scheduled tier change and expired block grants',
160+
)
161+
}
162+
137163
logger.info(
138164
{
139165
subscriptionId,
@@ -233,10 +259,13 @@ export async function handleSubscriptionUpdated(params: {
233259

234260
const status = mapStripeStatus(stripeSubscription.status)
235261

236-
// Check existing tier to detect downgrades — downgrades preserve the
237-
// current tier until the next billing period (invoice.paid updates it).
262+
// Check existing tier to detect downgrades. During a downgrade the old
263+
// higher tier is kept in `scheduled_tier` so limits remain until renewal.
238264
const existingSub = await db
239-
.select({ tier: schema.subscription.tier })
265+
.select({
266+
tier: schema.subscription.tier,
267+
scheduled_tier: schema.subscription.scheduled_tier,
268+
})
240269
.from(schema.subscription)
241270
.where(eq(schema.subscription.stripe_subscription_id, subscriptionId))
242271
.limit(1)
@@ -267,8 +296,11 @@ export async function handleSubscriptionUpdated(params: {
267296
target: schema.subscription.stripe_subscription_id,
268297
set: {
269298
user_id: userId,
270-
stripe_price_id: priceId,
271-
...(isDowngrade ? {} : { tier }),
299+
// Downgrade: preserve current tier & stripe_price_id, schedule the
300+
// new tier for the next billing period.
301+
...(isDowngrade
302+
? { scheduled_tier: tier }
303+
: { tier, stripe_price_id: priceId, scheduled_tier: null }),
272304
status,
273305
cancel_at_period_end: stripeSubscription.cancel_at_period_end,
274306
billing_period_start: new Date(
@@ -281,14 +313,23 @@ export async function handleSubscriptionUpdated(params: {
281313
},
282314
})
283315

316+
// If this is an upgrade, expire old block grants so the user gets new
317+
// grants at the higher tier's limits. Also serves as a fallback if the
318+
// route handler's DB update failed.
319+
const isUpgrade = existingTier != null && tier > existingTier
320+
if (isUpgrade) {
321+
await expireActiveBlockGrants({ userId, subscriptionId, logger })
322+
}
323+
284324
logger.info(
285325
{
286326
subscriptionId,
287327
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
288328
isDowngrade,
329+
isUpgrade,
289330
},
290331
isDowngrade
291-
? 'Processed subscription update — downgrade deferred to next billing period'
332+
? 'Processed subscription update — downgrade scheduled for next billing period'
292333
: 'Processed subscription update',
293334
)
294335
}
@@ -315,6 +356,7 @@ export async function handleSubscriptionDeleted(params: {
315356
.update(schema.subscription)
316357
.set({
317358
status: 'canceled',
359+
scheduled_tier: null,
318360
canceled_at: new Date(),
319361
updated_at: new Date(),
320362
})

packages/billing/src/subscription.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,6 @@ export async function expireActiveBlockGrants(params: {
505505
eq(schema.creditLedger.user_id, userId),
506506
eq(schema.creditLedger.type, 'subscription'),
507507
gt(schema.creditLedger.expires_at, now),
508-
gt(schema.creditLedger.balance, 0),
509508
),
510509
)
511510
.returning({ operation_id: schema.creditLedger.operation_id })
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "subscription" ADD COLUMN "scheduled_tier" integer;

0 commit comments

Comments
 (0)