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 () => {