diff --git a/apps/readest-app/src/__tests__/app/api/stripe-webhook-cancel.test.ts b/apps/readest-app/src/__tests__/app/api/stripe-webhook-cancel.test.ts new file mode 100644 index 00000000..c876c08d --- /dev/null +++ b/apps/readest-app/src/__tests__/app/api/stripe-webhook-cancel.test.ts @@ -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>, + 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) => ({ + 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[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' }); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/payment/stripe-server.live.test.ts b/apps/readest-app/src/__tests__/libs/payment/stripe-server.live.test.ts new file mode 100644 index 00000000..544e0c3d --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/payment/stripe-server.live.test.ts @@ -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); + } + }, + ); +}); diff --git a/apps/readest-app/src/__tests__/libs/payment/stripe-server.test.ts b/apps/readest-app/src/__tests__/libs/payment/stripe-server.test.ts new file mode 100644 index 00000000..eedb3f2f --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/payment/stripe-server.test.ts @@ -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>, +})); + +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) => ({ + 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'); + }); +}); diff --git a/apps/readest-app/src/app/api/stripe/webhook/route.ts b/apps/readest-app/src/app/api/stripe/webhook/route.ts index 21aa4995..787eba8f 100644 --- a/apps/readest-app/src/app/api/stripe/webhook/route.ts +++ b/apps/readest-app/src/app/api/stripe/webhook/route.ts @@ -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); } diff --git a/apps/readest-app/src/libs/payment/stripe/server.ts b/apps/readest-app/src/libs/payment/stripe/server.ts index cd4145f0..f024675a 100644 --- a/apps/readest-app/src/libs/payment/stripe/server.ts +++ b/apps/readest-app/src/libs/payment/stripe/server.ts @@ -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 = { + 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 => { + 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( + (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);