forked from akai/readest
9e163fe746
* 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>
212 lines
6.0 KiB
TypeScript
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);
|
|
}
|
|
}
|