diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index e052a97..c3a6383 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -81,6 +81,7 @@ interface BillingOverview { checkoutAvailable: boolean; portalAvailable: boolean; cancelAvailable: boolean; + cancelIsImmediate: boolean; needsPaymentFix: boolean; openInvoice: { id: string | null; @@ -581,8 +582,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo

{billing.subscription.billingAccessEndedAt ? (

- Access to your workspaces continues until{' '} - {new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. + {new Date(billing.subscription.billingAccessEndedAt) > new Date() + ? `Access to your workspaces continues until ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.` + : `Access to your workspaces ended on ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. Paying this invoice restores it.`}

) : null} {billing.openInvoice.hostedInvoiceUrl ? ( @@ -673,7 +675,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo Cancel your subscription? - {billing.needsPaymentFix + {billing.cancelIsImmediate ? 'Your subscription ends right away and the unpaid invoice is canceled, so no further payment is attempted. This cannot be undone: getting the subscription back means going through checkout again.' : 'Your subscription stays active until the end of the current billing period and is not renewed after that. This cannot be undone from here.'} diff --git a/app/api/billing/cancel/route.ts b/app/api/billing/cancel/route.ts index f8e08ae..a1b6a05 100644 --- a/app/api/billing/cancel/route.ts +++ b/app/api/billing/cancel/route.ts @@ -5,7 +5,7 @@ import { findLiveStripeSubscription, getBillingOverview, isUnpaidStripeSubscription, - syncStripeSubscriptionToUser, + syncStripeCustomerSubscriptions, voidOpenSubscriptionInvoices, } from '@/lib/billing'; import { rateLimit } from '@/lib/rate-limit'; @@ -62,7 +62,10 @@ export async function POST(request: NextRequest) { ? await voidOpenSubscriptionInvoices(customerId, subscription.id) : []; - await syncStripeSubscriptionToUser(canceled); + // Re-derived from the customer's whole set rather than written from `canceled` alone. + // A customer can hold more than one subscription, and mirroring just the one that was + // cancelled would lock out an account still being billed on another. + await syncStripeCustomerSubscriptions(customerId); const response = successResponse({ canceledImmediately: unpaid, diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts index b85cae1..5c49907 100644 --- a/app/api/billing/route.ts +++ b/app/api/billing/route.ts @@ -51,6 +51,11 @@ export async function GET() { !billing.subscription.cancelAt && !billing.subscription.cancelAtPeriodEnd, needsPaymentFix, + // Whether cancelling ends the subscription there and then rather than at the period + // end, which is what the confirmation copy has to say. Mirrors the branch the cancel + // route takes: nothing was paid for the open period, so there is nothing to run out. + cancelIsImmediate: + needsPaymentFix || billing.subscription.status === BillingSubscriptionStatus.INCOMPLETE, openInvoice: openInvoice ? { id: openInvoice.id, diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 746d55a..180ddca 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -167,7 +167,11 @@ export async function POST(request: NextRequest) { // owner too. A workspace admin on somebody else's trial hits the same ceiling. const owner = await db.user.findUnique({ where: { id: workspace.ownerId }, - select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true }, + select: { + subscriptionStatus: true, + stripeCurrentPeriodEnd: true, + billingAccessEndedAt: true, + }, }); if (owner && !isPaidTier(owner)) { diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 52dbb4a..9815204 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server'; import type Stripe from 'stripe'; -import { syncStripeCustomerSubscriptions } from '@/lib/billing'; +import { getInvoiceSubscriptionId, syncStripeCustomerSubscriptions } from '@/lib/billing'; import { getStripe, getStripeWebhookSecret } from '@/lib/stripe'; import { logError } from '@/lib/logger'; @@ -65,7 +65,11 @@ export async function POST(request: NextRequest) { case 'invoice.marked_uncollectible': { const invoice = event.data.object as Stripe.Invoice; const customerId = getCustomerId(invoice.customer); - if (customerId) { + // Only subscription invoices. A one-off invoice against a customer record left + // behind by an abandoned checkout has no subscription, and syncing on it would + // find an empty list, mark the account canceled and book a churn event for a + // subscription that never existed. + if (customerId && getInvoiceSubscriptionId(invoice)) { await syncStripeCustomerSubscriptions(customerId); } break; diff --git a/lib/billing.ts b/lib/billing.ts index 6eaa502..2807bb4 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -36,26 +36,17 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set([ BillingSubscriptionStatus.INCOMPLETE_EXPIRED, ]); -// Stripe-side counterparts of the sets above, used where a raw Stripe subscription is in -// hand rather than the mirrored status. - -// A customer holding one of these must not be sent through checkout again; they belong in -// the billing portal. `incomplete` is deliberately absent: an abandoned first payment -// leaves one behind for about a day, and blocking on it would lock the customer out of -// checkout with nothing to fix in the portal. -const BLOCKING_STRIPE_STATUSES = new Set([ - 'active', - 'trialing', - 'past_due', - 'unpaid', -]); - -// Subscriptions still worth mirroring onto the user. +// The Stripe-side counterpart of RECOVERABLE_SUBSCRIPTION_STATUSES, for the places that +// hold a raw Stripe subscription rather than the mirrored status. Deliberately the same +// membership: a subscription worth cancelling is a subscription worth blocking a second +// checkout over, and two sets that disagreed only produced a Cancel button that always +// failed and a checkout guard weaker than the mirror it was backing up. const LIVE_STRIPE_STATUSES = new Set([ 'active', 'trialing', 'past_due', 'unpaid', + 'incomplete', ]); // Cancelling one of these takes effect immediately: the open period was never paid for, @@ -135,7 +126,10 @@ export function hasRecoverableSubscription(status: BillingSubscriptionStatus | n * A legacy Stripe trial counts as paid because a card was handed over for it. */ export function isPaidTier( - subject: Pick, + subject: Pick< + BillingAccessSubject, + 'subscriptionStatus' | 'stripeCurrentPeriodEnd' | 'billingAccessEndedAt' + >, now: Date = new Date() ) { if (!isStripeFeatureEnabled()) { @@ -146,14 +140,19 @@ 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. + // The period end alone is not proof of payment. if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) { return false; } + // Same cutoff `hasBillingAccess` applies, so the two cannot disagree about a customer + // behind on payment. They did once: access stopped at the end of Stripe's retry window + // while this kept saying "paid" for the rest of the period, which left the account with + // no banner explaining the lockout and able to create workspaces it could not then see. + if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) { + return false; + } + return Boolean( subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime() ); @@ -164,16 +163,6 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new return true; } - // A recorded end date is a hard stop, checked before anything else. Stripe advances the - // billing period the moment it issues the renewal invoice, paid or not, and the period - // survives cancellation, so the period end below would otherwise hand a full free month - // to anyone whose renewal fails. `isPaidTier` guards the storage tier against the same - // thing; this guards access itself, without the lockout that worried it: a subscription - // behind on payment is stamped with the end of Stripe's retry window, not with today. - if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) { - return false; - } - if (hasActiveSubscription(subject.subscriptionStatus)) { return true; } @@ -182,6 +171,26 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new return true; } + // Everything below decides whether the reported period still stands in for access, and + // the two guards exist because it very often does not. Both are scoped to this branch + // rather than applied at the top of the function: `billingAccessEndedAt` is only ever + // cleared by a Stripe sync, so a stale one from a lapsed subscription would otherwise + // outrank a freshly started cardless trial and burn the account's one trial for nothing. + + // Stripe stamps a period on a subscription whose first charge never went through, so + // that period is not evidence of payment. The same rejection `isPaidTier` makes. + if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) { + return false; + } + + // Stripe advances the period the moment it issues the renewal invoice, paid or not, and + // the period survives cancellation, so on its own it would hand a full free month to + // anyone whose renewal fails. This is the bound: a subscription behind on payment is + // stamped with the end of Stripe's retry window, a cancelled one with `ended_at`. + if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) { + return false; + } + return Boolean( subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime() ); @@ -211,23 +220,21 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use return {}; } - // Mirrors `hasBillingAccess`, including its hard stop, so the query and the in-memory - // check cannot disagree about who still has access. + // Mirrors `hasBillingAccess` branch for branch, including the two guards scoped to its + // period-end arm, so the query and the in-memory check cannot disagree about who still + // has access. return { - AND: [ + OR: [ { - OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }], + subscriptionStatus: { + in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING], + }, }, + { trialEndsAt: { gt: now } }, { - OR: [ - { - subscriptionStatus: { - in: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING], - }, - }, - { trialEndsAt: { gt: now } }, - { stripeCurrentPeriodEnd: { gt: now } }, - ], + stripeCurrentPeriodEnd: { gt: now }, + subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] }, + OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }], }, ], }; @@ -866,13 +873,23 @@ function getInactiveBillingAccessEndedAt( } // Still running, just behind on payment: access ends when Stripe gives up retrying, not - // at the period end, which Stripe already advanced to cover the unpaid invoice. + // at the period end, which Stripe already advanced to cover the unpaid invoice. The + // period start is when that invoice was issued, so it is what the window runs from; when + // it is missing (a paginated item list, an older payload shape) the window runs from now + // instead. Falling through to "ended" here would lock out the customer this branch + // exists to keep in, which is the wrong way to fail on missing data. if (RETRYING_STRIPE_STATUSES.has(subscription.status)) { + const grace = UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60; const periodStart = getSubscriptionPeriodStart(subscription); - if (periodStart) { - const graceEnd = periodStart + UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60; - return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000); - } + const graceEnd = periodStart ? periodStart + grace : Math.floor(Date.now() / 1000) + grace; + + return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000); + } + + // A pause is not a non-payment: the period behind it was paid for, so it runs out + // normally. Stripe's portal pauses keep the status `active`, but the API can set this. + if (subscription.status === 'paused' && currentPeriodEnd) { + return new Date(currentPeriodEnd * 1000); } // Anything else that gets here never paid for the period Stripe is reporting, so that @@ -1156,7 +1173,7 @@ export async function findBlockingStripeSubscription(customerId: string) { }); return ( - subscriptions.find((subscription) => BLOCKING_STRIPE_STATUSES.has(subscription.status)) ?? null + subscriptions.find((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status)) ?? null ); } @@ -1166,8 +1183,14 @@ export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) { /** * Cancelling a subscription in Stripe does not stop collection on invoices that were - * already issued; they keep retrying on their own until they are paid or voided. Only - * invoices for a period the customer never paid for are voided here. + * already issued; they keep retrying on their own until they are paid or voided. Voiding + * them is what actually stops the card being charged after someone has cancelled. + * + * Note this writes off a real receivable, not only an unserved one: a `past_due` customer + * has had access for up to `UNPAID_ACCESS_GRACE_DAYS` before they get here. That is a + * deliberate trade, on the grounds that chasing a single month of a small subscription + * costs more than it recovers and that the customer is leaving anyway. `markUncollectible` + * is the one-line change if the receivable should be kept on the books instead. */ export async function voidOpenSubscriptionInvoices(customerId: string, subscriptionId: string) { const stripe = getStripe(); diff --git a/lib/storage-quota.ts b/lib/storage-quota.ts index f9bbba3..64438fe 100644 --- a/lib/storage-quota.ts +++ b/lib/storage-quota.ts @@ -43,7 +43,7 @@ export interface StorageContext { export async function getStorageContextForUser(userId: string): Promise { const user = await db.user.findUnique({ where: { id: userId }, - select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true }, + select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true, billingAccessEndedAt: true }, }); const isPaid = user ? isPaidTier(user) : false; diff --git a/scripts/resync-stripe-subscriptions.ts b/scripts/resync-stripe-subscriptions.ts index 0a06235..bec7f00 100644 --- a/scripts/resync-stripe-subscriptions.ts +++ b/scripts/resync-stripe-subscriptions.ts @@ -7,8 +7,8 @@ * which for a customer whose payment already failed may never happen on its own. */ import { db, disconnectDb } from '../lib/db'; -import { findLiveStripeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing'; -import { isStripeConfigured } from '../lib/stripe'; +import { selectAuthoritativeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing'; +import { getStripe, isStripeConfigured } from '../lib/stripe'; import { logError } from '../lib/logger'; const TAG = '[resync-stripe-subscriptions]'; @@ -34,15 +34,24 @@ async function main() { if (!user.stripeCustomerId) continue; try { - const subscription = await findLiveStripeSubscription(user.stripeCustomerId); + const label = user.email ?? user.id; + + // Selected exactly the way the write path selects, over the customer's whole set + // rather than the live ones only. A mirror left wrong by the version change is most + // likely on a customer whose subscription is already canceled or incomplete, which + // is precisely who a live-only filter would skip. + const { data: subscriptions } = await getStripe().subscriptions.list({ + customer: user.stripeCustomerId, + status: 'all', + limit: 100, + }); + const subscription = selectAuthoritativeSubscription(subscriptions); if (!subscription) { withoutSubscription += 1; continue; } - const label = user.email ?? user.id; - if (dryRun) { console.log( `${TAG} Would sync ${label}: ${subscription.id} (${subscription.status}), stored period end ${user.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}` @@ -54,7 +63,7 @@ async function main() { const updated = await syncStripeCustomerSubscriptions(user.stripeCustomerId); if (updated) { console.log( - `${TAG} Synced ${label}: ${subscription.status}, period end ${updated.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}` + `${TAG} Synced ${label}: ${subscription.status}, period end ${updated.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}, access ends ${updated.billingAccessEndedAt?.toISOString() ?? 'null'}` ); synced += 1; } diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts index ddaec7d..432faf6 100644 --- a/tests/unit/lib/billing.test.ts +++ b/tests/unit/lib/billing.test.ts @@ -152,7 +152,11 @@ describe('isPaidTier', () => { it('counts an active subscription as paid', () => { expect( isPaidTier( - { subscriptionStatus: BillingSubscriptionStatus.ACTIVE, stripeCurrentPeriodEnd: null }, + { + subscriptionStatus: BillingSubscriptionStatus.ACTIVE, + stripeCurrentPeriodEnd: null, + billingAccessEndedAt: null, + }, NOW ) ).toBe(true); @@ -163,7 +167,11 @@ describe('isPaidTier', () => { it('counts a Stripe trial as paid', () => { expect( isPaidTier( - { subscriptionStatus: BillingSubscriptionStatus.TRIALING, stripeCurrentPeriodEnd: null }, + { + subscriptionStatus: BillingSubscriptionStatus.TRIALING, + stripeCurrentPeriodEnd: null, + billingAccessEndedAt: null, + }, NOW ) ).toBe(true); @@ -175,6 +183,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.CANCELED, stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -185,7 +194,11 @@ describe('isPaidTier', () => { it('does not count a cardless trial as paid', () => { expect( isPaidTier( - { subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null }, + { + subscriptionStatus: BillingSubscriptionStatus.FREE, + stripeCurrentPeriodEnd: null, + billingAccessEndedAt: null, + }, NOW ) ).toBe(false); @@ -200,6 +213,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE, stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -212,6 +226,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED, stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -227,6 +242,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.PAST_DUE, stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -239,6 +255,7 @@ describe('isPaidTier', () => { { subscriptionStatus: BillingSubscriptionStatus.CANCELED, stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS), + billingAccessEndedAt: null, }, NOW ) @@ -250,7 +267,11 @@ describe('isPaidTier', () => { expect( isPaidTier( - { subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null }, + { + subscriptionStatus: BillingSubscriptionStatus.FREE, + stripeCurrentPeriodEnd: null, + billingAccessEndedAt: null, + }, NOW ) ).toBe(true); @@ -366,7 +387,12 @@ describe('hasBillingAccess', () => { ).toBe(true); }); - it('ignores billingAccessEndedAt while the paid period is still running', () => { + // Was the opposite assertion, on the premise that a future period end means a paid + // period. It does not: Stripe advances the period when it issues the renewal invoice, + // paid or not, and the period survives cancellation, so this exact shape (cutoff in the + // past, period end in the future) is what a subscription cancelled while behind on + // payment looks like. Honouring the period here handed out a free month. + it('honours billingAccessEndedAt even while the reported period is still running', () => { const result = hasBillingAccess( subject({ subscriptionStatus: 'CANCELED', @@ -375,9 +401,36 @@ describe('hasBillingAccess', () => { }), NOW ); + expect(result).toBe(false); + }); + + // The other half of that: a stale cutoff must not outrank a live trial, or starting a + // cardless trial on a lapsed account would consume the account's one trial and grant + // nothing, since only a Stripe sync ever clears the cutoff. + it('lets an unexpired trial win over a cutoff already in the past', () => { + const result = hasBillingAccess( + subject({ + subscriptionStatus: 'CANCELED', + trialEndsAt: new Date(NOW.getTime() + DAY_MS), + billingAccessEndedAt: new Date(NOW.getTime() - DAY_MS), + }), + NOW + ); expect(result).toBe(true); }); + // Stripe stamps a period on a subscription whose first charge never went through. + it('refuses a period end carried by a subscription that never paid', () => { + const result = hasBillingAccess( + subject({ + subscriptionStatus: 'INCOMPLETE_EXPIRED', + stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), + }), + NOW + ); + expect(result).toBe(false); + }); + it('grants access to everyone when Stripe is disabled', () => { vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false'); const result = hasBillingAccess( @@ -452,7 +505,14 @@ describe('buildBillingAccessWhereInput', () => { OR: [ { subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } }, { trialEndsAt: { gt: NOW } }, - { stripeCurrentPeriodEnd: { gt: NOW } }, + // Both guards sit inside this arm, mirroring `hasBillingAccess`: the period end + // is only evidence of access when a payment stands behind it and no cutoff has + // passed. Scoped to this arm, not the whole query, so a live trial still wins. + { + stripeCurrentPeriodEnd: { gt: NOW }, + subscriptionStatus: { notIn: ['INCOMPLETE', 'INCOMPLETE_EXPIRED'] }, + OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: NOW } }], + }, ], }); }); @@ -1451,31 +1511,48 @@ describe('database backed billing helpers', () => { expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date); }); - it('keeps access while a canceled subscription is still inside its paid period', async () => { + // Was asserting `billingAccessEndedAt: null` here, i.e. that a canceled subscription + // keeps access to the reported period end. That is only right if the period was paid + // for, and a canceled subscription cannot tell you that it was: the period Stripe + // reports advances when the renewal invoice is issued and survives the cancellation, + // so this is also exactly the shape of "cancelled while behind on payment". A cutoff + // is stamped instead, and `ended_at` is what it comes from. + it('stamps a cutoff on a canceled subscription rather than trusting its period', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); + const endedAt = Math.floor(NOW.getTime() / 1000); await syncStripeSubscriptionToUser( stripeSub({ status: 'canceled', + ended_at: endedAt, current_period_end: Math.floor(NOW.getTime() / 1000) + 3600, }) ); expect(updateData()).toMatchObject({ subscriptionStatus: BillingSubscriptionStatus.CANCELED, - billingAccessEndedAt: null, }); + expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(endedAt * 1000); }); - it('ends access at the period end once the paid period has passed', async () => { + // Behind on payment but still being retried: access runs to the end of Stripe's retry + // window, measured from the period start, not to the period end Stripe advanced to + // cover the invoice that was never paid. + it('bounds a past_due subscription to the retry window', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); - const periodEnd = Math.floor(NOW.getTime() / 1000) - 3600; + const periodStart = Math.floor(NOW.getTime() / 1000); await syncStripeSubscriptionToUser( - stripeSub({ status: 'canceled', current_period_end: periodEnd }) + stripeSub({ + status: 'past_due', + current_period_start: periodStart, + current_period_end: periodStart + 30 * 24 * 60 * 60, + }) ); - expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(periodEnd * 1000); + expect((updateData().billingAccessEndedAt as Date).getTime()).toBe( + (periodStart + 14 * 24 * 60 * 60) * 1000 + ); }); it('falls back to ended_at when there is no period end', async () => {