mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
fix(billing): preserve entitlements and bound cancellation cleanup
Integrate the latest cancellation-reason flow from master. Keep paid periods and independent trials intact, stop collection without erasing historical or mixed receivables, and make scheduled and partial cancellations recoverable. Add regression coverage for invoice boundaries, entitlement expiry, cancellation selection and concurrent reason writes.
This commit is contained in:
+584
-684
File diff suppressed because it is too large
Load Diff
@@ -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,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}
|
||||
|
||||
Reference in New Issue
Block a user