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:
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// When a subscription is cancelled, the user may still hold other active
|
||||
// subscriptions (e.g. cancelling the leftover Plus subscription after an
|
||||
// upgrade to Pro). The webhook used to drop the account to `free`
|
||||
// unconditionally. It must instead reflect the highest plan that remains
|
||||
// active.
|
||||
|
||||
const hooks = vi.hoisted(() => ({
|
||||
constructEvent: vi.fn(),
|
||||
getHighestActivePlan: vi.fn(),
|
||||
planUpdates: [] as Array<Record<string, unknown>>,
|
||||
subscriptionData: { user_id: 'user-1', stripe_customer_id: 'cus_1' } as unknown,
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/payment/stripe/server', () => ({
|
||||
getStripe: () => ({ webhooks: { constructEvent: hooks.constructEvent } }),
|
||||
createOrUpdateSubscription: vi.fn(),
|
||||
createOrUpdatePayment: vi.fn(),
|
||||
getHighestActivePlan: (...args: unknown[]) => hooks.getHighestActivePlan(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/supabase', () => ({
|
||||
createSupabaseAdminClient: () => ({
|
||||
from: (table: string) => {
|
||||
if (table === 'plans') {
|
||||
return {
|
||||
update: (values: Record<string, unknown>) => ({
|
||||
eq: () => {
|
||||
hooks.planUpdates.push(values);
|
||||
return Promise.resolve({ data: null, error: null });
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === 'subscriptions') {
|
||||
return {
|
||||
update: () => ({ eq: () => Promise.resolve({ data: null, error: null }) }),
|
||||
select: () => ({
|
||||
eq: () => ({ single: () => Promise.resolve({ data: hooks.subscriptionData }) }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table: ${table}`);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { POST } from '@/app/api/stripe/webhook/route';
|
||||
|
||||
const makeReq = () =>
|
||||
new Request('https://web.readest.com/api/stripe/webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'stripe-signature': 'sig', 'content-type': 'application/json' },
|
||||
body: '{}',
|
||||
}) as unknown as Parameters<typeof POST>[0];
|
||||
|
||||
beforeEach(() => {
|
||||
hooks.constructEvent.mockReset();
|
||||
hooks.getHighestActivePlan.mockReset();
|
||||
hooks.planUpdates = [];
|
||||
hooks.subscriptionData = { user_id: 'user-1', stripe_customer_id: 'cus_1' };
|
||||
process.env['STRIPE_WEBHOOK_SECRET'] = 'whsec_dummy';
|
||||
hooks.constructEvent.mockReturnValue({
|
||||
type: 'customer.subscription.deleted',
|
||||
data: { object: { id: 'sub_plus', customer: 'cus_1' } },
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stripe/webhook — subscription cancelled', () => {
|
||||
it('keeps the highest remaining active plan when another subscription is still active', async () => {
|
||||
hooks.getHighestActivePlan.mockResolvedValue('pro');
|
||||
|
||||
const res = await POST(makeReq());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(hooks.getHighestActivePlan).toHaveBeenCalledWith(expect.anything(), 'cus_1');
|
||||
expect(hooks.planUpdates.at(-1)).toEqual({ plan: 'pro', status: 'active' });
|
||||
});
|
||||
|
||||
it('drops to free + cancelled when no subscription remains active', async () => {
|
||||
hooks.getHighestActivePlan.mockResolvedValue('free');
|
||||
|
||||
const res = await POST(makeReq());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(hooks.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'cancelled' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
// Live integration test for `getHighestActivePlan` against the real Stripe API.
|
||||
//
|
||||
// Skipped by default (including in CI) — it only runs when you explicitly opt
|
||||
// in by providing a live key and a target customer id. To run it locally:
|
||||
//
|
||||
// STRIPE_SECRET_KEY=sk_live_xxx \
|
||||
// STRIPE_TEST_CUSTOMER_ID=cus_xxx \
|
||||
// STRIPE_TEST_EXPECTED_PLAN=pro \
|
||||
// pnpm test src/__tests__/libs/payment/stripe-server.live.test.ts
|
||||
//
|
||||
// Point STRIPE_TEST_CUSTOMER_ID at a customer that holds overlapping active
|
||||
// subscriptions (e.g. Plus + Pro after an upgrade) to verify the helper
|
||||
// resolves to the highest plan. STRIPE_TEST_EXPECTED_PLAN is optional; when
|
||||
// omitted the test just asserts a valid plan and logs the resolved value.
|
||||
//
|
||||
// The module under test is imported dynamically inside the test body so that
|
||||
// when the test is skipped the file stays import-safe (it pulls in Supabase,
|
||||
// whose top-level setup needs env that CI does not provide for this lane).
|
||||
|
||||
const stripeKey = process.env['STRIPE_SECRET_KEY'] || process.env['STRIPE_SECRET_KEY_DEV'];
|
||||
const customerId = process.env['STRIPE_TEST_CUSTOMER_ID'];
|
||||
const expectedPlan = process.env['STRIPE_TEST_EXPECTED_PLAN'];
|
||||
|
||||
describe('getHighestActivePlan (live Stripe)', () => {
|
||||
it.skipIf(!stripeKey || !customerId)(
|
||||
'resolves the highest active plan for a real customer',
|
||||
async () => {
|
||||
const { getHighestActivePlan } = await import('@/libs/payment/stripe/server');
|
||||
|
||||
const stripe = new Stripe(stripeKey!, {
|
||||
httpClient: Stripe.createFetchHttpClient(),
|
||||
});
|
||||
const plan = await getHighestActivePlan(stripe, customerId!);
|
||||
console.info(`[live] highest active plan for ${customerId}: ${plan}`);
|
||||
|
||||
if (expectedPlan) {
|
||||
expect(plan).toBe(expectedPlan);
|
||||
} else {
|
||||
expect(['free', 'plus', 'pro', 'purchase']).toContain(plan);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// When a user upgrades Plus -> Pro on Stripe, both subscriptions stay active
|
||||
// for a while (the old Plus one is not cancelled immediately). Each webhook
|
||||
// calls `createOrUpdateSubscription`, which used to overwrite `plans.plan` with
|
||||
// the plan of whichever subscription's event fired last. If the Plus event was
|
||||
// processed after the Pro event, the user was downgraded to `plus`.
|
||||
//
|
||||
// The plan written to the `plans` table must reflect the HIGHEST active plan
|
||||
// the user holds, regardless of webhook ordering.
|
||||
|
||||
const stripeMocks = vi.hoisted(() => ({
|
||||
subscriptionsRetrieve: vi.fn(),
|
||||
subscriptionsList: vi.fn(),
|
||||
}));
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
existingSubscription: null as unknown,
|
||||
planUpdates: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock('stripe', () => {
|
||||
function MockStripe() {
|
||||
return {
|
||||
subscriptions: {
|
||||
retrieve: stripeMocks.subscriptionsRetrieve,
|
||||
list: stripeMocks.subscriptionsList,
|
||||
},
|
||||
};
|
||||
}
|
||||
MockStripe.createFetchHttpClient = () => ({});
|
||||
return { default: MockStripe };
|
||||
});
|
||||
|
||||
vi.mock('@/utils/supabase', () => ({
|
||||
createSupabaseAdminClient: () => ({
|
||||
from: (table: string) => {
|
||||
if (table === 'plans') {
|
||||
return {
|
||||
update: (values: Record<string, unknown>) => ({
|
||||
eq: () => {
|
||||
db.planUpdates.push(values);
|
||||
return Promise.resolve({ data: null, error: null });
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === 'subscriptions') {
|
||||
return {
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
single: () => Promise.resolve({ data: db.existingSubscription }),
|
||||
}),
|
||||
}),
|
||||
update: () => ({ eq: () => Promise.resolve({ data: null, error: null }) }),
|
||||
insert: () => Promise.resolve({ data: null, error: null }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table: ${table}`);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
createOrUpdateSubscription,
|
||||
getHighestActivePlan,
|
||||
getStripe,
|
||||
} from '@/libs/payment/stripe/server';
|
||||
|
||||
const makeSub = (id: string, plan: string, status = 'active') => ({
|
||||
id,
|
||||
status,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: `price_${plan}`, product: { id: `prod_${plan}`, metadata: { plan } } },
|
||||
current_period_start: 1700000000,
|
||||
current_period_end: 1702592000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
stripeMocks.subscriptionsRetrieve.mockReset();
|
||||
stripeMocks.subscriptionsList.mockReset();
|
||||
db.existingSubscription = null;
|
||||
db.planUpdates = [];
|
||||
process.env['STRIPE_SECRET_KEY_DEV'] = 'sk_test_dummy';
|
||||
});
|
||||
|
||||
describe('getHighestActivePlan', () => {
|
||||
it('returns the highest plan among multiple active subscriptions', async () => {
|
||||
stripeMocks.subscriptionsList.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'sub_plus', status: 'active' },
|
||||
{ id: 'sub_pro', status: 'active' },
|
||||
],
|
||||
});
|
||||
stripeMocks.subscriptionsRetrieve.mockImplementation((id: string) =>
|
||||
Promise.resolve(id === 'sub_pro' ? makeSub('sub_pro', 'pro') : makeSub('sub_plus', 'plus')),
|
||||
);
|
||||
|
||||
expect(await getHighestActivePlan(getStripe(), 'cus_1')).toBe('pro');
|
||||
});
|
||||
|
||||
it('ignores subscriptions that are not active or trialing', async () => {
|
||||
stripeMocks.subscriptionsList.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'sub_pro_old', status: 'canceled' },
|
||||
{ id: 'sub_plus', status: 'active' },
|
||||
{ id: 'sub_pro_pastdue', status: 'past_due' },
|
||||
],
|
||||
});
|
||||
stripeMocks.subscriptionsRetrieve.mockImplementation((id: string) =>
|
||||
Promise.resolve(makeSub(id, 'plus')),
|
||||
);
|
||||
|
||||
expect(await getHighestActivePlan(getStripe(), 'cus_1')).toBe('plus');
|
||||
// Only the single active subscription needs to be retrieved.
|
||||
expect(stripeMocks.subscriptionsRetrieve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('counts trialing subscriptions', async () => {
|
||||
stripeMocks.subscriptionsList.mockResolvedValue({
|
||||
data: [{ id: 'sub_pro', status: 'trialing' }],
|
||||
});
|
||||
stripeMocks.subscriptionsRetrieve.mockResolvedValue(makeSub('sub_pro', 'pro'));
|
||||
|
||||
expect(await getHighestActivePlan(getStripe(), 'cus_1')).toBe('pro');
|
||||
});
|
||||
|
||||
it('returns "free" when no subscription is active', async () => {
|
||||
stripeMocks.subscriptionsList.mockResolvedValue({
|
||||
data: [{ id: 'sub_plus', status: 'canceled' }],
|
||||
});
|
||||
|
||||
expect(await getHighestActivePlan(getStripe(), 'cus_1')).toBe('free');
|
||||
expect(stripeMocks.subscriptionsRetrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOrUpdateSubscription', () => {
|
||||
it('writes the highest active plan when a user holds both Plus and Pro (Plus event fires last)', async () => {
|
||||
db.existingSubscription = { id: 1 };
|
||||
// The webhook being processed is for the older Plus subscription.
|
||||
stripeMocks.subscriptionsRetrieve.mockImplementation((id: string) =>
|
||||
Promise.resolve(id === 'sub_pro' ? makeSub('sub_pro', 'pro') : makeSub('sub_plus', 'plus')),
|
||||
);
|
||||
stripeMocks.subscriptionsList.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'sub_plus', status: 'active' },
|
||||
{ id: 'sub_pro', status: 'active' },
|
||||
],
|
||||
});
|
||||
|
||||
await createOrUpdateSubscription('user-1', 'cus_1', 'sub_plus');
|
||||
|
||||
expect(db.planUpdates.at(-1)?.['plan']).toBe('pro');
|
||||
});
|
||||
|
||||
it('keeps the active higher plan even when the triggering subscription is past_due', async () => {
|
||||
db.existingSubscription = { id: 1 };
|
||||
stripeMocks.subscriptionsRetrieve.mockImplementation((id: string) =>
|
||||
Promise.resolve(
|
||||
id === 'sub_pro' ? makeSub('sub_pro', 'pro') : makeSub('sub_plus', 'plus', 'past_due'),
|
||||
),
|
||||
);
|
||||
stripeMocks.subscriptionsList.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'sub_plus', status: 'past_due' },
|
||||
{ id: 'sub_pro', status: 'active' },
|
||||
],
|
||||
});
|
||||
|
||||
await createOrUpdateSubscription('user-1', 'cus_1', 'sub_plus');
|
||||
|
||||
expect(db.planUpdates.at(-1)?.['plan']).toBe('pro');
|
||||
});
|
||||
|
||||
it('downgrades to "free" when the only subscription becomes inactive', async () => {
|
||||
db.existingSubscription = { id: 1 };
|
||||
stripeMocks.subscriptionsRetrieve.mockResolvedValue(makeSub('sub_plus', 'plus', 'canceled'));
|
||||
stripeMocks.subscriptionsList.mockResolvedValue({
|
||||
data: [{ id: 'sub_plus', status: 'canceled' }],
|
||||
});
|
||||
|
||||
await createOrUpdateSubscription('user-1', 'cus_1', 'sub_plus');
|
||||
|
||||
expect(db.planUpdates.at(-1)?.['plan']).toBe('free');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,55 @@ export const getStripe = () => {
|
||||
return stripe;
|
||||
};
|
||||
|
||||
// A user can hold several subscriptions at once (e.g. right after upgrading
|
||||
// Plus -> Pro, before the old Plus subscription is cancelled). Rank the plans
|
||||
// so we can always reflect the highest one on the account.
|
||||
const PLAN_RANK: Record<UserPlan, number> = {
|
||||
free: 0,
|
||||
purchase: 0,
|
||||
plus: 1,
|
||||
pro: 2,
|
||||
};
|
||||
|
||||
const getSubscriptionPlan = (subscription: Stripe.Subscription): UserPlan => {
|
||||
const product = subscription.items.data[0]?.price.product as
|
||||
| (Stripe.Product & { metadata: StripeProductMetadata })
|
||||
| undefined;
|
||||
return product?.metadata?.plan || 'free';
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the highest plan among a customer's currently active (or trialing)
|
||||
* Stripe subscriptions. Returns `'free'` when none are active. This keeps the
|
||||
* `plans` table correct regardless of the order in which subscription webhooks
|
||||
* arrive when multiple subscriptions overlap.
|
||||
*/
|
||||
export const getHighestActivePlan = async (
|
||||
stripe: Stripe,
|
||||
customerId: string,
|
||||
): Promise<UserPlan> => {
|
||||
const { data: subscriptions } = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100,
|
||||
});
|
||||
const activeSubscriptions = subscriptions.filter((sub) =>
|
||||
['active', 'trialing'].includes(sub.status),
|
||||
);
|
||||
const plans = await Promise.all(
|
||||
activeSubscriptions.map(async (sub) => {
|
||||
const detailed = await stripe.subscriptions.retrieve(sub.id, {
|
||||
expand: ['items.data.price.product'],
|
||||
});
|
||||
return getSubscriptionPlan(detailed);
|
||||
}),
|
||||
);
|
||||
return plans.reduce<UserPlan>(
|
||||
(highest, plan) => (PLAN_RANK[plan] > PLAN_RANK[highest] ? plan : highest),
|
||||
'free',
|
||||
);
|
||||
};
|
||||
|
||||
export const createOrUpdateSubscription = async (
|
||||
userId: string,
|
||||
customerId: string,
|
||||
@@ -27,15 +76,9 @@ export const createOrUpdateSubscription = async (
|
||||
const stripe = getStripe();
|
||||
const supabase = createSupabaseAdminClient();
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ['items.data.price.product'],
|
||||
});
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const subscriptionItem = subscription.items.data[0]!;
|
||||
const priceId = subscriptionItem.price.id;
|
||||
const product = subscriptionItem.price.product as Stripe.Product & {
|
||||
metadata: StripeProductMetadata;
|
||||
};
|
||||
const plan = product.metadata?.plan || 'free';
|
||||
|
||||
try {
|
||||
const { data: existingSubscription } = await supabase
|
||||
@@ -71,10 +114,11 @@ export const createOrUpdateSubscription = async (
|
||||
console.error('Error checking existing subscription:', error);
|
||||
}
|
||||
|
||||
const plan = await getHighestActivePlan(stripe, customerId);
|
||||
await supabase
|
||||
.from('plans')
|
||||
.update({
|
||||
plan: ['active', 'trialing'].includes(subscription.status) ? plan : 'free',
|
||||
plan,
|
||||
status: subscription.status,
|
||||
})
|
||||
.eq('id', userId);
|
||||
|
||||
Reference in New Issue
Block a user