fix(payment): reflect highest active plan across overlapping Stripe subscriptions (#4694)

* fix(payment): reflect highest active plan across overlapping Stripe subscriptions

When a user upgrades Plus to Pro, both subscriptions stay active until the old
one is cancelled. Each subscription webhook overwrote plans.plan with only that
event's plan, so whichever webhook arrived last won and could downgrade the
account back to plus.

Derive plans.plan from the highest active (or trialing) subscription via a new
getHighestActivePlan helper, used by createOrUpdateSubscription and by the
cancellation handler so a still-active higher plan is preserved instead of
dropping to free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(payment): add opt-in live Stripe test for getHighestActivePlan

Skipped by default (CI included); runs only when STRIPE_SECRET_KEY and
STRIPE_TEST_CUSTOMER_ID are set, so it can be exercised locally against a real
customer with overlapping subscriptions. The module under test is imported
dynamically so the file stays import-safe while skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-21 13:29:39 +08:00
committed by GitHub
parent 89f98979e1
commit 9e163fe746
5 changed files with 387 additions and 11 deletions
@@ -4,6 +4,7 @@ import {
getStripe,
createOrUpdateSubscription,
createOrUpdatePayment,
getHighestActivePlan,
} from '@/libs/payment/stripe/server';
import { createSupabaseAdminClient } from '@/utils/supabase';
@@ -190,16 +191,20 @@ async function handleSubscriptionCancelled(subscription: Stripe.Subscription) {
const { data: subscriptionData } = await supabase
.from('subscriptions')
.select('user_id')
.select('user_id, stripe_customer_id')
.eq('stripe_subscription_id', subscriptionId)
.single();
if (subscriptionData?.user_id) {
// The user may still hold other active subscriptions (e.g. cancelling the
// old Plus subscription after upgrading to Pro). Reflect the highest plan
// that remains active rather than always dropping to free.
const plan = await getHighestActivePlan(getStripe(), subscriptionData.stripe_customer_id);
await supabase
.from('plans')
.update({
plan: 'free',
status: 'cancelled',
plan,
status: plan === 'free' ? 'cancelled' : 'active',
})
.eq('id', subscriptionData.user_id);
}