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:
@@ -12,16 +12,24 @@ const envSchema = z.object({
|
||||
SUB2API_BASE_URL: z.string().url(),
|
||||
SUB2API_ADMIN_API_KEY: z.string().min(1),
|
||||
|
||||
EASY_PAY_PID: z.string().min(1),
|
||||
EASY_PAY_PKEY: z.string().min(1),
|
||||
EASY_PAY_API_BASE: z.string().url(),
|
||||
EASY_PAY_NOTIFY_URL: z.string().url(),
|
||||
EASY_PAY_RETURN_URL: z.string().url(),
|
||||
// ── Easy-Pay (optional when only using Stripe) ──
|
||||
EASY_PAY_PID: optionalTrimmedString,
|
||||
EASY_PAY_PKEY: optionalTrimmedString,
|
||||
EASY_PAY_API_BASE: optionalTrimmedString,
|
||||
EASY_PAY_NOTIFY_URL: optionalTrimmedString,
|
||||
EASY_PAY_RETURN_URL: optionalTrimmedString,
|
||||
EASY_PAY_CID: optionalTrimmedString,
|
||||
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
|
||||
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
||||
|
||||
ENABLED_PAYMENT_TYPES: z.string().default('alipay,wxpay').transform(v => v.split(',').map(s => s.trim())),
|
||||
STRIPE_SECRET_KEY: optionalTrimmedString,
|
||||
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
|
||||
STRIPE_WEBHOOK_SECRET: optionalTrimmedString,
|
||||
|
||||
ENABLED_PAYMENT_TYPES: z
|
||||
.string()
|
||||
.default('alipay,wxpay')
|
||||
.transform((v) => v.split(',').map((s) => s.trim())),
|
||||
|
||||
ORDER_TIMEOUT_MINUTES: z.string().default('5').transform(Number).pipe(z.number().int().positive()),
|
||||
MIN_RECHARGE_AMOUNT: z.string().default('1').transform(Number).pipe(z.number().positive()),
|
||||
|
||||
@@ -28,8 +28,29 @@ function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined {
|
||||
return normalizeCidList(env.EASY_PAY_CID_WXPAY) || normalizeCidList(env.EASY_PAY_CID);
|
||||
}
|
||||
|
||||
function assertEasyPayEnv(env: ReturnType<typeof getEnv>) {
|
||||
if (
|
||||
!env.EASY_PAY_PID ||
|
||||
!env.EASY_PAY_PKEY ||
|
||||
!env.EASY_PAY_API_BASE ||
|
||||
!env.EASY_PAY_NOTIFY_URL ||
|
||||
!env.EASY_PAY_RETURN_URL
|
||||
) {
|
||||
throw new Error(
|
||||
'EasyPay environment variables (EASY_PAY_PID, EASY_PAY_PKEY, EASY_PAY_API_BASE, EASY_PAY_NOTIFY_URL, EASY_PAY_RETURN_URL) are required',
|
||||
);
|
||||
}
|
||||
return env as typeof env & {
|
||||
EASY_PAY_PID: string;
|
||||
EASY_PAY_PKEY: string;
|
||||
EASY_PAY_API_BASE: string;
|
||||
EASY_PAY_NOTIFY_URL: string;
|
||||
EASY_PAY_RETURN_URL: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPayCreateResponse> {
|
||||
const env = getEnv();
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const params: Record<string, string> = {
|
||||
pid: env.EASY_PAY_PID,
|
||||
type: opts.paymentType,
|
||||
@@ -57,7 +78,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
|
||||
const data = await response.json() as EasyPayCreateResponse;
|
||||
const data = (await response.json()) as EasyPayCreateResponse;
|
||||
if (data.code !== 1) {
|
||||
throw new Error(`EasyPay create payment failed: ${data.msg || 'unknown error'}`);
|
||||
}
|
||||
@@ -65,10 +86,10 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
||||
}
|
||||
|
||||
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
|
||||
const env = getEnv();
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as EasyPayQueryResponse;
|
||||
const data = (await response.json()) as EasyPayQueryResponse;
|
||||
if (data.code !== 1) {
|
||||
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
|
||||
}
|
||||
@@ -76,7 +97,7 @@ export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryRespon
|
||||
}
|
||||
|
||||
export async function refund(tradeNo: string, outTradeNo: string, money: string): Promise<EasyPayRefundResponse> {
|
||||
const env = getEnv();
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const params = new URLSearchParams({
|
||||
pid: env.EASY_PAY_PID,
|
||||
key: env.EASY_PAY_PKEY,
|
||||
@@ -89,7 +110,7 @@ export async function refund(tradeNo: string, outTradeNo: string, money: string)
|
||||
body: params,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
const data = await response.json() as EasyPayRefundResponse;
|
||||
const data = (await response.json()) as EasyPayRefundResponse;
|
||||
if (data.code !== 1) {
|
||||
throw new Error(`EasyPay refund failed: ${data.msg || 'unknown error'}`);
|
||||
}
|
||||
|
||||
87
src/lib/easy-pay/provider.ts
Normal file
87
src/lib/easy-pay/provider.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
PaymentProvider,
|
||||
PaymentType,
|
||||
CreatePaymentRequest,
|
||||
CreatePaymentResponse,
|
||||
QueryOrderResponse,
|
||||
PaymentNotification,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
} from '@/lib/payment/types';
|
||||
import { createPayment, queryOrder, refund } from './client';
|
||||
import { verifySign } from './sign';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
export class EasyPayProvider implements PaymentProvider {
|
||||
readonly name = 'easy-pay';
|
||||
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const result = await createPayment({
|
||||
outTradeNo: request.orderId,
|
||||
amount: request.amount.toFixed(2),
|
||||
paymentType: request.paymentType as 'alipay' | 'wxpay',
|
||||
clientIp: request.clientIp || '127.0.0.1',
|
||||
productName: request.subject,
|
||||
});
|
||||
|
||||
return {
|
||||
tradeNo: result.trade_no,
|
||||
payUrl: result.payurl,
|
||||
qrCode: result.qrcode,
|
||||
};
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const result = await queryOrder(tradeNo);
|
||||
return {
|
||||
tradeNo: result.trade_no,
|
||||
status: result.status === 1 ? 'paid' : 'pending',
|
||||
amount: parseFloat(result.money),
|
||||
paidAt: result.endtime ? new Date(result.endtime) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyNotification(rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
|
||||
const env = getEnv();
|
||||
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
|
||||
const searchParams = new URLSearchParams(body);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
const sign = params.sign || '';
|
||||
const paramsForSign: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (key !== 'sign' && key !== 'sign_type' && value !== undefined && value !== null) {
|
||||
paramsForSign[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.EASY_PAY_PKEY || !verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) {
|
||||
throw new Error('EasyPay notification signature verification failed');
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: params.trade_no || '',
|
||||
orderId: params.out_trade_no || '',
|
||||
amount: parseFloat(params.money || '0'),
|
||||
status: params.trade_status === 'TRADE_SUCCESS' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
};
|
||||
}
|
||||
|
||||
async refund(request: RefundRequest): Promise<RefundResponse> {
|
||||
await refund(request.tradeNo, request.orderId, request.amount.toFixed(2));
|
||||
return {
|
||||
refundId: `${request.tradeNo}-refund`,
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
async cancelPayment(): Promise<void> {
|
||||
// EasyPay does not support cancelling payments
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import crypto from 'crypto';
|
||||
|
||||
export function generateSign(params: Record<string, string>, pkey: string): string {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { generateRechargeCode } from './code-gen';
|
||||
import { createPayment } from '@/lib/easy-pay/client';
|
||||
import { verifySign } from '@/lib/easy-pay/sign';
|
||||
import { refund as easyPayRefund } from '@/lib/easy-pay/client';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
||||
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { EasyPayNotifyParams } from '@/lib/easy-pay/types';
|
||||
import { deriveOrderState, isRefundStatus } from './status';
|
||||
|
||||
const MAX_PENDING_ORDERS = 3;
|
||||
@@ -14,7 +12,7 @@ const MAX_PENDING_ORDERS = 3;
|
||||
export interface CreateOrderInput {
|
||||
userId: number;
|
||||
amount: number;
|
||||
paymentType: 'alipay' | 'wxpay';
|
||||
paymentType: PaymentType;
|
||||
clientIp: string;
|
||||
}
|
||||
|
||||
@@ -22,11 +20,12 @@ export interface CreateOrderResult {
|
||||
orderId: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
paymentType: 'alipay' | 'wxpay';
|
||||
paymentType: PaymentType;
|
||||
userName: string;
|
||||
userBalance: number;
|
||||
payUrl?: string | null;
|
||||
qrCode?: string | null;
|
||||
checkoutUrl?: string | null;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
@@ -67,20 +66,24 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
});
|
||||
|
||||
try {
|
||||
const easyPayResult = await createPayment({
|
||||
outTradeNo: order.id,
|
||||
amount: input.amount.toFixed(2),
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(input.paymentType);
|
||||
const paymentResult = await provider.createPayment({
|
||||
orderId: order.id,
|
||||
amount: input.amount,
|
||||
paymentType: input.paymentType,
|
||||
subject: `${env.PRODUCT_NAME} ${input.amount.toFixed(2)} CNY`,
|
||||
notifyUrl: env.EASY_PAY_NOTIFY_URL || '',
|
||||
returnUrl: env.EASY_PAY_RETURN_URL || '',
|
||||
clientIp: input.clientIp,
|
||||
productName: `${env.PRODUCT_NAME} ${input.amount.toFixed(2)} CNY`,
|
||||
});
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
paymentTradeNo: easyPayResult.trade_no,
|
||||
payUrl: easyPayResult.payurl || null,
|
||||
qrCode: easyPayResult.qrcode || null,
|
||||
paymentTradeNo: paymentResult.tradeNo,
|
||||
payUrl: paymentResult.payUrl || null,
|
||||
qrCode: paymentResult.qrCode || null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,8 +103,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
paymentType: input.paymentType,
|
||||
userName: user.username,
|
||||
userBalance: user.balance,
|
||||
payUrl: easyPayResult.payurl,
|
||||
qrCode: easyPayResult.qrcode,
|
||||
payUrl: paymentResult.payUrl,
|
||||
qrCode: paymentResult.qrCode,
|
||||
checkoutUrl: paymentResult.checkoutUrl,
|
||||
expiresAt,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -155,46 +159,41 @@ export async function adminCancelOrder(orderId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<boolean> {
|
||||
const env = getEnv();
|
||||
|
||||
const { sign, ...rest } = params;
|
||||
const paramsForSign: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
paramsForSign[key] = String(value);
|
||||
}
|
||||
}
|
||||
if (!verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) {
|
||||
console.error('EasyPay notify: invalid signature');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params.trade_status !== 'TRADE_SUCCESS') {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-agnostic: confirm a payment and trigger recharge.
|
||||
* Called by any provider's webhook/notify handler after verification.
|
||||
*/
|
||||
export async function confirmPayment(input: {
|
||||
orderId: string;
|
||||
tradeNo: string;
|
||||
paidAmount: number;
|
||||
providerName: string;
|
||||
}): Promise<boolean> {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: params.out_trade_no },
|
||||
where: { id: input.orderId },
|
||||
});
|
||||
if (!order) {
|
||||
console.error('EasyPay notify: order not found:', params.out_trade_no);
|
||||
console.error(`${input.providerName} notify: order not found:`, input.orderId);
|
||||
return false;
|
||||
}
|
||||
|
||||
let paidAmount: Prisma.Decimal;
|
||||
try {
|
||||
paidAmount = new Prisma.Decimal(params.money);
|
||||
paidAmount = new Prisma.Decimal(input.paidAmount.toFixed(2));
|
||||
} catch {
|
||||
console.error('EasyPay notify: invalid money format:', params.money);
|
||||
console.error(`${input.providerName} notify: invalid amount:`, input.paidAmount);
|
||||
return false;
|
||||
}
|
||||
if (paidAmount.lte(0)) {
|
||||
console.error('EasyPay notify: non-positive money:', params.money);
|
||||
console.error(`${input.providerName} notify: non-positive amount:`, input.paidAmount);
|
||||
return false;
|
||||
}
|
||||
if (!paidAmount.equals(order.amount)) {
|
||||
console.warn('EasyPay notify: amount changed, use paid amount', order.amount.toString(), params.money);
|
||||
console.warn(
|
||||
`${input.providerName} notify: amount changed, use paid amount`,
|
||||
order.amount.toString(),
|
||||
paidAmount.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await prisma.order.updateMany({
|
||||
@@ -205,7 +204,7 @@ export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<
|
||||
data: {
|
||||
status: 'PAID',
|
||||
amount: paidAmount,
|
||||
paymentTradeNo: params.trade_no,
|
||||
paymentTradeNo: input.tradeNo,
|
||||
paidAt: new Date(),
|
||||
failedAt: null,
|
||||
failedReason: null,
|
||||
@@ -222,25 +221,41 @@ export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<
|
||||
action: 'ORDER_PAID',
|
||||
detail: JSON.stringify({
|
||||
previous_status: order.status,
|
||||
trade_no: params.trade_no,
|
||||
trade_no: input.tradeNo,
|
||||
expected_amount: order.amount.toString(),
|
||||
paid_amount: paidAmount.toString(),
|
||||
}),
|
||||
operator: 'easy-pay',
|
||||
operator: input.providerName,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Recharge inline to avoid "paid but still recharging" async gaps.
|
||||
await executeRecharge(order.id);
|
||||
} catch (err) {
|
||||
// Payment has been confirmed, always ack notify to avoid endless retries from gateway.
|
||||
console.error('Recharge failed for order:', order.id, err);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a verified payment notification from any provider.
|
||||
* The caller (webhook route) is responsible for verifying the notification
|
||||
* via provider.verifyNotification() before calling this function.
|
||||
*/
|
||||
export async function handlePaymentNotify(notification: PaymentNotification, providerName: string): Promise<boolean> {
|
||||
if (notification.status !== 'success') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return confirmPayment({
|
||||
orderId: notification.orderId,
|
||||
tradeNo: notification.tradeNo,
|
||||
paidAmount: notification.amount,
|
||||
providerName,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeRecharge(orderId: string): Promise<void> {
|
||||
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) {
|
||||
@@ -442,15 +457,17 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
|
||||
try {
|
||||
if (order.paymentTradeNo) {
|
||||
await easyPayRefund(order.paymentTradeNo, order.id, amount.toFixed(2));
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
|
||||
await provider.refund({
|
||||
tradeNo: order.paymentTradeNo,
|
||||
orderId: order.id,
|
||||
amount,
|
||||
reason: input.reason,
|
||||
});
|
||||
}
|
||||
|
||||
await subtractBalance(
|
||||
order.userId,
|
||||
amount,
|
||||
`sub2apipay refund order:${order.id}`,
|
||||
`sub2apipay:refund:${order.id}`,
|
||||
);
|
||||
await subtractBalance(order.userId, amount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`);
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: input.orderId },
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export type RechargeStatus =
|
||||
| 'not_paid'
|
||||
| 'paid_pending'
|
||||
| 'recharging'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'closed';
|
||||
export type RechargeStatus = 'not_paid' | 'paid_pending' | 'recharging' | 'success' | 'failed' | 'closed';
|
||||
|
||||
export interface OrderStatusLike {
|
||||
status: string;
|
||||
@@ -12,13 +6,7 @@ export interface OrderStatusLike {
|
||||
completedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
const CLOSED_STATUSES = new Set([
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
'REFUNDING',
|
||||
'REFUNDED',
|
||||
'REFUND_FAILED',
|
||||
]);
|
||||
const CLOSED_STATUSES = new Set(['EXPIRED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -56,6 +56,41 @@ export function formatCreatedAt(value: string): string {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export interface PaymentTypeMeta {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
color: string;
|
||||
selectedBorder: string;
|
||||
selectedBg: string;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
alipay: {
|
||||
label: '支付宝',
|
||||
sublabel: 'ALIPAY',
|
||||
color: '#00AEEF',
|
||||
selectedBorder: 'border-cyan-400',
|
||||
selectedBg: 'bg-cyan-50',
|
||||
iconBg: 'bg-[#00AEEF]',
|
||||
},
|
||||
wxpay: {
|
||||
label: '微信支付',
|
||||
color: '#2BB741',
|
||||
selectedBorder: 'border-green-500',
|
||||
selectedBg: 'bg-green-50',
|
||||
iconBg: 'bg-[#2BB741]',
|
||||
},
|
||||
stripe: {
|
||||
label: 'Stripe',
|
||||
sublabel: '信用卡 / 借记卡',
|
||||
color: '#635bff',
|
||||
selectedBorder: 'border-[#635bff]',
|
||||
selectedBg: 'bg-[#635bff]/10',
|
||||
iconBg: 'bg-[#635bff]',
|
||||
},
|
||||
};
|
||||
|
||||
export function getStatusBadgeClass(status: string, isDark: boolean): string {
|
||||
if (['COMPLETED', 'PAID'].includes(status)) {
|
||||
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';
|
||||
|
||||
30
src/lib/payment/index.ts
Normal file
30
src/lib/payment/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { paymentRegistry } from './registry';
|
||||
import { EasyPayProvider } from '@/lib/easy-pay/provider';
|
||||
import { StripeProvider } from '@/lib/stripe/provider';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
export { paymentRegistry } from './registry';
|
||||
export type {
|
||||
PaymentType,
|
||||
PaymentProvider,
|
||||
CreatePaymentRequest,
|
||||
CreatePaymentResponse,
|
||||
QueryOrderResponse,
|
||||
PaymentNotification,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
} from './types';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export function initPaymentProviders(): void {
|
||||
if (initialized) return;
|
||||
paymentRegistry.register(new EasyPayProvider());
|
||||
|
||||
const env = getEnv();
|
||||
if (env.STRIPE_SECRET_KEY) {
|
||||
paymentRegistry.register(new StripeProvider());
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
29
src/lib/payment/registry.ts
Normal file
29
src/lib/payment/registry.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { PaymentProvider, PaymentType } from './types';
|
||||
|
||||
export class PaymentProviderRegistry {
|
||||
private providers = new Map<PaymentType, PaymentProvider>();
|
||||
|
||||
register(provider: PaymentProvider): void {
|
||||
for (const type of provider.supportedTypes) {
|
||||
this.providers.set(type, provider);
|
||||
}
|
||||
}
|
||||
|
||||
getProvider(type: PaymentType): PaymentProvider {
|
||||
const provider = this.providers.get(type);
|
||||
if (!provider) {
|
||||
throw new Error(`No payment provider registered for type: ${type}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
hasProvider(type: PaymentType): boolean {
|
||||
return this.providers.has(type);
|
||||
}
|
||||
|
||||
getSupportedTypes(): PaymentType[] {
|
||||
return Array.from(this.providers.keys());
|
||||
}
|
||||
}
|
||||
|
||||
export const paymentRegistry = new PaymentProviderRegistry();
|
||||
66
src/lib/payment/types.ts
Normal file
66
src/lib/payment/types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/** Unified payment method types across all providers */
|
||||
export type PaymentType = 'alipay' | 'wxpay' | 'stripe';
|
||||
|
||||
/** Request to create a payment with any provider */
|
||||
export interface CreatePaymentRequest {
|
||||
orderId: string;
|
||||
amount: number; // in CNY (yuan)
|
||||
paymentType: PaymentType;
|
||||
subject: string; // product description
|
||||
notifyUrl?: string;
|
||||
returnUrl?: string;
|
||||
clientIp?: string;
|
||||
}
|
||||
|
||||
/** Response from creating a payment */
|
||||
export interface CreatePaymentResponse {
|
||||
tradeNo: string; // third-party transaction ID
|
||||
payUrl?: string; // H5 payment URL (alipay/wxpay)
|
||||
qrCode?: string; // QR code content
|
||||
checkoutUrl?: string; // Stripe Checkout URL
|
||||
}
|
||||
|
||||
/** Response from querying an order's payment status */
|
||||
export interface QueryOrderResponse {
|
||||
tradeNo: string;
|
||||
status: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
amount: number;
|
||||
paidAt?: Date;
|
||||
}
|
||||
|
||||
/** Parsed payment notification from webhook/notify callback */
|
||||
export interface PaymentNotification {
|
||||
tradeNo: string;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
status: 'success' | 'failed';
|
||||
rawData: unknown;
|
||||
}
|
||||
|
||||
/** Request to refund a payment */
|
||||
export interface RefundRequest {
|
||||
tradeNo: string;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Response from a refund request */
|
||||
export interface RefundResponse {
|
||||
refundId: string;
|
||||
status: 'success' | 'pending' | 'failed';
|
||||
}
|
||||
|
||||
/** Common interface that all payment providers must implement */
|
||||
export interface PaymentProvider {
|
||||
readonly name: string;
|
||||
readonly supportedTypes: PaymentType[];
|
||||
|
||||
createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse>;
|
||||
queryOrder(tradeNo: string): Promise<QueryOrderResponse>;
|
||||
/** Returns null for unrecognized/irrelevant webhook events (caller should return 200). */
|
||||
verifyNotification(rawBody: string | Buffer, headers: Record<string, string>): Promise<PaymentNotification | null>;
|
||||
refund(request: RefundRequest): Promise<RefundResponse>;
|
||||
/** Cancel/expire a pending payment on the platform. Optional — not all providers support it. */
|
||||
cancelPayment?(tradeNo: string): Promise<void>;
|
||||
}
|
||||
139
src/lib/stripe/provider.ts
Normal file
139
src/lib/stripe/provider.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import Stripe from 'stripe';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type {
|
||||
PaymentProvider,
|
||||
PaymentType,
|
||||
CreatePaymentRequest,
|
||||
CreatePaymentResponse,
|
||||
QueryOrderResponse,
|
||||
PaymentNotification,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
} from '@/lib/payment/types';
|
||||
|
||||
export class StripeProvider implements PaymentProvider {
|
||||
readonly name = 'stripe';
|
||||
readonly supportedTypes: PaymentType[] = ['stripe'];
|
||||
|
||||
private client: Stripe | null = null;
|
||||
|
||||
private getClient(): Stripe {
|
||||
if (this.client) return this.client;
|
||||
const env = getEnv();
|
||||
if (!env.STRIPE_SECRET_KEY) throw new Error('STRIPE_SECRET_KEY not configured');
|
||||
this.client = new Stripe(env.STRIPE_SECRET_KEY);
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const stripe = this.getClient();
|
||||
const env = getEnv();
|
||||
|
||||
const timeoutMinutes = Math.max(30, env.ORDER_TIMEOUT_MINUTES); // Stripe minimum is 30 minutes
|
||||
|
||||
const session = await stripe.checkout.sessions.create(
|
||||
{
|
||||
mode: 'payment',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: 'cny',
|
||||
product_data: { name: request.subject },
|
||||
unit_amount: Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber()),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata: { orderId: request.orderId },
|
||||
expires_at: Math.floor(Date.now() / 1000) + timeoutMinutes * 60,
|
||||
success_url: `${env.NEXT_PUBLIC_APP_URL}/pay/result?order_id=${request.orderId}&status=success`,
|
||||
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/pay/result?order_id=${request.orderId}&status=cancelled`,
|
||||
},
|
||||
{ idempotencyKey: `checkout-${request.orderId}` },
|
||||
);
|
||||
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
checkoutUrl: session.url || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const stripe = this.getClient();
|
||||
const session = await stripe.checkout.sessions.retrieve(tradeNo);
|
||||
|
||||
let status: QueryOrderResponse['status'] = 'pending';
|
||||
if (session.payment_status === 'paid') status = 'paid';
|
||||
else if (session.status === 'expired') status = 'failed';
|
||||
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
status,
|
||||
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyNotification(rawBody: string | Buffer, headers: Record<string, string>): Promise<PaymentNotification | null> {
|
||||
const stripe = this.getClient();
|
||||
const env = getEnv();
|
||||
if (!env.STRIPE_WEBHOOK_SECRET) throw new Error('STRIPE_WEBHOOK_SECRET not configured');
|
||||
|
||||
const sig = headers['stripe-signature'] || '';
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
typeof rawBody === 'string' ? Buffer.from(rawBody) : rawBody,
|
||||
sig,
|
||||
env.STRIPE_WEBHOOK_SECRET,
|
||||
);
|
||||
|
||||
if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
orderId: session.metadata?.orderId || '',
|
||||
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
|
||||
status: session.payment_status === 'paid' ? 'success' : 'failed',
|
||||
rawData: event,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.async_payment_failed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
orderId: session.metadata?.orderId || '',
|
||||
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
|
||||
status: 'failed',
|
||||
rawData: event,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown event — return null (caller returns 200 to Stripe)
|
||||
return null;
|
||||
}
|
||||
|
||||
async refund(request: RefundRequest): Promise<RefundResponse> {
|
||||
const stripe = this.getClient();
|
||||
|
||||
// Retrieve checkout session to find the payment intent
|
||||
const session = await stripe.checkout.sessions.retrieve(request.tradeNo);
|
||||
if (!session.payment_intent) throw new Error('No payment intent found for session');
|
||||
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent.id,
|
||||
amount: Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber()),
|
||||
reason: 'requested_by_customer',
|
||||
});
|
||||
|
||||
return {
|
||||
refundId: refund.id,
|
||||
status: refund.status === 'succeeded' ? 'success' : 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
async cancelPayment(tradeNo: string): Promise<void> {
|
||||
const stripe = this.getClient();
|
||||
await stripe.checkout.sessions.expire(tradeNo);
|
||||
}
|
||||
}
|
||||
@@ -51,20 +51,17 @@ export async function createAndRedeem(
|
||||
notes: string,
|
||||
): Promise<Sub2ApiRedeemCode> {
|
||||
const env = getEnv();
|
||||
const response = await fetch(
|
||||
`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getHeaders(`sub2apipay:recharge:${code}`),
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
type: 'balance',
|
||||
value,
|
||||
user_id: userId,
|
||||
notes,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(`sub2apipay:recharge:${code}`),
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
type: 'balance',
|
||||
value,
|
||||
user_id: userId,
|
||||
notes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
@@ -82,18 +79,15 @@ export async function subtractBalance(
|
||||
idempotencyKey: string,
|
||||
): Promise<void> {
|
||||
const env = getEnv();
|
||||
const response = await fetch(
|
||||
`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getHeaders(idempotencyKey),
|
||||
body: JSON.stringify({
|
||||
operation: 'subtract',
|
||||
amount,
|
||||
notes,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(idempotencyKey),
|
||||
body: JSON.stringify({
|
||||
operation: 'subtract',
|
||||
amount,
|
||||
notes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
Reference in New Issue
Block a user