Files
qipai/backend/src/payments/wechat-payment-service.ts
T

613 lines
24 KiB
TypeScript

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<string, WechatPayCredential>
) {}
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<string, unknown>;
const [identityRows] = await this.pool.execute<RowDataPacket[]>(
`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<ResultSetHeader>(
`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<RowDataPacket[]>(
`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<RefundRow[]>(
`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<ResultSetHeader>(
`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<string, unknown>;
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<RefundRow[]>(
`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<ResultSetHeader>(
`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<RowDataPacket[]>(
`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<ResultSetHeader>(
`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<RowDataPacket[]>(
`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<PaymentRow[]>(
`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<PaymentRow[]>(
`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<RowDataPacket[]>(
`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<RefundRow[]>(
`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<PaymentRow[]>(
`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<RowDataPacket[]>(
`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<T>(work: (connection: PoolConnection) => Promise<T>) {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>;
}
function stringValue(value: unknown) {
return typeof value === 'string' ? value : '';
}
function settingUrl(
settings: Record<string, unknown>, 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<string, unknown>) {
const copy = { ...value };
delete copy.payer;
delete copy.user_received_account;
return copy;
}