feat(billing): 人工退款入口 —— paid 单冲销积分+置 refunded(幂等)
PAYMENT_DESIGN §5 承诺却只留了 OrderRefunded 常量、无入口。补齐全链路: - store.RefundOrder:订单 CAS(paid→refunded) 为主闸(重复退 changed=false 幂等), 同事务记 adjust 负分录(ref=订单号)+ 回退物化余额。照抄 MarkOrderPaid 双闸范式; 新增 idx_ledger_refund_ref 部分唯一索引(kind='adjust' AND ref<>'')做账本级兜底, 与 grant 索引对称、不与 admin 手工校正(ref 空)冲突。 - 积分若已消费,回退后余额可为负(人工退款预期,账本仍自洽,后续消费被硬拦截)。 - handler AdminRefundOrder + POST /admin/orders/:id/refund(admin 组已挂 Audit 留痕); 真渠道钱款原路退回需 admin 另在商户后台操作,本地仅冲销积分与订单态(不接自动退款 API)。 - admin 订单流加「退款」按钮(仅 paid 单可见,二次确认+填原因)。 - 测试:RefundOrder 冲销+幂等、只退 paid 两个不变量测试(sqlite 真 DB,余额=账本之和)。 gateway build/vet/test 全绿,admin tsc 干净。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -301,6 +301,19 @@ export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: bo
|
||||
return { diffs: d.diffs ?? [], ok: !!d.ok };
|
||||
}
|
||||
|
||||
// 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。
|
||||
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
|
||||
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {
|
||||
const res = guard(await fetch(`${ADMIN}/orders/${id}/refund`, {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ memo }),
|
||||
}));
|
||||
const d = (await res.json().catch(() => ({}))) as { status?: string; detail?: string; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `refund failed: ${res.status}`);
|
||||
return { status: d.status ?? "", detail: d.detail };
|
||||
}
|
||||
|
||||
// ---- 自动评测观测(真数据,来自 sundynix_eval)----
|
||||
export interface EvalDay {
|
||||
day: string; // YYYYMMDD
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { adminOrders, adminReconcile, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
|
||||
import { adminOrders, adminReconcile, adminRefundOrder, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
|
||||
|
||||
// 充值订单流 + 日终对账(P5.3 观测)。全平台充值单的落地视角:
|
||||
// - 状态计数卡片(pending/paid/expired + 累计到账额)
|
||||
@@ -27,6 +27,7 @@ export function OrderStream() {
|
||||
// 对账结果:null=未跑;[]=零差异;有元素=有差异
|
||||
const [diffs, setDiffs] = useState<ReconcileDiff[] | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [refunding, setRefunding] = useState(""); // 正在退款的订单 id
|
||||
|
||||
const load = useCallback(() => {
|
||||
adminOrders(filter)
|
||||
@@ -51,6 +52,27 @@ export function OrderStream() {
|
||||
}
|
||||
};
|
||||
|
||||
const refund = async (o: PayOrder) => {
|
||||
// 退款不可逆(冲销积分、订单置 refunded),且真渠道钱需另在商户后台原路退——二次确认 + 留原因。
|
||||
const memo = window.prompt(
|
||||
`确认退款?将冲销 ${credits(o.credits_micro)} 积分并把订单置为已退款(余额可能因积分已消费而变负)。\n` +
|
||||
`注意:微信真单的钱款原路退回需另在商户后台操作,此处仅冲销本地积分与订单态。\n\n请填写退款原因:`,
|
||||
"",
|
||||
);
|
||||
if (memo === null) return; // 取消
|
||||
setRefunding(o.id);
|
||||
setErr("");
|
||||
try {
|
||||
const r = await adminRefundOrder(o.id, memo);
|
||||
if (r.status === "noop") setErr(r.detail ?? "该订单无需退款");
|
||||
load();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setRefunding("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
@@ -117,7 +139,8 @@ export function OrderStream() {
|
||||
<th className="py-2 pr-3 font-medium">渠道</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">积分</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">金额</th>
|
||||
<th className="py-2 font-medium">状态</th>
|
||||
<th className="py-2 pr-3 font-medium">状态</th>
|
||||
<th className="py-2 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -128,16 +151,26 @@ export function OrderStream() {
|
||||
<td className="py-1.5 pr-3 text-xs text-gray-500">{CHANNEL_LABEL[o.channel] ?? o.channel}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-800">{credits(o.credits_micro)}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-500">{o.amount_fen > 0 ? yuan(o.amount_fen) : "—"}</td>
|
||||
<td className="py-1.5">
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${STATUS_BADGE[o.status] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{STATUS_LABEL[o.status] ?? o.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{o.status === "paid" ? (
|
||||
<button onClick={() => void refund(o)} disabled={refunding === o.id}
|
||||
className="rounded border border-rose-200 px-2 py-0.5 text-[11px] text-rose-600 hover:bg-rose-50 disabled:opacity-40">
|
||||
{refunding === o.id ? "退款中…" : "退款"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11px] text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{orders.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-6 text-center text-xs text-gray-400">暂无充值订单</td>
|
||||
<td colSpan={7} className="py-6 text-center text-xs text-gray-400">暂无充值订单</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user