Files
order_site/apps/backend/src/services/finance/order-refund-service.ts
T

940 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 订单退款(P2 手动先行):
* 1. 手动登记退款 → order_refunds(一条记录 = 一个订单项);
* 2. 整单退款按订单项拆成多条、共享 batch_no;
* 3. 全额退完后把订单 pay_status 置为 refunded,并关闭该订单项的成本异常;
* 4. 重算当天销售事实,净收入与毛利立即冲减。
*
* 后续接平台退款 API 时,只需写 source='api'/'platform' 的记录,其余逻辑复用。
*/
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import { createHttpError } from '../../utils/http.js'
import { generateRefundNo } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js'
import { syncChannelCostSnapshots } from '../cost-center/cost-snapshot-service.js'
import { approveKuaishouIndustryRefund } from '../platforms/kuaishou-industry/refund-service.js'
import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js'
export type OrderRefundRow = {
id: number
refund_no: string
batch_no: string
order_id: number
order_item_id: number
amount_fen: number
reason: string
note: string
source: string
status: string
refunded_at: string
operator_admin_user_id: number | null
operator_name?: string | null
voided_at: string | null
void_reason: string
created_at: string
sku_name?: string | null
}
export type OrderRefund = {
id: number
refundNo: string
batchNo: string
orderId: number
orderItemId: number
skuName: string
amountFen: number
reason: string
note: string
source: string
status: string
refundedAt: string
operatorAdminUserId: number | null
operatorName: string
voidedAt: string | null
voidReason: string
createdAt: string
}
export type CreateManualRefundInput = {
orderId: number | string
orderItemId?: number | string | null | undefined
amountFen?: number | string | null | undefined
reason?: string | undefined
note?: string | undefined
refundedAt?: string | undefined
/** 默认 manual;平台接口退款写 platform,平台回调写 api。 */
source?: 'manual' | 'api' | 'platform' | 'legacy'
/** 平台退款单号/外部流水号。 */
externalRef?: string
}
export async function listOrderRefunds(orderId: number | string): Promise<OrderRefund[]> {
const id = normalizeId(orderId, '订单 ID')
const result = await query<OrderRefundRow>(
`
SELECT
r.*,
oi.sku_name,
COALESCE(NULLIF(u.display_name, ''), u.username, '') AS operator_name
FROM order_refunds r
LEFT JOIN v_order_item_current oi ON oi.id = r.order_item_id
LEFT JOIN admin_users u ON u.id = r.operator_admin_user_id
WHERE r.order_id = $1
ORDER BY r.refunded_at DESC, r.id DESC
`,
[id],
)
return result.rows.map(mapOrderRefund)
}
export async function createManualRefund(
input: CreateManualRefundInput,
session: AdminSession | null,
): Promise<{
batchNo: string
refunds: OrderRefund[]
fullRefunded: boolean
closedExceptionCount: number
}> {
const orderId = normalizeId(input.orderId, '订单 ID')
const requestedItemId = input.orderItemId ? normalizeId(input.orderItemId, '订单项 ID') : null
const amountFen = normalizeAmountFen(input.amountFen)
if (requestedItemId && amountFen <= 0) {
throw createHttpError('退款金额必须大于 0', {
statusCode: 400,
errorCode: 'order_refund_amount_invalid',
})
}
if (!requestedItemId && amountFen > 0) {
throw createHttpError('整单退款不需要填写金额,系统按订单项剩余可退金额拆分', {
statusCode: 400,
errorCode: 'order_refund_amount_not_allowed',
})
}
const reason = normalizeText(input.reason, 200) || '手动退款'
const note = normalizeText(input.note, 500)
const refundedAt = normalizeTimestamp(input.refundedAt)
const source = normalizeRefundSource(input.source)
const externalRef = normalizeText(input.externalRef, 120)
const now = nowIso()
const written = await withTransaction(async (client) => {
const orderResult = await client.query<{
id: number
pay_status: string
basis_at: string
}>(
`
SELECT id, pay_status, COALESCE(paid_at, created_at) AS basis_at
FROM orders
WHERE id = $1
FOR UPDATE
`,
[orderId],
)
const order = orderResult.rows[0]
if (!order) {
throw createHttpError('订单不存在', {
statusCode: 404,
errorCode: 'order_refund_order_not_found',
})
}
const activeItems = await client.query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM v_order_item_current
WHERE order_id = $1 AND fulfillment_status <> 'archived'`,
[orderId],
)
if (Number(activeItems.rows[0]?.total || 0) > 0) {
// 只有断代后订单才影响财务;历史归档订单退款不校验期间。
await assertBasisPeriodOpen(client, order.basis_at, '退款')
await assertRefundMonthOpen(client, '退款', toChinaDate(refundedAt || now))
}
const itemsResult = await client.query<{
id: number
sku_name: string
revenue_fen: number
refunded_fen: number
}>(
`
SELECT
oi.id,
COALESCE(oi.sku_name, '') AS sku_name,
GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)::bigint AS revenue_fen,
COALESCE((
SELECT SUM(r.amount_fen)
FROM order_refunds r
WHERE r.order_item_id = oi.id AND r.status = 'confirmed'
), 0)::bigint AS refunded_fen
FROM v_order_item_current oi
WHERE oi.order_id = $1
ORDER BY oi.id
`,
[orderId],
)
const items = itemsResult.rows
if (items.length === 0) {
throw createHttpError('订单没有商品明细,无法退款', {
statusCode: 409,
errorCode: 'order_refund_items_missing',
})
}
const targets = requestedItemId
? items.filter((item) => Number(item.id) === requestedItemId)
: items
if (requestedItemId && targets.length === 0) {
throw createHttpError('订单项不属于该订单', {
statusCode: 400,
errorCode: 'order_refund_item_mismatch',
})
}
const rows: Array<{ itemId: number; amountFen: number }> = []
for (const item of targets) {
const remaining = Math.max(0, Number(item.revenue_fen) - Number(item.refunded_fen))
if (requestedItemId) {
if (amountFen > remaining) {
throw createHttpError(`退款金额超出可退金额(剩余 ${formatFen(remaining)})`, {
statusCode: 409,
errorCode: 'order_refund_amount_exceeds',
})
}
rows.push({ itemId: Number(item.id), amountFen })
} else if (remaining > 0) {
rows.push({ itemId: Number(item.id), amountFen: remaining })
}
}
if (rows.length === 0) {
throw createHttpError('该订单没有可退金额(可能已全部退款)', {
statusCode: 409,
errorCode: 'order_refund_nothing_left',
})
}
if (externalRef) {
const duplicate = await client.query<{ id: number }>(
`SELECT id FROM order_refunds
WHERE source = $1 AND external_ref = $2
LIMIT 1`,
[source, externalRef],
)
if (duplicate.rows[0]) {
throw createHttpError('该平台退款单号已登记过,不能重复退款', {
statusCode: 409,
errorCode: 'order_refund_external_ref_exists',
})
}
}
const batchNo = generateRefundNo(new Date(refundedAt))
const insertedIds: number[] = []
for (const [index, row] of rows.entries()) {
try {
const inserted = await client.query<{ id: number }>(
`
INSERT INTO order_refunds (
refund_no, batch_no, order_id, order_item_id, amount_fen,
reason, note, source, status, refunded_at, external_ref,
operator_admin_user_id, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, 'confirmed', $9::timestamptz, $10,
$11, $12::timestamptz, $12::timestamptz
)
RETURNING id
`,
[
`${batchNo}-${String(index + 1).padStart(2, '0')}`,
batchNo,
orderId,
row.itemId,
row.amountFen,
reason,
note,
source,
refundedAt,
externalRef,
session?.userId || null,
now,
],
)
insertedIds.push(Number(inserted.rows[0]?.id || 0))
} catch (error) {
if ((error as { code?: string }).code === '23505') {
throw createHttpError('该平台退款单号已登记过,不能重复退款', {
statusCode: 409,
errorCode: 'order_refund_external_ref_exists',
})
}
throw error
}
}
// 订单项都退完 → 订单级 pay_status 同步为 refunded(与平台全额退款口径一致)。
const totalResult = await client.query<{ revenue_fen: number; refunded_fen: number }>(
`
SELECT
COALESCE(SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)), 0)::bigint AS revenue_fen,
COALESCE((
SELECT SUM(r.amount_fen)
FROM order_refunds r
JOIN order_items ri ON ri.id = r.order_item_id
WHERE ri.order_id = $1 AND r.status = 'confirmed'
), 0)::bigint AS refunded_fen
FROM v_order_item_current oi
WHERE oi.order_id = $1
`,
[orderId],
)
const totalRevenue = Number(totalResult.rows[0]?.revenue_fen || 0)
const totalRefunded = Number(totalResult.rows[0]?.refunded_fen || 0)
const fullRefunded = totalRevenue > 0 && totalRefunded >= totalRevenue
if (fullRefunded && order.pay_status !== 'refunded') {
await client.query(
`UPDATE orders SET pay_status = 'refunded', updated_at = $1::timestamptz WHERE id = $2`,
[now, orderId],
)
}
// 已完成全额退款的订单项,成本异常直接闭环(退款单不再需要成本)。
const fullyRefundedItems = items
.filter((item) => {
const refunded = rows
.filter((row) => row.itemId === Number(item.id))
.reduce((sum, row) => sum + row.amountFen, 0)
return Number(item.refunded_fen) + refunded >= Number(item.revenue_fen)
})
.map((item) => Number(item.id))
let closedExceptionCount = 0
if (fullyRefundedItems.length > 0) {
const closed = await client.query(
`
UPDATE cost_exceptions
SET status = 'ignored',
resolved_at = $1::timestamptz,
resolved_by_admin_user_id = $2,
resolution_note = $3
WHERE status = 'open'
AND order_item_id = ANY($4::bigint[])
`,
[now, session?.userId || null, '订单已退款,成本异常自动关闭', fullyRefundedItems],
)
closedExceptionCount = closed.rowCount || 0
}
return {
batchNo,
insertedIds,
fullRefunded,
closedExceptionCount,
basisAt: order.basis_at,
}
})
// 比例类费用按净收入计:退款后重算相关订单项的费用快照,再回填事实。
const refundedItems = await query<{ order_item_id: number }>(
'SELECT DISTINCT order_item_id FROM order_refunds WHERE id = ANY($1::bigint[])',
[written.insertedIds],
)
try {
for (const row of refundedItems.rows) {
await syncChannelCostSnapshots({
afterId: Math.max(0, Number(row.order_item_id) - 1),
batchSize: 1,
maxBatches: 1,
})
}
} catch {
// 快照重算失败不影响退款本身,成本快照管道会兜底。
}
await refreshActiveRefundFacts(
refundedItems.rows.map((row) => Number(row.order_item_id)),
toChinaDate(written.basisAt),
)
const refunds = await listOrderRefunds(orderId)
return {
batchNo: written.batchNo,
refunds: refunds.filter((item) => written.insertedIds.includes(item.id)),
fullRefunded: written.fullRefunded,
closedExceptionCount: written.closedExceptionCount,
}
}
export async function voidOrderRefund(
refundId: number | string,
payload: { reason?: string },
session: AdminSession | null,
): Promise<{ refund: OrderRefund; rebuiltDate: string }> {
const id = normalizeId(refundId, '退款记录 ID')
const reason = normalizeText(payload.reason, 200) || '撤销退款'
const now = nowIso()
const context = await withTransaction(async (client) => {
const result = await client.query<{
id: number
order_id: number
status: string
pay_status: string
basis_at: string
}>(
`
SELECT r.id, r.order_id, r.status, o.pay_status, COALESCE(o.paid_at, o.created_at) AS basis_at
FROM order_refunds r
JOIN orders o ON o.id = r.order_id
WHERE r.id = $1
FOR UPDATE
`,
[id],
)
const row = result.rows[0]
if (!row) {
throw createHttpError('退款记录不存在', {
statusCode: 404,
errorCode: 'order_refund_not_found',
})
}
if (row.status !== 'confirmed') {
throw createHttpError('该退款记录已撤销', {
statusCode: 409,
errorCode: 'order_refund_already_voided',
})
}
const activeItems = await client.query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM v_order_item_current
WHERE order_id = $1 AND fulfillment_status <> 'archived'`,
[row.order_id],
)
if (Number(activeItems.rows[0]?.total || 0) > 0) {
await assertBasisPeriodOpen(client, row.basis_at, '撤销退款')
await assertRefundMonthOpen(client, '撤销退款')
}
await client.query(
`
UPDATE order_refunds
SET status = 'voided',
voided_at = $1::timestamptz,
void_reason = $2,
operator_admin_user_id = COALESCE($3, operator_admin_user_id),
updated_at = $1::timestamptz
WHERE id = $4
`,
[now, reason, session?.userId || null, id],
)
// 撤销后按“剩余 confirmed 退款”重算订单支付状态:
// 全额退款撤销必须把订单从 refunded 恢复为 paid,否则销售事实仍会按全额退款冲减。
const totals = await client.query<{ revenue_fen: number; refunded_fen: number }>(
`
SELECT
COALESCE(SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)), 0)::bigint AS revenue_fen,
COALESCE((
SELECT SUM(r.amount_fen)
FROM order_refunds r
JOIN order_items ri ON ri.id = r.order_item_id
WHERE ri.order_id = $1 AND r.status = 'confirmed'
), 0)::bigint AS refunded_fen
FROM v_order_item_current oi
WHERE oi.order_id = $1
`,
[row.order_id],
)
const totalRevenue = Number(totals.rows[0]?.revenue_fen || 0)
const totalRefunded = Number(totals.rows[0]?.refunded_fen || 0)
let nextPayStatus = row.pay_status
if (totalRevenue > 0) {
nextPayStatus = totalRefunded >= totalRevenue ? 'refunded' : 'paid'
}
await client.query(
`UPDATE orders SET pay_status = $1, updated_at = $2::timestamptz WHERE id = $3`,
[nextPayStatus, now, row.order_id],
)
// 退款时自动关闭的成本异常,撤销后重新打开,避免成本缺口被永久忽略。
await client.query(
`
UPDATE cost_exceptions
SET status = 'open',
resolved_at = NULL,
resolved_by_admin_user_id = NULL,
resolution_note = ''
WHERE status = 'ignored'
AND resolution_note = '订单已退款,成本异常自动关闭'
AND order_item_id IN (SELECT id FROM order_items WHERE order_id = $1)
`,
[row.order_id],
)
return { orderId: Number(row.order_id), basisAt: row.basis_at }
})
const rebuiltDate = toChinaDate(context.basisAt)
const voidedItem = await query<{ order_item_id: number }>(
'SELECT order_item_id FROM order_refunds WHERE id = $1',
[id],
)
await refreshActiveRefundFacts(
voidedItem.rows.map((row) => Number(row.order_item_id)),
rebuiltDate,
)
const refunds = await listOrderRefunds(context.orderId)
const refund = refunds.find((item) => item.id === id)
if (!refund) {
throw createHttpError('退款记录读取失败', {
statusCode: 500,
errorCode: 'order_refund_reload_failed',
})
}
return { refund, rebuiltDate }
}
export function mapOrderRefund(row: OrderRefundRow): OrderRefund {
return {
id: Number(row.id),
refundNo: row.refund_no,
batchNo: row.batch_no || '',
orderId: Number(row.order_id),
orderItemId: Number(row.order_item_id),
skuName: row.sku_name || '',
amountFen: Number(row.amount_fen),
reason: row.reason || '',
note: row.note || '',
source: row.source || 'manual',
status: row.status,
refundedAt: row.refunded_at,
operatorAdminUserId: row.operator_admin_user_id ? Number(row.operator_admin_user_id) : null,
operatorName: row.operator_name || '',
voidedAt: row.voided_at,
voidReason: row.void_reason || '',
createdAt: row.created_at,
}
}
async function assertRefundMonthOpen(
client: Pick<PoolClient, 'query'>,
action: string,
date?: string,
) {
const day = date || CHINA_DATE_FORMATTER.format(new Date())
const periodKey = day.slice(0, 7)
const period = await client.query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (period.rows[0]?.status === 'closed') {
throw createHttpError(`退款所属期间 ${periodKey} 已关账,请先反关账后再${action}`, {
statusCode: 409,
errorCode: 'order_refund_period_closed',
})
}
}
async function assertBasisPeriodOpen(
client: Pick<PoolClient, 'query'>,
basisAt: string,
action: string,
) {
const basisDate = toChinaDate(basisAt)
if (!basisDate) return
const periodKey = basisDate.slice(0, 7)
const period = await client.query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (period.rows[0]?.status === 'closed') {
throw createHttpError(`订单所属期间 ${periodKey} 已关账,请先反关账后再${action}`, {
statusCode: 409,
errorCode: 'order_refund_period_closed',
})
}
}
function normalizeId(value: unknown, label: string): number {
const id = Number(value || 0)
if (!Number.isFinite(id) || id <= 0) {
throw createHttpError(`${label}无效`, { statusCode: 400, errorCode: 'invalid_id' })
}
return Math.trunc(id)
}
function normalizeAmountFen(value: unknown): number {
const amount = Number(value || 0)
if (!Number.isFinite(amount) || amount < 0) return 0
return Math.round(amount)
}
function normalizeText(value: unknown, maxLength: number): string {
return String(value ?? '')
.trim()
.slice(0, maxLength)
}
function normalizeTimestamp(value: unknown): string {
const text = String(value || '').trim()
if (!text) return nowIso()
const date = new Date(text)
if (Number.isNaN(date.getTime())) return nowIso()
return date.toISOString()
}
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */
const CHINA_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
function toChinaDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return CHINA_DATE_FORMATTER.format(date)
}
/**
* 退款后回填事实与总账:只处理断代后的订单项。
* 历史归档订单的退款不进入新财务口径(业务侧自行处理)。
*/
async function refreshActiveRefundFacts(itemIds: number[], basisDate: string): Promise<void> {
const ids = [...new Set(itemIds.filter((id) => Number.isInteger(id) && id > 0))]
if (ids.length === 0 || !basisDate) return
const active = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM v_order_item_current
WHERE id = ANY($1::bigint[]) AND fulfillment_status <> 'archived'`,
[ids],
)
if (Number(active.rows[0]?.total || 0) === 0) return
await rebuildChannelSalesDailyFacts({ dateFrom: basisDate, dateTo: basisDate })
await syncFinanceLedgerFromFacts({ dateFrom: basisDate, dateTo: basisDate })
}
function formatFen(value: number): string {
return `¥${(Number(value || 0) / 100).toFixed(2)}`
}
/**
* 平台退款:先调用平台退款接口,成功后再落本地退款记录(source=platform)。
*
* 当前只接入快手行业(91)的「同意退款」接口;抖音/拼多多暂无可用退款 API,
* 会返回 501,由后台手工登记兜底。
*/
export async function approveAndRecordKuaishouRefund(
input: {
orderId: number | string
orderItemId?: number | string | null | undefined
amountFen?: number | string | null | undefined
platformRefundId?: string | undefined
reason?: string | undefined
note?: string | undefined
refundedAt?: string | undefined
sellerId?: string | undefined
refundHandingWay?: unknown
status?: unknown
negotiateStatus?: unknown
desc?: string | undefined
},
session: AdminSession | null,
) {
const orderId = normalizeId(input.orderId, '订单 ID')
const platformRefundId = normalizeText(input.platformRefundId, 120)
if (!platformRefundId) {
throw createHttpError('缺少平台退款单号', {
statusCode: 400,
errorCode: 'platform_refund_id_required',
})
}
// 平台退款必须在调用平台前完成本地参数校验:平台一旦同意,本地再失败会造成账实不一致。
const orderItemId = Number(input.orderItemId)
if (!Number.isInteger(orderItemId) || orderItemId <= 0) {
throw createHttpError('平台退款必须指定订单项', {
statusCode: 400,
errorCode: 'platform_refund_order_item_required',
})
}
const order = await query<{ provider: string; platform_order_id: string }>(
`SELECT provider, platform_order_id FROM orders WHERE id = $1 LIMIT 1`,
[orderId],
)
const row = order.rows[0]
if (!row) {
throw createHttpError('订单不存在', {
statusCode: 404,
errorCode: 'platform_refund_order_not_found',
})
}
if (!['91kaquan', 'kuaishou-91'].includes(String(row.provider || ''))) {
throw createHttpError('该平台暂未接入退款 API,请使用手工退款登记', {
statusCode: 501,
errorCode: 'platform_refund_not_supported',
})
}
const amountFen = normalizeAmountFen(input.amountFen)
if (amountFen <= 0) {
throw createHttpError('平台退款金额必须大于 0', {
statusCode: 400,
errorCode: 'platform_refund_amount_invalid',
})
}
const existingItem = await query<{ id: number; fulfillment_status: string }>(
`SELECT id, fulfillment_status
FROM v_order_item_current
WHERE id = $1 AND order_id = $2
LIMIT 1`,
[orderItemId, orderId],
)
if (!existingItem.rows[0]) {
throw createHttpError('订单项不存在或不属于该订单', {
statusCode: 404,
errorCode: 'platform_refund_order_item_not_found',
})
}
// 已关账期间不能直接改原期间事实;必须先反关账,否则平台退款后本地无法正确冲减。
const basis = await query<{ basis_at: string }>(
`SELECT COALESCE(paid_at, created_at) AS basis_at FROM orders WHERE id = $1 LIMIT 1`,
[orderId],
)
const platformItemArchived = existingItem.rows[0]?.fulfillment_status === 'archived'
if (!platformItemArchived && basis.rows[0]?.basis_at) {
const basisDate = toChinaDate(String(basis.rows[0].basis_at))
const periodKey = basisDate.slice(0, 7)
const period = await query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (period.rows[0]?.status === 'closed') {
throw createHttpError(`订单所属期间 ${periodKey} 已关账,请先反关账后再退款`, {
statusCode: 409,
errorCode: 'order_refund_period_closed',
})
}
}
// 退款当天所属期间也必须开着,否则退款冲减会静默丢失(历史归档订单除外)。
const refundPeriodKey = CHINA_DATE_FORMATTER.format(new Date()).slice(0, 7)
const refundPeriod = platformItemArchived
? { rows: [] as Array<{ status: string }> }
: await query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[refundPeriodKey],
)
if (refundPeriod.rows[0]?.status === 'closed') {
throw createHttpError(`退款所属期间 ${refundPeriodKey} 已关账,请先反关账后再退款`, {
statusCode: 409,
errorCode: 'order_refund_period_closed',
})
}
// 已经登记过的平台退款必须幂等返回,不能因为剩余可退为 0 被误判为超额。
const recorded = await findRecordedPlatformRefund(orderId, platformRefundId)
if (recorded) {
return {
approval: { idempotent: true, platformRefundId },
batchNo: recorded.batchNo,
refunds: [recorded],
fullRefunded: false,
closedExceptionCount: 0,
}
}
// 先做可退金额校验,再落退款意图;平台调用之后不再做“才发现金额超了”的校验。
const remainingFen = await getRefundableFen(orderId, orderItemId)
if (amountFen > remainingFen) {
throw createHttpError(`退款金额超出可退金额(剩余 ${formatFen(remainingFen)})`, {
statusCode: 409,
errorCode: 'order_refund_amount_exceeds',
})
}
const intent = await beginPlatformRefundIntent({
provider: '91kaquan',
externalRef: platformRefundId,
orderId,
orderItemId,
amountFen,
actorUserId: session?.userId || null,
})
const needsPlatformCall =
intent.status === 'pending' ||
(intent.status === 'failed' && !String(intent.error_message || '').startsWith('record_failed:'))
let approval: unknown = { idempotent: true, platformRefundId }
if (needsPlatformCall) {
try {
approval = await approveKuaishouIndustryRefund({
refundId: platformRefundId,
refundAmount: amountFen,
desc: input.desc || input.note || '',
refundHandingWay: input.refundHandingWay,
status: input.status,
negotiateStatus: input.negotiateStatus,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
})
await updatePlatformRefundIntent(intent.id, 'platform_approved')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await updatePlatformRefundIntent(intent.id, 'failed', `platform_call_failed:${message}`)
throw error
}
}
try {
const refund = await createManualRefund(
{
orderId,
orderItemId,
amountFen,
reason: input.reason || '平台退款',
note: input.note,
refundedAt: input.refundedAt,
source: 'platform',
externalRef: platformRefundId,
},
session,
)
await updatePlatformRefundIntent(intent.id, 'recorded')
return { approval, ...refund }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await updatePlatformRefundIntent(intent.id, 'failed', `record_failed:${message}`)
throw createHttpError(
`平台已同意退款,但本地记账失败:${message}。已保留退款意图,可用同一 platformRefundId 重试`,
{
statusCode: 502,
errorCode: 'platform_refund_record_failed',
},
)
}
}
type PlatformRefundIntentRow = {
id: number
status: string
error_message: string
}
async function beginPlatformRefundIntent(input: {
provider: string
externalRef: string
orderId: number
orderItemId: number
amountFen: number
actorUserId: number | null
}): Promise<PlatformRefundIntentRow> {
return withTransaction(async (client) => {
const existing = await client.query<PlatformRefundIntentRow>(
`SELECT id, status, error_message
FROM finance_refund_intents
WHERE provider = $1 AND external_ref = $2
FOR UPDATE`,
[input.provider, input.externalRef],
)
if (existing.rows[0]) return existing.rows[0]
try {
const inserted = await client.query<PlatformRefundIntentRow>(
`
INSERT INTO finance_refund_intents (
provider, external_ref, order_id, order_item_id, amount_fen,
status, error_message, created_by_admin_user_id, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, 'pending', '', $6, $7, $7)
RETURNING id, status, error_message
`,
[
input.provider,
input.externalRef,
input.orderId,
input.orderItemId,
input.amountFen,
input.actorUserId,
nowIso(),
],
)
return inserted.rows[0] as PlatformRefundIntentRow
} catch (error) {
// 并发请求同时插入时,唯一键冲突后读取已存在的意图,由调用方按状态决定是否重试。
if ((error as { code?: string }).code === '23505') {
const again = await client.query<PlatformRefundIntentRow>(
`SELECT id, status, error_message
FROM finance_refund_intents
WHERE provider = $1 AND external_ref = $2
LIMIT 1`,
[input.provider, input.externalRef],
)
return again.rows[0] as PlatformRefundIntentRow
}
throw error
}
})
}
async function updatePlatformRefundIntent(
id: number,
status: 'platform_approved' | 'recorded' | 'failed',
errorMessage = '',
) {
await query(
`UPDATE finance_refund_intents
SET status = $2, error_message = $3, updated_at = $4
WHERE id = $1`,
[id, status, errorMessage.slice(0, 1000), nowIso()],
)
}
async function getRefundableFen(orderId: number, orderItemId: number): Promise<number> {
const result = await query<{ remaining_fen: string }>(
`
SELECT
(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)
- COALESCE((
SELECT SUM(r.amount_fen)
FROM order_refunds r
WHERE r.order_item_id = oi.id AND r.status = 'confirmed'
), 0))::bigint AS remaining_fen
FROM v_order_item_current oi
WHERE oi.id = $1 AND oi.order_id = $2
LIMIT 1
`,
[orderItemId, orderId],
)
if (!result.rows[0]) {
throw createHttpError('订单项不存在或不属于该订单', {
statusCode: 404,
errorCode: 'platform_refund_order_item_not_found',
})
}
return Number(result.rows[0].remaining_fen || 0)
}
async function findRecordedPlatformRefund(orderId: number, externalRef: string) {
const result = await query<{ id: number }>(
`SELECT id FROM order_refunds
WHERE order_id = $1 AND source = 'platform' AND external_ref = $2 AND status = 'confirmed'
LIMIT 1`,
[orderId, externalRef],
)
if (!result.rows[0]) return null
const refunds = await listOrderRefunds(orderId)
return refunds.find((item) => item.id === Number(result.rows[0]?.id)) || null
}
function normalizeRefundSource(value: unknown): 'manual' | 'api' | 'platform' | 'legacy' {
const source = String(value || 'manual').trim()
if (source === 'manual' || source === 'api' || source === 'platform' || source === 'legacy') {
return source
}
throw createHttpError('退款来源无效', {
statusCode: 400,
errorCode: 'order_refund_source_invalid',
})
}