20 Commits
Author SHA1 Message Date
Yusuf İpek 9ca56c4226 Merge pull request #77 from yusufipk/fix/stripe-billing-lifecycle
fix(billing): stop collection after unpaid cancellation and preserve paid access
2026-09-08 16:56:06 +03:00
yusufipek 1c24336b6a fix(billing): serialize reconciliation and record accepted cancellations 2026-09-08 16:32:11 +03:00
yusufipek 57061b5a5d fix(billing): preserve entitlements and bound cancellation cleanup
Integrate the latest cancellation-reason flow from master. Keep paid periods and independent trials intact, stop collection without erasing historical or mixed receivables, and make scheduled and partial cancellations recoverable. Add regression coverage for invoice boundaries, entitlement expiry, cancellation selection and concurrent reason writes.
2026-09-08 15:49:37 +03:00
Yusuf İpek 6d283f0b60 Merge pull request #80 from yusufipk/codex/video-review-metadata
fix(seo): clarify video review metadata
2026-09-08 15:22:40 +03:00
yusufipek 7019676398 fix(seo): clarify video review metadata 2026-09-08 15:18:53 +03:00
yusufipek 2c890314c1 fix(billing): show paid cancellation dates instead of leftover trial 2026-09-08 15:17:19 +03:00
Yusuf İpek 36b2e5c905 Merge pull request #79 from yusufipk/claude/landing-page-design-refresh-c78363
feat(landing): refresh product-focused landing page
2026-09-08 15:08:33 +03:00
yusufipek a8607e8254 feat(landing): refresh product-focused landing page 2026-09-08 15:04:25 +03:00
Yusuf İpek 53c7899659 Merge pull request #78 from yusufipk/claude/lifecycle-messages-cancellation-a0142a
feat(billing): cancel in-app with a one-question reason
2026-09-08 14:21:18 +03:00
Yusuf İpek 6ad22508fe test(api): classify the billing cancel route in the auth matrix 2026-09-08 14:12:46 +03:00
yusufipek c0809e23bd test(billing): cover the Stripe field locations this change depends on
Every subscription fixture in the suite carries current_period_end at the top
level, which is the location the pinned API version no longer uses. So the item
level read, the reason this code exists, had no test at all and every other case
passed through the legacy fallback instead.

Covers both locations for the period and for the invoice's subscription link,
the null case the webhook relies on to leave a one-off invoice alone, and the
retry-window bound through the payload shape production actually sends.
2026-09-08 14:07:48 +03:00
Yusuf İpek 7aeda83eb6 feat(billing): cancel in-app with a one-question reason
Add a "Cancel subscription" button beside "Manage Subscription" in Settings.
It opens a dialog with one optional question (five answers, no default, a
note box under the two that want detail), then schedules the Stripe
subscription to end at the close of the current period without a trip to
the portal. The answer is stored in a new subscription_cancellations table
and shown, with an all-time tally, on the admin dashboard; the category is
also mirrored onto Stripe's cancellation feedback, the free text stays local.

The cancel route claims the local cancel flag with a conditional update
before calling Stripe, so two racing requests cannot both write a reason
row, and hands the claim back when Stripe refuses. A subscription Stripe no
longer knows answers 409 with a pointer to the portal instead of a 500. The
route carries an account-keyed rate limit on top of the shared IP one.

Two fixes found on the way: the pinned Stripe API version reports
current_period_end on the subscription item rather than the subscription, so
the sync stored null for every period end; a shared helper now reads the item
first. And the RadioGroup styles targeted a data-checked attribute radix
never writes, so the checked state was invisible in the light theme.
2026-09-08 14:05:16 +03:00
yusufipek d5cb288719 test(api): register the billing cancel route in the auth matrix
The api suite enumerates every route module under app/api and requires each
one to be classified as session-guarded or deliberately public. The new cancel
route was neither, so the suite failed on an unclassified module and on the
module count. It takes the same shape as the other billing routes: a session
plus a same-origin header.
2026-09-08 13:58:54 +03:00
yusufipek 85855a6d52 fix(billing): close the gaps the code and security reviews found
Follow-up on the same change, from a high-effort code review and security
review run over the diff.

Access gate:
- Scope both period-end guards to the period-end branch of hasBillingAccess
  instead of the top of the function. A cutoff is only ever cleared by a Stripe
  sync, so checking it first meant a stale one from a lapsed subscription
  outranked a freshly started cardless trial: the account burned its
  once-per-account trial and got nothing. buildBillingAccessWhereInput mirrors
  the same shape.
- Refuse a period end carried by an INCOMPLETE or INCOMPLETE_EXPIRED
  subscription, the rejection isPaidTier already makes. The cutoff is
  deliberately left null while a trial is live, so a trial user who abandoned a
  checkout kept the failed subscription's period once the trial ran out.
- Apply the cutoff in isPaidTier too, so it cannot say "paid" for a period
  where hasBillingAccess says access is over. That split left a locked-out
  account with no banner explaining it and able to create workspaces it could
  not then see. Both callers now select the field.

Lifecycle:
- Cancel through syncStripeCustomerSubscriptions rather than writing the single
  cancelled subscription, so a customer holding a second live subscription is
  not locked out of an account they are still being billed for.
- Ignore invoice events with no subscription. A one-off invoice against a
  customer record left by an abandoned checkout was marking the account
  canceled and booking a churn event for a subscription that never existed.
- Fall back to a window measured from now when a subscription behind on payment
  reports no period start, rather than falling through to "access ended", which
  locked out the customer that branch exists to keep in.
- Let a paused subscription run to its period end; it was being ended at once.
- Collapse BLOCKING_STRIPE_STATUSES into LIVE_STRIPE_STATUSES and include
  incomplete. The two sets were identical, which offered a Cancel button that
  always returned "No subscription to cancel" and left the Stripe-side checkout
  guard weaker than the mirror check it backs up.

UI and ops:
- cancelIsImmediate from the API, so the confirmation says what will actually
  happen to an incomplete subscription instead of promising the period end.
- The access banner reads "ended on" once the date has passed.
- The resync script selects the way the write path selects, over the customer's
  whole set. Filtering to live subscriptions first made the dry run disagree
  with the real run and skipped canceled and incomplete customers entirely,
  who are exactly the stale mirrors the script exists for.

Three existing tests asserted the behaviour this fixes: that a canceled
subscription keeps access to its reported period end, and that the cutoff is
ignored while that period runs. Both rest on the premise that a future period
end means a paid period, which is what is not true. They now assert the bound,
alongside new cases for the retry window, the trial-versus-stale-cutoff
ordering, and a never-paid period.
2026-09-08 13:51:59 +03:00
yusufipek fe1faeced4 fix(billing): pin the Stripe API version and stop unpaid periods granting access
The Stripe client was built without an apiVersion, so the SDK followed whatever
version it shipped with. Two fields moved in the Basil API version: the billing
period went from the subscription onto its items, and the invoice link to its
subscription went under parent.subscription_details. Both reads returned
undefined without failing, which left stripeCurrentPeriodEnd null for every
subscriber and left the app with no invoice handling at all. A customer whose
card failed saw nothing about the invoice that was still retrying, and a
cancellation did nothing to stop those retries.

- Pin the API version, with `satisfies` so an SDK bump is a compile error here
  before it is a null read in production.
- Read the period off subscription items and the subscription off invoice
  parents, keeping the legacy fields as a fallback for older payloads.
- Handle invoice.paid, invoice.payment_failed, invoice.voided and
  invoice.marked_uncollectible through the existing customer-wide resync, so
  the mirror reflects payment health during dunning rather than after it.
- Add an in-app cancellation route: at period end when the subscription is
  paid, immediately plus voiding the open invoices when it is not, because
  cancelling alone does not stop collection on an invoice already issued.
- Ask Stripe, not just the local mirror, before opening checkout.
- Show the open invoice, the retry date and a payment-method-update shortcut in
  settings, and put a confirmation in front of cancellation.

Access no longer rests on the reported period alone. Stripe advances the period
when it issues the renewal invoice, paid or not, and the period survives
cancellation, so once the period field started being read correctly that check
would have handed a full free month to anyone whose renewal failed, and the new
cancel route would have let them void the invoice and keep the month. Access now
follows the subscription status, billingAccessEndedAt is enforced as a hard
cutoff in both hasBillingAccess and the query that mirrors it, and a subscription
behind on payment keeps access for Stripe's retry window rather than for the
period it never paid for.
2026-09-08 13:30:42 +03:00
Yusuf İpek d5d2f0535e Merge pull request #75 from yusufipk/claude/openframe-mouse-overlay-bug-66603b
fix(player): re-arm the cursor idle timer when playback changes
2026-09-08 13:23:44 +03:00
Yusuf İpek 42b974e422 Merge pull request #76 from yusufipk/claude/webm-to-wav-conversion-0169ff
feat(voice-notes): convert recordings to WAV on download
2026-09-08 13:20:12 +03:00
yusufipek 7357f24831 refactor(player): share the cursor idle hook and stop waking on element pauses
Move the cursor idle logic into useCursorIdle and use it from both the video
page and the compare page, which carried its own copy. Only pointer activity
wakes the cursor now: a pause/play pair the element emits on its own (a
rebuffer, a source switch) leaves the idle state alone instead of bringing
the chrome back for a second. The fullscreen-while-paused arming is gone,
nothing rendered it. The scrub test now leaves the player before pressing
the timeline, as the real layout forces.
2026-09-08 13:17:34 +03:00
yusufipek 142dee0c06 feat(voice-notes): convert recordings to WAV on download
MediaRecorder gives us WebM/Opus, and that is exactly what we stored and served back. Browsers and desktop players read it, but no editing suite does: DaVinci Resolve, Premiere and Final Cut all refuse the container outright, so a voice note downloaded byte-for-byte was useless to the editor it was recorded for.

The browser already decodes these formats in order to play them, so the conversion costs nothing but a RIFF header. lib/audio-to-wav.ts decodes through an OfflineAudioContext and writes interleaved 16-bit PCM. This runs at download time rather than at record time, so the stored object stays the small Opus file, uploads keep their 10MB limit, and self-hosted installs gain no server-side ffmpeg dependency.

Voice comments had no download control at all, only a play button, so reviewers were saving files straight off the audio element and getting a bare UUID. They now get a download button on both comments and replies, named after the reviewer and the frame they were talking about, gated on the same download permission as the video and asset downloads. Audio assets get a WAV / Original menu.

Files already in an editable container (wav, mp3, m4a) are handed over untouched: audio assets are not only recordings, and decoding an uploaded master back out would resample it to 48 kHz and requantise it to 16 bit for no gain. When a browser cannot decode the stored format at all, the original is saved and the user is told.
2026-09-08 13:15:28 +03:00
yusufipek b2070c1030 fix(player): re-arm the cursor idle timer when playback changes
The idle countdown that hides the cursor and the play/pause overlay was only
started from mousemove. A cursor that stayed still over the player while a
click, a key or a scrub release started playback never got a countdown, so
the overlay stayed on the video until the mouse moved again.

