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 { 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; export interface CreateOrderInput { userId: number; amount: number; paymentType: 'alipay' | 'wxpay'; clientIp: string; } export interface CreateOrderResult { orderId: string; amount: number; status: string; paymentType: 'alipay' | 'wxpay'; userName: string; userBalance: number; payUrl?: string | null; qrCode?: string | null; expiresAt: Date; } export async function createOrder(input: CreateOrderInput): Promise { const env = getEnv(); const user = await getUser(input.userId); if (user.status !== 'active') { throw new OrderError('USER_INACTIVE', 'User account is disabled', 422); } const pendingCount = await prisma.order.count({ where: { userId: input.userId, status: 'PENDING' }, }); if (pendingCount >= MAX_PENDING_ORDERS) { throw new OrderError('TOO_MANY_PENDING', `Too many pending orders (${MAX_PENDING_ORDERS})`, 429); } const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000); const order = await prisma.order.create({ data: { userId: input.userId, userEmail: user.email, userName: user.username, amount: new Prisma.Decimal(input.amount.toFixed(2)), rechargeCode: '', status: 'PENDING', paymentType: input.paymentType, expiresAt, clientIp: input.clientIp, }, }); const rechargeCode = generateRechargeCode(order.id); await prisma.order.update({ where: { id: order.id }, data: { rechargeCode }, }); try { const easyPayResult = await createPayment({ outTradeNo: order.id, amount: input.amount.toFixed(2), paymentType: input.paymentType, 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, }, }); await prisma.auditLog.create({ data: { orderId: order.id, action: 'ORDER_CREATED', detail: JSON.stringify({ userId: input.userId, amount: input.amount, paymentType: input.paymentType }), operator: `user:${input.userId}`, }, }); return { orderId: order.id, amount: input.amount, status: 'PENDING', paymentType: input.paymentType, userName: user.username, userBalance: user.balance, payUrl: easyPayResult.payurl, qrCode: easyPayResult.qrcode, expiresAt, }; } catch (error) { await prisma.order.delete({ where: { id: order.id } }); throw error; } } export async function cancelOrder(orderId: string, userId: number): Promise { const result = await prisma.order.updateMany({ where: { id: orderId, userId, status: 'PENDING' }, data: { status: 'CANCELLED', updatedAt: new Date() }, }); if (result.count === 0) { const order = await prisma.order.findUnique({ where: { id: orderId } }); if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); if (order.userId !== userId) throw new OrderError('FORBIDDEN', 'Forbidden', 403); throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400); } await prisma.auditLog.create({ data: { orderId, action: 'ORDER_CANCELLED', detail: 'User cancelled order', operator: `user:${userId}`, }, }); } export async function adminCancelOrder(orderId: string): Promise { const result = await prisma.order.updateMany({ where: { id: orderId, status: 'PENDING' }, data: { status: 'CANCELLED', updatedAt: new Date() }, }); if (result.count === 0) { const order = await prisma.order.findUnique({ where: { id: orderId } }); if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400); } await prisma.auditLog.create({ data: { orderId, action: 'ORDER_CANCELLED', detail: 'Admin cancelled order', operator: 'admin', }, }); } export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise { const env = getEnv(); const { sign, ...rest } = params; const paramsForSign: Record = {}; 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; } const order = await prisma.order.findUnique({ where: { id: params.out_trade_no }, }); if (!order) { console.error('EasyPay notify: order not found:', params.out_trade_no); return false; } let paidAmount: Prisma.Decimal; try { paidAmount = new Prisma.Decimal(params.money); } catch { console.error('EasyPay notify: invalid money format:', params.money); return false; } if (paidAmount.lte(0)) { console.error('EasyPay notify: non-positive money:', params.money); return false; } if (!paidAmount.equals(order.amount)) { console.warn('EasyPay notify: amount changed, use paid amount', order.amount.toString(), params.money); } const result = await prisma.order.updateMany({ where: { id: order.id, status: { in: ['PENDING', 'EXPIRED'] }, }, data: { status: 'PAID', amount: paidAmount, paymentTradeNo: params.trade_no, paidAt: new Date(), failedAt: null, failedReason: null, }, }); if (result.count === 0) { return true; } await prisma.auditLog.create({ data: { orderId: order.id, action: 'ORDER_PAID', detail: JSON.stringify({ previous_status: order.status, trade_no: params.trade_no, expected_amount: order.amount.toString(), paid_amount: paidAmount.toString(), }), operator: 'easy-pay', }, }); 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; } export async function executeRecharge(orderId: string): Promise { const order = await prisma.order.findUnique({ where: { id: orderId } }); if (!order) { throw new OrderError('NOT_FOUND', 'Order not found', 404); } if (order.status === 'COMPLETED') { return; } if (isRefundStatus(order.status)) { throw new OrderError('INVALID_STATUS', 'Refund-related order cannot recharge', 400); } if (order.status !== 'PAID' && order.status !== 'FAILED') { throw new OrderError('INVALID_STATUS', `Order cannot recharge in status ${order.status}`, 400); } try { await createAndRedeem( order.rechargeCode, Number(order.amount), order.userId, `sub2apipay recharge order:${orderId}`, ); await prisma.order.update({ where: { id: orderId }, data: { status: 'COMPLETED', completedAt: new Date() }, }); await prisma.auditLog.create({ data: { orderId, action: 'RECHARGE_SUCCESS', detail: JSON.stringify({ rechargeCode: order.rechargeCode, amount: Number(order.amount) }), operator: 'system', }, }); } catch (error) { await prisma.order.update({ where: { id: orderId }, data: { status: 'FAILED', failedAt: new Date(), failedReason: error instanceof Error ? error.message : String(error), }, }); await prisma.auditLog.create({ data: { orderId, action: 'RECHARGE_FAILED', detail: error instanceof Error ? error.message : String(error), operator: 'system', }, }); throw error; } } function assertRetryAllowed(order: { status: string; paidAt: Date | null }): void { if (!order.paidAt) { throw new OrderError('INVALID_STATUS', 'Order is not paid, retry denied', 400); } if (isRefundStatus(order.status)) { throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400); } if (order.status === 'FAILED' || order.status === 'PAID') { return; } if (order.status === 'RECHARGING') { throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409); } if (order.status === 'COMPLETED') { throw new OrderError('INVALID_STATUS', 'Order already completed', 400); } throw new OrderError('INVALID_STATUS', 'Only paid and failed orders can retry', 400); } export async function retryRecharge(orderId: string): Promise { const order = await prisma.order.findUnique({ where: { id: orderId }, select: { id: true, status: true, paidAt: true, completedAt: true, }, }); if (!order) { throw new OrderError('NOT_FOUND', 'Order not found', 404); } assertRetryAllowed(order); const result = await prisma.order.updateMany({ where: { id: orderId, status: { in: ['FAILED', 'PAID'] }, paidAt: { not: null }, }, data: { status: 'PAID', failedAt: null, failedReason: null }, }); if (result.count === 0) { const latest = await prisma.order.findUnique({ where: { id: orderId }, select: { status: true, paidAt: true, completedAt: true, }, }); if (!latest) { throw new OrderError('NOT_FOUND', 'Order not found', 404); } const derived = deriveOrderState(latest); if (derived.rechargeStatus === 'recharging' || latest.status === 'PAID') { throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409); } if (derived.rechargeStatus === 'success') { throw new OrderError('INVALID_STATUS', 'Order already completed', 400); } if (isRefundStatus(latest.status)) { throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400); } throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409); } await prisma.auditLog.create({ data: { orderId, action: 'RECHARGE_RETRY', detail: 'Admin manual retry recharge', operator: 'admin', }, }); await executeRecharge(orderId); } export interface RefundInput { orderId: string; reason?: string; force?: boolean; } export interface RefundResult { success: boolean; warning?: string; requireForce?: boolean; } export async function processRefund(input: RefundInput): Promise { const order = await prisma.order.findUnique({ where: { id: input.orderId } }); if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); if (order.status !== 'COMPLETED') { throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400); } const amount = Number(order.amount); if (!input.force) { try { const user = await getUser(order.userId); if (user.balance < amount) { return { success: false, warning: `User balance ${user.balance} is lower than refund ${amount}`, requireForce: true, }; } } catch { return { success: false, warning: 'Cannot fetch user balance, use force=true', requireForce: true, }; } } const lockResult = await prisma.order.updateMany({ where: { id: input.orderId, status: 'COMPLETED' }, data: { status: 'REFUNDING' }, }); if (lockResult.count === 0) { throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409); } try { if (order.paymentTradeNo) { await easyPayRefund(order.paymentTradeNo, order.id, amount.toFixed(2)); } await subtractBalance( order.userId, amount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`, ); await prisma.order.update({ where: { id: input.orderId }, data: { status: 'REFUNDED', refundAmount: new Prisma.Decimal(amount.toFixed(2)), refundReason: input.reason || null, refundAt: new Date(), forceRefund: input.force || false, }, }); await prisma.auditLog.create({ data: { orderId: input.orderId, action: 'REFUND_SUCCESS', detail: JSON.stringify({ amount, reason: input.reason, force: input.force }), operator: 'admin', }, }); return { success: true }; } catch (error) { await prisma.order.update({ where: { id: input.orderId }, data: { status: 'REFUND_FAILED', failedAt: new Date(), failedReason: error instanceof Error ? error.message : String(error), }, }); await prisma.auditLog.create({ data: { orderId: input.orderId, action: 'REFUND_FAILED', detail: error instanceof Error ? error.message : String(error), operator: 'admin', }, }); throw error; } } export class OrderError extends Error { code: string; statusCode: number; constructor(code: string, message: string, statusCode: number = 400) { super(message); this.name = 'OrderError'; this.code = code; this.statusCode = statusCode; } }