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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user