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(