From 313cd552e6651dcff186421a18d0439ef153844e Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 18 Aug 2026 09:48:10 +0300 Subject: [PATCH] fix(billing): stop an unpaid subscription from unlocking the paid limits isPaidTier read a future stripeCurrentPeriodEnd as proof of payment, and Stripe stamps a current period on an incomplete subscription all the same. A checkout whose first charge failed therefore carried a period end a month out with nothing paid behind it, and every ceiling the cardless trial puts on an unpaid account (200 GB of storage, unlimited projects, unlimited workspaces) came off with it. Written as a deny list of the two statuses that mean no charge has ever gone through, so a real customer whose renewal failed keeps the full plan for the period they already paid for. hasBillingAccess reads the same column the same way and is deliberately left alone: being wrong there locks a paying customer out, and the SQL in buildBillingAccessWhereInput has to move with it. --- lib/billing.ts | 18 +++++++++++++++ tests/unit/lib/billing.test.ts | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/lib/billing.ts b/lib/billing.ts index f661ae5..79169a6 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -25,6 +25,16 @@ const RECOVERABLE_SUBSCRIPTION_STATUSES = new Set([ BillingSubscriptionStatus.INCOMPLETE, ]); +// Statuses that mean no payment on this subscription has ever gone through. +// Stripe stamps a current period on an `incomplete` subscription all the same, +// so a checkout whose first charge failed leaves `stripeCurrentPeriodEnd` a +// month into the future with nothing paid behind it. Every other status in the +// enum follows at least one successful charge, or has no period end at all. +const UNPAID_SUBSCRIPTION_STATUSES = new Set([ + BillingSubscriptionStatus.INCOMPLETE, + BillingSubscriptionStatus.INCOMPLETE_EXPIRED, +]); + export const DEFAULT_TRIAL_PERIOD_DAYS = 7; const STORAGE_CLEANUP_GRACE_DAYS = 15; @@ -96,6 +106,14 @@ export function isPaidTier( return true; } + // The period end alone is not proof of payment. Checked here and not in + // `hasBillingAccess`, which keeps granting access on a period end it did not + // question before: the cost of being wrong there is a customer locked out, + // while the cost of being wrong here is a free account holding 200 GB. + if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) { + return false; + } + return Boolean( subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime() ); diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts index 906e22d..6e777a6 100644 --- a/tests/unit/lib/billing.test.ts +++ b/tests/unit/lib/billing.test.ts @@ -185,6 +185,48 @@ describe('isPaidTier', () => { ).toBe(false); }); + // A checkout whose first charge never went through. Stripe hands back a + // subscription carrying a period end a month out, and reading that as payment + // would have given a free account the full 200 GB and unlimited projects. + it('does not count an incomplete subscription as paid, period end or not', () => { + expect( + isPaidTier( + { + subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE, + stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS), + }, + NOW + ) + ).toBe(false); + }); + + it('does not count an expired incomplete subscription as paid', () => { + expect( + isPaidTier( + { + subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED, + stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS), + }, + NOW + ) + ).toBe(false); + }); + + // A card that failed on renewal is a customer, not a free account: the period + // it is inside was paid for. Kept as a test because the fix above is one + // `Set.has` away from catching this case too. + it('still counts a past due subscription inside its paid period as paid', () => { + expect( + isPaidTier( + { + subscriptionStatus: BillingSubscriptionStatus.PAST_DUE, + stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), + }, + NOW + ) + ).toBe(true); + }); + it('does not count an expired paid period as paid', () => { expect( isPaidTier(