forked from wangziqi/gongxue-base
feat: query provider refunds
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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: {} };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user