mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
fix(billing): close the gaps the code and security reviews found
Follow-up on the same change, from a high-effort code review and security review run over the diff. Access gate: - Scope both period-end guards to the period-end branch of hasBillingAccess instead of the top of the function. A cutoff is only ever cleared by a Stripe sync, so checking it first meant a stale one from a lapsed subscription outranked a freshly started cardless trial: the account burned its once-per-account trial and got nothing. buildBillingAccessWhereInput mirrors the same shape. - Refuse a period end carried by an INCOMPLETE or INCOMPLETE_EXPIRED subscription, the rejection isPaidTier already makes. The cutoff is deliberately left null while a trial is live, so a trial user who abandoned a checkout kept the failed subscription's period once the trial ran out. - Apply the cutoff in isPaidTier too, so it cannot say "paid" for a period where hasBillingAccess says access is over. That split left a locked-out account with no banner explaining it and able to create workspaces it could not then see. Both callers now select the field. Lifecycle: - Cancel through syncStripeCustomerSubscriptions rather than writing the single cancelled subscription, so a customer holding a second live subscription is not locked out of an account they are still being billed for. - Ignore invoice events with no subscription. A one-off invoice against a customer record left by an abandoned checkout was marking the account canceled and booking a churn event for a subscription that never existed. - Fall back to a window measured from now when a subscription behind on payment reports no period start, rather than falling through to "access ended", which locked out the customer that branch exists to keep in. - Let a paused subscription run to its period end; it was being ended at once. - Collapse BLOCKING_STRIPE_STATUSES into LIVE_STRIPE_STATUSES and include incomplete. The two sets were identical, which offered a Cancel button that always returned "No subscription to cancel" and left the Stripe-side checkout guard weaker than the mirror check it backs up. UI and ops: - cancelIsImmediate from the API, so the confirmation says what will actually happen to an incomplete subscription instead of promising the period end. - The access banner reads "ended on" once the date has passed. - The resync script selects the way the write path selects, over the customer's whole set. Filtering to live subscriptions first made the dry run disagree with the real run and skipped canceled and incomplete customers entirely, who are exactly the stale mirrors the script exists for. Three existing tests asserted the behaviour this fixes: that a canceled subscription keeps access to its reported period end, and that the cutoff is ignored while that period runs. Both rest on the premise that a future period end means a paid period, which is what is not true. They now assert the bound, alongside new cases for the retry window, the trial-versus-stale-cutoff ordering, and a never-paid period.
This commit is contained in:
@@ -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
|
||||
</p>
|
||||
{billing.subscription.billingAccessEndedAt ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.`}
|
||||
</p>
|
||||
) : null}
|
||||
{billing.openInvoice.hostedInvoiceUrl ? (
|
||||
@@ -673,7 +675,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel your subscription?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{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.'}
|
||||
</AlertDialogDescription>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+74
-51
@@ -36,26 +36,17 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
||||
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<Stripe.Subscription.Status>([
|
||||
'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<Stripe.Subscription.Status>([
|
||||
'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<BillingAccessSubject, 'subscriptionStatus' | 'stripeCurrentPeriodEnd'>,
|
||||
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();
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface StorageContext {
|
||||
export async function getStorageContextForUser(userId: string): Promise<StorageContext> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user