mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(billing): cancel in-app with a one-question reason
Add a "Cancel subscription" button beside "Manage Subscription" in Settings. It opens a dialog with one optional question (five answers, no default, a note box under the two that want detail), then schedules the Stripe subscription to end at the close of the current period without a trip to the portal. The answer is stored in a new subscription_cancellations table and shown, with an all-time tally, on the admin dashboard; the category is also mirrored onto Stripe's cancellation feedback, the free text stays local. The cancel route claims the local cancel flag with a conditional update before calling Stripe, so two racing requests cannot both write a reason row, and hands the claim back when Stripe refuses. A subscription Stripe no longer knows answers 409 with a pointer to the portal instead of a 500. The route carries an account-keyed rate limit on top of the shared IP one. Two fixes found on the way: the pinned Stripe API version reports current_period_end on the subscription item rather than the subscription, so the sync stored null for every period end; a shared helper now reads the item first. And the RadioGroup styles targeted a data-checked attribute radix never writes, so the checked state was invisible in the light theme.
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>
|
||||
|
||||
Reference in New Issue
Block a user