mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
fix(billing): prevent duplicate subscriptions and make webhook sync authoritative
A Stripe customer can own several subscriptions. Two defects let that happen
and corrupt the user's billing state:
1. Checkout allowed a fresh subscription whenever the user was not ACTIVE/
TRIALING, so a PAST_DUE user started a brand-new subscription (Stripe
Checkout always creates one) instead of recovering the existing one.
Add hasRecoverableSubscription() (ACTIVE/TRIALING/PAST_DUE/UNPAID/
INCOMPLETE); block checkout and route these users to the billing portal
('Update Payment Method') both in the API guard and the settings UI.
2. Subscription webhooks trusted the event's single subscription, so an old
subscription's deletion could clobber a newer active one (marking the user
CANCELED / No access). Every subscription event now re-derives state from
the full set of the customer's Stripe subscriptions via
syncStripeCustomerSubscriptions() + selectAuthoritativeSubscription(),
making the sync order-independent and self-healing.
This commit is contained in:
@@ -53,6 +53,7 @@ interface BillingOverview {
|
||||
status: string;
|
||||
label: string;
|
||||
hasActiveSubscription: boolean;
|
||||
hasRecoverableSubscription: boolean;
|
||||
hasActiveTrial: boolean;
|
||||
hasBillingAccess: boolean;
|
||||
isTrialEligible: boolean;
|
||||
@@ -400,6 +401,14 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{billing.subscription.hasRecoverableSubscription &&
|
||||
!billing.subscription.hasActiveSubscription ? (
|
||||
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
||||
Your latest payment didn't go through. Update your payment method to keep
|
||||
your subscription — starting a new one would create a duplicate.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{billing.subscription.hasActiveTrial &&
|
||||
billing.subscription.trialEndsAt &&
|
||||
hasScheduledCancellation ? (
|
||||
@@ -444,7 +453,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{billing.subscription.hasActiveSubscription && billing.portalAvailable ? (
|
||||
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (
|
||||
<Button
|
||||
onClick={() => handleBillingRedirect('/api/billing/portal')}
|
||||
disabled={billingAction !== null}
|
||||
@@ -454,8 +463,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Opening Portal...
|
||||
</>
|
||||
) : (
|
||||
) : billing.subscription.hasActiveSubscription ? (
|
||||
'Manage Subscription'
|
||||
) : (
|
||||
'Update Payment Method'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user