613 lines
24 KiB
TypeScript
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;
|
|
}
|