feat(trade): connect WeChat refund API and callback flow
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -529,6 +529,177 @@ export class TradeService {
|
||||
return { orderId: order.id.toString(), alreadyPaid: false };
|
||||
}
|
||||
|
||||
/** 总部审批退款 / 协同取回后退款:发起微信原路退(Mock 同步完成) */
|
||||
async initiateRefund(
|
||||
orderId: bigint,
|
||||
ticketId: bigint,
|
||||
remark: string,
|
||||
actorType = 'HQ',
|
||||
) {
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
|
||||
if (order.payStatus === 'REFUNDED' || order.status === 'REFUNDED') {
|
||||
return { orderId: order.id.toString(), alreadyRefunded: true };
|
||||
}
|
||||
if (order.payStatus === 'REFUNDING' || order.status === 'REFUNDING') {
|
||||
throw new BadRequestException('订单已在退款处理中');
|
||||
}
|
||||
if (order.payStatus !== 'PAID') {
|
||||
throw new BadRequestException('订单未支付,无法退款');
|
||||
}
|
||||
|
||||
const outRefundNo = `RF-${order.orderNo}-${ticketId}`;
|
||||
const pendingLog = await this.prisma.logThirdParty.findFirst({
|
||||
where: {
|
||||
provider: 'WECHAT_REFUND',
|
||||
externalNo: outRefundNo,
|
||||
status: { in: ['PENDING', 'SUCCESS'] },
|
||||
},
|
||||
});
|
||||
if (pendingLog?.status === 'SUCCESS') {
|
||||
return { orderId: order.id.toString(), alreadyRefunded: true };
|
||||
}
|
||||
|
||||
const fromStatus = order.status;
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'REFUNDING', payStatus: 'REFUNDING' },
|
||||
});
|
||||
await this.prisma.commonEvent.create({
|
||||
data: buildOrderStatusEvent({
|
||||
orderId,
|
||||
fromStatus,
|
||||
toStatus: 'REFUNDING',
|
||||
operator: actorType,
|
||||
remark,
|
||||
}),
|
||||
});
|
||||
|
||||
let refundResult;
|
||||
try {
|
||||
refundResult = await this.payProvider.refundOrder(orderId, outRefundNo, remark);
|
||||
} catch (err) {
|
||||
this.alert.notify({
|
||||
level: 'P0',
|
||||
category: 'pay',
|
||||
title: '微信退款发起失败',
|
||||
detail: `订单 ${order.orderNo}\n${err instanceof Error ? err.message : String(err)}`,
|
||||
dedupeKey: `refund_init_fail|${order.orderNo}`,
|
||||
dedupeTtlSec: 120,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (refundResult.mode === 'mock' || refundResult.status === 'SUCCESS') {
|
||||
await this.handleRefundSuccess({
|
||||
outRefundNo,
|
||||
refundId: refundResult.mode === 'wechat' ? refundResult.refundId : `MOCK-REF-${outRefundNo}`,
|
||||
amountFen: Math.round(Number(order.payAmount) * 100),
|
||||
remark,
|
||||
actorType,
|
||||
});
|
||||
return { orderId: order.id.toString(), alreadyRefunded: false, completed: true };
|
||||
}
|
||||
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_REFUND',
|
||||
scene: 'ORDER_REFUND',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
externalNo: refundResult.refundId ?? outRefundNo,
|
||||
status: 'PENDING',
|
||||
amount: order.payAmount,
|
||||
},
|
||||
});
|
||||
|
||||
return { orderId: order.id.toString(), alreadyRefunded: false, completed: false, outRefundNo };
|
||||
}
|
||||
|
||||
/** 微信退款回调 / Mock 同步:幂等更新订单为已退款并作废权益 */
|
||||
async handleRefundSuccess(params: {
|
||||
outRefundNo: string;
|
||||
refundId: string;
|
||||
amountFen: number;
|
||||
remark?: string;
|
||||
actorType?: string;
|
||||
}) {
|
||||
const existingLog = await this.prisma.logThirdParty.findFirst({
|
||||
where: {
|
||||
provider: 'WECHAT_REFUND',
|
||||
externalNo: params.refundId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
if (existingLog) {
|
||||
return {
|
||||
orderId: existingLog.refId?.toString() ?? '',
|
||||
alreadyRefunded: true,
|
||||
};
|
||||
}
|
||||
|
||||
const match = params.outRefundNo.match(/^RF-(.+)-(\d+)$/);
|
||||
if (!match) {
|
||||
throw new BadRequestException('退款单号格式无效');
|
||||
}
|
||||
const [, orderNo] = match;
|
||||
const order = await this.prisma.order.findUnique({ where: { orderNo } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
|
||||
if (order.payStatus === 'REFUNDED') {
|
||||
return { orderId: order.id.toString(), alreadyRefunded: true };
|
||||
}
|
||||
|
||||
const expectedFen = Math.round(Number(order.payAmount) * 100);
|
||||
if (params.amountFen > 0 && params.amountFen !== expectedFen) {
|
||||
this.alert.notify({
|
||||
level: 'P0',
|
||||
category: 'pay',
|
||||
title: '退款金额不一致',
|
||||
detail: `订单 ${order.orderNo}\n期望 ${expectedFen} 分,回调 ${params.amountFen} 分`,
|
||||
dedupeKey: `refund_amount_mismatch|${order.orderNo}`,
|
||||
});
|
||||
throw new BadRequestException('退款金额与订单不符');
|
||||
}
|
||||
|
||||
const fromStatus = order.status;
|
||||
const operator = params.actorType ?? 'WECHAT_REFUND';
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.order.findUnique({ where: { id: order.id } });
|
||||
if (!current || current.payStatus === 'REFUNDED') return;
|
||||
|
||||
await tx.order.update({
|
||||
where: { id: order.id },
|
||||
data: { status: 'REFUNDED', payStatus: 'REFUNDED' },
|
||||
});
|
||||
await tx.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_REFUND',
|
||||
scene: 'ORDER_REFUND',
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
externalNo: params.refundId,
|
||||
status: 'SUCCESS',
|
||||
amount: order.payAmount,
|
||||
},
|
||||
});
|
||||
await tx.commonEvent.create({
|
||||
data: buildOrderStatusEvent({
|
||||
orderId: order.id,
|
||||
fromStatus,
|
||||
toStatus: 'REFUNDED',
|
||||
operator,
|
||||
remark: params.remark ?? '退款成功',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await this.benefitService.voidCouponsOnRefund(order.id);
|
||||
|
||||
return { orderId: order.id.toString(), alreadyRefunded: false };
|
||||
}
|
||||
|
||||
async listOrders(userId: bigint, tab = 'all', page = 1, pageSize = 20) {
|
||||
const statuses = orderTabToStatuses(tab);
|
||||
const where = {
|
||||
|
||||
Reference in New Issue
Block a user