Author SHA1 Message Date
dependabot[bot] 6d74602b3e chore(deps): bump next in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 16.2.11 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.11...v16.3.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.3
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-11 06:02:05 +00:00
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
Yusuf İpek 79bba5e7a1 Merge pull request #74 from yusufipk/claude/compress-video-landing-page-08cc57
feat(landing): replace the hero image with the flow video
2026-09-01 15:28:59 +03:00
Yusuf İpek ab03f7c378 Merge pull request #73 from yusufipk/fix/download-unload-guard
feat(billing): defer the cardless trial for invited collaborators
2026-09-01 15:24:50 +03:00
yusufipek 43cc54c0ae feat(landing): drop the toolbar overlay and gradient from the hero video 2026-09-01 15:23:17 +03:00
yusufipek 54e99cb4ab test(api): register billing/trial in the auth matrix 2026-09-01 15:17:51 +03:00
yusufipek 5f061d1b09 feat(landing): replace the hero image with the flow video 2026-09-01 15:12:40 +03:00
yusufipek 59a64141ee chore(lint): ignore .claude worktree checkouts in eslint
A worktree parked under .claude/worktrees is a separate checkout; eslint
scanning it fails bun run check on files outside this tree.
2026-09-01 15:09:53 +03:00
yusufipek 4b3c3934dd feat(billing): defer the cardless trial for invited collaborators
An account that signs up through an invitation works on the inviter's
billing, so handing it a trial at signup spent its only trial before it
owned anything. The trial is now held back for collaborators and claimed
only explicitly: a Start Free Trial button on the new-workspace and
billing screens calls the new POST /api/billing/trial endpoint, which
grants the once-per-account trial atomically. Nothing starts the clock
as a side effect, and pure collaborators no longer see a trial-ending
banner about work that is not theirs.
2026-09-01 15:07:54 +03:00
Yusuf İpek c5c9da1e30 Merge pull request #70 from yusufipk/claude/landing-page-removal-c07551
feat(landing): remove the Fair Source badge from the hero
2026-08-25 07:44:00 +03:00
yusufipek 07a6bfbef4 feat(landing): remove the Fair Source badge from the hero 2026-08-25 07:37:30 +03:00
Yusuf İpek d894eeb0e4 Merge pull request #69 from yusufipk/fix/download-unload-guard
fix(download): warn before the tab closes mid-download
2026-08-22 13:02:32 +03:00
yusufipek cba8163286 fix(download): warn before the tab closes mid-download
Bunny and direct downloads are pulled through fetch() so we can save them
under our own filename. The browser does not treat that as a download, so
closing the tab discarded everything received so far without a word.

Register a reference counted beforeunload guard while those transfers are
in flight, and while a project manifest is being pulled file by file.
Browser owned downloads (same-origin proxy, the over-10GB fallback, asset
downloads) survive a tab close on their own and stay unguarded.
2026-08-22 12:50:45 +03:00
Yusuf İpek 74e4b4353e Merge pull request #68 from yusufipk/feat/version-subtitles
feat(player): let editors upload subtitles for a version
2026-08-22 08:16:35 +03:00
yusufipek a709ca8544 fix(subtitles): escape a rejected cue tag instead of deleting it
Deleting a tag whole is what lets a filter like this be reassembled around: strip the `<b>` out of `<scr<b>ipt>`
and the two halves close up into a tag nobody wrote. The leftovers are escaped one character at a time instead,
which also covers `-->` in cue text without a second multi-character replacement.

Both are what CodeQL flagged on the branch, js/incomplete-multi-character-sanitization and js/bad-tag-filter.
Neither was reachable as an injection, because the file is served as text/vtt and a cue is parsed by the WebVTT
cue-text parser rather than as HTML, but a sanitiser that cannot be reassembled around is the cheaper thing to own.
2026-08-22 08:01:12 +03:00
yusufipek d981d98cf5 feat(player): let editors upload subtitles for a version
Subtitle tracks hang off a version rather than off a video, because re-editing a cut shifts every cue. The file
always lands in our own S3-compatible storage whatever hosts the video, so a Bunny-hosted cut and an R2 one take the
same path: both already play through our own video element, so a track element is all it takes.

Uploads are normalised before they are stored. Whatever arrives, SRT or WebVTT, is parsed into cues and
re-serialised as a canonical WebVTT file, and anything we did not understand is dropped rather than passed through.
That is what makes it safe to serve a user-supplied text file from our own origin. Files saved out of Windows
editors are decoded as windows-1254 or windows-1252 when they are not valid UTF-8, rather than refused.

A YouTube version cannot carry an uploaded track, so the same CC menu drives YouTube's own captions through the
iframe module API. The embed hides YouTube's controls, so until now those captions were unreachable even when the
video had them.

