import { randomBytes } from 'node:crypto'; import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; import type { MySqlPool } from '../db/mysql.js'; import type { PaymentRepository } from './payment-repository.js'; import { WechatPayClient, WechatPayError, type WechatNotificationHeaders, type WechatPayCredential } from './wechat-pay-client.js'; interface PaymentRow extends RowDataPacket { id: string; tenantId: string; platformAppId: string; orderId: string; storeId: string; paymentNo: string; status: string; amountCents: number; paidAmountCents: number; totalAmountCents: number; orderStatus: string; userId?: string; } interface RefundRow extends RowDataPacket { id: string; tenantId: string; paymentId: string; orderId: string; refundNo: string; status: string; amountCents: number; } export class WechatPaymentService { constructor( private readonly pool: MySqlPool, private readonly paymentRepository: PaymentRepository, private readonly client: WechatPayClient, private readonly credentials: ReadonlyMap ) {} async createPrepay(input: { tenantId: string; platformAppId: string; userId: string; paymentId: string; }) { const payment = await this.loadOwnedPayment(input, false); if (payment.status === 'SUCCEEDED') { throw new WechatPayError('PAYMENT_ALREADY_SUCCEEDED'); } if (payment.status !== 'PENDING') throw new WechatPayError('PAYMENT_STATUS_INVALID'); const config = await this.paymentRepository.resolveConfig( this.pool, input.tenantId, input.platformAppId, payment.storeId, 'WECHAT' ); const credential = this.resolveCredential(config.credentialRef); const settings = config.settings as Record; const [identityRows] = await this.pool.execute( `SELECT openid FROM qipai_user_identities WHERE tenant_id = ? AND platform_app_id = ? AND user_id = ? AND provider = 'WECHAT' LIMIT 1`, [input.tenantId, input.platformAppId, input.userId] ); if (!identityRows[0]?.openid) throw new WechatPayError('WECHAT_OPENID_NOT_FOUND'); const result = await this.client.createJsapiPrepay(credential, { description: typeof settings.description === 'string' ? settings.description : `棋牌室订单 ${payment.paymentNo}`, outTradeNo: payment.paymentNo, notifyUrl: settingUrl( settings, 'paymentNotifyUrl', 'https://api.txyundm.cn/app-api/pay/wechat/notify' ), amountCents: Number(payment.amountCents), payerOpenId: String(identityRows[0].openid) }); await this.pool.execute( `UPDATE qipai_payment_attempts SET status = 'PREPAY_CREATED', response_payload = JSON_OBJECT( 'prepayId', ?, 'credentialExposed', FALSE ), completed_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND payment_id = ? AND attempt_no = 1`, [result.prepayId, input.tenantId, input.paymentId] ); return { paymentId: input.paymentId, paymentNo: payment.paymentNo, amountCents: Number(payment.amountCents), ...result.paymentParams }; } async queryPayment(input: { tenantId: string; platformAppId: string; userId: string; paymentId: string; }) { const payment = await this.loadOwnedPayment(input, false); const config = await this.paymentRepository.resolveConfig( this.pool, input.tenantId, input.platformAppId, payment.storeId, 'WECHAT' ); const result = await this.client.queryTransaction( this.resolveCredential(config.credentialRef), payment.paymentNo ); return { paymentId: payment.id, localStatus: payment.status, providerStatus: stringValue(result.trade_state), providerTransactionId: stringValue(result.transaction_id) }; } async processPaymentNotification( headers: WechatNotificationHeaders, rawBody: string, traceId: string ) { const data = this.verifyWithConfiguredCredential(headers, rawBody); const paymentNo = requiredString(data, 'out_trade_no'); const transactionId = requiredString(data, 'transaction_id'); const tradeState = requiredString(data, 'trade_state'); const amount = objectValue(data.amount); const total = requiredNumber(amount, 'total'); return this.transaction(async (connection) => { const payment = await this.loadPaymentByNo(connection, paymentNo, true); const callbackId = `${headers.serial}:${transactionId}:${tradeState}`; const [callback] = await connection.execute( `INSERT IGNORE INTO qipai_payment_callbacks (tenant_id, payment_id, provider, callback_id, callback_type, verified, payload) VALUES (?, ?, 'WECHAT', ?, 'PAYMENT_NOTIFICATION', 1, CAST(? AS JSON))`, [payment.tenantId, payment.id, callbackId, JSON.stringify(redactNotification(data))] ); if (callback.affectedRows === 0) { return { paymentId: payment.id, status: payment.status, idempotent: true }; } if (tradeState !== 'SUCCESS' || total !== Number(payment.amountCents)) { const code = tradeState !== 'SUCCESS' ? `WECHAT_TRADE_${tradeState}` : 'PAYMENT_AMOUNT_MISMATCH'; await this.rejectCallback(connection, payment.tenantId, callbackId, code); return { paymentId: payment.id, status: 'REJECTED', code, idempotent: false }; } await this.applyPaymentSuccess( connection, payment, transactionId, callbackId, traceId ); return { paymentId: payment.id, status: 'SUCCEEDED', idempotent: false }; }); } async createRefund(input: { tenantId: string; platformAppId: string; actorId: string; paymentId: string; amountCents: number; reason: string; clientRequestId: string; }) { const payment = await this.loadPayment(input.tenantId, input.paymentId, false); if (payment.status !== 'SUCCEEDED' && payment.status !== 'PARTIALLY_REFUNDED') { throw new WechatPayError('PAYMENT_NOT_REFUNDABLE'); } const [sumRows] = await this.pool.execute( `SELECT COALESCE(SUM(amount_cents), 0) AS refundedCents FROM qipai_refunds WHERE tenant_id = ? AND payment_id = ? AND status IN ('PENDING', 'PROCESSING', 'SUCCEEDED')`, [input.tenantId, input.paymentId] ); const refundable = Number(payment.amountCents) - Number(sumRows[0].refundedCents); if (input.amountCents > refundable) throw new WechatPayError('REFUND_AMOUNT_EXCEEDED'); const [existing] = await this.pool.execute( `SELECT id, tenant_id AS tenantId, payment_id AS paymentId, order_id AS orderId, refund_no AS refundNo, status, amount_cents AS amountCents FROM qipai_refunds WHERE tenant_id = ? AND client_request_id = ? LIMIT 1`, [input.tenantId, input.clientRequestId] ); if (existing[0]) { if (String(existing[0].paymentId) !== input.paymentId || Number(existing[0].amountCents) !== input.amountCents) { throw new WechatPayError('REFUND_IDEMPOTENCY_CONFLICT'); } return { ...normalizeRefund(existing[0]), idempotent: true }; } const refundNo = `REF${Date.now()}${randomBytes(5).toString('hex').toUpperCase()}`; const [insert] = await this.pool.execute( `INSERT INTO qipai_refunds (tenant_id, payment_id, order_id, client_request_id, provider, refund_no, status, amount_cents, reason) VALUES (?, ?, ?, ?, 'WECHAT', ?, 'PENDING', ?, ?)`, [input.tenantId, input.paymentId, payment.orderId, input.clientRequestId, refundNo, input.amountCents, input.reason.slice(0, 512)] ); const config = await this.paymentRepository.resolveConfig( this.pool, input.tenantId, input.platformAppId, payment.storeId, 'WECHAT' ); const settings = config.settings as Record; try { const response = await this.client.createRefund( this.resolveCredential(config.credentialRef), { outTradeNo: payment.paymentNo, outRefundNo: refundNo, reason: input.reason, notifyUrl: settingUrl( settings, 'refundNotifyUrl', 'https://api.txyundm.cn/app-api/pay/wechat/refund-notify' ), refundCents: input.amountCents, totalCents: Number(payment.amountCents) } ); const providerRefundId = requiredString(response, 'refund_id'); const status = mapRefundStatus(requiredString(response, 'status')); await this.pool.execute( `UPDATE qipai_refunds SET status = ?, provider_refund_id = ?, raw_response = CAST(? AS JSON), completed_at = IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), NULL) WHERE tenant_id = ? AND id = ?`, [status, providerRefundId, JSON.stringify(redactNotification(response)), status, input.tenantId, insert.insertId] ); if (status === 'SUCCEEDED') { await this.finalizeRefund(input.tenantId, String(insert.insertId), 'refund-api'); } return { refundId: String(insert.insertId), refundNo, status, amountCents: input.amountCents, refundableCents: refundable - input.amountCents, idempotent: false }; } catch (error) { await this.pool.execute( `UPDATE qipai_refunds SET status = 'MANUAL_REVIEW', failure_code = ? WHERE tenant_id = ? AND id = ?`, [error instanceof WechatPayError ? error.code : 'WECHAT_REFUND_FAILED', input.tenantId, insert.insertId] ); throw error; } } async processRefundNotification( headers: WechatNotificationHeaders, rawBody: string ) { const data = this.verifyWithConfiguredCredential(headers, rawBody); const refundNo = requiredString(data, 'out_refund_no'); const providerRefundId = requiredString(data, 'refund_id'); const status = mapRefundStatus(requiredString(data, 'refund_status')); const [rows] = await this.pool.execute( `SELECT id, tenant_id AS tenantId, payment_id AS paymentId, order_id AS orderId, refund_no AS refundNo, status, amount_cents AS amountCents FROM qipai_refunds WHERE refund_no = ? LIMIT 1`, [refundNo] ); const refund = rows[0]; if (!refund) throw new WechatPayError('REFUND_NOT_FOUND'); if (refund.status === 'SUCCEEDED') { return { refundId: String(refund.id), status: refund.status, idempotent: true }; } const callbackId = `${headers.serial}:${providerRefundId}:${status}`; const [result] = await this.pool.execute( `UPDATE qipai_refunds SET status = ?, provider_refund_id = ?, provider_callback_id = ?, raw_response = CAST(? AS JSON), completed_at = IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), completed_at), failure_code = IF(? = 'FAILED', 'WECHAT_REFUND_FAILED', failure_code) WHERE tenant_id = ? AND id = ? AND (provider_callback_id IS NULL OR provider_callback_id <> ?)`, [status, providerRefundId, callbackId, JSON.stringify(redactNotification(data)), status, status, refund.tenantId, refund.id, callbackId] ); if (result.affectedRows === 0) { return { refundId: String(refund.id), status: refund.status, idempotent: true }; } if (status === 'SUCCEEDED') { await this.finalizeRefund(refund.tenantId, String(refund.id), callbackId); } return { refundId: String(refund.id), status, idempotent: false }; } async requestReconciliation(input: { tenantId: string; platformAppId: string; storeId: string; actorId: string; billDate: string; billType: 'ALL' | 'SUCCESS' | 'REFUND'; }) { const config = await this.paymentRepository.resolveConfig( this.pool, input.tenantId, input.platformAppId, input.storeId, 'WECHAT' ); const [existing] = await this.pool.execute( `SELECT id, status, download_url AS downloadUrl FROM qipai_reconciliation_runs WHERE tenant_id = ? AND payment_config_id = ? AND bill_date = ? AND bill_type = ? LIMIT 1`, [input.tenantId, config.id, input.billDate, input.billType] ); if (existing[0]) return { ...existing[0], idempotent: true }; const response = await this.client.downloadTradeBill( this.resolveCredential(config.credentialRef), input.billDate, input.billType ); const downloadUrl = requiredString(response, 'download_url'); const [result] = await this.pool.execute( `INSERT INTO qipai_reconciliation_runs (tenant_id, payment_config_id, bill_date, bill_type, status, download_url, requested_by, completed_at) VALUES (?, ?, ?, ?, 'READY', ?, ?, UTC_TIMESTAMP(3))`, [input.tenantId, config.id, input.billDate, input.billType, downloadUrl, input.actorId] ); return { id: String(result.insertId), status: 'READY', downloadUrl, idempotent: false }; } private resolveCredential(reference: string) { const credential = this.credentials.get(reference) ?? this.credentials.get(reference.replace(/^env:/, '')); if (!credential) throw new WechatPayError('WECHAT_CREDENTIAL_NOT_CONFIGURED'); return credential; } private verifyWithConfiguredCredential( headers: WechatNotificationHeaders, rawBody: string ) { let lastError: unknown; for (const credential of this.credentials.values()) { if (!credential.platformCertificates[headers.serial]) continue; try { return this.client.verifyAndDecrypt(credential, headers, rawBody); } catch (error) { lastError = error; } } throw lastError ?? new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND'); } private async loadOwnedPayment(input: { tenantId: string; platformAppId: string; userId: string; paymentId: string; }, lock: boolean) { const payment = await this.loadPayment(input.tenantId, input.paymentId, lock); if (String(payment.platformAppId) !== input.platformAppId) { throw new WechatPayError('PAYMENT_APP_MISMATCH'); } const [access] = await this.pool.execute( `SELECT 1 FROM qipai_order_user_access WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`, [input.tenantId, payment.orderId, input.userId] ); if (!access[0]) throw new WechatPayError('ORDER_ACCESS_FORBIDDEN'); return payment; } private async loadPayment(tenantId: string, paymentId: string, lock: boolean) { const connection = lock ? await this.pool.getConnection() : this.pool; try { const [rows] = await connection.execute( `SELECT p.id, p.tenant_id AS tenantId, p.platform_app_id AS platformAppId, p.order_id AS orderId, p.store_id AS storeId, p.payment_no AS paymentNo, p.status, p.amount_cents AS amountCents, o.paid_amount_cents AS paidAmountCents, o.total_amount_cents AS totalAmountCents, o.status AS orderStatus FROM qipai_payments p INNER JOIN qipai_orders o ON o.tenant_id = p.tenant_id AND o.id = p.order_id WHERE p.tenant_id = ? AND p.id = ? AND p.provider = 'WECHAT' AND p.deleted_at IS NULL ${lock ? 'FOR UPDATE' : ''}`, [tenantId, paymentId] ); if (!rows[0]) throw new WechatPayError('PAYMENT_NOT_FOUND'); return rows[0]; } finally { if (lock && 'release' in connection) connection.release(); } } private async loadPaymentByNo( connection: PoolConnection, paymentNo: string, lock: boolean ) { const [rows] = await connection.execute( `SELECT p.id, p.tenant_id AS tenantId, p.platform_app_id AS platformAppId, p.order_id AS orderId, p.store_id AS storeId, p.payment_no AS paymentNo, p.status, p.amount_cents AS amountCents, o.paid_amount_cents AS paidAmountCents, o.total_amount_cents AS totalAmountCents, o.status AS orderStatus FROM qipai_payments p INNER JOIN qipai_orders o ON o.tenant_id = p.tenant_id AND o.id = p.order_id WHERE p.payment_no = ? AND p.provider = 'WECHAT' AND p.deleted_at IS NULL ${lock ? 'FOR UPDATE' : ''}`, [paymentNo] ); if (!rows[0]) throw new WechatPayError('PAYMENT_NOT_FOUND'); return rows[0]; } private async applyPaymentSuccess( connection: PoolConnection, payment: PaymentRow, transactionId: string, callbackId: string, traceId: string ) { if (payment.status === 'SUCCEEDED') { await connection.execute( `UPDATE qipai_payment_callbacks SET processing_status = 'DUPLICATE', processed_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND provider = 'WECHAT' AND callback_id = ?`, [payment.tenantId, callbackId] ); return; } const userId = await this.loadOrderUserId(connection, payment.tenantId, payment.orderId); await this.paymentRepository.applyPaymentSuccess(connection, { paymentId: payment.id, orderId: payment.orderId, tenantId: payment.tenantId, userId, amountCents: Number(payment.amountCents), providerPaymentId: transactionId, traceId, reason: 'Verified Wechat payment callback' }); await connection.execute( `UPDATE qipai_payment_callbacks SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND provider = 'WECHAT' AND callback_id = ?`, [payment.tenantId, callbackId] ); } private async loadOrderUserId(connection: PoolConnection, tenantId: string, orderId: string) { const [rows] = await connection.execute( `SELECT user_id AS userId FROM qipai_order_user_access WHERE tenant_id = ? AND order_id = ? AND revoked_at IS NULL ORDER BY user_id LIMIT 1`, [tenantId, orderId] ); return rows[0]?.userId ? String(rows[0].userId) : undefined; } private async rejectCallback( connection: PoolConnection, tenantId: string, callbackId: string, code: string ) { await connection.execute( `UPDATE qipai_payment_callbacks SET processing_status = 'REJECTED', error_code = ?, processed_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND provider = 'WECHAT' AND callback_id = ?`, [code, tenantId, callbackId] ); } private async finalizeRefund(tenantId: string, refundId: string, traceId: string) { await this.transaction(async (connection) => { const [rows] = await connection.execute( `SELECT id, tenant_id AS tenantId, payment_id AS paymentId, order_id AS orderId, refund_no AS refundNo, status, amount_cents AS amountCents FROM qipai_refunds WHERE tenant_id = ? AND id = ? FOR UPDATE`, [tenantId, refundId] ); const refund = rows[0]; if (!refund || refund.status !== 'SUCCEEDED') return; const [paymentRows] = await connection.execute( `SELECT p.id, p.tenant_id AS tenantId, p.platform_app_id AS platformAppId, p.order_id AS orderId, p.store_id AS storeId, p.payment_no AS paymentNo, p.status, p.amount_cents AS amountCents, o.paid_amount_cents AS paidAmountCents, o.total_amount_cents AS totalAmountCents, o.status AS orderStatus FROM qipai_payments p INNER JOIN qipai_orders o ON o.tenant_id = p.tenant_id AND o.id = p.order_id WHERE p.tenant_id = ? AND p.id = ? FOR UPDATE`, [tenantId, refund.paymentId] ); const payment = paymentRows[0]; const [sumRows] = await connection.execute( `SELECT COALESCE(SUM(amount_cents), 0) AS refundedCents FROM qipai_refunds WHERE tenant_id = ? AND payment_id = ? AND status = 'SUCCEEDED'`, [tenantId, refund.paymentId] ); const refundedCents = Number(sumRows[0].refundedCents); const paymentStatus = refundedCents >= Number(payment.amountCents) ? 'REFUNDED' : 'PARTIALLY_REFUNDED'; await connection.execute( `UPDATE qipai_payments SET status = ? WHERE tenant_id = ? AND id = ?`, [paymentStatus, tenantId, refund.paymentId] ); await connection.execute( `UPDATE qipai_orders SET paid_amount_cents = GREATEST(0, total_amount_cents - ?) WHERE tenant_id = ? AND id = ?`, [refundedCents, tenantId, refund.orderId] ); if (paymentStatus === 'REFUNDED' && ['CANCELLED', 'REFUNDING'].includes(payment.orderStatus)) { await connection.execute( `UPDATE qipai_orders SET status = 'REFUNDED', status_version = status_version + 1, status_updated_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND id = ?`, [tenantId, refund.orderId] ); await connection.execute( `INSERT INTO qipai_order_status_history (tenant_id, order_id, from_status, to_status, action, actor_type, actor_id, source, reason, trace_id, metadata) VALUES (?, ?, ?, 'REFUNDED', 'COMPLETE_REFUND', 'SYSTEM', NULL, 'PAYMENT', 'Verified Wechat refund', ?, JSON_OBJECT('refundId', ?, 'amountCents', ?))`, [tenantId, refund.orderId, payment.orderStatus, traceId, refund.id, refund.amountCents] ); await connection.execute( `INSERT IGNORE INTO qipai_outbox_events (tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload) VALUES (?, 'ORDER', ?, 'ORDER_COMPLETE_REFUND', ?, JSON_OBJECT( 'storeId', ?, 'orderId', ?, 'customerId', ( SELECT user_id FROM qipai_order_user_access WHERE tenant_id = ? AND order_id = ? ORDER BY user_id LIMIT 1 ), 'refundId', ?, 'amountCents', ?, 'status', 'REFUNDED'))`, [tenantId, refund.orderId, `refund:${refund.id}:succeeded:notification`, payment.storeId, refund.orderId, tenantId, refund.orderId, refund.id, refund.amountCents] ); } }); } private async transaction(work: (connection: PoolConnection) => Promise) { const connection = await this.pool.getConnection(); try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } } function requiredString(value: Record, key: string) { const field = value[key]; if (typeof field !== 'string' || field.length === 0) { throw new WechatPayError('WECHAT_RESOURCE_INVALID'); } return field; } function requiredNumber(value: Record, key: string) { const field = value[key]; if (typeof field !== 'number' || !Number.isInteger(field)) { throw new WechatPayError('WECHAT_RESOURCE_INVALID'); } return field; } function objectValue(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new WechatPayError('WECHAT_RESOURCE_INVALID'); } return value as Record; } function stringValue(value: unknown) { return typeof value === 'string' ? value : ''; } function settingUrl( settings: Record, key: string, fallback: string ) { const value = settings[key]; return typeof value === 'string' && value.startsWith('https://') ? value : fallback; } function mapRefundStatus(value: string) { if (value === 'SUCCESS') return 'SUCCEEDED'; if (value === 'CLOSED' || value === 'ABNORMAL') return 'FAILED'; return 'PROCESSING'; } function normalizeRefund(row: RefundRow) { return { refundId: String(row.id), refundNo: row.refundNo, status: row.status, amountCents: Number(row.amountCents) }; } function redactNotification(value: Record) { const copy = { ...value }; delete copy.payer; delete copy.user_received_account; return copy; }