feat: handle refund notifications

This commit is contained in:
Codex
2026-06-29 05:34:41 +08:00
parent e9cf363df9
commit ad8e32fa6b
12 changed files with 586 additions and 57 deletions

View File

@@ -14,6 +14,7 @@ import {
paymentNotifyRoute,
redeemActivationCodeRoute,
refundRequestsRoute,
refundNotifyRoute,
updateRefundStatusRoute,
} from './routes.js';
@@ -32,6 +33,9 @@ export const commerceRoutes: RouteDefinition[] = [
['POST', '/api/commerce/payments/notify/wechat_pay', paymentNotifyRoute],
['POST', '/api/commerce/payments/notify/wechat-pay', paymentNotifyRoute],
['POST', '/api/commerce/payments/notify/alipay', paymentNotifyRoute],
['POST', '/api/commerce/refunds/notify/wechat_pay', refundNotifyRoute],
['POST', '/api/commerce/refunds/notify/wechat-pay', refundNotifyRoute],
['POST', '/api/commerce/refunds/notify/alipay', refundNotifyRoute],
['POST', '/api/commerce/activation-codes/check', checkActivationCodeRoute],
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
['POST', '/api/commerce/coupons/claim', claimCouponRoute],

View File

@@ -65,6 +65,15 @@ export interface PaymentRefundResult {
raw: Record<string, unknown>;
}
export interface PaymentRefundNotificationResult extends PaymentRefundResult {
eventId: string;
eventType: string;
orderNo: string;
refundNo: string;
amountCents: number;
notifiedAt?: string;
}
export interface PaymentNotificationResult {
provider: PaymentProviderName;
eventId: string;
@@ -86,6 +95,11 @@ export interface PaymentProvider {
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentNotificationResult>;
parseRefundNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentRefundNotificationResult>;
}
function base64urlDecode(value: string) {
@@ -182,6 +196,50 @@ function randomNonce(size = 16) {
return crypto.randomBytes(size).toString('base64url');
}
function verifyWechatNotification(
config: TenantPaymentProviderConfig,
input: {
headers: Record<string, string>;
body: Record<string, unknown>;
rawBody: string;
},
) {
const publicKey = optionalPublicString(config, ['wechatpayPublicKey', 'platformCertificatePublicKey'])
|| requireSecretString(config, ['wechatpayPublicKey', 'platformCertificatePublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED');
const allowMockSignature = !appConfig.isProduction && optionalPublicBoolean(config, 'allowLocalMock', false);
if (publicKey && input.headers['wechatpay-signature']) {
const timestamp = input.headers['wechatpay-timestamp'] || '';
const nonceHeader = input.headers['wechatpay-nonce'] || '';
const message = `${timestamp}\n${nonceHeader}\n${input.rawBody}\n`;
if (!rsaVerifySha256(normalizePem(publicKey, 'PUBLIC KEY'), message, input.headers['wechatpay-signature'])) {
throw new HttpError(400, 'WeChat Pay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID');
}
} else if (!allowMockSignature) {
throw new HttpError(400, 'WeChat Pay notification signature is required', 'PAYMENT_SIGNATURE_REQUIRED');
}
}
function decryptWechatNotificationResource(config: TenantPaymentProviderConfig, body: Record<string, unknown>) {
const apiV3Key = requireSecretString(config, ['apiV3Key', 'apiv3Key'], 'PAYMENT_SECRET_REQUIRED');
const resource = objectValue(body.resource);
const ciphertext = requiredBodyString(resource, 'ciphertext', 'PAYMENT_NOTIFICATION_INVALID');
const nonce = requiredBodyString(resource, 'nonce', 'PAYMENT_NOTIFICATION_INVALID');
const associatedData = optionalBodyString(resource, 'associated_data');
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(apiV3Key, 'utf8'), Buffer.from(nonce, 'utf8'));
if (associatedData) decipher.setAAD(Buffer.from(associatedData, 'utf8'));
const encrypted = base64urlDecode(ciphertext);
const authTag = encrypted.subarray(encrypted.length - 16);
const cipherText = encrypted.subarray(0, encrypted.length - 16);
decipher.setAuthTag(authTag);
const buffer = Buffer.concat([decipher.update(cipherText), decipher.final()]);
return safeJson(JSON.parse(buffer.toString('utf8')));
} catch {
throw new HttpError(400, 'WeChat Pay notification decrypt failed', 'PAYMENT_NOTIFICATION_INVALID');
}
}
class WechatPayProvider implements PaymentProvider {
readonly name = 'wechat_pay' as const;
@@ -370,39 +428,8 @@ class WechatPayProvider implements PaymentProvider {
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentNotificationResult> {
const publicKey = optionalPublicString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey'])
|| requireSecretString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED');
const allowMockSignature = !appConfig.isProduction && optionalPublicBoolean(this.config, 'allowLocalMock', false);
if (publicKey && input.headers['wechatpay-signature']) {
const timestamp = input.headers['wechatpay-timestamp'] || '';
const nonceHeader = input.headers['wechatpay-nonce'] || '';
const message = `${timestamp}\n${nonceHeader}\n${input.rawBody}\n`;
if (!rsaVerifySha256(normalizePem(publicKey, 'PUBLIC KEY'), message, input.headers['wechatpay-signature'])) {
throw new HttpError(400, 'WeChat Pay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID');
}
} else if (!allowMockSignature) {
throw new HttpError(400, 'WeChat Pay notification signature is required', 'PAYMENT_SIGNATURE_REQUIRED');
}
const apiV3Key = requireSecretString(this.config, ['apiV3Key', 'apiv3Key'], 'PAYMENT_SECRET_REQUIRED');
const resource = objectValue(input.body.resource);
const ciphertext = requiredBodyString(resource, 'ciphertext', 'PAYMENT_NOTIFICATION_INVALID');
const nonce = requiredBodyString(resource, 'nonce', 'PAYMENT_NOTIFICATION_INVALID');
const associatedData = optionalBodyString(resource, 'associated_data');
let decrypted: Record<string, unknown>;
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(apiV3Key, 'utf8'), Buffer.from(nonce, 'utf8'));
if (associatedData) decipher.setAAD(Buffer.from(associatedData, 'utf8'));
const encrypted = base64urlDecode(ciphertext);
const authTag = encrypted.subarray(encrypted.length - 16);
const cipherText = encrypted.subarray(0, encrypted.length - 16);
decipher.setAuthTag(authTag);
const buffer = Buffer.concat([decipher.update(cipherText), decipher.final()]);
decrypted = safeJson(JSON.parse(buffer.toString('utf8')));
} catch {
throw new HttpError(400, 'WeChat Pay notification decrypt failed', 'PAYMENT_NOTIFICATION_INVALID');
}
verifyWechatNotification(this.config, input);
const decrypted = decryptWechatNotificationResource(this.config, input.body);
const tradeState = requiredBodyString(decrypted, 'trade_state', 'PAYMENT_NOTIFICATION_INVALID');
if (tradeState !== 'SUCCESS') {
@@ -434,6 +461,48 @@ class WechatPayProvider implements PaymentProvider {
},
};
}
async parseRefundNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentRefundNotificationResult> {
verifyWechatNotification(this.config, input);
const decrypted = decryptWechatNotificationResource(this.config, input.body);
const statusText = requiredBodyString(decrypted, 'refund_status', 'PAYMENT_NOTIFICATION_INVALID').toUpperCase();
const status = wechatRefundStatus(statusText);
const orderNo = requiredBodyString(decrypted, 'out_trade_no', 'PAYMENT_NOTIFICATION_INVALID');
const refundNo = requiredBodyString(decrypted, 'out_refund_no', 'PAYMENT_NOTIFICATION_INVALID');
const amount = objectValue(decrypted.amount);
const amountCents = Number(amount.refund ?? amount.payer_refund);
if (!Number.isInteger(amountCents) || amountCents <= 0) {
throw new HttpError(400, 'WeChat Pay refund notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID');
}
return {
provider: this.name,
eventId: requiredBodyString(input.body, 'id', 'PAYMENT_NOTIFICATION_INVALID'),
eventType: optionalBodyString(input.body, 'event_type') || 'REFUND.SUCCESS',
orderNo,
refundNo,
amountCents,
status,
providerRefundNo: optionalBodyString(decrypted, 'refund_id') || undefined,
failureReason: status === 'failed'
? optionalBodyString(decrypted, 'refund_status') || 'WeChat refund failed'
: undefined,
notifiedAt: optionalBodyString(decrypted, 'success_time') || optionalBodyString(input.body, 'create_time') || undefined,
raw: {
notification: input.body,
refund: decrypted,
headers: {
serial: input.headers['wechatpay-serial'] || '',
timestamp: input.headers['wechatpay-timestamp'] || '',
},
},
};
}
}
class AlipayProvider implements PaymentProvider {
@@ -662,6 +731,56 @@ class AlipayProvider implements PaymentProvider {
},
};
}
async parseRefundNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentRefundNotificationResult> {
const alipayPublicKey = normalizePem(
requireSecretString(this.config, ['alipayPublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED'),
'PUBLIC KEY',
);
const params: Record<string, string> = {};
for (const [key, value] of Object.entries(input.body)) {
if (typeof value === 'string') params[key] = value;
}
const sign = params.sign || '';
const signType = params.sign_type || 'RSA2';
delete params.sign;
delete params.sign_type;
if (signType !== 'RSA2') throw new HttpError(400, 'Alipay notification must use RSA2', 'PAYMENT_SIGNATURE_INVALID');
if (!rsaVerifySha256(alipayPublicKey, canonicalForm(params), sign)) {
throw new HttpError(400, 'Alipay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID');
}
const orderNo = params.out_trade_no;
const refundNo = params.out_biz_no || params.out_request_no || params.refund_no;
if (!orderNo || !refundNo) throw new HttpError(400, 'Alipay refund notification is missing refund identity', 'PAYMENT_NOTIFICATION_INVALID');
const amountCents = optionalYuanToCents(params.refund_fee || params.refund_amount);
if (!amountCents || amountCents <= 0) {
throw new HttpError(400, 'Alipay refund notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID');
}
const status = alipayRefundStatus(params);
return {
provider: this.name,
eventId: params.notify_id || `${params.trade_no || orderNo}:${refundNo}:${params.notify_time || params.gmt_refund || ''}`,
eventType: params.notify_type || params.trade_status || 'REFUND.NOTIFY',
orderNo,
refundNo,
amountCents,
status,
providerRefundNo: params.trade_no || refundNo,
failureReason: status === 'failed'
? params.sub_msg || params.refund_status || 'Alipay refund failed'
: undefined,
notifiedAt: params.gmt_refund || params.notify_time || undefined,
raw: {
notification: input.body,
},
};
}
}
class ManualPaymentProvider implements PaymentProvider {
@@ -690,6 +809,10 @@ class ManualPaymentProvider implements PaymentProvider {
async parseNotification(): Promise<PaymentNotificationResult> {
throw new HttpError(501, 'Manual provider does not support webhook notifications', 'PAYMENT_PROVIDER_NOT_SUPPORTED');
}
async parseRefundNotification(): Promise<PaymentRefundNotificationResult> {
throw new HttpError(501, 'Manual provider does not support refund webhook notifications', 'PAYMENT_PROVIDER_REFUND_NOT_SUPPORTED');
}
}
export function normalizePaymentProvider(value: string): PaymentProviderName {

View File

@@ -17,6 +17,7 @@ import {
normalizePaymentProvider,
paymentProviderAliases,
type PaymentProviderName,
type PaymentRefundNotificationResult,
} from './providers.js';
import { loadTenantPaymentProviderConfig } from '../../core/tenant-provider-config.js';
import { requireTenantAdmin, requireTenantPermission } from '../tenant-admin/auth.js';
@@ -505,12 +506,35 @@ async function fetchRefundById(client: pg.PoolClient, tenantId: string, refundId
return result.rows[0] || null;
}
async function fetchRefundByNo(client: pg.PoolClient, tenantId: string, refundNo: string, lock = false) {
const result = await client.query<RefundRequestRow>(
`
select rr.id, rr.refund_no as "refundNo", rr.order_id as "orderId", rr.payment_id as "paymentId",
o.order_no as "orderNo", o.status as "orderStatus", o.pay_provider as "paymentProvider",
rr.provider, rr.provider_refund_no as "providerRefundNo", rr.status,
rr.amount_cents as "amountCents", rr.reason, rr.entitlement_action as "entitlementAction",
rr.requested_by as "requestedBy", rr.reviewed_by as "reviewedBy", rr.processed_by as "processedBy",
rr.requested_at as "requestedAt", rr.reviewed_at as "reviewedAt", rr.processed_at as "processedAt",
rr.succeeded_at as "succeededAt", rr.failed_at as "failedAt", rr.cancelled_at as "cancelledAt",
rr.failure_reason as "failureReason", rr.metadata,
rr.created_at as "createdAt", rr.updated_at as "updatedAt"
from public.commerce_refund_requests rr
join public.orders o on o.tenant_id = rr.tenant_id and o.id = rr.order_id
where rr.tenant_id = $1 and rr.refund_no = $2
limit 1
${lock ? 'for update of rr' : ''}
`,
[tenantId, normalizeRefundNo(refundNo)],
);
return result.rows[0] || null;
}
async function applySuccessfulRefund(
client: pg.PoolClient,
input: {
tenantId: string;
refund: RefundRequestRow;
actorUserId: string;
actorUserId?: string | null;
providerRefundNo?: string | null;
},
) {
@@ -1781,6 +1805,50 @@ async function queryRefundFromProvider(
return { refundResult, nextStatus: 'processing', processResult: {} };
}
async function settleRefundNotification(
client: pg.PoolClient,
input: {
tenantId: string;
refund: RefundRequestRow;
notification: PaymentRefundNotificationResult;
},
) {
const { refund, notification } = input;
if (refund.orderNo !== notification.orderNo) {
throw new HttpError(409, 'Refund notification order number mismatch', 'REFUND_NOTIFICATION_ORDER_MISMATCH');
}
if (refund.amountCents !== notification.amountCents) {
throw new HttpError(409, 'Refund notification amount mismatch', 'REFUND_NOTIFICATION_AMOUNT_MISMATCH');
}
if (refund.provider && normalizePaymentProvider(refund.provider) !== notification.provider) {
throw new HttpError(409, 'Refund notification provider mismatch', 'REFUND_NOTIFICATION_PROVIDER_MISMATCH');
}
if (refund.status === 'succeeded') {
return { nextStatus: 'succeeded', processResult: {}, idempotent: true, eventType: 'provider_notify_idempotent' };
}
if (['failed', 'rejected', 'cancelled'].includes(refund.status)) {
throw new HttpError(409, `Refund status is ${refund.status}`, 'REFUND_STATUS_INVALID');
}
if (!['approved', 'processing'].includes(refund.status)) {
throw new HttpError(409, `Refund status is ${refund.status}`, 'REFUND_STATUS_INVALID');
}
if (notification.status === 'succeeded') {
const processResult = await applySuccessfulRefund(client, {
tenantId: input.tenantId,
refund,
actorUserId: null,
providerRefundNo: notification.providerRefundNo || null,
});
return { nextStatus: 'succeeded', processResult, idempotent: false, eventType: 'provider_notify_succeeded' };
}
if (notification.status === 'failed') {
return { nextStatus: 'failed', processResult: {}, idempotent: false, eventType: 'provider_notify_failed' };
}
return { nextStatus: 'processing', processResult: {}, idempotent: false, eventType: 'provider_notify_processing' };
}
export async function createPaymentRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
@@ -2089,6 +2157,168 @@ export async function paymentNotifyRoute(ctx: RequestContext) {
return { item };
}
export async function refundNotifyRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const providerName = ctx.url.pathname.split('/').at(-1) || optionalString(Object.fromEntries(ctx.url.searchParams), 'provider');
const provider = await loadPaymentProvider(tenantId, providerName);
const { rawBody, body } = await readWebhookBody(ctx);
const notification = await provider.parseRefundNotification({
headers: requestHeaders(ctx),
body,
rawBody,
});
const eventKey = `${tenantId}:refund:${notification.eventId}`;
const item = await transaction(async client => {
const eventResult = await client.query<{
id: string;
processedAt: string | null;
error: string | null;
}>(
`
insert into public.payment_events (
tenant_id, provider, event_type, event_id, signature_valid, payload
)
values ($1, $2, $3, $4, true, $5::jsonb)
on conflict (provider, event_id)
do update set payload = case
when public.payment_events.processed_at is null or public.payment_events.error is not null
then excluded.payload
else public.payment_events.payload
end,
signature_valid = true
returning id, processed_at as "processedAt", error
`,
[
tenantId,
notification.provider,
notification.eventType,
eventKey,
JSON.stringify(notification.raw),
],
);
const event = eventResult.rows[0];
if (event.processedAt && !event.error) {
return {
status: 'processed',
idempotent: true,
eventId: notification.eventId,
refundNo: notification.refundNo,
};
}
try {
const refund = await fetchRefundByNo(client, tenantId, notification.refundNo, true);
if (!refund) throw new HttpError(404, 'Refund request not found', 'REFUND_NOT_FOUND');
const settled = await settleRefundNotification(client, {
tenantId,
refund,
notification,
});
if (settled.idempotent) {
await client.query(
`
update public.payment_events
set payment_id = $2, processed_at = coalesce(processed_at, now()), error = null
where id = $1
`,
[event.id, refund.paymentId],
);
return {
status: settled.nextStatus,
idempotent: true,
eventId: notification.eventId,
refundNo: notification.refundNo,
item: refundPayload(refund),
};
}
const updated = await client.query<RefundRequestRow>(
`
update public.commerce_refund_requests
set status = $3,
provider_refund_no = coalesce($4, provider_refund_no),
processed_by = case when $3 in ('processing', 'succeeded', 'failed') then null else processed_by end,
processed_at = case when $3 in ('processing', 'succeeded', 'failed') then now() else processed_at end,
succeeded_at = case when $3 = 'succeeded' then coalesce(succeeded_at, now()) else succeeded_at end,
failed_at = case when $3 = 'failed' then coalesce(failed_at, now()) else failed_at end,
failure_reason = case when $3 = 'failed' then $5 else failure_reason end,
metadata = coalesce(metadata, '{}'::jsonb) || $6::jsonb,
updated_at = now()
where tenant_id = $1 and id = $2
returning id
`,
[
tenantId,
refund.id,
settled.nextStatus,
notification.providerRefundNo || null,
notification.failureReason || null,
JSON.stringify({
lastAction: 'refund_notify',
providerResult: notification.raw,
processResult: settled.processResult,
notifiedAt: notification.notifiedAt || null,
}),
],
);
const updatedRefund = await fetchRefundById(client, tenantId, updated.rows[0].id, true);
if (!updatedRefund) throw new HttpError(500, 'Refund request update failed', 'REFUND_UPDATE_FAILED');
await recordRefundEvent(client, {
tenantId,
refundRequestId: refund.id,
fromStatus: refund.status,
toStatus: settled.nextStatus,
eventType: settled.eventType,
actorUserId: null,
details: {
source: 'payment_provider_notify',
provider: notification.provider,
providerStatus: notification.status,
providerRefundNo: notification.providerRefundNo || null,
eventId: notification.eventId,
notifiedAt: notification.notifiedAt || null,
failureReason: notification.failureReason || null,
processResult: settled.processResult,
idempotent: settled.idempotent,
},
});
await client.query(
`
update public.payment_events
set payment_id = $2, processed_at = now(), error = null
where id = $1
`,
[event.id, refund.paymentId],
);
return {
status: settled.nextStatus,
idempotent: settled.idempotent,
eventId: notification.eventId,
refundNo: notification.refundNo,
item: refundPayload(updatedRefund),
};
} catch (error) {
await client.query(
`
update public.payment_events
set error = $2
where id = $1
`,
[event.id, error instanceof Error ? error.message : 'Unknown refund notification error'],
);
throw error;
}
});
return { item };
}
export async function checkActivationCodeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);