Uploading and deleting take the editor permission rather than the commenter one: a subtitle is part of the
delivered cut, not a comment attachment.
2026-08-22 07:51:46 +03:00
Yusuf İpek 1f3c6b3f1e Merge pull request #67 from yusufipk/chore/version-0-1-1
chore(release): set the package version to 0.1.1
2026-08-20 17:21:40 +03:00
yusufipek 6f575e48bf chore(release): set the package version to 0.1.1
The tag and the manifest had drifted: v0.1.1 ships the runtime Bunny CDN
config, while package.json still read 0.1.0. bun.lock records no version for
the root workspace, so a frozen install is unaffected.
2026-08-20 17:11:16 +03:00
86 changed files with 8785 additions and 1079 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
- Timestamped comments directly on the video timeline
- Voice notes, image attachments, and frame annotations
- Version history with side-by-side compare
- Version history with side-by-side compare and per-version subtitle tracks
- Approval requests and sign-off tracking
- Share links for client review with optional guest commenting
- Workspaces, projects, member roles, and invitation flows
@@ -41,7 +41,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
### Versioning And Comparison
- Videos support multiple versions inside the same review thread.
- Videos support multiple versions inside the same review thread, each with its own subtitle tracks uploaded as SRT or WebVTT.
- Teams can switch between versions without losing review context.
- Compare mode lets reviewers inspect two versions side by side.
@@ -52,6 +52,7 @@ import {
type ProjectDownloadManifest,
} from '@/lib/client/project-download';
import { downloadProgressPercent } from '@/lib/client/download-file';
import { beginUnloadGuard } from '@/lib/client/unload-guard';
import {
createDownloadProgressToast,
type DownloadProgressToastHandle,
@@ -209,6 +210,7 @@ export function ProjectContentClient({
setIsDownloading(true);
let progressToast: DownloadProgressToastHandle | null = null;
let releaseUnloadGuard: (() => void) | null = null;
try {
const response = await fetch(`/api/projects/${projectId}/download${query}`, {
cache: 'no-store',
@@ -232,6 +234,9 @@ export function ProjectContentClient({
title: `Downloading ${manifest.totalFiles} files`,
description: 'Starting…',
});
// The files are pulled one by one through this tab, so closing it drops
// everything that hasn't been saved yet. Warn before that happens.
releaseUnloadGuard = beginUnloadGuard();
await runProjectDownloadManifest(manifest, (p) => {
const percent = downloadProgressPercent({
receivedBytes: p.receivedBytes,
@@ -250,6 +255,7 @@ export function ProjectContentClient({
progressToast?.dismiss();
toast.error('Failed to start project download');
} finally {
releaseUnloadGuard?.();
setIsDownloading(false);
}
},
@@ -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) => {
+212 -19
View File
@@ -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;
@@ -67,6 +99,7 @@ interface BillingOverview {
};
workspaceCreation: {
canCreateWorkspace: boolean;
canStartTrial?: boolean;
reason: string | null;
ownedWorkspaceCount: number;
invitedWorkspaceCount: number;
@@ -147,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' | 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);
@@ -254,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();
@@ -278,6 +318,72 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
[showMessage]
);
const handleStartTrial = useCallback(async () => {
setBillingAction('trial');
try {
const res = await fetch('/api/billing/trial', { method: 'POST' });
const data = await res.json();
if (!res.ok) {
showMessage('error', data.error || 'Failed to start your free trial');
return;
}
const billingRes = await fetch('/api/billing');
if (billingRes.ok) {
setBilling((await billingRes.json()).data);
}
showMessage('success', 'Your free trial has started');
} catch {
showMessage('error', 'Failed to start your free trial');
} finally {
setBillingAction(null);
}
}, [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">
@@ -388,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
@@ -408,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">
@@ -431,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}
@@ -457,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' ? (
@@ -474,20 +626,50 @@ 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
onClick={() => handleBillingRedirect('/api/billing/checkout')}
disabled={!billing.checkoutAvailable || billingAction !== null}
variant="ghost"
className="text-muted-foreground"
onClick={() => setCancelDialogOpen(true)}
disabled={billingAction !== null}
>
{billingAction === 'checkout' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Redirecting...
</>
) : (
'Upgrade with Stripe'
)}
Cancel subscription
</Button>
) : null}
{billing.subscription.hasRecoverableSubscription &&
billing.portalAvailable ? null : (
<>
{billing.workspaceCreation.canStartTrial ? (
<Button onClick={handleStartTrial} disabled={billingAction !== null}>
{billingAction === 'trial' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Starting Trial...
</>
) : (
'Start Free Trial'
)}
</Button>
) : null}
<Button
variant={billing.workspaceCreation.canStartTrial ? 'outline' : 'default'}
onClick={() => handleBillingRedirect('/api/billing/checkout')}
disabled={!billing.checkoutAvailable || billingAction !== null}
>
{billingAction === 'checkout' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Redirecting...
</>
) : (
'Upgrade with Stripe'
)}
</Button>
</>
)}
</div>
</>
@@ -495,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>
@@ -15,12 +15,35 @@ export default function NewWorkspacePage({
}: {
workspaceCreation: {
canCreateWorkspace: boolean;
canStartTrial?: boolean;
reason: string | null;
};
}) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isStartingTrial, setIsStartingTrial] = useState(false);
const [error, setError] = useState('');
const handleStartTrial = async () => {
setIsStartingTrial(true);
setError('');
try {
const response = await fetch('/api/billing/trial', { method: 'POST' });
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to start your free trial');
return;
}
router.refresh();
} catch {
setError('Something went wrong. Please try again.');
} finally {
setIsStartingTrial(false);
}
};
const [formData, setFormData] = useState({
name: '',
description: '',
@@ -76,7 +99,11 @@ export default function NewWorkspacePage({
)}
</div>
<CardTitle className="text-2xl">
{workspaceCreation.canCreateWorkspace ? 'Create New Workspace' : 'Upgrade Required'}
{workspaceCreation.canCreateWorkspace
? 'Create New Workspace'
: workspaceCreation.canStartTrial
? 'Start Your Free Trial'
: 'Upgrade Required'}
</CardTitle>
<CardDescription className="text-base">
{workspaceCreation.canCreateWorkspace
@@ -139,9 +166,27 @@ export default function NewWorkspacePage({
You can still create and manage projects inside workspaces where you are already a
member.
</p>
<Button asChild className="w-full">
<Link href="/settings">Open Billing Settings</Link>
</Button>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{workspaceCreation.canStartTrial ? (
<Button className="w-full" onClick={handleStartTrial} disabled={isStartingTrial}>
{isStartingTrial ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Starting Trial...
</>
) : (
'Start Free Trial'
)}
</Button>
) : (
<Button asChild className="w-full">
<Link href="/settings">Open Billing Settings</Link>
</Button>
)}
</div>
)}
</CardContent>
+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>
);
}
+2 -2
View File
@@ -22,7 +22,7 @@ import {
isValidEmailAddress,
normalizeEmail,
} from '@/lib/email-validation';
import { startCardlessTrial } from '@/lib/billing';
import { startCardlessTrialOnSignup } from '@/lib/billing';
import { recordSignupCompleted } from '@/lib/analytics/signup';
import { readRequestVisitor } from '@/lib/analytics/visitor';
@@ -166,7 +166,7 @@ export async function POST(request: NextRequest) {
// just lock the user out of an instance that has billing switched on.
if (!emailVerificationRequired) {
warnIfTrialsSkipVerification();
await startCardlessTrial(user.id);
await startCardlessTrialOnSignup(user.id);
}
// Send verification email if SMTP is configured
+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,
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse } from '@/lib/api-response';
import { startCardlessTrial } from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger';
import { db } from '@/lib/db';
/**
* The explicit claim of a deferred cardless trial.
*
* An invited collaborator has their trial held back at signup; nothing else in
* the product is allowed to start it as a side effect, because the clock spends
* the account's only trial. This endpoint is the one place the user says "start
* it now", from the workspace-creation and billing screens.
*/
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();
}
if (!isStripeFeatureEnabled()) {
return apiErrors.badRequest('Stripe billing is disabled by this host');
}
const started = await startCardlessTrial(session.user.id);
if (!started) {
return apiErrors.conflict('Your free trial has already been used');
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { trialEndsAt: true },
});
return successResponse({ trialEndsAt: user?.trialEndsAt ?? null });
} catch (error) {
logError('billing.trial.start', error);
return apiErrors.internalError();
}
}
@@ -130,6 +130,14 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
videoId: result.version.videoId,
};
// Read before the delete: the rows cascade away with the version, and their stored
// objects would then have nothing pointing at them. Subtitles live in our own storage
// whatever hosts the video, so this runs for a Bunny-hosted cut too.
const subtitles = await db.videoSubtitle.findMany({
where: { versionId },
select: { sourceUrl: true },
});
await db.$transaction(async (tx) => {
// Delete the version (cascades to comments).
await tx.videoVersion.delete({ where: { id: versionId } });
@@ -149,15 +157,16 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
});
const versionMediaUrls = [
...subtitles.map((subtitle) => subtitle.sourceUrl),
...(result.version.providerId === 'r2'
? [result.version.originalUrl, result.version.thumbnailUrl]
: []),
].filter((url): url is string => Boolean(url));
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
cleanupBunnyStreamVideosBestEffort([bunnyRef]),
result.version.providerId === 'r2'
? deleteMediaFilesBestEffort(
[result.version.originalUrl, result.version.thumbnailUrl].filter((url): url is string =>
Boolean(url)
)
)
: Promise.resolve({ attempted: 0, failed: 0, failedKeys: [] }),
deleteMediaFilesBestEffort(versionMediaUrls),
]);
const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult };
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
+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;
}
@@ -0,0 +1,91 @@
import { NextRequest } from 'next/server';
import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db';
import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { apiErrors } from '@/lib/api-response';
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
import { logError } from '@/lib/logger';
import {
SAFE_SUBTITLE_FILENAME,
SUBTITLE_CONTENT_TYPE,
SUBTITLE_OBJECT_KEY_PREFIX,
subtitleFileNameToProxyUrl,
} from '@/lib/subtitle-validation';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Validate filename to prevent path traversal
if (!SAFE_SUBTITLE_FILENAME.test(filename)) {
return apiErrors.badRequest('Invalid filename');
}
// Parallelize the DB lookup and session check to narrow the timing delta
// between "subtitle not found" and "subtitle found, access denied" responses.
const [subtitle, session] = await Promise.all([
db.videoSubtitle.findUnique({
where: { sourceUrl: subtitleFileNameToProxyUrl(filename) },
select: {
version: {
select: {
video: {
select: {
id: true,
projectId: true,
project: {
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
},
},
},
},
},
},
}),
auth(),
]);
const video = subtitle?.version?.video ?? null;
if (!video) {
return apiErrors.forbidden('Access denied');
}
const access = await checkProjectAccess(video.project, session?.user?.id);
if (!access.hasAccess) {
const shareSession = getShareSessionFromRequest(request, video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: video.projectId,
videoId: video.id,
requiredPermission: 'VIEW',
passwordVerified: shareSession.passwordVerified,
})
: null;
if (!shareAccess?.hasAccess) {
return apiErrors.forbidden('Access denied');
}
}
return proxyR2MediaObject({
request,
key: `${SUBTITLE_OBJECT_KEY_PREFIX}${filename}`,
fallbackContentType: SUBTITLE_CONTENT_TYPE,
cacheControl: 'private, no-store',
extraHeaders: {
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
},
internalErrorMessage: 'Failed to retrieve subtitle',
});
} catch (error: unknown) {
logError('Error serving subtitle:', error);
return apiErrors.internalError('Failed to retrieve subtitle');
}
}
@@ -0,0 +1,48 @@
import { NextRequest } from 'next/server';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { db } from '@/lib/db';
import { logError } from '@/lib/logger';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { rateLimit } from '@/lib/rate-limit';
import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation';
import { getVideoAssetAccessContext } from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string; subtitleId: string }> };
// DELETE /api/videos/[videoId]/subtitles/[subtitleId]
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'subtitle-delete');
if (limited) return limited;
const { videoId, subtitleId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
if (!context.viewerUserId || !context.canManageAssets) {
return apiErrors.forbidden('Access denied');
}
const subtitle = await db.videoSubtitle.findFirst({
where: { id: subtitleId, version: { videoParentId: videoId } },
select: { id: true, sourceUrl: true },
});
if (!subtitle) return apiErrors.notFound('Subtitle');
// Storage first, row second, for the same reason video deletion does it in that
// order: a refused delete leaves the row in place so the operation can be retried,
// rather than orphaning an object nothing points at any more.
const objectKey = subtitleProxyPathToObjectKey(subtitle.sourceUrl);
if (objectKey) {
await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: objectKey }));
}
await db.videoSubtitle.delete({ where: { id: subtitle.id } });
const response = successResponse({ id: subtitle.id, deleted: true });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting subtitle:', error);
return apiErrors.internalError('Failed to delete subtitle');
}
}
+276
View File
@@ -0,0 +1,276 @@
import { NextRequest } from 'next/server';
import { DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { db } from '@/lib/db';
import { logError } from '@/lib/logger';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { rateLimit } from '@/lib/rate-limit';
import {
releaseStorageReservation,
reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import {
getSubtitleExtension,
MAX_SUBTITLE_FILE_SIZE,
normalizeSubtitleFile,
normalizeSubtitleLanguage,
sanitizeSubtitleLabel,
subtitleFileNameToProxyUrl,
SUBTITLE_CONTENT_TYPE,
SUBTITLE_OBJECT_KEY_PREFIX,
subtitleProxyPathToObjectKey,
} from '@/lib/subtitle-validation';
import { getVideoAssetAccessContext } from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string }> };
const MAX_MULTIPART_BODY_SIZE = MAX_SUBTITLE_FILE_SIZE + 64 * 1024;
/** A cut with more tracks than this is not being subtitled, it is being used as storage. */
const MAX_SUBTITLES_PER_VERSION = 20;
type SubtitleRow = {
id: string;
versionId: string;
language: string;
label: string;
sourceUrl: string;
sizeBytes: bigint;
createdAt: Date;
updatedAt: Date;
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
};
function shapeSubtitle(subtitle: SubtitleRow, canManage: boolean) {
return {
id: subtitle.id,
versionId: subtitle.versionId,
language: subtitle.language,
label: subtitle.label,
url: subtitle.sourceUrl,
sizeBytes: Number(subtitle.sizeBytes),
createdAt: subtitle.createdAt,
updatedAt: subtitle.updatedAt,
uploadedByUser: subtitle.uploadedByUser,
canDelete: canManage,
};
}
const SUBTITLE_SELECT = {
id: true,
versionId: true,
language: true,
label: true,
sourceUrl: true,
sizeBytes: true,
createdAt: true,
updatedAt: true,
uploadedByUser: { select: { id: true, name: true, image: true } },
} as const;
// GET /api/videos/[videoId]/subtitles?versionId=...
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'subtitle-list');
if (limited) return limited;
const { videoId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
const versionId = request.nextUrl.searchParams.get('versionId')?.trim() || null;
const subtitles = await db.videoSubtitle.findMany({
where: {
version: {
videoParentId: videoId,
...(versionId ? { id: versionId } : {}),
},
},
orderBy: [{ language: 'asc' }],
select: SUBTITLE_SELECT,
});
const response = successResponse({
subtitles: subtitles.map((subtitle) => shapeSubtitle(subtitle, context.canManageAssets)),
canManageSubtitles: context.canManageAssets,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error listing subtitles:', error);
return apiErrors.internalError('Failed to load subtitles');
}
}
// POST /api/videos/[videoId]/subtitles
export async function POST(request: NextRequest, { params }: RouteParams) {
let reservationId: string | null = null;
let billedUserId: string | null = null;
let storedObjectKey: string | null = null;
try {
const contentLength = request.headers.get('content-length');
if (!contentLength) {
return apiErrors.badRequest('Missing Content-Length header');
}
const bodySize = Number.parseInt(contentLength, 10);
if (!Number.isFinite(bodySize) || bodySize <= 0) {
return apiErrors.badRequest('Invalid Content-Length header');
}
if (bodySize > MAX_MULTIPART_BODY_SIZE) {
return apiErrors.badRequest('Subtitle file is too large. Maximum size is 2MB.');
}
const limited = await rateLimit(request, 'subtitle-create');
if (limited) return limited;
const { videoId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
// A subtitle is part of the delivered cut rather than a comment attachment, so it
// takes the editor permission and never the commenter one. Guests and share-link
// viewers can read the tracks but cannot add them.
if (!context.viewerUserId || !context.canManageAssets) {
return apiErrors.forbidden('Access denied');
}
const formData = await request.formData();
const files = formData.getAll('subtitle');
if (files.length !== 1 || !(files[0] instanceof File)) {
return apiErrors.badRequest('No subtitle file provided');
}
const file = files[0];
if (file.size > MAX_SUBTITLE_FILE_SIZE) {
return apiErrors.badRequest('Subtitle file is too large. Maximum size is 2MB.');
}
if (!getSubtitleExtension(file.name)) {
return apiErrors.badRequest('Subtitle must be a .srt or .vtt file');
}
const versionIdValue = formData.get('versionId');
if (typeof versionIdValue !== 'string' || !versionIdValue.trim()) {
return apiErrors.badRequest('versionId is required');
}
const versionId = versionIdValue.trim();
const language = normalizeSubtitleLanguage(formData.get('language'));
if (!language) {
return apiErrors.badRequest('language must be a BCP-47 tag such as "tr" or "en-US"');
}
const label = sanitizeSubtitleLabel(formData.get('label'), language.toUpperCase());
const version = await db.videoVersion.findFirst({
where: { id: versionId, videoParentId: videoId },
select: { id: true },
});
if (!version) return apiErrors.notFound('Version');
const existing = await db.videoSubtitle.findUnique({
where: { versionId_language: { versionId, language } },
select: { id: true, sourceUrl: true },
});
if (!existing) {
const trackCount = await db.videoSubtitle.count({ where: { versionId } });
if (trackCount >= MAX_SUBTITLES_PER_VERSION) {
return apiErrors.badRequest(
`A version can hold at most ${MAX_SUBTITLES_PER_VERSION} subtitle tracks`
);
}
}
const normalized = normalizeSubtitleFile(new Uint8Array(await file.arrayBuffer()));
if (!normalized.ok) {
return apiErrors.badRequest(normalized.error);
}
const body = Buffer.from(normalized.vtt, 'utf8');
const sizeBytes = BigInt(body.byteLength);
billedUserId = context.video.project.workspace.ownerId;
const reserveResult = await reserveStorageQuota(
billedUserId,
sizeBytes,
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
);
if ('error' in reserveResult) return reserveResult.error;
reservationId = reserveResult.reservationId;
const fileName = `${randomUUID()}.vtt`;
const objectKey = `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`;
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: objectKey,
Body: body,
ContentType: SUBTITLE_CONTENT_TYPE,
})
);
storedObjectKey = objectKey;
const created = await db.$transaction(async (tx) => {
if (existing) {
await tx.videoSubtitle.delete({ where: { id: existing.id } });
}
return tx.videoSubtitle.create({
data: {
versionId,
language,
label,
sourceUrl: subtitleFileNameToProxyUrl(fileName),
sizeBytes,
billedUserId: billedUserId as string,
uploadedByUserId: context.viewerUserId,
},
select: SUBTITLE_SELECT,
});
});
// The row is committed, so the bytes are counted by the usage sum and the hold that
// stood in for them until now is no longer needed.
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
);
reservationId = null;
storedObjectKey = null;
if (existing) {
// Best effort: the replaced track is already unreachable, and a stranded object is
// a cleanup problem rather than a reason to fail an upload that succeeded.
const staleKey = subtitleProxyPathToObjectKey(existing.sourceUrl);
if (staleKey) {
try {
await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: staleKey }));
} catch (deleteError) {
logError('Failed to delete replaced subtitle object:', deleteError);
}
}
}
const response = successResponse(shapeSubtitle(created, true), 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
if (storedObjectKey) {
try {
await r2Client.send(
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: storedObjectKey })
);
} catch (cleanupError) {
logError('Failed to clean up subtitle object after a failed upload:', cleanupError);
}
}
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
);
logError('Error uploading subtitle:', error);
return apiErrors.internalError('Failed to upload subtitle');
}
}
+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],
},
+578 -703
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}
+56 -1
View File
@@ -38,6 +38,8 @@ import type {
} from '@/components/video-page/types';
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
import { useSubtitles } from '@/components/video-page/hooks/use-subtitles';
import { useYoutubeCaptions } from '@/components/video-page/hooks/use-youtube-captions';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils';
@@ -102,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');
@@ -212,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) => {
@@ -275,6 +278,24 @@ export function VideoPageContent({
}, [video?.versions, activeVersionId]);
const activeProviderId = activeVersion?.providerId;
const speedOptions = getSpeedOptionsForProvider(activeProviderId);
// Only the providers that play through our own <video> element can carry a <track>.
// A YouTube version is an iframe we do not control, and it brings its own captions.
const supportsSubtitles = activeProviderId === 'bunny' || activeProviderId === 'r2';
const {
subtitles,
subtitleTrackKey,
canManageSubtitles,
activeSubtitleLanguage,
selectSubtitleLanguage,
uploadSubtitle,
deleteSubtitle,
isUploadingSubtitle,
} = useSubtitles({
videoId,
versionId: activeVersionId,
videoRef,
supportsSubtitles,
});
const activeVersionDuration = activeVersion?.duration;
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const embedUrl = useMemo(() => {
@@ -305,6 +326,7 @@ export function VideoPageContent({
const {
isReady,
youtubeModuleRevision,
bunnyPlaybackState,
currentTime,
setCurrentTime,
@@ -359,6 +381,27 @@ export function VideoPageContent({
setViewingAnnotation,
});
const { youtubeCaptionTracks, activeYoutubeCaptionLanguage, selectYoutubeCaptionLanguage } =
useYoutubeCaptions({
videoId,
versionId: activeVersionId,
playerRef,
enabled: activeProviderId === 'youtube',
isReady,
moduleRevision: youtubeModuleRevision,
});
// One CC menu, two sources behind it. A YouTube version can only offer the captions the
// video already carries, so nothing there is ours to manage.
const isYoutubeVersion = activeProviderId === 'youtube';
const subtitleTracks = isYoutubeVersion ? youtubeCaptionTracks : subtitles;
const activeCaptionLanguage = isYoutubeVersion
? activeYoutubeCaptionLanguage
: activeSubtitleLanguage;
const selectCaptionLanguage = isYoutubeVersion
? selectYoutubeCaptionLanguage
: selectSubtitleLanguage;
const {
savedProgress,
showResumePrompt,
@@ -823,6 +866,15 @@ export function VideoPageContent({
selectedQualityLevel={selectedQualityLevel}
qualityOptions={qualityOptions}
handleQualityChange={handleQualityChange}
subtitles={subtitles}
subtitleTracks={subtitleTracks}
subtitleTrackKey={subtitleTrackKey}
activeSubtitleLanguage={activeCaptionLanguage}
onSelectSubtitleLanguage={selectCaptionLanguage}
canManageSubtitles={canManageSubtitles}
onUploadSubtitle={uploadSubtitle}
onDeleteSubtitle={deleteSubtitle}
isUploadingSubtitle={isUploadingSubtitle}
playbackSpeed={playbackSpeed}
speedOptions={speedOptions}
handleSpeedChange={handleSpeedChange}
@@ -875,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>
@@ -0,0 +1,32 @@
/**
* The chosen subtitle language, remembered per video the way a player is expected to.
*
* Shared by both caption paths, so a viewer who turned Turkish on for a Bunny-hosted cut
* gets Turkish again on the YouTube version of the same video.
*/
function preferenceKey(videoId: string): string {
return `openframe:subtitle-language:${videoId}`;
}
export function readStoredSubtitleLanguage(videoId: string): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(preferenceKey(videoId));
} catch {
return null;
}
}
export function writeStoredSubtitleLanguage(videoId: string, language: string | null): void {
if (typeof window === 'undefined') return;
try {
if (language) {
window.localStorage.setItem(preferenceKey(videoId), language);
} else {
window.localStorage.removeItem(preferenceKey(videoId));
}
} catch {
// A browser with storage disabled still gets subtitles, just not a remembered choice.
}
}
@@ -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,18 +17,13 @@ import {
downloadProgressPercent,
extensionFromUrl,
navigateDownload,
sanitizeDownloadFileName,
} from '@/lib/client/download-file';
import {
createDownloadProgressToast,
type DownloadProgressToastHandle,
} from '@/components/download-progress-toast';
function sanitizeDownloadFileName(value: string): string {
return value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
.replace(/\s+/g, ' ')
.trim();
}
import { beginUnloadGuard } from '@/lib/client/unload-guard';
function getAllowedHosts() {
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
@@ -94,6 +89,7 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
isDownloadingRef.current = true;
setActiveDownloadTarget(target);
let progressToast: DownloadProgressToastHandle | null = null;
let releaseUnloadGuard: (() => void) | null = null;
try {
let downloadUrl: string | null = null;
@@ -167,6 +163,9 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
title: `Downloading “${baseName}`,
description: 'Starting…',
});
// The bytes only exist in this tab until the blob is saved, so warn
// before the page goes away instead of losing the whole transfer.
releaseUnloadGuard = beginUnloadGuard();
const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`, (p) => {
progressToast?.update({
description: downloadProgressLabel(p),
@@ -195,6 +194,7 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
toast.error('Failed to start download');
}
} finally {
releaseUnloadGuard?.();
isDownloadingRef.current = false;
setActiveDownloadTarget(null);
}
@@ -0,0 +1,270 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import {
readStoredSubtitleLanguage,
writeStoredSubtitleLanguage,
} from '@/components/video-page/hooks/subtitle-preference';
import type { Subtitle } from '@/components/video-page/types';
interface UseSubtitlesParams {
videoId: string;
versionId: string | null;
videoRef: RefObject<HTMLVideoElement | null>;
/** Only the providers we play through our own element can carry a track. */
supportsSubtitles: boolean;
}
/**
* A wiped track is remounted at most this many times per version. A file that really is
* empty cannot reach storage (the upload route refuses one), so the cap only exists so a
* surprise can never turn into a fetch loop.
*/
const MAX_TRACK_REPAIRS = 3;
export function useSubtitles({
videoId,
versionId,
videoRef,
supportsSubtitles,
}: UseSubtitlesParams) {
const [subtitles, setSubtitles] = useState<Subtitle[]>([]);
const [canManageSubtitles, setCanManageSubtitles] = useState(false);
const [activeLanguage, setActiveLanguage] = useState<string | null>(null);
const [isUploadingSubtitle, setIsUploadingSubtitle] = useState(false);
// Bumped to remount the <track> elements when something empties them. See the effect
// below for what does that and why remounting is the fix.
const [trackEpoch, setTrackEpoch] = useState(0);
// The stored preference is applied once per version, not on every list refresh: turning
// subtitles off and then deleting an unrelated track must not switch them back on.
const appliedPreferenceForVersionRef = useRef<string | null>(null);
const loadedLanguagesRef = useRef<Set<string>>(new Set());
const repairCountRef = useRef(0);
useEffect(() => {
loadedLanguagesRef.current.clear();
repairCountRef.current = 0;
}, [versionId]);
const refresh = useCallback(async () => {
if (!versionId || !supportsSubtitles) {
setSubtitles([]);
setCanManageSubtitles(false);
return;
}
try {
const res = await fetch(
`/api/videos/${videoId}/subtitles?versionId=${encodeURIComponent(versionId)}`,
{ cache: 'no-store' }
);
if (!res.ok) return;
const payload = await res.json();
const list: Subtitle[] = Array.isArray(payload?.data?.subtitles)
? payload.data.subtitles
: [];
setSubtitles(list);
setCanManageSubtitles(Boolean(payload?.data?.canManageSubtitles));
} catch {
// A failed list leaves the player without tracks, which is the same as having none.
}
}, [supportsSubtitles, versionId, videoId]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!versionId) return;
if (appliedPreferenceForVersionRef.current === versionId) return;
if (subtitles.length === 0) return;
appliedPreferenceForVersionRef.current = versionId;
const stored = readStoredSubtitleLanguage(videoId);
if (stored && subtitles.some((subtitle) => subtitle.language === stored)) {
setActiveLanguage(stored);
}
}, [subtitles, versionId, videoId]);
// A track that is no longer in the list cannot stay selected.
useEffect(() => {
if (!activeLanguage) return;
if (subtitles.some((subtitle) => subtitle.language === activeLanguage)) return;
setActiveLanguage(null);
}, [activeLanguage, subtitles]);
/**
* React renders the <track> elements; their display mode is set here rather than through
* the `default` attribute, which the browser only honours on first load and which would
* fight the user's choice on every re-render.
*
* The second job here is repair. hls.js empties every text track on the media element,
* ours included, each time it loads a manifest (`_cleanTracks()` in its timeline
* controller). That fires on the initial load and again on every source switch, so a
* viewer who flips quality would watch the subtitles vanish for good: the file has
* already been fetched, so the browser never parses it a second time. Remounting the
* track element under a new key is what makes it fetch again.
*/
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
const findActiveTrack = (): TextTrack | null => {
if (!activeLanguage) return null;
const tracks = videoEl.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
if (tracks[index].language === activeLanguage) return tracks[index];
}
return null;
};
const applyModes = () => {
const tracks = videoEl.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
const shouldShow = Boolean(activeLanguage) && track.language === activeLanguage;
track.mode = shouldShow ? 'showing' : 'disabled';
if (!shouldShow) continue;
// The control bar sits over the bottom of the frame in fullscreen, so cues are
// lifted clear of it instead of landing underneath.
const cues = track.cues;
if (!cues) continue;
for (let cueIndex = 0; cueIndex < cues.length; cueIndex += 1) {
const cue = cues[cueIndex] as VTTCue;
if (typeof cue.line !== 'undefined') {
cue.snapToLines = true;
cue.line = -3;
}
}
}
};
const markLoaded = (event: Event) => {
const element = event.currentTarget as HTMLTrackElement;
loadedLanguagesRef.current.add(element.srclang);
applyModes();
};
const repairIfEmptied = () => {
if (!activeLanguage) return;
// Before the file has loaded a track legitimately has no cues, so only a track we
// have seen load and that is now empty counts as wiped.
if (!loadedLanguagesRef.current.has(activeLanguage)) return;
const track = findActiveTrack();
if (!track || (track.cues?.length ?? 0) > 0) return;
if (repairCountRef.current >= MAX_TRACK_REPAIRS) return;
repairCountRef.current += 1;
loadedLanguagesRef.current.delete(activeLanguage);
setTrackEpoch((epoch) => epoch + 1);
};
applyModes();
// A track's cues are null until the browser has fetched the file, which it only does
// once the track is not disabled. The lift above therefore has to run again on load.
const trackElements = Array.from(videoEl.querySelectorAll('track'));
trackElements.forEach((element) => element.addEventListener('load', markLoaded));
videoEl.textTracks.addEventListener('addtrack', applyModes);
// `loadeddata` catches a source switch while paused; `timeupdate` catches everything
// else within a quarter of a second of playback.
videoEl.addEventListener('loadeddata', repairIfEmptied);
videoEl.addEventListener('timeupdate', repairIfEmptied);
return () => {
trackElements.forEach((element) => element.removeEventListener('load', markLoaded));
videoEl.textTracks.removeEventListener('addtrack', applyModes);
videoEl.removeEventListener('loadeddata', repairIfEmptied);
videoEl.removeEventListener('timeupdate', repairIfEmptied);
};
}, [activeLanguage, subtitles, trackEpoch, videoRef, versionId]);
const selectSubtitleLanguage = useCallback(
(language: string | null) => {
setActiveLanguage(language);
writeStoredSubtitleLanguage(videoId, language);
appliedPreferenceForVersionRef.current = versionId;
},
[versionId, videoId]
);
const uploadSubtitle = useCallback(
async (file: File, language: string, label: string): Promise<string | null> => {
if (!versionId) return 'No version selected';
setIsUploadingSubtitle(true);
try {
const formData = new FormData();
formData.append('subtitle', file);
formData.append('versionId', versionId);
formData.append('language', language);
formData.append('label', label);
const res = await fetch(`/api/videos/${videoId}/subtitles`, {
method: 'POST',
body: formData,
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
return payload?.error?.message || payload?.error || 'Failed to upload subtitle';
}
await refresh();
selectSubtitleLanguage(language.toLowerCase());
return null;
} catch {
return 'Failed to upload subtitle';
} finally {
setIsUploadingSubtitle(false);
}
},
[refresh, selectSubtitleLanguage, versionId, videoId]
);
const deleteSubtitle = useCallback(
async (subtitleId: string): Promise<string | null> => {
try {
const res = await fetch(`/api/videos/${videoId}/subtitles/${subtitleId}`, {
method: 'DELETE',
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
return payload?.error?.message || payload?.error || 'Failed to delete subtitle';
}
await refresh();
return null;
} catch {
return 'Failed to delete subtitle';
}
},
[refresh, videoId]
);
return useMemo(
() => ({
subtitles,
canManageSubtitles,
activeSubtitleLanguage: activeLanguage,
subtitleTrackKey: String(trackEpoch),
selectSubtitleLanguage,
uploadSubtitle,
deleteSubtitle,
isUploadingSubtitle,
refreshSubtitles: refresh,
}),
[
activeLanguage,
canManageSubtitles,
deleteSubtitle,
isUploadingSubtitle,
refresh,
selectSubtitleLanguage,
subtitles,
trackEpoch,
uploadSubtitle,
]
);
}
@@ -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',
+10 -26
View File
@@ -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;
@@ -81,6 +82,10 @@ export function useVideoPlayer({
}: UseVideoPlayerParams) {
const [isApiLoaded, setIsApiLoaded] = useState(false);
const [isReady, setIsReady] = useState(false);
// Bumped every time the YouTube player loads or unloads a module. It is the only
// signal that `getOption('captions', ...)` will answer, so the captions hook waits
// on it rather than polling.
const [youtubeModuleRevision, setYoutubeModuleRevision] = useState(0);
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
const [currentTime, setCurrentTime] = useState(0);
const [videoDuration, setVideoDuration] = useState(0);
@@ -110,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);
@@ -208,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;
@@ -315,6 +295,9 @@ export function useVideoPlayer({
const dur = event.target.getDuration();
if (dur > 0) setVideoDuration(dur);
},
onApiChange: () => {
setYoutubeModuleRevision((revision) => revision + 1);
},
onStateChange: (event: YT.OnStateChangeEvent) => {
setIsPlaying(event.data === YT.PlayerState.PLAYING);
@@ -1344,6 +1327,7 @@ export function useVideoPlayer({
return {
isReady,
youtubeModuleRevision,
bunnyPlaybackState,
currentTime,
setCurrentTime,
@@ -0,0 +1,177 @@
'use client';
// Same exemption as the players themselves: this hook mirrors an external player's
// caption state into React, which is the case the rule cannot distinguish.
/* eslint-disable react-hooks/set-state-in-effect */
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import {
readStoredSubtitleLanguage,
writeStoredSubtitleLanguage,
} from '@/components/video-page/hooks/subtitle-preference';
import type { PlayerAdapter, SubtitleTrackOption } from '@/components/video-page/types';
interface UseYoutubeCaptionsParams {
videoId: string;
versionId: string | null;
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
/** The active version is a YouTube one. */
enabled: boolean;
isReady: boolean;
/** Incremented by the player on every onApiChange. */
moduleRevision: number;
}
/** One entry of `getOption('captions', 'tracklist')`. Only these fields are relied on. */
type YoutubeCaptionTrack = {
languageCode?: string;
languageName?: string;
displayName?: string;
};
const CAPTIONS_MODULE = 'captions';
function asYoutubePlayer(
player: YT.Player | PlayerAdapter | null
): (YT.Player & { loadModule?: unknown }) | null {
if (!player) return null;
const candidate = player as YT.Player;
return typeof candidate.loadModule === 'function' ? candidate : null;
}
/**
* Drives YouTube's own captions from our control bar.
*
* A YouTube version plays inside an iframe we do not own, so a <track> element is not an
* option and neither is an uploaded file: the only captions that exist for it are the ones
* the video already carries. The player is embedded with controls=0, which hides
* YouTube's CC button along with the rest of its chrome, so without this the captions
* would be unreachable even when they exist.
*/
export function useYoutubeCaptions({
videoId,
versionId,
playerRef,
enabled,
isReady,
moduleRevision,
}: UseYoutubeCaptionsParams) {
const [tracks, setTracks] = useState<SubtitleTrackOption[]>([]);
const [activeLanguage, setActiveLanguage] = useState<string | null>(null);
// Read inside effects that must not re-run when the selection changes.
const activeLanguageRef = useRef<string | null>(null);
useEffect(() => {
activeLanguageRef.current = activeLanguage;
}, [activeLanguage]);
const appliedPreferenceForVersionRef = useRef<string | null>(null);
/**
* The caption state we last pushed into the player, or `undefined` before the first
* push. Loading and unloading a module both fire onApiChange, so an effect that reacted
* to every revision by unloading again would answer its own event forever.
*/
const appliedLanguageRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
setTracks([]);
setActiveLanguage(null);
appliedLanguageRef.current = undefined;
}, [versionId]);
// Loading the module is what makes the track list readable, and it also switches
// captions on. The probe below turns them straight back off for a viewer who has not
// asked for them: at this point the video is at its first frame with no cue to draw,
// so there is nothing to flash.
useEffect(() => {
if (!enabled || !isReady) return;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
try {
player.loadModule(CAPTIONS_MODULE);
} catch {
// An older or restricted player without the module API simply has no captions.
}
}, [enabled, isReady, playerRef, versionId]);
useEffect(() => {
if (!enabled || !isReady || moduleRevision === 0) return;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
let rawTracks: YoutubeCaptionTrack[] = [];
try {
rawTracks = player.getOption<YoutubeCaptionTrack[]>(CAPTIONS_MODULE, 'tracklist') ?? [];
} catch {
rawTracks = [];
}
const mapped: SubtitleTrackOption[] = rawTracks
.filter((track): track is YoutubeCaptionTrack & { languageCode: string } =>
Boolean(track?.languageCode)
)
.map((track) => ({
id: `youtube:${track.languageCode}`,
language: track.languageCode.toLowerCase(),
label: track.displayName || track.languageName || track.languageCode.toUpperCase(),
canDelete: false,
}));
setTracks(mapped);
const stored =
versionId && appliedPreferenceForVersionRef.current !== versionId
? readStoredSubtitleLanguage(videoId)
: null;
if (versionId) appliedPreferenceForVersionRef.current = versionId;
const wanted =
activeLanguageRef.current ??
(stored && mapped.some((track) => track.language === stored) ? stored : null);
if (appliedLanguageRef.current === wanted) return;
appliedLanguageRef.current = wanted;
try {
if (wanted) {
player.setOption(CAPTIONS_MODULE, 'track', { languageCode: wanted });
setActiveLanguage(wanted);
} else {
player.unloadModule(CAPTIONS_MODULE);
}
} catch {
// Same as above: a player that will not take the option has no captions to give.
}
}, [enabled, isReady, moduleRevision, playerRef, versionId, videoId]);
const selectCaptionLanguage = useCallback(
(language: string | null) => {
setActiveLanguage(language);
writeStoredSubtitleLanguage(videoId, language);
appliedPreferenceForVersionRef.current = versionId;
appliedLanguageRef.current = language;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
try {
if (language) {
player.loadModule(CAPTIONS_MODULE);
player.setOption(CAPTIONS_MODULE, 'track', { languageCode: language });
} else {
player.unloadModule(CAPTIONS_MODULE);
}
} catch {
// Nothing to recover: the menu already reflects the choice, and a player that
// refuses the module was never going to show captions.
}
},
[playerRef, versionId, videoId]
);
return useMemo(
() => ({
youtubeCaptionTracks: enabled ? tracks : [],
activeYoutubeCaptionLanguage: enabled ? activeLanguage : null,
selectYoutubeCaptionLanguage: selectCaptionLanguage,
}),
[activeLanguage, enabled, selectCaptionLanguage, tracks]
);
}
+57 -2
View File
@@ -32,7 +32,13 @@ import {
type AnnotationStroke,
} from '@/components/annotation-canvas';
import { SILENT_ABOVE_SPEED } from '@/components/video-page/hooks/video-player-utils';
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types';
import { SubtitleControls } from '@/components/video-page/subtitle-controls';
import type {
BunnyQualityOption,
CommentMarker,
Subtitle,
SubtitleTrackOption,
} from '@/components/video-page/types';
interface PlayerCoreProps {
activeVersionId: string | null;
@@ -85,6 +91,24 @@ interface PlayerCoreProps {
selectedQualityLevel: number;
qualityOptions: BunnyQualityOption[];
handleQualityChange: (level: number) => void;
/** Uploaded tracks, rendered as <track> elements. Empty for a YouTube version. */
subtitles: Subtitle[];
/**
* What the CC menu offers, which is the list above for our own player and YouTube's
* own caption list for an embedded YouTube version.
*/
subtitleTracks: SubtitleTrackOption[];
/**
* Changes when a track has to be re-fetched. It is part of each <track> key because
* remounting the element is the only way to make the browser parse the file again.
*/
subtitleTrackKey: string;
activeSubtitleLanguage: string | null;
onSelectSubtitleLanguage: (language: string | null) => void;
canManageSubtitles: boolean;
onUploadSubtitle: (file: File, language: string, label: string) => Promise<string | null>;
onDeleteSubtitle: (subtitleId: string) => Promise<string | null>;
isUploadingSubtitle: boolean;
playbackSpeed: number;
speedOptions: number[];
handleSpeedChange: (speed: number) => void;
@@ -153,6 +177,15 @@ export const PlayerCore = memo(function PlayerCore({
selectedQualityLevel,
qualityOptions,
handleQualityChange,
subtitles,
subtitleTracks,
subtitleTrackKey,
activeSubtitleLanguage,
onSelectSubtitleLanguage,
canManageSubtitles,
onUploadSubtitle,
onDeleteSubtitle,
isUploadingSubtitle,
playbackSpeed,
speedOptions,
handleSpeedChange,
@@ -208,7 +241,17 @@ export const PlayerCore = memo(function PlayerCore({
}}
preload="metadata"
playsInline
/>
>
{subtitles.map((subtitle) => (
<track
key={`${subtitle.id}:${subtitleTrackKey}`}
kind="subtitles"
src={subtitle.url}
srcLang={subtitle.language}
label={subtitle.label}
/>
))}
</video>
</div>
</div>
) : (
@@ -442,6 +485,18 @@ export const PlayerCore = memo(function PlayerCore({
</DropdownMenu>
)}
{activeProviderId && activeProviderId !== 'direct' && (
<SubtitleControls
subtitles={subtitleTracks}
activeSubtitleLanguage={activeSubtitleLanguage}
onSelectSubtitleLanguage={onSelectSubtitleLanguage}
canManageSubtitles={canManageSubtitles}
onUploadSubtitle={onUploadSubtitle}
onDeleteSubtitle={onDeleteSubtitle}
isUploadingSubtitle={isUploadingSubtitle}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
+303
View File
@@ -0,0 +1,303 @@
'use client';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { Captions, Loader2, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { SubtitleTrackOption } from '@/components/video-page/types';
const COMMON_LANGUAGES = [
'tr',
'en',
'de',
'fr',
'es',
'it',
'pt',
'nl',
'pl',
'ru',
'ar',
'ja',
'ko',
'zh',
'hi',
] as const;
const OTHER_LANGUAGE = '__other__';
/** A file named `cut-v3.tr.srt` already says which language it is. */
const LANGUAGE_FROM_FILENAME = /\.([a-z]{2,3}(?:-[a-z0-9]{2,8})?)\.(?:srt|vtt)$/i;
function describeLanguage(tag: string): string {
try {
const displayNames = new Intl.DisplayNames(undefined, { type: 'language' });
return displayNames.of(tag) || tag.toUpperCase();
} catch {
return tag.toUpperCase();
}
}
function guessLanguageFromFileName(fileName: string): string | null {
const match = LANGUAGE_FROM_FILENAME.exec(fileName);
return match ? match[1].toLowerCase() : null;
}
interface SubtitleControlsProps {
/**
* What the menu lists. For a Bunny or R2 version these are the tracks uploaded to this
* cut; for a YouTube version they are the captions the video already carries, which is
* why the shape is narrower than a stored subtitle.
*/
subtitles: SubtitleTrackOption[];
activeSubtitleLanguage: string | null;
onSelectSubtitleLanguage: (language: string | null) => void;
canManageSubtitles: boolean;
onUploadSubtitle: (file: File, language: string, label: string) => Promise<string | null>;
onDeleteSubtitle: (subtitleId: string) => Promise<string | null>;
isUploadingSubtitle: boolean;
}
export const SubtitleControls = memo(function SubtitleControls({
subtitles,
activeSubtitleLanguage,
onSelectSubtitleLanguage,
canManageSubtitles,
onUploadSubtitle,
onDeleteSubtitle,
isUploadingSubtitle,
}: SubtitleControlsProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [languageChoice, setLanguageChoice] = useState<string>('tr');
const [customLanguage, setCustomLanguage] = useState('');
const [label, setLabel] = useState('');
const activeSubtitle = useMemo(
() => subtitles.find((subtitle) => subtitle.language === activeSubtitleLanguage) ?? null,
[activeSubtitleLanguage, subtitles]
);
const resolvedLanguage = (
languageChoice === OTHER_LANGUAGE ? customLanguage : languageChoice
).trim();
const handleFileChosen = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
// Clearing the input lets the same file be picked again after a failed upload.
event.target.value = '';
if (!file) return;
const guessed = guessLanguageFromFileName(file.name);
const known = guessed && (COMMON_LANGUAGES as readonly string[]).includes(guessed);
setLanguageChoice(known ? (guessed as string) : guessed ? OTHER_LANGUAGE : 'tr');
setCustomLanguage(known ? '' : (guessed ?? ''));
setLabel('');
setPendingFile(file);
}, []);
const handleUpload = useCallback(async () => {
if (!pendingFile || !resolvedLanguage) return;
const finalLabel = label.trim() || describeLanguage(resolvedLanguage);
const error = await onUploadSubtitle(pendingFile, resolvedLanguage, finalLabel);
if (error) {
toast.error(error);
return;
}
toast.success('Subtitle added');
setPendingFile(null);
}, [label, onUploadSubtitle, pendingFile, resolvedLanguage]);
const handleDelete = useCallback(
async (subtitle: SubtitleTrackOption) => {
const error = await onDeleteSubtitle(subtitle.id);
if (error) {
toast.error(error);
return;
}
toast.success(`${subtitle.label} removed`);
},
[onDeleteSubtitle]
);
if (subtitles.length === 0 && !canManageSubtitles) return null;
const replacesExisting = subtitles.some(
(subtitle) => subtitle.language === resolvedLanguage.toLowerCase()
);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={activeSubtitle ? 'default' : 'ghost'}
size="sm"
className="h-8 gap-1 text-xs"
title="Subtitles"
>
<Captions className="h-3.5 w-3.5" />
{activeSubtitle ? activeSubtitle.label : 'CC'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem
onClick={() => onSelectSubtitleLanguage(null)}
className={cn(!activeSubtitleLanguage && 'font-bold text-primary')}
>
Off
</DropdownMenuItem>
{subtitles.map((subtitle) => (
<DropdownMenuItem
key={subtitle.id}
onClick={() => onSelectSubtitleLanguage(subtitle.language)}
className={cn(
'flex items-center justify-between gap-2',
subtitle.language === activeSubtitleLanguage && 'font-bold text-primary'
)}
>
<span className="truncate">{subtitle.label}</span>
{subtitle.canDelete && (
<button
type="button"
aria-label={`Delete ${subtitle.label} subtitle`}
className="text-muted-foreground hover:text-destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDelete(subtitle);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</DropdownMenuItem>
))}
{canManageSubtitles && (
<>
{subtitles.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingSubtitle}
>
<Upload className="h-3.5 w-3.5 mr-2" />
Add subtitle
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<input
ref={fileInputRef}
type="file"
accept=".srt,.vtt,text/vtt,application/x-subrip"
className="hidden"
onChange={handleFileChosen}
/>
<Dialog
open={!!pendingFile}
onOpenChange={(open) => {
if (!open && !isUploadingSubtitle) setPendingFile(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add subtitle</DialogTitle>
<DialogDescription>
{pendingFile?.name} is attached to this version only, because cue timings belong to
one cut. SRT files are converted to WebVTT on upload.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="subtitle-language">Language</Label>
<Select value={languageChoice} onValueChange={setLanguageChoice}>
<SelectTrigger id="subtitle-language">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMMON_LANGUAGES.map((tag) => (
<SelectItem key={tag} value={tag}>
{describeLanguage(tag)}
</SelectItem>
))}
<SelectItem value={OTHER_LANGUAGE}>Other</SelectItem>
</SelectContent>
</Select>
{languageChoice === OTHER_LANGUAGE && (
<Input
value={customLanguage}
onChange={(event) => setCustomLanguage(event.target.value)}
placeholder="Language tag, e.g. en-US"
maxLength={20}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="subtitle-label">Label</Label>
<Input
id="subtitle-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder={resolvedLanguage ? describeLanguage(resolvedLanguage) : 'Türkçe'}
maxLength={60}
/>
</div>
{replacesExisting && (
<p className="text-xs text-muted-foreground">
This version already has a track in that language. Uploading replaces it.
</p>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setPendingFile(null)}
disabled={isUploadingSubtitle}
>
Cancel
</Button>
<Button
onClick={() => void handleUpload()}
disabled={isUploadingSubtitle || !resolvedLanguage}
>
{isUploadingSubtitle && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
});
+23
View File
@@ -35,6 +35,27 @@ export interface VideoAsset {
canDelete: boolean;
}
/**
* What the player's CC menu needs to know about one track. Our own uploaded tracks and
* the ones a YouTube video brings with it are different things underneath, and the menu
* is the one place that does not have to care.
*/
export interface SubtitleTrackOption {
id: string;
language: string;
label: string;
canDelete: boolean;
}
export interface Subtitle extends SubtitleTrackOption {
versionId: string;
url: string;
sizeBytes: number;
createdAt: string;
updatedAt: string;
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
}
export interface ApprovalDecision {
id: string;
approverId: string;
@@ -149,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;
+3
View File
@@ -21,6 +21,9 @@ const eslintConfig = defineConfig([
'test-results/**',
'reports/**',
'.stryker-tmp/**',
// Git worktrees checked out under .claude/worktrees are separate checkouts,
// not part of this tree; linting them fails the run on their files.
'.claude/**',
]),
prettier,
{
+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;
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { PrismaAdapter } from '@auth/prisma-adapter';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { hasBillingAccess, startCardlessTrial } from '@/lib/billing';
import { hasBillingAccess, startCardlessTrialOnSignup } from '@/lib/billing';
import { isInviteCodeRequired } from '@/lib/feature-flags';
import { isEmailVerificationEnabled } from '@/lib/email-verification';
@@ -171,7 +171,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// opposite of what the signup page promised it. The address is already
// proven here: the signIn callback above turns away an OAuth profile that
// reports its email as unverified.
await startCardlessTrial(user.id);
await startCardlessTrialOnSignup(user.id);
},
},
});
+607 -89
View File
@@ -1,6 +1,6 @@
import type { Prisma } from '@prisma/client';
import type Stripe from 'stripe';
import { BillingSubscriptionStatus } from '@prisma/client';
import { BillingSubscriptionStatus, InvitationStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
@@ -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] } },
],
},
],
},
@@ -336,6 +406,12 @@ export function buildEffectiveBillingStatusWhereInput(
* `billingTrialConsumedAt` is written here rather than only by the Stripe sync.
* It is the once-per-account marker, so a re-issued verification link, a second
* device or a replayed request all land on the `WHERE` clause and change nothing.
*
* Signup goes through `startCardlessTrialOnSignup` instead, which holds the trial
* back for an account that only exists because somebody invited it. This is the
* unconditional grant, reached later only when that account explicitly asks for
* its deferred trial through the start-trial endpoint. It is never started as a
* side effect of some other action; the clock costs the account its only trial.
*/
export async function startCardlessTrial(userId: string, now: Date = new Date()) {
// Without billing nothing is gated, so a trial would be a date nobody reads.
@@ -366,6 +442,69 @@ export async function startCardlessTrial(userId: string, now: Date = new Date())
return true;
}
/**
* Whether this account arrived as somebody else's collaborator.
*
* An invited member works inside the inviter's workspace on the inviter's
* billing, so a trial handed to them at signup buys them nothing and is spent
* before they have seen the product on an account of their own. Worse, it is
* spent for good: `billingTrialConsumedAt` is never cleared, so the day they
* consider becoming a customer themselves the trial is already gone.
*
* Two signals, because the invitation lands at different points on the two
* signup paths. The credentials route accepts the token inside the same request
* that creates the account, so by the time the trial is considered the
* membership row exists. An OAuth signup creates the account on the way out to
* the provider and accepts the invitation only on the way back, so there the
* pending invitation is the only thing to go on.
*/
async function arrivedAsCollaborator(userId: string, now: Date) {
const user = await db.user.findUnique({
where: { id: userId },
select: { email: true },
});
const [workspaceMemberships, projectMemberships, pendingInvitations] = await Promise.all([
db.workspaceMember.count({
where: { userId, workspace: { ownerId: { not: userId } } },
}),
db.projectMember.count({
where: { userId, project: { ownerId: { not: userId } } },
}),
user?.email
? db.invitation.count({
where: {
email: user.email,
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
})
: Promise.resolve(0),
]);
return workspaceMemberships > 0 || projectMemberships > 0 || pendingInvitations > 0;
}
/**
* The trial as granted at signup: to everyone except an invited collaborator,
* whose clock is deferred until they own something of their own.
*
* Nothing is lost by waiting. The deferred trial stays claimable forever: the
* account starts it whenever it chooses through the start-trial endpoint, which
* the workspace-creation and billing screens point at.
*/
export async function startCardlessTrialOnSignup(userId: string, now: Date = new Date()) {
if (!isStripeFeatureEnabled()) {
return false;
}
if (await arrivedAsCollaborator(userId, now)) {
return false;
}
return startCardlessTrial(userId, now);
}
export async function getStripeCheckoutState(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
@@ -439,6 +578,12 @@ export async function getWorkspaceCreationEligibility(userId: string) {
const billingAccess = hasBillingAccess(user);
const isPaid = isPaidTier(user);
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
// An invited collaborator whose trial was deferred at signup. Their trial is
// still owed, but starting it is their call, not a side effect of clicking
// "create workspace": the clock costs them their only trial, so it runs only
// after they ask for it through the explicit start-trial endpoint.
const canStartTrial =
isStripeFeatureEnabled() && !billingAccess && !user.trialEndsAt && !user.billingTrialConsumedAt;
// A paying account creates as many workspaces as it wants. Everyone else gets
// one, which covers both the cardless trial and the pre-trial state where an
@@ -452,9 +597,9 @@ export async function getWorkspaceCreationEligibility(userId: string) {
if (!canCreateWorkspace && isStripeFeatureEnabled()) {
if (billingAccess && ownedWorkspaceCount >= TRIAL_WORKSPACE_LIMIT) {
reason = 'Your free trial includes one workspace. Subscribe to create more.';
} else if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
} else if (canStartTrial && collaborationCount > 0 && ownedWorkspaceCount === 0) {
reason =
'You are currently collaborating in someone elses workspace or project. Start a subscription to create a workspace of your own.';
'You are collaborating in someone elses workspace, so your free trial has not started yet. Start it to create a workspace of your own.';
} else {
reason = 'Your trial has ended. Start a subscription to create and keep owning workspaces.';
}
@@ -462,6 +607,7 @@ export async function getWorkspaceCreationEligibility(userId: string) {
return {
canCreateWorkspace,
canStartTrial,
reason,
ownedWorkspaceCount,
invitedWorkspaceCount,
@@ -493,6 +639,7 @@ export async function getBillingOverview(userId: string) {
return {
workspaceCreation: {
canCreateWorkspace: billing.canCreateWorkspace,
canStartTrial: billing.canStartTrial,
reason: billing.reason,
ownedWorkspaceCount: billing.ownedWorkspaceCount,
invitedWorkspaceCount: billing.invitedWorkspaceCount,
@@ -547,26 +694,65 @@ export async function getTrialNotice(
const contentKeptUntil = getStorageCleanupEligibleAt(user);
if (hasActiveTrial(user.trialEndsAt, now) && user.trialEndsAt) {
const daysLeft = (user.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000);
if (daysLeft > TRIAL_ENDING_NOTICE_DAYS) {
const notice = ((): TrialNotice | null => {
if (hasActiveTrial(user.trialEndsAt, now) && user.trialEndsAt) {
const daysLeft = (user.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000);
if (daysLeft > TRIAL_ENDING_NOTICE_DAYS) {
return null;
}
return { kind: 'ending', endsAt: user.trialEndsAt, contentKeptUntil };
}
const endsAt = getBillingAccessEndDate(user);
if (!endsAt || hasBillingAccess(user, now)) {
return null;
}
return { kind: 'ending', endsAt: user.trialEndsAt, contentKeptUntil };
}
// Past the cleanup date there is nothing left to reassure anybody about.
if (contentKeptUntil && contentKeptUntil.getTime() <= now.getTime()) {
return null;
}
const endsAt = getBillingAccessEndDate(user);
if (!endsAt || hasBillingAccess(user, now)) {
return { kind: 'ended', endsAt, contentKeptUntil };
})();
// Neither sentence is true for a guest in somebody else's workspace: no
// deadline is coming for them, and the media the banner promises to keep is
// not theirs and is not at risk. They were reading "your projects and media
// are kept until" about a paying customer's work. Checked last so the queries
// only run for the few accounts a banner was about to be shown to.
if (notice && (await isCollaboratorWithNothingOfTheirOwn(userId, now))) {
return null;
}
// Past the cleanup date there is nothing left to reassure anybody about.
if (contentKeptUntil && contentKeptUntil.getTime() <= now.getTime()) {
return null;
}
return notice;
}
return { kind: 'ended', endsAt, contentKeptUntil };
/**
* Somebody who only ever works inside workspaces they do not own.
*
* Ownership is what makes billing personal: the storage, the projects and the
* cleanup deadline all hang off the owning account. An account that owns none of
* that, and reaches the product entirely through a workspace whose owner is
* paying, has nothing of its own on the line.
*/
async function isCollaboratorWithNothingOfTheirOwn(userId: string, now: Date) {
const [ownedWorkspaceCount, collaborationCount] = await Promise.all([
db.workspace.count({ where: { ownerId: userId } }),
db.workspace.count({
where: {
ownerId: { not: userId },
owner: buildBillingAccessWhereInput(now),
OR: [
{ members: { some: { userId } } },
{ projects: { some: { members: { some: { userId } } } } },
],
},
}),
]);
return ownedWorkspaceCount === 0 && collaborationCount > 0;
}
export async function getOrCreateStripeCustomerId(userId: string) {
@@ -607,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
@@ -617,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) {
@@ -631,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,
@@ -652,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
@@ -680,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,
@@ -700,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: {
@@ -729,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
@@ -784,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,
@@ -825,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,
@@ -837,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(),
},
});
@@ -847,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: {
@@ -861,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 {
+48
View File
@@ -0,0 +1,48 @@
'use client';
/**
* Downloads that we pull through fetch() live inside the page: closing the tab
* (or reloading) throws away every byte received so far and the browser gives no
* warning, because as far as it knows nothing is downloading. While one of those
* is in flight we register a beforeunload handler so the user gets the native
* "leave site?" dialog instead of silently losing the transfer.
*
* Plain navigation downloads (the `download` attribute / a redirect to the CDN)
* are owned by the browser and survive a tab close, so they must NOT be guarded.
*/
let activeCount = 0;
function handleBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
// Legacy browsers only show the dialog when returnValue is set; the string
// itself is ignored, every browser shows its own wording.
event.returnValue = '';
}
/** Registers the guard and returns a release function. Safe to call again while
* another download is already guarded the listener is reference counted and
* only detaches once the last one releases. Releasing twice is a no-op. */
export function beginUnloadGuard(): () => void {
if (typeof window === 'undefined') return () => {};
if (activeCount === 0) {
window.addEventListener('beforeunload', handleBeforeUnload);
}
activeCount += 1;
let released = false;
return () => {
if (released) return;
released = true;
activeCount -= 1;
if (activeCount === 0) {
window.removeEventListener('beforeunload', handleBeforeUnload);
}
};
}
/** Test helper: number of downloads currently holding the guard. */
export function unloadGuardCount(): number {
return activeCount;
}
+2 -2
View File
@@ -11,7 +11,7 @@ import {
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled, isStripeFeatureEnabled } from '@/lib/feature-flags';
import { startCardlessTrial } from '@/lib/billing';
import { startCardlessTrialOnSignup } from '@/lib/billing';
// Reduce window to 2 hours — shorter exposure in access logs and backups.
const TOKEN_EXPIRY_HOURS = 2;
@@ -120,7 +120,7 @@ export async function consumeVerificationToken(token: string): Promise<string |
});
}
await startCardlessTrial(verified.id);
await startCardlessTrialOnSignup(verified.id);
}
return record.identifier;
+6
View File
@@ -235,6 +235,12 @@ export type BuildProjectDownloadManifestOptions = {
includeAssets?: boolean;
};
/**
* Subtitle tracks are deliberately not in the manifest. They belong to a version rather
* than to a video, and a zip that carried them would need a naming scheme that pairs each
* .vtt with the cut it was timed against. Add them the day that pairing is designed, not
* as a loose file next to the videos.
*/
export function buildProjectDownloadManifest(
projectName: string,
videos: VideoRow[],
+26 -4
View File
@@ -3,6 +3,7 @@ import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { db } from '@/lib/db';
import { runWithConcurrency } from '@/lib/async-pool';
import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation';
import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation';
import { logError } from '@/lib/logger';
/** The path prefix for images served by the upload API. */
@@ -34,7 +35,7 @@ export function mediaUrlToKey(url: string): string | null {
return filename ? `images/${filename}` : null;
}
return videoProxyPathToObjectKey(url);
return subtitleProxyPathToObjectKey(url) ?? videoProxyPathToObjectKey(url);
}
/**
@@ -82,7 +83,7 @@ export async function deleteMediaFilesBestEffort(mediaUrls: string[]): Promise<R
* Collect all media URLs from comments under a given video (all versions).
*/
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
const [comments, assets, versions] = await Promise.all([
const [comments, assets, versions, subtitles] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
@@ -101,6 +102,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
where: { videoParentId: videoId, providerId: 'r2' },
select: { originalUrl: true, thumbnailUrl: true },
}),
db.videoSubtitle.findMany({
where: { version: { videoParentId: videoId } },
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -114,6 +119,9 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
subtitles.forEach((subtitle) => {
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
});
return urls;
}
@@ -121,7 +129,7 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
* Collect all media URLs from comments under all videos in a project.
*/
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
const [comments, assets, versions] = await Promise.all([
const [comments, assets, versions, subtitles] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
@@ -140,6 +148,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
where: { providerId: 'r2', video: { projectId } },
select: { originalUrl: true, thumbnailUrl: true },
}),
db.videoSubtitle.findMany({
where: { version: { video: { projectId } } },
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -153,6 +165,9 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
subtitles.forEach((subtitle) => {
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
});
return urls;
}
@@ -160,7 +175,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
* Collect all media URLs from comments under all projects in a workspace.
*/
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
const [comments, assets, versions] = await Promise.all([
const [comments, assets, versions, subtitles] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
@@ -179,6 +194,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
where: { providerId: 'r2', video: { project: { workspaceId } } },
select: { originalUrl: true, thumbnailUrl: true },
}),
db.videoSubtitle.findMany({
where: { version: { video: { project: { workspaceId } } } },
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -192,6 +211,9 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
subtitles.forEach((subtitle) => {
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
});
return urls;
}
+1 -1
View File
@@ -24,7 +24,7 @@ type ProxyR2MediaOptions = {
// call sites gate the file name on a strict pattern first, and a fourth that forgot would
// otherwise hand a traversal straight to GetObject.
const SAFE_MEDIA_OBJECT_KEY =
/^(?:images|voice|videos)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
/^(?:images|voice|videos|subtitles)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export function isSafeR2MediaKey(key: string): boolean {
return SAFE_MEDIA_OBJECT_KEY.test(key);
+4
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
@@ -71,6 +72,9 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'asset-r2-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'subtitle-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
'subtitle-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'subtitle-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
// Search — debounced on client but protect against scripted callers
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 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',
+26 -9
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;
@@ -132,6 +132,8 @@ export const UPLOAD_RESERVATION_PURPOSES = {
R2_VIDEO: 'R2_VIDEO',
/** A direct upload to Bunny, where the bytes never pass through us. */
BUNNY: 'BUNNY',
/** A subtitle track, which lands in our own S3-compatible storage whatever hosts the video. */
SUBTITLE: 'SUBTITLE',
} as const;
export type UploadReservationPurpose =
@@ -147,14 +149,15 @@ class QuotaExceededError extends Error {}
* every upload.
*/
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([
db.$queryRaw<[{ total: bigint }]>`
const [r2AssetRows, r2VideoRows, subtitleRows, bunnyUserBytes, reservationRows] =
await Promise.all([
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_assets
WHERE "billedUserId" = ${userId}
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
`,
db.$queryRaw<[{ total: bigint }]>`
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
FROM video_versions vv
INNER JOIN videos v ON v.id = vv."videoParentId"
@@ -163,21 +166,27 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2'
`,
getUserBunnyStorageBytes(userId),
db.$queryRaw<[{ total: bigint }]>`
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_subtitles
WHERE "billedUserId" = ${userId}
`,
getUserBunnyStorageBytes(userId),
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
FROM upload_reservations
WHERE "billedUserId" = ${userId}
AND "expiresAt" > NOW()
`,
]);
]);
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
const subtitleBytes = subtitleRows[0]?.total ?? BigInt(0);
const bunnyBytes = BigInt(bunnyUserBytes);
const reservedBytes = reservationRows[0]?.total ?? BigInt(0);
return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes;
return r2AssetBytes + r2VideoBytes + subtitleBytes + bunnyBytes + reservedBytes;
}
/**
@@ -291,7 +300,15 @@ export async function reserveStorageQuota(
WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2'
`;
const r2Bytes = (r2AssetRow?.total ?? BigInt(0)) + (r2VideoRow?.total ?? BigInt(0));
const [subtitleRow] = await tx.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_subtitles
WHERE "billedUserId" = ${userId}
`;
const r2Bytes =
(r2AssetRow?.total ?? BigInt(0)) +
(r2VideoRow?.total ?? BigInt(0)) +
(subtitleRow?.total ?? BigInt(0));
// Read active (non-expired) reservations under the same lock
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
+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;
+298
View File
@@ -0,0 +1,298 @@
/**
* Subtitle uploads are normalised before they are stored: whatever the user hands us,
* SRT or WebVTT, is parsed into cues and re-serialised as a canonical WebVTT file.
* Anything we did not understand is dropped rather than passed through, so the file the
* player fetches contains cues and nothing else. That is what makes it safe to serve a
* user-supplied text file from our own origin.
*/
/** Uploaded subtitle files are text. Two megabytes is a feature-length film with room to spare. */
export const MAX_SUBTITLE_FILE_SIZE = 2 * 1024 * 1024;
/** Ceiling on the normalised output, so a pathological input cannot be stored. */
export const MAX_NORMALIZED_SUBTITLE_SIZE = 1024 * 1024;
export const MAX_SUBTITLE_CUES = 5000;
/** Longer than this and it is not a subtitle, it is a document being smuggled in. */
const MAX_CUE_TEXT_LENGTH = 500;
export const ALLOWED_SUBTITLE_EXTENSIONS = ['vtt', 'srt'] as const;
export const SUBTITLE_OBJECT_KEY_PREFIX = 'subtitles/';
export const SUBTITLE_PROXY_PREFIX = '/api/upload/subtitle/';
/** The only shape a subtitle URL may take once it has been through our upload API. */
export const SAFE_SUBTITLE_PROXY_PATH =
/^\/api\/upload\/subtitle\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i;
export const SAFE_SUBTITLE_FILENAME =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i;
export const SUBTITLE_CONTENT_TYPE = 'text/vtt; charset=utf-8';
/** Room for a label a human typed, not for a paragraph. */
const MAX_SUBTITLE_LABEL_LENGTH = 60;
/**
* BCP-47, narrowed: a primary subtag plus optional subtags. Wide enough for `tr`,
* `en-US` and `zh-Hant-TW`, narrow enough that the value is safe in an HTML attribute
* and in a unique index.
*/
const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[a-z0-9]{2,8}){0,3}$/i;
export type SubtitleCue = {
/** Seconds from the start of the video. */
start: number;
end: number;
text: string;
};
export type SubtitleNormalizeResult =
| { ok: true; vtt: string; cueCount: number }
| { ok: false; error: string };
/**
* Cue text may carry a small amount of WebVTT markup. Everything outside this list is
* removed: the browser's VTT parser does not execute scripts, but a file that only ever
* contains tags we recognise is one less thing to reason about.
*/
const ALLOWED_CUE_TAGS = [
/^<\/?[biu]>$/i,
/^<\/?ruby>$/i,
/^<\/?rt>$/i,
/^<\/?c(?:\.[\w-]+)*>$/i,
/^<v(?:\.[\w-]+)*(?:\s+[^<>]{1,80})?>$/i,
/^<\/v>$/i,
/^<\d{1,3}:\d{2}(?::\d{2})?\.\d{3}>$/,
];
export function getSubtitleExtension(fileName: string): 'vtt' | 'srt' | null {
const ext = fileName.split('.').pop()?.toLowerCase();
if (ext === 'vtt' || ext === 'srt') return ext;
return null;
}
/**
* Normalise a language tag for storage. Kept lowercase so the unique index on
* (version, language) treats `TR` and `tr` as the same track.
*/
export function normalizeSubtitleLanguage(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed || !LANGUAGE_TAG.test(trimmed)) return null;
return trimmed.toLowerCase();
}
export function sanitizeSubtitleLabel(value: unknown, fallback: string): string {
const raw = typeof value === 'string' ? value : '';
const normalized = raw
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!normalized) return fallback;
return normalized.slice(0, MAX_SUBTITLE_LABEL_LENGTH);
}
/**
* Subtitle files written by desktop editors are routinely not UTF-8. A Turkish SRT saved
* out of a Windows tool is usually windows-1254, and rejecting it outright would send the
* user off to convert a file we can decode ourselves. UTF-8 is tried strictly first so a
* valid file is never mangled by a legacy codepage.
*/
export function decodeSubtitleBuffer(buffer: Uint8Array): string | null {
for (const encoding of ['utf-8', 'windows-1254', 'windows-1252']) {
try {
const decoded = new TextDecoder(encoding, { fatal: true }).decode(buffer);
return decoded.replace(/^\uFEFF/, '');
} catch {
// Wrong encoding, or one this runtime's ICU build does not carry. Try the next.
}
}
return null;
}
function parseTimestamp(value: string): number | null {
const match = /^(?:(\d{1,3}):)?([0-5]?\d):([0-5]?\d)[.,](\d{1,3})$/.exec(value.trim());
if (!match) return null;
const hours = match[1] ? Number(match[1]) : 0;
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const millis = Number(match[4].padEnd(3, '0'));
return hours * 3600 + minutes * 60 + seconds + millis / 1000;
}
function formatTimestamp(seconds: number): string {
const clamped = Math.max(0, seconds);
const totalMillis = Math.round(clamped * 1000);
const hours = Math.floor(totalMillis / 3_600_000);
const minutes = Math.floor((totalMillis % 3_600_000) / 60_000);
const secs = Math.floor((totalMillis % 60_000) / 1000);
const millis = totalMillis % 1000;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
}
function parseTimingLine(line: string): { start: number; end: number } | null {
const separatorIndex = line.indexOf('-->');
if (separatorIndex === -1) return null;
const start = parseTimestamp(line.slice(0, separatorIndex));
// Anything after the end timestamp is a cue setting (position, align, line). They are
// dropped: the player positions cues itself so its own control bar does not cover them.
const rest = line.slice(separatorIndex + 3).trim();
const end = parseTimestamp(rest.split(/\s+/)[0] ?? '');
if (start === null || end === null) return null;
return { start, end };
}
const CUE_TAG = /<[^<>]*>/g;
/**
* Angle brackets outside a recognised tag are escaped one character at a time rather than
* the offending tag being deleted whole. Deleting is what lets a filter like this be
* reassembled around: strip the `<b>` out of `<scr<b>ipt>` and the two halves close up
* into a tag that was never written. Nothing closes up when the leftovers are escaped
* instead, and the same escaping takes care of `-->`, which would otherwise be read back
* as a timing line and split the cue in two. `&` is left alone so a file that already
* spells its entities properly keeps them.
*/
function escapeCueText(text: string): string {
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function sanitizeCueLine(line: string): string {
// ASS/SSA override blocks travel in SRT files ripped from other formats. The VTT parser
// renders them as literal text, which is never what the author meant.
const withoutOverrides = line.replace(/\{\\[^}]*\}/g, '');
let sanitized = '';
let cursor = 0;
CUE_TAG.lastIndex = 0;
for (let match = CUE_TAG.exec(withoutOverrides); match; match = CUE_TAG.exec(withoutOverrides)) {
sanitized += escapeCueText(withoutOverrides.slice(cursor, match.index));
if (ALLOWED_CUE_TAGS.some((allowed) => allowed.test(match[0]))) {
sanitized += match[0];
}
cursor = match.index + match[0].length;
}
sanitized += escapeCueText(withoutOverrides.slice(cursor));
return sanitized.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trimEnd();
}
/**
* Parse SRT or WebVTT into cues. Unknown blocks (NOTE, STYLE, REGION, cue identifiers,
* SRT sequence numbers) are skipped rather than carried over.
*/
export function parseSubtitleCues(input: string): SubtitleCue[] {
const lines = input.replace(/\r\n?/g, '\n').split('\n');
const cues: SubtitleCue[] = [];
let index = 0;
// A STYLE or REGION block runs until the next blank line and may itself contain no
// timing, so it is skipped wholesale rather than line by line.
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
if (/^(?:WEBVTT|NOTE|STYLE|REGION)\b/.test(trimmed)) {
index += 1;
while (index < lines.length && lines[index].trim()) index += 1;
continue;
}
// A cue may be preceded by an identifier line (an SRT sequence number, or a VTT cue
// id). The timing is then on the following line.
let timing = parseTimingLine(trimmed);
if (!timing) {
const next = lines[index + 1]?.trim();
if (!next) {
index += 1;
continue;
}
timing = parseTimingLine(next);
if (!timing) {
index += 1;
continue;
}
index += 1;
}
index += 1;
const textLines: string[] = [];
while (index < lines.length && lines[index].trim()) {
const sanitized = sanitizeCueLine(lines[index]);
if (sanitized.trim()) textLines.push(sanitized);
index += 1;
}
if (timing.end <= timing.start) continue;
// The cap can land inside an escape the sanitiser wrote, so a dangling `&lt` tail is
// trimmed rather than left for the parser to render as text.
const text = textLines
.join('\n')
.slice(0, MAX_CUE_TEXT_LENGTH)
.replace(/&[a-z]{0,5}$/i, '')
.trim();
if (!text) continue;
cues.push({ start: timing.start, end: timing.end, text });
if (cues.length >= MAX_SUBTITLE_CUES) break;
}
return cues;
}
export function serializeWebVtt(cues: SubtitleCue[]): string {
const body = cues
.map((cue) => `${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}`)
.join('\n\n');
return `WEBVTT\n\n${body}\n`;
}
/**
* The whole pipeline: bytes in, a canonical WebVTT string out, or a message explaining
* what is wrong with the file in terms the person who uploaded it can act on.
*/
export function normalizeSubtitleFile(buffer: Uint8Array): SubtitleNormalizeResult {
if (buffer.byteLength === 0) {
return { ok: false, error: 'Subtitle file is empty' };
}
const decoded = decodeSubtitleBuffer(buffer);
if (decoded === null) {
return { ok: false, error: 'Could not read the subtitle file. Save it as UTF-8 and retry.' };
}
const cues = parseSubtitleCues(decoded);
if (cues.length === 0) {
return { ok: false, error: 'No subtitle cues found. Upload a valid .srt or .vtt file.' };
}
const vtt = serializeWebVtt(cues);
if (Buffer.byteLength(vtt, 'utf8') > MAX_NORMALIZED_SUBTITLE_SIZE) {
return { ok: false, error: 'Subtitle file is too large after conversion' };
}
return { ok: true, vtt, cueCount: cues.length };
}
export function subtitleFileNameToProxyUrl(fileName: string): string {
return `${SUBTITLE_PROXY_PREFIX}${fileName}`;
}
export function extractSubtitleFileNameFromProxyUrl(url: string): string | null {
if (!SAFE_SUBTITLE_PROXY_PATH.test(url)) return null;
return url.slice(SUBTITLE_PROXY_PREFIX.length) || null;
}
export function subtitleProxyPathToObjectKey(url: string): string | null {
const fileName = extractSubtitleFileNameFromProxyUrl(url);
if (!fileName) return null;
return `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`;
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "openframe",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -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",
@@ -51,7 +53,7 @@
"gsap": "^3.14.2",
"hls.js": "^1.6.15",
"lucide-react": "^0.563.0",
"next": "16.2.11",
"next": "16.3.3",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"nodemailer": "^9.0.1",
@@ -0,0 +1,39 @@
-- Subtitle tracks hang off a version, not off the video: re-editing a cut shifts
-- every cue, so a track attached to the parent would be wrong for every version
-- but the one it was written against.
CREATE TABLE "video_subtitles" (
"id" TEXT NOT NULL,
"versionId" TEXT NOT NULL,
"language" TEXT NOT NULL,
"label" TEXT NOT NULL,
"sourceUrl" TEXT NOT NULL,
"size_bytes" BIGINT NOT NULL DEFAULT 0,
"billedUserId" TEXT NOT NULL,
"uploadedByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "video_subtitles_pkey" PRIMARY KEY ("id")
);
-- One stored object belongs to exactly one row, so the reference check that runs
-- before an object delete cannot be fooled by a second row pointing at the file.
CREATE UNIQUE INDEX "video_subtitles_sourceUrl_key" ON "video_subtitles"("sourceUrl");
-- Re-uploading a language replaces the track rather than stacking a second one,
-- which would leave the player with two tracks labelled the same.
CREATE UNIQUE INDEX "video_subtitles_versionId_language_key" ON "video_subtitles"("versionId", "language");
CREATE INDEX "video_subtitles_versionId_idx" ON "video_subtitles"("versionId");
-- The storage quota sums this column per billed user on every upload.
CREATE INDEX "video_subtitles_billedUserId_idx" ON "video_subtitles"("billedUserId");
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_versionId_fkey"
FOREIGN KEY ("versionId") REFERENCES "video_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_billedUserId_fkey"
FOREIGN KEY ("billedUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_uploadedByUserId_fkey"
FOREIGN KEY ("uploadedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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;
+62
View File
@@ -42,6 +42,8 @@ model User {
comments Comment[]
uploadedVideoAssets VideoAsset[] @relation("VideoAssetUploadedBy")
billedVideoAssets VideoAsset[] @relation("VideoAssetBilledTo")
uploadedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleUploadedBy")
billedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleBilledTo")
projectMemberships ProjectMember[]
notificationSetting NotificationSetting?
watchProgress WatchProgress[]
@@ -52,6 +54,7 @@ model User {
sentInvitations Invitation[] @relation("InvitationsSentBy")
acquisition UserAcquisition?
analyticsEvents AnalyticsEvent[]
subscriptionCancellations SubscriptionCancellation[]
@@map("users")
}
@@ -383,6 +386,7 @@ model VideoVersion {
comments Comment[]
watchProgress WatchProgress[]
approvalRequests ApprovalRequest[]
subtitles VideoSubtitle[]
@@unique([videoParentId, versionNumber])
@@index([videoParentId])
@@ -418,6 +422,34 @@ model VideoAsset {
@@map("video_assets")
}
/// A subtitle track for one cut. Timings belong to a version rather than to the
/// video: re-editing shifts every cue, so a track attached to the parent would be
/// wrong for every version but the one it was written against.
model VideoSubtitle {
id String @id @default(cuid())
versionId String
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
/// BCP-47 tag, lowercased primary subtag, e.g. `tr`, `en-US`.
language String
label String
/// Always an /api/upload/subtitle/<uuid>.vtt path. The file itself lives in
/// S3-compatible storage whatever the video's own provider is, so a Bunny-hosted
/// video and an R2-hosted one take the same path through the player.
sourceUrl String @unique
sizeBytes BigInt @default(0) @map("size_bytes")
billedUserId String
billedUser User @relation("VideoSubtitleBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade)
uploadedByUserId String?
uploadedByUser User? @relation("VideoSubtitleUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([versionId, language])
@@index([versionId])
@@index([billedUserId])
@@map("video_subtitles")
}
model Comment {
id String @id @default(cuid())
@@ -578,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.
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();
});
+62 -1
View File
@@ -48,8 +48,10 @@ 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';
import * as billingRoute from '@/app/api/billing/route';
import * as commentRoute from '@/app/api/comments/[commentId]/route';
import * as feedbackRoute from '@/app/api/feedback/route';
@@ -82,6 +84,7 @@ import * as uploadAudioFileRoute from '@/app/api/upload/audio/[filename]/route';
import * as uploadAudioRoute from '@/app/api/upload/audio/route';
import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route';
import * as uploadImageRoute from '@/app/api/upload/image/route';
import * as uploadSubtitleFileRoute from '@/app/api/upload/subtitle/[filename]/route';
import * as uploadVideoFileRoute from '@/app/api/upload/video/[filename]/route';
import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route';
import * as commentsExportRoute from '@/app/api/versions/[versionId]/comments/export/route';
@@ -92,6 +95,8 @@ import * as assetRoute from '@/app/api/videos/[videoId]/assets/[assetId]/route';
import * as assetsBunnyInitRoute from '@/app/api/videos/[videoId]/assets/bunny-init/route';
import * as assetsR2InitRoute from '@/app/api/videos/[videoId]/assets/r2-init/route';
import * as assetsRoute from '@/app/api/videos/[videoId]/assets/route';
import * as subtitleRoute from '@/app/api/videos/[videoId]/subtitles/[subtitleId]/route';
import * as subtitlesRoute from '@/app/api/videos/[videoId]/subtitles/route';
import * as watchProgressRoute from '@/app/api/watch/[videoId]/progress/route';
import * as watchRoute from '@/app/api/watch/[videoId]/route';
import * as watchUploadTokenRoute from '@/app/api/watch/[videoId]/upload-token/route';
@@ -145,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 = 63;
const EXPECTED_ROUTE_MODULE_COUNT = 68;
/**
* Routes that are public by design, and why. Everything else must reject an
@@ -203,6 +208,7 @@ const PUBLIC_ROUTES: ReadonlyMap<string, string> = new Map([
const IMAGE_FILENAME = '11111111-1111-4111-8111-111111111111.png';
const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm';
const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4';
const SUBTITLE_FILENAME = '44444444-4444-4444-8444-444444444444.vtt';
interface Fixtures {
userId: string;
@@ -217,6 +223,7 @@ interface Fixtures {
versionId: string;
commentId: string;
assetId: string;
subtitleId: string;
approvalRequestId: string;
feedbackId: string;
}
@@ -280,6 +287,20 @@ async function seedFixtures(): Promise<Fixtures> {
sourceUrl: `/api/upload/audio/${AUDIO_FILENAME}`,
});
// A real track, so /api/upload/subtitle/[filename] resolves to a row and its
// refusal comes from the access check rather than from the reverse lookup.
const subtitle = await db.videoSubtitle.create({
data: {
versionId: version.id,
language: 'tr',
label: 'Türkçe',
sourceUrl: `/api/upload/subtitle/${SUBTITLE_FILENAME}`,
sizeBytes: BigInt(64),
billedUserId: owner.id,
uploadedByUserId: owner.id,
},
});
await createShareLink({ projectId: project.id, videoId: video.id, permission: 'COMMENT' });
const approvalRequest = await createApprovalRequest({
@@ -310,6 +331,7 @@ async function seedFixtures(): Promise<Fixtures> {
versionId: version.id,
commentId: comment.id,
assetId: asset.id,
subtitleId: subtitle.id,
approvalRequestId: approvalRequest.id,
feedbackId: feedback.id,
};
@@ -384,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,
@@ -397,6 +425,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
headers: { origin: 'http://localhost:3000' },
},
{ file: 'billing/route.ts', module: billingRoute, url: () => '/api/billing' },
{
file: 'billing/trial/route.ts',
module: billingTrialRoute,
url: () => '/api/billing/trial',
headers: { origin: 'http://localhost:3000' },
},
{
file: 'comments/[commentId]/route.ts',
module: commentRoute,
@@ -595,6 +629,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
// and constructing a Request from a FormData does not set one.
headers: { 'content-length': '2048' },
},
{
file: 'upload/subtitle/[filename]/route.ts',
module: uploadSubtitleFileRoute,
url: () => `/api/upload/subtitle/${SUBTITLE_FILENAME}`,
params: () => ({ filename: SUBTITLE_FILENAME }),
},
{
file: 'upload/video/[filename]/route.ts',
module: uploadVideoFileRoute,
@@ -669,6 +709,27 @@ const ROUTE_CASES: readonly RouteCase[] = [
// exact-status coverage lives in tests/api/assets-authz.test.ts.
body: { kind: 'IMAGE', sourceUrl: `/api/upload/image/${IMAGE_FILENAME}` },
},
{
file: 'videos/[videoId]/subtitles/[subtitleId]/route.ts',
module: subtitleRoute,
url: (f) => `/api/videos/${f.videoId}/subtitles/${f.subtitleId}`,
params: (f) => ({ videoId: f.videoId, subtitleId: f.subtitleId }),
},
{
file: 'videos/[videoId]/subtitles/route.ts',
module: subtitlesRoute,
url: (f) => `/api/videos/${f.videoId}/subtitles`,
params: (f) => ({ videoId: f.videoId }),
// POST sizes the body before it does anything else, and a Request built from
// a FormData carries no Content-Length, so without this the anonymous call
// would stop on a 400 above the guard rather than on the guard.
headers: { 'content-length': '4096' },
rawBody: () => {
const form = new FormData();
form.append('subtitle', new File(['WEBVTT'], 'anon.vtt', { type: 'text/vtt' }));
return form;
},
},
{
file: 'watch/[videoId]/progress/route.ts',
module: watchProgressRoute,
+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);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { POST as startTrialRoute } from '@/app/api/billing/trial/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import { addWorkspaceMember, createExpiredUser, createUser, seedProject } from '../factories';
const ORIGIN_HEADERS = { origin: 'http://localhost:3000' };
function startTrialRequest() {
return apiRequest('/api/billing/trial', { method: 'POST', headers: ORIGIN_HEADERS });
}
describe('POST /api/billing/trial', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(startTrialRoute, startTrialRequest());
expect(response.status).toBe(401);
});
it('rejects a cross-origin request', async () => {
const response = await callRoute(
startTrialRoute,
apiRequest('/api/billing/trial', { method: 'POST', headers: { origin: 'https://evil.test' } })
);
expect(response.status).toBe(403);
});
// The whole point of the endpoint: an invited collaborator whose trial was
// deferred at signup claims it here, explicitly, and nowhere else.
it('starts the deferred trial for a collaborator who asks for it', async () => {
const host = await seedProject();
const invited = await createUser({ trialEndsAt: null, billingTrialConsumedAt: null });
await addWorkspaceMember({ workspaceId: host.workspace.id, userId: invited.id });
signedInAs(invited);
const response = await callRoute(startTrialRoute, startTrialRequest());
expect(response.status).toBe(200);
const data = await readData<{ trialEndsAt: string | null }>(response);
expect(data.trialEndsAt).not.toBeNull();
const after = await db.user.findUniqueOrThrow({ where: { id: invited.id } });
expect(after.billingTrialConsumedAt).not.toBeNull();
expect(after.trialEndsAt!.getTime()).toBeGreaterThan(Date.now());
});
// Once per account. An expired user already spent theirs; asking again must
// not reset the clock.
it('refuses a second trial to an account that already spent one', async () => {
const expired = await createExpiredUser();
signedInAs(expired);
const response = await callRoute(startTrialRoute, startTrialRequest());
expect(response.status).toBe(409);
const after = await db.user.findUniqueOrThrow({ where: { id: expired.id } });
expect(after.trialEndsAt?.getTime()).toBeLessThan(Date.now());
});
});
+50 -1
View File
@@ -7,6 +7,7 @@
// fails a test rather than a security review.
import { createHash } from 'node:crypto';
import { InvitationScope } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import nodemailer from 'nodemailer';
import { db } from '@/lib/db';
@@ -20,7 +21,7 @@ import { GET as verifyEmail } from '@/app/api/auth/verify-email/route';
import { POST as resendVerification } from '@/app/api/auth/verify-email/resend/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { mailTo, sentMail } from '../helpers/mail';
import { createUser } from '../factories';
import { addWorkspaceMember, createInvitation, createUser, seedProject } from '../factories';
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
@@ -117,6 +118,54 @@ describe('consumeVerificationToken', () => {
expect(days).toBe(7);
});
// An invited collaborator works inside the inviter's workspace on the inviter's
// billing, so a trial handed over here would be spent before they had seen the
// product on an account of their own, and `billingTrialConsumedAt` is never
// cleared. It waits until they create a workspace of their own.
it('holds the trial back for somebody who verified as an invited member', async () => {
const host = await seedProject();
const user = await createUser({
email: '[email protected]',
emailVerified: null,
trialEndsAt: null,
billingTrialConsumedAt: null,
});
await addWorkspaceMember({ workspaceId: host.workspace.id, userId: user.id });
const token = await createVerificationToken('[email protected]');
await consumeVerificationToken(token);
const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(verified.emailVerified).toBeInstanceOf(Date);
expect(verified.trialEndsAt).toBeNull();
expect(verified.billingTrialConsumedAt).toBeNull();
});
// The OAuth half of the same case: the account exists before the invitation is
// accepted, so the still-open invitation is the only signal there is.
it('holds the trial back while an invitation to that address is still open', async () => {
const host = await seedProject();
const user = await createUser({
email: '[email protected]',
emailVerified: null,
trialEndsAt: null,
billingTrialConsumedAt: null,
});
await createInvitation({
email: '[email protected]',
scope: InvitationScope.WORKSPACE,
workspaceId: host.workspace.id,
invitedById: host.owner.id,
});
const token = await createVerificationToken('[email protected]');
await consumeVerificationToken(token);
const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(verified.trialEndsAt).toBeNull();
expect(verified.billingTrialConsumedAt).toBeNull();
});
it('does not hand a second trial to an account that already had one', async () => {
const consumedAt = new Date('2026-01-01T00:00:00.000Z');
const trialEndsAt = new Date('2026-01-08T00:00:00.000Z');
+47
View File
@@ -407,6 +407,53 @@ describe('POST /api/auth/register', () => {
expect(created.billingTrialConsumedAt).toBeNull();
});
// Registering through an invitation is the one case where the trial is held
// back even on an instance with no SMTP: the account is verified and created,
// but it joined somebody else's workspace and does not need a trial to work
// there. Creating a workspace of its own is what starts the clock.
it('grants no trial to an invited collaborator even without a verification step', async () => {
vi.stubEnv('SMTP_HOST', '');
vi.stubEnv('SMTP_USER', '');
vi.stubEnv('SMTP_PASSWORD', '');
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
role: 'COMMENTATOR',
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Invited Guest',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(201);
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
expect(created.emailVerified).toBeInstanceOf(Date);
expect(created.trialEndsAt).toBeNull();
expect(created.billingTrialConsumedAt).toBeNull();
});
it('starts the trial for somebody signing themselves up without SMTP', async () => {
vi.stubEnv('SMTP_HOST', '');
vi.stubEnv('SMTP_USER', '');
vi.stubEnv('SMTP_PASSWORD', '');
await post({ name: 'Self Hosted', email: '[email protected]', password: PASSWORD });
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
expect(created.trialEndsAt).toBeInstanceOf(Date);
expect(created.billingTrialConsumedAt).toBeInstanceOf(Date);
});
it('reports the rate limit budget on a successful registration', async () => {
const response = await post({
name: 'Rate Limited',
+514
View File
@@ -0,0 +1,514 @@
// The subtitle family: list, upload, delete, and the proxy that serves the stored
// WebVTT back to the player.
//
// Two properties are worth pinning down here rather than in the unit suite.
//
// - The upload path is editor-only. Every other write under /api/videos/[videoId]
// is open to anyone who may comment, guests included, so a subtitle route that
// reached for `canUploadAssets` instead of `canManageAssets` would look correct
// next to its neighbours and would let a share-link viewer rewrite the captions
// on a delivered cut.
//
// - What lands in storage is the normalised file, never the bytes that were
// uploaded. The assertions below read the PutObject command rather than trusting
// the 201.
//
// tests/setup/api.ts stubs the named helpers in `@/lib/r2` but leaves `r2Client`
// real, and the real one throws on first use, so it is replaced with a recorder.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import {
GET as listSubtitles,
POST as uploadSubtitle,
} from '@/app/api/videos/[videoId]/subtitles/route';
import { DELETE as deleteSubtitle } from '@/app/api/videos/[videoId]/subtitles/[subtitleId]/route';
import { GET as serveSubtitle } from '@/app/api/upload/subtitle/[filename]/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import { addProjectMember, createUser, seedVersion } from '../factories';
const r2 = vi.hoisted(() => ({
bucket: 'openframe-subtitle-test-bucket',
puts: [] as Array<{ key: string; body: string; contentType: string }>,
deletedKeys: [] as string[],
gets: [] as string[],
}));
vi.mock('@/lib/r2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/r2')>();
return {
...actual,
R2_BUCKET_NAME: r2.bucket,
r2Client: {
send: async (command: {
constructor: { name: string };
input?: { Key?: string; Body?: Buffer; ContentType?: string };
}) => {
const key = command.input?.Key ?? '';
switch (command.constructor.name) {
case 'PutObjectCommand':
r2.puts.push({
key,
body: Buffer.from(command.input?.Body ?? Buffer.alloc(0)).toString('utf8'),
contentType: command.input?.ContentType ?? '',
});
return {};
case 'DeleteObjectCommand':
r2.deletedKeys.push(key);
return {};
case 'GetObjectCommand': {
r2.gets.push(key);
const stored = r2.puts.find((put) => put.key === key);
if (!stored) {
const error = new Error('NoSuchKey');
error.name = 'NoSuchKey';
throw error;
}
return {
Body: new Response(stored.body).body,
ContentType: stored.contentType,
ContentLength: Buffer.byteLength(stored.body),
};
}
default:
return {};
}
},
},
};
});
const SRT_FILE = ['1', '00:00:01,000 --> 00:00:02,500', 'Merhaba', '', ''].join('\n');
const NORMALIZED_VTT = 'WEBVTT\n\n00:00:01.000 --> 00:00:02.500\nMerhaba\n';
const SUBTITLE_KEY = /^subtitles\/[0-9a-f-]{36}\.vtt$/;
beforeEach(() => {
r2.puts.length = 0;
r2.deletedKeys.length = 0;
r2.gets.length = 0;
});
function subtitlesUrl(videoId: string): string {
return `/api/videos/${videoId}/subtitles`;
}
function subtitleForm(input: {
content?: string;
fileName?: string;
versionId: string;
language?: string;
label?: string;
}): FormData {
const form = new FormData();
form.append(
'subtitle',
new File([input.content ?? SRT_FILE], input.fileName ?? 'cut.tr.srt', { type: 'text/plain' })
);
form.append('versionId', input.versionId);
if (input.language !== undefined) form.append('language', input.language);
if (input.label !== undefined) form.append('label', input.label);
return form;
}
function uploadRequest(videoId: string, form: FormData) {
return apiRequest(subtitlesUrl(videoId), {
rawBody: form,
// Constructing a Request from a FormData sets no Content-Length, and the route
// refuses a body it cannot size before it reads one.
headers: { 'content-length': '4096' },
});
}
/** An editor-owned bunny version with one Turkish track already uploaded. */
async function seedSubtitledVersion() {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr', label: 'Türkçe' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(201);
const subtitle = await readData<{ id: string; url: string }>(response);
return { ...scenario, subtitle };
}
// ---------------------------------------------------------------------------
// POST /api/videos/[videoId]/subtitles
// ---------------------------------------------------------------------------
describe('POST /api/videos/[videoId]/subtitles', () => {
it('stores the normalised WebVTT rather than the uploaded SRT', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'TR', label: ' Türkçe ' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(201);
const created = await readData<{ language: string; label: string; url: string }>(response);
expect(created.language).toBe('tr');
expect(created.label).toBe('Türkçe');
expect(created.url).toMatch(/^\/api\/upload\/subtitle\/[0-9a-f-]{36}\.vtt$/);
expect(r2.puts).toHaveLength(1);
expect(r2.puts[0].key).toMatch(SUBTITLE_KEY);
expect(r2.puts[0].body).toBe(NORMALIZED_VTT);
expect(r2.puts[0].contentType).toBe('text/vtt; charset=utf-8');
const row = await db.videoSubtitle.findFirstOrThrow({
where: { versionId: scenario.version.id },
});
expect(row.billedUserId).toBe(scenario.owner.id);
expect(row.uploadedByUserId).toBe(scenario.owner.id);
expect(Number(row.sizeBytes)).toBe(Buffer.byteLength(NORMALIZED_VTT));
});
it('leaves no upload reservation behind once the row is committed', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(await db.uploadReservation.count()).toBe(0);
});
it('replaces the track for a language instead of stacking a second one', async () => {
const scenario = await seedSubtitledVersion();
const firstKey = r2.puts[0].key;
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({
versionId: scenario.version.id,
language: 'tr',
label: 'Türkçe düzeltme',
content: ['1', '00:00:04,000 --> 00:00:05,000', 'Düzeltildi', '', ''].join('\n'),
})
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(201);
const rows = await db.videoSubtitle.findMany({ where: { versionId: scenario.version.id } });
expect(rows).toHaveLength(1);
expect(rows[0].label).toBe('Türkçe düzeltme');
// The object the replaced row pointed at is gone, so it cannot outlive its row.
expect(r2.deletedKeys).toEqual([firstKey]);
});
it('keeps a second language alongside the first', async () => {
const scenario = await seedSubtitledVersion();
await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'en', fileName: 'cut.en.srt' })
),
{ videoId: scenario.video.id }
);
const rows = await db.videoSubtitle.findMany({
where: { versionId: scenario.version.id },
orderBy: { language: 'asc' },
});
expect(rows.map((row) => row.language)).toEqual(['en', 'tr']);
});
it('refuses a file with no cues and stores nothing', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({
versionId: scenario.version.id,
language: 'tr',
content: 'just some prose\nand more of it\n',
})
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(400);
expect(r2.puts).toHaveLength(0);
expect(await db.videoSubtitle.count()).toBe(0);
});
it('refuses a file that is not a subtitle by extension', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr', fileName: 'payload.html' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toBe('Subtitle must be a .srt or .vtt file');
});
it('refuses a language that is not a tag', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: '<script>' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(400);
});
it('refuses a version that belongs to another video', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
const other = await seedVersion({ providerId: 'bunny', ownerUser: scenario.owner });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: other.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(404);
expect(r2.puts).toHaveLength(0);
});
it('refuses a project COMMENTATOR, who may comment but not edit the cut', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
expect(r2.puts).toHaveLength(0);
});
it('refuses an anonymous caller', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedOut();
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// GET /api/videos/[videoId]/subtitles
// ---------------------------------------------------------------------------
describe('GET /api/videos/[videoId]/subtitles', () => {
it('lists the tracks of one version and tells an editor they may manage them', async () => {
const scenario = await seedSubtitledVersion();
const response = await callRoute(
listSubtitles,
apiRequest(subtitlesUrl(scenario.video.id), {
searchParams: { versionId: scenario.version.id },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(200);
const data = await readData<{
subtitles: Array<{ language: string; canDelete: boolean }>;
canManageSubtitles: boolean;
}>(response);
expect(data.subtitles.map((subtitle) => subtitle.language)).toEqual(['tr']);
expect(data.canManageSubtitles).toBe(true);
expect(data.subtitles[0].canDelete).toBe(true);
});
it('shows a COMMENTATOR the tracks without the ability to manage them', async () => {
const scenario = await seedSubtitledVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(listSubtitles, apiRequest(subtitlesUrl(scenario.video.id)), {
videoId: scenario.video.id,
});
expect(response.status).toBe(200);
const data = await readData<{
subtitles: Array<{ canDelete: boolean }>;
canManageSubtitles: boolean;
}>(response);
expect(data.subtitles).toHaveLength(1);
expect(data.canManageSubtitles).toBe(false);
expect(data.subtitles[0].canDelete).toBe(false);
});
it('refuses a signed-in stranger', async () => {
const scenario = await seedSubtitledVersion();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(listSubtitles, apiRequest(subtitlesUrl(scenario.video.id)), {
videoId: scenario.video.id,
});
expect(response.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// DELETE /api/videos/[videoId]/subtitles/[subtitleId]
// ---------------------------------------------------------------------------
describe('DELETE /api/videos/[videoId]/subtitles/[subtitleId]', () => {
it('removes the row and the stored object', async () => {
const scenario = await seedSubtitledVersion();
const storedKey = r2.puts[0].key;
const response = await callRoute(
deleteSubtitle,
apiRequest(`${subtitlesUrl(scenario.video.id)}/${scenario.subtitle.id}`, {
method: 'DELETE',
}),
{ videoId: scenario.video.id, subtitleId: scenario.subtitle.id }
);
expect(response.status).toBe(200);
expect(await db.videoSubtitle.count()).toBe(0);
expect(r2.deletedKeys).toEqual([storedKey]);
});
it('refuses a COMMENTATOR and leaves the track in place', async () => {
const scenario = await seedSubtitledVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
deleteSubtitle,
apiRequest(`${subtitlesUrl(scenario.video.id)}/${scenario.subtitle.id}`, {
method: 'DELETE',
}),
{ videoId: scenario.video.id, subtitleId: scenario.subtitle.id }
);
expect(response.status).toBe(403);
expect(await db.videoSubtitle.count()).toBe(1);
expect(r2.deletedKeys).toHaveLength(0);
});
it('answers 404 for a subtitle that belongs to another video', async () => {
const scenario = await seedSubtitledVersion();
const other = await seedVersion({ providerId: 'bunny', ownerUser: scenario.owner });
signedInAs(scenario.owner);
const response = await callRoute(
deleteSubtitle,
apiRequest(`${subtitlesUrl(other.video.id)}/${scenario.subtitle.id}`, { method: 'DELETE' }),
{ videoId: other.video.id, subtitleId: scenario.subtitle.id }
);
expect(response.status).toBe(404);
expect(await db.videoSubtitle.count()).toBe(1);
});
});
// ---------------------------------------------------------------------------
// GET /api/upload/subtitle/[filename]
// ---------------------------------------------------------------------------
describe('GET /api/upload/subtitle/[filename]', () => {
function fileNameOf(url: string): string {
return url.slice('/api/upload/subtitle/'.length);
}
it('serves the stored WebVTT to a viewer', async () => {
const scenario = await seedSubtitledVersion();
const filename = fileNameOf(scenario.subtitle.url);
const response = await callRoute(serveSubtitle, apiRequest(scenario.subtitle.url), {
filename,
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/vtt; charset=utf-8');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(await response.text()).toBe(NORMALIZED_VTT);
});
it('refuses a signed-in stranger without reading the object', async () => {
const scenario = await seedSubtitledVersion();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(serveSubtitle, apiRequest(scenario.subtitle.url), {
filename: fileNameOf(scenario.subtitle.url),
});
expect(response.status).toBe(403);
expect(r2.gets).toHaveLength(0);
});
it('rejects a filename that is not a stored subtitle', async () => {
const response = await callRoute(serveSubtitle, apiRequest('/api/upload/subtitle/x'), {
filename: '../../etc/passwd',
});
expect(response.status).toBe(400);
});
});
+38
View File
@@ -14,6 +14,7 @@ import {
DELETE as removeWorkspaceMember,
PATCH as patchWorkspaceMember,
} from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route';
import { startCardlessTrial } from '@/lib/billing';
import { apiRequest, callRoute, readData, readJson } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
@@ -180,6 +181,43 @@ describe('POST /api/workspaces', () => {
expect(await db.workspace.count()).toBe(1);
});
// An invited collaborator's trial is deferred, and nothing starts it as a side
// effect: the create is refused until they claim the trial explicitly.
it('refuses a workspace to a collaborator whose trial is still unclaimed', async () => {
const host = await seedProject();
const invited = await createUser({ trialEndsAt: null, billingTrialConsumedAt: null });
await addWorkspaceMember({ workspaceId: host.workspace.id, userId: invited.id });
signedInAs(invited);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'My Own' } })
);
expect(response.status).toBe(403);
expect(await db.workspace.count({ where: { ownerId: invited.id } })).toBe(0);
const after = await db.user.findUniqueOrThrow({ where: { id: invited.id } });
expect(after.trialEndsAt).toBeNull();
expect(after.billingTrialConsumedAt).toBeNull();
});
it('lets that collaborator create a workspace once they start their trial', async () => {
const host = await seedProject();
const invited = await createUser({ trialEndsAt: null, billingTrialConsumedAt: null });
await addWorkspaceMember({ workspaceId: host.workspace.id, userId: invited.id });
signedInAs(invited);
await startCardlessTrial(invited.id);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'My Own' } })
);
expect(response.status).toBe(201);
expect(await db.workspace.count({ where: { ownerId: invited.id } })).toBe(1);
});
it('refuses a second workspace for an expired user', async () => {
const expired = await createExpiredUser();
await createWorkspace({ ownerId: expired.id });
@@ -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();
});
});
@@ -652,3 +652,69 @@ describe('useDownloadActions repeated clicks', () => {
expect(clicked).toHaveLength(1);
});
});
describe('useDownloadActions guarding the tab', () => {
function fireBeforeUnload(): BeforeUnloadEvent {
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent;
window.dispatchEvent(event);
return event;
}
// Closing the tab used to throw away a half-pulled file without a word,
// because the browser has no idea a fetch-driven download is running.
it('warns before the tab closes while the bytes are being pulled', async () => {
const pending = deferred<unknown>();
const harness = renderDownload();
fetchMock.mockImplementation((url: string) => {
if (typeof url === 'string' && url.includes('prepare=1')) {
return Promise.resolve(prepareResponse(true, { data: {} }));
}
return pending.promise;
});
let started: Promise<void> | undefined;
await act(async () => {
started = harness.result.current.startDownload();
// Let the prepare call settle so the byte fetch is the pending one.
await Promise.resolve();
});
expect(fireBeforeUnload().defaultPrevented).toBe(true);
await act(async () => {
pending.resolve(fileResponse());
await started;
});
expect(fireBeforeUnload().defaultPrevented).toBe(false);
});
it('releases the guard when the download fails', async () => {
downloadResponse = fileResponse({ ok: false });
const harness = renderDownload();
await act(async () => {
await harness.result.current.startDownload();
});
expect(fireBeforeUnload().defaultPrevented).toBe(false);
});
// A same-origin proxy download is handed to the browser, which keeps going
// after the tab closes, so nothing should block the unload there.
it('does not warn for a browser-owned download', async () => {
const harness = renderDownload({
activeVersion: makeVersion({
providerId: 'r2',
originalUrl: '/api/upload/video/abc.mp4',
}),
});
await act(async () => {
await harness.result.current.startDownload();
});
expect(clicked).toHaveLength(1);
expect(fireBeforeUnload().defaultPrevented).toBe(false);
});
});
@@ -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`);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
import { beginUnloadGuard, unloadGuardCount } from '@/lib/client/unload-guard';
let addSpy: MockInstance<typeof window.addEventListener>;
let removeSpy: MockInstance<typeof window.removeEventListener>;
/** True when something cancelled the unload, which is what makes the browser
* show its "leave site?" dialog. */
function unloadWasBlocked(): boolean {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
function listenerCalls(spy: MockInstance<typeof window.addEventListener>): number {
return spy.mock.calls.filter(([type]) => type === 'beforeunload').length;
}
beforeEach(() => {
addSpy = vi.spyOn(window, 'addEventListener');
removeSpy = vi.spyOn(window, 'removeEventListener');
});
afterEach(() => {
vi.restoreAllMocks();
// A leaked guard would block the unload for the rest of the session, so a
// test that leaves one behind must fail here rather than in the next test.
expect(unloadGuardCount()).toBe(0);
});
describe('beginUnloadGuard', () => {
it('cancels the unload while a download holds it', () => {
const release = beginUnloadGuard();
const blocked = unloadWasBlocked();
release();
expect(blocked).toBe(true);
});
it('lets the page go once the download is released', () => {
beginUnloadGuard()();
expect(unloadWasBlocked()).toBe(false);
});
it('keeps the listener until the last concurrent download releases', () => {
const releaseA = beginUnloadGuard();
const releaseB = beginUnloadGuard();
expect(listenerCalls(addSpy)).toBe(1);
releaseA();
const stillBlocked = unloadWasBlocked();
releaseB();
expect(stillBlocked).toBe(true);
expect(listenerCalls(removeSpy)).toBe(1);
expect(unloadWasBlocked()).toBe(false);
});
// The download hook releases from a finally block, and a caller could hold
// the returned function longer; a double release must not drop a guard
// another download still holds.
it('ignores a second release', () => {
const releaseA = beginUnloadGuard();
const releaseB = beginUnloadGuard();
releaseA();
releaseA();
const stillBlocked = unloadWasBlocked();
releaseB();
expect(stillBlocked).toBe(true);
});
});
+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' })) },
},
+2
View File
@@ -69,6 +69,8 @@ const REVIEWED_MIGRATIONS = [
'20260801120000_add_acquisition_analytics',
'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);
});
});
+426 -55
View File
@@ -15,6 +15,10 @@ import {
getOrCreateStripeCustomerId,
getStorageCleanupEligibleAt,
getStripeCheckoutState,
getInvoiceSubscriptionId,
getSubscriptionPeriodEnd,
getSubscriptionPeriodStart,
getTrialNotice,
getWorkspaceCreationEligibility,
hasActiveSubscription,
hasActiveTrial,
@@ -26,15 +30,19 @@ import {
markSubscriptionCanceledByCustomerId,
selectAuthoritativeSubscription,
startCardlessTrial,
startCardlessTrialOnSignup,
syncStripeCustomerSubscriptions,
syncStripeSubscriptionToUser,
} 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() },
projectMember: { count: vi.fn() },
invitation: { count: vi.fn() },
analyticsEvent: { createMany: vi.fn() },
}));
@@ -149,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);
@@ -160,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);
@@ -172,6 +188,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
billingAccessEndedAt: null,
},
NOW
)
@@ -182,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);
@@ -197,6 +218,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
billingAccessEndedAt: null,
},
NOW
)
@@ -209,6 +231,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
billingAccessEndedAt: null,
},
NOW
)
@@ -224,6 +247,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
billingAccessEndedAt: null,
},
NOW
)
@@ -236,6 +260,7 @@ describe('isPaidTier', () => {
{
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS),
billingAccessEndedAt: null,
},
NOW
)
@@ -247,7 +272,11 @@ describe('isPaidTier', () => {
expect(
isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
{
subscriptionStatus: BillingSubscriptionStatus.FREE,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: null,
},
NOW
)
).toBe(true);
@@ -363,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',
@@ -372,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(
@@ -390,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({
@@ -399,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({
@@ -410,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', () => {
@@ -449,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 } }],
},
],
});
});
@@ -469,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: [] } });
@@ -805,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();
@@ -818,6 +954,7 @@ describe('database backed billing helpers', () => {
dbMock.workspace.count.mockReset();
dbMock.workspaceMember.count.mockReset();
dbMock.projectMember.count.mockReset();
dbMock.invitation.count.mockReset();
stripeMock.customers.create.mockReset();
stripeMock.subscriptions.list.mockReset();
dbMock.user.update.mockImplementation(async (args: { data: unknown }) => args.data);
@@ -926,19 +1063,188 @@ describe('database backed billing helpers', () => {
});
});
describe('startCardlessTrialOnSignup', () => {
function mockSignup(options: {
email?: string | null;
workspaceMemberships?: number;
projectMemberships?: number;
pendingInvitations?: number;
}) {
dbMock.user.findUnique.mockResolvedValue({
email: 'email' in options ? options.email : '[email protected]',
});
dbMock.workspaceMember.count.mockResolvedValue(options.workspaceMemberships ?? 0);
dbMock.projectMember.count.mockResolvedValue(options.projectMemberships ?? 0);
dbMock.invitation.count.mockResolvedValue(options.pendingInvitations ?? 0);
dbMock.user.updateMany.mockResolvedValue({ count: 1 });
}
it('grants the trial to somebody who signed themselves up', async () => {
mockSignup({});
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(true);
expect(dbMock.user.updateMany).toHaveBeenCalled();
});
// The credentials route accepts the invitation in the same request that
// creates the account, so the membership is what gives the collaborator away.
it('holds the trial back for a member of somebody else workspace', async () => {
mockSignup({ workspaceMemberships: 1 });
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
expect(dbMock.user.updateMany).not.toHaveBeenCalled();
});
it('holds the trial back for a member of somebody else project', async () => {
mockSignup({ projectMemberships: 1 });
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
expect(dbMock.user.updateMany).not.toHaveBeenCalled();
});
// An OAuth signup creates the account before the invitation is accepted, so
// there the still-pending invitation is the only signal available.
it('holds the trial back while an invitation to this address is pending', async () => {
mockSignup({ pendingInvitations: 1 });
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
expect(dbMock.user.updateMany).not.toHaveBeenCalled();
});
it('only counts invitations that are still open', async () => {
mockSignup({});
await startCardlessTrialOnSignup('u1');
expect(dbMock.invitation.count).toHaveBeenCalledWith({
where: {
email: '[email protected]',
status: 'PENDING',
expiresAt: { gt: NOW },
},
});
});
it('does not look for invitations when the account has no address', async () => {
mockSignup({ email: null });
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(true);
expect(dbMock.invitation.count).not.toHaveBeenCalled();
});
it('grants nothing when billing is switched off entirely', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
mockSignup({});
await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
expect(dbMock.user.findUnique).not.toHaveBeenCalled();
});
});
describe('getTrialNotice', () => {
function mockNotice(options: {
trialEndsAt?: Date | null;
status?: BillingSubscriptionStatus;
billingAccessEndedAt?: Date | null;
ownedWorkspaces?: number;
collaborations?: number;
}) {
dbMock.user.findUnique.mockResolvedValue({
subscriptionStatus: options.status ?? BillingSubscriptionStatus.FREE,
trialEndsAt: options.trialEndsAt ?? null,
stripeCurrentPeriodEnd: null,
billingAccessEndedAt: options.billingAccessEndedAt ?? null,
});
dbMock.workspace.count
.mockResolvedValueOnce(options.ownedWorkspaces ?? 1)
.mockResolvedValueOnce(options.collaborations ?? 0);
}
it('says nothing while the trial still has more than the notice window left', async () => {
mockNotice({ trialEndsAt: new Date(NOW.getTime() + 5 * DAY_MS) });
await expect(getTrialNotice('u1')).resolves.toBeNull();
});
it('counts down once the trial is inside the notice window', async () => {
mockNotice({ trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS) });
const notice = await getTrialNotice('u1');
expect(notice?.kind).toBe('ending');
});
it('reports the trial as ended along with the date the media is kept until', async () => {
const endedAt = new Date(NOW.getTime() - 2 * DAY_MS);
mockNotice({ trialEndsAt: endedAt, billingAccessEndedAt: endedAt });
const notice = await getTrialNotice('u1');
expect(notice?.kind).toBe('ended');
expect(notice?.contentKeptUntil?.getTime()).toBe(endedAt.getTime() + 15 * DAY_MS);
});
// The banner is about this account's own deadline and its own media. A guest
// in a paying customer's workspace has neither, and was being told a paying
// customer's work would be deleted.
it('says nothing to a collaborator who owns no workspace of their own', async () => {
mockNotice({
trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
ownedWorkspaces: 0,
collaborations: 1,
});
await expect(getTrialNotice('u1')).resolves.toBeNull();
});
it('still warns a collaborator who also owns a workspace', async () => {
mockNotice({
trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
ownedWorkspaces: 1,
collaborations: 1,
});
expect((await getTrialNotice('u1'))?.kind).toBe('ending');
});
// A solo account that has not set anything up yet is not a collaborator, and
// its deadline is real.
it('still warns an account that owns nothing and collaborates nowhere', async () => {
mockNotice({
trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
ownedWorkspaces: 0,
collaborations: 0,
});
expect((await getTrialNotice('u1'))?.kind).toBe('ending');
});
it('leaves the ownership queries unrun when there is no notice to show', async () => {
mockNotice({ trialEndsAt: new Date(NOW.getTime() + 5 * DAY_MS) });
await getTrialNotice('u1');
expect(dbMock.workspace.count).not.toHaveBeenCalled();
});
});
describe('getWorkspaceCreationEligibility', () => {
function mockEligibility(options: {
user?: Record<string, unknown> | null;
owned?: number;
invited?: number;
projectOnly?: number;
/** Whether the once-per-account trial has already been spent and run out. */
consumed?: boolean;
}) {
dbMock.user.findUnique.mockResolvedValue(
options.user === undefined
? {
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: null,
billingTrialConsumedAt: null,
billingTrialConsumedAt: options.consumed
? new Date(NOW.getTime() - 30 * DAY_MS)
: null,
stripeCustomerId: null,
stripeSubscriptionId: null,
stripePriceId: null,
@@ -1045,7 +1351,7 @@ describe('database backed billing helpers', () => {
});
it('blocks an expired owner who already has a workspace', async () => {
mockEligibility({ owned: 1 });
mockEligibility({ owned: 1, consumed: true });
const result = await getWorkspaceCreationEligibility('u1');
@@ -1053,27 +1359,41 @@ describe('database backed billing helpers', () => {
expect(result.reason).toContain('Your trial has ended');
});
it('blocks an expired user who only collaborates in someone else workspace', async () => {
// The deferred trial stays the collaborator's to spend, but never as a side
// effect: the workspace door stays shut until they explicitly start it, which
// is what `canStartTrial` tells the UI to offer.
it('blocks a collaborator whose trial is still unclaimed but offers to start it', async () => {
mockEligibility({ owned: 0, invited: 1 });
const result = await getWorkspaceCreationEligibility('u1');
expect(result.canCreateWorkspace).toBe(false);
expect(result.reason).toContain('currently collaborating');
expect(result.canStartTrial).toBe(true);
expect(result.reason).toContain('Start it to create a workspace of your own');
});
it('counts project-only collaboration towards the same block', async () => {
it('offers the same deferred trial to a project-only collaborator', async () => {
mockEligibility({ owned: 0, projectOnly: 2 });
const result = await getWorkspaceCreationEligibility('u1');
expect(result.canCreateWorkspace).toBe(false);
expect(result.reason).toContain('currently collaborating');
expect(result.canStartTrial).toBe(true);
expect(result.projectOnlyCollaborationCount).toBe(2);
});
it('blocks a collaborator whose own trial has already run out', async () => {
mockEligibility({ owned: 0, invited: 1, consumed: true });
const result = await getWorkspaceCreationEligibility('u1');
expect(result.canCreateWorkspace).toBe(false);
expect(result.canStartTrial).toBe(false);
expect(result.reason).toContain('Your trial has ended');
});
it('prefers the trial-ended reason when the user both owns and collaborates', async () => {
mockEligibility({ owned: 1, invited: 1 });
mockEligibility({ owned: 1, invited: 1, consumed: true });
expect((await getWorkspaceCreationEligibility('u1')).reason).toContain(
'Your trial has ended'
@@ -1136,6 +1456,7 @@ describe('database backed billing helpers', () => {
expect(overview.workspaceCreation).toEqual({
canCreateWorkspace: true,
canStartTrial: false,
reason: null,
ownedWorkspaceCount: 2,
invitedWorkspaceCount: 1,
@@ -1263,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 () => {
@@ -1439,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),
@@ -1453,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);
});
@@ -1517,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 () => {
@@ -1528,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());
});
+211
View File
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import {
decodeSubtitleBuffer,
getSubtitleExtension,
MAX_SUBTITLE_CUES,
normalizeSubtitleFile,
normalizeSubtitleLanguage,
parseSubtitleCues,
sanitizeSubtitleLabel,
SAFE_SUBTITLE_PROXY_PATH,
serializeWebVtt,
subtitleProxyPathToObjectKey,
} from '@/lib/subtitle-validation';
const UUID = '11111111-2222-3333-4444-555555555555';
function encode(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
describe('getSubtitleExtension', () => {
it('accepts the two subtitle formats and nothing else', () => {
expect(getSubtitleExtension('cut.srt')).toBe('srt');
expect(getSubtitleExtension('cut.VTT')).toBe('vtt');
expect(getSubtitleExtension('cut.ass')).toBeNull();
expect(getSubtitleExtension('cut.srt.exe')).toBeNull();
});
});
describe('normalizeSubtitleLanguage', () => {
it('lowercases so a re-upload replaces the track it means to', () => {
expect(normalizeSubtitleLanguage('TR')).toBe('tr');
expect(normalizeSubtitleLanguage(' en-US ')).toBe('en-us');
expect(normalizeSubtitleLanguage('zh-Hant-TW')).toBe('zh-hant-tw');
});
it('rejects anything that is not a language tag', () => {
expect(normalizeSubtitleLanguage('')).toBeNull();
expect(normalizeSubtitleLanguage('t')).toBeNull();
expect(normalizeSubtitleLanguage('tr; drop table')).toBeNull();
expect(normalizeSubtitleLanguage('<script>')).toBeNull();
expect(normalizeSubtitleLanguage(42)).toBeNull();
});
});
describe('sanitizeSubtitleLabel', () => {
it('falls back when the label is empty after cleaning', () => {
expect(sanitizeSubtitleLabel(' ', 'TR')).toBe('TR');
expect(sanitizeSubtitleLabel(undefined, 'TR')).toBe('TR');
});
it('strips control characters and collapses whitespace', () => {
expect(sanitizeSubtitleLabel('Türk\u0000\n çe ', 'TR')).toBe('Türk çe');
});
it('caps the length', () => {
expect(sanitizeSubtitleLabel('a'.repeat(200), 'TR')).toHaveLength(60);
});
});
describe('decodeSubtitleBuffer', () => {
it('reads UTF-8 and drops the byte order mark', () => {
expect(decodeSubtitleBuffer(encode('\uFEFFmerhaba'))).toBe('merhaba');
});
it('falls back to a legacy codepage rather than rejecting the file', () => {
// 0xFD is "ı" in windows-1254 and not valid UTF-8 on its own.
const decoded = decodeSubtitleBuffer(new Uint8Array([0x61, 0xfd, 0x62]));
expect(decoded).not.toBeNull();
expect(decoded).toHaveLength(3);
});
});
describe('parseSubtitleCues', () => {
it('parses SRT, comma decimals and sequence numbers included', () => {
const cues = parseSubtitleCues(
[
'1',
'00:00:01,000 --> 00:00:02,500',
'Merhaba',
'',
'2',
'00:00:03,000 --> 00:00:04,000',
'Dünya',
'',
].join('\n')
);
expect(cues).toEqual([
{ start: 1, end: 2.5, text: 'Merhaba' },
{ start: 3, end: 4, text: 'Dünya' },
]);
});
it('parses WebVTT with cue ids, settings and short timestamps', () => {
const cues = parseSubtitleCues(
['WEBVTT', '', 'intro', '00:01.000 --> 00:02.000 align:start position:10%', 'Hello', ''].join(
'\n'
)
);
expect(cues).toEqual([{ start: 1, end: 2, text: 'Hello' }]);
});
it('skips NOTE, STYLE and REGION blocks', () => {
const cues = parseSubtitleCues(
[
'WEBVTT',
'',
'NOTE this is a comment',
'still the comment',
'',
'STYLE',
'::cue { color: red }',
'',
'00:00:01.000 --> 00:00:02.000',
'Kept',
'',
].join('\n')
);
expect(cues).toEqual([{ start: 1, end: 2, text: 'Kept' }]);
});
it('drops cues that end before they start and cues with no text', () => {
const cues = parseSubtitleCues(
[
'00:00:05,000 --> 00:00:02,000',
'Backwards',
'',
'00:00:06,000 --> 00:00:07,000',
'',
'00:00:08,000 --> 00:00:09,000',
'Good',
'',
].join('\n')
);
expect(cues).toEqual([{ start: 8, end: 9, text: 'Good' }]);
});
it('keeps known cue markup and removes everything else', () => {
const cues = parseSubtitleCues(
['00:00:01,000 --> 00:00:02,000', '<i>tilt</i><script>alert(1)</script>{\\an8}', ''].join(
'\n'
)
);
expect(cues[0].text).toBe('<i>tilt</i>alert(1)');
});
it('escapes the leftovers of a rejected tag so it cannot be reassembled', () => {
// Deleting `<b>` out of the middle would close the two halves into a `<script>` that
// was never written. Escaping what is left over is what stops that.
const cues = parseSubtitleCues(
['00:00:01,000 --> 00:00:02,000', '<scr<b>ipt>alert(1)', ''].join('\n')
);
expect(cues[0].text).toBe('&lt;scr<b>ipt&gt;alert(1)');
expect(cues[0].text).not.toContain('<script');
});
it('neutralises an arrow in cue text so the file cannot be re-split', () => {
const cues = parseSubtitleCues(['00:00:01,000 --> 00:00:02,000', 'a --> b', ''].join('\n'));
expect(cues[0].text).toBe('a --&gt; b');
expect(parseSubtitleCues(serializeWebVtt(cues))).toHaveLength(1);
});
it('stops at the cue ceiling', () => {
const lines: string[] = [];
for (let index = 0; index < MAX_SUBTITLE_CUES + 10; index += 1) {
lines.push(`00:00:0${index % 9}.000 --> 00:00:0${(index % 9) + 1}.000`, `line ${index}`, '');
}
expect(parseSubtitleCues(lines.join('\n'))).toHaveLength(MAX_SUBTITLE_CUES);
});
});
describe('normalizeSubtitleFile', () => {
it('converts SRT to a canonical WebVTT document', () => {
const result = normalizeSubtitleFile(
encode('1\r\n00:00:01,500 --> 00:00:02,000\r\nMerhaba\r\n\r\n')
);
expect(result).toEqual({
ok: true,
cueCount: 1,
vtt: 'WEBVTT\n\n00:00:01.500 --> 00:00:02.000\nMerhaba\n',
});
});
it('refuses an empty file', () => {
const result = normalizeSubtitleFile(new Uint8Array());
expect(result.ok).toBe(false);
});
it('refuses a file with no cues rather than storing an empty track', () => {
const result = normalizeSubtitleFile(encode('this is just prose\nand more prose\n'));
expect(result).toEqual({
ok: false,
error: 'No subtitle cues found. Upload a valid .srt or .vtt file.',
});
});
});
describe('subtitle proxy paths', () => {
it('only recognises a uuid .vtt path', () => {
expect(SAFE_SUBTITLE_PROXY_PATH.test(`/api/upload/subtitle/${UUID}.vtt`)).toBe(true);
expect(SAFE_SUBTITLE_PROXY_PATH.test(`/api/upload/subtitle/${UUID}.srt`)).toBe(false);
expect(SAFE_SUBTITLE_PROXY_PATH.test('/api/upload/subtitle/../../etc/passwd')).toBe(false);
});
it('maps a proxy path to its object key and refuses anything else', () => {
expect(subtitleProxyPathToObjectKey(`/api/upload/subtitle/${UUID}.vtt`)).toBe(
`subtitles/${UUID}.vtt`
);
expect(subtitleProxyPathToObjectKey(`/api/upload/image/${UUID}.png`)).toBeNull();
});
});
+10
View File
@@ -25,6 +25,16 @@ declare namespace YT {
getPlaybackRate(): number;
getAvailablePlaybackRates(): number[];
destroy(): void;
// The module API is undocumented but is the only way to drive captions on a
// player embedded with controls=0, where YouTube's own CC button is hidden.
// `loadModule('captions')` turns them on, `unloadModule` turns them off, and
// `getOption('captions', 'tracklist')` answers only once the module has loaded
// and announced itself through onApiChange.
loadModule(moduleName: string): void;
unloadModule(moduleName: string): void;
setOption(module: string, option: string, value: unknown): void;
getOption<T = unknown>(module: string, option: string): T | undefined;
}
interface PlayerOptions {