mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Merge pull request #78 from yusufipk/claude/lifecycle-messages-cancellation-a0142a
feat(billing): cancel in-app with a one-question reason
This commit is contained in:
@@ -30,6 +30,8 @@ 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';
|
||||
|
||||
interface NotificationSettings {
|
||||
telegramChatId: string | null;
|
||||
@@ -148,7 +150,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);
|
||||
@@ -302,6 +307,47 @@ 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',
|
||||
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">
|
||||
@@ -498,7 +544,24 @@ 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.subscription.hasActiveSubscription &&
|
||||
billing.portalAvailable &&
|
||||
!hasScheduledCancellation ? (
|
||||
<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 +597,16 @@ 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'}
|
||||
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,109 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import {
|
||||
CANCELLATION_NOTE_MAX_LENGTH,
|
||||
cancelSubscriptionAtPeriodEnd,
|
||||
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 the subscription at the close of the current
|
||||
* period and keep the one answer the customer gave about why.
|
||||
*
|
||||
* 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 cancelSubscriptionAtPeriodEnd({
|
||||
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: true,
|
||||
periodEnd: result.periodEnd?.toISOString() ?? null,
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('billing.cancel', error);
|
||||
return apiErrors.internalError('Failed to cancel subscription');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { format } from 'date-fns';
|
||||
import { UserX } from 'lucide-react';
|
||||
import { db } from '@/lib/db';
|
||||
import { CANCELLATION_REASONS, getCancellationReasonLabel } from '@/lib/cancellation-reasons';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
const RECENT_LIMIT = 15;
|
||||
|
||||
/**
|
||||
* The answers to the one question asked on the way out, newest first, with an
|
||||
* all-time tally per answer above them.
|
||||
*
|
||||
* Only in-app cancellations appear here. Someone who cancels inside the Stripe
|
||||
* portal, or whose card simply stops working, never sees the question, so the
|
||||
* tally undercounts churn and says nothing about the accounts that pay and go
|
||||
* silent. Read it as "what people said", not "why people leave".
|
||||
*/
|
||||
export async function CancellationReasonsCard() {
|
||||
const [recent, tally] = await Promise.all([
|
||||
db.subscriptionCancellation.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: RECENT_LIMIT,
|
||||
select: {
|
||||
id: true,
|
||||
reason: true,
|
||||
note: true,
|
||||
periodEnd: true,
|
||||
createdAt: true,
|
||||
user: { select: { name: true, email: true } },
|
||||
},
|
||||
}),
|
||||
db.subscriptionCancellation.groupBy({
|
||||
by: ['reason'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const countByReason = new Map(tally.map((row) => [row.reason, row._count._all]));
|
||||
const total = tally.reduce((sum, row) => sum + row._count._all, 0);
|
||||
const skipped = countByReason.get(null) ?? 0;
|
||||
|
||||
return (
|
||||
<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 ? ` · access until ${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,164 @@
|
||||
'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;
|
||||
/** 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,
|
||||
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>
|
||||
{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}
|
||||
|
||||
+21
-4
@@ -746,6 +746,26 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId:
|
||||
return subscription.items.data.some((item) => item.price.id === configuredPriceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the current billing period ends, as a Unix timestamp, or null.
|
||||
*
|
||||
* The API version this client pins (2026-02-25) reports the period on each
|
||||
* subscription item rather than on the subscription itself, and every item of
|
||||
* a single-price subscription carries the same dates. The top-level field is
|
||||
* still read afterwards so an older fixture or a replayed event body from a
|
||||
* previous version keeps working.
|
||||
*/
|
||||
export function getSubscriptionPeriodEnd(subscription: Stripe.Subscription): number | null {
|
||||
const fromItem = subscription.items?.data?.[0]?.current_period_end;
|
||||
if (typeof fromItem === 'number') {
|
||||
return fromItem;
|
||||
}
|
||||
|
||||
return 'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
|
||||
? subscription.current_period_end
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
@@ -768,10 +788,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
|
||||
|
||||
@@ -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,136 @@
|
||||
import type Stripe from 'stripe';
|
||||
import type { CancellationReason } from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
import { getStripe } from '@/lib/stripe';
|
||||
import {
|
||||
getSubscriptionPeriodEnd,
|
||||
hasActiveSubscription,
|
||||
syncStripeSubscriptionToUser,
|
||||
} from '@/lib/billing';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
export {
|
||||
CANCELLATION_NOTE_MAX_LENGTH,
|
||||
CANCELLATION_REASONS,
|
||||
getCancellationReasonLabel,
|
||||
isCancellationReason,
|
||||
} from '@/lib/cancellation-reasons';
|
||||
|
||||
// Stripe keeps its own fixed list of cancellation feedback values. Mirroring
|
||||
// ours onto it costs nothing and puts the category next to the subscription in
|
||||
// the Stripe dashboard, where it is read during a refund or a support reply.
|
||||
// The free-text note deliberately stays on our side: the dialog does not say
|
||||
// the text leaves the product, so it does not.
|
||||
const STRIPE_FEEDBACK: Record<
|
||||
CancellationReason,
|
||||
Stripe.SubscriptionUpdateParams.CancellationDetails.Feedback
|
||||
> = {
|
||||
NOT_USING: 'unused',
|
||||
MISSING_FEATURE: 'missing_features',
|
||||
PRICE_OR_BILLING: 'too_expensive',
|
||||
PROJECT_ENDED: 'other',
|
||||
OTHER: 'other',
|
||||
};
|
||||
|
||||
export type CancelSubscriptionResult =
|
||||
| { ok: true; periodEnd: Date | null }
|
||||
| { 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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the account's subscription to end at the close of the current
|
||||
* billing period and records why.
|
||||
*
|
||||
* The order of the writes is deliberate. The local flag is claimed first with
|
||||
* a conditional update, so two requests racing for the same subscription (a
|
||||
* double click, a retried request) cannot both reach Stripe and both write a
|
||||
* reason row: the second one loses the claim and gets `ALREADY_CANCELING`.
|
||||
* Stripe goes second because it is the only step that can refuse, and a
|
||||
* refusal hands the claim back. The reason row goes third, straight after
|
||||
* Stripe accepts, so it exists even if the sync below throws. The sync goes
|
||||
* last and is best effort: the webhook for the same update is already on its
|
||||
* way and will write the identical state, so a failure here only delays what
|
||||
* the settings page shows, it never loses the cancellation.
|
||||
*/
|
||||
export async function cancelSubscriptionAtPeriodEnd(params: {
|
||||
userId: string;
|
||||
reason: CancellationReason | null;
|
||||
note: string | null;
|
||||
}): Promise<CancelSubscriptionResult> {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: params.userId },
|
||||
select: {
|
||||
subscriptionStatus: true,
|
||||
stripeSubscriptionId: true,
|
||||
stripeCancelAtPeriodEnd: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.stripeSubscriptionId || !hasActiveSubscription(user.subscriptionStatus)) {
|
||||
return { ok: false, code: 'NO_SUBSCRIPTION' };
|
||||
}
|
||||
|
||||
const subscriptionId = user.stripeSubscriptionId;
|
||||
const claimed = await db.user.updateMany({
|
||||
where: {
|
||||
id: params.userId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
stripeCancelAtPeriodEnd: false,
|
||||
},
|
||||
data: { stripeCancelAtPeriodEnd: true },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
return { ok: false, code: 'ALREADY_CANCELING' };
|
||||
}
|
||||
|
||||
let subscription: Stripe.Subscription;
|
||||
try {
|
||||
subscription = await getStripe().subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
cancellation_details: params.reason ? { feedback: STRIPE_FEEDBACK[params.reason] } : {},
|
||||
});
|
||||
} catch (error) {
|
||||
await db.user.updateMany({
|
||||
where: { id: params.userId, stripeSubscriptionId: subscriptionId },
|
||||
data: { stripeCancelAtPeriodEnd: false },
|
||||
});
|
||||
// The subscription Stripe knows about is not the one we hold, most often
|
||||
// because it already ended there and the webhook has not caught up. That
|
||||
// is the customer's state, not a server fault, and the portal can show it.
|
||||
if (isStripeInvalidRequest(error)) {
|
||||
logError('billing.cancel.rejected', error);
|
||||
return { ok: false, code: 'STRIPE_REJECTED' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const periodEndUnix = getSubscriptionPeriodEnd(subscription);
|
||||
const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd;
|
||||
|
||||
await db.subscriptionCancellation.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
reason: params.reason,
|
||||
note: params.note,
|
||||
periodEnd,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await syncStripeSubscriptionToUser(subscription);
|
||||
} catch (error) {
|
||||
logError('billing.cancel.sync', error);
|
||||
}
|
||||
|
||||
return { ok: true, periodEnd };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,309 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { BillingSubscriptionStatus } from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
import { getStripe } from '@/lib/stripe';
|
||||
import { POST as cancelRoute } from '@/app/api/billing/cancel/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;
|
||||
|
||||
function unix(offsetSeconds: number): number {
|
||||
return Math.floor(Date.now() / 1000) + offsetSeconds;
|
||||
}
|
||||
|
||||
function cancelRequest(body?: unknown) {
|
||||
return apiRequest('/api/billing/cancel', {
|
||||
method: 'POST',
|
||||
headers: ORIGIN_HEADERS,
|
||||
body: body ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for `stripe.subscriptions.update`, echoing back the subscription
|
||||
* the way Stripe does: same id, `cancel_at_period_end` flipped, period end
|
||||
* intact. The echo matters because the route syncs that object into the user
|
||||
* row without waiting for the webhook.
|
||||
*/
|
||||
function stubStripeUpdate(
|
||||
options: { customer?: string | null; periodEnd?: number | null; status?: string } = {}
|
||||
) {
|
||||
const periodEnd = options.periodEnd === undefined ? unix(20 * DAY) : options.periodEnd;
|
||||
const update = vi.fn(async (id: string, params: Record<string, unknown>) => ({
|
||||
id,
|
||||
// The sync looks the user up by this, so a stub that names the wrong
|
||||
// customer leaves the user row untouched and the webhook to fix it later.
|
||||
customer: options.customer ?? 'cus_test_cancel',
|
||||
status: options.status ?? 'active',
|
||||
created: unix(-30 * DAY),
|
||||
cancel_at_period_end: params.cancel_at_period_end === true,
|
||||
cancel_at: null,
|
||||
trial_end: null,
|
||||
// Where the pinned API version reports the period: on the item, not on the
|
||||
// subscription. A stub that puts it at the top level hides a null date.
|
||||
items: { data: [{ price: { id: ENTITLED_PRICE_ID }, current_period_end: periodEnd }] },
|
||||
}));
|
||||
|
||||
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
|
||||
subscriptions: { update, list: vi.fn(async () => ({ data: [] })) },
|
||||
});
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
describe('POST /api/billing/cancel', () => {
|
||||
it('returns 401 without a session', async () => {
|
||||
signedOut();
|
||||
|
||||
const response = await callRoute(cancelRoute, cancelRequest());
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a cross-origin request', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
|
||||
const response = await callRoute(
|
||||
cancelRoute,
|
||||
apiRequest('/api/billing/cancel', {
|
||||
method: 'POST',
|
||||
headers: { origin: 'https://evil.test' },
|
||||
body: {},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses when there is no active subscription to cancel', async () => {
|
||||
const trialUser = await createUser();
|
||||
signedInAs(trialUser);
|
||||
const update = stubStripeUpdate();
|
||||
|
||||
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(await db.subscriptionCancellation.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a second cancellation of a subscription already set to end', async () => {
|
||||
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
|
||||
signedInAs(user);
|
||||
const update = stubStripeUpdate();
|
||||
|
||||
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unknown reason without touching Stripe', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
const update = stubStripeUpdate();
|
||||
|
||||
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'RAGE_QUIT' }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await readError(response)).toMatch(/reason/i);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a note longer than the column allows', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
const update = stubStripeUpdate();
|
||||
|
||||
const response = await callRoute(
|
||||
cancelRoute,
|
||||
cancelRequest({ reason: 'OTHER', note: 'x'.repeat(501) })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The whole point: one Stripe write with the answer attached, one row that
|
||||
// keeps the answer on our side, and the user row updated before any webhook.
|
||||
it('schedules the cancellation, records the reason and syncs the user row', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
const periodEnd = unix(12 * DAY);
|
||||
const update = stubStripeUpdate({ customer: user.stripeCustomerId, periodEnd });
|
||||
|
||||
const response = await callRoute(
|
||||
cancelRoute,
|
||||
cancelRequest({ reason: 'MISSING_FEATURE', note: ' Bulk upload for 16x9 and 9x16. ' })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await readData<{ cancelAtPeriodEnd: boolean; periodEnd: string | null }>(response);
|
||||
expect(data.cancelAtPeriodEnd).toBe(true);
|
||||
expect(data.periodEnd).toBe(new Date(periodEnd * 1000).toISOString());
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledWith(user.stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
cancellation_details: { feedback: 'missing_features' },
|
||||
});
|
||||
|
||||
const rows = await db.subscriptionCancellation.findMany({ where: { userId: user.id } });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
stripeSubscriptionId: user.stripeSubscriptionId,
|
||||
reason: 'MISSING_FEATURE',
|
||||
note: 'Bulk upload for 16x9 and 9x16.',
|
||||
});
|
||||
expect(rows[0].periodEnd?.getTime()).toBe(periodEnd * 1000);
|
||||
|
||||
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
|
||||
expect(after.stripeCancelAtPeriodEnd).toBe(true);
|
||||
expect(after.subscriptionStatus).toBe(BillingSubscriptionStatus.ACTIVE);
|
||||
expect(after.stripeCurrentPeriodEnd?.getTime()).toBe(periodEnd * 1000);
|
||||
});
|
||||
|
||||
// Skipping the question is allowed and must still cancel. The row is kept
|
||||
// with no reason so the admin tally can count how often the question is
|
||||
// skipped rather than pretending those cancellations never happened.
|
||||
it('cancels with no reason given and records the skip', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
const update = stubStripeUpdate();
|
||||
|
||||
const response = await callRoute(cancelRoute, cancelRequest({}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(update).toHaveBeenCalledWith(user.stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
cancellation_details: {},
|
||||
});
|
||||
|
||||
const row = await db.subscriptionCancellation.findFirstOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
expect(row.reason).toBeNull();
|
||||
expect(row.note).toBeNull();
|
||||
});
|
||||
|
||||
// A note under an answer that does not ask for one is still accepted by the
|
||||
// API; the dialog is what hides the box, and the route must not depend on it.
|
||||
it('keeps a note on any reason and drops an empty one', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
stubStripeUpdate();
|
||||
|
||||
const response = await callRoute(
|
||||
cancelRoute,
|
||||
cancelRequest({ reason: 'PROJECT_ENDED', note: ' ' })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const row = await db.subscriptionCancellation.findFirstOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
expect(row.reason).toBe('PROJECT_ENDED');
|
||||
expect(row.note).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a note that is not text', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
const update = stubStripeUpdate();
|
||||
|
||||
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER', note: 42 }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Two requests racing for the same subscription must produce one Stripe
|
||||
// write and one reason row, or the admin tally counts a churn twice.
|
||||
it('lets only one of two concurrent requests through', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
const update = stubStripeUpdate({ customer: user.stripeCustomerId });
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' })),
|
||||
callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' })),
|
||||
]);
|
||||
|
||||
expect([first.status, second.status].sort()).toEqual([200, 409]);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
|
||||
});
|
||||
|
||||
// The reason row and the local flag must survive a sync that blows up: the
|
||||
// webhook rewrites the same state later, the answer would be gone for good.
|
||||
it('keeps the cancellation and the reason when the local sync fails', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
|
||||
subscriptions: {
|
||||
// No `items` at all: the sync reads `items.data` and throws.
|
||||
update: vi.fn(async (id: string) => ({ id, customer: user.stripeCustomerId })),
|
||||
list: vi.fn(async () => ({ data: [] })),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'PRICE_OR_BILLING' }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const row = await db.subscriptionCancellation.findFirstOrThrow({ where: { userId: user.id } });
|
||||
expect(row.reason).toBe('PRICE_OR_BILLING');
|
||||
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
|
||||
expect(after.stripeCancelAtPeriodEnd).toBe(true);
|
||||
});
|
||||
|
||||
// A subscription Stripe no longer knows is the customer's state, not a
|
||||
// server fault: a 409 with a pointer to the portal, and the claim handed back
|
||||
// so the button still works once the webhook catches up.
|
||||
it('answers 409 and releases the claim when Stripe rejects the subscription id', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
|
||||
subscriptions: {
|
||||
update: vi.fn(async () => {
|
||||
throw Object.assign(new Error('No such subscription'), {
|
||||
type: 'StripeInvalidRequestError',
|
||||
});
|
||||
}),
|
||||
list: vi.fn(async () => ({ data: [] })),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(await readError(response)).toMatch(/Manage Subscription/);
|
||||
expect(await db.subscriptionCancellation.count()).toBe(0);
|
||||
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
|
||||
expect(after.stripeCancelAtPeriodEnd).toBe(false);
|
||||
});
|
||||
|
||||
it('does not record a reason and releases the claim when Stripe is down', async () => {
|
||||
const user = await createSubscribedUser();
|
||||
signedInAs(user);
|
||||
vi.mocked(getStripe as unknown as () => unknown).mockReturnValue({
|
||||
subscriptions: {
|
||||
update: vi.fn(async () => {
|
||||
throw new Error('No such subscription');
|
||||
}),
|
||||
list: vi.fn(async () => ({ data: [] })),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'NOT_USING' }));
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await db.subscriptionCancellation.count()).toBe(0);
|
||||
const after = await db.user.findUniqueOrThrow({ where: { id: user.id } });
|
||||
expect(after.stripeCancelAtPeriodEnd).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
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; 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}
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
+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. */
|
||||
|
||||
Reference in New Issue
Block a user