feat: add commerce refund workflow

This commit is contained in:
Codex
2026-06-29 04:53:12 +08:00
parent ead1296f80
commit 24fd788b48
13 changed files with 977 additions and 13 deletions

View File

@@ -7,11 +7,14 @@ import {
createOrderRoute,
entitlementCheckRoute,
entitlementsRoute,
createRefundRequestRoute,
orderDetailRoute,
orderStatusRoute,
ordersRoute,
paymentNotifyRoute,
redeemActivationCodeRoute,
refundRequestsRoute,
updateRefundStatusRoute,
} from './routes.js';
export const commerceRoutes: RouteDefinition[] = [
@@ -21,6 +24,9 @@ export const commerceRoutes: RouteDefinition[] = [
['GET', '/api/commerce/orders/status', orderStatusRoute],
['GET', '/api/commerce/entitlements', entitlementsRoute],
['GET', '/api/commerce/entitlements/check', entitlementCheckRoute],
['GET', '/api/commerce/refunds', refundRequestsRoute],
['POST', '/api/commerce/refunds', createRefundRequestRoute],
['POST', '/api/commerce/refunds/status', updateRefundStatusRoute],
['POST', '/api/commerce/payments/create', createPaymentRoute],
['POST', '/api/commerce/payments/manual-confirm', confirmManualPaymentRoute],
['POST', '/api/commerce/payments/notify/wechat_pay', paymentNotifyRoute],

View File

@@ -11,7 +11,7 @@ import {
userIdFrom,
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { createOrderNo, grantSvipEntitlement } from './service.js';
import { createOrderNo, createRefundNo, grantSvipEntitlement } from './service.js';
import {
createPaymentProvider,
normalizePaymentProvider,
@@ -45,6 +45,7 @@ interface PaymentRow {
id: string;
status: string;
amount_cents: number;
refunded_amount_cents?: number;
}
interface PaymentOrderRow {
@@ -75,11 +76,41 @@ interface OrderDetailRow {
userId: string | null;
regionId: string | null;
paidAt: string | null;
refundedAmountCents?: number;
rawPayload: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
interface RefundRequestRow {
id: string;
refundNo: string;
orderId: string;
paymentId: string | null;
orderNo: string;
orderStatus: string;
paymentProvider: string | null;
provider: string | null;
providerRefundNo: string | null;
status: string;
amountCents: number;
reason: string | null;
entitlementAction: string;
requestedBy: string | null;
reviewedBy: string | null;
processedBy: string | null;
requestedAt: string;
reviewedAt: string | null;
processedAt: string | null;
succeededAt: string | null;
failedAt: string | null;
cancelledAt: string | null;
failureReason: string | null;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
interface CouponRow {
id: string;
code: string;
@@ -331,6 +362,7 @@ function orderDetailPayload(order: OrderDetailRow, payments: unknown[], items: u
productName: order.productName,
amountCents: order.amountCents,
amount: formatPrice(order.amountCents),
refundedAmountCents: order.refundedAmountCents ?? 0,
payMethod: order.payMethod,
payProvider: order.payProvider,
tradeNo: order.tradeNo,
@@ -347,6 +379,270 @@ function orderDetailPayload(order: OrderDetailRow, payments: unknown[], items: u
};
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function optionalChoice<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
if (typeof value === 'string' && allowed.includes(value as T)) return value as T;
return fallback;
}
function normalizeRefundNo(value: string) {
return value.trim().replace(/\s+/g, '').toUpperCase();
}
function refundPayload(row: RefundRequestRow) {
return {
id: row.id,
refundNo: row.refundNo,
orderId: row.orderId,
paymentId: row.paymentId,
orderNo: row.orderNo,
orderStatus: row.orderStatus,
paymentProvider: row.paymentProvider,
provider: row.provider,
providerRefundNo: row.providerRefundNo,
status: row.status,
amountCents: row.amountCents,
amount: formatPrice(row.amountCents),
reason: row.reason,
entitlementAction: row.entitlementAction,
requestedBy: row.requestedBy,
reviewedBy: row.reviewedBy,
processedBy: row.processedBy,
requestedAt: row.requestedAt,
reviewedAt: row.reviewedAt,
processedAt: row.processedAt,
succeededAt: row.succeededAt,
failedAt: row.failedAt,
cancelledAt: row.cancelledAt,
failureReason: row.failureReason,
metadata: row.metadata || {},
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async function recordCommerceAudit(
client: pg.PoolClient,
input: {
tenantId: string;
actorUserId?: string | null;
action: string;
targetType: string;
targetId: string | null;
details?: Record<string, unknown>;
},
) {
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, $4, $5, $6::jsonb)
`,
[
input.tenantId,
input.actorUserId || null,
input.action,
input.targetType,
input.targetId,
JSON.stringify(input.details || {}),
],
);
}
async function recordRefundEvent(
client: pg.PoolClient,
input: {
tenantId: string;
refundRequestId: string;
fromStatus?: string | null;
toStatus: string;
eventType: string;
actorUserId?: string | null;
details?: Record<string, unknown>;
},
) {
await client.query(
`
insert into public.commerce_refund_events (
tenant_id, refund_request_id, from_status, to_status, event_type, actor_user_id, details
)
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
`,
[
input.tenantId,
input.refundRequestId,
input.fromStatus || null,
input.toStatus,
input.eventType,
input.actorUserId || null,
JSON.stringify(input.details || {}),
],
);
}
async function fetchRefundById(client: pg.PoolClient, tenantId: string, refundId: 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.id = $2
limit 1
${lock ? 'for update of rr' : ''}
`,
[tenantId, refundId],
);
return result.rows[0] || null;
}
async function applySuccessfulRefund(
client: pg.PoolClient,
input: {
tenantId: string;
refund: RefundRequestRow;
actorUserId: string;
providerRefundNo?: string | null;
},
) {
const orderResult = await client.query<{
id: string;
orderNo: string;
status: string;
amountCents: number;
refundedAmountCents: number;
userId: string | null;
}>(
`
select id, order_no as "orderNo", status, amount_cents as "amountCents",
refunded_amount_cents as "refundedAmountCents", user_id as "userId"
from public.orders
where tenant_id = $1 and id = $2
limit 1
for update
`,
[input.tenantId, input.refund.orderId],
);
const order = orderResult.rows[0];
if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
if (!['paid', 'partially_refunded'].includes(order.status)) {
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_REFUNDABLE');
}
const nextRefundedAmount = order.refundedAmountCents + input.refund.amountCents;
if (nextRefundedAmount > order.amountCents) {
throw new HttpError(409, 'Refund amount exceeds paid amount', 'REFUND_AMOUNT_EXCEEDS_PAID');
}
const finalStatus = nextRefundedAmount === order.amountCents ? 'refunded' : 'partially_refunded';
await client.query(
`
update public.orders
set status = $4, refunded_amount_cents = $3, updated_at = now(),
raw_payload = coalesce(raw_payload, '{}'::jsonb) || $5::jsonb
where tenant_id = $1 and id = $2
`,
[
input.tenantId,
order.id,
nextRefundedAmount,
finalStatus,
JSON.stringify({
lastRefund: {
refundNo: input.refund.refundNo,
amountCents: input.refund.amountCents,
providerRefundNo: input.providerRefundNo || input.refund.providerRefundNo || null,
},
}),
],
);
if (input.refund.paymentId) {
const paymentResult = await client.query<PaymentRow>(
`
select id, status, amount_cents, refunded_amount_cents
from public.payments
where tenant_id = $1 and id = $2
limit 1
for update
`,
[input.tenantId, input.refund.paymentId],
);
const payment = paymentResult.rows[0];
if (!payment) throw new HttpError(404, 'Payment not found', 'PAYMENT_NOT_FOUND');
const paymentRefundedAmount = (payment.refunded_amount_cents || 0) + input.refund.amountCents;
if (paymentRefundedAmount > payment.amount_cents) {
throw new HttpError(409, 'Refund amount exceeds payment amount', 'REFUND_AMOUNT_EXCEEDS_PAID');
}
await client.query(
`
update public.payments
set status = $4, refunded_amount_cents = $3, updated_at = now(),
raw_payload = coalesce(raw_payload, '{}'::jsonb) || $5::jsonb
where tenant_id = $1 and id = $2
`,
[
input.tenantId,
payment.id,
paymentRefundedAmount,
paymentRefundedAmount === payment.amount_cents ? 'refunded' : 'partially_refunded',
JSON.stringify({
lastRefund: {
refundNo: input.refund.refundNo,
amountCents: input.refund.amountCents,
providerRefundNo: input.providerRefundNo || input.refund.providerRefundNo || null,
},
}),
],
);
}
let revokedEntitlements = 0;
if (input.refund.entitlementAction === 'revoke_on_success' && finalStatus === 'refunded') {
const revoked = await client.query(
`
update public.entitlements
set status = 'revoked',
revoked_at = now(),
revoked_by = $3,
revoked_reason = $4,
metadata = coalesce(metadata, '{}'::jsonb) || $5::jsonb
where tenant_id = $1
and source_type = 'order'
and source_id = $2
and status = 'active'
`,
[
input.tenantId,
order.id,
input.actorUserId,
`refund:${input.refund.refundNo}`,
JSON.stringify({
revokedByRefundNo: input.refund.refundNo,
refundAmountCents: input.refund.amountCents,
}),
],
);
revokedEntitlements = revoked.rowCount || 0;
}
return {
orderNo: order.orderNo,
orderStatus: finalStatus,
refundedAmountCents: nextRefundedAmount,
revokedEntitlements,
};
}
export async function createOrderRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
@@ -634,6 +930,7 @@ export async function ordersRoute(ctx: RequestContext) {
pay_method as "payMethod", pay_provider as "payProvider",
trade_no as "tradeNo", plan_id as "planId", days,
region_id as "regionId", paid_at as "paidAt",
refunded_amount_cents as "refundedAmountCents",
created_at as "createdAt", updated_at as "updatedAt"
from public.orders
where tenant_id = $1 and user_id = $2
@@ -653,7 +950,8 @@ async function loadOrderDetail(tenantId: string, userId: string, orderNo: string
product_name as "productName", amount_cents as "amountCents",
pay_method as "payMethod", pay_provider as "payProvider",
trade_no as "tradeNo", plan_id as "planId", days, user_id as "userId",
region_id as "regionId", paid_at as "paidAt", raw_payload as "rawPayload",
region_id as "regionId", paid_at as "paidAt",
refunded_amount_cents as "refundedAmountCents", raw_payload as "rawPayload",
created_at as "createdAt", updated_at as "updatedAt"
from public.orders
where tenant_id = $1 and user_id = $2 and order_no = $3
@@ -721,7 +1019,8 @@ export async function orderStatusRoute(ctx: RequestContext) {
product_name as "productName", amount_cents as "amountCents",
pay_method as "payMethod", pay_provider as "payProvider",
trade_no as "tradeNo", plan_id as "planId", days, user_id as "userId",
region_id as "regionId", paid_at as "paidAt", raw_payload as "rawPayload",
region_id as "regionId", paid_at as "paidAt",
refunded_amount_cents as "refundedAmountCents", raw_payload as "rawPayload",
created_at as "createdAt", updated_at as "updatedAt"
from public.orders
where tenant_id = $1 and user_id = $2 and order_no = $3
@@ -758,6 +1057,7 @@ export async function orderStatusRoute(ctx: RequestContext) {
status: order.status,
amountCents: order.amountCents,
amount: formatPrice(order.amountCents),
refundedAmountCents: order.refundedAmountCents ?? 0,
payProvider: order.payProvider,
payMethod: order.payMethod,
tradeNo: order.tradeNo,
@@ -865,6 +1165,319 @@ export async function entitlementCheckRoute(ctx: RequestContext) {
};
}
export async function refundRequestsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:refund:read');
const limit = intParam(ctx, 'limit', 50, 200);
const status = stringParam(ctx, 'status');
const orderNo = stringParam(ctx, 'orderNo');
const params: unknown[] = [auth.tenantId, limit];
const where: string[] = ['rr.tenant_id = $1'];
if (status) {
params.push(status);
where.push(`rr.status = $${params.length}`);
}
if (orderNo) {
params.push(orderNo);
where.push(`o.order_no = $${params.length}`);
}
const items = await 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 ${where.join(' and ')}
order by rr.created_at desc
limit $2
`,
params,
);
return { items: items.map(refundPayload) };
}
export async function createRefundRequestRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:refund:write');
const body = await readJsonBody(ctx);
const orderNo = requiredString(body, 'orderNo');
const requestedAmountCents = Math.max(0, optionalInteger(body, 'amountCents', 0));
const reason = optionalString(body, 'reason') || null;
const refundNo = normalizeRefundNo(optionalString(body, 'refundNo') || createRefundNo());
const entitlementAction = optionalChoice(body.entitlementAction, ['none', 'revoke_on_success'] as const, 'revoke_on_success');
const metadata = objectValue(body.metadata);
const item = await transaction(async client => {
const orderResult = await client.query<{
id: string;
orderNo: string;
status: string;
amountCents: number;
refundedAmountCents: number;
userId: string | null;
payProvider: string | null;
payMethod: string | null;
}>(
`
select id, order_no as "orderNo", status, amount_cents as "amountCents",
refunded_amount_cents as "refundedAmountCents", user_id as "userId",
pay_provider as "payProvider", pay_method as "payMethod"
from public.orders
where tenant_id = $1 and order_no = $2
limit 1
for update
`,
[auth.tenantId, orderNo],
);
const order = orderResult.rows[0];
if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
if (!order.userId) throw new HttpError(409, 'Order has no user', 'ORDER_USER_MISSING');
if (!['paid', 'partially_refunded'].includes(order.status)) {
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_REFUNDABLE');
}
const existingByRefundNo = await client.query<{ id: string; order_id: string; amount_cents: number }>(
`
select id, order_id, amount_cents
from public.commerce_refund_requests
where tenant_id = $1 and refund_no = $2
limit 1
for update
`,
[auth.tenantId, refundNo],
);
const existingRefund = existingByRefundNo.rows[0];
if (existingRefund) {
if (existingRefund.order_id !== order.id || (requestedAmountCents > 0 && requestedAmountCents !== existingRefund.amount_cents)) {
throw new HttpError(409, 'Refund number already belongs to another refund request', 'REFUND_NO_CONFLICT');
}
const refund = await fetchRefundById(client, auth.tenantId, existingRefund.id, true);
if (!refund) throw new HttpError(500, 'Refund request lookup failed', 'REFUND_LOOKUP_FAILED');
return { ...refundPayload(refund), idempotent: true };
}
const alreadySucceeded = await client.query<{ total: number }>(
`
select coalesce(sum(amount_cents), 0)::integer as total
from public.commerce_refund_requests
where tenant_id = $1
and order_id = $2
and status in ('requested', 'approved', 'processing', 'succeeded')
`,
[auth.tenantId, order.id],
);
const reservedAmount = Number(alreadySucceeded.rows[0]?.total || 0);
const remainingAmount = Math.max(0, order.amountCents - reservedAmount);
const amountCents = requestedAmountCents || remainingAmount;
if (amountCents <= 0) throw new HttpError(409, 'Order has no refundable amount', 'ORDER_NOT_REFUNDABLE');
if (amountCents > remainingAmount) {
throw new HttpError(409, 'Refund amount exceeds paid amount', 'REFUND_AMOUNT_EXCEEDS_PAID');
}
const paymentResult = await client.query<PaymentRow & { provider: string | null }>(
`
select id, provider, status, amount_cents, refunded_amount_cents
from public.payments
where tenant_id = $1
and order_id = $2
and status in ('paid', 'partially_refunded', 'refunded')
order by paid_at desc nulls last, updated_at desc
limit 1
for update
`,
[auth.tenantId, order.id],
);
const payment = paymentResult.rows[0];
if (!payment && amountCents > 0) throw new HttpError(404, 'Paid payment not found', 'PAYMENT_NOT_FOUND');
if (payment && amountCents > payment.amount_cents - (payment.refunded_amount_cents || 0)) {
throw new HttpError(409, 'Refund amount exceeds payment amount', 'REFUND_AMOUNT_EXCEEDS_PAID');
}
const inserted = await client.query<RefundRequestRow>(
`
insert into public.commerce_refund_requests (
tenant_id, order_id, payment_id, refund_no, provider, status, amount_cents,
reason, entitlement_action, requested_by, metadata
)
values ($1, $2, $3, $4, $5, 'requested', $6, $7, $8, $9, $10::jsonb)
returning id
`,
[
auth.tenantId,
order.id,
payment?.id || null,
refundNo,
payment?.provider || order.payProvider || null,
amountCents,
reason,
entitlementAction,
auth.userId,
JSON.stringify(metadata),
],
);
const refund = await fetchRefundById(client, auth.tenantId, inserted.rows[0].id, true);
if (!refund) throw new HttpError(500, 'Refund request was not created', 'REFUND_CREATE_FAILED');
await recordRefundEvent(client, {
tenantId: auth.tenantId,
refundRequestId: refund.id,
toStatus: refund.status,
eventType: 'created',
actorUserId: auth.userId,
details: { orderNo, amountCents, reason, entitlementAction },
});
await recordCommerceAudit(client, {
tenantId: auth.tenantId,
actorUserId: auth.userId,
action: 'commerce.refund.requested',
targetType: 'commerce_refund_request',
targetId: refund.id,
details: { refundNo: refund.refundNo, orderNo, amountCents, reason, entitlementAction },
});
return refundPayload(refund);
});
return { item };
}
export async function updateRefundStatusRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
const body = await readJsonBody(ctx);
const refundId = requiredString(body, 'refundId');
const action = requiredString(body, 'action');
const note = optionalString(body, 'note') || null;
const providerRefundNo = optionalString(body, 'providerRefundNo') || null;
const failureReason = optionalString(body, 'failureReason') || note;
const metadataPatch = objectValue(body.metadata);
const reviewActions = new Set(['approve', 'reject', 'cancel']);
requireTenantPermission(auth, reviewActions.has(action) ? 'tenant:refund:review' : 'tenant:refund:write');
const item = await transaction(async client => {
const refund = await fetchRefundById(client, auth.tenantId, refundId, true);
if (!refund) throw new HttpError(404, 'Refund request not found', 'REFUND_NOT_FOUND');
const fromStatus = refund.status;
let toStatus = fromStatus;
let eventType = action;
let processResult: Record<string, unknown> = {};
const details: Record<string, unknown> = { note, providerRefundNo, failureReason, metadata: metadataPatch };
if (action === 'approve') {
if (fromStatus !== 'requested') throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
toStatus = 'approved';
} else if (action === 'reject') {
if (!['requested', 'approved'].includes(fromStatus)) {
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
}
toStatus = 'rejected';
} else if (action === 'mark_processing') {
if (!['requested', 'approved'].includes(fromStatus)) {
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
}
toStatus = 'processing';
eventType = 'processing';
} else if (action === 'mark_succeeded') {
if (!['requested', 'approved', 'processing'].includes(fromStatus)) {
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
}
processResult = await applySuccessfulRefund(client, {
tenantId: auth.tenantId,
refund,
actorUserId: auth.userId,
providerRefundNo,
});
toStatus = 'succeeded';
eventType = 'succeeded';
details.processResult = processResult;
} else if (action === 'mark_failed') {
if (!['approved', 'processing'].includes(fromStatus)) {
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
}
toStatus = 'failed';
eventType = 'failed';
} else if (action === 'cancel') {
if (!['requested', 'approved'].includes(fromStatus)) {
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
}
toStatus = 'cancelled';
} else {
throw new HttpError(400, 'Unsupported refund action', 'REFUND_ACTION_INVALID');
}
const updated = await client.query<RefundRequestRow>(
`
update public.commerce_refund_requests
set status = $3,
provider_refund_no = coalesce($4, 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,
processed_at = case when $3 in ('processing', 'succeeded', 'failed') then now() else processed_at end,
succeeded_at = case when $3 = 'succeeded' then now() else succeeded_at end,
failed_at = case when $3 = 'failed' then now() else failed_at end,
cancelled_at = case when $3 = 'cancelled' then now() else cancelled_at end,
failure_reason = case when $3 = 'failed' then $6 else failure_reason end,
metadata = coalesce(metadata, '{}'::jsonb) || $7::jsonb,
updated_at = now()
where tenant_id = $1 and id = $2
returning id
`,
[
auth.tenantId,
refund.id,
toStatus,
providerRefundNo,
auth.userId,
failureReason,
JSON.stringify({ lastAction: action, note, processResult, ...metadataPatch }),
],
);
const updatedRefund = await fetchRefundById(client, auth.tenantId, updated.rows[0].id, true);
if (!updatedRefund) throw new HttpError(500, 'Refund request update failed', 'REFUND_UPDATE_FAILED');
await recordRefundEvent(client, {
tenantId: auth.tenantId,
refundRequestId: refund.id,
fromStatus,
toStatus,
eventType,
actorUserId: auth.userId,
details,
});
await recordCommerceAudit(client, {
tenantId: auth.tenantId,
actorUserId: auth.userId,
action: `commerce.refund.${eventType}`,
targetType: 'commerce_refund_request',
targetId: refund.id,
details: {
refundNo: refund.refundNo,
orderNo: refund.orderNo,
fromStatus,
toStatus,
amountCents: refund.amountCents,
providerRefundNo,
processResult,
},
});
return refundPayload(updatedRefund);
});
return { item };
}
export async function confirmManualPaymentRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const auth = await requireTenantAdmin(ctx);
@@ -900,6 +1513,12 @@ export async function confirmManualPaymentRoute(ctx: RequestContext) {
if (order.status === 'paid') {
return { orderNo, status: 'paid', idempotent: true };
}
if (['partially_refunded', 'refunded'].includes(order.status)) {
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_PAYABLE');
}
if (order.status !== 'pending') {
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_PAYABLE');
}
await client.query(
`
@@ -1216,6 +1835,12 @@ export async function paymentNotifyRoute(ctx: RequestContext) {
orderNo: order.order_no,
};
}
if (['partially_refunded', 'refunded'].includes(order.status)) {
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_PAYABLE');
}
if (order.status !== 'pending') {
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_PAYABLE');
}
await client.query(
`

View File

@@ -80,3 +80,7 @@ export function createOrderNo(prefix = 'SVIP') {
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `${prefix}${stamp}${random}`;
}
export function createRefundNo(prefix = 'RF') {
return createOrderNo(prefix);
}

View File

@@ -88,6 +88,9 @@ export function tenantPermissionCatalog() {
{ key: 'tenant:domains:write', label: '域名管理' },
{ key: 'tenant:payment:read', label: '商户配置查看' },
{ key: 'tenant:payment:write', label: '商户配置管理' },
{ key: 'tenant:refund:read', label: '退款查看' },
{ key: 'tenant:refund:write', label: '退款申请/处理' },
{ key: 'tenant:refund:review', label: '退款审核' },
{ key: 'tenant:auth:read', label: '登录配置查看' },
{ key: 'tenant:auth:write', label: '登录配置管理' },
{ key: 'tenant:secrets:read', label: '密钥掩码查看' },