diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 96b3744..f1e2793 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -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(null); const [billing, setBilling] = useState(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(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 (
@@ -498,7 +544,24 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo 'Update Payment Method' )} - ) : ( + ) : 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 ? ( + + ) : null} + {billing.subscription.hasRecoverableSubscription && + billing.portalAvailable ? null : ( <> {billing.workspaceCreation.canStartTrial ? (
)} + + {/* 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() && ( + + + + )} ); } diff --git a/app/api/billing/cancel/route.ts b/app/api/billing/cancel/route.ts new file mode 100644 index 0000000..5a6d913 --- /dev/null +++ b/app/api/billing/cancel/route.ts @@ -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'); + } +} diff --git a/components/admin/cancellation-reasons-card.tsx b/components/admin/cancellation-reasons-card.tsx new file mode 100644 index 0000000..7b77966 --- /dev/null +++ b/components/admin/cancellation-reasons-card.tsx @@ -0,0 +1,101 @@ +import { format } from 'date-fns'; +import { UserX } from 'lucide-react'; +import { db } from '@/lib/db'; +import { CANCELLATION_REASONS, getCancellationReasonLabel } from '@/lib/cancellation-reasons'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +const RECENT_LIMIT = 15; + +/** + * The answers to the one question asked on the way out, newest first, with an + * all-time tally per answer above them. + * + * Only in-app cancellations appear here. Someone who cancels inside the Stripe + * portal, or whose card simply stops working, never sees the question, so the + * tally undercounts churn and says nothing about the accounts that pay and go + * silent. Read it as "what people said", not "why people leave". + */ +export async function CancellationReasonsCard() { + const [recent, tally] = await Promise.all([ + db.subscriptionCancellation.findMany({ + orderBy: { createdAt: 'desc' }, + take: RECENT_LIMIT, + select: { + id: true, + reason: true, + note: true, + periodEnd: true, + createdAt: true, + user: { select: { name: true, email: true } }, + }, + }), + db.subscriptionCancellation.groupBy({ + by: ['reason'], + _count: { _all: true }, + }), + ]); + + const countByReason = new Map(tally.map((row) => [row.reason, row._count._all])); + const total = tally.reduce((sum, row) => sum + row._count._all, 0); + const skipped = countByReason.get(null) ?? 0; + + return ( + + + Why people cancelled + + + + {total === 0 ? ( +

+ No in-app cancellations yet. Cancellations made in the Stripe portal do not show up + here. +

+ ) : ( + <> +
+ {CANCELLATION_REASONS.map((entry) => ( + + {entry.label}:{' '} + + {countByReason.get(entry.value) ?? 0} + + + ))} + + Skipped the question: {skipped} + +
+
    + {recent.map((row) => ( +
  • +
    + + {row.user.name || 'Anonymous'}{' '} + + {row.user.email} + + + + {format(row.createdAt, 'MMM dd, yyyy')} + {row.periodEnd ? ` ยท access until ${format(row.periodEnd, 'MMM dd')}` : ''} + +
    +

    {getCancellationReasonLabel(row.reason)}

    + {row.note ? ( +

    {row.note}

    + ) : null} +
  • + ))} +
+ {total > RECENT_LIMIT ? ( +

+ Showing the latest {RECENT_LIMIT} of {total}. +

+ ) : null} + + )} +
+
+ ); +} diff --git a/components/settings/cancel-subscription-dialog.tsx b/components/settings/cancel-subscription-dialog.tsx new file mode 100644 index 0000000..aa4810d --- /dev/null +++ b/components/settings/cancel-subscription-dialog.tsx @@ -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; +} + +/** + * 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(null); + const [note, setNote] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const selected = CANCELLATION_REASONS.find((entry) => entry.value === reason) ?? null; + const showNote = selected?.askForDetail ?? false; + + const handleOpenChange = useCallback( + (next: boolean) => { + if (submitting) return; + if (!next) { + setReason(null); + setNote(''); + } + onOpenChange(next); + }, + [onOpenChange, submitting] + ); + + const handleConfirm = useCallback(async () => { + setSubmitting(true); + try { + const trimmed = note.trim(); + const done = await onConfirm({ + reason, + note: showNote && trimmed.length > 0 ? trimmed : null, + }); + // A failed request keeps the answer on screen. Wiping a typed note + // because Stripe timed out is the fastest way to never get it back. + if (done) { + setReason(null); + setNote(''); + } + } finally { + setSubmitting(false); + } + }, [note, onConfirm, reason, showNote]); + + const endsOn = periodEnd ? new Date(periodEnd).toLocaleDateString() : null; + + return ( + + + + Cancel your {isTrial ? 'trial' : 'subscription'}? + + {endsOn + ? `Everything stays on until ${endsOn}. Nothing is deleted before then, and you will not be charged again.` + : 'Everything stays on until the end of the current period. Nothing is deleted before then, and you will not be charged again.'} + + + +
+

+ What is the main reason?{' '} + (optional) +

+ setReason(value as CancellationReason)} + disabled={submitting} + > + {CANCELLATION_REASONS.map((entry) => ( + + ))} + + + {showNote ? ( +
+ +