forked from wangziqi/gongxue-base
feat: add commerce refund workflow
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: '密钥掩码查看' },
|
||||
|
||||
Reference in New Issue
Block a user