+ {/* 7) FINAL CTA */}
+
+
+
Stop chasing feedback. Start getting approvals.
-
+
Your first review link takes minutes.
-
+
+
+
-
- {hostedCtaLabel}
-
diff --git a/components/admin/cancellation-reasons-card.tsx b/components/admin/cancellation-reasons-card.tsx
new file mode 100644
index 0000000..7b77966
--- /dev/null
+++ b/components/admin/cancellation-reasons-card.tsx
@@ -0,0 +1,101 @@
+import { format } from 'date-fns';
+import { UserX } from 'lucide-react';
+import { db } from '@/lib/db';
+import { CANCELLATION_REASONS, getCancellationReasonLabel } from '@/lib/cancellation-reasons';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+
+const RECENT_LIMIT = 15;
+
+/**
+ * The answers to the one question asked on the way out, newest first, with an
+ * all-time tally per answer above them.
+ *
+ * Only in-app cancellations appear here. Someone who cancels inside the Stripe
+ * portal, or whose card simply stops working, never sees the question, so the
+ * tally undercounts churn and says nothing about the accounts that pay and go
+ * silent. Read it as "what people said", not "why people leave".
+ */
+export async function CancellationReasonsCard() {
+ const [recent, tally] = await Promise.all([
+ db.subscriptionCancellation.findMany({
+ orderBy: { createdAt: 'desc' },
+ take: RECENT_LIMIT,
+ select: {
+ id: true,
+ reason: true,
+ note: true,
+ periodEnd: true,
+ createdAt: true,
+ user: { select: { name: true, email: true } },
+ },
+ }),
+ db.subscriptionCancellation.groupBy({
+ by: ['reason'],
+ _count: { _all: true },
+ }),
+ ]);
+
+ const countByReason = new Map(tally.map((row) => [row.reason, row._count._all]));
+ const total = tally.reduce((sum, row) => sum + row._count._all, 0);
+ const skipped = countByReason.get(null) ?? 0;
+
+ return (
+
+
+ Why people cancelled
+
+
+
+ {total === 0 ? (
+
+ No in-app cancellations yet. Cancellations made in the Stripe portal do not show up
+ here.
+
+ ) : (
+ <>
+
+ {CANCELLATION_REASONS.map((entry) => (
+
+ {entry.label}:{' '}
+
+ {countByReason.get(entry.value) ?? 0}
+
+
+ ))}
+
+ Skipped the question: {skipped}
+
+
+
+ {recent.map((row) => (
+
+
+
+ {row.user.name || 'Anonymous'}{' '}
+
+ {row.user.email}
+
+
+
+ {format(row.createdAt, 'MMM dd, yyyy')}
+ {row.periodEnd ? ` · access until ${format(row.periodEnd, 'MMM dd')}` : ''}
+
+
+ {getCancellationReasonLabel(row.reason)}
+ {row.note ? (
+ {row.note}
+ ) : null}
+
+ ))}
+
+ {total > RECENT_LIMIT ? (
+
+ Showing the latest {RECENT_LIMIT} of {total}.
+
+ ) : null}
+ >
+ )}
+
+
+ );
+}
diff --git a/components/settings/cancel-subscription-dialog.tsx b/components/settings/cancel-subscription-dialog.tsx
new file mode 100644
index 0000000..4d96cea
--- /dev/null
+++ b/components/settings/cancel-subscription-dialog.tsx
@@ -0,0 +1,169 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import { Loader2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ CANCELLATION_NOTE_MAX_LENGTH,
+ CANCELLATION_REASONS,
+ type CancellationReason,
+} from '@/lib/cancellation-reasons';
+
+interface CancelSubscriptionDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** When access ends if the cancellation goes through, or null when unknown. */
+ periodEnd: string | null;
+ /** True for a subscription that is still inside its Stripe trial. */
+ isTrial: boolean;
+ /** Unpaid subscriptions end now; cancellation does not extend access. */
+ canceledImmediately?: boolean;
+ /** Resolves true once the cancellation went through; false keeps the dialog and its answer. */
+ onConfirm: (input: {
+ reason: CancellationReason | null;
+ note: string | null;
+ }) => Promise
;
+}
+
+/**
+ * One question, five answers, no default, all of it skippable.
+ *
+ * The answer is the whole reason this dialog exists instead of a plain confirm,
+ * and the way to get honest answers is to make them cheap: one click, no
+ * required field, and a cancel button that works with nothing selected. A
+ * free-text box appears only under the two answers where the detail is worth
+ * more than the category.
+ */
+export function CancelSubscriptionDialog({
+ open,
+ onOpenChange,
+ periodEnd,
+ isTrial,
+ canceledImmediately = false,
+ onConfirm,
+}: CancelSubscriptionDialogProps) {
+ const [reason, setReason] = useState(null);
+ const [note, setNote] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ const selected = CANCELLATION_REASONS.find((entry) => entry.value === reason) ?? null;
+ const showNote = selected?.askForDetail ?? false;
+
+ const handleOpenChange = useCallback(
+ (next: boolean) => {
+ if (submitting) return;
+ if (!next) {
+ setReason(null);
+ setNote('');
+ }
+ onOpenChange(next);
+ },
+ [onOpenChange, submitting]
+ );
+
+ const handleConfirm = useCallback(async () => {
+ setSubmitting(true);
+ try {
+ const trimmed = note.trim();
+ const done = await onConfirm({
+ reason,
+ note: showNote && trimmed.length > 0 ? trimmed : null,
+ });
+ // A failed request keeps the answer on screen. Wiping a typed note
+ // because Stripe timed out is the fastest way to never get it back.
+ if (done) {
+ setReason(null);
+ setNote('');
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ }, [note, onConfirm, reason, showNote]);
+
+ const endsOn = periodEnd ? new Date(periodEnd).toLocaleDateString() : null;
+
+ return (
+
+
+
+ Cancel your {isTrial ? 'trial' : 'subscription'}?
+
+ {canceledImmediately
+ ? 'This subscription ends immediately. Canceling does not extend access to your workspaces. Automatic collection stops for its open invoices. Eligible current-period subscription invoices are canceled; charges for prior service and other items may still be owed.'
+ : endsOn
+ ? `Everything stays on until ${endsOn}. Nothing is deleted before then, and you will not be charged again.`
+ : 'Everything stays on until the end of the current period. Nothing is deleted before then, and you will not be charged again.'}
+
+
+
+
+
+ What is the main reason?{' '}
+ (optional)
+
+
setReason(value as CancellationReason)}
+ disabled={submitting}
+ >
+ {CANCELLATION_REASONS.map((entry) => (
+
+
+ {entry.label}
+
+ ))}
+
+
+ {showNote ? (
+
+
+ {reason === 'MISSING_FEATURE' ? 'What was missing?' : 'Tell us more'}{' '}
+ (optional)
+
+
+ ) : null}
+
+
+
+ handleOpenChange(false)} disabled={submitting}>
+ Keep {isTrial ? 'trial' : 'subscription'}
+
+
+ {submitting ? (
+ <>
+
+ Cancelling...
+ >
+ ) : (
+ `Cancel ${isTrial ? 'trial' : 'subscription'}`
+ )}
+
+
+
+
+ );
+}
diff --git a/components/ui/radio-group.tsx b/components/ui/radio-group.tsx
index 3e4570c..33ee320 100644
--- a/components/ui/radio-group.tsx
+++ b/components/ui/radio-group.tsx
@@ -27,7 +27,7 @@ function RadioGroupItem({
([
// had a chance to fix it. `incomplete` is not here: nothing has ever been paid on it.
const RETRYING_STRIPE_STATUSES = new Set(['past_due', 'unpaid']);
-// Roughly Stripe's default Smart Retries window. Access follows the retry window rather
-// than the period Stripe advanced when it issued the invoice that was never paid.
+// Application grace period, independent of the Stripe retry settings. An unpaid
+// invoice's future period end does not extend this access window.
const UNPAID_ACCESS_GRACE_DAYS = 14;
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
@@ -146,7 +145,7 @@ export function isPaidTier(
}
// 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
+ // behind on payment. They did once: access stopped at the end of the payment grace 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()) {
@@ -186,7 +185,7 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
// 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`.
+ // stamped with the end of the payment grace window, a cancelled one with `ended_at`.
if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
return false;
}
@@ -197,15 +196,16 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
}
export function getBillingAccessEndDate(subject: BillingAccessSubject) {
- if (subject.billingAccessEndedAt) {
- return subject.billingAccessEndedAt;
- }
-
- if (subject.stripeCurrentPeriodEnd) {
- return subject.stripeCurrentPeriodEnd;
- }
-
- return subject.trialEndsAt;
+ const subscriptionEnd =
+ subject.billingAccessEndedAt ??
+ (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)
+ ? null
+ : subject.stripeCurrentPeriodEnd);
+ // A trial grants access independently of the subscription cutoff. Retention starts
+ // after the last legitimate entitlement, never from an unpaid invoice's period.
+ if (!subscriptionEnd) return subject.trialEndsAt;
+ if (!subject.trialEndsAt) return subscriptionEnd;
+ return new Date(Math.max(subscriptionEnd.getTime(), subject.trialEndsAt.getTime()));
}
export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) {
@@ -251,13 +251,8 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
return { id: { in: [] } };
}
- // Spelled out as positive AND branches instead of `NOT: buildBillingAccessWhereInput(now)`.
- // Prisma renders that NOT as `NOT (status IN (...) OR "trialEndsAt" > $1 OR
- // "stripeCurrentPeriodEnd" > $2)`, and SQL comparisons against NULL are unknown rather than
- // false, so for a row with both dates empty the OR evaluates to NULL and NOT NULL is still
- // NULL: the row is never returned. Both columns empty is exactly what a canceled subscriber
- // looks like (markSubscriptionCanceledByCustomerId clears trialEndsAt, and Stripe no longer
- // reports current_period_end on the subscription), so the cleanup silently matched nobody.
+ // Match the same last entitlement date as getBillingAccessEndDate, with explicit
+ // null branches because SQL comparisons against null do not evaluate to false.
return {
AND: [
{
@@ -265,13 +260,22 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
},
},
- { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }] },
- { OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: now } }] },
+ { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: cleanupCutoff } }] },
{
OR: [
{ billingAccessEndedAt: { lte: cleanupCutoff } },
{
- AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cleanupCutoff } }],
+ billingAccessEndedAt: null,
+ subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
+ stripeCurrentPeriodEnd: { lte: cleanupCutoff },
+ },
+ {
+ billingAccessEndedAt: null,
+ trialEndsAt: { lte: cleanupCutoff },
+ OR: [
+ { stripeCurrentPeriodEnd: null },
+ { subscriptionStatus: { in: [...UNPAID_SUBSCRIPTION_STATUSES] } },
+ ],
},
],
},
@@ -872,8 +876,8 @@ function getInactiveBillingAccessEndedAt(
return new Date(endedAt * 1000);
}
- // 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. The
+ // Still running, just behind on payment: bound access to the application grace period,
+ // not the period end Stripe 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
@@ -886,8 +890,8 @@ function getInactiveBillingAccessEndedAt(
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.
+ // Preserve the existing period-based access policy for paused subscriptions.
+ // The paused status itself is not evidence that this period was paid.
if (subscription.status === 'paused' && currentPeriodEnd) {
return new Date(currentPeriodEnd * 1000);
}
@@ -953,7 +957,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
// subscription created after the cardless trial shipped, and this fallback is
// what stops an abandoned or failed checkout from erasing the days the account
// still had. Legacy card-backed trials keep arriving through the branch above.
- const preservedTrialEnd = effectiveTrialEnd ?? keepUnexpiredTrial(user.trialEndsAt);
+ const preservedTrialEnd = effectiveTrialEnd ?? user.trialEndsAt ?? null;
// The reported period is not proof of payment: Stripe advances it when it issues the
// renewal invoice, paid or not, and it survives cancellation. Access therefore follows
// the status, and every other case gets a cutoff stamped into `billingAccessEndedAt`,
@@ -974,18 +978,11 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
hasEntitledPrice && trialEnd
? (user.billingTrialConsumedAt ?? new Date())
: user.billingTrialConsumedAt,
- // A live trial means access has not ended, whatever the subscription says.
- // Stamping an end date here while the trial runs would date the storage
- // cleanup from today and tell the user their work dies before their trial
- // does. `hasActiveTrial`, not merely a non-null date: a legacy Stripe trial
- // that has already elapsed is a reason to stamp the end date, not to skip it.
- billingAccessEndedAt:
- hasAccess || hasActiveTrial(preservedTrialEnd)
- ? null
- : getInactiveBillingAccessEndedAt(
- subscription,
- hasEntitledPrice ? currentPeriodEnd : null
- ),
+ // Preserve the subscription cutoff even during a trial. The trial has its own
+ // access branch; clearing this cutoff would resurrect an unpaid period later.
+ billingAccessEndedAt: hasAccess
+ ? null
+ : getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
},
});
@@ -1099,7 +1096,7 @@ export async function markSubscriptionCanceledByCustomerId(
// Losing the subscription does not retract a trial that has not run out. The
// account keeps the days it was given and lands back on the trial's own end
// date, which is also what the cancellation copy in settings promises.
- const preservedTrialEnd = keepUnexpiredTrial(user.trialEndsAt);
+ const preservedTrialEnd = user.trialEndsAt ?? null;
const updated = await db.user.update({
where: { id: user.id },
@@ -1111,9 +1108,7 @@ export async function markSubscriptionCanceledByCustomerId(
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
stripeCancelAtPeriodEnd: false,
stripeCancelAt: null,
- billingAccessEndedAt: preservedTrialEnd
- ? null
- : (options?.endedAt ?? options?.currentPeriodEnd ?? new Date()),
+ billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
},
});
@@ -1181,42 +1176,135 @@ export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) {
return UNPAID_STRIPE_STATUSES.has(subscription.status);
}
-/**
- * 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. 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();
- const { data: invoices } = await stripe.invoices.list({
- customer: customerId,
- status: 'open',
- limit: 100,
+/** Only a wholly unpaid, ordinary current-period invoice can be written off. */
+export function isCurrentSubscriptionInvoice(
+ invoice: Stripe.Invoice,
+ subscription: Stripe.Subscription
+): boolean {
+ const latestId =
+ typeof subscription.latest_invoice === 'string'
+ ? subscription.latest_invoice
+ : subscription.latest_invoice?.id;
+ const start = getSubscriptionPeriodStart(subscription);
+ const end = getSubscriptionPeriodEnd(subscription);
+ if (
+ invoice.id !== latestId ||
+ invoice.status !== 'open' ||
+ invoice.amount_paid !== 0 ||
+ !['subscription_cycle', 'subscription_create'].includes(invoice.billing_reason ?? '') ||
+ getInvoiceSubscriptionId(invoice) !== subscription.id ||
+ start === null ||
+ end === null ||
+ !invoice.lines ||
+ invoice.lines.has_more ||
+ invoice.lines.data.length === 0
+ )
+ return false;
+ return invoice.lines.data.every((line) => {
+ const details = line.parent?.subscription_item_details;
+ return (
+ line.parent?.type === 'subscription_item_details' &&
+ details?.subscription === subscription.id &&
+ details.proration === false &&
+ line.pricing?.price_details?.price === getStripePriceId() &&
+ line.period.start === start &&
+ line.period.end === end
+ );
});
+}
+async function listOpenSubscriptionInvoices(customerId: string, subscriptionId: string) {
+ const invoices: Stripe.Invoice[] = [];
+ let startingAfter: string | undefined;
+ while (true) {
+ const page = await getStripe().invoices.list({
+ customer: customerId,
+ status: 'open',
+ limit: 100,
+ ...(startingAfter ? { starting_after: startingAfter } : {}),
+ });
+ invoices.push(
+ ...page.data.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
+ );
+ if (!page.has_more || page.data.length === 0) break;
+ startingAfter = page.data[page.data.length - 1].id;
+ }
+ return invoices;
+}
+
+/**
+ * Stop automatic collection on all open invoices for this subscription. Older or
+ * mixed invoices remain receivables; only a complete current renewal is voided.
+ * Failures propagate so callers can report and retry unfinished cleanup.
+ */
+export async function voidOpenSubscriptionInvoices(
+ customerId: string,
+ subscriptionId: string,
+ subscriptionSnapshot?: Stripe.Subscription
+) {
+ const stripe = getStripe();
+ const subscription =
+ subscriptionSnapshot ?? (await stripe.subscriptions.retrieve(subscriptionId));
+ const customer =
+ typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
+ if (customer !== customerId) throw new Error('Subscription customer mismatch');
const voided: string[] = [];
-
- for (const invoice of invoices) {
- if (!invoice.id) continue;
- if (getInvoiceSubscriptionId(invoice) !== subscriptionId) continue;
-
- try {
+ for (const invoice of await listOpenSubscriptionInvoices(customerId, subscriptionId)) {
+ // Immediate cancellation normally pauses collection too. Explicitly keep retained
+ // receivables paused, including when retrying a partly completed cancellation.
+ if (invoice.auto_advance) await stripe.invoices.update(invoice.id, { auto_advance: false });
+ if (isCurrentSubscriptionInvoice(invoice, subscription)) {
await stripe.invoices.voidInvoice(invoice.id);
voided.push(invoice.id);
- } catch (error) {
- logError(`Failed to void Stripe invoice ${invoice.id}:`, error);
}
}
-
return voided;
}
+/** Cancellation candidates differ from the subscription granting access. */
+export async function findCancelableStripeSubscription(customerId: string) {
+ const subscriptions: Stripe.Subscription[] = [];
+ let startingAfter: string | undefined;
+ while (true) {
+ const page = await getStripe().subscriptions.list({
+ customer: customerId,
+ status: 'all',
+ limit: 100,
+ ...(startingAfter ? { starting_after: startingAfter } : {}),
+ });
+ subscriptions.push(...page.data);
+ if (!page.has_more || page.data.length === 0) break;
+ startingAfter = page.data[page.data.length - 1].id;
+ }
+ const candidate = selectAuthoritativeSubscription(
+ subscriptions.filter(
+ (subscription) =>
+ hasEntitledPrice(subscription, getStripePriceId()) &&
+ LIVE_STRIPE_STATUSES.has(subscription.status) &&
+ (isUnpaidStripeSubscription(subscription) ||
+ (!subscription.cancel_at && !subscription.cancel_at_period_end))
+ )
+ );
+ if (candidate) return candidate;
+ // A failed invoice write must remain reachable after Stripe accepted cancellation.
+ for (const subscription of subscriptions) {
+ if (
+ !['canceled', 'incomplete_expired'].includes(subscription.status) ||
+ !hasEntitledPrice(subscription, getStripePriceId())
+ )
+ continue;
+ const invoices = await listOpenSubscriptionInvoices(customerId, subscription.id);
+ if (
+ invoices.some(
+ (invoice) => invoice.auto_advance || isCurrentSubscriptionInvoice(invoice, subscription)
+ )
+ ) {
+ return subscription;
+ }
+ }
+ return null;
+}
+
/**
* Scoped to a subscription when one is known, the same way `voidOpenSubscriptionInvoices`
* is: a customer can carry an open invoice left behind by a subscription they no longer
diff --git a/lib/cancellation-reasons.ts b/lib/cancellation-reasons.ts
new file mode 100644
index 0000000..44f9b60
--- /dev/null
+++ b/lib/cancellation-reasons.ts
@@ -0,0 +1,43 @@
+// Shared by the cancellation dialog (client) and the cancel route (server), so
+// nothing in here may pull in the database or Stripe.
+
+import type { CancellationReason } from '@prisma/client';
+
+export type { CancellationReason };
+
+/** Longest note the cancellation dialog accepts. Matches the column width. */
+export const CANCELLATION_NOTE_MAX_LENGTH = 500;
+
+/**
+ * The one question asked on the way out, and the order it is asked in.
+ *
+ * Five answers, no default. The list is short so the answer takes one click,
+ * and it is ordered by how often the pattern has shown up in customer replies:
+ * paying accounts that never ran a single real delivery outnumber every other
+ * kind of churn, so "not using it" comes first.
+ */
+export const CANCELLATION_REASONS: ReadonlyArray<{
+ value: CancellationReason;
+ label: string;
+ /** Whether the dialog opens a free-text field under this answer. */
+ askForDetail: boolean;
+}> = [
+ { value: 'NOT_USING', label: 'I am not using it enough', askForDetail: false },
+ { value: 'MISSING_FEATURE', label: 'It is missing something I need', askForDetail: true },
+ {
+ value: 'PRICE_OR_BILLING',
+ label: 'The price or billing did not work for me',
+ askForDetail: false,
+ },
+ { value: 'PROJECT_ENDED', label: 'The project or client work ended', askForDetail: false },
+ { value: 'OTHER', label: 'Something else', askForDetail: true },
+];
+
+export function isCancellationReason(value: unknown): value is CancellationReason {
+ return CANCELLATION_REASONS.some((entry) => entry.value === value);
+}
+
+export function getCancellationReasonLabel(reason: CancellationReason | null): string {
+ if (!reason) return 'No reason given';
+ return CANCELLATION_REASONS.find((entry) => entry.value === reason)?.label ?? reason;
+}
diff --git a/lib/cancellation.ts b/lib/cancellation.ts
new file mode 100644
index 0000000..ecd8d3a
--- /dev/null
+++ b/lib/cancellation.ts
@@ -0,0 +1,239 @@
+import type Stripe from 'stripe';
+import type { CancellationReason } from '@prisma/client';
+import { db } from '@/lib/db';
+import { getStripe } from '@/lib/stripe';
+import {
+ getSubscriptionPeriodEnd,
+ findCancelableStripeSubscription,
+ isUnpaidStripeSubscription,
+ syncStripeCustomerSubscriptions,
+ voidOpenSubscriptionInvoices,
+} from '@/lib/billing';
+import { logError } from '@/lib/logger';
+
+export {
+ CANCELLATION_NOTE_MAX_LENGTH,
+ CANCELLATION_REASONS,
+ getCancellationReasonLabel,
+ isCancellationReason,
+} from '@/lib/cancellation-reasons';
+
+// Stripe keeps its own fixed list of cancellation feedback values. Mirroring
+// ours onto it costs nothing and puts the category next to the subscription in
+// the Stripe dashboard, where it is read during a refund or a support reply.
+// The free-text note deliberately stays on our side: the dialog does not say
+// the text leaves the product, so it does not.
+const STRIPE_FEEDBACK: Record<
+ CancellationReason,
+ Stripe.SubscriptionUpdateParams.CancellationDetails.Feedback
+> = {
+ NOT_USING: 'unused',
+ MISSING_FEATURE: 'missing_features',
+ PRICE_OR_BILLING: 'too_expensive',
+ PROJECT_ENDED: 'other',
+ OTHER: 'other',
+};
+
+export type CancelSubscriptionResult =
+ | {
+ ok: true;
+ periodEnd: Date | null;
+ canceledImmediately: boolean;
+ voidedInvoices: string[];
+ status: Stripe.Subscription.Status;
+ cancelAt: Date | null;
+ }
+ | { ok: false; code: 'NO_SUBSCRIPTION' | 'ALREADY_CANCELING' | 'STRIPE_REJECTED' };
+
+function isStripeInvalidRequest(error: unknown): boolean {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'type' in error &&
+ (error as { type?: unknown }).type === 'StripeInvalidRequestError'
+ );
+}
+
+/** Expire every open Checkout session for this incomplete subscription, including later pages. */
+async function expireSubscriptionCheckout(customerId: string, subscriptionId: string) {
+ const stripe = getStripe();
+ let startingAfter: string | undefined;
+ let expired = false;
+ do {
+ const sessions = await stripe.checkout.sessions.list({
+ customer: customerId,
+ status: 'open',
+ limit: 100,
+ ...(startingAfter ? { starting_after: startingAfter } : {}),
+ });
+ const matching = sessions.data.filter((session) => {
+ const owner = typeof session.customer === 'string' ? session.customer : session.customer?.id;
+ const id =
+ typeof session.subscription === 'string' ? session.subscription : session.subscription?.id;
+ return owner === customerId && id === subscriptionId && session.status === 'open';
+ });
+ await Promise.all(matching.map((session) => stripe.checkout.sessions.expire(session.id)));
+ expired ||= matching.length > 0;
+ startingAfter = sessions.has_more ? sessions.data.at(-1)?.id : undefined;
+ } while (startingAfter);
+ return expired;
+}
+
+/**
+ * Paid subscriptions end at period end; unpaid subscriptions end immediately.
+ * Record the reason before invoice cleanup so a failed cleanup can be retried
+ * on the canceled subscription without losing or duplicating the answer.
+ */
+export async function cancelSubscription(params: {
+ userId: string;
+ reason: CancellationReason | null;
+ note: string | null;
+}): Promise {
+ const requestStartedAt = new Date();
+ const user = await db.user.findUnique({
+ where: { id: params.userId },
+ select: {
+ stripeCustomerId: true,
+ stripeSubscriptionId: true,
+ stripeCancelAtPeriodEnd: true,
+ stripeCurrentPeriodEnd: true,
+ },
+ });
+ if (!user?.stripeCustomerId) return { ok: false, code: 'NO_SUBSCRIPTION' };
+
+ const customerId = user.stripeCustomerId;
+ const original = await findCancelableStripeSubscription(customerId);
+ if (!original) return { ok: false, code: 'NO_SUBSCRIPTION' };
+ const owner = typeof original.customer === 'string' ? original.customer : original.customer.id;
+ if (owner !== customerId) return { ok: false, code: 'NO_SUBSCRIPTION' };
+
+ const subscriptionId = original.id;
+ const cleanupRetry = original.status === 'canceled' || original.status === 'incomplete_expired';
+ const canceledImmediately = cleanupRetry || isUnpaidStripeSubscription(original);
+ if (!canceledImmediately && original.status !== 'active' && original.status !== 'trialing') {
+ return { ok: false, code: 'NO_SUBSCRIPTION' };
+ }
+ if (!canceledImmediately && (original.cancel_at_period_end || original.cancel_at)) {
+ return { ok: false, code: 'ALREADY_CANCELING' };
+ }
+
+ // Retain the paid mirror's conditional claim for double-clicks. It cannot
+ // guard an unpaid cancellation, cleanup retry, or a different subscription.
+ const claimPaidMirror = !canceledImmediately && user.stripeSubscriptionId === subscriptionId;
+ if (claimPaidMirror) {
+ const claimed = await db.user.updateMany({
+ where: {
+ id: params.userId,
+ stripeCustomerId: customerId,
+ stripeSubscriptionId: subscriptionId,
+ stripeCancelAtPeriodEnd: false,
+ },
+ data: { stripeCancelAtPeriodEnd: true },
+ });
+ if (claimed.count === 0) return { ok: false, code: 'ALREADY_CANCELING' };
+ }
+
+ let subscription = original;
+ try {
+ const stripe = getStripe();
+ const cancellationDetails = params.reason ? { feedback: STRIPE_FEEDBACK[params.reason] } : {};
+ if (!cleanupRetry) {
+ if (!canceledImmediately) {
+ subscription = await stripe.subscriptions.update(subscriptionId, {
+ cancel_at_period_end: true,
+ cancellation_details: cancellationDetails,
+ });
+ } else if (
+ original.status === 'incomplete' &&
+ (await expireSubscriptionCheckout(customerId, subscriptionId))
+ ) {
+ // Checkout owns incomplete subscriptions it created. Expiration cancels
+ // them; retrieving gives the response the actual resulting Stripe state.
+ subscription = await stripe.subscriptions.retrieve(subscriptionId);
+ if (subscription.status !== 'canceled' && subscription.status !== 'incomplete_expired') {
+ throw new Error('Checkout expiration did not end the subscription');
+ }
+ } else {
+ subscription = await stripe.subscriptions.cancel(subscriptionId, {
+ cancellation_details: cancellationDetails,
+ });
+ }
+ }
+ } catch (error) {
+ if (claimPaidMirror) {
+ await db.user.updateMany({
+ where: { id: params.userId, stripeSubscriptionId: subscriptionId },
+ data: { stripeCancelAtPeriodEnd: false },
+ });
+ }
+ if (isStripeInvalidRequest(error)) {
+ logError('billing.cancel.rejected', error);
+ return { ok: false, code: 'STRIPE_REJECTED' };
+ }
+ throw error;
+ }
+
+ const periodEndUnix = getSubscriptionPeriodEnd(original);
+ const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd;
+ await db.$transaction(async (tx) => {
+ // The paid mirror's claim does not cover other subscriptions. Serialize every
+ // reason write and reuse only a row written during this request, so a resumed
+ // subscription can record another cancellation without duplicating concurrent calls.
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${subscriptionId}))`;
+ // Cleanup can be retried long after the request that canceled the subscription.
+ // Match that period and, when Stripe reports it, the terminal transition time.
+ // An incomplete expiration may have no ended_at, so its period is the fallback.
+ const existing = await tx.subscriptionCancellation.findFirst({
+ where: {
+ userId: params.userId,
+ stripeSubscriptionId: subscriptionId,
+ ...(cleanupRetry
+ ? {
+ periodEnd,
+ ...(original.ended_at
+ ? { createdAt: { gte: new Date(original.ended_at * 1000) } }
+ : {}),
+ }
+ : { createdAt: { gte: requestStartedAt } }),
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+ if (!existing) {
+ await tx.subscriptionCancellation.create({
+ data: {
+ userId: params.userId,
+ stripeSubscriptionId: subscriptionId,
+ reason: params.reason,
+ note: params.note,
+ periodEnd,
+ },
+ });
+ }
+ });
+
+ let voidedInvoices: string[] = [];
+ try {
+ if (canceledImmediately) {
+ // Eligibility must use the pre-cancellation period, not a shortened one.
+ // Failures propagate; the selector exposes canceled cleanup candidates.
+ voidedInvoices = await voidOpenSubscriptionInvoices(customerId, subscriptionId, original);
+ }
+ } finally {
+ // Reconcile the whole customer even if cleanup failed. Another subscription
+ // may still provide access. Webhooks can repair a failed local sync.
+ try {
+ await syncStripeCustomerSubscriptions(customerId);
+ } catch (error) {
+ logError('billing.cancel.sync', error);
+ }
+ }
+
+ return {
+ ok: true,
+ periodEnd,
+ canceledImmediately,
+ voidedInvoices,
+ status: subscription.status,
+ cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null,
+ };
+}
diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts
index 2e29fa6..91b4a37 100644
--- a/lib/rate-limit.ts
+++ b/lib/rate-limit.ts
@@ -60,6 +60,7 @@ export const RATE_LIMIT_CONFIGS: Record = {
'image-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'voice-upload': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'feedback-submit': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
+ 'billing-cancel': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour per account
'feedback-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'create-project': { windowMs: 60 * 60 * 1000, maxRequests: 20 }, // 20 per hour
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
diff --git a/prisma/migrations/20260908120000_add_subscription_cancellations/migration.sql b/prisma/migrations/20260908120000_add_subscription_cancellations/migration.sql
new file mode 100644
index 0000000..504ae2a
--- /dev/null
+++ b/prisma/migrations/20260908120000_add_subscription_cancellations/migration.sql
@@ -0,0 +1,22 @@
+-- One row per in-app cancellation, carrying the single answer the customer
+-- gave on the way out. Written when the request is made, before Stripe
+-- confirms it, so a reason is never lost to a webhook that arrives late.
+CREATE TYPE "CancellationReason" AS ENUM ('NOT_USING', 'MISSING_FEATURE', 'PRICE_OR_BILLING', 'PROJECT_ENDED', 'OTHER');
+
+CREATE TABLE "subscription_cancellations" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "stripeSubscriptionId" TEXT NOT NULL,
+ "reason" "CancellationReason",
+ "note" VARCHAR(500),
+ "periodEnd" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "subscription_cancellations_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "subscription_cancellations_userId_createdAt_idx" ON "subscription_cancellations"("userId", "createdAt" DESC);
+CREATE INDEX "subscription_cancellations_createdAt_idx" ON "subscription_cancellations"("createdAt" DESC);
+
+ALTER TABLE "subscription_cancellations" ADD CONSTRAINT "subscription_cancellations_userId_fkey"
+ FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index adc4f2d..8590476 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -54,6 +54,7 @@ model User {
sentInvitations Invitation[] @relation("InvitationsSentBy")
acquisition UserAcquisition?
analyticsEvents AnalyticsEvent[]
+ subscriptionCancellations SubscriptionCancellation[]
@@map("users")
}
@@ -609,6 +610,36 @@ model UserFeedback {
@@map("user_feedback")
}
+enum CancellationReason {
+ NOT_USING
+ MISSING_FEATURE
+ PRICE_OR_BILLING
+ PROJECT_ENDED
+ OTHER
+}
+
+// One row per in-app cancellation, written the moment the customer asks for
+// it, not when Stripe later confirms it. The reason is the whole point of the
+// row and it is optional on purpose: the question can be skipped, and a skipped
+// answer still counts as a cancellation whose reason is unknown rather than a
+// cancellation that never happened. Cancellations made in the Stripe portal
+// never produce a row here; the funnel event still records those.
+model SubscriptionCancellation {
+ id String @id @default(cuid())
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ stripeSubscriptionId String
+ reason CancellationReason?
+ note String? @db.VarChar(500)
+ // When access was due to end at the time of the request.
+ periodEnd DateTime?
+ createdAt DateTime @default(now())
+
+ @@index([userId, createdAt(sort: Desc)])
+ @@index([createdAt(sort: Desc)])
+ @@map("subscription_cancellations")
+}
+
model UserFeedbackScreenshot {
id String @id @default(cuid())
feedbackId String
diff --git a/public/landing/compare-cuts-poster.webp b/public/landing/compare-cuts-poster.webp
new file mode 100644
index 0000000..a4a4599
Binary files /dev/null and b/public/landing/compare-cuts-poster.webp differ
diff --git a/public/landing/compare-cuts.mp4 b/public/landing/compare-cuts.mp4
new file mode 100644
index 0000000..4c4479d
Binary files /dev/null and b/public/landing/compare-cuts.mp4 differ
diff --git a/scripts/resync-stripe-subscriptions.ts b/scripts/resync-stripe-subscriptions.ts
index bec7f00..d605ac7 100644
--- a/scripts/resync-stripe-subscriptions.ts
+++ b/scripts/resync-stripe-subscriptions.ts
@@ -1,5 +1,5 @@
/**
- * Re-reads every Stripe customer's live subscription and writes it back onto the user
+ * Re-reads each Stripe customer's authoritative subscription and writes it back onto the user
* through the normal sync path.
*
* Needed once after a Stripe API version change: mirrored fields that moved between
@@ -76,7 +76,7 @@ async function main() {
console.log(`${TAG} Summary${dryRun ? ' (dry run)' : ''}`);
console.log(`${TAG} Customers: ${users.length}`);
console.log(`${TAG} Synced: ${synced}`);
- console.log(`${TAG} Without a live subscription: ${withoutSubscription}`);
+ console.log(`${TAG} Without a subscription: ${withoutSubscription}`);
console.log(`${TAG} Failed: ${failed}`);
}
diff --git a/tests/api/billing-cancel.test.ts b/tests/api/billing-cancel.test.ts
new file mode 100644
index 0000000..0884198
--- /dev/null
+++ b/tests/api/billing-cancel.test.ts
@@ -0,0 +1,679 @@
+import { describe, expect, it, vi } from 'vitest';
+import type Stripe from 'stripe';
+import { BillingSubscriptionStatus, type User } from '@prisma/client';
+import { db } from '@/lib/db';
+import { getStripe } from '@/lib/stripe';
+import { POST as cancelRoute } from '@/app/api/billing/cancel/route';
+import { GET as billingRoute } from '@/app/api/billing/route';
+import { apiRequest, callRoute, readData, readError } from '../helpers/request';
+import { signedInAs, signedOut } from '../helpers/session';
+import { createSubscribedUser, createUser } from '../factories';
+
+const ORIGIN_HEADERS = { origin: 'http://localhost:3000' };
+const ENTITLED_PRICE_ID = 'price_test_openframe_dummy';
+const DAY = 24 * 60 * 60;
+const unix = (offsetSeconds: number) => Math.floor(Date.now() / 1000) + offsetSeconds;
+
+function cancelRequest(body: unknown = {}) {
+ return apiRequest('/api/billing/cancel', {
+ method: 'POST',
+ headers: ORIGIN_HEADERS,
+ body,
+ });
+}
+
+function subscription(user: User, overrides: Partial = {}) {
+ return {
+ id: user.stripeSubscriptionId ?? 'sub_unmirrored',
+ customer: user.stripeCustomerId,
+ status: 'active',
+ created: unix(-30 * DAY),
+ cancel_at_period_end: user.stripeCancelAtPeriodEnd,
+ cancel_at: null,
+ trial_end: null,
+ latest_invoice: 'in_renewal',
+ items: {
+ data: [
+ {
+ id: 'si_plan',
+ price: { id: ENTITLED_PRICE_ID },
+ current_period_start: unix(-10 * DAY),
+ current_period_end: unix(20 * DAY),
+ },
+ ],
+ },
+ ...overrides,
+ } as Stripe.Subscription;
+}
+
+function renewal(sub: Stripe.Subscription, overrides: Partial = {}) {
+ return {
+ id: 'in_renewal',
+ customer: sub.customer,
+ status: 'open',
+ auto_advance: true,
+ amount_paid: 0,
+ billing_reason: 'subscription_cycle',
+ period_start: sub.items.data[0].current_period_start,
+ period_end: sub.items.data[0].current_period_end,
+ parent: { type: 'subscription_details', subscription_details: { subscription: sub.id } },
+ lines: {
+ has_more: false,
+ data: [
+ {
+ id: 'il_renewal',
+ amount: 2000,
+ period: {
+ start: sub.items.data[0].current_period_start,
+ end: sub.items.data[0].current_period_end,
+ },
+ parent: {
+ type: 'subscription_item_details',
+ subscription_item_details: {
+ subscription: sub.id,
+ subscription_item: 'si_plan',
+ proration: false,
+ },
+ },
+ pricing: { type: 'price_details', price_details: { price: ENTITLED_PRICE_ID } },
+ },
+ ],
+ },
+ ...overrides,
+ } as Stripe.Invoice;
+}
+
+// Only Stripe is replaced. Selection, invoice eligibility, reason persistence,
+// paid claims and customer-wide sync use their real implementation and test DB.
+function stubStripe(initial: Stripe.Subscription[], invoices: Stripe.Invoice[] = []) {
+ const subscriptions = initial.map((sub) => structuredClone(sub));
+ const replace = (id: string, patch: Partial) => {
+ const index = subscriptions.findIndex((sub) => sub.id === id);
+ if (index < 0) throw new Error(`Unknown fixture subscription ${id}`);
+ subscriptions[index] = { ...subscriptions[index], ...patch };
+ return structuredClone(subscriptions[index]);
+ };
+ const update = vi.fn(async (id: string, params: Stripe.SubscriptionUpdateParams) =>
+ replace(id, { cancel_at_period_end: params.cancel_at_period_end })
+ );
+ const cancel = vi.fn(async (id: string, params: Stripe.SubscriptionCancelParams) => {
+ void params;
+ return replace(id, {
+ status: 'canceled',
+ cancel_at_period_end: false,
+ canceled_at: unix(0),
+ ended_at: unix(0),
+ });
+ });
+ const list = vi.fn(async (params: Stripe.SubscriptionListParams) => ({
+ data: structuredClone(subscriptions.filter((sub) => sub.customer === params.customer)),
+ has_more: false,
+ }));
+ const sessions: Stripe.Checkout.Session[] = [];
+ const sessionList = vi.fn(async (params: Stripe.Checkout.SessionListParams) => ({
+ data: sessions.filter(
+ (session) => session.status === 'open' && session.customer === params.customer
+ ),
+ has_more: false,
+ }));
+ const expire = vi.fn(async (id: string) => {
+ const session = sessions.find((item) => item.id === id)!;
+ session.status = 'expired';
+ const subId =
+ typeof session.subscription === 'string' ? session.subscription : session.subscription!.id;
+ replace(subId, { status: 'incomplete_expired' });
+ return session;
+ });
+ const voidInvoice = vi.fn(async (id: string) => {
+ const invoice = invoices.find((item) => item.id === id)!;
+ invoice.status = 'void';
+ return invoice;
+ });
+ const invoiceUpdate = vi.fn(async (id: string, params: Stripe.InvoiceUpdateParams) => {
+ const invoice = invoices.find((item) => item.id === id)!;
+ invoice.auto_advance = params.auto_advance ?? invoice.auto_advance;
+ return invoice;
+ });
+ vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
+ subscriptions: {
+ update,
+ cancel,
+ list,
+ retrieve: vi.fn(async (id: string) =>
+ structuredClone(subscriptions.find((sub) => sub.id === id))
+ ),
+ },
+ checkout: { sessions: { list: sessionList, expire } },
+ invoices: {
+ list: vi.fn(async (params: Stripe.InvoiceListParams) => ({
+ data: invoices.filter(
+ (invoice) => invoice.customer === params.customer && invoice.status === 'open'
+ ),
+ has_more: false,
+ })),
+ listLineItems: vi.fn(async (id: string) => invoices.find((item) => item.id === id)!.lines),
+ voidInvoice,
+ update: invoiceUpdate,
+ },
+ });
+ return { update, cancel, list, sessionList, expire, sessions, voidInvoice, invoiceUpdate };
+}
+
+describe('POST /api/billing/cancel', () => {
+ it('returns 401 without a session and leaves subscription and reasons untouched', async () => {
+ const user = await createSubscribedUser();
+ const stripe = stubStripe([subscription(user)]);
+ signedOut();
+ const response = await callRoute(cancelRoute, cancelRequest());
+ expect(response.status).toBe(401);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
+ });
+
+ it('rejects a cross-origin request without changing billing state', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const stripe = stubStripe([subscription(user)]);
+ const response = await callRoute(
+ cancelRoute,
+ apiRequest('/api/billing/cancel', {
+ method: 'POST',
+ headers: { origin: 'https://evil.test' },
+ body: {},
+ })
+ );
+ expect(response.status).toBe(403);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
+ });
+
+ it('refuses an account with no Stripe subscription', async () => {
+ const user = await createUser();
+ signedInAs(user);
+ const stripe = stubStripe([]);
+ expect((await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }))).status).toBe(409);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ });
+
+ it('does not cancel another customer subscription referenced by a stale local mirror or request body', async () => {
+ const owner = await createSubscribedUser();
+ const caller = await createSubscribedUser({ stripeSubscriptionId: 'sub_foreign_stale' });
+ signedInAs(caller);
+ const stripe = stubStripe([subscription(owner, { id: 'sub_foreign_stale' })]);
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({
+ reason: 'OTHER',
+ customerId: owner.stripeCustomerId,
+ subscriptionId: owner.stripeSubscriptionId,
+ })
+ );
+ expect(response.status).toBe(409);
+ expect(stripe.list).toHaveBeenCalledWith(
+ expect.objectContaining({ customer: caller.stripeCustomerId })
+ );
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: owner.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
+ });
+
+ it('rejects a candidate whose Stripe customer does not match the signed-in account', async () => {
+ const user = await createSubscribedUser();
+ const owner = await createSubscribedUser();
+ signedInAs(user);
+ const foreign = subscription(owner);
+ const stripe = stubStripe([foreign]);
+ stripe.list.mockResolvedValueOnce({ data: [foreign], has_more: false });
+ expect((await callRoute(cancelRoute, cancelRequest())).status).toBe(409);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: owner.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
+ });
+
+ it('rejects an already scheduled paid subscription without a reason write', async () => {
+ const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
+ signedInAs(user);
+ const stripe = stubStripe([subscription(user)]);
+ expect((await callRoute(cancelRoute, cancelRequest())).status).toBe(409);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ });
+
+ it.each([
+ { reason: 'RAGE_QUIT' },
+ { reason: 'OTHER', note: 'x'.repeat(501) },
+ { reason: 'OTHER', note: 42 },
+ ])('rejects malformed cancellation input %# before Stripe writes', async (body) => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const stripe = stubStripe([subscription(user)]);
+ expect((await callRoute(cancelRoute, cancelRequest(body))).status).toBe(400);
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ });
+
+ it.each(['active', 'trialing'] as const)(
+ 'schedules %s, persists the trimmed reason and syncs the user',
+ async (status) => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user, { status });
+ const stripe = stubStripe([original]);
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'MISSING_FEATURE', note: ' Bulk upload. ' })
+ );
+ expect(response.status).toBe(200);
+ expect(await readData(response)).toMatchObject({
+ cancelAtPeriodEnd: true,
+ canceledImmediately: false,
+ voidedInvoices: [],
+ status,
+ periodEnd: new Date(original.items.data[0].current_period_end * 1000).toISOString(),
+ });
+ expect(stripe.update).toHaveBeenCalledExactlyOnceWith(user.stripeSubscriptionId, {
+ cancel_at_period_end: true,
+ cancellation_details: { feedback: 'missing_features' },
+ });
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ stripeSubscriptionId: original.id,
+ reason: 'MISSING_FEATURE',
+ note: 'Bulk upload.',
+ });
+ const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(after.stripeCancelAtPeriodEnd).toBe(true);
+ expect(after.subscriptionStatus).toBe(
+ status === 'active' ? BillingSubscriptionStatus.ACTIVE : BillingSubscriptionStatus.TRIALING
+ );
+ expect(after.stripeCurrentPeriodEnd?.getTime()).toBe(
+ original.items.data[0].current_period_end * 1000
+ );
+ }
+ );
+
+ it('finds an unscheduled paid subscription behind an already scheduled authoritative one', async () => {
+ const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
+ signedInAs(user);
+ const scheduled = subscription(user);
+ const other = subscription(user, {
+ id: 'sub_other_paid',
+ cancel_at_period_end: false,
+ created: unix(-60 * DAY),
+ });
+ const stripe = stubStripe([scheduled, other]);
+ const overview = await billingRoute();
+ expect(overview.status).toBe(200);
+ expect(await readData(overview)).toMatchObject({
+ cancelAvailable: true,
+ cancelIsImmediate: false,
+ });
+ const response = await callRoute(cancelRoute, cancelRequest({ reason: 'PROJECT_ENDED' }));
+ expect(response.status).toBe(200);
+ expect(stripe.update).toHaveBeenCalledExactlyOnceWith(
+ other.id,
+ expect.objectContaining({ cancel_at_period_end: true })
+ );
+ expect((await db.subscriptionCancellation.findFirstOrThrow()).stripeSubscriptionId).toBe(
+ other.id
+ );
+ expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeSubscriptionId).toBe(
+ scheduled.id
+ );
+ });
+
+ it.each(['past_due', 'unpaid', 'incomplete'] as const)(
+ 'cancels %s immediately, stops collection and records the reason',
+ async (status) => {
+ const user = await createSubscribedUser({
+ subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
+ });
+ signedInAs(user);
+ const original = subscription(user, { status });
+ const invoice = renewal(original);
+ const stripe = stubStripe([original], [invoice]);
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'PRICE_OR_BILLING', note: ' Stop billing. ' })
+ );
+ expect(response.status).toBe(200);
+ expect(await readData(response)).toMatchObject({
+ canceledImmediately: true,
+ cancelAtPeriodEnd: false,
+ voidedInvoices: [invoice.id],
+ status: 'canceled',
+ });
+ expect(stripe.cancel).toHaveBeenCalledExactlyOnceWith(original.id, {
+ cancellation_details: { feedback: 'too_expensive' },
+ });
+ expect(stripe.update).not.toHaveBeenCalled();
+ expect(invoice.status).toBe('void');
+ expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
+ reason: 'PRICE_OR_BILLING',
+ note: 'Stop billing.',
+ stripeSubscriptionId: original.id,
+ });
+ const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.CANCELED);
+ expect(after.stripeCancelAtPeriodEnd).toBe(false);
+ }
+ );
+
+ it('uses the original period to void the renewal when cancellation shortens the response period', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user, { status: 'past_due' });
+ const invoice = renewal(original);
+ const stripe = stubStripe([original], [invoice]);
+ const cancel = stripe.cancel.getMockImplementation()!;
+ stripe.cancel.mockImplementationOnce(async (id, params) => ({
+ ...(await cancel(id, params)),
+ items: {
+ ...original.items,
+ data: original.items.data.map((item) => ({ ...item, current_period_end: unix(0) })),
+ },
+ }));
+ const response = await callRoute(cancelRoute, cancelRequest());
+ expect(response.status).toBe(200);
+ expect(await readData(response)).toMatchObject({ voidedInvoices: [invoice.id] });
+ expect(invoice.status).toBe('void');
+ });
+
+ it.each(['past_due', 'unpaid'] as const)(
+ 'offers cancellation for already scheduled %s and preserves another paid subscription',
+ async (status) => {
+ const user = await createSubscribedUser({
+ stripeCancelAtPeriodEnd: true,
+ subscriptionStatus:
+ status === 'past_due'
+ ? BillingSubscriptionStatus.PAST_DUE
+ : BillingSubscriptionStatus.UNPAID,
+ });
+ signedInAs(user);
+ const unpaid = subscription(user, { status });
+ const paid = subscription(user, { id: 'sub_still_paid', cancel_at_period_end: true });
+ const stripe = stubStripe([unpaid, paid]);
+ const overview = await billingRoute();
+ expect(overview.status).toBe(200);
+ expect(await readData(overview)).toMatchObject({
+ cancelAvailable: true,
+ cancelIsImmediate: true,
+ });
+ expect((await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }))).status).toBe(
+ 200
+ );
+ expect(stripe.cancel).toHaveBeenCalledExactlyOnceWith(unpaid.id, expect.anything());
+ expect(stripe.update).not.toHaveBeenCalled();
+ const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.ACTIVE);
+ expect(after.stripeSubscriptionId).toBe(paid.id);
+ expect(after.stripeCancelAtPeriodEnd).toBe(true);
+ expect((await db.subscriptionCancellation.findFirstOrThrow()).stripeSubscriptionId).toBe(
+ unpaid.id
+ );
+ }
+ );
+
+ it('expires only the incomplete subscription matching an owned open Checkout session', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user, { status: 'incomplete' });
+ const other = subscription(user, { id: 'sub_other_checkout', status: 'incomplete' });
+ const stripe = stubStripe([original, other]);
+ stripe.sessions.push(
+ {
+ id: 'cs_other',
+ customer: user.stripeCustomerId,
+ subscription: other.id,
+ status: 'open',
+ } as Stripe.Checkout.Session,
+ {
+ id: 'cs_match',
+ customer: user.stripeCustomerId,
+ subscription: { id: original.id },
+ status: 'open',
+ } as Stripe.Checkout.Session
+ );
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'OTHER', note: 'Checkout abandoned' })
+ );
+ expect(response.status).toBe(200);
+ expect(await readData(response)).toMatchObject({
+ canceledImmediately: true,
+ status: 'incomplete_expired',
+ });
+ expect(stripe.sessionList).toHaveBeenCalledWith(
+ expect.objectContaining({ customer: user.stripeCustomerId, status: 'open' })
+ );
+ expect(stripe.expire).toHaveBeenCalledExactlyOnceWith('cs_match');
+ expect(stripe.cancel).not.toHaveBeenCalled();
+ expect(stripe.sessions[0].status).toBe('open');
+ expect((await db.subscriptionCancellation.findFirstOrThrow()).note).toBe('Checkout abandoned');
+ });
+
+ it.each(['past_due', 'incomplete'] as const)(
+ 'retries failed %s cleanup without recanceling or overwriting its reason',
+ async (status) => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user, { status });
+ const invoice = renewal(original);
+ const stripe = stubStripe([original], [invoice]);
+ if (status === 'incomplete') {
+ stripe.sessions.push({
+ id: 'cs_retry',
+ customer: user.stripeCustomerId,
+ subscription: original.id,
+ status: 'open',
+ } as Stripe.Checkout.Session);
+ }
+ stripe.voidInvoice.mockRejectedValueOnce(new Error('Invoice cleanup unavailable'));
+ const first = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'OTHER', note: 'Keep my answer' })
+ );
+ expect(first.status).toBe(500);
+ expect(invoice.status).toBe('open');
+ expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
+ status === 'incomplete'
+ ? BillingSubscriptionStatus.INCOMPLETE_EXPIRED
+ : BillingSubscriptionStatus.CANCELED
+ );
+ const overview = await billingRoute();
+ expect(overview.status).toBe(200);
+ expect(await readData(overview)).toMatchObject({
+ cancelAvailable: true,
+ cancelIsImmediate: true,
+ });
+ const second = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
+ expect(second.status).toBe(200);
+ expect(await readData(second)).toMatchObject({
+ canceledImmediately: true,
+ voidedInvoices: [invoice.id],
+ });
+ expect(stripe.cancel).toHaveBeenCalledTimes(status === 'incomplete' ? 0 : 1);
+ expect(stripe.expire).toHaveBeenCalledTimes(status === 'incomplete' ? 1 : 0);
+ expect(stripe.voidInvoice).toHaveBeenCalledTimes(2);
+ expect(invoice.status).toBe('void');
+ const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({ reason: 'OTHER', note: 'Keep my answer' });
+ }
+ );
+
+ it('stops retrying a retained receivable without voiding it', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user, { status: 'unpaid' });
+ const invoice = renewal(original, { billing_reason: 'manual' });
+ const stripe = stubStripe([original], [invoice]);
+ const response = await callRoute(cancelRoute, cancelRequest());
+ expect(response.status).toBe(200);
+ expect(await readData(response)).toMatchObject({
+ canceledImmediately: true,
+ voidedInvoices: [],
+ });
+ expect(stripe.voidInvoice).not.toHaveBeenCalled();
+ expect(stripe.invoiceUpdate).toHaveBeenCalledWith(
+ invoice.id,
+ expect.objectContaining({ auto_advance: false })
+ );
+ expect(invoice.status).toBe('open');
+ expect(invoice.auto_advance).toBe(false);
+ });
+
+ it.each([{}, { reason: 'PROJECT_ENDED', note: ' ' }])(
+ 'allows skipping feedback and trims empty notes %#',
+ async (body) => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ stubStripe([subscription(user)]);
+ expect((await callRoute(cancelRoute, cancelRequest(body))).status).toBe(200);
+ expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
+ reason: 'reason' in body ? body.reason : null,
+ note: null,
+ });
+ }
+ );
+
+ it('lets only one of two concurrent paid requests through', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const stripe = stubStripe([subscription(user)]);
+ const results = await Promise.all([
+ callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
+ callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
+ ]);
+ expect(results.map((response) => response.status).sort()).toEqual([200, 409]);
+ expect(stripe.update).toHaveBeenCalledTimes(1);
+ expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
+ });
+
+ it('keeps the cancellation and reason when customer-wide sync fails', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user);
+ const stripe = stubStripe([original]);
+ stripe.list
+ .mockResolvedValueOnce({ data: [original], has_more: false })
+ .mockRejectedValueOnce(new Error('Sync unavailable'));
+ expect(
+ (await callRoute(cancelRoute, cancelRequest({ reason: 'PRICE_OR_BILLING' }))).status
+ ).toBe(200);
+ expect((await db.subscriptionCancellation.findFirstOrThrow()).reason).toBe('PRICE_OR_BILLING');
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
+ ).toBe(true);
+ });
+
+ it.each([true, false])(
+ 'releases the paid claim when Stripe rejects the update (invalid request: %s)',
+ async (invalidRequest) => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const stripe = stubStripe([subscription(user)]);
+ const error = invalidRequest
+ ? Object.assign(new Error('No such subscription'), { type: 'StripeInvalidRequestError' })
+ : new Error('Stripe unavailable');
+ stripe.update.mockRejectedValueOnce(error);
+ const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
+ expect(response.status).toBe(invalidRequest ? 409 : 500);
+ if (invalidRequest) expect(await readError(response)).toMatch(/Manage Subscription/);
+ expect(await db.subscriptionCancellation.count()).toBe(0);
+ expect(
+ (await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
+ ).toBe(false);
+ }
+ );
+});
+
+describe('repeated and concurrent cancellation reasons', () => {
+ it('records a new immediate cancellation after an earlier scheduled cancellation was resumed', async () => {
+ const user = await createSubscribedUser();
+ signedInAs(user);
+ const original = subscription(user, { status: 'past_due' });
+ const stripe = stubStripe([original]);
+ await db.subscriptionCancellation.create({
+ data: {
+ userId: user.id,
+ stripeSubscriptionId: original.id,
+ reason: 'PRICE_OR_BILLING',
+ note: 'Previous canceled cycle',
+ createdAt: new Date(Date.now() - 40 * DAY * 1000),
+ periodEnd: new Date(Date.now() - 30 * DAY * 1000),
+ },
+ });
+ const response = await callRoute(
+ cancelRoute,
+ cancelRequest({ reason: 'PROJECT_ENDED', note: 'Current cancellation' })
+ );
+ expect(response.status).toBe(200);
+ expect(stripe.cancel).toHaveBeenCalledTimes(1);
+ const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
+ expect(rows).toHaveLength(2);
+ expect(rows).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ reason: 'PROJECT_ENDED', note: 'Current cancellation' }),
+ ])
+ );
+ });
+
+ it('records only one reason when concurrent requests cancel a nonmirrored paid subscription', async () => {
+ const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
+ signedInAs(user);
+ const scheduled = subscription(user);
+ const other = subscription(user, {
+ id: 'sub_other_paid',
+ cancel_at_period_end: false,
+ created: unix(-60 * DAY),
+ });
+ const stripe = stubStripe([scheduled, other]);
+ const update = stripe.update.getMockImplementation()!;
+ let entered = 0;
+ let release!: () => void;
+ const barrier = new Promise((resolve) => {
+ release = resolve;
+ });
+ const timeout = setTimeout(release, 1000);
+ stripe.update.mockImplementation(async (id, params) => {
+ entered += 1;
+ if (entered === 2) release();
+ await barrier;
+ return update(id, params);
+ });
+ try {
+ const responses = await Promise.all([
+ callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
+ callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
+ ]);
+ expect(entered).toBe(2);
+ expect(responses.map((response) => response.status)).toEqual([200, 200]);
+ expect(
+ await db.subscriptionCancellation.count({
+ where: { userId: user.id, stripeSubscriptionId: other.id },
+ })
+ ).toBe(1);
+ } finally {
+ clearTimeout(timeout);
+ }
+ });
+});
diff --git a/tests/api/billing-entitlement.test.ts b/tests/api/billing-entitlement.test.ts
new file mode 100644
index 0000000..43c050a
--- /dev/null
+++ b/tests/api/billing-entitlement.test.ts
@@ -0,0 +1,224 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type Stripe from 'stripe';
+import {
+ buildBillingAccessWhereInput,
+ buildExpiredBillingWhereInput,
+ getBillingAccessEndDate,
+ getStorageCleanupEligibleAt,
+ hasBillingAccess,
+ isPaidTier,
+ startCardlessTrial,
+ syncStripeCustomerSubscriptions,
+} from '@/lib/billing';
+import { getStripe } from '@/lib/stripe';
+import { db } from '../helpers/db';
+import { createUser } from '../factories';
+
+// Uses the API project's real database and reset hooks. Run only when no other API suite uses it.
+const CUSTOMER_ID = 'cus_entitlement_regression';
+const SUBSCRIPTION_ID = 'sub_entitlement_regression';
+const PRICE_ID = 'price_entitlement_regression';
+const TRIAL_START = new Date('2026-10-01T00:00:00.000Z');
+const TRIAL_END = new Date('2026-10-08T00:00:00.000Z');
+const CANCELED_AT = new Date('2026-10-02T00:00:00.000Z');
+const REPORTED_PERIOD_END = new Date('2026-11-01T00:00:00.000Z');
+
+function subscription(overrides: Partial = {}): Stripe.Subscription {
+ return {
+ id: SUBSCRIPTION_ID,
+ customer: CUSTOMER_ID,
+ status: 'canceled',
+ created: Date.parse('2026-09-01T00:00:00.000Z') / 1000,
+ trial_end: null,
+ ended_at: CANCELED_AT.getTime() / 1000,
+ canceled_at: CANCELED_AT.getTime() / 1000,
+ cancel_at: null,
+ cancel_at_period_end: false,
+ // Deliberately no top-level period: the regression depends on the item-only payload.
+ items: {
+ data: [
+ {
+ price: { id: PRICE_ID },
+ current_period_start: TRIAL_START.getTime() / 1000,
+ current_period_end: REPORTED_PERIOD_END.getTime() / 1000,
+ },
+ ],
+ },
+ ...overrides,
+ } as Stripe.Subscription;
+}
+
+function stubSubscription(value: Stripe.Subscription) {
+ const list = vi.fn(async () => ({ data: [value] }));
+ vi.mocked(getStripe).mockReturnValue({ subscriptions: { list } } as unknown as Stripe);
+ return list;
+}
+
+async function startDeferredTrial() {
+ const user = await createUser({
+ subscriptionStatus: 'PAST_DUE',
+ stripeCustomerId: CUSTOMER_ID,
+ stripeSubscriptionId: SUBSCRIPTION_ID,
+ stripePriceId: PRICE_ID,
+ stripeCurrentPeriodEnd: REPORTED_PERIOD_END,
+ trialEndsAt: null,
+ billingTrialConsumedAt: null,
+ });
+ expect(await startCardlessTrial(user.id, TRIAL_START)).toBe(true);
+ const stored = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(stored.trialEndsAt).toEqual(TRIAL_END);
+ expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
+ return user.id;
+}
+
+async function matchingAccessUsers(userId: string, now: Date) {
+ return db.user.findMany({
+ where: { AND: [{ id: userId }, buildBillingAccessWhereInput(now)] },
+ select: { id: true },
+ });
+}
+
+async function matchingCleanupUsers(userId: string, now: Date) {
+ return db.user.findMany({
+ where: { AND: [{ id: userId }, buildExpiredBillingWhereInput(now)] },
+ select: { id: true },
+ });
+}
+
+describe('billing entitlement and retention after subscription sync', () => {
+ beforeEach(() => {
+ vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
+ vi.stubEnv('STRIPE_PRICE_ID', PRICE_ID);
+ // Mock only Date so PostgreSQL sockets and query timers keep running normally.
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(TRIAL_START);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // Catches restoring `hasAccess || hasActiveTrial(preservedTrialEnd)` when writing the cutoff.
+ it('preserves a deferred trial without granting paid access to the canceled unpaid period', async () => {
+ const userId = await startDeferredTrial();
+ const list = stubSubscription(subscription());
+ vi.setSystemTime(CANCELED_AT);
+
+ await syncStripeCustomerSubscriptions(CUSTOMER_ID);
+
+ expect(list).toHaveBeenCalledWith({ customer: CUSTOMER_ID, status: 'all', limit: 100 });
+ const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
+ expect(stored.subscriptionStatus).toBe('CANCELED');
+ expect(stored.stripeCurrentPeriodEnd).toEqual(REPORTED_PERIOD_END);
+ expect(stored.trialEndsAt).toEqual(TRIAL_END);
+ expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
+ expect(stored.billingAccessEndedAt).toEqual(CANCELED_AT);
+
+ await Promise.all(
+ [
+ { now: CANCELED_AT, expected: true },
+ { now: new Date('2026-10-07T23:59:59.999Z'), expected: true },
+ { now: TRIAL_END, expected: false },
+ { now: new Date('2026-10-09T00:00:00.000Z'), expected: false },
+ ].map(async ({ now, expected }) => {
+ expect(isPaidTier(stored, now)).toBe(false);
+ expect(hasBillingAccess(stored, now)).toBe(expected);
+ expect(await matchingAccessUsers(userId, now)).toEqual(expected ? [{ id: userId }] : []);
+ })
+ );
+ });
+
+ // Catches choosing the raw unpaid period, choosing the earlier expiry, or requiring that raw period to lapse in SQL.
+ it.each([
+ {
+ label: 'trial outlasts the subscription',
+ subscriptionEnd: CANCELED_AT,
+ lastEntitlementEnd: TRIAL_END,
+ cleanupAt: new Date('2026-10-23T00:00:00.000Z'),
+ },
+ {
+ label: 'subscription outlasts the trial',
+ subscriptionEnd: new Date('2026-10-12T00:00:00.000Z'),
+ lastEntitlementEnd: new Date('2026-10-12T00:00:00.000Z'),
+ cleanupAt: new Date('2026-10-27T00:00:00.000Z'),
+ },
+ ])('retains storage until the last legitimate expiry plus 15 days: $label', async (scenario) => {
+ const userId = await startDeferredTrial();
+ stubSubscription(
+ subscription({
+ ended_at: scenario.subscriptionEnd.getTime() / 1000,
+ canceled_at: scenario.subscriptionEnd.getTime() / 1000,
+ })
+ );
+ vi.setSystemTime(scenario.subscriptionEnd);
+
+ await syncStripeCustomerSubscriptions(CUSTOMER_ID);
+
+ const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
+ expect(stored.billingAccessEndedAt).toEqual(scenario.subscriptionEnd);
+ expect(stored.trialEndsAt).toEqual(TRIAL_END);
+ expect(stored.stripeCurrentPeriodEnd).toEqual(REPORTED_PERIOD_END);
+ expect(getBillingAccessEndDate(stored)).toEqual(scenario.lastEntitlementEnd);
+ expect(getStorageCleanupEligibleAt(stored)).toEqual(scenario.cleanupAt);
+ expect(hasBillingAccess(stored, scenario.cleanupAt)).toBe(false);
+ const [before, at] = await Promise.all([
+ matchingCleanupUsers(userId, new Date(scenario.cleanupAt.getTime() - 1)),
+ matchingCleanupUsers(userId, scenario.cleanupAt),
+ ]);
+ expect(before).toEqual([]);
+ expect(at).toEqual([{ id: userId }]);
+ });
+
+ // Catches replacing persisted trial history with keepUnexpiredTrial on a terminal resync.
+ it('keeps expired trial history and the retention deadline across repeated terminal syncs', async () => {
+ const userId = await startDeferredTrial();
+ stubSubscription(subscription());
+ vi.setSystemTime(CANCELED_AT);
+ await syncStripeCustomerSubscriptions(CUSTOMER_ID);
+
+ for (const now of ['2026-10-09T00:00:00.000Z', '2026-10-20T00:00:00.000Z']) {
+ vi.setSystemTime(new Date(now));
+ await syncStripeCustomerSubscriptions(CUSTOMER_ID);
+
+ const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
+ expect(stored.trialEndsAt).toEqual(TRIAL_END);
+ expect(stored.billingTrialConsumedAt).toEqual(TRIAL_START);
+ expect(stored.billingAccessEndedAt).toEqual(CANCELED_AT);
+ expect(isPaidTier(stored)).toBe(false);
+ expect(hasBillingAccess(stored)).toBe(false);
+ expect(getStorageCleanupEligibleAt(stored)).toEqual(new Date('2026-10-23T00:00:00.000Z'));
+ expect(await matchingCleanupUsers(userId, new Date(now))).toEqual([]);
+ }
+
+ expect(await matchingCleanupUsers(userId, new Date('2026-10-23T00:00:00.000Z'))).toEqual([
+ { id: userId },
+ ]);
+ });
+
+ // Catches treating scheduled cancellation as immediate termination of a paid subscription.
+ it('keeps a paid scheduled cancellation accessible after the cardless trial expires', async () => {
+ const userId = await startDeferredTrial();
+ stubSubscription(
+ subscription({
+ status: 'active',
+ ended_at: null,
+ cancel_at_period_end: true,
+ cancel_at: REPORTED_PERIOD_END.getTime() / 1000,
+ })
+ );
+ vi.setSystemTime(CANCELED_AT);
+ await syncStripeCustomerSubscriptions(CUSTOMER_ID);
+
+ const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
+ const afterTrial = new Date('2026-10-09T00:00:00.000Z');
+ expect(stored.subscriptionStatus).toBe('ACTIVE');
+ expect(stored.stripeCancelAtPeriodEnd).toBe(true);
+ expect(stored.billingAccessEndedAt).toBeNull();
+ expect(stored.trialEndsAt).toEqual(TRIAL_END);
+ expect(isPaidTier(stored, afterTrial)).toBe(true);
+ expect(hasBillingAccess(stored, afterTrial)).toBe(true);
+ expect(await matchingAccessUsers(userId, afterTrial)).toEqual([{ id: userId }]);
+ expect(await matchingCleanupUsers(userId, new Date('2026-10-23T00:00:00.000Z'))).toEqual([]);
+ expect(getStorageCleanupEligibleAt(stored)).toEqual(new Date('2026-11-16T00:00:00.000Z'));
+ });
+});
diff --git a/tests/component/cancel-subscription-dialog.test.tsx b/tests/component/cancel-subscription-dialog.test.tsx
new file mode 100644
index 0000000..837c15c
--- /dev/null
+++ b/tests/component/cancel-subscription-dialog.test.tsx
@@ -0,0 +1,146 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
+
+function renderDialog(
+ overrides: {
+ periodEnd?: string | null;
+ isTrial?: boolean;
+ canceledImmediately?: boolean;
+ confirmResult?: boolean;
+ } = {}
+) {
+ const onConfirm = vi.fn(async () => overrides.confirmResult ?? true);
+ const onOpenChange = vi.fn();
+ render(
+
+ );
+ return { onConfirm, onOpenChange };
+}
+
+describe('CancelSubscriptionDialog', () => {
+ it('lists every answer with none selected', () => {
+ renderDialog();
+
+ const radios = screen.getAllByRole('radio');
+ expect(radios).toHaveLength(5);
+ for (const radio of radios) {
+ expect(radio).toHaveAttribute('aria-checked', 'false');
+ }
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+ });
+
+ // The question is skippable: the destructive button works with nothing
+ // chosen, and the handler receives an explicit null rather than a default.
+ it('cancels with no reason when the question is skipped', async () => {
+ const { onConfirm } = renderDialog();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
+
+ expect(onConfirm).toHaveBeenCalledWith({ reason: null, note: null });
+ });
+
+ it('opens the note box only under the answers that ask for detail', async () => {
+ renderDialog();
+
+ await userEvent.click(screen.getByRole('radio', { name: 'I am not using it enough' }));
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('radio', { name: 'It is missing something I need' }));
+ expect(screen.getByLabelText(/What was missing\?/)).toBeInTheDocument();
+ });
+
+ it('sends the chosen reason with a trimmed note', async () => {
+ const { onConfirm } = renderDialog();
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
+ await userEvent.type(screen.getByRole('textbox'), ' Moved the client to Frame.io ');
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
+
+ expect(onConfirm).toHaveBeenCalledWith({
+ reason: 'OTHER',
+ note: 'Moved the client to Frame.io',
+ });
+ });
+
+ // A note typed under "Something else" must not travel with an answer that
+ // never showed the box, or the admin reads a comment about nothing.
+ it('drops the note when the answer changes to one without a note box', async () => {
+ const { onConfirm } = renderDialog();
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
+ await userEvent.type(screen.getByRole('textbox'), 'Some detail');
+ await userEvent.click(screen.getByRole('radio', { name: 'The project or client work ended' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
+
+ expect(onConfirm).toHaveBeenCalledWith({ reason: 'PROJECT_ENDED', note: null });
+ });
+
+ // A failed request must not cost the customer the answer they typed.
+ it('keeps the answer on screen when the confirmation fails', async () => {
+ const { onConfirm } = renderDialog({ confirmResult: false });
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
+ await userEvent.type(screen.getByRole('textbox'), 'Kept this');
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(screen.getByRole('radio', { name: 'Something else' })).toHaveAttribute(
+ 'aria-checked',
+ 'true'
+ );
+ expect(screen.getByRole('textbox')).toHaveValue('Kept this');
+ });
+
+ it('closes without confirming from the keep button', async () => {
+ const { onConfirm, onOpenChange } = renderDialog();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Keep subscription' }));
+
+ expect(onConfirm).not.toHaveBeenCalled();
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('explains immediate unpaid cancellation without promising future access or forgiving prior charges', () => {
+ renderDialog({ canceledImmediately: true });
+
+ expect(screen.getByRole('heading', { name: 'Cancel your subscription?' })).toBeInTheDocument();
+ expect(screen.getByText(/This subscription ends immediately/)).toHaveTextContent(
+ 'Canceling does not extend access to your workspaces.'
+ );
+ expect(screen.getByText(/Automatic collection stops/)).toHaveTextContent(
+ 'charges for prior service and other items may still be owed.'
+ );
+ expect(screen.queryByText(/Everything stays on/)).not.toBeInTheDocument();
+ expect(screen.getAllByRole('radio')).toHaveLength(5);
+ });
+
+ it('retains the scheduled period-end explanation', () => {
+ renderDialog();
+
+ expect(screen.getByText(/Everything stays on until/)).toHaveTextContent(
+ new Date('2026-10-01T00:00:00.000Z').toLocaleDateString()
+ );
+ expect(screen.queryByText(/This subscription ends immediately/)).not.toBeInTheDocument();
+ });
+
+ it('names the trial instead of the subscription while still trialing', () => {
+ renderDialog({ isTrial: true });
+
+ expect(screen.getByRole('heading', { name: 'Cancel your trial?' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Cancel trial' })).toBeInTheDocument();
+ });
+});
diff --git a/tests/component/settings-billing.test.tsx b/tests/component/settings-billing.test.tsx
index 4f1c4a9..177afd1 100644
--- a/tests/component/settings-billing.test.tsx
+++ b/tests/component/settings-billing.test.tsx
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import SettingsPage from '@/app/(dashboard)/settings/settings-page-client';
function renderScheduledCancellation(status: 'ACTIVE' | 'TRIALING') {
@@ -70,3 +71,86 @@ describe('scheduled cancellation in billing settings', () => {
);
});
});
+
+function renderPastDue(hasBillingAccess: boolean) {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string) => {
+ if (url !== '/api/billing') return { ok: false };
+ return {
+ ok: true,
+ json: async () => ({
+ data: {
+ isEnabled: true,
+ isConfigured: true,
+ checkoutAvailable: false,
+ portalAvailable: true,
+ cancelAvailable: true,
+ cancelIsImmediate: true,
+ needsPaymentFix: true,
+ openInvoice: null,
+ workspaceCreation: { canCreateWorkspace: hasBillingAccess, canStartTrial: false },
+ subscription: {
+ status: 'PAST_DUE',
+ label: 'Past due',
+ hasActiveSubscription: false,
+ hasRecoverableSubscription: true,
+ hasActiveTrial: false,
+ hasBillingAccess,
+ currentPeriodEnd: '2026-10-08T12:00:00Z',
+ trialEndsAt: null,
+ cancelAtPeriodEnd: false,
+ cancelAt: null,
+ },
+ },
+ }),
+ };
+ })
+ );
+ render( );
+}
+
+describe('past-due access in billing settings', () => {
+ it('opens immediate unpaid cancellation copy and waits for confirmation', async () => {
+ const user = userEvent.setup();
+ renderPastDue(true);
+
+ await user.click(await screen.findByRole('button', { name: 'Cancel subscription' }));
+
+ const dialog = within(screen.getByRole('dialog'));
+ expect(dialog.getByText(/This subscription ends immediately\./)).toHaveTextContent(
+ 'Canceling does not extend access to your workspaces.'
+ );
+ expect(dialog.getByText(/Automatic collection stops/)).toHaveTextContent(
+ 'charges for prior service and other items may still be owed.'
+ );
+ expect(dialog.queryByText(/Everything stays on until/)).not.toBeInTheDocument();
+ expect(dialog.getByRole('button', { name: 'Cancel subscription' })).toBeEnabled();
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false);
+
+ await user.click(dialog.getByRole('button', { name: 'Keep subscription' }));
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false);
+ });
+
+ it('shows continued workspace access during payment grace without an active trial', async () => {
+ renderPastDue(true);
+
+ expect(
+ await screen.findByText('Workspace access remains available while you resolve your payment.')
+ ).toBeInTheDocument();
+ expect(screen.queryByText('Billing access has ended.')).not.toBeInTheDocument();
+ expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument();
+ });
+
+ it('shows access has ended when payment grace has expired and no trial remains', async () => {
+ renderPastDue(false);
+
+ expect(await screen.findByText('Billing access has ended.')).toBeInTheDocument();
+ expect(
+ screen.queryByText('Workspace access remains available while you resolve your payment.')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/setup/api.ts b/tests/setup/api.ts
index c91582e..4a30cce 100644
--- a/tests/setup/api.ts
+++ b/tests/setup/api.ts
@@ -127,7 +127,12 @@ vi.mock('@/lib/stripe', async (importOriginal) => {
...actual,
getStripe: vi.fn(() => ({
customers: { create: vi.fn(async () => ({ id: 'cus_test_default' })) },
- subscriptions: { list: vi.fn(async () => ({ data: [] })) },
+ subscriptions: {
+ list: vi.fn(async () => ({ data: [] })),
+ update: vi.fn(() => {
+ throw new Error('stripe.subscriptions.update was not stubbed for this test');
+ }),
+ },
checkout: {
sessions: { create: vi.fn(async () => ({ url: 'https://stripe.test/checkout' })) },
},
diff --git a/tests/setup/db-global.ts b/tests/setup/db-global.ts
index 0655199..3be7dfd 100644
--- a/tests/setup/db-global.ts
+++ b/tests/setup/db-global.ts
@@ -70,6 +70,7 @@ const REVIEWED_MIGRATIONS = [
'20260818120000_add_upload_reservation_purpose',
'20260820120000_add_comment_images',
'20260822120000_add_video_subtitles',
+ '20260908120000_add_subscription_cancellations',
];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
diff --git a/tests/unit/lib/billing-invoices.test.ts b/tests/unit/lib/billing-invoices.test.ts
new file mode 100644
index 0000000..ab8ad14
--- /dev/null
+++ b/tests/unit/lib/billing-invoices.test.ts
@@ -0,0 +1,409 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type Stripe from 'stripe';
+import {
+ findCancelableStripeSubscription,
+ isCurrentSubscriptionInvoice,
+ voidOpenSubscriptionInvoices,
+} from '@/lib/billing';
+
+const stripe = vi.hoisted(() => ({
+ subscriptions: { list: vi.fn(), retrieve: vi.fn() },
+ invoices: { list: vi.fn(), update: vi.fn(), voidInvoice: vi.fn() },
+}));
+
+vi.mock('@/lib/db', () => ({ db: {} }));
+vi.mock('@/lib/stripe', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getStripe: () => stripe,
+}));
+
+const START = 1_800_000_000;
+const END = 1_802_592_000;
+
+function subscription(overrides: Record = {}): Stripe.Subscription {
+ return {
+ id: 'sub_target',
+ customer: 'cus_target',
+ status: 'past_due',
+ created: 100,
+ latest_invoice: 'in_current',
+ cancel_at: null,
+ cancel_at_period_end: false,
+ items: {
+ data: [
+ {
+ price: { id: 'price_plan' },
+ current_period_start: START,
+ current_period_end: END,
+ },
+ ],
+ },
+ ...overrides,
+ } as unknown as Stripe.Subscription;
+}
+
+function line(overrides: Record = {}) {
+ return {
+ id: 'il_plan',
+ amount: 1900,
+ period: { start: START, end: END },
+ parent: {
+ type: 'subscription_item_details',
+ subscription_item_details: { subscription: 'sub_target', proration: false },
+ },
+ pricing: { price_details: { price: 'price_plan' } },
+ ...overrides,
+ };
+}
+
+function invoice(overrides: Record = {}): Stripe.Invoice {
+ return {
+ id: 'in_current',
+ customer: 'cus_target',
+ status: 'open',
+ amount_paid: 0,
+ amount_due: 1900,
+ billing_reason: 'subscription_cycle',
+ auto_advance: true,
+ parent: { subscription_details: { subscription: 'sub_target' } },
+ lines: { data: [line()], has_more: false },
+ ...overrides,
+ } as unknown as Stripe.Invoice;
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.stubEnv('STRIPE_PRICE_ID', 'price_plan');
+ stripe.subscriptions.retrieve.mockResolvedValue(subscription());
+ stripe.subscriptions.list.mockResolvedValue({ data: [], has_more: false });
+ stripe.invoices.list.mockResolvedValue({ data: [], has_more: false });
+ stripe.invoices.update.mockResolvedValue({});
+ stripe.invoices.voidInvoice.mockResolvedValue({});
+});
+
+describe('subscription invoice cleanup', () => {
+ it.each(['subscription_cycle', 'subscription_create'])(
+ 'voids a complete unpaid current %s invoice and returns its id',
+ async (billingReason) => {
+ const current = invoice({ billing_reason: billingReason });
+ stripe.invoices.list.mockResolvedValue({ data: [current], has_more: false });
+
+ expect(isCurrentSubscriptionInvoice(current, subscription())).toBe(true);
+ await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).resolves.toEqual([
+ 'in_current',
+ ]);
+
+ expect(stripe.subscriptions.retrieve).toHaveBeenCalledExactlyOnceWith('sub_target');
+ expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith('in_current', {
+ auto_advance: false,
+ });
+ expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
+ }
+ );
+
+ const retainedInvoices: [string, () => Stripe.Invoice][] = [
+ [
+ 'a different start with the current end',
+ () =>
+ invoice({
+ lines: { data: [line({ period: { start: START - 86400, end: END } })], has_more: false },
+ }),
+ ],
+ [
+ 'a different end with the current start',
+ () =>
+ invoice({
+ lines: { data: [line({ period: { start: START, end: END + 86400 } })], has_more: false },
+ }),
+ ],
+ ['an older invoice id', () => invoice({ id: 'in_old' })],
+ [
+ 'an older service period',
+ () =>
+ invoice({
+ lines: {
+ data: [line({ period: { start: START - 2_592_000, end: START } })],
+ has_more: false,
+ },
+ }),
+ ],
+ [
+ 'a mixed invoice containing a manual charge',
+ () =>
+ invoice({
+ lines: {
+ data: [
+ line(),
+ line({
+ id: 'il_manual',
+ parent: { type: 'invoice_item_details', invoice_item_details: {} },
+ }),
+ ],
+ has_more: false,
+ },
+ }),
+ ],
+ [
+ 'a proration',
+ () =>
+ invoice({
+ lines: {
+ data: [
+ line({
+ parent: {
+ type: 'subscription_item_details',
+ subscription_item_details: { subscription: 'sub_target', proration: true },
+ },
+ }),
+ ],
+ has_more: false,
+ },
+ }),
+ ],
+ ['a partly paid invoice', () => invoice({ amount_paid: 500, amount_due: 1400 })],
+ ['a truncated line item page', () => invoice({ lines: { data: [line()], has_more: true } })],
+ [
+ 'a different price',
+ () =>
+ invoice({
+ lines: {
+ data: [line({ pricing: { price_details: { price: 'price_other' } } })],
+ has_more: false,
+ },
+ }),
+ ],
+ [
+ 'a line belonging to a different subscription',
+ () =>
+ invoice({
+ lines: {
+ data: [
+ line({
+ parent: {
+ type: 'subscription_item_details',
+ subscription_item_details: { subscription: 'sub_other', proration: false },
+ },
+ }),
+ ],
+ has_more: false,
+ },
+ }),
+ ],
+ ['an invoice with no lines', () => invoice({ lines: { data: [], has_more: false } })],
+ ['a subscription update invoice', () => invoice({ billing_reason: 'subscription_update' })],
+ ];
+
+ it.each(retainedInvoices)('retains %s but pauses collection', async (_label, makeInvoice) => {
+ const retained = makeInvoice();
+ stripe.invoices.list.mockResolvedValue({ data: [retained], has_more: false });
+
+ expect(isCurrentSubscriptionInvoice(retained, subscription())).toBe(false);
+ await expect(
+ voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription())
+ ).resolves.toEqual([]);
+ expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith(retained.id, {
+ auto_advance: false,
+ });
+ expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
+ expect(stripe.subscriptions.retrieve).not.toHaveBeenCalled();
+ });
+
+ it('traverses invoice pages using the last unfiltered id and leaves foreign invoices untouched', async () => {
+ stripe.invoices.list
+ .mockResolvedValueOnce({
+ data: [
+ invoice({ id: 'in_old' }),
+ invoice({
+ id: 'in_foreign',
+ parent: { subscription_details: { subscription: 'sub_other' } },
+ }),
+ ],
+ has_more: true,
+ })
+ .mockResolvedValueOnce({ data: [invoice()], has_more: false });
+
+ await expect(
+ voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription())
+ ).resolves.toEqual(['in_current']);
+ expect(stripe.invoices.list.mock.calls).toEqual([
+ [{ customer: 'cus_target', status: 'open', limit: 100 }],
+ [{ customer: 'cus_target', status: 'open', limit: 100, starting_after: 'in_foreign' }],
+ ]);
+ expect(stripe.invoices.update.mock.calls).toEqual([
+ ['in_old', { auto_advance: false }],
+ ['in_current', { auto_advance: false }],
+ ]);
+ expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
+ });
+
+ it('voids a current invoice even when collection was already paused before a retry', async () => {
+ stripe.invoices.list.mockResolvedValue({
+ data: [invoice({ auto_advance: false })],
+ has_more: false,
+ });
+
+ await expect(
+ voidOpenSubscriptionInvoices(
+ 'cus_target',
+ 'sub_target',
+ subscription({
+ status: 'canceled',
+ latest_invoice: { id: 'in_current' },
+ })
+ )
+ ).resolves.toEqual(['in_current']);
+ expect(stripe.invoices.update).not.toHaveBeenCalled();
+ expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
+ });
+
+ it.each(['retrieve', 'list', 'update', 'voidInvoice'] as const)(
+ 'propagates the Stripe %s failure rather than claiming successful cleanup',
+ async (operation) => {
+ const failure = new Error(`Stripe ${operation} failed`);
+ stripe.invoices.list.mockResolvedValue({ data: [invoice()], has_more: false });
+ const failingCall =
+ operation === 'retrieve' ? stripe.subscriptions.retrieve : stripe.invoices[operation];
+ failingCall.mockRejectedValueOnce(failure);
+
+ await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).rejects.toBe(failure);
+ expect(failingCall).toHaveBeenCalledTimes(1);
+ if (operation !== 'voidInvoice') expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
+ }
+ );
+
+ it('rejects a subscription snapshot belonging to another customer before touching invoices', async () => {
+ await expect(
+ voidOpenSubscriptionInvoices(
+ 'cus_target',
+ 'sub_target',
+ subscription({
+ customer: { id: 'cus_other' },
+ })
+ )
+ ).rejects.toThrow('Subscription customer mismatch');
+ expect(stripe.invoices.list).not.toHaveBeenCalled();
+ expect(stripe.invoices.update).not.toHaveBeenCalled();
+ expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
+ });
+});
+
+describe('cancellation candidate selection', () => {
+ it.each([
+ { cancel_at: END, cancel_at_period_end: false },
+ { cancel_at: null, cancel_at_period_end: true },
+ ])(
+ 'skips a scheduled paid subscription ($cancel_at, $cancel_at_period_end) for an older unscheduled one',
+ async (schedule) => {
+ stripe.subscriptions.list
+ .mockResolvedValueOnce({
+ data: [
+ subscription({
+ id: 'sub_newer',
+ status: 'active',
+ created: 200,
+ ...schedule,
+ }),
+ ],
+ has_more: true,
+ })
+ .mockResolvedValueOnce({
+ data: [
+ subscription({
+ id: 'sub_older',
+ status: 'active',
+ created: 100,
+ }),
+ ],
+ has_more: false,
+ });
+
+ expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_older');
+ expect(stripe.subscriptions.list.mock.calls).toEqual([
+ [{ customer: 'cus_target', status: 'all', limit: 100 }],
+ [{ customer: 'cus_target', status: 'all', limit: 100, starting_after: 'sub_newer' }],
+ ]);
+ expect(stripe.invoices.list).not.toHaveBeenCalled();
+ }
+ );
+
+ it.each(['past_due', 'unpaid', 'incomplete'] as const)(
+ 'still selects a scheduled %s subscription for immediate cancellation',
+ async (status) => {
+ stripe.subscriptions.list.mockResolvedValue({
+ data: [subscription({ status, cancel_at: END, cancel_at_period_end: true })],
+ has_more: false,
+ });
+
+ expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target');
+ expect(stripe.invoices.list).not.toHaveBeenCalled();
+ }
+ );
+
+ it.each([
+ ['a current invoice still awaiting void', { auto_advance: false }],
+ ['an older invoice still collecting', { id: 'in_old', auto_advance: true }],
+ ])('selects a canceled subscription with %s for cleanup retry', async (_label, overrides) => {
+ stripe.subscriptions.list.mockResolvedValue({
+ data: [subscription({ status: 'canceled' })],
+ has_more: false,
+ });
+ stripe.invoices.list.mockResolvedValue({ data: [invoice(overrides)], has_more: false });
+
+ expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target');
+ expect(stripe.invoices.list).toHaveBeenCalledExactlyOnceWith({
+ customer: 'cus_target',
+ status: 'open',
+ limit: 100,
+ });
+ expect(stripe.invoices.update).not.toHaveBeenCalled();
+ expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
+ });
+
+ it('does not offer cleanup again for retained paused debt or a foreign invoice', async () => {
+ stripe.subscriptions.list.mockResolvedValue({
+ data: [subscription({ status: 'canceled' })],
+ has_more: false,
+ });
+ stripe.invoices.list.mockResolvedValue({
+ data: [
+ invoice({ id: 'in_old', auto_advance: false }),
+ invoice({
+ id: 'in_foreign',
+ parent: { subscription_details: { subscription: 'sub_other' } },
+ }),
+ ],
+ has_more: false,
+ });
+
+ await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull();
+ });
+
+ it.each(['active', 'canceled'] as const)(
+ 'ignores a %s subscription for a different product',
+ async (status) => {
+ stripe.subscriptions.list.mockResolvedValue({
+ data: [
+ subscription({
+ status,
+ items: { data: [{ price: { id: 'price_other' } }] },
+ }),
+ ],
+ has_more: false,
+ });
+
+ await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull();
+ expect(stripe.invoices.list).not.toHaveBeenCalled();
+ }
+ );
+
+ it('propagates invoice lookup failures while finding canceled cleanup candidates', async () => {
+ const failure = new Error('Stripe invoice lookup failed');
+ stripe.subscriptions.list.mockResolvedValue({
+ data: [subscription({ status: 'canceled' })],
+ has_more: false,
+ });
+ stripe.invoices.list.mockRejectedValueOnce(failure);
+
+ await expect(findCancelableStripeSubscription('cus_target')).rejects.toBe(failure);
+ });
+});
diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts
index 6f1b4db..31450f4 100644
--- a/tests/unit/lib/billing.test.ts
+++ b/tests/unit/lib/billing.test.ts
@@ -449,7 +449,7 @@ describe('hasBillingAccess', () => {
});
describe('getBillingAccessEndDate', () => {
- it('prefers billingAccessEndedAt over every other date', () => {
+ it('keeps an independent trial beyond the subscription cutoff', () => {
const ended = new Date('2026-01-10T00:00:00Z');
const result = getBillingAccessEndDate(
subject({
@@ -458,10 +458,10 @@ describe('getBillingAccessEndDate', () => {
trialEndsAt: new Date('2026-03-01T00:00:00Z'),
})
);
- expect(result).toBe(ended);
+ expect(result).toEqual(new Date('2026-03-01T00:00:00Z'));
});
- it('falls back to stripeCurrentPeriodEnd when billing has not been marked ended', () => {
+ it('keeps a longer trial when billing has not been marked ended', () => {
const periodEnd = new Date('2026-02-01T00:00:00Z');
const result = getBillingAccessEndDate(
subject({
@@ -469,7 +469,7 @@ describe('getBillingAccessEndDate', () => {
trialEndsAt: new Date('2026-03-01T00:00:00Z'),
})
);
- expect(result).toBe(periodEnd);
+ expect(result).toEqual(new Date('2026-03-01T00:00:00Z'));
});
it('falls back to trialEndsAt when there is no paid period', () => {
@@ -535,36 +535,18 @@ describe('buildBillingAccessWhereInput', () => {
});
describe('buildExpiredBillingWhereInput', () => {
- it('states the lack of access positively and requires the fifteen day grace to have elapsed', () => {
- const cutoff = new Date('2025-12-31T00:00:00.000Z');
-
- expect(buildExpiredBillingWhereInput(NOW)).toEqual({
- AND: [
- { subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } },
- { OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: NOW } }] },
- { OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: NOW } }] },
- {
- OR: [
- { billingAccessEndedAt: { lte: cutoff } },
- { AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cutoff } }] },
- ],
- },
- ],
+ it('requires the entire trial retention window before deleting an inactive account', () => {
+ const where = buildExpiredBillingWhereInput(NOW) as {
+ AND: Array>;
+ };
+ expect(where.AND[0]).toEqual({ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } });
+ expect(where.AND[1]).toEqual({
+ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: new Date('2025-12-31T00:00:00Z') } }],
});
});
- // The NOT form this replaced could not express "no access" for a row whose date columns are
- // empty, because SQL turns a comparison against NULL into unknown rather than false. Every
- // branch has to name NULL explicitly instead. tests/api/expired-billing-cleanup.test.ts
- // proves it against a real database; this only guards the shape.
- it('admits a null trial and a null period end as expired rather than skipping the row', () => {
- const where = buildExpiredBillingWhereInput(NOW) as {
- AND: Array<{ OR?: Array> }>;
- };
- expect(where.AND[1].OR).toContainEqual({ trialEndsAt: null });
- expect(where.AND[2].OR).toContainEqual({ stripeCurrentPeriodEnd: null });
- });
-
+ // Real SQL behavior with null dates and future unpaid periods is covered by the
+ // API cleanup and entitlement suites; the unit check guards the retention boundary.
it('matches nobody when Stripe is disabled, because nothing can expire without billing', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
expect(buildExpiredBillingWhereInput(NOW)).toEqual({ id: { in: [] } });
@@ -1818,10 +1800,13 @@ describe('database backed billing helpers', () => {
stripeSub({ status: 'incomplete', current_period_end: null })
);
- expect(updateData().billingAccessEndedAt).toBeNull();
+ expect(updateData().billingAccessEndedAt).toEqual(NOW);
+ expect(getStorageCleanupEligibleAt(subject(updateData()))).toEqual(
+ new Date(NOW.getTime() + 19 * DAY_MS)
+ );
});
- it('clears a trial that has already run out', async () => {
+ it('preserves an expired trial for the storage retention calculation', async () => {
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS),
@@ -1832,7 +1817,7 @@ describe('database backed billing helpers', () => {
stripeSub({ status: 'incomplete', current_period_end: null })
);
- expect(updateData().trialEndsAt).toBeNull();
+ expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS));
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
});
@@ -1896,7 +1881,8 @@ describe('database backed billing helpers', () => {
await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBe(trialEndsAt);
- expect(updateData().billingAccessEndedAt).toBeNull();
+ expect(updateData().billingAccessEndedAt).toEqual(NOW);
+ expect(hasBillingAccess(subject(updateData()), NOW)).toBe(true);
});
it('still ends access when the trial has already run out', async () => {
@@ -1907,7 +1893,7 @@ describe('database backed billing helpers', () => {
await markSubscriptionCanceledByCustomerId('cus_1');
- expect(updateData().trialEndsAt).toBeNull();
+ expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS));
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
});