feat: integrate Stripe payment with bugfixes and active timeout cancellation

- Add Stripe payment provider with Checkout Session flow
- Payment provider abstraction layer (EasyPay + Stripe unified interface)
- Stripe webhook with proper raw body handling and signature verification
- Frontend: Stripe button with URL validation, anti-duplicate click, noopener
- Active timeout cancellation: query platform before expiring, recover paid orders
- Singleton Stripe client, idempotency keys, Math.round for amounts
- Handle async_payment events, return null for unknown webhook events
- Set Checkout Session expires_at aligned with order timeout
- Add cancelPayment to provider interface (Stripe: sessions.expire, EasyPay: no-op)
- Enable stripe in frontend payment type list
This commit is contained in:
erio
2026-03-01 17:58:08 +08:00
parent 2f45044073
commit d9ab65ecf2
59 changed files with 1571 additions and 432 deletions

View File

@@ -1,22 +1,83 @@
import { prisma } from '@/lib/db';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { confirmPayment } from './service';
const INTERVAL_MS = 30_000; // 30 seconds
let timer: ReturnType<typeof setInterval> | null = null;
export async function expireOrders(): Promise<number> {
const result = await prisma.order.updateMany({
const orders = await prisma.order.findMany({
where: {
status: 'PENDING',
expiresAt: { lt: new Date() },
},
data: { status: 'EXPIRED' },
select: {
id: true,
paymentTradeNo: true,
paymentType: true,
},
});
if (result.count > 0) {
console.log(`Expired ${result.count} orders`);
if (orders.length === 0) return 0;
let expiredCount = 0;
for (const order of orders) {
try {
// If order has a payment on the platform, check its actual status
if (order.paymentTradeNo && order.paymentType) {
try {
initPaymentProviders();
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
// Query the real payment status before expiring
const queryResult = await provider.queryOrder(order.paymentTradeNo);
if (queryResult.status === 'paid') {
// User already paid — process as success instead of expiring
await confirmPayment({
orderId: order.id,
tradeNo: order.paymentTradeNo,
paidAmount: queryResult.amount,
providerName: provider.name,
});
console.log(`Order ${order.id} was paid during timeout, processed as success`);
continue;
}
// Not paid — cancel on the platform
if (provider.cancelPayment) {
try {
await provider.cancelPayment(order.paymentTradeNo);
} catch (cancelErr) {
// Cancel may fail if session already expired on platform side — that's fine
console.warn(`Failed to cancel payment for order ${order.id}:`, cancelErr);
}
}
} catch (platformErr) {
// Platform unreachable — still expire the order locally
console.warn(`Platform check failed for order ${order.id}, expiring anyway:`, platformErr);
}
}
// Mark as expired in database (WHERE status='PENDING' ensures idempotency)
const result = await prisma.order.updateMany({
where: { id: order.id, status: 'PENDING' },
data: { status: 'EXPIRED' },
});
if (result.count > 0) expiredCount++;
} catch (err) {
console.error(`Error expiring order ${order.id}:`, err);
}
}
return result.count;
if (expiredCount > 0) {
console.log(`Expired ${expiredCount} orders`);
}
return expiredCount;
}
export function startTimeoutScheduler(): void {