forked from wangziqi/gongxue-base
feat: handle refund notifications
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user