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