Arm the timer from one place and rerun it whenever playback or fullscreen
changes, keeping the cursor-over-player state in a ref so the same rule
applies from every entry point.
2026-09-08 12:55:30 +03:00
53 changed files with 5335 additions and 991 deletions
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle';
import Hls from 'hls.js';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
@@ -128,8 +129,7 @@ export default function CompareVersionsPageClient({
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [cursorIdle, setCursorIdle] = useState(false);
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying);
const timelineRef = useRef<HTMLDivElement>(null);
// Map of versionId -> YT.Player or Custom Adapter
@@ -409,57 +409,6 @@ export default function CompareVersionsPageClient({
handleSeek(currentTimeRef.current);
}, [isDragging, handleSeek]);
const handleVideoMouseMove = useCallback(() => {
setCursorIdle(false);
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
}
if (isPlaying) {
cursorIdleTimerRef.current = setTimeout(() => {
setCursorIdle(true);
}, 1000);
}
}, [isPlaying]);
const handleVideoMouseLeave = useCallback(() => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
}
setCursorIdle(false);
}, []);
useEffect(() => {
return () => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
}
};
}, []);
useEffect(() => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
cursorIdleTimerRef.current = null;
}
if (!isPlaying) {
setCursorIdle(false);
return;
}
cursorIdleTimerRef.current = setTimeout(() => {
setCursorIdle(true);
}, 1000);
return () => {
if (cursorIdleTimerRef.current) {
clearTimeout(cursorIdleTimerRef.current);
cursorIdleTimerRef.current = null;
}
};
}, [isPlaying]);
// Keyboard shortcuts (matching video page)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -30,6 +30,27 @@ import {
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
import type { CancellationReason } from '@/lib/cancellation-reasons';
/** Convert Stripe API units separately from the currency's display precision. */
function formatInvoiceAmount(amountInMinorUnits: number, currency: string) {
const currencyCode = currency.toUpperCase();
try {
const formatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
});
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
// Stripe retains two-decimal API amounts for ISK/UGX despite their zero-decimal display.
// https://docs.stripe.com/currencies#special-cases
const apiExponent = currencyCode === 'ISK' || currencyCode === 'UGX' ? 2 : fractionDigits;
return formatter.format(amountInMinorUnits / 10 ** apiExponent);
} catch {
return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`;
}
}
interface NotificationSettings {
telegramChatId: string | null;
@@ -49,6 +70,17 @@ interface BillingOverview {
status: 'disabled' | 'ready' | 'misconfigured';
checkoutAvailable: boolean;
portalAvailable: boolean;
cancelAvailable: boolean;
cancelIsImmediate: boolean;
needsPaymentFix: boolean;
openInvoice: {
id: string | null;
hostedInvoiceUrl: string | null;
amountDue: number;
currency: string;
attemptCount: number;
nextPaymentAttempt: string | null;
} | null;
subscription: {
status: string;
label: string;
@@ -148,7 +180,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
const [testing, setTesting] = useState<string | null>(null);
const [billing, setBilling] = useState<BillingOverview | null>(null);
const [billingLoading, setBillingLoading] = useState(true);
const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | 'trial' | null>(null);
const [billingAction, setBillingAction] = useState<
'checkout' | 'portal' | 'trial' | 'cancel' | null
>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null);
const [storageLoading, setStorageLoading] = useState(true);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -255,12 +290,16 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
);
const handleBillingRedirect = useCallback(
async (endpoint: '/api/billing/checkout' | '/api/billing/portal') => {
async (
endpoint: '/api/billing/checkout' | '/api/billing/portal',
flow?: 'payment_method_update'
) => {
setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal');
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flow ? { flow } : {}),
});
const data = await res.json();
@@ -302,6 +341,49 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
}
}, [showMessage]);
const handleCancelSubscription = useCallback(
async (input: { reason: CancellationReason | null; note: string | null }) => {
setBillingAction('cancel');
try {
const res = await fetch('/api/billing/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json();
if (!res.ok) {
showMessage('error', data.error || 'Failed to cancel subscription');
return false;
}
setCancelDialogOpen(false);
const billingRes = await fetch('/api/billing');
if (billingRes.ok) {
setBilling((await billingRes.json()).data);
}
const endsOn = data.data?.periodEnd
? new Date(data.data.periodEnd).toLocaleDateString()
: null;
showMessage(
'success',
data.data?.canceledImmediately
? 'Subscription canceled. Automatic collection has stopped for its open invoices. Charges for prior service may still be owed.'
: endsOn
? `Your subscription ends on ${endsOn}. You keep full access until then.`
: 'Your subscription ends at the close of the current period.'
);
return true;
} catch {
showMessage('error', 'Failed to cancel subscription');
return false;
} finally {
setBillingAction(null);
}
},
[showMessage]
);
if (loading) {
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
@@ -412,13 +494,15 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<p className="text-sm text-muted-foreground mt-1">
{billing.subscription.hasActiveSubscription
? hasScheduledCancellation
? billing.subscription.hasActiveTrial
? billing.subscription.status === 'TRIALING'
? 'Trial canceled. Access remains active until the trial ends.'
: 'Subscription canceled. Access remains active until the end of the current billing period.'
: 'Paid account with workspace creation unlocked.'
: billing.subscription.hasActiveTrial
? 'Free trial, no card required.'
: 'Billing access has ended.'}
: billing.subscription.hasBillingAccess
? 'Workspace access remains available while you resolve your payment.'
: 'Billing access has ended.'}
</p>
</div>
<Badge
@@ -432,11 +516,11 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
!billing.subscription.hasActiveSubscription ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
Your latest payment didn&apos;t go through. Update your payment method to keep
your subscription starting a new one would create a duplicate.
your subscription. Starting a new one would create a duplicate.
</p>
) : null}
{billing.subscription.hasActiveTrial &&
{billing.subscription.status === 'TRIALING' &&
billing.subscription.trialEndsAt &&
hasScheduledCancellation ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
@@ -455,7 +539,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
{hasScheduledCancellation && billing.subscription.cancelAt ? (
<p className="text-sm text-muted-foreground">
Cancellation was scheduled on{' '}
Cancellation takes effect on{' '}
{new Date(billing.subscription.cancelAt).toLocaleDateString()}.
</p>
) : null}
@@ -481,10 +565,54 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</div>
) : null}
{billing.openInvoice ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-4 space-y-2">
<p className="text-sm font-semibold text-destructive">
A payment of{' '}
{formatInvoiceAmount(
billing.openInvoice.amountDue,
billing.openInvoice.currency
)}{' '}
did not go through
</p>
<p className="text-sm text-muted-foreground">
{billing.openInvoice.attemptCount} attempt
{billing.openInvoice.attemptCount === 1 ? '' : 's'} so far
{billing.openInvoice.nextPaymentAttempt
? `, next one on ${new Date(billing.openInvoice.nextPaymentAttempt).toLocaleDateString()}`
: ''}
. Update your payment method or pay the invoice to stop the retries, or cancel
to stop them for good.
</p>
{billing.subscription.billingAccessEndedAt ? (
<p className="text-sm text-muted-foreground">
{new Date(billing.subscription.billingAccessEndedAt) > new Date()
? `Access to your workspaces continues until ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.`
: `Access to your workspaces ended on ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. Paying this invoice restores it.`}
</p>
) : null}
{billing.openInvoice.hostedInvoiceUrl ? (
<a
href={billing.openInvoice.hostedInvoiceUrl}
target="_blank"
rel="noreferrer"
className="inline-block text-sm font-medium text-primary hover:underline"
>
View and pay this invoice
</a>
) : null}
</div>
) : null}
<div className="flex flex-col sm:flex-row gap-3">
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (
<Button
onClick={() => handleBillingRedirect('/api/billing/portal')}
onClick={() =>
handleBillingRedirect(
'/api/billing/portal',
billing.needsPaymentFix ? 'payment_method_update' : undefined
)
}
disabled={billingAction !== null}
>
{billingAction === 'portal' ? (
@@ -498,7 +626,22 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
'Update Payment Method'
)}
</Button>
) : (
) : null}
{/* Beside the portal button, not inside it. Someone who came to
cancel should not have to guess that "Manage" is the way, and
the portal cannot ask why they are leaving. */}
{billing.cancelAvailable ? (
<Button
variant="ghost"
className="text-muted-foreground"
onClick={() => setCancelDialogOpen(true)}
disabled={billingAction !== null}
>
Cancel subscription
</Button>
) : null}
{billing.subscription.hasRecoverableSubscription &&
billing.portalAvailable ? null : (
<>
{billing.workspaceCreation.canStartTrial ? (
<Button onClick={handleStartTrial} disabled={billingAction !== null}>
@@ -534,6 +677,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</CardContent>
</Card>
{billing ? (
<CancelSubscriptionDialog
open={cancelDialogOpen}
onOpenChange={setCancelDialogOpen}
periodEnd={billing.subscription.currentPeriodEnd}
isTrial={billing.subscription.status === 'TRIALING'}
canceledImmediately={billing.cancelIsImmediate}
onConfirm={handleCancelSubscription}
/>
) : null}
{billing?.subscription.hasBillingAccess && (
<Card className="mb-6">
<CardHeader>
+10
View File
@@ -1,4 +1,5 @@
import { Metadata } from 'next';
import { Suspense } from 'react';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
@@ -10,6 +11,7 @@ import {
} from '@/lib/admin-stats';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button';
import { CancellationReasonsCard } from '@/components/admin/cancellation-reasons-card';
import {
Users,
Folder,
@@ -289,6 +291,14 @@ export default async function AdminDashboardPage() {
</div>
</>
)}
{/* Outside the `stripeStats` guard on purpose: the answers live in our own
table and must stay readable while a Stripe outage blanks the cards above. */}
{isStripeBillingEnabled() && (
<Suspense fallback={null}>
<CancellationReasonsCard />
</Suspense>
)}
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import {
CANCELLATION_NOTE_MAX_LENGTH,
cancelSubscription,
isCancellationReason,
} from '@/lib/cancellation';
import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimit, rateLimitHeaders } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { isStripeConfigured } from '@/lib/stripe';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger';
/**
* In-app cancellation: end unpaid subscriptions immediately, schedule paid
* subscriptions for period end, and record the optional reason.
*
* This exists beside the Stripe portal rather than instead of it. The portal
* cannot ask a question of our own, and by the time its webhook arrives the
* customer has already left the page. Both fields are optional: skipping the
* question is allowed and must never stand between someone and cancelling.
*/
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
if (!isTrustedSameOriginRequest(request)) {
return apiErrors.forbidden('Invalid request origin');
}
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
// A second limit keyed on the account. The IP-keyed one above is shared by
// every mutating route and, without TRUSTED_PROXY_MODE, by every caller,
// so it is the wrong thing to lean on for the one action a leaving
// customer most needs to succeed.
const config = RATE_LIMIT_CONFIGS['billing-cancel'];
const limit = await checkRateLimit(session.user.id, 'billing-cancel', config);
if (!limit.allowed) {
return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
...rateLimitHeaders(limit, config.maxRequests),
},
});
}
if (!isStripeFeatureEnabled()) {
return apiErrors.badRequest('Stripe billing is disabled by this host');
}
if (!isStripeConfigured()) {
return apiErrors.internalError('Stripe billing is not configured');
}
const body = await request.json().catch(() => null);
const rawReason = body?.reason ?? null;
if (rawReason !== null && !isCancellationReason(rawReason)) {
return apiErrors.badRequest('Unknown cancellation reason');
}
const rawNote = body?.note;
if (rawNote !== undefined && rawNote !== null && typeof rawNote !== 'string') {
return apiErrors.badRequest('Note must be text');
}
const trimmedNote = typeof rawNote === 'string' ? rawNote.trim() : '';
if (trimmedNote.length > CANCELLATION_NOTE_MAX_LENGTH) {
return apiErrors.badRequest(
`Note must be at most ${CANCELLATION_NOTE_MAX_LENGTH} characters`
);
}
const result = await cancelSubscription({
userId: session.user.id,
reason: rawReason,
note: trimmedNote.length > 0 ? trimmedNote : null,
});
if (!result.ok) {
switch (result.code) {
case 'ALREADY_CANCELING':
return apiErrors.conflict(
'Your subscription is already set to end at the close of this period'
);
case 'STRIPE_REJECTED':
return apiErrors.conflict(
'Stripe could not find this subscription. Open Manage Subscription to see its current state.'
);
default:
return apiErrors.conflict('There is no active subscription to cancel');
}
}
const response = successResponse({
cancelAtPeriodEnd: !result.canceledImmediately,
canceledImmediately: result.canceledImmediately,
status: result.status,
cancelAt: result.cancelAt?.toISOString() ?? null,
voidedInvoices: result.voidedInvoices,
periodEnd: result.periodEnd?.toISOString() ?? null,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('billing.cancel', error);
return apiErrors.internalError('Failed to cancel subscription');
}
}
+16 -1
View File
@@ -1,7 +1,11 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getOrCreateStripeCustomerId, getStripeCheckoutState } from '@/lib/billing';
import {
findBlockingStripeSubscription,
getOrCreateStripeCustomerId,
getStripeCheckoutState,
} from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
@@ -57,6 +61,17 @@ export async function POST(request: NextRequest) {
const stripe = getStripe();
const priceId = getStripePriceId();
const customerId = await getOrCreateStripeCustomerId(session.user.id);
// The guard above reads the local mirror, which can be stale or cleared: the incident
// that prompted this had a customer holding three subscriptions at once because the
// mirror said there were none. Stripe is the one that knows.
const blockingSubscription = await findBlockingStripeSubscription(customerId);
if (blockingSubscription) {
return apiErrors.badRequest(
'A subscription already exists for this account. Manage it from the billing portal.'
);
}
const appOrigin = getAppOrigin(request);
const checkoutSession = await stripe.checkout.sessions.create({
+41 -4
View File
@@ -1,4 +1,5 @@
import { NextRequest } from 'next/server';
import type Stripe from 'stripe';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing';
@@ -19,6 +20,40 @@ function getAppOrigin(request: NextRequest) {
return request.nextUrl.origin;
}
async function readRequestedFlow(request: NextRequest) {
try {
const body = await request.json();
return body?.flow === 'payment_method_update' ? 'payment_method_update' : null;
} catch {
return null;
}
}
async function createPortalSession(
stripe: Stripe,
customer: string,
returnUrl: string,
flow: 'payment_method_update' | null
) {
if (flow === 'payment_method_update') {
try {
return await stripe.billingPortal.sessions.create({
customer,
return_url: returnUrl,
flow_data: { type: 'payment_method_update' },
});
} catch (error) {
// The portal configuration may not expose this flow; the plain portal still works.
logError('Falling back to the default Stripe portal flow:', error);
}
}
return stripe.billingPortal.sessions.create({
customer,
return_url: returnUrl,
});
}
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'mutate');
@@ -47,10 +82,12 @@ export async function POST(request: NextRequest) {
}
const stripe = getStripe();
const portalSession = await stripe.billingPortal.sessions.create({
customer: billing.subscription.stripeCustomerId,
return_url: `${getAppOrigin(request)}/settings`,
});
const portalSession = await createPortalSession(
stripe,
billing.subscription.stripeCustomerId,
`${getAppOrigin(request)}/settings`,
await readRequestedFlow(request)
);
const response = successResponse({ url: portalSession.url });
return withCacheControl(response, 'private, no-store');
+51 -2
View File
@@ -1,6 +1,12 @@
import { BillingSubscriptionStatus } from '@prisma/client';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing';
import {
findCancelableStripeSubscription,
isUnpaidStripeSubscription,
getBillingOverview,
getOpenInvoiceForCustomer,
} from '@/lib/billing';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe';
import { logError } from '@/lib/logger';
@@ -15,12 +21,55 @@ export async function GET() {
const billing = await getBillingOverview(session.user.id);
const isEnabled = isStripeFeatureEnabled();
const isConfigured = hasStripeRuntimeConfig();
// Invoice details are only needed when the current subscription is behind on payment.
const needsPaymentFix =
billing.subscription.status === BillingSubscriptionStatus.PAST_DUE ||
billing.subscription.status === BillingSubscriptionStatus.UNPAID;
const openInvoice =
isStripeConfigured() && needsPaymentFix && billing.subscription.stripeCustomerId
? await getOpenInvoiceForCustomer(
billing.subscription.stripeCustomerId,
billing.subscription.stripeSubscriptionId
)
: null;
const cancelable =
isStripeConfigured() && billing.subscription.stripeCustomerId
? await findCancelableStripeSubscription(billing.subscription.stripeCustomerId)
: null;
const response = successResponse({
isEnabled,
isConfigured,
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription,
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
// A customer id alone is not enough: it is created on the first checkout attempt, so
// someone who abandoned checkout would be sent to an empty portal.
portalAvailable:
isStripeConfigured() &&
Boolean(billing.subscription.stripeCustomerId) &&
(billing.subscription.hasRecoverableSubscription ||
Boolean(billing.subscription.stripeSubscriptionId)),
// An already scheduled unpaid subscription still needs immediate cancellation.
// A different unscheduled subscription may also remain after an earlier cancel.
cancelAvailable: Boolean(cancelable),
needsPaymentFix,
cancelIsImmediate: Boolean(
cancelable &&
(isUnpaidStripeSubscription(cancelable) ||
['canceled', 'incomplete_expired'].includes(cancelable.status))
),
openInvoice: openInvoice
? {
id: openInvoice.id,
hostedInvoiceUrl: openInvoice.hostedInvoiceUrl,
amountDue: openInvoice.amountDue,
currency: openInvoice.currency,
attemptCount: openInvoice.attemptCount,
nextPaymentAttempt: openInvoice.nextPaymentAttempt?.toISOString() ?? null,
}
: null,
subscription: {
status: billing.subscription.status,
label: billing.subscription.label,
+5 -1
View File
@@ -167,7 +167,11 @@ export async function POST(request: NextRequest) {
// owner too. A workspace admin on somebody else's trial hits the same ceiling.
const owner = await db.user.findUnique({
where: { id: workspace.ownerId },
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
select: {
subscriptionStatus: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
});
if (owner && !isPaidTier(owner)) {
+20 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
import type Stripe from 'stripe';
import { syncStripeCustomerSubscriptions } from '@/lib/billing';
import { getInvoiceSubscriptionId, syncStripeCustomerSubscriptions } from '@/lib/billing';
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
import { logError } from '@/lib/logger';
@@ -55,6 +55,25 @@ export async function POST(request: NextRequest) {
}
break;
}
// Invoice events carry the payment health of a subscription earlier and more
// reliably than the subscription events alone. Without them a customer whose card
// failed keeps the mirror of a healthy subscription until Stripe eventually gives
// up, which is the whole dunning window spent showing them the wrong state.
case 'invoice.paid':
case 'invoice.payment_failed':
case 'invoice.voided':
case 'invoice.marked_uncollectible': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = getCustomerId(invoice.customer);
// Only subscription invoices. A one-off invoice against a customer record left
// behind by an abandoned checkout has no subscription, and syncing on it would
// find an empty list, mark the account canceled and book a churn event for a
// subscription that never existed.
if (customerId && getInvoiceSubscriptionId(invoice)) {
await syncStripeCustomerSubscriptions(customerId);
}
break;
}
default:
break;
}
+4 -4
View File
@@ -26,8 +26,8 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
metadataBase: new URL(seoConfig.url),
title: {
default: `${seoConfig.name} | ${seoConfig.title}`,
template: `%s | ${seoConfig.name}`,
default: `${seoConfig.name} - ${seoConfig.title}`,
template: `%s - ${seoConfig.name}`,
},
description: seoConfig.description,
applicationName: seoConfig.name,
@@ -51,7 +51,7 @@ export const metadata: Metadata = {
locale: 'en_US',
siteName: seoConfig.name,
url: seoConfig.url,
title: `${seoConfig.name} | ${seoConfig.title}`,
title: `${seoConfig.name} - ${seoConfig.title}`,
description: seoConfig.description,
images: [
{
@@ -64,7 +64,7 @@ export const metadata: Metadata = {
},
twitter: {
card: 'summary_large_image',
title: `${seoConfig.name} | ${seoConfig.title}`,
title: `${seoConfig.name} - ${seoConfig.title}`,
description: seoConfig.description,
images: [seoConfig.ogImage],
},
+584 -684
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Why people cancelled</CardTitle>
<UserX className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent className="space-y-4">
{total === 0 ? (
<p className="text-sm text-muted-foreground">
No in-app cancellations yet. Cancellations made in the Stripe portal do not show up
here.
</p>
) : (
<>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
{CANCELLATION_REASONS.map((entry) => (
<span key={entry.value} className="text-muted-foreground">
{entry.label}:{' '}
<span className="font-medium text-foreground">
{countByReason.get(entry.value) ?? 0}
</span>
</span>
))}
<span className="text-muted-foreground">
Skipped the question: <span className="font-medium text-foreground">{skipped}</span>
</span>
</div>
<ul className="divide-y">
{recent.map((row) => (
<li key={row.id} className="py-2 text-sm">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<span className="font-medium">
{row.user.name || 'Anonymous'}{' '}
<span className="font-normal text-xs text-muted-foreground">
{row.user.email}
</span>
</span>
<span className="text-xs text-muted-foreground">
{format(row.createdAt, 'MMM dd, yyyy')}
{row.periodEnd
? ` · billing period ends ${format(row.periodEnd, 'MMM dd')}`
: ''}
</span>
</div>
<p className="text-muted-foreground">{getCancellationReasonLabel(row.reason)}</p>
{row.note ? (
<p className="mt-0.5 whitespace-pre-wrap break-words">{row.note}</p>
) : null}
</li>
))}
</ul>
{total > RECENT_LIMIT ? (
<p className="text-xs text-muted-foreground">
Showing the latest {RECENT_LIMIT} of {total}.
</p>
) : null}
</>
)}
</CardContent>
</Card>
);
}
@@ -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<boolean>;
}
/**
* 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<CancellationReason | null>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Cancel your {isTrial ? 'trial' : 'subscription'}?</DialogTitle>
<DialogDescription>
{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.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm font-medium">
What is the main reason?{' '}
<span className="font-normal text-muted-foreground">(optional)</span>
</p>
<RadioGroup
value={reason ?? ''}
onValueChange={(value) => setReason(value as CancellationReason)}
disabled={submitting}
>
{CANCELLATION_REASONS.map((entry) => (
<Label
key={entry.value}
htmlFor={`cancel-reason-${entry.value}`}
className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-sm font-normal transition-colors hover:bg-accent/50 has-[[data-state=checked]]:border-primary/50 has-[[data-state=checked]]:bg-primary/5"
>
<RadioGroupItem value={entry.value} id={`cancel-reason-${entry.value}`} />
{entry.label}
</Label>
))}
</RadioGroup>
{showNote ? (
<div className="space-y-1.5">
<Label htmlFor="cancel-reason-note" className="text-sm">
{reason === 'MISSING_FEATURE' ? 'What was missing?' : 'Tell us more'}{' '}
<span className="font-normal text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="cancel-reason-note"
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={CANCELLATION_NOTE_MAX_LENGTH}
rows={3}
disabled={submitting}
className="text-sm md:text-sm"
/>
</div>
) : null}
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}>
Keep {isTrial ? 'trial' : 'subscription'}
</Button>
<Button variant="destructive" onClick={handleConfirm} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Cancelling...
</>
) : (
`Cancel ${isTrial ? 'trial' : 'subscription'}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -27,7 +27,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'border-input text-primary dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 data-checked:bg-primary data-checked:border-primary flex size-4 rounded-full focus-visible:ring-1 aria-invalid:ring-1 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
'border-input text-primary dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 data-[state=checked]:bg-primary data-[state=checked]:border-primary flex size-4 rounded-full focus-visible:ring-1 aria-invalid:ring-1 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
+5 -1
View File
@@ -104,8 +104,10 @@ export function VideoPageContent({
voiceProgress,
voiceCurrentTime,
voicePlaybackRate,
downloadingVoiceIds,
playVoice,
toggleVoiceSpeed,
downloadVoice,
} = useCommentMedia();
const [showResolved, setShowResolved] = useState(false);
const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments');
@@ -214,7 +216,6 @@ export function VideoPageContent({
setActiveVersionId,
});
// Cursor idle detection: hide overlay when cursor idle for 3s while playing
// Memoize version selection handler to prevent recreating on each render
const handleVersionSelect = useCallback(
(versionId: string) => {
@@ -926,6 +927,9 @@ export function VideoPageContent({
handleEditComment={commentsActions.onEditComment}
handleDeleteComment={commentsActions.onDeleteComment}
playVoice={playVoice}
downloadVoice={downloadVoice}
downloadingVoiceIds={downloadingVoiceIds}
canDownloadVoiceNotes={canDownloadAssets}
playingVoiceId={playingVoiceId}
voiceProgress={voiceProgress}
voiceCurrentTime={voiceCurrentTime}
+85 -50
View File
@@ -11,7 +11,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { VideoAsset } from '@/components/video-page/types';
import type { AssetDownloadPreference, VideoAsset } from '@/components/video-page/types';
interface AssetListSectionProps {
assets: VideoAsset[];
@@ -25,12 +25,88 @@ interface AssetListSectionProps {
hasMoreAssets: boolean;
isLoadingMoreAssets: boolean;
onViewAsset: (asset: VideoAsset) => void;
onDownloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => void;
onDownloadAsset: (asset: VideoAsset, preference?: AssetDownloadPreference) => void;
onDeleteAsset: (assetId: string) => void;
onLoadMoreAssets: () => void;
renderAssetPreview: (asset: VideoAsset) => ReactNode;
}
/**
* Three shapes of download. A Bunny video offers the original or the compressed
* rendition; a voice note offers WAV (converted in the browser, because no
* editing suite opens the WebM/Opus we store) or the file as recorded;
* everything else is a single button.
*/
function AssetDownloadControl({
asset,
isBusy,
onDownloadAsset,
}: {
asset: VideoAsset;
isBusy: boolean;
onDownloadAsset: (asset: VideoAsset, preference?: AssetDownloadPreference) => void;
}) {
const options: { preference: AssetDownloadPreference; label: string; hint?: string }[] =
asset.provider === 'BUNNY' && asset.kind !== 'AUDIO'
? [
{ preference: 'original', label: 'Original' },
{ preference: 'compressed', label: 'Compressed' },
]
: asset.provider === 'R2_AUDIO'
? [
{ preference: 'wav', label: 'WAV', hint: 'for editing software' },
{ preference: 'original', label: 'Original' },
]
: [];
if (options.length === 0) {
return (
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={isBusy}
onClick={() => onDownloadAsset(asset)}
>
{isBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={isBusy}
>
{isBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{options.map((option) => (
<DropdownMenuItem
key={option.preference}
onClick={() => onDownloadAsset(asset, option.preference)}
>
<Download className="h-3 w-3 mr-2" />
{option.label}
{option.hint && (
<span className="ml-1 text-xs text-muted-foreground">{option.hint}</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const AssetListSection = memo(function AssetListSection({
assets,
isLoadingAssets,
@@ -130,54 +206,13 @@ export const AssetListSection = memo(function AssetListSection({
)}
</Button>
{canDownloadAssets &&
asset.provider !== 'YOUTUBE' &&
(asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
>
{activeDownloadAssetId === asset.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'original')}>
<Download className="h-3 w-3 mr-2" />
Original
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'compressed')}>
<Download className="h-3 w-3 mr-2" />
Compressed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
onClick={() => onDownloadAsset(asset)}
>
{activeDownloadAssetId === asset.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
))}
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
<AssetDownloadControl
asset={asset}
isBusy={activeDownloadAssetId === asset.id || isBunnyProcessing}
onDownloadAsset={onDownloadAsset}
/>
)}
{asset.canDelete && (
<Button
+6 -2
View File
@@ -35,7 +35,11 @@ import {
type BunnyPreviewPlayerHandle,
} from '@/components/video-page/bunny-preview-player';
import { AssetListSection } from '@/components/video-page/asset-list-section';
import type { DirectUploadProvider, VideoAsset } from '@/components/video-page/types';
import type {
AssetDownloadPreference,
DirectUploadProvider,
VideoAsset,
} from '@/components/video-page/types';
import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload';
import {
extractPastedImageFiles,
@@ -101,7 +105,7 @@ interface AssetsPaneProps {
reservationId?: string | null;
}) => Promise<VideoAsset | null>;
deleteAsset: (assetId: string) => Promise<boolean>;
downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise<void>;
downloadAsset: (asset: VideoAsset, preference?: AssetDownloadPreference) => Promise<void>;
hasMoreAssets: boolean;
isLoadingMoreAssets: boolean;
loadMoreAssets: () => Promise<void>;
+66
View File
@@ -92,6 +92,11 @@ interface CommentsPaneProps {
handleEditComment: (commentId: string) => void;
handleDeleteComment: (commentId: string) => void;
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
downloadVoice: (commentId: string, voiceUrl: string, baseName: string) => void;
downloadingVoiceIds: ReadonlySet<string>;
/** Same gate as the video and asset downloads: a project or share link with
* downloads disabled must not offer to save voice notes either. */
canDownloadVoiceNotes: boolean;
playingVoiceId: string | null;
voiceProgress: number;
voiceCurrentTime: number;
@@ -136,6 +141,18 @@ interface CommentsPaneProps {
assetsPane: ReactNode;
}
/**
* Names the download after the reviewer and the frame they were talking about,
* so a folder of voice notes still makes sense next to the cut.
*/
function voiceNoteFileName(
entry: { timestamp: number; author: { name: string | null } | null; guestName: string | null },
formatTime: (seconds: number) => string
): string {
const who = entry.author?.name || entry.guestName || 'guest';
return `voice-${who}-${formatTime(entry.timestamp).replace(/:/g, '-')}`;
}
export const CommentsPane = memo(function CommentsPane({
isMobileCommentsOpen,
setIsMobileCommentsOpen,
@@ -174,6 +191,9 @@ export const CommentsPane = memo(function CommentsPane({
handleEditComment,
handleDeleteComment,
playVoice,
downloadVoice,
downloadingVoiceIds,
canDownloadVoiceNotes,
playingVoiceId,
voiceProgress,
voiceCurrentTime,
@@ -680,6 +700,29 @@ export const CommentsPane = memo(function CommentsPane({
{voicePlaybackRate}x
</button>
)}
{canDownloadVoiceNotes && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
title="Download as WAV"
aria-label="Download voice note as WAV"
disabled={downloadingVoiceIds.has(comment.id)}
onClick={() =>
downloadVoice(
comment.id,
comment.voiceUrl!,
voiceNoteFileName(comment, formatTime)
)
}
>
{downloadingVoiceIds.has(comment.id) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
)}
</div>
)}
@@ -904,6 +947,29 @@ export const CommentsPane = memo(function CommentsPane({
{voicePlaybackRate}x
</button>
)}
{canDownloadVoiceNotes && (
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
title="Download as WAV"
aria-label="Download voice note as WAV"
disabled={downloadingVoiceIds.has(reply.id)}
onClick={() =>
downloadVoice(
reply.id,
reply.voiceUrl!,
voiceNoteFileName(reply, formatTime)
)
}
>
{downloadingVoiceIds.has(reply.id) ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
)}
</div>
)}
</div>
@@ -1,12 +1,19 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { downloadAudioAsWav } from '@/lib/client/download-file';
export function useCommentMedia() {
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const [voiceProgress, setVoiceProgress] = useState(0);
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
// A set, not a single id: two downloads can be in flight at once, and one
// finishing must not clear the other's spinner and re-enable its button.
const [downloadingVoiceIds, setDownloadingVoiceIds] = useState<ReadonlySet<string>>(
() => new Set()
);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const voiceRafRef = useRef<number | null>(null);
@@ -121,13 +128,41 @@ export function useCommentMedia() {
};
}, [stopVoiceTracking]);
/**
* Voice notes are stored the way MediaRecorder wrote them, and an editor
* cannot import WebM/Opus. Hand over a WAV instead, converted in the browser
* from the file it already knows how to decode.
*/
const downloadVoice = useCallback(
async (commentId: string, voiceUrl: string, baseName: string) => {
setDownloadingVoiceIds((prev) => new Set(prev).add(commentId));
try {
const result = await downloadAudioAsWav(voiceUrl, baseName);
if (result === 'failed') {
toast.error('Failed to download voice note');
} else if (result === 'conversion-unsupported') {
toast.warning('This browser cannot convert the recording. Downloaded the original.');
}
} finally {
setDownloadingVoiceIds((prev) => {
const next = new Set(prev);
next.delete(commentId);
return next;
});
}
},
[]
);
return {
playingVoiceId,
voiceProgress,
voiceCurrentTime,
voicePlaybackRate,
downloadingVoiceIds,
playVoice,
stopVoice,
toggleVoiceSpeed,
downloadVoice,
};
}
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/** How long the cursor has to sit still over the player before it and the overlay hide. */
export const CURSOR_IDLE_DELAY_MS = 1000;
/**
* Tracks whether the cursor has rested over the player long enough to hide it
* and the play/pause overlay. Playback can start without the cursor moving (a
* click, a key, the resume after a scrub), so the countdown is re-armed on
* every playback change. Only pointer activity wakes the cursor: a pause/play
* pair the element emits on its own (rebuffering, a source switch) leaves the
* idle state alone, so the chrome does not flash back for a second.
*/
export function useCursorIdle(isPlaying: boolean) {
const [cursorIdle, setCursorIdle] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isCursorOverPlayerRef = useRef(false);
// Restart the countdown. It only runs while the cursor is over the player
// and playback is running; otherwise nothing is pending.
const armTimer = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
if (!isCursorOverPlayerRef.current || !isPlaying) return;
timerRef.current = setTimeout(() => {
setCursorIdle(true);
}, CURSOR_IDLE_DELAY_MS);
}, [isPlaying]);
const handleVideoMouseMove = useCallback(() => {
isCursorOverPlayerRef.current = true;
setCursorIdle(false);
armTimer();
}, [armTimer]);
const handleVideoMouseLeave = useCallback(() => {
isCursorOverPlayerRef.current = false;
setCursorIdle(false);
armTimer();
}, [armTimer]);
useEffect(() => {
armTimer();
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [armTimer]);
return { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave };
}
@@ -17,6 +17,7 @@ import {
downloadProgressPercent,
extensionFromUrl,
navigateDownload,
sanitizeDownloadFileName,
} from '@/lib/client/download-file';
import {
createDownloadProgressToast,
@@ -24,13 +25,6 @@ import {
} from '@/components/download-progress-toast';
import { beginUnloadGuard } from '@/lib/client/unload-guard';
function sanitizeDownloadFileName(value: string): string {
return value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
.replace(/\s+/g, ' ')
.trim();
}
function getAllowedHosts() {
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
return [
@@ -2,10 +2,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import type { VideoAsset } from '@/components/video-page/types';
import type { AssetDownloadPreference, VideoAsset } from '@/components/video-page/types';
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
type BunnyDownloadPreference = 'original' | 'compressed';
import { downloadAudioAsWav } from '@/lib/client/download-file';
type CreateAssetPayload = {
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
@@ -234,7 +233,7 @@ export function useVideoAssets({
);
const downloadAsset = useCallback(
async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
async (asset: VideoAsset, preference: AssetDownloadPreference = 'compressed') => {
if (!canDownloadAssets) {
toast.error('Asset downloads require an authenticated account');
return;
@@ -248,6 +247,20 @@ export function useVideoAssets({
try {
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
// A voice note is stored as MediaRecorder wrote it, and no editing suite
// reads WebM/Opus. Convert it in the browser so the download opens in the
// timeline it was recorded for; 'original' is there for anyone who wants
// the stored bytes instead.
if (asset.provider === 'R2_AUDIO' && preference !== 'original') {
const result = await downloadAudioAsWav(downloadUrl, asset.displayName);
if (result === 'failed') {
toast.error('Failed to download voice note');
} else if (result === 'conversion-unsupported') {
toast.warning('This browser cannot convert the recording. Downloaded the original.');
}
return;
}
if (asset.provider === 'BUNNY') {
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, {
cache: 'no-store',
@@ -33,6 +33,7 @@ import {
resolveSkipAmount as resolveSkipAmountFor,
timeFromClientX as timeFromClientXWithin,
} from '@/components/video-page/hooks/video-player-utils';
import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle';
interface UseVideoPlayerParams {
activeVersion: Version | undefined;
@@ -114,8 +115,7 @@ export function useVideoPlayer({
const previousVersionKeyRef = useRef<string | null>(null);
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
const [cursorIdle, setCursorIdle] = useState(false);
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying);
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const bunnyFrameCallbackIdRef = useRef<number | null>(null);
const bunnyFrameSampleRef = useRef<{ mediaTime: number; presentedFrames: number } | null>(null);
@@ -212,30 +212,6 @@ export function useVideoPlayer({
return () => observer.disconnect();
}, [activeVersionId, bunnyViewportRef]);
const handleVideoMouseMove = useCallback(() => {
setCursorIdle(false);
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
const shouldHideControls = isFullscreenMode;
if (isPlaying || shouldHideControls) {
cursorIdleTimerRef.current = setTimeout(() => {
setCursorIdle(true);
}, 1000);
}
}, [isFullscreenMode, isPlaying]);
const handleVideoMouseLeave = useCallback(() => {
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
setCursorIdle(false);
}, []);
useEffect(() => {
return () => {
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
};
}, []);
useEffect(() => {
if (isApiLoaded) return;
+2
View File
@@ -170,6 +170,8 @@ export interface BunnyQualityOption {
export type BunnyPlaybackState = 'none' | 'processing' | 'error';
export type BunnyDownloadPreference = 'original' | 'compressed';
export type DownloadTarget = BunnyDownloadPreference | 'direct';
/** Voice notes add one more option: converted to WAV in the browser on the way out. */
export type AssetDownloadPreference = BunnyDownloadPreference | 'wav';
export interface CommentMarker {
id: string;
+20 -11
View File
@@ -1,11 +1,7 @@
// Turning Stripe state into funnel events.
//
// These four events are derived from a before/after comparison inside the sync
// that already re-reads every subscription a customer has, rather than from the
// webhook event types. That is deliberate: webhooks arrive out of order and get
// replayed, and `customer.subscription.updated` fires for changes that mean
// nothing here. Comparing the row we are about to overwrite with the row we are
// writing is order-independent, and the dedupe keys make a replay a no-op.
// Turning Stripe state and accepted cancellations into funnel events.
// Sync compares before/after state; in-app cancellation also records acceptance
// because its local claim can hide that transition. Shared cycle keys make both
// paths and replayed webhooks count the same cancellation once.
import type { BillingSubscriptionStatus } from '@prisma/client';
import { eventKey, recordEvent } from '@/lib/analytics/record';
@@ -37,6 +33,19 @@ function cycleMarker(currentPeriodEnd: Date | null): string {
return String(currentPeriodEnd ? currentPeriodEnd.getTime() : 0);
}
/** Shared by accepted in-app cancellations and sync; recordEvent logs write failures. */
export async function recordSubscriptionCancellation(params: {
userId: string;
subscriptionId: string;
currentPeriodEnd: Date | null;
}): Promise<void> {
await recordEvent({
name: 'SUBSCRIPTION_CANCELED',
dedupeKey: `SUBSCRIPTION_CANCELED:${params.subscriptionId}:${cycleMarker(params.currentPeriodEnd)}`,
userId: params.userId,
});
}
export async function recordSubscriptionTransition(params: {
userId: string;
subscriptionId: string;
@@ -71,10 +80,10 @@ export async function recordSubscriptionTransition(params: {
const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd;
const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED';
if (startedCanceling || becameCanceled) {
await recordEvent({
name: 'SUBSCRIPTION_CANCELED',
dedupeKey: `SUBSCRIPTION_CANCELED:${subscriptionId}:${cycle}`,
await recordSubscriptionCancellation({
userId,
subscriptionId,
currentPeriodEnd: after.currentPeriodEnd,
});
}
+121
View File
@@ -0,0 +1,121 @@
/**
* MediaRecorder hands us WebM/Opus (MP4/AAC on Safari). Browsers and desktop
* players read both; editing suites read neither. DaVinci Resolve, Premiere and
* Final Cut all refuse the container outright, so a voice note downloaded
* byte-for-byte is useless to the editor it was recorded for.
*
* The browser already decodes these formats in order to play them, so the whole
* conversion costs us is a RIFF header: decode to PCM through the Web Audio API,
* then write the samples back out as a WAV. Nothing is transcoded server side
* and the stored object stays the small Opus file, which is why this runs at
* download time rather than at record time.
*/
const WAV_HEADER_BYTES = 44;
const BYTES_PER_SAMPLE = 2; // 16-bit PCM
const PCM_FORMAT_TAG = 1;
/**
* Decoding holds the float PCM and the encoded copy in memory at once, roughly
* three times the size of the WAV. A 10MB Opus upload is over half an hour of
* speech, so cap the output rather than let a long recording take the tab down.
*/
export const MAX_WAV_OUTPUT_BYTES = 400 * 1024 * 1024;
function writeAscii(view: DataView, offset: number, text: string): void {
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
}
export function wavByteLength(frameCount: number, channelCount: number): number {
return WAV_HEADER_BYTES + frameCount * channelCount * BYTES_PER_SAMPLE;
}
/**
* Interleaved 16-bit PCM in a RIFF container: the one audio format every NLE on
* the market imports without an argument. `channels` holds one Float32Array of
* samples per channel, all the same length.
*/
export function encodeWav(channels: Float32Array[], sampleRate: number): Blob {
const channelCount = channels.length;
if (channelCount === 0 || !Number.isFinite(sampleRate) || sampleRate <= 0) {
throw new Error('encodeWav needs at least one channel and a positive sample rate');
}
const frameCount = channels[0].length;
const blockAlign = channelCount * BYTES_PER_SAMPLE;
const dataBytes = frameCount * blockAlign;
const buffer = new ArrayBuffer(WAV_HEADER_BYTES + dataBytes);
const view = new DataView(buffer);
writeAscii(view, 0, 'RIFF');
// Everything after this field, i.e. the file minus the 8-byte RIFF preamble.
view.setUint32(4, 36 + dataBytes, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true); // fmt chunk payload size for PCM
view.setUint16(20, PCM_FORMAT_TAG, true);
view.setUint16(22, channelCount, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
view.setUint16(32, blockAlign, true);
view.setUint16(34, BYTES_PER_SAMPLE * 8, true);
writeAscii(view, 36, 'data');
view.setUint32(40, dataBytes, true);
let offset = WAV_HEADER_BYTES;
for (let frame = 0; frame < frameCount; frame++) {
for (let channel = 0; channel < channelCount; channel++) {
// Decoded samples can overshoot ±1. Scaled unclamped they wrap to the
// opposite rail, and a loud passage comes out as a burst of noise.
const sample = Math.max(-1, Math.min(1, channels[channel][frame] ?? 0));
// The negative rail reaches one step further than the positive one, so the
// two directions take different scale factors to stay symmetric.
view.setInt16(offset, Math.round(sample < 0 ? sample * 0x8000 : sample * 0x7fff), true);
offset += BYTES_PER_SAMPLE;
}
}
return new Blob([buffer], { type: 'audio/wav' });
}
type OfflineAudioContextConstructor = new (
channels: number,
length: number,
sampleRate: number
) => OfflineAudioContext;
function getOfflineAudioContext(): OfflineAudioContextConstructor | null {
if (typeof window === 'undefined') return null;
const scope = window as unknown as {
OfflineAudioContext?: OfflineAudioContextConstructor;
webkitOfflineAudioContext?: OfflineAudioContextConstructor;
};
return scope.OfflineAudioContext ?? scope.webkitOfflineAudioContext ?? null;
}
/**
* Decodes any audio blob the browser can play and returns it as a WAV, or null
* when this browser cannot decode that format (older Safari has no WebM/Opus
* decoder) or the result would be too large to hold. Callers fall back to the
* original file, so a failure costs the download nothing but the extension.
*/
export async function convertAudioBlobToWav(blob: Blob): Promise<Blob | null> {
const OfflineCtx = getOfflineAudioContext();
if (!OfflineCtx) return null;
try {
// decodeAudioData resamples to the context's rate, and 48 kHz is what both
// Opus and AAC recordings already run at, so this decodes them untouched.
// We read the rate back off the result anyway in case a browser ignores it.
const context = new OfflineCtx(1, 1, 48000);
const decoded = await context.decodeAudioData(await blob.arrayBuffer());
if (!decoded || decoded.length === 0) return null;
if (wavByteLength(decoded.length, decoded.numberOfChannels) > MAX_WAV_OUTPUT_BYTES) return null;
const channels: Float32Array[] = [];
for (let i = 0; i < decoded.numberOfChannels; i++) channels.push(decoded.getChannelData(i));
return encodeWav(channels, decoded.sampleRate);
} catch {
return null;
}
}
+476 -74
View File
@@ -35,6 +35,36 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
]);
// The Stripe-side counterpart of RECOVERABLE_SUBSCRIPTION_STATUSES, for the places that
// hold a raw Stripe subscription rather than the mirrored status. Deliberately the same
// membership: a subscription worth cancelling is a subscription worth blocking a second
// checkout over, and two sets that disagreed only produced a Cancel button that always
// failed and a checkout guard weaker than the mirror it was backing up.
const LIVE_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>([
'active',
'trialing',
'past_due',
'unpaid',
'incomplete',
]);
// Cancelling one of these takes effect immediately: the open period was never paid for,
// so there is nothing left to run out.
const UNPAID_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>([
'past_due',
'unpaid',
'incomplete',
]);
// A subscription that was running and then missed a payment. It keeps access while Stripe
// retries the card, so a customer whose card expired is not locked out before they have
// had a chance to fix it. `incomplete` is not here: nothing has ever been paid on it.
const RETRYING_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>(['past_due', 'unpaid']);
// 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;
const STORAGE_CLEANUP_GRACE_DAYS = 15;
@@ -95,7 +125,10 @@ export function hasRecoverableSubscription(status: BillingSubscriptionStatus | n
* A legacy Stripe trial counts as paid because a card was handed over for it.
*/
export function isPaidTier(
subject: Pick<BillingAccessSubject, 'subscriptionStatus' | 'stripeCurrentPeriodEnd'>,
subject: Pick<
BillingAccessSubject,
'subscriptionStatus' | 'stripeCurrentPeriodEnd' | 'billingAccessEndedAt'
>,
now: Date = new Date()
) {
if (!isStripeFeatureEnabled()) {
@@ -106,14 +139,19 @@ export function isPaidTier(
return true;
}
// The period end alone is not proof of payment. Checked here and not in
// `hasBillingAccess`, which keeps granting access on a period end it did not
// question before: the cost of being wrong there is a customer locked out,
// while the cost of being wrong here is a free account holding 200 GB.
// The period end alone is not proof of payment.
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
return false;
}
// Same cutoff `hasBillingAccess` applies, so the two cannot disagree about a customer
// behind on payment. They did once: access stopped at the end of 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()) {
return false;
}
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
@@ -132,21 +170,42 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
return true;
}
// Everything below decides whether the reported period still stands in for access, and
// the two guards exist because it very often does not. Both are scoped to this branch
// rather than applied at the top of the function: `billingAccessEndedAt` is only ever
// cleared by a Stripe sync, so a stale one from a lapsed subscription would otherwise
// outrank a freshly started cardless trial and burn the account's one trial for nothing.
// Stripe stamps a period on a subscription whose first charge never went through, so
// that period is not evidence of payment. The same rejection `isPaidTier` makes.
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
return false;
}
// Stripe advances the period the moment it issues the renewal invoice, paid or not, and
// the period survives cancellation, so on its own it would hand a full free month to
// anyone whose renewal fails. This is the bound: a subscription behind on payment is
// stamped with the end of the payment grace window, a cancelled one with `ended_at`.
if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
return false;
}
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
}
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) {
@@ -161,6 +220,9 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
return {};
}
// Mirrors `hasBillingAccess` branch for branch, including the two guards scoped to its
// period-end arm, so the query and the in-memory check cannot disagree about who still
// has access.
return {
OR: [
{
@@ -169,7 +231,11 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
},
},
{ trialEndsAt: { gt: now } },
{ stripeCurrentPeriodEnd: { gt: now } },
{
stripeCurrentPeriodEnd: { gt: now },
subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }],
},
],
};
}
@@ -185,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: [
{
@@ -199,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] } },
],
},
],
},
@@ -723,6 +793,72 @@ function getStripeTimestamp(value: unknown): number | null {
return typeof value === 'number' ? value : null;
}
/**
* The billing period moved off the subscription and onto its items in the Basil API
* version, so reading `subscription.current_period_end` yields undefined on every current
* version. Webhook payloads can still be rendered at an older version, so the legacy field
* is kept as a fallback rather than dropped.
*/
export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null {
const itemPeriodEnds = (subscription.items?.data ?? [])
.map((item) =>
getStripeTimestamp(
(item as Stripe.SubscriptionItem & { current_period_end?: unknown }).current_period_end
)
)
.filter((value): value is number => value !== null);
if (itemPeriodEnds.length > 0) {
return Math.max(...itemPeriodEnds);
}
return getStripeTimestamp(
(subscription as Stripe.Subscription & { current_period_end?: unknown }).current_period_end
);
}
/**
* Same field move as the period end. Stripe opens the new period when it issues the
* renewal invoice, so for an unpaid subscription this is roughly when the first payment
* attempt failed, which is what the retry window is measured from.
*/
export function getSubscriptionPeriodStart(subscription: Stripe.Subscription): number | null {
const itemPeriodStarts = (subscription.items?.data ?? [])
.map((item) =>
getStripeTimestamp(
(item as Stripe.SubscriptionItem & { current_period_start?: unknown }).current_period_start
)
)
.filter((value): value is number => value !== null);
if (itemPeriodStarts.length > 0) {
return Math.min(...itemPeriodStarts);
}
return getStripeTimestamp(
(subscription as Stripe.Subscription & { current_period_start?: unknown }).current_period_start
);
}
/**
* The invoice link to its subscription moved under `parent.subscription_details` in the
* Basil API version. Same fallback reasoning as the period above.
*/
export function getInvoiceSubscriptionId(invoice: Stripe.Invoice): string | null {
const fromParent = invoice.parent?.subscription_details?.subscription;
if (typeof fromParent === 'string') return fromParent;
if (fromParent && typeof fromParent === 'object') return fromParent.id;
const legacy = (invoice as Stripe.Invoice & { subscription?: unknown }).subscription;
if (typeof legacy === 'string') return legacy;
if (legacy && typeof legacy === 'object' && 'id' in legacy) {
const id = (legacy as { id: unknown }).id;
return typeof id === 'string' ? id : null;
}
return null;
}
function getInactiveBillingAccessEndedAt(
subscription: Stripe.Subscription,
currentPeriodEnd: number | null
@@ -733,9 +869,37 @@ function getInactiveBillingAccessEndedAt(
const canceledAt = getStripeTimestamp(
(subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at
);
const reference = currentPeriodEnd ?? endedAt ?? canceledAt;
return reference ? new Date(reference * 1000) : new Date();
// `ended_at` wins over everything: a subscription killed mid-period for non-payment
// must not keep access until a period the customer never paid for.
if (endedAt) {
return new Date(endedAt * 1000);
}
// 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
// exists to keep in, which is the wrong way to fail on missing data.
if (RETRYING_STRIPE_STATUSES.has(subscription.status)) {
const grace = UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60;
const periodStart = getSubscriptionPeriodStart(subscription);
const graceEnd = periodStart ? periodStart + grace : Math.floor(Date.now() / 1000) + grace;
return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000);
}
// 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);
}
// Anything else that gets here never paid for the period Stripe is reporting, so that
// period is not a date access can run to. `incomplete` and `incomplete_expired` are the
// cases that matter: their very first payment never went through.
return canceledAt ? new Date(canceledAt * 1000) : new Date();
}
function getEntitledStripePriceId(subscription: Stripe.Subscription) {
@@ -747,10 +911,17 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId:
}
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
return recordSyncedSubscription(await writeStripeSubscriptionToUser(subscription, db));
}
async function writeStripeSubscriptionToUser(
subscription: Stripe.Subscription,
client: Prisma.TransactionClient
) {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const user = await db.user.findUnique({
const user = await client.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
id: true,
@@ -768,10 +939,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
return null;
}
const currentPeriodEnd =
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
? subscription.current_period_end
: null;
const currentPeriodEnd = getSubscriptionPeriodEnd(subscription);
const cancelAt =
'cancel_at' in subscription && typeof subscription.cancel_at === 'number'
? subscription.cancel_at
@@ -796,13 +964,14 @@ 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 hasAccess =
hasEntitledPrice &&
(hasActiveSubscription(mappedStatus) ||
Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
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`,
// which is cleared again as soon as the subscription goes back to active.
const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus);
const updated = await db.user.update({
const updated = await client.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
@@ -816,22 +985,15 @@ 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),
},
});
await recordSubscriptionTransition({
const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
userId: user.id,
subscriptionId: subscription.id,
before: {
@@ -845,9 +1007,19 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: effectiveCurrentPeriodEnd,
},
});
};
return updated;
return { updated, transition };
}
async function recordSyncedSubscription(
result: Awaited<ReturnType<typeof writeStripeSubscriptionToUser>>
) {
if (!result) return null;
// Analytics uses its own connection. Run it after commit, not while a billing
// transaction holds a connection and other syncs are queued on its advisory lock.
await recordSubscriptionTransition(result.transition);
return result.updated;
}
// A single Stripe customer can own several subscriptions at once (e.g. after
@@ -900,28 +1072,49 @@ export function selectAuthoritativeSubscription(
// Source-of-truth sync: instead of trusting a single subscription from a webhook
// event body (which may be an OLD subscription being deleted while a NEWER one is
// active), re-list ALL of the customer's subscriptions from Stripe and sync the
// authoritative one. This is order-independent and self-healing.
// authoritative one. The customer lock covers the Stripe read as well as the mirror
// write: locking only after the read would still let a delayed older response win.
export async function syncStripeCustomerSubscriptions(customerId: string) {
const stripe = getStripe();
const { data: subscriptions } = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
const result = await db.$transaction(
async (tx) => {
// Two-key advisory locks occupy a separate namespace from the one-key
// cancellation locks. Cancellation releases its lock before calling sync.
await tx.$executeRaw`
SELECT pg_advisory_xact_lock(hashtext('stripe-subscription-sync'), hashtext(${customerId}))
`;
const { data: subscriptions } = await getStripe().subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
const authoritative = selectAuthoritativeSubscription(subscriptions);
if (!authoritative) {
return markSubscriptionCanceledByCustomerId(customerId);
}
return syncStripeSubscriptionToUser(authoritative);
const authoritative = selectAuthoritativeSubscription(subscriptions);
return authoritative
? writeStripeSubscriptionToUser(authoritative, tx)
: writeSubscriptionCanceledByCustomerId(customerId, undefined, tx);
},
// Bound lock and connection occupancy. A slow Stripe call or lock wait fails
// this sync; writes through the expired transaction cannot overwrite a newer sync.
{ maxWait: 10_000, timeout: 30_000 }
);
return recordSyncedSubscription(result);
}
export async function markSubscriptionCanceledByCustomerId(
customerId: string,
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }
) {
const user = await db.user.findUnique({
return recordSyncedSubscription(
await writeSubscriptionCanceledByCustomerId(customerId, options, db)
);
}
async function writeSubscriptionCanceledByCustomerId(
customerId: string,
options: { currentPeriodEnd?: Date | null; endedAt?: Date | null } | undefined,
client: Prisma.TransactionClient
) {
const user = await client.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
id: true,
@@ -941,9 +1134,9 @@ 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({
const updated = await client.user.update({
where: { id: user.id },
data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
@@ -953,9 +1146,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(),
},
});
@@ -963,7 +1154,7 @@ export async function markSubscriptionCanceledByCustomerId(
// uses the period end being cleared here, which is the same one the earlier
// "cancel at period end" write carried, so a customer who cancelled through the
// portal and then reached the end of their term produces one cancellation, not two.
await recordSubscriptionTransition({
const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
userId: user.id,
subscriptionId: user.stripeSubscriptionId ?? user.id,
before: {
@@ -977,7 +1168,218 @@ export async function markSubscriptionCanceledByCustomerId(
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
},
};
return { updated, transition };
}
/**
* Returns a subscription of this customer that still grants access, if any. A customer can
* hold several at once, so the state of one says nothing about the others.
*/
export async function findLiveStripeSubscription(customerId: string) {
const stripe = getStripe();
const { data: subscriptions } = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
return updated;
return (
selectAuthoritativeSubscription(
subscriptions.filter((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status))
) ?? null
);
}
/**
* Asked before opening checkout. Answered by Stripe rather than by the local mirror: the
* mirror can be stale or cleared, and a customer who slips past this ends up paying for two
* subscriptions at once.
*/
export async function findBlockingStripeSubscription(customerId: string) {
const stripe = getStripe();
const { data: subscriptions } = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
return (
subscriptions.find((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status)) ?? null
);
}
export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) {
return UNPAID_STRIPE_STATUSES.has(subscription.status);
}
/** 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 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);
}
}
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
* hold, and pointing them at that one does nothing about the retries they are seeing.
*/
export async function getOpenInvoiceForCustomer(
customerId: string,
subscriptionId?: string | null
) {
const stripe = getStripe();
const { data: invoices } = await stripe.invoices.list({
customer: customerId,
status: 'open',
limit: 100,
});
const candidates = subscriptionId
? invoices.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
: invoices;
const newest = candidates
.slice()
.sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
.at(0);
if (!newest) {
return null;
}
return {
id: newest.id ?? null,
hostedInvoiceUrl: newest.hosted_invoice_url ?? null,
amountDue: newest.amount_due ?? newest.total ?? 0,
currency: newest.currency ?? 'usd',
attemptCount: newest.attempt_count ?? 0,
nextPaymentAttempt: newest.next_payment_attempt
? new Date(newest.next_payment_attempt * 1000)
: null,
};
}
+43
View File
@@ -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;
}
+248
View File
@@ -0,0 +1,248 @@
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';
import { recordSubscriptionCancellation } from '@/lib/analytics/billing-events';
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<CancelSubscriptionResult> {
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;
// The paid claim already set the local flag, and another subscription may
// drive customer sync. Record acceptance directly with the same cycle key.
await recordSubscriptionCancellation({
userId: params.userId,
subscriptionId,
currentPeriodEnd: periodEnd,
});
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,
};
}
+93 -1
View File
@@ -1,5 +1,7 @@
'use client';
import { convertAudioBlobToWav } from '@/lib/audio-to-wav';
// Above this size we don't buffer the file in memory to rename it — the caller
// falls back to a plain navigation so the browser streams it straight to disk
// (with the CDN's own filename). 10 GiB.
@@ -27,12 +29,20 @@ export function extensionFromUrl(url: string): string {
return ext.length >= 1 && ext.length <= 5 ? ext : '';
}
function replaceExtension(fileName: string, ext: string): string {
export function replaceExtension(fileName: string, ext: string): string {
const dot = fileName.lastIndexOf('.');
const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
return `${stem}.${ext}`;
}
/** Strips the characters Windows and macOS reject in a file name. */
export function sanitizeDownloadFileName(value: string): string {
return value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
.replace(/\s+/g, ' ')
.trim();
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
const mb = bytes / (1024 * 1024);
@@ -146,6 +156,88 @@ export async function downloadNamedFile(
return true;
}
const AUDIO_MIME_EXTENSION_MAP: Record<string, string> = {
'audio/webm': 'webm',
'audio/ogg': 'ogg',
'audio/opus': 'opus',
'audio/mp4': 'm4a',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
};
const AUDIO_EXTENSIONS = new Set(Object.values(AUDIO_MIME_EXTENSION_MAP).concat('mp4', 'oga'));
/** Display names are often the recorded file name, extension and all, and
* `recording.webm.wav` helps nobody. */
function stripAudioExtension(name: string): string {
const ext = extensionFromUrl(name);
return ext && AUDIO_EXTENSIONS.has(ext) ? name.slice(0, -(ext.length + 1)) : name;
}
/**
* Containers an editing suite already opens. Audio assets are not only voice
* recordings, anyone can upload an audio file, and decoding one of these back out
* through the Web Audio API would resample it to 48 kHz and requantise it to 16
* bit for no gain. A file in this set is handed over exactly as stored.
*/
const EDITOR_READY_MIME_TYPES = new Set(['audio/wav', 'audio/mpeg', 'audio/mp4']);
export type AudioDownloadResult =
| 'wav'
| 'no-conversion-needed'
| 'conversion-unsupported'
| 'failed';
/**
* Voice notes are stored exactly as MediaRecorder produced them: WebM/Opus,
* which browsers play and no editing suite imports. Convert on the way out so
* the file lands in the timeline it was recorded for.
*
* Saves the stored file untouched when it is already in an editable container,
* or when this browser cannot decode it, so the download always works. The
* return value says which of the three happened, or 'failed' when the file
* could not be fetched at all.
*/
export async function downloadAudioAsWav(
url: string,
baseName: string
): Promise<AudioDownloadResult> {
let res: Response;
try {
res = await fetch(url, { cache: 'no-store' });
} catch {
return 'failed';
}
if (!res.ok) return 'failed';
let source: Blob;
try {
source = await res.blob();
} catch {
return 'failed';
}
const stem = stripAudioExtension(sanitizeDownloadFileName(baseName)) || 'voice-note';
// The proxy route names the real container; the URL usually carries no
// extension, so the content type is the better source for both decisions.
const contentType = (res.headers.get('content-type') || '').split(';')[0]?.trim() ?? '';
const ext = AUDIO_MIME_EXTENSION_MAP[contentType] || extensionFromUrl(url) || 'webm';
if (EDITOR_READY_MIME_TYPES.has(contentType)) {
saveBlobAs(source, `${stem}.${ext}`);
return 'no-conversion-needed';
}
const wav = await convertAudioBlobToWav(source);
if (wav) {
saveBlobAs(wav, `${stem}.wav`);
return 'wav';
}
saveBlobAs(source, `${stem}.${ext}`);
return 'conversion-unsupported';
}
/** Plain navigation download (streams to disk; filename controlled only for
* same-origin URLs via the download attribute). */
export function navigateDownload(url: string, sameOriginFileName?: string): void {
+1
View File
@@ -60,6 +60,7 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
'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
+2 -2
View File
@@ -26,9 +26,9 @@ export function getSiteUrl(): string {
export const seoConfig = {
name: 'OpenFrame',
title: 'Fair Source Video Review Platform',
title: 'Video Review & Approval',
description:
'OpenFrame is a fair source video review platform for collecting timestamped feedback with text and voice comments.',
'Review videos together with timestamped text and voice comments. Keep feedback organized and your team on the same page.',
keywords: [
'fair source video review platform',
'video review platform',
+1 -1
View File
@@ -43,7 +43,7 @@ export interface StorageContext {
export async function getStorageContextForUser(userId: string): Promise<StorageContext> {
const user = await db.user.findUnique({
where: { id: userId },
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true, billingAccessEndedAt: true },
});
const isPaid = user ? isPaidTier(user) : false;
+7 -1
View File
@@ -3,6 +3,12 @@ import { hasStripeConfig, isStripeBillingEnabled } from '@/lib/feature-flags';
let stripeClient: Stripe | null = null;
// Pinned on purpose. Without it the SDK silently follows whatever version it ships
// with, and field moves between versions (the subscription period moving onto items,
// the invoice subscription link moving under `parent`) turn into null reads instead
// of build failures. `satisfies` makes an SDK bump a compile error here first.
const STRIPE_API_VERSION = '2026-02-25.clover' satisfies Stripe.LatestApiVersion;
export function isStripeConfigured() {
return isStripeBillingEnabled();
}
@@ -18,7 +24,7 @@ export function getStripe() {
}
if (!stripeClient) {
stripeClient = new Stripe(secretKey);
stripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION });
}
return stripeClient;
+3 -1
View File
@@ -36,7 +36,9 @@
"r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",
"bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts"
"bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts",
"stripe:resync:dry": "bun run scripts/resync-stripe-subscriptions.ts --dry-run",
"stripe:resync": "bun run scripts/resync-stripe-subscriptions.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
@@ -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;
+31
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
+90
View File
@@ -0,0 +1,90 @@
/**
* 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
* versions stay wrong in the database until that customer happens to produce a webhook,
* which for a customer whose payment already failed may never happen on its own.
*/
import { db, disconnectDb } from '../lib/db';
import { selectAuthoritativeSubscription, syncStripeCustomerSubscriptions } from '../lib/billing';
import { getStripe, isStripeConfigured } from '../lib/stripe';
import { logError } from '../lib/logger';
const TAG = '[resync-stripe-subscriptions]';
async function main() {
const dryRun = process.argv.includes('--dry-run');
if (!isStripeConfigured()) {
console.log(`${TAG} Stripe is not configured, nothing to do`);
return;
}
const users = await db.user.findMany({
where: { stripeCustomerId: { not: null } },
select: { id: true, email: true, stripeCustomerId: true, stripeCurrentPeriodEnd: true },
});
let synced = 0;
let withoutSubscription = 0;
let failed = 0;
for (const user of users) {
if (!user.stripeCustomerId) continue;
try {
const label = user.email ?? user.id;
// Selected exactly the way the write path selects, over the customer's whole set
// rather than the live ones only. A mirror left wrong by the version change is most
// likely on a customer whose subscription is already canceled or incomplete, which
// is precisely who a live-only filter would skip.
const { data: subscriptions } = await getStripe().subscriptions.list({
customer: user.stripeCustomerId,
status: 'all',
limit: 100,
});
const subscription = selectAuthoritativeSubscription(subscriptions);
if (!subscription) {
withoutSubscription += 1;
continue;
}
if (dryRun) {
console.log(
`${TAG} Would sync ${label}: ${subscription.id} (${subscription.status}), stored period end ${user.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}`
);
synced += 1;
continue;
}
const updated = await syncStripeCustomerSubscriptions(user.stripeCustomerId);
if (updated) {
console.log(
`${TAG} Synced ${label}: ${subscription.status}, period end ${updated.stripeCurrentPeriodEnd?.toISOString() ?? 'null'}, access ends ${updated.billingAccessEndedAt?.toISOString() ?? 'null'}`
);
synced += 1;
}
} catch (error) {
failed += 1;
logError(`${TAG} Failed syncing ${user.email ?? user.id}:`, error);
}
}
console.log(`${TAG} Summary${dryRun ? ' (dry run)' : ''}`);
console.log(`${TAG} Customers: ${users.length}`);
console.log(`${TAG} Synced: ${synced}`);
console.log(`${TAG} Without a subscription: ${withoutSubscription}`);
console.log(`${TAG} Failed: ${failed}`);
}
main()
.catch((error) => {
logError(`${TAG} Fatal error:`, error);
process.exitCode = 1;
})
.finally(async () => {
await disconnectDb();
});
+8 -1
View File
@@ -48,6 +48,7 @@ import * as adminGrowthRoute from '@/app/api/admin/growth/route';
import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route';
import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route';
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
import * as billingCancelRoute from '@/app/api/billing/cancel/route';
import * as billingCheckoutRoute from '@/app/api/billing/checkout/route';
import * as billingPortalRoute from '@/app/api/billing/portal/route';
import * as billingTrialRoute from '@/app/api/billing/trial/route';
@@ -149,7 +150,7 @@ vi.mock('@/lib/r2', async (importOriginal) => {
// The count guard
// ---------------------------------------------------------------------------
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
const EXPECTED_ROUTE_MODULE_COUNT = 67;
const EXPECTED_ROUTE_MODULE_COUNT = 68;
/**
* Routes that are public by design, and why. Everything else must reject an
@@ -405,6 +406,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
params: (f) => ({ requestId: f.approvalRequestId }),
body: { decision: 'APPROVED' },
},
{
file: 'billing/cancel/route.ts',
module: billingCancelRoute,
url: () => '/api/billing/cancel',
headers: { origin: 'http://localhost:3000' },
},
{
file: 'billing/checkout/route.ts',
module: billingCheckoutRoute,
+824
View File
@@ -0,0 +1,824 @@
import { beforeEach, 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 { syncStripeCustomerSubscriptions } from '@/lib/billing';
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<Stripe.Subscription> = {}) {
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<Stripe.Invoice> = {}) {
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<Stripe.Subscription>) => {
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<void>((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);
}
});
});
describe('cancellation analytics through the route', () => {
beforeEach(() => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
});
it.each(['canceled subscription', 'empty customer'] as const)(
'records paid cancellation before sync and deduplicates %s deletion in the same cycle',
async (deletion) => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user);
const stripe = stubStripe([original]);
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'OTHER', note: 'Leaving after this project' })
);
expect(response.status).toBe(200);
expect(stripe.update).toHaveBeenCalledExactlyOnceWith(original.id, {
cancel_at_period_end: true,
cancellation_details: { feedback: 'other' },
});
expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
userId: user.id,
stripeSubscriptionId: original.id,
reason: 'OTHER',
note: 'Leaving after this project',
});
const where = { userId: user.id, name: 'SUBSCRIPTION_CANCELED' as const };
// This must exist before any later transition can conceal the missing event.
const accepted = await db.analyticsEvent.findMany({ where });
expect(accepted).toHaveLength(1);
expect(accepted[0].dedupeKey).toBe(
`SUBSCRIPTION_CANCELED:${original.id}:${original.items.data[0].current_period_end * 1000}`
);
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
stripe.list.mockResolvedValue({
data:
deletion === 'empty customer'
? []
: [{ ...original, status: 'canceled', cancel_at_period_end: false }],
has_more: false,
});
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
const replayed = await db.analyticsEvent.findMany({ where });
expect(replayed.map((event) => event.id)).toEqual([accepted[0].id]);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
BillingSubscriptionStatus.CANCELED
);
}
);
it('records cancellation of a paid subscription that does not drive the customer mirror', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
const authoritative = subscription(user);
const other = subscription(user, {
id: 'sub_analytics_other',
cancel_at_period_end: false,
created: unix(-60 * DAY),
});
const stripe = stubStripe([authoritative, other]);
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
expect(response.status).toBe(200);
expect(stripe.update).toHaveBeenCalledExactlyOnceWith(other.id, expect.anything());
const events = await db.analyticsEvent.findMany({
where: { userId: user.id, name: 'SUBSCRIPTION_CANCELED' },
});
expect(events).toHaveLength(1);
expect(events[0].dedupeKey).toBe(
`SUBSCRIPTION_CANCELED:sub_analytics_other:${other.items.data[0].current_period_end * 1000}`
);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeSubscriptionId).toBe(
authoritative.id
);
});
it('records no cancellation event when Stripe rejects the paid cancellation', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
stripe.update.mockRejectedValueOnce(
Object.assign(new Error('Stripe rejected cancellation'), {
type: 'StripeInvalidRequestError',
})
);
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
expect(response.status).toBe(409);
expect(stripe.update).toHaveBeenCalledTimes(1);
expect(
await db.analyticsEvent.count({ where: { userId: user.id, name: 'SUBSCRIPTION_CANCELED' } })
).toBe(0);
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(false);
});
it('keeps the cancellation and reason when analytics recording fails', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user);
const stripe = stubStripe([original]);
const recording = vi
.spyOn(db.analyticsEvent, 'createMany')
.mockRejectedValue(new Error('Analytics unavailable'));
try {
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'OTHER', note: 'Keep this answer' })
);
expect(response.status).toBe(200);
expect(stripe.update).toHaveBeenCalledTimes(1);
expect(recording).toHaveBeenCalledWith(
expect.objectContaining({
data: [expect.objectContaining({ name: 'SUBSCRIPTION_CANCELED', userId: user.id })],
})
);
expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
reason: 'OTHER',
note: 'Keep this answer',
});
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(true);
} finally {
recording.mockRestore();
}
});
it('still cancels without recording analytics when the feature is disabled', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
expect((await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }))).status).toBe(200);
expect(stripe.update).toHaveBeenCalledTimes(1);
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
expect(await db.analyticsEvent.count({ where: { userId: user.id } })).toBe(0);
});
});
+224
View File
@@ -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> = {}): 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'));
});
});
+339
View File
@@ -0,0 +1,339 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type Stripe from 'stripe';
import { Pool } from 'pg';
import {
buildBillingAccessWhereInput,
hasBillingAccess,
syncStripeCustomerSubscriptions,
} from '@/lib/billing';
import { getStripe } from '@/lib/stripe';
import { db } from '../helpers/db';
import { createUser } from '../factories';
// Real PostgreSQL persistence and advisory locks; only Stripe responses are emulated.
// The held response models transport delay, not Stripe's actual webhook scheduling.
const CUSTOMER = 'cus_sync_concurrency';
const PRICE = 'price_sync_concurrency';
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
function subscription(
status: 'active' | 'canceled',
id = 'sub_paid',
customer = CUSTOMER
): Stripe.Subscription {
const now = Math.floor(Date.now() / 1000);
return {
id,
customer,
status,
created: now - 86_400,
trial_end: null,
cancel_at: null,
cancel_at_period_end: false,
ended_at: status === 'canceled' ? now - 60 : null,
canceled_at: status === 'canceled' ? now - 60 : null,
items: {
data: [
{
price: { id: PRICE },
current_period_start: now - 86_400,
current_period_end: now + 30 * 86_400,
},
],
},
} as Stripe.Subscription;
}
async function seed(status: 'ACTIVE' | 'CANCELED' = 'CANCELED', customer = CUSTOMER) {
return createUser({
stripeCustomerId: customer,
stripeSubscriptionId: `sub_seed_${customer}`,
stripePriceId: PRICE,
subscriptionStatus: status,
stripeCurrentPeriodEnd: new Date(Date.now() + 30 * 86_400_000),
trialEndsAt: null,
billingTrialConsumedAt: new Date(Date.now() - 60 * 86_400_000),
billingAccessEndedAt: status === 'CANCELED' ? new Date(Date.now() - 60_000) : null,
});
}
function installStripe() {
const list = vi.fn<
(params: Stripe.SubscriptionListParams) => Promise<{
data: Stripe.Subscription[];
has_more: boolean;
}>
>();
vi.mocked(getStripe).mockReturnValue({ subscriptions: { list } } as unknown as Stripe);
return list;
}
async function waitForQueuedSync(customer = CUSTOMER) {
// Observe a real waiter rather than sleeping and assuming the other request ran.
// Replacing the database lock with a process-local mutex fails this assertion.
await vi.waitFor(
async () => {
const rows = await db.$queryRaw<{ waiting: boolean }[]>`
SELECT EXISTS (
SELECT 1 FROM pg_locks
WHERE locktype = 'advisory' AND NOT granted
AND classid = hashtext('stripe-subscription-sync')::oid
AND objid = hashtext(${customer})::oid AND objsubid = 2
) AS waiting
`;
expect(rows).toEqual([{ waiting: true }]);
},
{ timeout: 2_000, interval: 20 }
);
}
async function assertAccess(userId: string, expected: boolean) {
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
expect(hasBillingAccess(stored)).toBe(expected);
expect(
await db.user.findMany({
where: { AND: [{ id: userId }, buildBillingAccessWhereInput()] },
select: { id: true },
})
).toEqual(expected ? [{ id: userId }] : []);
return stored;
}
beforeEach(() => {
vi.stubEnv('STRIPE_PRICE_ID', PRICE);
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
});
describe('customer-wide Stripe sync serialization', () => {
it.each(['canceled-then-paid', 'paid-then-canceled', 'empty-then-paid'] as const)(
'keeps the newer snapshot for overlapping %s reads',
async (order) => {
const paidFirst = order === 'paid-then-canceled';
const user = await seed(paidFirst ? 'ACTIVE' : 'CANCELED');
const stale =
order === 'empty-then-paid'
? []
: [subscription(paidFirst ? 'active' : 'canceled', paidFirst ? 'sub_paid' : 'sub_old')];
const fresh = subscription(paidFirst ? 'canceled' : 'active');
const list = installStripe();
const entered = deferred();
const release = deferred();
list.mockImplementationOnce(async () => {
entered.resolve();
await release.promise;
return { data: stale, has_more: false };
});
list.mockImplementationOnce(async () => {
// Reading Stripe for the next sync must wait for the previous mirror commit.
const previous = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(previous.stripeSubscriptionId).toBe(stale[0]?.id ?? null);
return { data: [fresh], has_more: false };
});
const first = syncStripeCustomerSubscriptions(CUSTOMER);
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
// Attach handlers immediately so assertion failures still drain both requests.
void first.catch(() => {});
try {
await entered.promise;
second = syncStripeCustomerSubscriptions(CUSTOMER);
void second.catch(() => {});
await waitForQueuedSync();
expect(list).toHaveBeenCalledTimes(1);
const unchanged = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(unchanged.stripeSubscriptionId).toBe(user.stripeSubscriptionId);
} finally {
release.resolve();
await Promise.allSettled([first, ...(second ? [second] : [])]);
}
await expect(first).resolves.not.toBeNull();
await expect(second!).resolves.not.toBeNull();
expect(list).toHaveBeenCalledTimes(2);
const stored = await assertAccess(user.id, !paidFirst);
expect(stored.subscriptionStatus).toBe(paidFirst ? 'CANCELED' : 'ACTIVE');
expect(stored.stripeSubscriptionId).toBe('sub_paid');
expect(
await db.analyticsEvent.findMany({
where: { userId: user.id },
select: { name: true },
})
).toEqual([{ name: paidFirst ? 'SUBSCRIPTION_CANCELED' : 'SUBSCRIPTION_STARTED' }]);
}
);
it('honors the customer lock held by an independent database connection before reading Stripe', async () => {
const user = await seed();
const list = installStripe().mockResolvedValue({
data: [subscription('active')],
has_more: false,
});
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
const connection = await pool.connect();
let pending: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
try {
await connection.query('BEGIN');
await connection.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [
'stripe-subscription-sync',
CUSTOMER,
]);
pending = syncStripeCustomerSubscriptions(CUSTOMER);
void pending.catch(() => {});
await waitForQueuedSync();
expect(list).not.toHaveBeenCalled();
} finally {
await connection.query('ROLLBACK');
connection.release();
await pool.end();
if (pending) await Promise.allSettled([pending]);
}
await expect(pending!).resolves.not.toBeNull();
expect(list).toHaveBeenCalledTimes(1);
await assertAccess(user.id, true);
});
it('lets a different customer sync while the first customer waits on Stripe', async () => {
await seed();
const otherCustomer = 'cus_sync_independent';
const otherUser = await seed('CANCELED', otherCustomer);
const entered = deferred();
const release = deferred();
const list = installStripe().mockImplementation(async ({ customer }) => {
if (customer === CUSTOMER) {
entered.resolve();
await release.promise;
}
return { data: [subscription('active', `sub_${customer}`, customer)], has_more: false };
});
const first = syncStripeCustomerSubscriptions(CUSTOMER);
void first.catch(() => {});
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
let secondFinished = false;
try {
await entered.promise;
second = syncStripeCustomerSubscriptions(otherCustomer);
void second.then(
() => {
secondFinished = true;
},
() => {
secondFinished = true;
}
);
await vi.waitFor(() => expect(secondFinished).toBe(true), { timeout: 2_000 });
await expect(second).resolves.not.toBeNull();
await assertAccess(otherUser.id, true);
expect(list).toHaveBeenCalledTimes(2);
} finally {
release.resolve();
await Promise.allSettled([first, ...(second ? [second] : [])]);
}
await expect(first).resolves.not.toBeNull();
});
it('releases a failed sync for its queued successor without changing the original mirror', async () => {
const user = await seed();
const entered = deferred();
const release = deferred();
const failure = new Error('Emulated Stripe read failure');
const list = installStripe();
list.mockImplementationOnce(async () => {
entered.resolve();
await release.promise;
throw failure;
});
list.mockImplementationOnce(async () => {
expect(await db.user.findUniqueOrThrow({ where: { id: user.id } })).toEqual(user);
return { data: [subscription('active')], has_more: false };
});
const first = syncStripeCustomerSubscriptions(CUSTOMER);
void first.catch(() => {});
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
try {
await entered.promise;
second = syncStripeCustomerSubscriptions(CUSTOMER);
void second.catch(() => {});
await waitForQueuedSync();
} finally {
release.resolve();
await Promise.allSettled([first, ...(second ? [second] : [])]);
}
await expect(first).rejects.toBe(failure);
await expect(second!).resolves.not.toBeNull();
expect(list).toHaveBeenCalledTimes(2);
await assertAccess(user.id, true);
});
it('cannot overwrite a newer mirror when a Stripe response arrives after transaction expiry', async () => {
const user = await seed();
const entered = deferred();
const release = deferred();
const callbackFinished = deferred();
const list = installStripe();
list.mockImplementationOnce(async () => {
entered.resolve();
await release.promise;
return { data: [subscription('canceled', 'sub_stale')], has_more: false };
});
list.mockResolvedValueOnce({ data: [subscription('active', 'sub_new')], has_more: false });
// Keep the real transaction and expiry machinery, shortening only the first
// request's deadline. Its callback can outlive rollback while Stripe is held.
const transact = db.$transaction.bind(db);
const transaction = vi.spyOn(db, '$transaction').mockImplementationOnce((callback, options) =>
transact(
async (tx) => {
try {
return await callback(tx);
} finally {
callbackFinished.resolve();
}
},
{ ...options, timeout: 200 }
)
);
const first = syncStripeCustomerSubscriptions(CUSTOMER);
void first.catch(() => {});
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
let committed: Awaited<ReturnType<typeof assertAccess>> | undefined;
try {
await entered.promise;
// Wait for PostgreSQL to release A's lock, not an assumed sleep duration.
await vi.waitFor(
async () => {
const rows = await db.$queryRaw<{ held: boolean }[]>`
SELECT EXISTS (
SELECT 1 FROM pg_locks
WHERE locktype = 'advisory' AND granted
AND classid = hashtext('stripe-subscription-sync')::oid
AND objid = hashtext(${CUSTOMER})::oid AND objsubid = 2
) AS held
`;
expect(rows).toEqual([{ held: false }]);
},
{ timeout: 3_000, interval: 20 }
);
second = syncStripeCustomerSubscriptions(CUSTOMER);
void second.catch(() => {});
await expect(second).resolves.not.toBeNull();
committed = await assertAccess(user.id, true);
expect(committed.stripeSubscriptionId).toBe('sub_new');
} finally {
release.resolve();
// The outer promise may reject on expiry before its callback finishes.
// Drain both so a late global-client write cannot escape the assertions.
await Promise.allSettled([first, ...(second ? [second] : [])]);
await callbackFinished.promise;
transaction.mockRestore();
}
await expect(first).rejects.toMatchObject({ code: 'P2028' });
expect(list).toHaveBeenCalledTimes(2);
expect(await assertAccess(user.id, true)).toEqual(committed);
});
});
@@ -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(
<CancelSubscriptionDialog
open
onOpenChange={onOpenChange}
periodEnd={
overrides.periodEnd === undefined ? '2026-10-01T00:00:00.000Z' : overrides.periodEnd
}
isTrial={overrides.isTrial ?? false}
canceledImmediately={overrides.canceledImmediately}
onConfirm={onConfirm}
/>
);
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();
});
});
@@ -418,6 +418,92 @@ describe('useVideoPlayer scrubbing', () => {
});
});
describe('useVideoPlayer cursor idling', () => {
/** The hook hides the cursor and the overlay after this much stillness. */
const IDLE_DELAY_MS = 1000;
it('goes idle when playback starts under a cursor that never moves again', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleVideoMouseMove());
startPlayback(video);
expect(result.current.cursorIdle).toBe(false);
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS));
expect(result.current.cursorIdle).toBe(true);
});
it('stays awake through a scrub and idles again once the cursor returns', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleVideoMouseMove());
startPlayback(video);
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS));
expect(result.current.cursorIdle).toBe(true);
// The timeline sits outside the player, so reaching it leaves the player
// first; the element then reports the pause the scrub asked for and the
// resume on release.
act(() => result.current.handleVideoMouseLeave());
act(() => result.current.handleTimelineMouseDown(mouseEventAt(50)));
stopPlayback(video);
act(() => result.current.handleTimelineMouseUp());
startPlayback(video);
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS * 5));
expect(result.current.cursorIdle).toBe(false);
act(() => result.current.handleVideoMouseMove());
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS));
expect(result.current.cursorIdle).toBe(true);
});
it('wakes on movement and idles again after the same delay', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleVideoMouseMove());
startPlayback(video);
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS));
act(() => result.current.handleVideoMouseMove());
expect(result.current.cursorIdle).toBe(false);
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS - 1));
expect(result.current.cursorIdle).toBe(false);
act(() => vi.advanceTimersByTime(1));
expect(result.current.cursorIdle).toBe(true);
});
it('never idles while paused', () => {
const { result } = renderPlayer();
act(() => result.current.handleVideoMouseMove());
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS * 5));
expect(result.current.cursorIdle).toBe(false);
});
it('stays awake once the cursor has left the player', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleVideoMouseMove());
act(() => result.current.handleVideoMouseLeave());
startPlayback(video);
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS * 5));
expect(result.current.cursorIdle).toBe(false);
});
it('stays idle across a pause and play the element emits on its own', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleVideoMouseMove());
startPlayback(video);
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS));
expect(result.current.cursorIdle).toBe(true);
// A rebuffer or a source switch pauses and resumes without any pointer
// activity, so the cursor must not come back for a second on every stall.
stopPlayback(video);
startPlayback(video);
expect(result.current.cursorIdle).toBe(true);
});
});
describe('useVideoPlayer keyboard shortcuts', () => {
it('starts and stops playback on space', () => {
const { video } = renderPlayer();
+156
View File
@@ -0,0 +1,156 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
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') {
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: false,
needsPaymentFix: false,
openInvoice: null,
workspaceCreation: { canCreateWorkspace: true, canStartTrial: false },
subscription: {
status,
label: status === 'ACTIVE' ? 'Active' : 'Trialing',
hasActiveSubscription: true,
hasRecoverableSubscription: true,
hasActiveTrial: true,
hasBillingAccess: true,
currentPeriodEnd: '2026-10-08T12:00:00Z',
trialEndsAt: '2026-09-15T12:00:00Z',
cancelAtPeriodEnd: true,
cancelAt: '2026-10-08T12:00:00Z',
},
},
}),
};
})
);
render(<SettingsPage billingOnly />);
}
afterEach(() => vi.unstubAllGlobals());
describe('scheduled cancellation in billing settings', () => {
it('keeps a paid subscription distinct from its remaining cardless trial', async () => {
renderScheduledCancellation('ACTIVE');
expect(
await screen.findByText(
'Subscription canceled. Access remains active until the end of the current billing period.'
)
).toBeInTheDocument();
expect(screen.queryByText(/Trial canceled/)).not.toBeInTheDocument();
expect(screen.queryByText(/Access ends on/)).not.toBeInTheDocument();
expect(screen.getByText(/Your subscription ends on/)).toHaveTextContent(
new Date('2026-10-08T12:00:00Z').toLocaleDateString()
);
expect(screen.getByText(/Cancellation takes effect on/)).toBeInTheDocument();
expect(screen.queryByText(/Cancellation was scheduled on/)).not.toBeInTheDocument();
});
it('still explains the trial end for a Stripe trial subscription', async () => {
renderScheduledCancellation('TRIALING');
expect(
await screen.findByText('Trial canceled. Access remains active until the trial ends.')
).toBeInTheDocument();
expect(screen.getByText(/Access ends on/)).toHaveTextContent(
new Date('2026-09-15T12:00:00Z').toLocaleDateString()
);
});
});
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(<SettingsPage billingOnly />);
}
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();
});
});
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import SettingsPage from '@/app/(dashboard)/settings/settings-page-client';
// API scaling expectations are literal, independent of production Intl logic.
// USD, JPY, KRW: https://docs.stripe.com/currencies#zero-decimal
// ISK, UGX: https://docs.stripe.com/currencies#special-cases
// KWD: https://support.stripe.com/questions/which-payments-methods-and-products-are-available-in-the-uae?locale=en-GB
// KWD support is account/region dependent. This is a synthetic component fixture,
// not evidence that the configured billing account accepts KWD invoices.
const cases = [
{ currency: 'usd', amountDue: 1099, expected: '$10.99' },
{ currency: 'jpy', amountDue: 500, expected: '¥500' },
{ currency: 'krw', amountDue: 500, expected: '₩500' },
{ currency: 'kwd', amountDue: 12340, expected: 'KWD 12.340' },
{ currency: 'isk', amountDue: 500, expected: 'ISK 5' },
{ currency: 'ugx', amountDue: 500, expected: 'UGX 5' },
];
const NumberFormat = Intl.NumberFormat;
beforeEach(() => {
// Pin the locale while retaining the real currency precision and formatting.
vi.spyOn(Intl, 'NumberFormat').mockImplementation(function (locales, options) {
return new NumberFormat(locales ?? 'en-US', options);
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('actual Settings invoice display against Stripe currency contract', () => {
it.each(cases)('$currency amount_due=$amountDue displays $expected', async (fixture) => {
expect(new Intl.NumberFormat().resolvedOptions().locale).toBe('en-US');
const fetchMock = vi.fn(async (url: string) => {
if (url !== '/api/billing') return { ok: false };
return {
ok: true,
json: async () => ({
data: {
isEnabled: true,
isConfigured: true,
status: 'ready',
checkoutAvailable: false,
portalAvailable: false,
cancelAvailable: false,
cancelIsImmediate: true,
needsPaymentFix: true,
openInvoice: {
id: 'in_currency_fixture',
hostedInvoiceUrl: null,
amountDue: fixture.amountDue,
currency: fixture.currency,
attemptCount: 1,
nextPaymentAttempt: null,
},
workspaceCreation: { canCreateWorkspace: true, canStartTrial: false },
subscription: {
status: 'PAST_DUE',
label: 'Past due',
hasActiveSubscription: false,
hasRecoverableSubscription: true,
hasActiveTrial: false,
hasBillingAccess: true,
isPaid: false,
priceId: null,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
cancelAt: null,
trialEndsAt: null,
billingAccessEndedAt: null,
storageCleanupEligibleAt: null,
},
},
}),
};
});
vi.stubGlobal('fetch', fetchMock);
render(<SettingsPage billingOnly />);
const banner = await screen.findByText(/^A payment of .* did not go through$/);
const actual = banner.textContent!.replace(/\s+/g, ' ');
expect(fetchMock.mock.calls.some(([url]) => url === '/api/billing')).toBe(true);
expect(actual).toBe(`A payment of ${fixture.expected} did not go through`);
});
});
+6 -1
View File
@@ -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' })) },
},
+1
View File
@@ -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. */
+107
View File
@@ -0,0 +1,107 @@
// The assertions parse the encoded bytes back out of the RIFF header, because
// the failure mode that matters is a file an editor opens and plays wrong:
// half speed, one channel, or a burst of noise where a loud passage was.
import { describe, expect, it } from 'vitest';
import { encodeWav, wavByteLength, MAX_WAV_OUTPUT_BYTES } from '@/lib/audio-to-wav';
const HEADER_BYTES = 44;
async function viewOf(blob: Blob): Promise<DataView> {
return new DataView(await blob.arrayBuffer());
}
function ascii(view: DataView, offset: number, length: number): string {
let out = '';
for (let i = 0; i < length; i++) out += String.fromCharCode(view.getUint8(offset + i));
return out;
}
/** Reads back the interleaved samples as the signed 16-bit values on disk. */
function samples(view: DataView): number[] {
const out: number[] = [];
for (let offset = HEADER_BYTES; offset < view.byteLength; offset += 2) {
out.push(view.getInt16(offset, true));
}
return out;
}
describe('encodeWav', () => {
it('writes a RIFF/WAVE header describing the audio it was given', async () => {
const view = await viewOf(encodeWav([new Float32Array(480), new Float32Array(480)], 48000));
expect(ascii(view, 0, 4)).toBe('RIFF');
expect(ascii(view, 8, 4)).toBe('WAVE');
expect(ascii(view, 12, 4)).toBe('fmt ');
expect(view.getUint32(16, true)).toBe(16); // PCM fmt payload
expect(view.getUint16(20, true)).toBe(1); // format tag: PCM
expect(view.getUint16(22, true)).toBe(2); // channels
expect(view.getUint32(24, true)).toBe(48000); // sample rate
expect(view.getUint32(28, true)).toBe(48000 * 2 * 2); // byte rate
expect(view.getUint16(32, true)).toBe(4); // block align
expect(view.getUint16(34, true)).toBe(16); // bits per sample
expect(ascii(view, 36, 4)).toBe('data');
});
it('declares sizes that match the bytes actually written', async () => {
const blob = encodeWav([new Float32Array(100), new Float32Array(100)], 44100);
const view = await viewOf(blob);
const dataBytes = 100 * 2 * 2;
expect(blob.size).toBe(HEADER_BYTES + dataBytes);
expect(view.getUint32(4, true)).toBe(blob.size - 8);
expect(view.getUint32(40, true)).toBe(dataBytes);
expect(wavByteLength(100, 2)).toBe(blob.size);
});
it('interleaves the channels frame by frame', async () => {
const left = Float32Array.from([1, 1, 1]);
const right = Float32Array.from([-1, -1, -1]);
const view = await viewOf(encodeWav([left, right], 48000));
// L R L R L R, not LLL RRR: a planar layout plays as a channel of speech
// followed by a channel of silence.
expect(samples(view)).toEqual([32767, -32768, 32767, -32768, 32767, -32768]);
});
it('clamps samples that overshoot the float range instead of wrapping them', async () => {
// Decoders routinely hand back values slightly outside ±1. Scaled unclamped
// these wrap to the opposite rail and the clip crackles.
const view = await viewOf(encodeWav([Float32Array.from([1.4, -1.4, 0])], 48000));
expect(samples(view)).toEqual([32767, -32768, 0]);
});
it('keeps a mono recording mono', async () => {
const blob = encodeWav([new Float32Array(240)], 48000);
const view = await viewOf(blob);
expect(view.getUint16(22, true)).toBe(1);
expect(view.getUint16(32, true)).toBe(2); // block align: one 16-bit sample
expect(blob.size).toBe(HEADER_BYTES + 240 * 2);
});
it('encodes an empty recording as a valid, empty WAV', async () => {
const blob = encodeWav([new Float32Array(0)], 48000);
const view = await viewOf(blob);
expect(blob.size).toBe(HEADER_BYTES);
expect(view.getUint32(40, true)).toBe(0);
});
it('rejects input it cannot describe in the header', () => {
expect(() => encodeWav([], 48000)).toThrow();
expect(() => encodeWav([new Float32Array(10)], 0)).toThrow();
});
});
describe('wavByteLength', () => {
it('puts the output cap beyond any plausible voice note', () => {
// The cap sits around 35 minutes of 48 kHz stereo. A 10MB Opus upload can
// just about exceed that, which is the case it exists for; an hour-long
// voice note is not a thing anyone records into a review comment.
expect(wavByteLength(48000 * 60 * 10, 2)).toBeLessThan(MAX_WAV_OUTPUT_BYTES);
expect(wavByteLength(48000 * 60 * 45, 2)).toBeGreaterThan(MAX_WAV_OUTPUT_BYTES);
});
});
+409
View File
@@ -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<typeof import('@/lib/stripe')>()),
getStripe: () => stripe,
}));
const START = 1_800_000_000;
const END = 1_802_592_000;
function subscription(overrides: Record<string, unknown> = {}): 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<string, unknown> = {}) {
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<string, unknown> = {}): 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);
});
});
+231 -48
View File
@@ -15,6 +15,9 @@ import {
getOrCreateStripeCustomerId,
getStorageCleanupEligibleAt,
getStripeCheckoutState,
getInvoiceSubscriptionId,
getSubscriptionPeriodEnd,
getSubscriptionPeriodStart,
getTrialNotice,
getWorkspaceCreationEligibility,
hasActiveSubscription,
@@ -33,6 +36,8 @@ import {
} from '@/lib/billing';
const dbMock = vi.hoisted(() => ({
$transaction: vi.fn(),
$executeRaw: vi.fn(),
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
workspace: { count: vi.fn() },
workspaceMember: { count: vi.fn() },
@@ -152,7 +157,11 @@ describe('isPaidTier', () => {
it('counts an active subscription as paid', () => {
expect(
isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.ACTIVE, stripeCurrentPeriodEnd: null },
{
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
},
NOW
)
).toBe(true);
@@ -163,7 +172,11 @@ describe('isPaidTier', () => {
it('counts a Stripe trial as paid', () => {
expect(
isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.TRIALING, stripeCurrentPeriodEnd: null },
{
subscriptionStatus: BillingSubscriptionStatus.TRIALING,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
},
NOW
)
).toBe(true);
@@ -175,6 +188,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
billingAccessEndedAt: null,
},
NOW
)
@@ -185,7 +199,11 @@ describe('isPaidTier', () => {
it('does not count a cardless trial as paid', () => {
expect(
isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
{
subscriptionStatus: BillingSubscriptionStatus.FREE,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
},
NOW
)
).toBe(false);
@@ -200,6 +218,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
billingAccessEndedAt: null,
},
NOW
)
@@ -212,6 +231,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
billingAccessEndedAt: null,
},
NOW
)
@@ -227,6 +247,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
billingAccessEndedAt: null,
},
NOW
)
@@ -239,6 +260,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS),
billingAccessEndedAt: null,
},
NOW
)
@@ -250,7 +272,11 @@ describe('isPaidTier', () => {
expect(
isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
{
subscriptionStatus: BillingSubscriptionStatus.FREE,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
},
NOW
)
).toBe(true);
@@ -366,7 +392,12 @@ describe('hasBillingAccess', () => {
).toBe(true);
});
it('ignores billingAccessEndedAt while the paid period is still running', () => {
// Was the opposite assertion, on the premise that a future period end means a paid
// period. It does not: Stripe advances the period when it issues the renewal invoice,
// paid or not, and the period survives cancellation, so this exact shape (cutoff in the
// past, period end in the future) is what a subscription cancelled while behind on
// payment looks like. Honouring the period here handed out a free month.
it('honours billingAccessEndedAt even while the reported period is still running', () => {
const result = hasBillingAccess(
subject({
subscriptionStatus: 'CANCELED',
@@ -375,9 +406,36 @@ describe('hasBillingAccess', () => {
}),
NOW
);
expect(result).toBe(false);
});
// The other half of that: a stale cutoff must not outrank a live trial, or starting a
// cardless trial on a lapsed account would consume the account's one trial and grant
// nothing, since only a Stripe sync ever clears the cutoff.
it('lets an unexpired trial win over a cutoff already in the past', () => {
const result = hasBillingAccess(
subject({
subscriptionStatus: 'CANCELED',
trialEndsAt: new Date(NOW.getTime() + DAY_MS),
billingAccessEndedAt: new Date(NOW.getTime() - DAY_MS),
}),
NOW
);
expect(result).toBe(true);
});
// Stripe stamps a period on a subscription whose first charge never went through.
it('refuses a period end carried by a subscription that never paid', () => {
const result = hasBillingAccess(
subject({
subscriptionStatus: 'INCOMPLETE_EXPIRED',
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
}),
NOW
);
expect(result).toBe(false);
});
it('grants access to everyone when Stripe is disabled', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const result = hasBillingAccess(
@@ -393,7 +451,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({
@@ -402,10 +460,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({
@@ -413,7 +471,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', () => {
@@ -452,7 +510,14 @@ describe('buildBillingAccessWhereInput', () => {
OR: [
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
{ trialEndsAt: { gt: NOW } },
{ stripeCurrentPeriodEnd: { gt: NOW } },
// Both guards sit inside this arm, mirroring `hasBillingAccess`: the period end
// is only evidence of access when a payment stands behind it and no cutoff has
// passed. Scoped to this arm, not the whole query, so a live trial still wins.
{
stripeCurrentPeriodEnd: { gt: NOW },
subscriptionStatus: { notIn: ['INCOMPLETE', 'INCOMPLETE_EXPIRED'] },
OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: NOW } }],
},
],
});
});
@@ -472,36 +537,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<Record<string, unknown>>;
};
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<Record<string, unknown>> }>;
};
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: [] } });
@@ -808,12 +855,98 @@ function updateData(): Record<string, unknown> {
return dbMock.user.update.mock.calls[0][0].data as Record<string, unknown>;
}
// The shape Stripe actually sends on the pinned API version: the period lives on the
// subscription's items, not on the subscription. `stripeSub` above still uses the older
// top-level shape, so without these the whole reason this code exists goes untested and
// every other test in this file passes through the legacy fallback instead.
describe('Stripe field locations', () => {
const periodStart = 1_800_000_000;
const periodEnd = periodStart + 30 * 86_400;
function itemPeriodSub(overrides: Record<string, unknown> = {}) {
return {
id: 'sub_1',
customer: 'cus_1',
status: 'past_due',
items: {
data: [
{
price: { id: ENTITLED_PRICE },
current_period_start: periodStart,
current_period_end: periodEnd,
},
],
},
...overrides,
} as unknown as Stripe.Subscription;
}
it('reads the period off the subscription items', () => {
expect(getSubscriptionPeriodEnd(itemPeriodSub())).toBe(periodEnd);
expect(getSubscriptionPeriodStart(itemPeriodSub())).toBe(periodStart);
});
// A webhook body can still be rendered at the version that was current when the
// endpoint was created, so the old location has to keep working.
it('falls back to the legacy top-level period', () => {
const legacy = {
items: { data: [{ price: { id: ENTITLED_PRICE } }] },
current_period_start: periodStart,
current_period_end: periodEnd,
} as unknown as Stripe.Subscription;
expect(getSubscriptionPeriodEnd(legacy)).toBe(periodEnd);
expect(getSubscriptionPeriodStart(legacy)).toBe(periodStart);
});
it('returns null when neither location carries a period', () => {
const bare = {
items: { data: [{ price: { id: ENTITLED_PRICE } }] },
} as unknown as Stripe.Subscription;
expect(getSubscriptionPeriodEnd(bare)).toBeNull();
expect(getSubscriptionPeriodStart(bare)).toBeNull();
});
it('reads the invoice subscription off parent.subscription_details', () => {
const invoice = {
parent: { subscription_details: { subscription: 'sub_9' } },
} as unknown as Stripe.Invoice;
expect(getInvoiceSubscriptionId(invoice)).toBe('sub_9');
});
it('accepts an expanded subscription object on the invoice parent', () => {
const invoice = {
parent: { subscription_details: { subscription: { id: 'sub_9' } } },
} as unknown as Stripe.Invoice;
expect(getInvoiceSubscriptionId(invoice)).toBe('sub_9');
});
it('falls back to the legacy top-level invoice subscription', () => {
expect(getInvoiceSubscriptionId({ subscription: 'sub_9' } as unknown as Stripe.Invoice)).toBe(
'sub_9'
);
});
// A one-off invoice belongs to no subscription, and the webhook relies on this to leave
// the account alone rather than marking it canceled.
it('returns null for an invoice with no subscription', () => {
expect(getInvoiceSubscriptionId({} as unknown as Stripe.Invoice)).toBeNull();
});
});
describe('database backed billing helpers', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE);
dbMock.$transaction
.mockReset()
.mockImplementation(async (work: (tx: typeof dbMock) => Promise<unknown>) => work(dbMock));
dbMock.$executeRaw.mockReset().mockResolvedValue(0);
dbMock.user.findUnique.mockReset();
dbMock.user.update.mockReset();
dbMock.user.updateMany.mockReset();
@@ -1451,31 +1584,77 @@ describe('database backed billing helpers', () => {
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
});
it('keeps access while a canceled subscription is still inside its paid period', async () => {
// Was asserting `billingAccessEndedAt: null` here, i.e. that a canceled subscription
// keeps access to the reported period end. That is only right if the period was paid
// for, and a canceled subscription cannot tell you that it was: the period Stripe
// reports advances when the renewal invoice is issued and survives the cancellation,
// so this is also exactly the shape of "cancelled while behind on payment". A cutoff
// is stamped instead, and `ended_at` is what it comes from.
it('stamps a cutoff on a canceled subscription rather than trusting its period', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
const endedAt = Math.floor(NOW.getTime() / 1000);
await syncStripeSubscriptionToUser(
stripeSub({
status: 'canceled',
ended_at: endedAt,
current_period_end: Math.floor(NOW.getTime() / 1000) + 3600,
})
);
expect(updateData()).toMatchObject({
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
billingAccessEndedAt: null,
});
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(endedAt * 1000);
});
it('ends access at the period end once the paid period has passed', async () => {
// Behind on payment but still being retried: access runs to the end of Stripe's retry
// window, measured from the period start, not to the period end Stripe advanced to
// cover the invoice that was never paid.
it('bounds a past_due subscription to the retry window', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
const periodEnd = Math.floor(NOW.getTime() / 1000) - 3600;
const periodStart = Math.floor(NOW.getTime() / 1000);
await syncStripeSubscriptionToUser(
stripeSub({ status: 'canceled', current_period_end: periodEnd })
stripeSub({
status: 'past_due',
current_period_start: periodStart,
current_period_end: periodStart + 30 * 24 * 60 * 60,
})
);
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(periodEnd * 1000);
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(
(periodStart + 14 * 24 * 60 * 60) * 1000
);
});
// The same thing through the payload shape production actually sends, where the period
// sits on the items rather than on the subscription. Every other fixture in this file
// uses the older top-level shape and so never exercises the read this change is for.
it('bounds a past_due subscription whose period is on its items', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
const periodStart = Math.floor(NOW.getTime() / 1000);
const periodEnd = periodStart + 30 * 24 * 60 * 60;
await syncStripeSubscriptionToUser({
id: 'sub_1',
customer: 'cus_1',
status: 'past_due',
items: {
data: [
{
price: { id: ENTITLED_PRICE },
current_period_start: periodStart,
current_period_end: periodEnd,
},
],
},
} as unknown as Stripe.Subscription);
expect((updateData().stripeCurrentPeriodEnd as Date).getTime()).toBe(periodEnd * 1000);
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(
(periodStart + 14 * 24 * 60 * 60) * 1000
);
});
it('falls back to ended_at when there is no period end', async () => {
@@ -1627,10 +1806,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),
@@ -1641,7 +1823,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);
});
@@ -1705,7 +1887,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 () => {
@@ -1716,7 +1899,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());
});