feat: submit provider refunds

This commit is contained in:
Codex
2026-06-29 05:05:42 +08:00
parent 24fd788b48
commit 8d752881c1
12 changed files with 397 additions and 23 deletions

View File

@@ -169,7 +169,17 @@ export function providerEndpoint(
allowedHosts: string[],
code = 'PROVIDER_ENDPOINT_NOT_ALLOWED',
) {
const raw = optionalPublicString(providerConfig, ['endpoint']) || fallback;
return providerEndpointForKeys(providerConfig, ['endpoint'], fallback, allowedHosts, code);
}
export function providerEndpointForKeys(
providerConfig: TenantProviderConfig,
keys: string[],
fallback: string,
allowedHosts: string[],
code = 'PROVIDER_ENDPOINT_NOT_ALLOWED',
) {
const raw = optionalPublicString(providerConfig, keys) || fallback;
let endpoint: URL;
try {
endpoint = new URL(raw);

View File

@@ -5,6 +5,7 @@ import {
optionalPublicBoolean,
optionalPublicString,
providerEndpoint,
providerEndpointForKeys,
requirePublicString,
requireSecretString,
type TenantPaymentProviderConfig,
@@ -25,6 +26,18 @@ export interface PaymentOrderInput {
quitUrl?: string;
}
export interface PaymentRefundInput {
tenantId: string;
orderId: string;
orderNo: string;
refundNo: string;
amountCents: number;
totalAmountCents: number;
providerTradeNo?: string | null;
reason?: string | null;
notifyUrl?: string | null;
}
export interface PaymentCreateResult {
provider: PaymentProviderName;
method: string;
@@ -33,6 +46,13 @@ export interface PaymentCreateResult {
raw: Record<string, unknown>;
}
export interface PaymentRefundResult {
provider: PaymentProviderName;
status: 'processing' | 'succeeded' | 'failed';
providerRefundNo?: string;
raw: Record<string, unknown>;
}
export interface PaymentNotificationResult {
provider: PaymentProviderName;
eventId: string;
@@ -47,6 +67,7 @@ export interface PaymentNotificationResult {
export interface PaymentProvider {
name: PaymentProviderName;
createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult>;
createRefund(input: PaymentRefundInput): Promise<PaymentRefundResult>;
parseNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
@@ -196,6 +217,63 @@ class WechatPayProvider implements PaymentProvider {
};
}
async createRefund(input: PaymentRefundInput): 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,
['refundEndpoint', 'endpoint'],
'https://api.mch.weixin.qq.com/v3/refund/domestic/refunds',
['api.mch.weixin.qq.com'],
'PAYMENT_ENDPOINT_NOT_ALLOWED',
);
const notifyUrl = input.notifyUrl || optionalPublicString(this.config, ['refundNotifyUrl', 'notifyUrl']);
const payloadObject: Record<string, unknown> = {
out_trade_no: input.orderNo,
out_refund_no: input.refundNo,
reason: (input.reason || 'refund').slice(0, 80),
amount: {
refund: input.amountCents,
total: input.totalAmountCents,
currency: 'CNY',
},
};
if (input.providerTradeNo) {
delete payloadObject.out_trade_no;
payloadObject.transaction_id = input.providerTradeNo;
}
if (notifyUrl) payloadObject.notify_url = notifyUrl;
const payload = JSON.stringify(payloadObject);
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomNonce();
const url = new URL(endpoint);
const message = ['POST', `${url.pathname}${url.search}`, timestamp, nonce, payload].join('\n') + '\n';
const signature = rsaSignSha256(wechatPrivateKey(this.config), message);
const response = await fetch(endpoint, {
method: 'POST',
headers: {
accept: 'application/json',
authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${merchantSerialNo}"`,
'content-type': 'application/json',
},
body: payload,
});
const raw = safeJson(await response.json().catch(() => ({})));
if (!response.ok) {
throw new HttpError(502, 'WeChat Pay refund request failed', 'PAYMENT_PROVIDER_REFUND_FAILED');
}
const statusText = typeof raw.status === 'string' ? raw.status.toUpperCase() : '';
return {
provider: this.name,
status: ['SUCCESS', 'CLOSED', 'ABNORMAL'].includes(statusText) ? 'succeeded' : 'processing',
providerRefundNo: typeof raw.refund_id === 'string' ? raw.refund_id : undefined,
raw,
};
}
async parseNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
@@ -319,6 +397,67 @@ class AlipayProvider implements PaymentProvider {
};
}
async createRefund(input: PaymentRefundInput): Promise<PaymentRefundResult> {
const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
const gateway = providerEndpointForKeys(
this.config,
['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,
refund_amount: (input.amountCents / 100).toFixed(2),
refund_reason: (input.reason || 'refund').slice(0, 256),
};
if (input.providerTradeNo) {
delete bizContent.out_trade_no;
bizContent.trade_no = input.providerTradeNo;
}
const params: Record<string, string> = {
app_id: appId,
method: 'alipay.trade.refund',
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 request failed', 'PAYMENT_PROVIDER_REFUND_FAILED');
}
const responseBody = objectValue(raw.alipay_trade_refund_response);
const code = typeof responseBody.code === 'string' ? responseBody.code : '';
if (code !== '10000') {
throw new HttpError(502, 'Alipay refund was rejected', 'PAYMENT_PROVIDER_REFUND_REJECTED');
}
return {
provider: this.name,
status: 'succeeded',
providerRefundNo:
(typeof responseBody.trade_no === 'string' && responseBody.trade_no)
|| (typeof responseBody.out_request_no === 'string' && responseBody.out_request_no)
|| undefined,
raw,
};
}
async parseNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
@@ -380,6 +519,10 @@ class ManualPaymentProvider implements PaymentProvider {
};
}
async createRefund(): Promise<PaymentRefundResult> {
throw new HttpError(501, 'Manual provider does not support online refunds', '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

@@ -1357,6 +1357,7 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
const action = requiredString(body, 'action');
const note = optionalString(body, 'note') || null;
const providerRefundNo = optionalString(body, 'providerRefundNo') || null;
const providerNotifyUrl = optionalString(body, 'providerNotifyUrl') || null;
const failureReason = optionalString(body, 'failureReason') || note;
const metadataPatch = objectValue(body.metadata);
@@ -1371,7 +1372,8 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
let toStatus = fromStatus;
let eventType = action;
let processResult: Record<string, unknown> = {};
const details: Record<string, unknown> = { note, providerRefundNo, failureReason, metadata: metadataPatch };
let providerResult: Record<string, unknown> = {};
const details: Record<string, unknown> = { note, providerRefundNo, providerNotifyUrl, failureReason, metadata: metadataPatch };
if (action === 'approve') {
if (fromStatus !== 'requested') throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
@@ -1387,6 +1389,24 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
}
toStatus = 'processing';
eventType = 'processing';
} else if (action === 'submit_provider_refund') {
if (fromStatus !== 'approved') {
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
}
const submitted = await submitRefundToProvider(client, {
tenantId: auth.tenantId,
refund,
actorUserId: auth.userId,
notifyUrl: providerNotifyUrl,
});
toStatus = submitted.nextStatus;
eventType = submitted.nextStatus === 'succeeded' ? 'provider_succeeded' : '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;
} else if (action === 'mark_succeeded') {
if (!['requested', 'approved', 'processing'].includes(fromStatus)) {
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
@@ -1419,7 +1439,7 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
`
update public.commerce_refund_requests
set status = $3,
provider_refund_no = coalesce($4, provider_refund_no),
provider_refund_no = coalesce($4, $8, provider_refund_no),
reviewed_by = case when $3 in ('approved', 'rejected') then $5 else reviewed_by end,
processed_by = case when $3 in ('processing', 'succeeded', 'failed') then $5 else processed_by end,
reviewed_at = case when $3 in ('approved', 'rejected') then now() else reviewed_at end,
@@ -1440,7 +1460,8 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
providerRefundNo,
auth.userId,
failureReason,
JSON.stringify({ lastAction: action, note, processResult, ...metadataPatch }),
JSON.stringify({ lastAction: action, note, processResult, providerResult, ...metadataPatch }),
typeof details.providerRefundNo === 'string' ? details.providerRefundNo : null,
],
);
const updatedRefund = await fetchRefundById(client, auth.tenantId, updated.rows[0].id, true);
@@ -1467,7 +1488,7 @@ export async function updateRefundStatusRoute(ctx: RequestContext) {
fromStatus,
toStatus,
amountCents: refund.amountCents,
providerRefundNo,
providerRefundNo: details.providerRefundNo || providerRefundNo,
processResult,
},
});
@@ -1613,6 +1634,61 @@ async function loadPaymentProvider(tenantId: string, providerName: string) {
return createPaymentProvider(provider, providerConfig);
}
async function submitRefundToProvider(
client: pg.PoolClient,
input: {
tenantId: string;
refund: RefundRequestRow;
actorUserId: string;
notifyUrl?: string | null;
},
) {
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.createRefund({
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,
reason: input.refund.reason,
notifyUrl: input.notifyUrl || null,
});
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 };
}
return { refundResult, nextStatus: 'processing', processResult: {} };
}
export async function createPaymentRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);