Files
readest/apps/readest-app/src/app/api/stripe/webhook/route.ts
T
Huang Xin 9e163fe746 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>
2026-06-21 07:29:39 +02:00

212 lines
6.0 KiB
TypeScript

import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
import {
getStripe,
createOrUpdateSubscription,
createOrUpdatePayment,
getHighestActivePlan,
} from '@/libs/payment/stripe/server';
import { createSupabaseAdminClient } from '@/utils/supabase';
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing Stripe signature' }, { status: 401 });
}
const stripe = getStripe();
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env['STRIPE_WEBHOOK_SECRET']!,
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`Webhook signature verification failed: ${message}`);
return NextResponse.json(
{
error: `Webhook signature verification failed: ${message}`,
},
{ status: 400 },
);
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
const userId = session.metadata?.['userId'];
if (userId) {
if (session.mode === 'subscription') {
await handleSuccessfulSubscription(session, userId);
} else {
await handleSuccessfulPayment(session, userId);
}
}
break;
case 'invoice.payment_succeeded':
await handleSuccessfulInvoice(event.data.object);
break;
case 'invoice.payment_failed':
await handleFailedInvoice(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCancelled(event.data.object);
break;
}
return NextResponse.json({ received: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Webhook error:', message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
async function handleSuccessfulPayment(session: Stripe.Checkout.Session, userId: string) {
const customerId = session.customer as string;
await createOrUpdatePayment(userId, customerId, session.id);
}
async function handleSuccessfulSubscription(session: Stripe.Checkout.Session, userId: string) {
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
await createOrUpdateSubscription(userId, customerId, subscriptionId);
}
async function handleSuccessfulInvoice(invoice: Stripe.Invoice) {
const customerId = invoice.customer;
const subscriptionId = invoice.parent?.subscription_details?.subscription;
if (!subscriptionId) {
return;
}
const supabase = createSupabaseAdminClient();
const { data: customerData } = await supabase
.from('customers')
.select('user_id')
.eq('stripe_customer_id', customerId)
.single();
if (!customerData?.user_id) {
console.error('Customer not found:', customerId);
return;
}
await supabase
.from('subscriptions')
.update({
status: 'active',
current_period_end: new Date(invoice.lines.data[0]!.period.end * 1000).toISOString(),
})
.eq('stripe_subscription_id', subscriptionId);
await supabase
.from('plans')
.update({
status: 'active',
})
.eq('user_id', customerData.user_id);
}
async function handleFailedInvoice(invoice: Stripe.Invoice) {
const customerId = invoice.customer;
const subscriptionId = invoice.parent?.subscription_details?.subscription;
if (!subscriptionId) {
return;
}
const supabase = createSupabaseAdminClient();
const { data: customerData } = await supabase
.from('customers')
.select('user_id')
.eq('stripe_customer_id', customerId)
.single();
if (!customerData?.user_id) {
console.error('Customer not found:', customerId);
return;
}
await supabase
.from('subscriptions')
.update({
status: 'past_due',
})
.eq('stripe_subscription_id', subscriptionId);
await supabase
.from('plans')
.update({
status: 'past_due',
})
.eq('id', customerData.user_id);
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const subscriptionId = subscription.id;
const supabase = createSupabaseAdminClient();
const { data: subscriptionData } = await supabase
.from('subscriptions')
.select('user_id, stripe_customer_id')
.eq('stripe_subscription_id', subscriptionId)
.single();
if (!subscriptionData) {
console.error('Subscription not found:', subscriptionId);
return;
}
const { user_id, stripe_customer_id } = subscriptionData;
await createOrUpdateSubscription(user_id, stripe_customer_id, subscriptionId);
}
async function handleSubscriptionCancelled(subscription: Stripe.Subscription) {
const subscriptionId = subscription.id;
const supabase = createSupabaseAdminClient();
await supabase
.from('subscriptions')
.update({
status: 'cancelled',
cancelled_at: new Date().toISOString(),
})
.eq('stripe_subscription_id', subscriptionId);
const { data: subscriptionData } = await supabase
.from('subscriptions')
.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,
status: plan === 'free' ? 'cancelled' : 'active',
})
.eq('id', subscriptionData.user_id);
}
}