mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Compare commits
20
Commits
79bba5e7a1
...
9ca56c4226
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca56c4226 | ||
|
|
1c24336b6a | ||
|
|
57061b5a5d | ||
|
|
6d283f0b60 | ||
|
|
7019676398 | ||
|
|
2c890314c1 | ||
|
|
36b2e5c905 | ||
|
|
a8607e8254 | ||
|
|
53c7899659 | ||
|
|
6ad22508fe | ||
|
|
c0809e23bd | ||
|
|
7aeda83eb6 | ||
|
|
d5cb288719 | ||
|
|
85855a6d52 | ||
|
|
fe1faeced4 | ||
|
|
d5d2f0535e | ||
|
|
42b974e422 | ||
|
|
7357f24831 | ||
|
|
142dee0c06 | ||
|
|
b2070c1030 |
+2
-53
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle';
|
||||
import Hls from 'hls.js';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
@@ -128,8 +129,7 @@ export default function CompareVersionsPageClient({
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [cursorIdle, setCursorIdle] = useState(false);
|
||||
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Map of versionId -> YT.Player or Custom Adapter
|
||||
@@ -409,57 +409,6 @@ export default function CompareVersionsPageClient({
|
||||
handleSeek(currentTimeRef.current);
|
||||
}, [isDragging, handleSeek]);
|
||||
|
||||
const handleVideoMouseMove = useCallback(() => {
|
||||
setCursorIdle(false);
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
cursorIdleTimerRef.current = setTimeout(() => {
|
||||
setCursorIdle(true);
|
||||
}, 1000);
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const handleVideoMouseLeave = useCallback(() => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
}
|
||||
setCursorIdle(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
cursorIdleTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!isPlaying) {
|
||||
setCursorIdle(false);
|
||||
return;
|
||||
}
|
||||
|
||||
cursorIdleTimerRef.current = setTimeout(() => {
|
||||
setCursorIdle(true);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
cursorIdleTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isPlaying]);
|
||||
|
||||
// Keyboard shortcuts (matching video page)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -30,6 +30,27 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
|
||||
import type { CancellationReason } from '@/lib/cancellation-reasons';
|
||||
|
||||
/** Convert Stripe API units separately from the currency's display precision. */
|
||||
function formatInvoiceAmount(amountInMinorUnits: number, currency: string) {
|
||||
const currencyCode = currency.toUpperCase();
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
});
|
||||
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
|
||||
// Stripe retains two-decimal API amounts for ISK/UGX despite their zero-decimal display.
|
||||
// https://docs.stripe.com/currencies#special-cases
|
||||
const apiExponent = currencyCode === 'ISK' || currencyCode === 'UGX' ? 2 : fractionDigits;
|
||||
return formatter.format(amountInMinorUnits / 10 ** apiExponent);
|
||||
} catch {
|
||||
return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`;
|
||||
}
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
telegramChatId: string | null;
|
||||
@@ -49,6 +70,17 @@ interface BillingOverview {
|
||||
status: 'disabled' | 'ready' | 'misconfigured';
|
||||
checkoutAvailable: boolean;
|
||||
portalAvailable: boolean;
|
||||
cancelAvailable: boolean;
|
||||
cancelIsImmediate: boolean;
|
||||
needsPaymentFix: boolean;
|
||||
openInvoice: {
|
||||
id: string | null;
|
||||
hostedInvoiceUrl: string | null;
|
||||
amountDue: number;
|
||||
currency: string;
|
||||
attemptCount: number;
|
||||
nextPaymentAttempt: string | null;
|
||||
} | null;
|
||||
subscription: {
|
||||
status: string;
|
||||
label: string;
|
||||
@@ -148,7 +180,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [billing, setBilling] = useState<BillingOverview | null>(null);
|
||||
const [billingLoading, setBillingLoading] = useState(true);
|
||||
const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | 'trial' | null>(null);
|
||||
const [billingAction, setBillingAction] = useState<
|
||||
'checkout' | 'portal' | 'trial' | 'cancel' | null
|
||||
>(null);
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null);
|
||||
const [storageLoading, setStorageLoading] = useState(true);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
@@ -255,12 +290,16 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
);
|
||||
|
||||
const handleBillingRedirect = useCallback(
|
||||
async (endpoint: '/api/billing/checkout' | '/api/billing/portal') => {
|
||||
async (
|
||||
endpoint: '/api/billing/checkout' | '/api/billing/portal',
|
||||
flow?: 'payment_method_update'
|
||||
) => {
|
||||
setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal');
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(flow ? { flow } : {}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@@ -302,6 +341,49 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
}
|
||||
}, [showMessage]);
|
||||
|
||||
const handleCancelSubscription = useCallback(
|
||||
async (input: { reason: CancellationReason | null; note: string | null }) => {
|
||||
setBillingAction('cancel');
|
||||
try {
|
||||
const res = await fetch('/api/billing/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage('error', data.error || 'Failed to cancel subscription');
|
||||
return false;
|
||||
}
|
||||
|
||||
setCancelDialogOpen(false);
|
||||
const billingRes = await fetch('/api/billing');
|
||||
if (billingRes.ok) {
|
||||
setBilling((await billingRes.json()).data);
|
||||
}
|
||||
const endsOn = data.data?.periodEnd
|
||||
? new Date(data.data.periodEnd).toLocaleDateString()
|
||||
: null;
|
||||
showMessage(
|
||||
'success',
|
||||
data.data?.canceledImmediately
|
||||
? 'Subscription canceled. Automatic collection has stopped for its open invoices. Charges for prior service may still be owed.'
|
||||
: endsOn
|
||||
? `Your subscription ends on ${endsOn}. You keep full access until then.`
|
||||
: 'Your subscription ends at the close of the current period.'
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
showMessage('error', 'Failed to cancel subscription');
|
||||
return false;
|
||||
} finally {
|
||||
setBillingAction(null);
|
||||
}
|
||||
},
|
||||
[showMessage]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
|
||||
@@ -412,13 +494,15 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{billing.subscription.hasActiveSubscription
|
||||
? hasScheduledCancellation
|
||||
? billing.subscription.hasActiveTrial
|
||||
? billing.subscription.status === 'TRIALING'
|
||||
? 'Trial canceled. Access remains active until the trial ends.'
|
||||
: 'Subscription canceled. Access remains active until the end of the current billing period.'
|
||||
: 'Paid account with workspace creation unlocked.'
|
||||
: billing.subscription.hasActiveTrial
|
||||
? 'Free trial, no card required.'
|
||||
: 'Billing access has ended.'}
|
||||
: billing.subscription.hasBillingAccess
|
||||
? 'Workspace access remains available while you resolve your payment.'
|
||||
: 'Billing access has ended.'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -432,11 +516,11 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
!billing.subscription.hasActiveSubscription ? (
|
||||
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
||||
Your latest payment didn't go through. Update your payment method to keep
|
||||
your subscription — starting a new one would create a duplicate.
|
||||
your subscription. Starting a new one would create a duplicate.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{billing.subscription.hasActiveTrial &&
|
||||
{billing.subscription.status === 'TRIALING' &&
|
||||
billing.subscription.trialEndsAt &&
|
||||
hasScheduledCancellation ? (
|
||||
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
||||
@@ -455,7 +539,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
|
||||
{hasScheduledCancellation && billing.subscription.cancelAt ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cancellation was scheduled on{' '}
|
||||
Cancellation takes effect on{' '}
|
||||
{new Date(billing.subscription.cancelAt).toLocaleDateString()}.
|
||||
</p>
|
||||
) : null}
|
||||
@@ -481,10 +565,54 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{billing.openInvoice ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-4 space-y-2">
|
||||
<p className="text-sm font-semibold text-destructive">
|
||||
A payment of{' '}
|
||||
{formatInvoiceAmount(
|
||||
billing.openInvoice.amountDue,
|
||||
billing.openInvoice.currency
|
||||
)}{' '}
|
||||
did not go through
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{billing.openInvoice.attemptCount} attempt
|
||||
{billing.openInvoice.attemptCount === 1 ? '' : 's'} so far
|
||||
{billing.openInvoice.nextPaymentAttempt
|
||||
? `, next one on ${new Date(billing.openInvoice.nextPaymentAttempt).toLocaleDateString()}`
|
||||
: ''}
|
||||
. Update your payment method or pay the invoice to stop the retries, or cancel
|
||||
to stop them for good.
|
||||
</p>
|
||||
{billing.subscription.billingAccessEndedAt ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(billing.subscription.billingAccessEndedAt) > new Date()
|
||||
? `Access to your workspaces continues until ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.`
|
||||
: `Access to your workspaces ended on ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. Paying this invoice restores it.`}
|
||||
</p>
|
||||
) : null}
|
||||
{billing.openInvoice.hostedInvoiceUrl ? (
|
||||
<a
|
||||
href={billing.openInvoice.hostedInvoiceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-block text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
View and pay this invoice
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (
|
||||
<Button
|
||||
onClick={() => handleBillingRedirect('/api/billing/portal')}
|
||||
onClick={() =>
|
||||
handleBillingRedirect(
|
||||
'/api/billing/portal',
|
||||
billing.needsPaymentFix ? 'payment_method_update' : undefined
|
||||
)
|
||||
}
|
||||
disabled={billingAction !== null}
|
||||
>
|
||||
{billingAction === 'portal' ? (
|
||||
@@ -498,7 +626,22 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
'Update Payment Method'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
) : null}
|
||||
{/* Beside the portal button, not inside it. Someone who came to
|
||||
cancel should not have to guess that "Manage" is the way, and
|
||||
the portal cannot ask why they are leaving. */}
|
||||
{billing.cancelAvailable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
disabled={billingAction !== null}
|
||||
>
|
||||
Cancel subscription
|
||||
</Button>
|
||||
) : null}
|
||||
{billing.subscription.hasRecoverableSubscription &&
|
||||
billing.portalAvailable ? null : (
|
||||
<>
|
||||
{billing.workspaceCreation.canStartTrial ? (
|
||||
<Button onClick={handleStartTrial} disabled={billingAction !== null}>
|
||||
@@ -534,6 +677,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{billing ? (
|
||||
<CancelSubscriptionDialog
|
||||
open={cancelDialogOpen}
|
||||
onOpenChange={setCancelDialogOpen}
|
||||
periodEnd={billing.subscription.currentPeriodEnd}
|
||||
isTrial={billing.subscription.status === 'TRIALING'}
|
||||
canceledImmediately={billing.cancelIsImmediate}
|
||||
onConfirm={handleCancelSubscription}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{billing?.subscription.hasBillingAccess && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -167,7 +167,11 @@ export async function POST(request: NextRequest) {
|
||||
// owner too. A workspace admin on somebody else's trial hits the same ceiling.
|
||||
const owner = await db.user.findUnique({
|
||||
where: { id: workspace.ownerId },
|
||||
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
||||
select: {
|
||||
subscriptionStatus: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
billingAccessEndedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (owner && !isPaidTier(owner)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import type Stripe from 'stripe';
|
||||
import { syncStripeCustomerSubscriptions } from '@/lib/billing';
|
||||
import { getInvoiceSubscriptionId, syncStripeCustomerSubscriptions } from '@/lib/billing';
|
||||
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
@@ -55,6 +55,25 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Invoice events carry the payment health of a subscription earlier and more
|
||||
// reliably than the subscription events alone. Without them a customer whose card
|
||||
// failed keeps the mirror of a healthy subscription until Stripe eventually gives
|
||||
// up, which is the whole dunning window spent showing them the wrong state.
|
||||
case 'invoice.paid':
|
||||
case 'invoice.payment_failed':
|
||||
case 'invoice.voided':
|
||||
case 'invoice.marked_uncollectible': {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const customerId = getCustomerId(invoice.customer);
|
||||
// Only subscription invoices. A one-off invoice against a customer record left
|
||||
// behind by an abandoned checkout has no subscription, and syncing on it would
|
||||
// find an empty list, mark the account canceled and book a churn event for a
|
||||
// subscription that never existed.
|
||||
if (customerId && getInvoiceSubscriptionId(invoice)) {
|
||||
await syncStripeCustomerSubscriptions(customerId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
+4
-4
@@ -26,8 +26,8 @@ const geistMono = Geist_Mono({
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(seoConfig.url),
|
||||
title: {
|
||||
default: `${seoConfig.name} | ${seoConfig.title}`,
|
||||
template: `%s | ${seoConfig.name}`,
|
||||
default: `${seoConfig.name} - ${seoConfig.title}`,
|
||||
template: `%s - ${seoConfig.name}`,
|
||||
},
|
||||
description: seoConfig.description,
|
||||
applicationName: seoConfig.name,
|
||||
@@ -51,7 +51,7 @@ export const metadata: Metadata = {
|
||||
locale: 'en_US',
|
||||
siteName: seoConfig.name,
|
||||
url: seoConfig.url,
|
||||
title: `${seoConfig.name} | ${seoConfig.title}`,
|
||||
title: `${seoConfig.name} - ${seoConfig.title}`,
|
||||
description: seoConfig.description,
|
||||
images: [
|
||||
{
|
||||
@@ -64,7 +64,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${seoConfig.name} | ${seoConfig.title}`,
|
||||
title: `${seoConfig.name} - ${seoConfig.title}`,
|
||||
description: seoConfig.description,
|
||||
images: [seoConfig.ogImage],
|
||||
},
|
||||
|
||||
+584
-684
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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -104,8 +104,10 @@ export function VideoPageContent({
|
||||
voiceProgress,
|
||||
voiceCurrentTime,
|
||||
voicePlaybackRate,
|
||||
downloadingVoiceIds,
|
||||
playVoice,
|
||||
toggleVoiceSpeed,
|
||||
downloadVoice,
|
||||
} = useCommentMedia();
|
||||
const [showResolved, setShowResolved] = useState(false);
|
||||
const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments');
|
||||
@@ -214,7 +216,6 @@ export function VideoPageContent({
|
||||
setActiveVersionId,
|
||||
});
|
||||
|
||||
// Cursor idle detection: hide overlay when cursor idle for 3s while playing
|
||||
// Memoize version selection handler to prevent recreating on each render
|
||||
const handleVersionSelect = useCallback(
|
||||
(versionId: string) => {
|
||||
@@ -926,6 +927,9 @@ export function VideoPageContent({
|
||||
handleEditComment={commentsActions.onEditComment}
|
||||
handleDeleteComment={commentsActions.onDeleteComment}
|
||||
playVoice={playVoice}
|
||||
downloadVoice={downloadVoice}
|
||||
downloadingVoiceIds={downloadingVoiceIds}
|
||||
canDownloadVoiceNotes={canDownloadAssets}
|
||||
playingVoiceId={playingVoiceId}
|
||||
voiceProgress={voiceProgress}
|
||||
voiceCurrentTime={voiceCurrentTime}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -92,6 +92,11 @@ interface CommentsPaneProps {
|
||||
handleEditComment: (commentId: string) => void;
|
||||
handleDeleteComment: (commentId: string) => void;
|
||||
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
|
||||
downloadVoice: (commentId: string, voiceUrl: string, baseName: string) => void;
|
||||
downloadingVoiceIds: ReadonlySet<string>;
|
||||
/** Same gate as the video and asset downloads: a project or share link with
|
||||
* downloads disabled must not offer to save voice notes either. */
|
||||
canDownloadVoiceNotes: boolean;
|
||||
playingVoiceId: string | null;
|
||||
voiceProgress: number;
|
||||
voiceCurrentTime: number;
|
||||
@@ -136,6 +141,18 @@ interface CommentsPaneProps {
|
||||
assetsPane: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the download after the reviewer and the frame they were talking about,
|
||||
* so a folder of voice notes still makes sense next to the cut.
|
||||
*/
|
||||
function voiceNoteFileName(
|
||||
entry: { timestamp: number; author: { name: string | null } | null; guestName: string | null },
|
||||
formatTime: (seconds: number) => string
|
||||
): string {
|
||||
const who = entry.author?.name || entry.guestName || 'guest';
|
||||
return `voice-${who}-${formatTime(entry.timestamp).replace(/:/g, '-')}`;
|
||||
}
|
||||
|
||||
export const CommentsPane = memo(function CommentsPane({
|
||||
isMobileCommentsOpen,
|
||||
setIsMobileCommentsOpen,
|
||||
@@ -174,6 +191,9 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
handleEditComment,
|
||||
handleDeleteComment,
|
||||
playVoice,
|
||||
downloadVoice,
|
||||
downloadingVoiceIds,
|
||||
canDownloadVoiceNotes,
|
||||
playingVoiceId,
|
||||
voiceProgress,
|
||||
voiceCurrentTime,
|
||||
@@ -680,6 +700,29 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
{voicePlaybackRate}x
|
||||
</button>
|
||||
)}
|
||||
{canDownloadVoiceNotes && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title="Download as WAV"
|
||||
aria-label="Download voice note as WAV"
|
||||
disabled={downloadingVoiceIds.has(comment.id)}
|
||||
onClick={() =>
|
||||
downloadVoice(
|
||||
comment.id,
|
||||
comment.voiceUrl!,
|
||||
voiceNoteFileName(comment, formatTime)
|
||||
)
|
||||
}
|
||||
>
|
||||
{downloadingVoiceIds.has(comment.id) ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -904,6 +947,29 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
{voicePlaybackRate}x
|
||||
</button>
|
||||
)}
|
||||
{canDownloadVoiceNotes && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 shrink-0"
|
||||
title="Download as WAV"
|
||||
aria-label="Download voice note as WAV"
|
||||
disabled={downloadingVoiceIds.has(reply.id)}
|
||||
onClick={() =>
|
||||
downloadVoice(
|
||||
reply.id,
|
||||
reply.voiceUrl!,
|
||||
voiceNoteFileName(reply, formatTime)
|
||||
)
|
||||
}
|
||||
>
|
||||
{downloadingVoiceIds.has(reply.id) ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { downloadAudioAsWav } from '@/lib/client/download-file';
|
||||
|
||||
export function useCommentMedia() {
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||
const [voiceProgress, setVoiceProgress] = useState(0);
|
||||
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
|
||||
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
|
||||
// A set, not a single id: two downloads can be in flight at once, and one
|
||||
// finishing must not clear the other's spinner and re-enable its button.
|
||||
const [downloadingVoiceIds, setDownloadingVoiceIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
|
||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
const voiceRafRef = useRef<number | null>(null);
|
||||
@@ -121,13 +128,41 @@ export function useCommentMedia() {
|
||||
};
|
||||
}, [stopVoiceTracking]);
|
||||
|
||||
/**
|
||||
* Voice notes are stored the way MediaRecorder wrote them, and an editor
|
||||
* cannot import WebM/Opus. Hand over a WAV instead, converted in the browser
|
||||
* from the file it already knows how to decode.
|
||||
*/
|
||||
const downloadVoice = useCallback(
|
||||
async (commentId: string, voiceUrl: string, baseName: string) => {
|
||||
setDownloadingVoiceIds((prev) => new Set(prev).add(commentId));
|
||||
try {
|
||||
const result = await downloadAudioAsWav(voiceUrl, baseName);
|
||||
if (result === 'failed') {
|
||||
toast.error('Failed to download voice note');
|
||||
} else if (result === 'conversion-unsupported') {
|
||||
toast.warning('This browser cannot convert the recording. Downloaded the original.');
|
||||
}
|
||||
} finally {
|
||||
setDownloadingVoiceIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(commentId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
playingVoiceId,
|
||||
voiceProgress,
|
||||
voiceCurrentTime,
|
||||
voicePlaybackRate,
|
||||
downloadingVoiceIds,
|
||||
playVoice,
|
||||
stopVoice,
|
||||
toggleVoiceSpeed,
|
||||
downloadVoice,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/** How long the cursor has to sit still over the player before it and the overlay hide. */
|
||||
export const CURSOR_IDLE_DELAY_MS = 1000;
|
||||
|
||||
/**
|
||||
* Tracks whether the cursor has rested over the player long enough to hide it
|
||||
* and the play/pause overlay. Playback can start without the cursor moving (a
|
||||
* click, a key, the resume after a scrub), so the countdown is re-armed on
|
||||
* every playback change. Only pointer activity wakes the cursor: a pause/play
|
||||
* pair the element emits on its own (rebuffering, a source switch) leaves the
|
||||
* idle state alone, so the chrome does not flash back for a second.
|
||||
*/
|
||||
export function useCursorIdle(isPlaying: boolean) {
|
||||
const [cursorIdle, setCursorIdle] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isCursorOverPlayerRef = useRef(false);
|
||||
|
||||
// Restart the countdown. It only runs while the cursor is over the player
|
||||
// and playback is running; otherwise nothing is pending.
|
||||
const armTimer = useCallback(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
|
||||
if (!isCursorOverPlayerRef.current || !isPlaying) return;
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setCursorIdle(true);
|
||||
}, CURSOR_IDLE_DELAY_MS);
|
||||
}, [isPlaying]);
|
||||
|
||||
const handleVideoMouseMove = useCallback(() => {
|
||||
isCursorOverPlayerRef.current = true;
|
||||
setCursorIdle(false);
|
||||
armTimer();
|
||||
}, [armTimer]);
|
||||
|
||||
const handleVideoMouseLeave = useCallback(() => {
|
||||
isCursorOverPlayerRef.current = false;
|
||||
setCursorIdle(false);
|
||||
armTimer();
|
||||
}, [armTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
armTimer();
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [armTimer]);
|
||||
|
||||
return { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
downloadProgressPercent,
|
||||
extensionFromUrl,
|
||||
navigateDownload,
|
||||
sanitizeDownloadFileName,
|
||||
} from '@/lib/client/download-file';
|
||||
import {
|
||||
createDownloadProgressToast,
|
||||
@@ -24,13 +25,6 @@ import {
|
||||
} from '@/components/download-progress-toast';
|
||||
import { beginUnloadGuard } from '@/lib/client/unload-guard';
|
||||
|
||||
function sanitizeDownloadFileName(value: string): string {
|
||||
return value
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getAllowedHosts() {
|
||||
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||
return [
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
import type { AssetDownloadPreference, VideoAsset } from '@/components/video-page/types';
|
||||
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
|
||||
|
||||
type BunnyDownloadPreference = 'original' | 'compressed';
|
||||
import { downloadAudioAsWav } from '@/lib/client/download-file';
|
||||
|
||||
type CreateAssetPayload = {
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
|
||||
@@ -234,7 +233,7 @@ export function useVideoAssets({
|
||||
);
|
||||
|
||||
const downloadAsset = useCallback(
|
||||
async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
|
||||
async (asset: VideoAsset, preference: AssetDownloadPreference = 'compressed') => {
|
||||
if (!canDownloadAssets) {
|
||||
toast.error('Asset downloads require an authenticated account');
|
||||
return;
|
||||
@@ -248,6 +247,20 @@ export function useVideoAssets({
|
||||
try {
|
||||
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
|
||||
|
||||
// A voice note is stored as MediaRecorder wrote it, and no editing suite
|
||||
// reads WebM/Opus. Convert it in the browser so the download opens in the
|
||||
// timeline it was recorded for; 'original' is there for anyone who wants
|
||||
// the stored bytes instead.
|
||||
if (asset.provider === 'R2_AUDIO' && preference !== 'original') {
|
||||
const result = await downloadAudioAsWav(downloadUrl, asset.displayName);
|
||||
if (result === 'failed') {
|
||||
toast.error('Failed to download voice note');
|
||||
} else if (result === 'conversion-unsupported') {
|
||||
toast.warning('This browser cannot convert the recording. Downloaded the original.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.provider === 'BUNNY') {
|
||||
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, {
|
||||
cache: 'no-store',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
resolveSkipAmount as resolveSkipAmountFor,
|
||||
timeFromClientX as timeFromClientXWithin,
|
||||
} from '@/components/video-page/hooks/video-player-utils';
|
||||
import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle';
|
||||
|
||||
interface UseVideoPlayerParams {
|
||||
activeVersion: Version | undefined;
|
||||
@@ -114,8 +115,7 @@ export function useVideoPlayer({
|
||||
const previousVersionKeyRef = useRef<string | null>(null);
|
||||
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
|
||||
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
|
||||
const [cursorIdle, setCursorIdle] = useState(false);
|
||||
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying);
|
||||
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const bunnyFrameCallbackIdRef = useRef<number | null>(null);
|
||||
const bunnyFrameSampleRef = useRef<{ mediaTime: number; presentedFrames: number } | null>(null);
|
||||
@@ -212,30 +212,6 @@ export function useVideoPlayer({
|
||||
return () => observer.disconnect();
|
||||
}, [activeVersionId, bunnyViewportRef]);
|
||||
|
||||
const handleVideoMouseMove = useCallback(() => {
|
||||
setCursorIdle(false);
|
||||
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
||||
|
||||
const shouldHideControls = isFullscreenMode;
|
||||
|
||||
if (isPlaying || shouldHideControls) {
|
||||
cursorIdleTimerRef.current = setTimeout(() => {
|
||||
setCursorIdle(true);
|
||||
}, 1000);
|
||||
}
|
||||
}, [isFullscreenMode, isPlaying]);
|
||||
|
||||
const handleVideoMouseLeave = useCallback(() => {
|
||||
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
||||
setCursorIdle(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isApiLoaded) return;
|
||||
|
||||
|
||||
@@ -170,6 +170,8 @@ export interface BunnyQualityOption {
|
||||
export type BunnyPlaybackState = 'none' | 'processing' | 'error';
|
||||
export type BunnyDownloadPreference = 'original' | 'compressed';
|
||||
export type DownloadTarget = BunnyDownloadPreference | 'direct';
|
||||
/** Voice notes add one more option: converted to WAV in the browser on the way out. */
|
||||
export type AssetDownloadPreference = BunnyDownloadPreference | 'wav';
|
||||
|
||||
export interface CommentMarker {
|
||||
id: string;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* MediaRecorder hands us WebM/Opus (MP4/AAC on Safari). Browsers and desktop
|
||||
* players read both; editing suites read neither. DaVinci Resolve, Premiere and
|
||||
* Final Cut all refuse the container outright, so a voice note downloaded
|
||||
* byte-for-byte is useless to the editor it was recorded for.
|
||||
*
|
||||
* The browser already decodes these formats in order to play them, so the whole
|
||||
* conversion costs us is a RIFF header: decode to PCM through the Web Audio API,
|
||||
* then write the samples back out as a WAV. Nothing is transcoded server side
|
||||
* and the stored object stays the small Opus file, which is why this runs at
|
||||
* download time rather than at record time.
|
||||
*/
|
||||
|
||||
const WAV_HEADER_BYTES = 44;
|
||||
const BYTES_PER_SAMPLE = 2; // 16-bit PCM
|
||||
const PCM_FORMAT_TAG = 1;
|
||||
|
||||
/**
|
||||
* Decoding holds the float PCM and the encoded copy in memory at once, roughly
|
||||
* three times the size of the WAV. A 10MB Opus upload is over half an hour of
|
||||
* speech, so cap the output rather than let a long recording take the tab down.
|
||||
*/
|
||||
export const MAX_WAV_OUTPUT_BYTES = 400 * 1024 * 1024;
|
||||
|
||||
function writeAscii(view: DataView, offset: number, text: string): void {
|
||||
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
|
||||
}
|
||||
|
||||
export function wavByteLength(frameCount: number, channelCount: number): number {
|
||||
return WAV_HEADER_BYTES + frameCount * channelCount * BYTES_PER_SAMPLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interleaved 16-bit PCM in a RIFF container: the one audio format every NLE on
|
||||
* the market imports without an argument. `channels` holds one Float32Array of
|
||||
* samples per channel, all the same length.
|
||||
*/
|
||||
export function encodeWav(channels: Float32Array[], sampleRate: number): Blob {
|
||||
const channelCount = channels.length;
|
||||
if (channelCount === 0 || !Number.isFinite(sampleRate) || sampleRate <= 0) {
|
||||
throw new Error('encodeWav needs at least one channel and a positive sample rate');
|
||||
}
|
||||
|
||||
const frameCount = channels[0].length;
|
||||
const blockAlign = channelCount * BYTES_PER_SAMPLE;
|
||||
const dataBytes = frameCount * blockAlign;
|
||||
const buffer = new ArrayBuffer(WAV_HEADER_BYTES + dataBytes);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
writeAscii(view, 0, 'RIFF');
|
||||
// Everything after this field, i.e. the file minus the 8-byte RIFF preamble.
|
||||
view.setUint32(4, 36 + dataBytes, true);
|
||||
writeAscii(view, 8, 'WAVE');
|
||||
writeAscii(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true); // fmt chunk payload size for PCM
|
||||
view.setUint16(20, PCM_FORMAT_TAG, true);
|
||||
view.setUint16(22, channelCount, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, BYTES_PER_SAMPLE * 8, true);
|
||||
writeAscii(view, 36, 'data');
|
||||
view.setUint32(40, dataBytes, true);
|
||||
|
||||
let offset = WAV_HEADER_BYTES;
|
||||
for (let frame = 0; frame < frameCount; frame++) {
|
||||
for (let channel = 0; channel < channelCount; channel++) {
|
||||
// Decoded samples can overshoot ±1. Scaled unclamped they wrap to the
|
||||
// opposite rail, and a loud passage comes out as a burst of noise.
|
||||
const sample = Math.max(-1, Math.min(1, channels[channel][frame] ?? 0));
|
||||
// The negative rail reaches one step further than the positive one, so the
|
||||
// two directions take different scale factors to stay symmetric.
|
||||
view.setInt16(offset, Math.round(sample < 0 ? sample * 0x8000 : sample * 0x7fff), true);
|
||||
offset += BYTES_PER_SAMPLE;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
type OfflineAudioContextConstructor = new (
|
||||
channels: number,
|
||||
length: number,
|
||||
sampleRate: number
|
||||
) => OfflineAudioContext;
|
||||
|
||||
function getOfflineAudioContext(): OfflineAudioContextConstructor | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const scope = window as unknown as {
|
||||
OfflineAudioContext?: OfflineAudioContextConstructor;
|
||||
webkitOfflineAudioContext?: OfflineAudioContextConstructor;
|
||||
};
|
||||
return scope.OfflineAudioContext ?? scope.webkitOfflineAudioContext ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes any audio blob the browser can play and returns it as a WAV, or null
|
||||
* when this browser cannot decode that format (older Safari has no WebM/Opus
|
||||
* decoder) or the result would be too large to hold. Callers fall back to the
|
||||
* original file, so a failure costs the download nothing but the extension.
|
||||
*/
|
||||
export async function convertAudioBlobToWav(blob: Blob): Promise<Blob | null> {
|
||||
const OfflineCtx = getOfflineAudioContext();
|
||||
if (!OfflineCtx) return null;
|
||||
|
||||
try {
|
||||
// decodeAudioData resamples to the context's rate, and 48 kHz is what both
|
||||
// Opus and AAC recordings already run at, so this decodes them untouched.
|
||||
// We read the rate back off the result anyway in case a browser ignores it.
|
||||
const context = new OfflineCtx(1, 1, 48000);
|
||||
const decoded = await context.decodeAudioData(await blob.arrayBuffer());
|
||||
if (!decoded || decoded.length === 0) return null;
|
||||
if (wavByteLength(decoded.length, decoded.numberOfChannels) > MAX_WAV_OUTPUT_BYTES) return null;
|
||||
|
||||
const channels: Float32Array[] = [];
|
||||
for (let i = 0; i < decoded.numberOfChannels; i++) channels.push(decoded.getChannelData(i));
|
||||
return encodeWav(channels, decoded.sampleRate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+476
-74
@@ -35,6 +35,36 @@ const UNPAID_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
||||
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
|
||||
]);
|
||||
|
||||
// The Stripe-side counterpart of RECOVERABLE_SUBSCRIPTION_STATUSES, for the places that
|
||||
// hold a raw Stripe subscription rather than the mirrored status. Deliberately the same
|
||||
// membership: a subscription worth cancelling is a subscription worth blocking a second
|
||||
// checkout over, and two sets that disagreed only produced a Cancel button that always
|
||||
// failed and a checkout guard weaker than the mirror it was backing up.
|
||||
const LIVE_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>([
|
||||
'active',
|
||||
'trialing',
|
||||
'past_due',
|
||||
'unpaid',
|
||||
'incomplete',
|
||||
]);
|
||||
|
||||
// Cancelling one of these takes effect immediately: the open period was never paid for,
|
||||
// so there is nothing left to run out.
|
||||
const UNPAID_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>([
|
||||
'past_due',
|
||||
'unpaid',
|
||||
'incomplete',
|
||||
]);
|
||||
|
||||
// A subscription that was running and then missed a payment. It keeps access while Stripe
|
||||
// retries the card, so a customer whose card expired is not locked out before they have
|
||||
// had a chance to fix it. `incomplete` is not here: nothing has ever been paid on it.
|
||||
const RETRYING_STRIPE_STATUSES = new Set<Stripe.Subscription.Status>(['past_due', 'unpaid']);
|
||||
|
||||
// Application grace period, independent of the Stripe retry settings. An unpaid
|
||||
// invoice's future period end does not extend this access window.
|
||||
const UNPAID_ACCESS_GRACE_DAYS = 14;
|
||||
|
||||
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
|
||||
const STORAGE_CLEANUP_GRACE_DAYS = 15;
|
||||
|
||||
@@ -95,7 +125,10 @@ export function hasRecoverableSubscription(status: BillingSubscriptionStatus | n
|
||||
* A legacy Stripe trial counts as paid because a card was handed over for it.
|
||||
*/
|
||||
export function isPaidTier(
|
||||
subject: Pick<BillingAccessSubject, 'subscriptionStatus' | 'stripeCurrentPeriodEnd'>,
|
||||
subject: Pick<
|
||||
BillingAccessSubject,
|
||||
'subscriptionStatus' | 'stripeCurrentPeriodEnd' | 'billingAccessEndedAt'
|
||||
>,
|
||||
now: Date = new Date()
|
||||
) {
|
||||
if (!isStripeFeatureEnabled()) {
|
||||
@@ -106,14 +139,19 @@ export function isPaidTier(
|
||||
return true;
|
||||
}
|
||||
|
||||
// The period end alone is not proof of payment. Checked here and not in
|
||||
// `hasBillingAccess`, which keeps granting access on a period end it did not
|
||||
// question before: the cost of being wrong there is a customer locked out,
|
||||
// while the cost of being wrong here is a free account holding 200 GB.
|
||||
// The period end alone is not proof of payment.
|
||||
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same cutoff `hasBillingAccess` applies, so the two cannot disagree about a customer
|
||||
// behind on payment. They did once: access stopped at the end of the payment grace window
|
||||
// while this kept saying "paid" for the rest of the period, which left the account with
|
||||
// no banner explaining the lockout and able to create workspaces it could not then see.
|
||||
if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
|
||||
);
|
||||
@@ -132,21 +170,42 @@ export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new
|
||||
return true;
|
||||
}
|
||||
|
||||
// Everything below decides whether the reported period still stands in for access, and
|
||||
// the two guards exist because it very often does not. Both are scoped to this branch
|
||||
// rather than applied at the top of the function: `billingAccessEndedAt` is only ever
|
||||
// cleared by a Stripe sync, so a stale one from a lapsed subscription would otherwise
|
||||
// outrank a freshly started cardless trial and burn the account's one trial for nothing.
|
||||
|
||||
// Stripe stamps a period on a subscription whose first charge never went through, so
|
||||
// that period is not evidence of payment. The same rejection `isPaidTier` makes.
|
||||
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stripe advances the period the moment it issues the renewal invoice, paid or not, and
|
||||
// the period survives cancellation, so on its own it would hand a full free month to
|
||||
// anyone whose renewal fails. This is the bound: a subscription behind on payment is
|
||||
// stamped with the end of the payment grace window, a cancelled one with `ended_at`.
|
||||
if (subject.billingAccessEndedAt && subject.billingAccessEndedAt.getTime() <= now.getTime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
export function getBillingAccessEndDate(subject: BillingAccessSubject) {
|
||||
if (subject.billingAccessEndedAt) {
|
||||
return subject.billingAccessEndedAt;
|
||||
}
|
||||
|
||||
if (subject.stripeCurrentPeriodEnd) {
|
||||
return subject.stripeCurrentPeriodEnd;
|
||||
}
|
||||
|
||||
return subject.trialEndsAt;
|
||||
const subscriptionEnd =
|
||||
subject.billingAccessEndedAt ??
|
||||
(UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)
|
||||
? null
|
||||
: subject.stripeCurrentPeriodEnd);
|
||||
// A trial grants access independently of the subscription cutoff. Retention starts
|
||||
// after the last legitimate entitlement, never from an unpaid invoice's period.
|
||||
if (!subscriptionEnd) return subject.trialEndsAt;
|
||||
if (!subject.trialEndsAt) return subscriptionEnd;
|
||||
return new Date(Math.max(subscriptionEnd.getTime(), subject.trialEndsAt.getTime()));
|
||||
}
|
||||
|
||||
export function getStorageCleanupEligibleAt(subject: BillingAccessSubject) {
|
||||
@@ -161,6 +220,9 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
|
||||
return {};
|
||||
}
|
||||
|
||||
// Mirrors `hasBillingAccess` branch for branch, including the two guards scoped to its
|
||||
// period-end arm, so the query and the in-memory check cannot disagree about who still
|
||||
// has access.
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
@@ -169,7 +231,11 @@ export function buildBillingAccessWhereInput(now: Date = new Date()): Prisma.Use
|
||||
},
|
||||
},
|
||||
{ trialEndsAt: { gt: now } },
|
||||
{ stripeCurrentPeriodEnd: { gt: now } },
|
||||
{
|
||||
stripeCurrentPeriodEnd: { gt: now },
|
||||
subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
|
||||
OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: now } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -185,13 +251,8 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
|
||||
return { id: { in: [] } };
|
||||
}
|
||||
|
||||
// Spelled out as positive AND branches instead of `NOT: buildBillingAccessWhereInput(now)`.
|
||||
// Prisma renders that NOT as `NOT (status IN (...) OR "trialEndsAt" > $1 OR
|
||||
// "stripeCurrentPeriodEnd" > $2)`, and SQL comparisons against NULL are unknown rather than
|
||||
// false, so for a row with both dates empty the OR evaluates to NULL and NOT NULL is still
|
||||
// NULL: the row is never returned. Both columns empty is exactly what a canceled subscriber
|
||||
// looks like (markSubscriptionCanceledByCustomerId clears trialEndsAt, and Stripe no longer
|
||||
// reports current_period_end on the subscription), so the cleanup silently matched nobody.
|
||||
// Match the same last entitlement date as getBillingAccessEndDate, with explicit
|
||||
// null branches because SQL comparisons against null do not evaluate to false.
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
@@ -199,13 +260,22 @@ export function buildExpiredBillingWhereInput(now: Date = new Date()): Prisma.Us
|
||||
notIn: [BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING],
|
||||
},
|
||||
},
|
||||
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }] },
|
||||
{ OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: now } }] },
|
||||
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: cleanupCutoff } }] },
|
||||
{
|
||||
OR: [
|
||||
{ billingAccessEndedAt: { lte: cleanupCutoff } },
|
||||
{
|
||||
AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cleanupCutoff } }],
|
||||
billingAccessEndedAt: null,
|
||||
subscriptionStatus: { notIn: [...UNPAID_SUBSCRIPTION_STATUSES] },
|
||||
stripeCurrentPeriodEnd: { lte: cleanupCutoff },
|
||||
},
|
||||
{
|
||||
billingAccessEndedAt: null,
|
||||
trialEndsAt: { lte: cleanupCutoff },
|
||||
OR: [
|
||||
{ stripeCurrentPeriodEnd: null },
|
||||
{ subscriptionStatus: { in: [...UNPAID_SUBSCRIPTION_STATUSES] } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -723,6 +793,72 @@ function getStripeTimestamp(value: unknown): number | null {
|
||||
return typeof value === 'number' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The billing period moved off the subscription and onto its items in the Basil API
|
||||
* version, so reading `subscription.current_period_end` yields undefined on every current
|
||||
* version. Webhook payloads can still be rendered at an older version, so the legacy field
|
||||
* is kept as a fallback rather than dropped.
|
||||
*/
|
||||
export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null {
|
||||
const itemPeriodEnds = (subscription.items?.data ?? [])
|
||||
.map((item) =>
|
||||
getStripeTimestamp(
|
||||
(item as Stripe.SubscriptionItem & { current_period_end?: unknown }).current_period_end
|
||||
)
|
||||
)
|
||||
.filter((value): value is number => value !== null);
|
||||
|
||||
if (itemPeriodEnds.length > 0) {
|
||||
return Math.max(...itemPeriodEnds);
|
||||
}
|
||||
|
||||
return getStripeTimestamp(
|
||||
(subscription as Stripe.Subscription & { current_period_end?: unknown }).current_period_end
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same field move as the period end. Stripe opens the new period when it issues the
|
||||
* renewal invoice, so for an unpaid subscription this is roughly when the first payment
|
||||
* attempt failed, which is what the retry window is measured from.
|
||||
*/
|
||||
export function getSubscriptionPeriodStart(subscription: Stripe.Subscription): number | null {
|
||||
const itemPeriodStarts = (subscription.items?.data ?? [])
|
||||
.map((item) =>
|
||||
getStripeTimestamp(
|
||||
(item as Stripe.SubscriptionItem & { current_period_start?: unknown }).current_period_start
|
||||
)
|
||||
)
|
||||
.filter((value): value is number => value !== null);
|
||||
|
||||
if (itemPeriodStarts.length > 0) {
|
||||
return Math.min(...itemPeriodStarts);
|
||||
}
|
||||
|
||||
return getStripeTimestamp(
|
||||
(subscription as Stripe.Subscription & { current_period_start?: unknown }).current_period_start
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The invoice link to its subscription moved under `parent.subscription_details` in the
|
||||
* Basil API version. Same fallback reasoning as the period above.
|
||||
*/
|
||||
export function getInvoiceSubscriptionId(invoice: Stripe.Invoice): string | null {
|
||||
const fromParent = invoice.parent?.subscription_details?.subscription;
|
||||
if (typeof fromParent === 'string') return fromParent;
|
||||
if (fromParent && typeof fromParent === 'object') return fromParent.id;
|
||||
|
||||
const legacy = (invoice as Stripe.Invoice & { subscription?: unknown }).subscription;
|
||||
if (typeof legacy === 'string') return legacy;
|
||||
if (legacy && typeof legacy === 'object' && 'id' in legacy) {
|
||||
const id = (legacy as { id: unknown }).id;
|
||||
return typeof id === 'string' ? id : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInactiveBillingAccessEndedAt(
|
||||
subscription: Stripe.Subscription,
|
||||
currentPeriodEnd: number | null
|
||||
@@ -733,9 +869,37 @@ function getInactiveBillingAccessEndedAt(
|
||||
const canceledAt = getStripeTimestamp(
|
||||
(subscription as Stripe.Subscription & { canceled_at?: unknown }).canceled_at
|
||||
);
|
||||
const reference = currentPeriodEnd ?? endedAt ?? canceledAt;
|
||||
|
||||
return reference ? new Date(reference * 1000) : new Date();
|
||||
// `ended_at` wins over everything: a subscription killed mid-period for non-payment
|
||||
// must not keep access until a period the customer never paid for.
|
||||
if (endedAt) {
|
||||
return new Date(endedAt * 1000);
|
||||
}
|
||||
|
||||
// Still running, just behind on payment: bound access to the application grace period,
|
||||
// not the period end Stripe advanced to cover the unpaid invoice. The
|
||||
// period start is when that invoice was issued, so it is what the window runs from; when
|
||||
// it is missing (a paginated item list, an older payload shape) the window runs from now
|
||||
// instead. Falling through to "ended" here would lock out the customer this branch
|
||||
// exists to keep in, which is the wrong way to fail on missing data.
|
||||
if (RETRYING_STRIPE_STATUSES.has(subscription.status)) {
|
||||
const grace = UNPAID_ACCESS_GRACE_DAYS * 24 * 60 * 60;
|
||||
const periodStart = getSubscriptionPeriodStart(subscription);
|
||||
const graceEnd = periodStart ? periodStart + grace : Math.floor(Date.now() / 1000) + grace;
|
||||
|
||||
return new Date(Math.min(graceEnd, currentPeriodEnd ?? graceEnd) * 1000);
|
||||
}
|
||||
|
||||
// Preserve the existing period-based access policy for paused subscriptions.
|
||||
// The paused status itself is not evidence that this period was paid.
|
||||
if (subscription.status === 'paused' && currentPeriodEnd) {
|
||||
return new Date(currentPeriodEnd * 1000);
|
||||
}
|
||||
|
||||
// Anything else that gets here never paid for the period Stripe is reporting, so that
|
||||
// period is not a date access can run to. `incomplete` and `incomplete_expired` are the
|
||||
// cases that matter: their very first payment never went through.
|
||||
return canceledAt ? new Date(canceledAt * 1000) : new Date();
|
||||
}
|
||||
|
||||
function getEntitledStripePriceId(subscription: Stripe.Subscription) {
|
||||
@@ -747,10 +911,17 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId:
|
||||
}
|
||||
|
||||
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
|
||||
return recordSyncedSubscription(await writeStripeSubscriptionToUser(subscription, db));
|
||||
}
|
||||
|
||||
async function writeStripeSubscriptionToUser(
|
||||
subscription: Stripe.Subscription,
|
||||
client: Prisma.TransactionClient
|
||||
) {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
const user = await client.user.findUnique({
|
||||
where: { stripeCustomerId: customerId },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -768,10 +939,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPeriodEnd =
|
||||
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
|
||||
? subscription.current_period_end
|
||||
: null;
|
||||
const currentPeriodEnd = getSubscriptionPeriodEnd(subscription);
|
||||
const cancelAt =
|
||||
'cancel_at' in subscription && typeof subscription.cancel_at === 'number'
|
||||
? subscription.cancel_at
|
||||
@@ -796,13 +964,14 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
// subscription created after the cardless trial shipped, and this fallback is
|
||||
// what stops an abandoned or failed checkout from erasing the days the account
|
||||
// still had. Legacy card-backed trials keep arriving through the branch above.
|
||||
const preservedTrialEnd = effectiveTrialEnd ?? keepUnexpiredTrial(user.trialEndsAt);
|
||||
const hasAccess =
|
||||
hasEntitledPrice &&
|
||||
(hasActiveSubscription(mappedStatus) ||
|
||||
Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
|
||||
const preservedTrialEnd = effectiveTrialEnd ?? user.trialEndsAt ?? null;
|
||||
// The reported period is not proof of payment: Stripe advances it when it issues the
|
||||
// renewal invoice, paid or not, and it survives cancellation. Access therefore follows
|
||||
// the status, and every other case gets a cutoff stamped into `billingAccessEndedAt`,
|
||||
// which is cleared again as soon as the subscription goes back to active.
|
||||
const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus);
|
||||
|
||||
const updated = await db.user.update({
|
||||
const updated = await client.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
@@ -816,22 +985,15 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
hasEntitledPrice && trialEnd
|
||||
? (user.billingTrialConsumedAt ?? new Date())
|
||||
: user.billingTrialConsumedAt,
|
||||
// A live trial means access has not ended, whatever the subscription says.
|
||||
// Stamping an end date here while the trial runs would date the storage
|
||||
// cleanup from today and tell the user their work dies before their trial
|
||||
// does. `hasActiveTrial`, not merely a non-null date: a legacy Stripe trial
|
||||
// that has already elapsed is a reason to stamp the end date, not to skip it.
|
||||
billingAccessEndedAt:
|
||||
hasAccess || hasActiveTrial(preservedTrialEnd)
|
||||
? null
|
||||
: getInactiveBillingAccessEndedAt(
|
||||
subscription,
|
||||
hasEntitledPrice ? currentPeriodEnd : null
|
||||
),
|
||||
// Preserve the subscription cutoff even during a trial. The trial has its own
|
||||
// access branch; clearing this cutoff would resurrect an unpaid period later.
|
||||
billingAccessEndedAt: hasAccess
|
||||
? null
|
||||
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
|
||||
},
|
||||
});
|
||||
|
||||
await recordSubscriptionTransition({
|
||||
const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
|
||||
userId: user.id,
|
||||
subscriptionId: subscription.id,
|
||||
before: {
|
||||
@@ -845,9 +1007,19 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
trialEndsAt: preservedTrialEnd,
|
||||
currentPeriodEnd: effectiveCurrentPeriodEnd,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return updated;
|
||||
return { updated, transition };
|
||||
}
|
||||
|
||||
async function recordSyncedSubscription(
|
||||
result: Awaited<ReturnType<typeof writeStripeSubscriptionToUser>>
|
||||
) {
|
||||
if (!result) return null;
|
||||
// Analytics uses its own connection. Run it after commit, not while a billing
|
||||
// transaction holds a connection and other syncs are queued on its advisory lock.
|
||||
await recordSubscriptionTransition(result.transition);
|
||||
return result.updated;
|
||||
}
|
||||
|
||||
// A single Stripe customer can own several subscriptions at once (e.g. after
|
||||
@@ -900,28 +1072,49 @@ export function selectAuthoritativeSubscription(
|
||||
// Source-of-truth sync: instead of trusting a single subscription from a webhook
|
||||
// event body (which may be an OLD subscription being deleted while a NEWER one is
|
||||
// active), re-list ALL of the customer's subscriptions from Stripe and sync the
|
||||
// authoritative one. This is order-independent and self-healing.
|
||||
// authoritative one. The customer lock covers the Stripe read as well as the mirror
|
||||
// write: locking only after the read would still let a delayed older response win.
|
||||
export async function syncStripeCustomerSubscriptions(customerId: string) {
|
||||
const stripe = getStripe();
|
||||
const { data: subscriptions } = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100,
|
||||
});
|
||||
const result = await db.$transaction(
|
||||
async (tx) => {
|
||||
// Two-key advisory locks occupy a separate namespace from the one-key
|
||||
// cancellation locks. Cancellation releases its lock before calling sync.
|
||||
await tx.$executeRaw`
|
||||
SELECT pg_advisory_xact_lock(hashtext('stripe-subscription-sync'), hashtext(${customerId}))
|
||||
`;
|
||||
const { data: subscriptions } = await getStripe().subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const authoritative = selectAuthoritativeSubscription(subscriptions);
|
||||
if (!authoritative) {
|
||||
return markSubscriptionCanceledByCustomerId(customerId);
|
||||
}
|
||||
|
||||
return syncStripeSubscriptionToUser(authoritative);
|
||||
const authoritative = selectAuthoritativeSubscription(subscriptions);
|
||||
return authoritative
|
||||
? writeStripeSubscriptionToUser(authoritative, tx)
|
||||
: writeSubscriptionCanceledByCustomerId(customerId, undefined, tx);
|
||||
},
|
||||
// Bound lock and connection occupancy. A slow Stripe call or lock wait fails
|
||||
// this sync; writes through the expired transaction cannot overwrite a newer sync.
|
||||
{ maxWait: 10_000, timeout: 30_000 }
|
||||
);
|
||||
return recordSyncedSubscription(result);
|
||||
}
|
||||
|
||||
export async function markSubscriptionCanceledByCustomerId(
|
||||
customerId: string,
|
||||
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }
|
||||
) {
|
||||
const user = await db.user.findUnique({
|
||||
return recordSyncedSubscription(
|
||||
await writeSubscriptionCanceledByCustomerId(customerId, options, db)
|
||||
);
|
||||
}
|
||||
|
||||
async function writeSubscriptionCanceledByCustomerId(
|
||||
customerId: string,
|
||||
options: { currentPeriodEnd?: Date | null; endedAt?: Date | null } | undefined,
|
||||
client: Prisma.TransactionClient
|
||||
) {
|
||||
const user = await client.user.findUnique({
|
||||
where: { stripeCustomerId: customerId },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -941,9 +1134,9 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
// Losing the subscription does not retract a trial that has not run out. The
|
||||
// account keeps the days it was given and lands back on the trial's own end
|
||||
// date, which is also what the cancellation copy in settings promises.
|
||||
const preservedTrialEnd = keepUnexpiredTrial(user.trialEndsAt);
|
||||
const preservedTrialEnd = user.trialEndsAt ?? null;
|
||||
|
||||
const updated = await db.user.update({
|
||||
const updated = await client.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||
@@ -953,9 +1146,7 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
|
||||
stripeCancelAtPeriodEnd: false,
|
||||
stripeCancelAt: null,
|
||||
billingAccessEndedAt: preservedTrialEnd
|
||||
? null
|
||||
: (options?.endedAt ?? options?.currentPeriodEnd ?? new Date()),
|
||||
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -963,7 +1154,7 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
// uses the period end being cleared here, which is the same one the earlier
|
||||
// "cancel at period end" write carried, so a customer who cancelled through the
|
||||
// portal and then reached the end of their term produces one cancellation, not two.
|
||||
await recordSubscriptionTransition({
|
||||
const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
|
||||
userId: user.id,
|
||||
subscriptionId: user.stripeSubscriptionId ?? user.id,
|
||||
before: {
|
||||
@@ -977,7 +1168,218 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
trialEndsAt: preservedTrialEnd,
|
||||
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return { updated, transition };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a subscription of this customer that still grants access, if any. A customer can
|
||||
* hold several at once, so the state of one says nothing about the others.
|
||||
*/
|
||||
export async function findLiveStripeSubscription(customerId: string) {
|
||||
const stripe = getStripe();
|
||||
const { data: subscriptions } = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return updated;
|
||||
return (
|
||||
selectAuthoritativeSubscription(
|
||||
subscriptions.filter((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status))
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asked before opening checkout. Answered by Stripe rather than by the local mirror: the
|
||||
* mirror can be stale or cleared, and a customer who slips past this ends up paying for two
|
||||
* subscriptions at once.
|
||||
*/
|
||||
export async function findBlockingStripeSubscription(customerId: string) {
|
||||
const stripe = getStripe();
|
||||
const { data: subscriptions } = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return (
|
||||
subscriptions.find((subscription) => LIVE_STRIPE_STATUSES.has(subscription.status)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function isUnpaidStripeSubscription(subscription: Stripe.Subscription) {
|
||||
return UNPAID_STRIPE_STATUSES.has(subscription.status);
|
||||
}
|
||||
|
||||
/** Only a wholly unpaid, ordinary current-period invoice can be written off. */
|
||||
export function isCurrentSubscriptionInvoice(
|
||||
invoice: Stripe.Invoice,
|
||||
subscription: Stripe.Subscription
|
||||
): boolean {
|
||||
const latestId =
|
||||
typeof subscription.latest_invoice === 'string'
|
||||
? subscription.latest_invoice
|
||||
: subscription.latest_invoice?.id;
|
||||
const start = getSubscriptionPeriodStart(subscription);
|
||||
const end = getSubscriptionPeriodEnd(subscription);
|
||||
if (
|
||||
invoice.id !== latestId ||
|
||||
invoice.status !== 'open' ||
|
||||
invoice.amount_paid !== 0 ||
|
||||
!['subscription_cycle', 'subscription_create'].includes(invoice.billing_reason ?? '') ||
|
||||
getInvoiceSubscriptionId(invoice) !== subscription.id ||
|
||||
start === null ||
|
||||
end === null ||
|
||||
!invoice.lines ||
|
||||
invoice.lines.has_more ||
|
||||
invoice.lines.data.length === 0
|
||||
)
|
||||
return false;
|
||||
return invoice.lines.data.every((line) => {
|
||||
const details = line.parent?.subscription_item_details;
|
||||
return (
|
||||
line.parent?.type === 'subscription_item_details' &&
|
||||
details?.subscription === subscription.id &&
|
||||
details.proration === false &&
|
||||
line.pricing?.price_details?.price === getStripePriceId() &&
|
||||
line.period.start === start &&
|
||||
line.period.end === end
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function listOpenSubscriptionInvoices(customerId: string, subscriptionId: string) {
|
||||
const invoices: Stripe.Invoice[] = [];
|
||||
let startingAfter: string | undefined;
|
||||
while (true) {
|
||||
const page = await getStripe().invoices.list({
|
||||
customer: customerId,
|
||||
status: 'open',
|
||||
limit: 100,
|
||||
...(startingAfter ? { starting_after: startingAfter } : {}),
|
||||
});
|
||||
invoices.push(
|
||||
...page.data.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
|
||||
);
|
||||
if (!page.has_more || page.data.length === 0) break;
|
||||
startingAfter = page.data[page.data.length - 1].id;
|
||||
}
|
||||
return invoices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop automatic collection on all open invoices for this subscription. Older or
|
||||
* mixed invoices remain receivables; only a complete current renewal is voided.
|
||||
* Failures propagate so callers can report and retry unfinished cleanup.
|
||||
*/
|
||||
export async function voidOpenSubscriptionInvoices(
|
||||
customerId: string,
|
||||
subscriptionId: string,
|
||||
subscriptionSnapshot?: Stripe.Subscription
|
||||
) {
|
||||
const stripe = getStripe();
|
||||
const subscription =
|
||||
subscriptionSnapshot ?? (await stripe.subscriptions.retrieve(subscriptionId));
|
||||
const customer =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
if (customer !== customerId) throw new Error('Subscription customer mismatch');
|
||||
const voided: string[] = [];
|
||||
for (const invoice of await listOpenSubscriptionInvoices(customerId, subscriptionId)) {
|
||||
// Immediate cancellation normally pauses collection too. Explicitly keep retained
|
||||
// receivables paused, including when retrying a partly completed cancellation.
|
||||
if (invoice.auto_advance) await stripe.invoices.update(invoice.id, { auto_advance: false });
|
||||
if (isCurrentSubscriptionInvoice(invoice, subscription)) {
|
||||
await stripe.invoices.voidInvoice(invoice.id);
|
||||
voided.push(invoice.id);
|
||||
}
|
||||
}
|
||||
return voided;
|
||||
}
|
||||
|
||||
/** Cancellation candidates differ from the subscription granting access. */
|
||||
export async function findCancelableStripeSubscription(customerId: string) {
|
||||
const subscriptions: Stripe.Subscription[] = [];
|
||||
let startingAfter: string | undefined;
|
||||
while (true) {
|
||||
const page = await getStripe().subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100,
|
||||
...(startingAfter ? { starting_after: startingAfter } : {}),
|
||||
});
|
||||
subscriptions.push(...page.data);
|
||||
if (!page.has_more || page.data.length === 0) break;
|
||||
startingAfter = page.data[page.data.length - 1].id;
|
||||
}
|
||||
const candidate = selectAuthoritativeSubscription(
|
||||
subscriptions.filter(
|
||||
(subscription) =>
|
||||
hasEntitledPrice(subscription, getStripePriceId()) &&
|
||||
LIVE_STRIPE_STATUSES.has(subscription.status) &&
|
||||
(isUnpaidStripeSubscription(subscription) ||
|
||||
(!subscription.cancel_at && !subscription.cancel_at_period_end))
|
||||
)
|
||||
);
|
||||
if (candidate) return candidate;
|
||||
// A failed invoice write must remain reachable after Stripe accepted cancellation.
|
||||
for (const subscription of subscriptions) {
|
||||
if (
|
||||
!['canceled', 'incomplete_expired'].includes(subscription.status) ||
|
||||
!hasEntitledPrice(subscription, getStripePriceId())
|
||||
)
|
||||
continue;
|
||||
const invoices = await listOpenSubscriptionInvoices(customerId, subscription.id);
|
||||
if (
|
||||
invoices.some(
|
||||
(invoice) => invoice.auto_advance || isCurrentSubscriptionInvoice(invoice, subscription)
|
||||
)
|
||||
) {
|
||||
return subscription;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped to a subscription when one is known, the same way `voidOpenSubscriptionInvoices`
|
||||
* is: a customer can carry an open invoice left behind by a subscription they no longer
|
||||
* hold, and pointing them at that one does nothing about the retries they are seeing.
|
||||
*/
|
||||
export async function getOpenInvoiceForCustomer(
|
||||
customerId: string,
|
||||
subscriptionId?: string | null
|
||||
) {
|
||||
const stripe = getStripe();
|
||||
const { data: invoices } = await stripe.invoices.list({
|
||||
customer: customerId,
|
||||
status: 'open',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const candidates = subscriptionId
|
||||
? invoices.filter((invoice) => getInvoiceSubscriptionId(invoice) === subscriptionId)
|
||||
: invoices;
|
||||
|
||||
const newest = candidates
|
||||
.slice()
|
||||
.sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
|
||||
.at(0);
|
||||
|
||||
if (!newest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: newest.id ?? null,
|
||||
hostedInvoiceUrl: newest.hosted_invoice_url ?? null,
|
||||
amountDue: newest.amount_due ?? newest.total ?? 0,
|
||||
currency: newest.currency ?? 'usd',
|
||||
attemptCount: newest.attempt_count ?? 0,
|
||||
nextPaymentAttempt: newest.next_payment_attempt
|
||||
? new Date(newest.next_payment_attempt * 1000)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -60,6 +60,7 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||
'image-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||
'voice-upload': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
'feedback-submit': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
|
||||
'billing-cancel': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour per account
|
||||
'feedback-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||
'create-project': { windowMs: 60 * 60 * 1000, maxRequests: 20 }, // 20 per hour
|
||||
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
|
||||
+2
-2
@@ -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',
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface StorageContext {
|
||||
export async function getStorageContextForUser(userId: string): Promise<StorageContext> {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
||||
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true, billingAccessEndedAt: true },
|
||||
});
|
||||
|
||||
const isPaid = user ? isPaidTier(user) : false;
|
||||
|
||||
+7
-1
@@ -3,6 +3,12 @@ import { hasStripeConfig, isStripeBillingEnabled } from '@/lib/feature-flags';
|
||||
|
||||
let stripeClient: Stripe | null = null;
|
||||
|
||||
// Pinned on purpose. Without it the SDK silently follows whatever version it ships
|
||||
// with, and field moves between versions (the subscription period moving onto items,
|
||||
// the invoice subscription link moving under `parent`) turn into null reads instead
|
||||
// of build failures. `satisfies` makes an SDK bump a compile error here first.
|
||||
const STRIPE_API_VERSION = '2026-02-25.clover' satisfies Stripe.LatestApiVersion;
|
||||
|
||||
export function isStripeConfigured() {
|
||||
return isStripeBillingEnabled();
|
||||
}
|
||||
@@ -18,7 +24,7 @@ export function getStripe() {
|
||||
}
|
||||
|
||||
if (!stripeClient) {
|
||||
stripeClient = new Stripe(secretKey);
|
||||
stripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION });
|
||||
}
|
||||
|
||||
return stripeClient;
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@
|
||||
"r2:cleanup-orphans:dry": "bun run scripts/r2-orphan-cleanup.ts --dry-run",
|
||||
"r2:cleanup-orphans": "bun run scripts/r2-orphan-cleanup.ts",
|
||||
"bunny:cleanup-orphans:dry": "bun run scripts/bunny-orphan-cleanup.ts --dry-run",
|
||||
"bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts"
|
||||
"bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts",
|
||||
"stripe:resync:dry": "bun run scripts/resync-stripe-subscriptions.ts --dry-run",
|
||||
"stripe:resync": "bun run scripts/resync-stripe-subscriptions.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- One row per in-app cancellation, carrying the single answer the customer
|
||||
-- gave on the way out. Written when the request is made, before Stripe
|
||||
-- confirms it, so a reason is never lost to a webhook that arrives late.
|
||||
CREATE TYPE "CancellationReason" AS ENUM ('NOT_USING', 'MISSING_FEATURE', 'PRICE_OR_BILLING', 'PROJECT_ENDED', 'OTHER');
|
||||
|
||||
CREATE TABLE "subscription_cancellations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"stripeSubscriptionId" TEXT NOT NULL,
|
||||
"reason" "CancellationReason",
|
||||
"note" VARCHAR(500),
|
||||
"periodEnd" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "subscription_cancellations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "subscription_cancellations_userId_createdAt_idx" ON "subscription_cancellations"("userId", "createdAt" DESC);
|
||||
CREATE INDEX "subscription_cancellations_createdAt_idx" ON "subscription_cancellations"("createdAt" DESC);
|
||||
|
||||
ALTER TABLE "subscription_cancellations" ADD CONSTRAINT "subscription_cancellations_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -54,6 +54,7 @@ model User {
|
||||
sentInvitations Invitation[] @relation("InvitationsSentBy")
|
||||
acquisition UserAcquisition?
|
||||
analyticsEvents AnalyticsEvent[]
|
||||
subscriptionCancellations SubscriptionCancellation[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -609,6 +610,36 @@ model UserFeedback {
|
||||
@@map("user_feedback")
|
||||
}
|
||||
|
||||
enum CancellationReason {
|
||||
NOT_USING
|
||||
MISSING_FEATURE
|
||||
PRICE_OR_BILLING
|
||||
PROJECT_ENDED
|
||||
OTHER
|
||||
}
|
||||
|
||||
// One row per in-app cancellation, written the moment the customer asks for
|
||||
// it, not when Stripe later confirms it. The reason is the whole point of the
|
||||
// row and it is optional on purpose: the question can be skipped, and a skipped
|
||||
// answer still counts as a cancellation whose reason is unknown rather than a
|
||||
// cancellation that never happened. Cancellations made in the Stripe portal
|
||||
// never produce a row here; the funnel event still records those.
|
||||
model SubscriptionCancellation {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
stripeSubscriptionId String
|
||||
reason CancellationReason?
|
||||
note String? @db.VarChar(500)
|
||||
// When access was due to end at the time of the request.
|
||||
periodEnd DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
@@index([createdAt(sort: Desc)])
|
||||
@@map("subscription_cancellations")
|
||||
}
|
||||
|
||||
model UserFeedbackScreenshot {
|
||||
id String @id @default(cuid())
|
||||
feedbackId String
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
@@ -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();
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import * as adminGrowthRoute from '@/app/api/admin/growth/route';
|
||||
import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route';
|
||||
import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route';
|
||||
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
|
||||
import * as billingCancelRoute from '@/app/api/billing/cancel/route';
|
||||
import * as billingCheckoutRoute from '@/app/api/billing/checkout/route';
|
||||
import * as billingPortalRoute from '@/app/api/billing/portal/route';
|
||||
import * as billingTrialRoute from '@/app/api/billing/trial/route';
|
||||
@@ -149,7 +150,7 @@ vi.mock('@/lib/r2', async (importOriginal) => {
|
||||
// The count guard
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
|
||||
const EXPECTED_ROUTE_MODULE_COUNT = 67;
|
||||
const EXPECTED_ROUTE_MODULE_COUNT = 68;
|
||||
|
||||
/**
|
||||
* Routes that are public by design, and why. Everything else must reject an
|
||||
@@ -405,6 +406,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
|
||||
params: (f) => ({ requestId: f.approvalRequestId }),
|
||||
body: { decision: 'APPROVED' },
|
||||
},
|
||||
{
|
||||
file: 'billing/cancel/route.ts',
|
||||
module: billingCancelRoute,
|
||||
url: () => '/api/billing/cancel',
|
||||
headers: { origin: 'http://localhost:3000' },
|
||||
},
|
||||
{
|
||||
file: 'billing/checkout/route.ts',
|
||||
module: billingCheckoutRoute,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type Stripe from 'stripe';
|
||||
import { Pool } from 'pg';
|
||||
import {
|
||||
buildBillingAccessWhereInput,
|
||||
hasBillingAccess,
|
||||
syncStripeCustomerSubscriptions,
|
||||
} from '@/lib/billing';
|
||||
import { getStripe } from '@/lib/stripe';
|
||||
import { db } from '../helpers/db';
|
||||
import { createUser } from '../factories';
|
||||
|
||||
// Real PostgreSQL persistence and advisory locks; only Stripe responses are emulated.
|
||||
// The held response models transport delay, not Stripe's actual webhook scheduling.
|
||||
const CUSTOMER = 'cus_sync_concurrency';
|
||||
const PRICE = 'price_sync_concurrency';
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function subscription(
|
||||
status: 'active' | 'canceled',
|
||||
id = 'sub_paid',
|
||||
customer = CUSTOMER
|
||||
): Stripe.Subscription {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
id,
|
||||
customer,
|
||||
status,
|
||||
created: now - 86_400,
|
||||
trial_end: null,
|
||||
cancel_at: null,
|
||||
cancel_at_period_end: false,
|
||||
ended_at: status === 'canceled' ? now - 60 : null,
|
||||
canceled_at: status === 'canceled' ? now - 60 : null,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: PRICE },
|
||||
current_period_start: now - 86_400,
|
||||
current_period_end: now + 30 * 86_400,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Stripe.Subscription;
|
||||
}
|
||||
|
||||
async function seed(status: 'ACTIVE' | 'CANCELED' = 'CANCELED', customer = CUSTOMER) {
|
||||
return createUser({
|
||||
stripeCustomerId: customer,
|
||||
stripeSubscriptionId: `sub_seed_${customer}`,
|
||||
stripePriceId: PRICE,
|
||||
subscriptionStatus: status,
|
||||
stripeCurrentPeriodEnd: new Date(Date.now() + 30 * 86_400_000),
|
||||
trialEndsAt: null,
|
||||
billingTrialConsumedAt: new Date(Date.now() - 60 * 86_400_000),
|
||||
billingAccessEndedAt: status === 'CANCELED' ? new Date(Date.now() - 60_000) : null,
|
||||
});
|
||||
}
|
||||
|
||||
function installStripe() {
|
||||
const list = vi.fn<
|
||||
(params: Stripe.SubscriptionListParams) => Promise<{
|
||||
data: Stripe.Subscription[];
|
||||
has_more: boolean;
|
||||
}>
|
||||
>();
|
||||
vi.mocked(getStripe).mockReturnValue({ subscriptions: { list } } as unknown as Stripe);
|
||||
return list;
|
||||
}
|
||||
|
||||
async function waitForQueuedSync(customer = CUSTOMER) {
|
||||
// Observe a real waiter rather than sleeping and assuming the other request ran.
|
||||
// Replacing the database lock with a process-local mutex fails this assertion.
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const rows = await db.$queryRaw<{ waiting: boolean }[]>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_locks
|
||||
WHERE locktype = 'advisory' AND NOT granted
|
||||
AND classid = hashtext('stripe-subscription-sync')::oid
|
||||
AND objid = hashtext(${customer})::oid AND objsubid = 2
|
||||
) AS waiting
|
||||
`;
|
||||
expect(rows).toEqual([{ waiting: true }]);
|
||||
},
|
||||
{ timeout: 2_000, interval: 20 }
|
||||
);
|
||||
}
|
||||
|
||||
async function assertAccess(userId: string, expected: boolean) {
|
||||
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
expect(hasBillingAccess(stored)).toBe(expected);
|
||||
expect(
|
||||
await db.user.findMany({
|
||||
where: { AND: [{ id: userId }, buildBillingAccessWhereInput()] },
|
||||
select: { id: true },
|
||||
})
|
||||
).toEqual(expected ? [{ id: userId }] : []);
|
||||
return stored;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('STRIPE_PRICE_ID', PRICE);
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
});
|
||||
|
||||
describe('customer-wide Stripe sync serialization', () => {
|
||||
it.each(['canceled-then-paid', 'paid-then-canceled', 'empty-then-paid'] as const)(
|
||||
'keeps the newer snapshot for overlapping %s reads',
|
||||
async (order) => {
|
||||
const paidFirst = order === 'paid-then-canceled';
|
||||
const user = await seed(paidFirst ? 'ACTIVE' : 'CANCELED');
|
||||
const stale =
|
||||
order === 'empty-then-paid'
|
||||
? []
|
||||
: [subscription(paidFirst ? 'active' : 'canceled', paidFirst ? 'sub_paid' : 'sub_old')];
|
||||
const fresh = subscription(paidFirst ? 'canceled' : 'active');
|
||||
const list = installStripe();
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
list.mockImplementationOnce(async () => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return { data: stale, has_more: false };
|
||||
});
|
||||
list.mockImplementationOnce(async () => {
|
||||
// Reading Stripe for the next sync must wait for the previous mirror commit.
|
||||
const previous = await db.user.findUniqueOrThrow({ where: { id: user.id } });
|
||||
expect(previous.stripeSubscriptionId).toBe(stale[0]?.id ?? null);
|
||||
return { data: [fresh], has_more: false };
|
||||
});
|
||||
const first = syncStripeCustomerSubscriptions(CUSTOMER);
|
||||
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
|
||||
// Attach handlers immediately so assertion failures still drain both requests.
|
||||
void first.catch(() => {});
|
||||
try {
|
||||
await entered.promise;
|
||||
second = syncStripeCustomerSubscriptions(CUSTOMER);
|
||||
void second.catch(() => {});
|
||||
await waitForQueuedSync();
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
const unchanged = await db.user.findUniqueOrThrow({ where: { id: user.id } });
|
||||
expect(unchanged.stripeSubscriptionId).toBe(user.stripeSubscriptionId);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await Promise.allSettled([first, ...(second ? [second] : [])]);
|
||||
}
|
||||
await expect(first).resolves.not.toBeNull();
|
||||
await expect(second!).resolves.not.toBeNull();
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
const stored = await assertAccess(user.id, !paidFirst);
|
||||
expect(stored.subscriptionStatus).toBe(paidFirst ? 'CANCELED' : 'ACTIVE');
|
||||
expect(stored.stripeSubscriptionId).toBe('sub_paid');
|
||||
expect(
|
||||
await db.analyticsEvent.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { name: true },
|
||||
})
|
||||
).toEqual([{ name: paidFirst ? 'SUBSCRIPTION_CANCELED' : 'SUBSCRIPTION_STARTED' }]);
|
||||
}
|
||||
);
|
||||
|
||||
it('honors the customer lock held by an independent database connection before reading Stripe', async () => {
|
||||
const user = await seed();
|
||||
const list = installStripe().mockResolvedValue({
|
||||
data: [subscription('active')],
|
||||
has_more: false,
|
||||
});
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
|
||||
const connection = await pool.connect();
|
||||
let pending: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
|
||||
try {
|
||||
await connection.query('BEGIN');
|
||||
await connection.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [
|
||||
'stripe-subscription-sync',
|
||||
CUSTOMER,
|
||||
]);
|
||||
pending = syncStripeCustomerSubscriptions(CUSTOMER);
|
||||
void pending.catch(() => {});
|
||||
await waitForQueuedSync();
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await connection.query('ROLLBACK');
|
||||
connection.release();
|
||||
await pool.end();
|
||||
if (pending) await Promise.allSettled([pending]);
|
||||
}
|
||||
await expect(pending!).resolves.not.toBeNull();
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
await assertAccess(user.id, true);
|
||||
});
|
||||
|
||||
it('lets a different customer sync while the first customer waits on Stripe', async () => {
|
||||
await seed();
|
||||
const otherCustomer = 'cus_sync_independent';
|
||||
const otherUser = await seed('CANCELED', otherCustomer);
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
const list = installStripe().mockImplementation(async ({ customer }) => {
|
||||
if (customer === CUSTOMER) {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
return { data: [subscription('active', `sub_${customer}`, customer)], has_more: false };
|
||||
});
|
||||
const first = syncStripeCustomerSubscriptions(CUSTOMER);
|
||||
void first.catch(() => {});
|
||||
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
|
||||
let secondFinished = false;
|
||||
try {
|
||||
await entered.promise;
|
||||
second = syncStripeCustomerSubscriptions(otherCustomer);
|
||||
void second.then(
|
||||
() => {
|
||||
secondFinished = true;
|
||||
},
|
||||
() => {
|
||||
secondFinished = true;
|
||||
}
|
||||
);
|
||||
await vi.waitFor(() => expect(secondFinished).toBe(true), { timeout: 2_000 });
|
||||
await expect(second).resolves.not.toBeNull();
|
||||
await assertAccess(otherUser.id, true);
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await Promise.allSettled([first, ...(second ? [second] : [])]);
|
||||
}
|
||||
await expect(first).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
it('releases a failed sync for its queued successor without changing the original mirror', async () => {
|
||||
const user = await seed();
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
const failure = new Error('Emulated Stripe read failure');
|
||||
const list = installStripe();
|
||||
list.mockImplementationOnce(async () => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
throw failure;
|
||||
});
|
||||
list.mockImplementationOnce(async () => {
|
||||
expect(await db.user.findUniqueOrThrow({ where: { id: user.id } })).toEqual(user);
|
||||
return { data: [subscription('active')], has_more: false };
|
||||
});
|
||||
const first = syncStripeCustomerSubscriptions(CUSTOMER);
|
||||
void first.catch(() => {});
|
||||
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
|
||||
try {
|
||||
await entered.promise;
|
||||
second = syncStripeCustomerSubscriptions(CUSTOMER);
|
||||
void second.catch(() => {});
|
||||
await waitForQueuedSync();
|
||||
} finally {
|
||||
release.resolve();
|
||||
await Promise.allSettled([first, ...(second ? [second] : [])]);
|
||||
}
|
||||
await expect(first).rejects.toBe(failure);
|
||||
await expect(second!).resolves.not.toBeNull();
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
await assertAccess(user.id, true);
|
||||
});
|
||||
|
||||
it('cannot overwrite a newer mirror when a Stripe response arrives after transaction expiry', async () => {
|
||||
const user = await seed();
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
const callbackFinished = deferred();
|
||||
const list = installStripe();
|
||||
list.mockImplementationOnce(async () => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return { data: [subscription('canceled', 'sub_stale')], has_more: false };
|
||||
});
|
||||
list.mockResolvedValueOnce({ data: [subscription('active', 'sub_new')], has_more: false });
|
||||
|
||||
// Keep the real transaction and expiry machinery, shortening only the first
|
||||
// request's deadline. Its callback can outlive rollback while Stripe is held.
|
||||
const transact = db.$transaction.bind(db);
|
||||
const transaction = vi.spyOn(db, '$transaction').mockImplementationOnce((callback, options) =>
|
||||
transact(
|
||||
async (tx) => {
|
||||
try {
|
||||
return await callback(tx);
|
||||
} finally {
|
||||
callbackFinished.resolve();
|
||||
}
|
||||
},
|
||||
{ ...options, timeout: 200 }
|
||||
)
|
||||
);
|
||||
const first = syncStripeCustomerSubscriptions(CUSTOMER);
|
||||
void first.catch(() => {});
|
||||
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
|
||||
let committed: Awaited<ReturnType<typeof assertAccess>> | undefined;
|
||||
try {
|
||||
await entered.promise;
|
||||
// Wait for PostgreSQL to release A's lock, not an assumed sleep duration.
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const rows = await db.$queryRaw<{ held: boolean }[]>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_locks
|
||||
WHERE locktype = 'advisory' AND granted
|
||||
AND classid = hashtext('stripe-subscription-sync')::oid
|
||||
AND objid = hashtext(${CUSTOMER})::oid AND objsubid = 2
|
||||
) AS held
|
||||
`;
|
||||
expect(rows).toEqual([{ held: false }]);
|
||||
},
|
||||
{ timeout: 3_000, interval: 20 }
|
||||
);
|
||||
second = syncStripeCustomerSubscriptions(CUSTOMER);
|
||||
void second.catch(() => {});
|
||||
await expect(second).resolves.not.toBeNull();
|
||||
committed = await assertAccess(user.id, true);
|
||||
expect(committed.stripeSubscriptionId).toBe('sub_new');
|
||||
} finally {
|
||||
release.resolve();
|
||||
// The outer promise may reject on expiry before its callback finishes.
|
||||
// Drain both so a late global-client write cannot escape the assertions.
|
||||
await Promise.allSettled([first, ...(second ? [second] : [])]);
|
||||
await callbackFinished.promise;
|
||||
transaction.mockRestore();
|
||||
}
|
||||
await expect(first).rejects.toMatchObject({ code: 'P2028' });
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
expect(await assertAccess(user.id, true)).toEqual(committed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
|
||||
|
||||
function renderDialog(
|
||||
overrides: {
|
||||
periodEnd?: string | null;
|
||||
isTrial?: boolean;
|
||||
canceledImmediately?: boolean;
|
||||
confirmResult?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const onConfirm = vi.fn(async () => overrides.confirmResult ?? true);
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<CancelSubscriptionDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
periodEnd={
|
||||
overrides.periodEnd === undefined ? '2026-10-01T00:00:00.000Z' : overrides.periodEnd
|
||||
}
|
||||
isTrial={overrides.isTrial ?? false}
|
||||
canceledImmediately={overrides.canceledImmediately}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
return { onConfirm, onOpenChange };
|
||||
}
|
||||
|
||||
describe('CancelSubscriptionDialog', () => {
|
||||
it('lists every answer with none selected', () => {
|
||||
renderDialog();
|
||||
|
||||
const radios = screen.getAllByRole('radio');
|
||||
expect(radios).toHaveLength(5);
|
||||
for (const radio of radios) {
|
||||
expect(radio).toHaveAttribute('aria-checked', 'false');
|
||||
}
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The question is skippable: the destructive button works with nothing
|
||||
// chosen, and the handler receives an explicit null rather than a default.
|
||||
it('cancels with no reason when the question is skipped', async () => {
|
||||
const { onConfirm } = renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({ reason: null, note: null });
|
||||
});
|
||||
|
||||
it('opens the note box only under the answers that ask for detail', async () => {
|
||||
renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'I am not using it enough' }));
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'It is missing something I need' }));
|
||||
expect(screen.getByLabelText(/What was missing\?/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends the chosen reason with a trimmed note', async () => {
|
||||
const { onConfirm } = renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
|
||||
await userEvent.type(screen.getByRole('textbox'), ' Moved the client to Frame.io ');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
reason: 'OTHER',
|
||||
note: 'Moved the client to Frame.io',
|
||||
});
|
||||
});
|
||||
|
||||
// A note typed under "Something else" must not travel with an answer that
|
||||
// never showed the box, or the admin reads a comment about nothing.
|
||||
it('drops the note when the answer changes to one without a note box', async () => {
|
||||
const { onConfirm } = renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
|
||||
await userEvent.type(screen.getByRole('textbox'), 'Some detail');
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'The project or client work ended' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({ reason: 'PROJECT_ENDED', note: null });
|
||||
});
|
||||
|
||||
// A failed request must not cost the customer the answer they typed.
|
||||
it('keeps the answer on screen when the confirmation fails', async () => {
|
||||
const { onConfirm } = renderDialog({ confirmResult: false });
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Something else' }));
|
||||
await userEvent.type(screen.getByRole('textbox'), 'Kept this');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel subscription' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('radio', { name: 'Something else' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Kept this');
|
||||
});
|
||||
|
||||
it('closes without confirming from the keep button', async () => {
|
||||
const { onConfirm, onOpenChange } = renderDialog();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Keep subscription' }));
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('explains immediate unpaid cancellation without promising future access or forgiving prior charges', () => {
|
||||
renderDialog({ canceledImmediately: true });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Cancel your subscription?' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/This subscription ends immediately/)).toHaveTextContent(
|
||||
'Canceling does not extend access to your workspaces.'
|
||||
);
|
||||
expect(screen.getByText(/Automatic collection stops/)).toHaveTextContent(
|
||||
'charges for prior service and other items may still be owed.'
|
||||
);
|
||||
expect(screen.queryByText(/Everything stays on/)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('retains the scheduled period-end explanation', () => {
|
||||
renderDialog();
|
||||
|
||||
expect(screen.getByText(/Everything stays on until/)).toHaveTextContent(
|
||||
new Date('2026-10-01T00:00:00.000Z').toLocaleDateString()
|
||||
);
|
||||
expect(screen.queryByText(/This subscription ends immediately/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the trial instead of the subscription while still trialing', () => {
|
||||
renderDialog({ isTrial: true });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Cancel your trial?' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Cancel trial' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -418,6 +418,92 @@ describe('useVideoPlayer scrubbing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoPlayer cursor idling', () => {
|
||||
/** The hook hides the cursor and the overlay after this much stillness. */
|
||||
const IDLE_DELAY_MS = 1000;
|
||||
|
||||
it('goes idle when playback starts under a cursor that never moves again', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
act(() => result.current.handleVideoMouseMove());
|
||||
|
||||
startPlayback(video);
|
||||
expect(result.current.cursorIdle).toBe(false);
|
||||
|
||||
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS));
|
||||
expect(result.current.cursorIdle).toBe(true);
|
||||
});
|
||||
|
||||
it('stays awake through a scrub and idles again once the cursor returns', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
act(() => result.current.handleVideoMouseMove());
|
||||
startPlayback(video);
|
||||
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS));
|
||||
expect(result.current.cursorIdle).toBe(true);
|
||||
|
||||
// The timeline sits outside the player, so reaching it leaves the player
|
||||
// first; the element then reports the pause the scrub asked for and the
|
||||
// resume on release.
|
||||
act(() => result.current.handleVideoMouseLeave());
|
||||
act(() => result.current.handleTimelineMouseDown(mouseEventAt(50)));
|
||||
stopPlayback(video);
|
||||
act(() => result.current.handleTimelineMouseUp());
|
||||
startPlayback(video);
|
||||
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS * 5));
|
||||
expect(result.current.cursorIdle).toBe(false);
|
||||
|
||||
act(() => result.current.handleVideoMouseMove());
|
||||
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS));
|
||||
expect(result.current.cursorIdle).toBe(true);
|
||||
});
|
||||
|
||||
it('wakes on movement and idles again after the same delay', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
act(() => result.current.handleVideoMouseMove());
|
||||
startPlayback(video);
|
||||
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS));
|
||||
|
||||
act(() => result.current.handleVideoMouseMove());
|
||||
expect(result.current.cursorIdle).toBe(false);
|
||||
|
||||
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS - 1));
|
||||
expect(result.current.cursorIdle).toBe(false);
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(result.current.cursorIdle).toBe(true);
|
||||
});
|
||||
|
||||
it('never idles while paused', () => {
|
||||
const { result } = renderPlayer();
|
||||
act(() => result.current.handleVideoMouseMove());
|
||||
|
||||
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS * 5));
|
||||
expect(result.current.cursorIdle).toBe(false);
|
||||
});
|
||||
|
||||
it('stays awake once the cursor has left the player', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
act(() => result.current.handleVideoMouseMove());
|
||||
act(() => result.current.handleVideoMouseLeave());
|
||||
|
||||
startPlayback(video);
|
||||
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS * 5));
|
||||
expect(result.current.cursorIdle).toBe(false);
|
||||
});
|
||||
|
||||
it('stays idle across a pause and play the element emits on its own', () => {
|
||||
const { result, video } = renderPlayer();
|
||||
act(() => result.current.handleVideoMouseMove());
|
||||
startPlayback(video);
|
||||
act(() => vi.advanceTimersByTime(IDLE_DELAY_MS));
|
||||
expect(result.current.cursorIdle).toBe(true);
|
||||
|
||||
// A rebuffer or a source switch pauses and resumes without any pointer
|
||||
// activity, so the cursor must not come back for a second on every stall.
|
||||
stopPlayback(video);
|
||||
startPlayback(video);
|
||||
expect(result.current.cursorIdle).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVideoPlayer keyboard shortcuts', () => {
|
||||
it('starts and stops playback on space', () => {
|
||||
const { video } = renderPlayer();
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import SettingsPage from '@/app/(dashboard)/settings/settings-page-client';
|
||||
|
||||
function renderScheduledCancellation(status: 'ACTIVE' | 'TRIALING') {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url !== '/api/billing') return { ok: false };
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
isEnabled: true,
|
||||
isConfigured: true,
|
||||
checkoutAvailable: false,
|
||||
portalAvailable: true,
|
||||
cancelAvailable: false,
|
||||
needsPaymentFix: false,
|
||||
openInvoice: null,
|
||||
workspaceCreation: { canCreateWorkspace: true, canStartTrial: false },
|
||||
subscription: {
|
||||
status,
|
||||
label: status === 'ACTIVE' ? 'Active' : 'Trialing',
|
||||
hasActiveSubscription: true,
|
||||
hasRecoverableSubscription: true,
|
||||
hasActiveTrial: true,
|
||||
hasBillingAccess: true,
|
||||
currentPeriodEnd: '2026-10-08T12:00:00Z',
|
||||
trialEndsAt: '2026-09-15T12:00:00Z',
|
||||
cancelAtPeriodEnd: true,
|
||||
cancelAt: '2026-10-08T12:00:00Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
render(<SettingsPage billingOnly />);
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('scheduled cancellation in billing settings', () => {
|
||||
it('keeps a paid subscription distinct from its remaining cardless trial', async () => {
|
||||
renderScheduledCancellation('ACTIVE');
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Subscription canceled. Access remains active until the end of the current billing period.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Trial canceled/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Access ends on/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Your subscription ends on/)).toHaveTextContent(
|
||||
new Date('2026-10-08T12:00:00Z').toLocaleDateString()
|
||||
);
|
||||
expect(screen.getByText(/Cancellation takes effect on/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Cancellation was scheduled on/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still explains the trial end for a Stripe trial subscription', async () => {
|
||||
renderScheduledCancellation('TRIALING');
|
||||
|
||||
expect(
|
||||
await screen.findByText('Trial canceled. Access remains active until the trial ends.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Access ends on/)).toHaveTextContent(
|
||||
new Date('2026-09-15T12:00:00Z').toLocaleDateString()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderPastDue(hasBillingAccess: boolean) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url !== '/api/billing') return { ok: false };
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
isEnabled: true,
|
||||
isConfigured: true,
|
||||
checkoutAvailable: false,
|
||||
portalAvailable: true,
|
||||
cancelAvailable: true,
|
||||
cancelIsImmediate: true,
|
||||
needsPaymentFix: true,
|
||||
openInvoice: null,
|
||||
workspaceCreation: { canCreateWorkspace: hasBillingAccess, canStartTrial: false },
|
||||
subscription: {
|
||||
status: 'PAST_DUE',
|
||||
label: 'Past due',
|
||||
hasActiveSubscription: false,
|
||||
hasRecoverableSubscription: true,
|
||||
hasActiveTrial: false,
|
||||
hasBillingAccess,
|
||||
currentPeriodEnd: '2026-10-08T12:00:00Z',
|
||||
trialEndsAt: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
render(<SettingsPage billingOnly />);
|
||||
}
|
||||
|
||||
describe('past-due access in billing settings', () => {
|
||||
it('opens immediate unpaid cancellation copy and waits for confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPastDue(true);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Cancel subscription' }));
|
||||
|
||||
const dialog = within(screen.getByRole('dialog'));
|
||||
expect(dialog.getByText(/This subscription ends immediately\./)).toHaveTextContent(
|
||||
'Canceling does not extend access to your workspaces.'
|
||||
);
|
||||
expect(dialog.getByText(/Automatic collection stops/)).toHaveTextContent(
|
||||
'charges for prior service and other items may still be owed.'
|
||||
);
|
||||
expect(dialog.queryByText(/Everything stays on until/)).not.toBeInTheDocument();
|
||||
expect(dialog.getByRole('button', { name: 'Cancel subscription' })).toBeEnabled();
|
||||
expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false);
|
||||
|
||||
await user.click(dialog.getByRole('button', { name: 'Keep subscription' }));
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(vi.mocked(fetch).mock.calls.some(([url]) => url === '/api/billing/cancel')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows continued workspace access during payment grace without an active trial', async () => {
|
||||
renderPastDue(true);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Workspace access remains available while you resolve your payment.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Billing access has ended.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows access has ended when payment grace has expired and no trial remains', async () => {
|
||||
renderPastDue(false);
|
||||
|
||||
expect(await screen.findByText('Billing access has ended.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Workspace access remains available while you resolve your payment.')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Free trial, no card required.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import SettingsPage from '@/app/(dashboard)/settings/settings-page-client';
|
||||
|
||||
// API scaling expectations are literal, independent of production Intl logic.
|
||||
// USD, JPY, KRW: https://docs.stripe.com/currencies#zero-decimal
|
||||
// ISK, UGX: https://docs.stripe.com/currencies#special-cases
|
||||
// KWD: https://support.stripe.com/questions/which-payments-methods-and-products-are-available-in-the-uae?locale=en-GB
|
||||
// KWD support is account/region dependent. This is a synthetic component fixture,
|
||||
// not evidence that the configured billing account accepts KWD invoices.
|
||||
const cases = [
|
||||
{ currency: 'usd', amountDue: 1099, expected: '$10.99' },
|
||||
{ currency: 'jpy', amountDue: 500, expected: '¥500' },
|
||||
{ currency: 'krw', amountDue: 500, expected: '₩500' },
|
||||
{ currency: 'kwd', amountDue: 12340, expected: 'KWD 12.340' },
|
||||
{ currency: 'isk', amountDue: 500, expected: 'ISK 5' },
|
||||
{ currency: 'ugx', amountDue: 500, expected: 'UGX 5' },
|
||||
];
|
||||
|
||||
const NumberFormat = Intl.NumberFormat;
|
||||
|
||||
beforeEach(() => {
|
||||
// Pin the locale while retaining the real currency precision and formatting.
|
||||
vi.spyOn(Intl, 'NumberFormat').mockImplementation(function (locales, options) {
|
||||
return new NumberFormat(locales ?? 'en-US', options);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('actual Settings invoice display against Stripe currency contract', () => {
|
||||
it.each(cases)('$currency amount_due=$amountDue displays $expected', async (fixture) => {
|
||||
expect(new Intl.NumberFormat().resolvedOptions().locale).toBe('en-US');
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url !== '/api/billing') return { ok: false };
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
isEnabled: true,
|
||||
isConfigured: true,
|
||||
status: 'ready',
|
||||
checkoutAvailable: false,
|
||||
portalAvailable: false,
|
||||
cancelAvailable: false,
|
||||
cancelIsImmediate: true,
|
||||
needsPaymentFix: true,
|
||||
openInvoice: {
|
||||
id: 'in_currency_fixture',
|
||||
hostedInvoiceUrl: null,
|
||||
amountDue: fixture.amountDue,
|
||||
currency: fixture.currency,
|
||||
attemptCount: 1,
|
||||
nextPaymentAttempt: null,
|
||||
},
|
||||
workspaceCreation: { canCreateWorkspace: true, canStartTrial: false },
|
||||
subscription: {
|
||||
status: 'PAST_DUE',
|
||||
label: 'Past due',
|
||||
hasActiveSubscription: false,
|
||||
hasRecoverableSubscription: true,
|
||||
hasActiveTrial: false,
|
||||
hasBillingAccess: true,
|
||||
isPaid: false,
|
||||
priceId: null,
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
trialEndsAt: null,
|
||||
billingAccessEndedAt: null,
|
||||
storageCleanupEligibleAt: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
render(<SettingsPage billingOnly />);
|
||||
const banner = await screen.findByText(/^A payment of .* did not go through$/);
|
||||
const actual = banner.textContent!.replace(/\s+/g, ' ');
|
||||
expect(fetchMock.mock.calls.some(([url]) => url === '/api/billing')).toBe(true);
|
||||
expect(actual).toBe(`A payment of ${fixture.expected} did not go through`);
|
||||
});
|
||||
});
|
||||
+6
-1
@@ -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' })) },
|
||||
},
|
||||
|
||||
@@ -70,6 +70,7 @@ const REVIEWED_MIGRATIONS = [
|
||||
'20260818120000_add_upload_reservation_purpose',
|
||||
'20260820120000_add_comment_images',
|
||||
'20260822120000_add_video_subtitles',
|
||||
'20260908120000_add_subscription_cancellations',
|
||||
];
|
||||
|
||||
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type Stripe from 'stripe';
|
||||
import {
|
||||
findCancelableStripeSubscription,
|
||||
isCurrentSubscriptionInvoice,
|
||||
voidOpenSubscriptionInvoices,
|
||||
} from '@/lib/billing';
|
||||
|
||||
const stripe = vi.hoisted(() => ({
|
||||
subscriptions: { list: vi.fn(), retrieve: vi.fn() },
|
||||
invoices: { list: vi.fn(), update: vi.fn(), voidInvoice: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({ db: {} }));
|
||||
vi.mock('@/lib/stripe', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/stripe')>()),
|
||||
getStripe: () => stripe,
|
||||
}));
|
||||
|
||||
const START = 1_800_000_000;
|
||||
const END = 1_802_592_000;
|
||||
|
||||
function subscription(overrides: Record<string, unknown> = {}): Stripe.Subscription {
|
||||
return {
|
||||
id: 'sub_target',
|
||||
customer: 'cus_target',
|
||||
status: 'past_due',
|
||||
created: 100,
|
||||
latest_invoice: 'in_current',
|
||||
cancel_at: null,
|
||||
cancel_at_period_end: false,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: 'price_plan' },
|
||||
current_period_start: START,
|
||||
current_period_end: END,
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as Stripe.Subscription;
|
||||
}
|
||||
|
||||
function line(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'il_plan',
|
||||
amount: 1900,
|
||||
period: { start: START, end: END },
|
||||
parent: {
|
||||
type: 'subscription_item_details',
|
||||
subscription_item_details: { subscription: 'sub_target', proration: false },
|
||||
},
|
||||
pricing: { price_details: { price: 'price_plan' } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function invoice(overrides: Record<string, unknown> = {}): Stripe.Invoice {
|
||||
return {
|
||||
id: 'in_current',
|
||||
customer: 'cus_target',
|
||||
status: 'open',
|
||||
amount_paid: 0,
|
||||
amount_due: 1900,
|
||||
billing_reason: 'subscription_cycle',
|
||||
auto_advance: true,
|
||||
parent: { subscription_details: { subscription: 'sub_target' } },
|
||||
lines: { data: [line()], has_more: false },
|
||||
...overrides,
|
||||
} as unknown as Stripe.Invoice;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv('STRIPE_PRICE_ID', 'price_plan');
|
||||
stripe.subscriptions.retrieve.mockResolvedValue(subscription());
|
||||
stripe.subscriptions.list.mockResolvedValue({ data: [], has_more: false });
|
||||
stripe.invoices.list.mockResolvedValue({ data: [], has_more: false });
|
||||
stripe.invoices.update.mockResolvedValue({});
|
||||
stripe.invoices.voidInvoice.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('subscription invoice cleanup', () => {
|
||||
it.each(['subscription_cycle', 'subscription_create'])(
|
||||
'voids a complete unpaid current %s invoice and returns its id',
|
||||
async (billingReason) => {
|
||||
const current = invoice({ billing_reason: billingReason });
|
||||
stripe.invoices.list.mockResolvedValue({ data: [current], has_more: false });
|
||||
|
||||
expect(isCurrentSubscriptionInvoice(current, subscription())).toBe(true);
|
||||
await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).resolves.toEqual([
|
||||
'in_current',
|
||||
]);
|
||||
|
||||
expect(stripe.subscriptions.retrieve).toHaveBeenCalledExactlyOnceWith('sub_target');
|
||||
expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith('in_current', {
|
||||
auto_advance: false,
|
||||
});
|
||||
expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
|
||||
}
|
||||
);
|
||||
|
||||
const retainedInvoices: [string, () => Stripe.Invoice][] = [
|
||||
[
|
||||
'a different start with the current end',
|
||||
() =>
|
||||
invoice({
|
||||
lines: { data: [line({ period: { start: START - 86400, end: END } })], has_more: false },
|
||||
}),
|
||||
],
|
||||
[
|
||||
'a different end with the current start',
|
||||
() =>
|
||||
invoice({
|
||||
lines: { data: [line({ period: { start: START, end: END + 86400 } })], has_more: false },
|
||||
}),
|
||||
],
|
||||
['an older invoice id', () => invoice({ id: 'in_old' })],
|
||||
[
|
||||
'an older service period',
|
||||
() =>
|
||||
invoice({
|
||||
lines: {
|
||||
data: [line({ period: { start: START - 2_592_000, end: START } })],
|
||||
has_more: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
'a mixed invoice containing a manual charge',
|
||||
() =>
|
||||
invoice({
|
||||
lines: {
|
||||
data: [
|
||||
line(),
|
||||
line({
|
||||
id: 'il_manual',
|
||||
parent: { type: 'invoice_item_details', invoice_item_details: {} },
|
||||
}),
|
||||
],
|
||||
has_more: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
'a proration',
|
||||
() =>
|
||||
invoice({
|
||||
lines: {
|
||||
data: [
|
||||
line({
|
||||
parent: {
|
||||
type: 'subscription_item_details',
|
||||
subscription_item_details: { subscription: 'sub_target', proration: true },
|
||||
},
|
||||
}),
|
||||
],
|
||||
has_more: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
['a partly paid invoice', () => invoice({ amount_paid: 500, amount_due: 1400 })],
|
||||
['a truncated line item page', () => invoice({ lines: { data: [line()], has_more: true } })],
|
||||
[
|
||||
'a different price',
|
||||
() =>
|
||||
invoice({
|
||||
lines: {
|
||||
data: [line({ pricing: { price_details: { price: 'price_other' } } })],
|
||||
has_more: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
'a line belonging to a different subscription',
|
||||
() =>
|
||||
invoice({
|
||||
lines: {
|
||||
data: [
|
||||
line({
|
||||
parent: {
|
||||
type: 'subscription_item_details',
|
||||
subscription_item_details: { subscription: 'sub_other', proration: false },
|
||||
},
|
||||
}),
|
||||
],
|
||||
has_more: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
['an invoice with no lines', () => invoice({ lines: { data: [], has_more: false } })],
|
||||
['a subscription update invoice', () => invoice({ billing_reason: 'subscription_update' })],
|
||||
];
|
||||
|
||||
it.each(retainedInvoices)('retains %s but pauses collection', async (_label, makeInvoice) => {
|
||||
const retained = makeInvoice();
|
||||
stripe.invoices.list.mockResolvedValue({ data: [retained], has_more: false });
|
||||
|
||||
expect(isCurrentSubscriptionInvoice(retained, subscription())).toBe(false);
|
||||
await expect(
|
||||
voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription())
|
||||
).resolves.toEqual([]);
|
||||
expect(stripe.invoices.update).toHaveBeenCalledExactlyOnceWith(retained.id, {
|
||||
auto_advance: false,
|
||||
});
|
||||
expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
|
||||
expect(stripe.subscriptions.retrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('traverses invoice pages using the last unfiltered id and leaves foreign invoices untouched', async () => {
|
||||
stripe.invoices.list
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
invoice({ id: 'in_old' }),
|
||||
invoice({
|
||||
id: 'in_foreign',
|
||||
parent: { subscription_details: { subscription: 'sub_other' } },
|
||||
}),
|
||||
],
|
||||
has_more: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ data: [invoice()], has_more: false });
|
||||
|
||||
await expect(
|
||||
voidOpenSubscriptionInvoices('cus_target', 'sub_target', subscription())
|
||||
).resolves.toEqual(['in_current']);
|
||||
expect(stripe.invoices.list.mock.calls).toEqual([
|
||||
[{ customer: 'cus_target', status: 'open', limit: 100 }],
|
||||
[{ customer: 'cus_target', status: 'open', limit: 100, starting_after: 'in_foreign' }],
|
||||
]);
|
||||
expect(stripe.invoices.update.mock.calls).toEqual([
|
||||
['in_old', { auto_advance: false }],
|
||||
['in_current', { auto_advance: false }],
|
||||
]);
|
||||
expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
|
||||
});
|
||||
|
||||
it('voids a current invoice even when collection was already paused before a retry', async () => {
|
||||
stripe.invoices.list.mockResolvedValue({
|
||||
data: [invoice({ auto_advance: false })],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
voidOpenSubscriptionInvoices(
|
||||
'cus_target',
|
||||
'sub_target',
|
||||
subscription({
|
||||
status: 'canceled',
|
||||
latest_invoice: { id: 'in_current' },
|
||||
})
|
||||
)
|
||||
).resolves.toEqual(['in_current']);
|
||||
expect(stripe.invoices.update).not.toHaveBeenCalled();
|
||||
expect(stripe.invoices.voidInvoice).toHaveBeenCalledExactlyOnceWith('in_current');
|
||||
});
|
||||
|
||||
it.each(['retrieve', 'list', 'update', 'voidInvoice'] as const)(
|
||||
'propagates the Stripe %s failure rather than claiming successful cleanup',
|
||||
async (operation) => {
|
||||
const failure = new Error(`Stripe ${operation} failed`);
|
||||
stripe.invoices.list.mockResolvedValue({ data: [invoice()], has_more: false });
|
||||
const failingCall =
|
||||
operation === 'retrieve' ? stripe.subscriptions.retrieve : stripe.invoices[operation];
|
||||
failingCall.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(voidOpenSubscriptionInvoices('cus_target', 'sub_target')).rejects.toBe(failure);
|
||||
expect(failingCall).toHaveBeenCalledTimes(1);
|
||||
if (operation !== 'voidInvoice') expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects a subscription snapshot belonging to another customer before touching invoices', async () => {
|
||||
await expect(
|
||||
voidOpenSubscriptionInvoices(
|
||||
'cus_target',
|
||||
'sub_target',
|
||||
subscription({
|
||||
customer: { id: 'cus_other' },
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Subscription customer mismatch');
|
||||
expect(stripe.invoices.list).not.toHaveBeenCalled();
|
||||
expect(stripe.invoices.update).not.toHaveBeenCalled();
|
||||
expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancellation candidate selection', () => {
|
||||
it.each([
|
||||
{ cancel_at: END, cancel_at_period_end: false },
|
||||
{ cancel_at: null, cancel_at_period_end: true },
|
||||
])(
|
||||
'skips a scheduled paid subscription ($cancel_at, $cancel_at_period_end) for an older unscheduled one',
|
||||
async (schedule) => {
|
||||
stripe.subscriptions.list
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
subscription({
|
||||
id: 'sub_newer',
|
||||
status: 'active',
|
||||
created: 200,
|
||||
...schedule,
|
||||
}),
|
||||
],
|
||||
has_more: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
subscription({
|
||||
id: 'sub_older',
|
||||
status: 'active',
|
||||
created: 100,
|
||||
}),
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_older');
|
||||
expect(stripe.subscriptions.list.mock.calls).toEqual([
|
||||
[{ customer: 'cus_target', status: 'all', limit: 100 }],
|
||||
[{ customer: 'cus_target', status: 'all', limit: 100, starting_after: 'sub_newer' }],
|
||||
]);
|
||||
expect(stripe.invoices.list).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['past_due', 'unpaid', 'incomplete'] as const)(
|
||||
'still selects a scheduled %s subscription for immediate cancellation',
|
||||
async (status) => {
|
||||
stripe.subscriptions.list.mockResolvedValue({
|
||||
data: [subscription({ status, cancel_at: END, cancel_at_period_end: true })],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target');
|
||||
expect(stripe.invoices.list).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['a current invoice still awaiting void', { auto_advance: false }],
|
||||
['an older invoice still collecting', { id: 'in_old', auto_advance: true }],
|
||||
])('selects a canceled subscription with %s for cleanup retry', async (_label, overrides) => {
|
||||
stripe.subscriptions.list.mockResolvedValue({
|
||||
data: [subscription({ status: 'canceled' })],
|
||||
has_more: false,
|
||||
});
|
||||
stripe.invoices.list.mockResolvedValue({ data: [invoice(overrides)], has_more: false });
|
||||
|
||||
expect((await findCancelableStripeSubscription('cus_target'))?.id).toBe('sub_target');
|
||||
expect(stripe.invoices.list).toHaveBeenCalledExactlyOnceWith({
|
||||
customer: 'cus_target',
|
||||
status: 'open',
|
||||
limit: 100,
|
||||
});
|
||||
expect(stripe.invoices.update).not.toHaveBeenCalled();
|
||||
expect(stripe.invoices.voidInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not offer cleanup again for retained paused debt or a foreign invoice', async () => {
|
||||
stripe.subscriptions.list.mockResolvedValue({
|
||||
data: [subscription({ status: 'canceled' })],
|
||||
has_more: false,
|
||||
});
|
||||
stripe.invoices.list.mockResolvedValue({
|
||||
data: [
|
||||
invoice({ id: 'in_old', auto_advance: false }),
|
||||
invoice({
|
||||
id: 'in_foreign',
|
||||
parent: { subscription_details: { subscription: 'sub_other' } },
|
||||
}),
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each(['active', 'canceled'] as const)(
|
||||
'ignores a %s subscription for a different product',
|
||||
async (status) => {
|
||||
stripe.subscriptions.list.mockResolvedValue({
|
||||
data: [
|
||||
subscription({
|
||||
status,
|
||||
items: { data: [{ price: { id: 'price_other' } }] },
|
||||
}),
|
||||
],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
await expect(findCancelableStripeSubscription('cus_target')).resolves.toBeNull();
|
||||
expect(stripe.invoices.list).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('propagates invoice lookup failures while finding canceled cleanup candidates', async () => {
|
||||
const failure = new Error('Stripe invoice lookup failed');
|
||||
stripe.subscriptions.list.mockResolvedValue({
|
||||
data: [subscription({ status: 'canceled' })],
|
||||
has_more: false,
|
||||
});
|
||||
stripe.invoices.list.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(findCancelableStripeSubscription('cus_target')).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
+231
-48
@@ -15,6 +15,9 @@ import {
|
||||
getOrCreateStripeCustomerId,
|
||||
getStorageCleanupEligibleAt,
|
||||
getStripeCheckoutState,
|
||||
getInvoiceSubscriptionId,
|
||||
getSubscriptionPeriodEnd,
|
||||
getSubscriptionPeriodStart,
|
||||
getTrialNotice,
|
||||
getWorkspaceCreationEligibility,
|
||||
hasActiveSubscription,
|
||||
@@ -33,6 +36,8 @@ import {
|
||||
} from '@/lib/billing';
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
$transaction: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
|
||||
workspace: { count: vi.fn() },
|
||||
workspaceMember: { count: vi.fn() },
|
||||
@@ -152,7 +157,11 @@ describe('isPaidTier', () => {
|
||||
it('counts an active subscription as paid', () => {
|
||||
expect(
|
||||
isPaidTier(
|
||||
{ subscriptionStatus: BillingSubscriptionStatus.ACTIVE, stripeCurrentPeriodEnd: null },
|
||||
{
|
||||
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
billingAccessEndedAt: null,
|
||||
},
|
||||
NOW
|
||||
)
|
||||
).toBe(true);
|
||||
@@ -163,7 +172,11 @@ describe('isPaidTier', () => {
|
||||
it('counts a Stripe trial as paid', () => {
|
||||
expect(
|
||||
isPaidTier(
|
||||
{ subscriptionStatus: BillingSubscriptionStatus.TRIALING, stripeCurrentPeriodEnd: null },
|
||||
{
|
||||
subscriptionStatus: BillingSubscriptionStatus.TRIALING,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
billingAccessEndedAt: null,
|
||||
},
|
||||
NOW
|
||||
)
|
||||
).toBe(true);
|
||||
@@ -175,6 +188,7 @@ describe('isPaidTier', () => {
|
||||
{
|
||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
|
||||
billingAccessEndedAt: null,
|
||||
},
|
||||
NOW
|
||||
)
|
||||
@@ -185,7 +199,11 @@ describe('isPaidTier', () => {
|
||||
it('does not count a cardless trial as paid', () => {
|
||||
expect(
|
||||
isPaidTier(
|
||||
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
|
||||
{
|
||||
subscriptionStatus: BillingSubscriptionStatus.FREE,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
billingAccessEndedAt: null,
|
||||
},
|
||||
NOW
|
||||
)
|
||||
).toBe(false);
|
||||
@@ -200,6 +218,7 @@ describe('isPaidTier', () => {
|
||||
{
|
||||
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE,
|
||||
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
|
||||
billingAccessEndedAt: null,
|
||||
},
|
||||
NOW
|
||||
)
|
||||
@@ -212,6 +231,7 @@ describe('isPaidTier', () => {
|
||||
{
|
||||
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
|
||||
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
|
||||
billingAccessEndedAt: null,
|
||||
},
|
||||
NOW
|
||||
)
|
||||
@@ -227,6 +247,7 @@ describe('isPaidTier', () => {
|
||||
{
|
||||
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
|
||||
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
|
||||
billingAccessEndedAt: null,
|
||||
},
|
||||
NOW
|
||||
)
|
||||
@@ -239,6 +260,7 @@ describe('isPaidTier', () => {
|
||||
{
|
||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||
stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS),
|
||||
billingAccessEndedAt: null,
|
||||
},
|
||||
NOW
|
||||
)
|
||||
@@ -250,7 +272,11 @@ describe('isPaidTier', () => {
|
||||
|
||||
expect(
|
||||
isPaidTier(
|
||||
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
|
||||
{
|
||||
subscriptionStatus: BillingSubscriptionStatus.FREE,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
billingAccessEndedAt: null,
|
||||
},
|
||||
NOW
|
||||
)
|
||||
).toBe(true);
|
||||
@@ -366,7 +392,12 @@ describe('hasBillingAccess', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores billingAccessEndedAt while the paid period is still running', () => {
|
||||
// Was the opposite assertion, on the premise that a future period end means a paid
|
||||
// period. It does not: Stripe advances the period when it issues the renewal invoice,
|
||||
// paid or not, and the period survives cancellation, so this exact shape (cutoff in the
|
||||
// past, period end in the future) is what a subscription cancelled while behind on
|
||||
// payment looks like. Honouring the period here handed out a free month.
|
||||
it('honours billingAccessEndedAt even while the reported period is still running', () => {
|
||||
const result = hasBillingAccess(
|
||||
subject({
|
||||
subscriptionStatus: 'CANCELED',
|
||||
@@ -375,9 +406,36 @@ describe('hasBillingAccess', () => {
|
||||
}),
|
||||
NOW
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
// The other half of that: a stale cutoff must not outrank a live trial, or starting a
|
||||
// cardless trial on a lapsed account would consume the account's one trial and grant
|
||||
// nothing, since only a Stripe sync ever clears the cutoff.
|
||||
it('lets an unexpired trial win over a cutoff already in the past', () => {
|
||||
const result = hasBillingAccess(
|
||||
subject({
|
||||
subscriptionStatus: 'CANCELED',
|
||||
trialEndsAt: new Date(NOW.getTime() + DAY_MS),
|
||||
billingAccessEndedAt: new Date(NOW.getTime() - DAY_MS),
|
||||
}),
|
||||
NOW
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
// Stripe stamps a period on a subscription whose first charge never went through.
|
||||
it('refuses a period end carried by a subscription that never paid', () => {
|
||||
const result = hasBillingAccess(
|
||||
subject({
|
||||
subscriptionStatus: 'INCOMPLETE_EXPIRED',
|
||||
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
|
||||
}),
|
||||
NOW
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('grants access to everyone when Stripe is disabled', () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||
const result = hasBillingAccess(
|
||||
@@ -393,7 +451,7 @@ describe('hasBillingAccess', () => {
|
||||
});
|
||||
|
||||
describe('getBillingAccessEndDate', () => {
|
||||
it('prefers billingAccessEndedAt over every other date', () => {
|
||||
it('keeps an independent trial beyond the subscription cutoff', () => {
|
||||
const ended = new Date('2026-01-10T00:00:00Z');
|
||||
const result = getBillingAccessEndDate(
|
||||
subject({
|
||||
@@ -402,10 +460,10 @@ describe('getBillingAccessEndDate', () => {
|
||||
trialEndsAt: new Date('2026-03-01T00:00:00Z'),
|
||||
})
|
||||
);
|
||||
expect(result).toBe(ended);
|
||||
expect(result).toEqual(new Date('2026-03-01T00:00:00Z'));
|
||||
});
|
||||
|
||||
it('falls back to stripeCurrentPeriodEnd when billing has not been marked ended', () => {
|
||||
it('keeps a longer trial when billing has not been marked ended', () => {
|
||||
const periodEnd = new Date('2026-02-01T00:00:00Z');
|
||||
const result = getBillingAccessEndDate(
|
||||
subject({
|
||||
@@ -413,7 +471,7 @@ describe('getBillingAccessEndDate', () => {
|
||||
trialEndsAt: new Date('2026-03-01T00:00:00Z'),
|
||||
})
|
||||
);
|
||||
expect(result).toBe(periodEnd);
|
||||
expect(result).toEqual(new Date('2026-03-01T00:00:00Z'));
|
||||
});
|
||||
|
||||
it('falls back to trialEndsAt when there is no paid period', () => {
|
||||
@@ -452,7 +510,14 @@ describe('buildBillingAccessWhereInput', () => {
|
||||
OR: [
|
||||
{ subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
{ trialEndsAt: { gt: NOW } },
|
||||
{ stripeCurrentPeriodEnd: { gt: NOW } },
|
||||
// Both guards sit inside this arm, mirroring `hasBillingAccess`: the period end
|
||||
// is only evidence of access when a payment stands behind it and no cutoff has
|
||||
// passed. Scoped to this arm, not the whole query, so a live trial still wins.
|
||||
{
|
||||
stripeCurrentPeriodEnd: { gt: NOW },
|
||||
subscriptionStatus: { notIn: ['INCOMPLETE', 'INCOMPLETE_EXPIRED'] },
|
||||
OR: [{ billingAccessEndedAt: null }, { billingAccessEndedAt: { gt: NOW } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -472,36 +537,18 @@ describe('buildBillingAccessWhereInput', () => {
|
||||
});
|
||||
|
||||
describe('buildExpiredBillingWhereInput', () => {
|
||||
it('states the lack of access positively and requires the fifteen day grace to have elapsed', () => {
|
||||
const cutoff = new Date('2025-12-31T00:00:00.000Z');
|
||||
|
||||
expect(buildExpiredBillingWhereInput(NOW)).toEqual({
|
||||
AND: [
|
||||
{ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } },
|
||||
{ OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: NOW } }] },
|
||||
{ OR: [{ stripeCurrentPeriodEnd: null }, { stripeCurrentPeriodEnd: { lte: NOW } }] },
|
||||
{
|
||||
OR: [
|
||||
{ billingAccessEndedAt: { lte: cutoff } },
|
||||
{ AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cutoff } }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
it('requires the entire trial retention window before deleting an inactive account', () => {
|
||||
const where = buildExpiredBillingWhereInput(NOW) as {
|
||||
AND: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(where.AND[0]).toEqual({ subscriptionStatus: { notIn: ['ACTIVE', 'TRIALING'] } });
|
||||
expect(where.AND[1]).toEqual({
|
||||
OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: new Date('2025-12-31T00:00:00Z') } }],
|
||||
});
|
||||
});
|
||||
|
||||
// The NOT form this replaced could not express "no access" for a row whose date columns are
|
||||
// empty, because SQL turns a comparison against NULL into unknown rather than false. Every
|
||||
// branch has to name NULL explicitly instead. tests/api/expired-billing-cleanup.test.ts
|
||||
// proves it against a real database; this only guards the shape.
|
||||
it('admits a null trial and a null period end as expired rather than skipping the row', () => {
|
||||
const where = buildExpiredBillingWhereInput(NOW) as {
|
||||
AND: Array<{ OR?: Array<Record<string, unknown>> }>;
|
||||
};
|
||||
expect(where.AND[1].OR).toContainEqual({ trialEndsAt: null });
|
||||
expect(where.AND[2].OR).toContainEqual({ stripeCurrentPeriodEnd: null });
|
||||
});
|
||||
|
||||
// Real SQL behavior with null dates and future unpaid periods is covered by the
|
||||
// API cleanup and entitlement suites; the unit check guards the retention boundary.
|
||||
it('matches nobody when Stripe is disabled, because nothing can expire without billing', () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||
expect(buildExpiredBillingWhereInput(NOW)).toEqual({ id: { in: [] } });
|
||||
@@ -808,12 +855,98 @@ function updateData(): Record<string, unknown> {
|
||||
return dbMock.user.update.mock.calls[0][0].data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// The shape Stripe actually sends on the pinned API version: the period lives on the
|
||||
// subscription's items, not on the subscription. `stripeSub` above still uses the older
|
||||
// top-level shape, so without these the whole reason this code exists goes untested and
|
||||
// every other test in this file passes through the legacy fallback instead.
|
||||
describe('Stripe field locations', () => {
|
||||
const periodStart = 1_800_000_000;
|
||||
const periodEnd = periodStart + 30 * 86_400;
|
||||
|
||||
function itemPeriodSub(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'sub_1',
|
||||
customer: 'cus_1',
|
||||
status: 'past_due',
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: ENTITLED_PRICE },
|
||||
current_period_start: periodStart,
|
||||
current_period_end: periodEnd,
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as Stripe.Subscription;
|
||||
}
|
||||
|
||||
it('reads the period off the subscription items', () => {
|
||||
expect(getSubscriptionPeriodEnd(itemPeriodSub())).toBe(periodEnd);
|
||||
expect(getSubscriptionPeriodStart(itemPeriodSub())).toBe(periodStart);
|
||||
});
|
||||
|
||||
// A webhook body can still be rendered at the version that was current when the
|
||||
// endpoint was created, so the old location has to keep working.
|
||||
it('falls back to the legacy top-level period', () => {
|
||||
const legacy = {
|
||||
items: { data: [{ price: { id: ENTITLED_PRICE } }] },
|
||||
current_period_start: periodStart,
|
||||
current_period_end: periodEnd,
|
||||
} as unknown as Stripe.Subscription;
|
||||
|
||||
expect(getSubscriptionPeriodEnd(legacy)).toBe(periodEnd);
|
||||
expect(getSubscriptionPeriodStart(legacy)).toBe(periodStart);
|
||||
});
|
||||
|
||||
it('returns null when neither location carries a period', () => {
|
||||
const bare = {
|
||||
items: { data: [{ price: { id: ENTITLED_PRICE } }] },
|
||||
} as unknown as Stripe.Subscription;
|
||||
|
||||
expect(getSubscriptionPeriodEnd(bare)).toBeNull();
|
||||
expect(getSubscriptionPeriodStart(bare)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the invoice subscription off parent.subscription_details', () => {
|
||||
const invoice = {
|
||||
parent: { subscription_details: { subscription: 'sub_9' } },
|
||||
} as unknown as Stripe.Invoice;
|
||||
|
||||
expect(getInvoiceSubscriptionId(invoice)).toBe('sub_9');
|
||||
});
|
||||
|
||||
it('accepts an expanded subscription object on the invoice parent', () => {
|
||||
const invoice = {
|
||||
parent: { subscription_details: { subscription: { id: 'sub_9' } } },
|
||||
} as unknown as Stripe.Invoice;
|
||||
|
||||
expect(getInvoiceSubscriptionId(invoice)).toBe('sub_9');
|
||||
});
|
||||
|
||||
it('falls back to the legacy top-level invoice subscription', () => {
|
||||
expect(getInvoiceSubscriptionId({ subscription: 'sub_9' } as unknown as Stripe.Invoice)).toBe(
|
||||
'sub_9'
|
||||
);
|
||||
});
|
||||
|
||||
// A one-off invoice belongs to no subscription, and the webhook relies on this to leave
|
||||
// the account alone rather than marking it canceled.
|
||||
it('returns null for an invoice with no subscription', () => {
|
||||
expect(getInvoiceSubscriptionId({} as unknown as Stripe.Invoice)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('database backed billing helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
|
||||
vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE);
|
||||
dbMock.$transaction
|
||||
.mockReset()
|
||||
.mockImplementation(async (work: (tx: typeof dbMock) => Promise<unknown>) => work(dbMock));
|
||||
dbMock.$executeRaw.mockReset().mockResolvedValue(0);
|
||||
dbMock.user.findUnique.mockReset();
|
||||
dbMock.user.update.mockReset();
|
||||
dbMock.user.updateMany.mockReset();
|
||||
@@ -1451,31 +1584,77 @@ describe('database backed billing helpers', () => {
|
||||
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('keeps access while a canceled subscription is still inside its paid period', async () => {
|
||||
// Was asserting `billingAccessEndedAt: null` here, i.e. that a canceled subscription
|
||||
// keeps access to the reported period end. That is only right if the period was paid
|
||||
// for, and a canceled subscription cannot tell you that it was: the period Stripe
|
||||
// reports advances when the renewal invoice is issued and survives the cancellation,
|
||||
// so this is also exactly the shape of "cancelled while behind on payment". A cutoff
|
||||
// is stamped instead, and `ended_at` is what it comes from.
|
||||
it('stamps a cutoff on a canceled subscription rather than trusting its period', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
|
||||
const endedAt = Math.floor(NOW.getTime() / 1000);
|
||||
|
||||
await syncStripeSubscriptionToUser(
|
||||
stripeSub({
|
||||
status: 'canceled',
|
||||
ended_at: endedAt,
|
||||
current_period_end: Math.floor(NOW.getTime() / 1000) + 3600,
|
||||
})
|
||||
);
|
||||
|
||||
expect(updateData()).toMatchObject({
|
||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||
billingAccessEndedAt: null,
|
||||
});
|
||||
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(endedAt * 1000);
|
||||
});
|
||||
|
||||
it('ends access at the period end once the paid period has passed', async () => {
|
||||
// Behind on payment but still being retried: access runs to the end of Stripe's retry
|
||||
// window, measured from the period start, not to the period end Stripe advanced to
|
||||
// cover the invoice that was never paid.
|
||||
it('bounds a past_due subscription to the retry window', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
|
||||
const periodEnd = Math.floor(NOW.getTime() / 1000) - 3600;
|
||||
const periodStart = Math.floor(NOW.getTime() / 1000);
|
||||
|
||||
await syncStripeSubscriptionToUser(
|
||||
stripeSub({ status: 'canceled', current_period_end: periodEnd })
|
||||
stripeSub({
|
||||
status: 'past_due',
|
||||
current_period_start: periodStart,
|
||||
current_period_end: periodStart + 30 * 24 * 60 * 60,
|
||||
})
|
||||
);
|
||||
|
||||
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(periodEnd * 1000);
|
||||
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(
|
||||
(periodStart + 14 * 24 * 60 * 60) * 1000
|
||||
);
|
||||
});
|
||||
|
||||
// The same thing through the payload shape production actually sends, where the period
|
||||
// sits on the items rather than on the subscription. Every other fixture in this file
|
||||
// uses the older top-level shape and so never exercises the read this change is for.
|
||||
it('bounds a past_due subscription whose period is on its items', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
|
||||
const periodStart = Math.floor(NOW.getTime() / 1000);
|
||||
const periodEnd = periodStart + 30 * 24 * 60 * 60;
|
||||
|
||||
await syncStripeSubscriptionToUser({
|
||||
id: 'sub_1',
|
||||
customer: 'cus_1',
|
||||
status: 'past_due',
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: ENTITLED_PRICE },
|
||||
current_period_start: periodStart,
|
||||
current_period_end: periodEnd,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Stripe.Subscription);
|
||||
|
||||
expect((updateData().stripeCurrentPeriodEnd as Date).getTime()).toBe(periodEnd * 1000);
|
||||
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(
|
||||
(periodStart + 14 * 24 * 60 * 60) * 1000
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to ended_at when there is no period end', async () => {
|
||||
@@ -1627,10 +1806,13 @@ describe('database backed billing helpers', () => {
|
||||
stripeSub({ status: 'incomplete', current_period_end: null })
|
||||
);
|
||||
|
||||
expect(updateData().billingAccessEndedAt).toBeNull();
|
||||
expect(updateData().billingAccessEndedAt).toEqual(NOW);
|
||||
expect(getStorageCleanupEligibleAt(subject(updateData()))).toEqual(
|
||||
new Date(NOW.getTime() + 19 * DAY_MS)
|
||||
);
|
||||
});
|
||||
|
||||
it('clears a trial that has already run out', async () => {
|
||||
it('preserves an expired trial for the storage retention calculation', async () => {
|
||||
dbMock.user.findUnique.mockResolvedValue({
|
||||
id: 'u1',
|
||||
billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS),
|
||||
@@ -1641,7 +1823,7 @@ describe('database backed billing helpers', () => {
|
||||
stripeSub({ status: 'incomplete', current_period_end: null })
|
||||
);
|
||||
|
||||
expect(updateData().trialEndsAt).toBeNull();
|
||||
expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS));
|
||||
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
@@ -1705,7 +1887,8 @@ describe('database backed billing helpers', () => {
|
||||
await markSubscriptionCanceledByCustomerId('cus_1');
|
||||
|
||||
expect(updateData().trialEndsAt).toBe(trialEndsAt);
|
||||
expect(updateData().billingAccessEndedAt).toBeNull();
|
||||
expect(updateData().billingAccessEndedAt).toEqual(NOW);
|
||||
expect(hasBillingAccess(subject(updateData()), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('still ends access when the trial has already run out', async () => {
|
||||
@@ -1716,7 +1899,7 @@ describe('database backed billing helpers', () => {
|
||||
|
||||
await markSubscriptionCanceledByCustomerId('cus_1');
|
||||
|
||||
expect(updateData().trialEndsAt).toBeNull();
|
||||
expect(updateData().trialEndsAt).toEqual(new Date(NOW.getTime() - DAY_MS));
|
||||
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user