feat: query provider refunds

This commit is contained in:
Codex
2026-06-29 05:19:16 +08:00
parent 8d752881c1
commit e9cf363df9
11 changed files with 348 additions and 25 deletions

View File

@@ -38,6 +38,17 @@ export interface PaymentRefundInput {
notifyUrl?: string | null;
}
export interface PaymentRefundQueryInput {
tenantId: string;
orderId: string;
orderNo: string;
refundNo: string;
amountCents: number;
totalAmountCents: number;
providerTradeNo?: string | null;
providerRefundNo?: string | null;
}
export interface PaymentCreateResult {
provider: PaymentProviderName;
method: string;
@@ -50,6 +61,7 @@ export interface PaymentRefundResult {
provider: PaymentProviderName;
status: 'processing' | 'succeeded' | 'failed';
providerRefundNo?: string;
failureReason?: string;
raw: Record<string, unknown>;
}
@@ -68,6 +80,7 @@ export interface PaymentProvider {
name: PaymentProviderName;
createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult>;
createRefund(input: PaymentRefundInput): Promise<PaymentRefundResult>;
queryRefund(input: PaymentRefundQueryInput): Promise<PaymentRefundResult>;
parseNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
@@ -112,6 +125,39 @@ function safeJson(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function wechatRefundStatus(value: unknown) {
const status = typeof value === 'string' ? value.toUpperCase() : '';
if (status === 'SUCCESS') return 'succeeded';
if (['CLOSED', 'ABNORMAL'].includes(status)) return 'failed';
return 'processing';
}
function alipayRefundStatus(value: Record<string, unknown>) {
const refundStatus = typeof value.refund_status === 'string' ? value.refund_status.toUpperCase() : '';
const fundChange = typeof value.fund_change === 'string' ? value.fund_change.toUpperCase() : '';
if (refundStatus === 'REFUND_SUCCESS' || fundChange === 'Y') return 'succeeded';
if (['REFUND_CLOSED', 'REFUND_FAIL'].includes(refundStatus)) return 'failed';
return 'processing';
}
function optionalIntegerAmount(value: unknown) {
const amount = typeof value === 'number' ? value : Number.NaN;
return Number.isInteger(amount) && amount >= 0 ? amount : null;
}
function optionalYuanToCents(value: unknown) {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const amount = Number(value);
if (!Number.isFinite(amount) || amount < 0) return null;
return Math.round(amount * 100);
}
function assertProviderRefundAmount(actualCents: number | null, expectedCents: number, code = 'PAYMENT_PROVIDER_REFUND_AMOUNT_MISMATCH') {
if (actualCents !== null && actualCents !== expectedCents) {
throw new HttpError(502, 'Payment provider refund amount mismatch', code);
}
}
function canonicalForm(params: Record<string, string>) {
return Object.keys(params)
.filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '')
@@ -266,10 +312,55 @@ class WechatPayProvider implements PaymentProvider {
}
const statusText = typeof raw.status === 'string' ? raw.status.toUpperCase() : '';
const status = wechatRefundStatus(statusText);
const amount = objectValue(raw.amount);
assertProviderRefundAmount(optionalIntegerAmount(amount.refund), input.amountCents);
return {
provider: this.name,
status: ['SUCCESS', 'CLOSED', 'ABNORMAL'].includes(statusText) ? 'succeeded' : 'processing',
status,
providerRefundNo: typeof raw.refund_id === 'string' ? raw.refund_id : undefined,
failureReason: status === 'failed' ? (statusText || 'WeChat refund failed') : undefined,
raw,
};
}
async queryRefund(input: PaymentRefundQueryInput): Promise<PaymentRefundResult> {
const mchId = requirePublicString(this.config, ['merchantId', 'mchId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
const merchantSerialNo = requirePublicString(this.config, ['merchantSerialNo'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
const endpoint = providerEndpointForKeys(
this.config,
['refundQueryEndpoint', 'refundEndpoint'],
'https://api.mch.weixin.qq.com/v3/refund/domestic/refunds',
['api.mch.weixin.qq.com'],
'PAYMENT_ENDPOINT_NOT_ALLOWED',
);
const url = new URL(`${endpoint.replace(/\/$/, '')}/${encodeURIComponent(input.refundNo)}`);
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomNonce();
const message = ['GET', `${url.pathname}${url.search}`, timestamp, nonce, ''].join('\n') + '\n';
const signature = rsaSignSha256(wechatPrivateKey(this.config), message);
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
accept: 'application/json',
authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${merchantSerialNo}"`,
},
});
const raw = safeJson(await response.json().catch(() => ({})));
if (!response.ok) {
throw new HttpError(502, 'WeChat Pay refund query failed', 'PAYMENT_PROVIDER_REFUND_QUERY_FAILED');
}
const statusText = typeof raw.status === 'string' ? raw.status.toUpperCase() : '';
const status = wechatRefundStatus(statusText);
const amount = objectValue(raw.amount);
assertProviderRefundAmount(optionalIntegerAmount(amount.refund), input.amountCents);
return {
provider: this.name,
status,
providerRefundNo: typeof raw.refund_id === 'string' ? raw.refund_id : input.providerRefundNo || undefined,
failureReason: status === 'failed' ? (statusText || 'WeChat refund failed') : undefined,
raw,
};
}
@@ -446,14 +537,83 @@ class AlipayProvider implements PaymentProvider {
if (code !== '10000') {
throw new HttpError(502, 'Alipay refund was rejected', 'PAYMENT_PROVIDER_REFUND_REJECTED');
}
const status = alipayRefundStatus(responseBody);
assertProviderRefundAmount(optionalYuanToCents(responseBody.refund_fee), input.amountCents);
return {
provider: this.name,
status: 'succeeded',
status,
providerRefundNo:
(typeof responseBody.trade_no === 'string' && responseBody.trade_no)
|| (typeof responseBody.out_request_no === 'string' && responseBody.out_request_no)
|| undefined,
failureReason: status === 'failed'
? (typeof responseBody.sub_msg === 'string' && responseBody.sub_msg) || 'Alipay refund failed'
: undefined,
raw,
};
}
async queryRefund(input: PaymentRefundQueryInput): Promise<PaymentRefundResult> {
const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
const gateway = providerEndpointForKeys(
this.config,
['refundQueryEndpoint', 'refundEndpoint', 'endpoint'],
'https://openapi.alipay.com/gateway.do',
['openapi.alipay.com'],
'PAYMENT_ENDPOINT_NOT_ALLOWED',
);
const privateKey = normalizePem(requireSecretString(this.config, ['privateKey', 'appPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY');
const bizContent: Record<string, unknown> = {
out_trade_no: input.orderNo,
out_request_no: input.refundNo,
};
if (input.providerTradeNo) {
delete bizContent.out_trade_no;
bizContent.trade_no = input.providerTradeNo;
}
const params: Record<string, string> = {
app_id: appId,
method: 'alipay.trade.fastpay.refund.query',
charset: 'utf-8',
sign_type: 'RSA2',
timestamp: new Date().toISOString().replace('T', ' ').slice(0, 19),
version: '1.0',
biz_content: JSON.stringify(bizContent),
};
params.sign = rsaSignSha256(privateKey, canonicalForm(params));
const response = await fetch(gateway, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
},
body: encodedForm(params),
});
const raw = safeJson(await response.json().catch(() => ({})));
if (!response.ok) {
throw new HttpError(502, 'Alipay refund query failed', 'PAYMENT_PROVIDER_REFUND_QUERY_FAILED');
}
const responseBody = objectValue(raw.alipay_trade_fastpay_refund_query_response);
const code = typeof responseBody.code === 'string' ? responseBody.code : '';
if (code !== '10000') {
throw new HttpError(502, 'Alipay refund query was rejected', 'PAYMENT_PROVIDER_REFUND_QUERY_REJECTED');
}
const status = alipayRefundStatus(responseBody);
assertProviderRefundAmount(optionalYuanToCents(responseBody.refund_amount), input.amountCents);
return {
provider: this.name,
status,
providerRefundNo:
(typeof responseBody.trade_no === 'string' && responseBody.trade_no)
|| (typeof responseBody.out_request_no === 'string' && responseBody.out_request_no)
|| input.providerRefundNo
|| undefined,
failureReason: status === 'failed'
? (typeof responseBody.sub_msg === 'string' && responseBody.sub_msg) || 'Alipay refund failed'
: undefined,
raw,
};
}
@@ -523,6 +683,10 @@ class ManualPaymentProvider implements PaymentProvider {
throw new HttpError(501, 'Manual provider does not support online refunds', 'PAYMENT_PROVIDER_REFUND_NOT_SUPPORTED');
}
async queryRefund(): Promise<PaymentRefundResult> {
throw new HttpError(501, 'Manual provider does not support online refund query', 'PAYMENT_PROVIDER_REFUND_NOT_SUPPORTED');
}
async parseNotification(): Promise<PaymentNotificationResult> {
throw new HttpError(501, 'Manual provider does not support webhook notifications', 'PAYMENT_PROVIDER_NOT_SUPPORTED');
}

View File

@@ -1373,6 +1373,7 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
let eventType = action;
let processResult: Record<string, unknown> = {};
let providerResult: Record<string, unknown> = {};
let statusFailureReason = failureReason;
const details: Record<string, unknown> = { note, providerRefundNo, providerNotifyUrl, failureReason, metadata: metadataPatch };
if (action === 'approve') {
@@ -1400,13 +1401,44 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
notifyUrl: providerNotifyUrl,
});
toStatus = submitted.nextStatus;
eventType = submitted.nextStatus === 'succeeded' ? 'provider_succeeded' : 'provider_submitted';
eventType = submitted.nextStatus === 'succeeded'
? 'provider_succeeded'
: submitted.nextStatus === 'failed'
? 'provider_failed'
: 'provider_submitted';
providerResult = submitted.refundResult.raw;
processResult = submitted.processResult;
details.provider = submitted.refundResult.provider;
details.providerStatus = submitted.refundResult.status;
details.providerRefundNo = submitted.refundResult.providerRefundNo || providerRefundNo || null;
details.processResult = processResult;
if (submitted.refundResult.failureReason) {
statusFailureReason = submitted.refundResult.failureReason;
details.failureReason = submitted.refundResult.failureReason;
}
} else if (action === 'query_provider_refund') {
if (fromStatus !== 'processing') {
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
}
const queried = await queryRefundFromProvider(client, {
tenantId: auth.tenantId,
refund,
actorUserId: auth.userId,
});
toStatus = queried.nextStatus;
eventType = queried.nextStatus === 'succeeded'
? 'provider_query_succeeded'
: queried.nextStatus === 'failed'
? 'provider_query_failed'
: 'provider_query_processing';
providerResult = queried.refundResult.raw;
processResult = queried.processResult;
details.provider = queried.refundResult.provider;
details.providerStatus = queried.refundResult.status;
details.providerRefundNo = queried.refundResult.providerRefundNo || providerRefundNo || null;
statusFailureReason = queried.refundResult.failureReason || failureReason;
details.failureReason = statusFailureReason;
details.processResult = processResult;
} else if (action === 'mark_succeeded') {
if (!['requested', 'approved', 'processing'].includes(fromStatus)) {
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
@@ -1459,7 +1491,7 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
toStatus,
providerRefundNo,
auth.userId,
failureReason,
statusFailureReason,
JSON.stringify({ lastAction: action, note, processResult, providerResult, ...metadataPatch }),
typeof details.providerRefundNo === 'string' ? details.providerRefundNo : null,
],
@@ -1685,6 +1717,66 @@ async function submitRefundToProvider(
});
return { refundResult, nextStatus: 'succeeded', processResult };
}
if (refundResult.status === 'failed') {
return { refundResult, nextStatus: 'failed', processResult: {} };
}
return { refundResult, nextStatus: 'processing', processResult: {} };
}
async function queryRefundFromProvider(
client: pg.PoolClient,
input: {
tenantId: string;
refund: RefundRequestRow;
actorUserId: string;
},
) {
const paymentResult = await client.query<{
id: string;
provider: string;
providerTradeNo: string | null;
amountCents: number;
}>(
`
select id, provider, provider_trade_no as "providerTradeNo", amount_cents as "amountCents"
from public.payments
where tenant_id = $1 and id = $2
limit 1
`,
[input.tenantId, input.refund.paymentId],
);
const payment = paymentResult.rows[0];
if (!payment) throw new HttpError(404, 'Payment not found', 'PAYMENT_NOT_FOUND');
if (normalizePaymentProvider(payment.provider) === 'manual') {
throw new HttpError(409, 'Manual payment refunds must be recorded manually', 'PAYMENT_PROVIDER_REFUND_NOT_SUPPORTED');
}
const provider = await loadPaymentProvider(input.tenantId, payment.provider);
const refundResult = await provider.queryRefund({
tenantId: input.tenantId,
orderId: input.refund.orderId,
orderNo: input.refund.orderNo,
refundNo: input.refund.refundNo,
amountCents: input.refund.amountCents,
totalAmountCents: payment.amountCents,
providerTradeNo: payment.providerTradeNo,
providerRefundNo: input.refund.providerRefundNo,
});
if (refundResult.status === 'succeeded') {
const processResult = await applySuccessfulRefund(client, {
tenantId: input.tenantId,
refund: input.refund,
actorUserId: input.actorUserId,
providerRefundNo: refundResult.providerRefundNo || null,
});
return { refundResult, nextStatus: 'succeeded', processResult };
}
if (refundResult.status === 'failed') {
return { refundResult, nextStatus: 'failed', processResult: {} };
}
return { refundResult, nextStatus: 'processing', processResult: {} };
}