《互联网产品的客户端架构·交易、增长与平台化》6.3 退款与售后状态

退款不是把订单状态改成已退款,而是挂在原支付单上的一组退款单:整单退与部分退、审核中与退款中、金额分摊与到账时间都各有口径。本节用四种状态、可退额度与最大余数分摊,给出一个可测试的 TypeScript 退款模型,并说明端上展示与渠道到账时间的差异。

问题:退款不是订单状态里的一个值

订单模型里常有一个 refunded 状态。很多客户端因此把退款理解成「把订单从已支付改成已退款」,用一个布尔值或一个状态位表达全部售后。真实退款不是这样。一笔订单可能只退一件商品,可能分两次退,可能第一次审核被拒后重新申请,也可能退款失败后换支付方式重试。订单只有一个 refunded,退款单却可以有多张,每张有自己的金额、状态与到账时间。端上要展示的不是「退没退」,而是「退了多少、退到哪张支付单、现在走到哪一步、预计什么时候到账」。这需要把退款单当成独立实体,而不是订单状态的一个附属字段。

整单退与部分退

类型判定典型场景展示重点
整单退退款金额等于支付单实付取消订单、整单退货全额原路退回,订单关闭
部分退退款金额小于支付单实付退一件商品、补偿差价本次退多少,剩余可退多少

整单退与部分退不是两个接口,而是同一套退款单的两种金额结果。端上不应该为它们写两套状态机,只需要在展示时区分「本次退款」与「订单剩余可退」。判定整单退的依据是支付单实付,不是订单当前金额。订单可能改过价、用过优惠,当前金额与实付金额不一定相等。用订单金额判断整单退,会把「实付 99、订单原价 129」的订单误判成部分退。

退款单必须挂在支付单上

退款退的是钱,钱是从某张支付单进来的,所以退款单必须挂在原支付单上,而不是只挂在订单上。这条约束带来三个直接结果:一个订单可以有多张退款单,分别挂在不同的支付单上或同一张支付单上分次退;可退额度按支付单计算,实付减去已退与在途才是当前额度,失败退款不占额度;累计退款不能超过实付,多张退款单之和超限时端上必须拒绝提交。把退款挂在订单上,会丢掉「哪张支付单退了多少」这个信息:用户换过渠道支付时,退款只能原路退回,端上无法解释钱退到了哪里。

四种状态与展示口径

状态含义展示文案用户可做什么
reviewing商家或平台审核中审核中,预计 1 个工作日内完成等待,可补充证据
refunding已发起退款,等待渠道处理退款中,按渠道给出到账时间等待
settled渠道确认退款到账已到账,展示到账时间无
failed渠道退款失败退款失败,可重试或联系客服重试,失败三次后转客服

状态迁移是白名单:审核中可以去退款中或失败,退款中可以去已到账或失败,失败可以重新进入退款中,已到账不能回退。端上不能因为用户刷新页面就把已到账改回退款中。失败次数要单独记录。第一次失败展示「可重新发起退款」,连续失败三次后展示「请联系客服」,避免用户无限重试同一笔注定失败的退款。

金额分摊:按行比例与最大余数

部分退款经常需要分摊到订单行,用于库存回补、优惠回收与财务记账。分摊不能简单四舍五入,否则各行之和会与退款金额差几分钱。正确做法是按行金额比例计算,再用最大余数法把剩下的分逐分派给小数部分最大的行。例如订单两行分别是 20 元和 30 元,退款 12.34 元:

第一行应得 1234 × 2000 / 5000 = 493.6,先取 493 分;第二行应得 1234 × 3000 / 5000 = 740.4,先取 740 分;合计 1233 分,还差 1 分,第一行小数部分更大,补 1 分。

最终分摊是 4.94 元与 7.40 元,合计精确等于 12.34 元。整单退时,分摊结果必须逐行等于行金额,不能有任何舍入。

到账时间按渠道说

用户最关心的是「什么时候到账」。端上不能统一写「1-3 个工作日」,因为不同渠道的清算周期不同。

渠道端上口径
支付宝预计 1-2 个工作日到账
微信支付预计 1-3 个工作日到账
银行卡预计 3-7 个工作日到账
其他预计 1-5 个工作日到账

退款模块的实现

下面的模块属于 L1:只有类型、校验与纯函数,时间由调用方传入,不访问网络,也不注册定时器。

// file: src/reconcile/refund.ts
// tier: L1
// verify: node --test test/reconcile/refund.test.ts

export type RefundState = "reviewing" | "refunding" | "settled" | "failed";

export interface RefundOrder {
  refundId: string; orderId: string; payId: string; amountCents: number;
  state: RefundState; reason: string; requestedAt: number; updatedAt: number;
  settledAt: number | null; attempts: number;
}

export interface PaymentForRefund { payId: string; orderId: string; paidCents: number; paid: boolean; }

export interface RefundLine { skuId: string; title: string; unitPriceCents: number; quantity: number; }

export interface RefundRequest { orderId: string; payId: string; amountCents: number; reason: string; }

export type RefundScope = "full" | "partial";

export interface RefundAllocation { skuId: string; cents: number; }

export interface RefundSummary { settledCents: number; inFlightCents: number; failedCents: number; }

export interface RefundView {
  label: string; hint: string; stage: number; settledAt: number | null;
  action: "none" | "wait" | "retry" | "contact";
}

const BUDGET_STATES: RefundState[] = ["reviewing", "refunding", "settled"];

const TRANSITIONS: Record<RefundState, RefundState[]> = {
  reviewing: ["refunding", "failed"],
  refunding: ["settled", "failed"],
  settled: [],
  failed: ["refunding"],
};

function assertCents(cents: number, label: string): void {
  if (!Number.isSafeInteger(cents) || cents <= 0) {
    throw new Error(`${label}必须是正整数分`);
  }
}

function assertPayment(payment: PaymentForRefund): void {
  if (payment.payId.trim() === "") throw new Error("支付单号不能为空");
  if (payment.orderId.trim() === "") throw new Error("订单号不能为空");
  assertCents(payment.paidCents, "实付金额");
  if (!payment.paid) throw new Error("支付单未支付,不能退款");
}

function assertRefunds(payment: PaymentForRefund, refunds: RefundOrder[]): void {
  for (const refund of refunds) {
    if (refund.payId !== payment.payId) {
      throw new Error(`退款单 ${refund.refundId} 不属于支付单 ${payment.payId}`);
    }
    assertCents(refund.amountCents, `退款金额 ${refund.refundId}`);
  }
}

// 已退与在途都占用额度,失败退款释放额度。
export function refundableCents(payment: PaymentForRefund, refunds: RefundOrder[]): number {
  assertPayment(payment);
  assertRefunds(payment, refunds);
  const used = refunds
    .filter((refund) => BUDGET_STATES.includes(refund.state))
    .reduce((sum, refund) => sum + refund.amountCents, 0);
  if (used > payment.paidCents) {
    throw new Error("历史退款金额已超过实付金额");
  }
  return payment.paidCents - used;
}

export function classifyRefund(
  payment: PaymentForRefund,
  refunds: RefundOrder[],
  amountCents: number,
): RefundScope {
  assertCents(amountCents, "退款金额");
  const remaining = refundableCents(payment, refunds);
  if (amountCents > remaining) {
    throw new Error(`退款金额超过可退额度:${amountCents} > ${remaining}`);
  }
  return amountCents === payment.paidCents ? "full" : "partial";
}

// 退款单必须挂在原支付单上,且只能退实付金额。
export function planRefund(
  payment: PaymentForRefund,
  refunds: RefundOrder[],
  request: RefundRequest,
  now: number,
): RefundOrder {
  assertPayment(payment);
  if (request.payId !== payment.payId) throw new Error("退款支付单号与支付单不一致");
  if (request.orderId !== payment.orderId) throw new Error("退款订单号与支付单不一致");
  const reason = request.reason.trim();
  if (reason === "") throw new Error("退款原因不能为空");
  if (!Number.isFinite(now)) throw new Error("申请时间必须是有限数字");
  const scope = classifyRefund(payment, refunds, request.amountCents);
  const seq = refunds.filter((refund) => refund.payId === payment.payId).length + 1;
  return {
    refundId: `rf-${payment.payId}-${seq}`,
    orderId: payment.orderId,
    payId: payment.payId,
    amountCents: request.amountCents,
    state: "reviewing",
    reason,
    requestedAt: now,
    updatedAt: now,
    settledAt: null,
    attempts: 0,
  };
}

// 状态迁移是白名单;失败可重试,已到账不可回退。
export function transitionRefund(
  refund: RefundOrder,
  to: RefundState,
  now: number,
): RefundOrder {
  if (!Number.isFinite(now)) throw new Error("状态时间必须是有限数字");
  if (!TRANSITIONS[refund.state].includes(to)) {
    throw new Error(`退款状态不能从 ${refund.state} 变为 ${to}`);
  }
  const next: RefundOrder = {
    ...refund,
    state: to,
    updatedAt: now,
    settledAt: to === "settled" ? now : null,
  };
  if (to === "failed") next.attempts = refund.attempts + 1;
  return next;
}

// 按行金额比例分摊退款,最大余数法保证分摊之和精确等于退款金额。
export function allocateRefund(lines: RefundLine[], amountCents: number): RefundAllocation[] {
  if (lines.length === 0) throw new Error("退款分摊需要至少一条订单行");
  if (!Number.isSafeInteger(amountCents) || amountCents < 0) {
    throw new Error("分摊金额必须是非负整数分");
  }
  let total = 0;
  for (const line of lines) {
    if (line.skuId.trim() === "") throw new Error("订单行缺少 SKU");
    if (!Number.isSafeInteger(line.unitPriceCents) || line.unitPriceCents < 0) {
      throw new Error(`单价非法:${line.skuId}`);
    }
    if (!Number.isInteger(line.quantity) || line.quantity < 1) {
      throw new Error(`数量非法:${line.skuId}`);
    }
    const lineTotal = line.unitPriceCents * line.quantity;
    if (!Number.isSafeInteger(lineTotal)) throw new Error(`行金额溢出:${line.skuId}`);
    total += lineTotal;
    if (!Number.isSafeInteger(total)) throw new Error("订单金额溢出");
  }
  if (amountCents > total) {
    throw new Error(`分摊金额超过订单总额:${amountCents} > ${total}`);
  }
  if (total === 0) {
    if (amountCents > 0) throw new Error("订单总额为零,不能分摊退款");
    return lines.map((line) => ({ skuId: line.skuId, cents: 0 }));
  }
  const shares = lines.map((line) => {
    const lineTotal = line.unitPriceCents * line.quantity;
    const product = amountCents * lineTotal;
    if (!Number.isSafeInteger(product)) throw new Error(`分摊计算溢出:${line.skuId}`);
    return {
      skuId: line.skuId,
      cents: Math.floor(product / total),
      fraction: product % total,
      index: 0,
    };
  });
  let remainder = amountCents - shares.reduce((sum, share) => sum + share.cents, 0);
  const order = shares
    .map((share, index) => ({ index, fraction: share.fraction }))
    .sort((a, b) => (a.fraction === b.fraction ? a.index - b.index : b.fraction - a.fraction));
  for (const item of order) {
    if (remainder === 0) break;
    shares[item.index].cents += 1;
    remainder -= 1;
  }
  const sum = shares.reduce((acc, share) => acc + share.cents, 0);
  if (sum !== amountCents) throw new Error("退款分摊结果与退款金额不一致");
  return shares.map((share) => ({ skuId: share.skuId, cents: share.cents }));
}

export function arrivalEstimate(channel: string): string {
  switch (channel.trim()) {
    case "alipay":
      return "预计 1-2 个工作日到账";
    case "wechat":
      return "预计 1-3 个工作日到账";
    case "card":
      return "预计 3-7 个工作日到账";
    default:
      return "预计 1-5 个工作日到账";
  }
}

// 端上只展示服务端给的退款状态,不自行推测到账。
export function refundView(refund: RefundOrder, channel = "unknown"): RefundView {
  switch (refund.state) {
    case "reviewing":
      return {
        label: "审核中",
        hint: "预计 1 个工作日内完成审核",
        stage: 1,
        settledAt: null,
        action: "wait",
      };
    case "refunding":
      return {
        label: "退款中",
        hint: arrivalEstimate(channel),
        stage: 2,
        settledAt: null,
        action: "wait",
      };
    case "settled":
      return {
        label: "已到账",
        hint: "退款已原路退回",
        stage: 3,
        settledAt: refund.settledAt,
        action: "none",
      };
    default:
      return refund.attempts >= 3
        ? {
            label: "退款失败",
            hint: "多次退款未成功,请联系客服",
            stage: 0,
            settledAt: null,
            action: "contact",
          }
        : {
            label: "退款失败",
            hint: "可重新发起退款",
            stage: 0,
            settledAt: null,
            action: "retry",
          };
  }
}

export function summarizeRefunds(refunds: RefundOrder[]): RefundSummary {
  let settledCents = 0;
  let inFlightCents = 0;
  let failedCents = 0;
  for (const refund of refunds) {
    assertCents(refund.amountCents, `退款金额 ${refund.refundId}`);
    if (refund.state === "settled") settledCents += refund.amountCents;
    else if (refund.state === "failed") failedCents += refund.amountCents;
    else inFlightCents += refund.amountCents;
  }
  return { settledCents, inFlightCents, failedCents };
}

refundableCents() 把已退与在途都算进占用,失败退款排除在外,并校验历史退款是否属于同一支付单;planRefund() 要求退款单同时匹配支付单号与订单号,金额不能超过可退额度,退款单号由支付单号加序号生成;allocateRefund() 用最大余数法分摊,并在返回前重新求和校验,整单退时结果自然等于各行金额;transitionRefund() 是白名单状态机,进入失败时累加尝试次数;refundView() 只把状态映射成文案与动作,不推测到账;summarizeRefunds() 分别汇总已退、在途与失败金额。

测试与真实输出

24 条用例分成五组:额度与分类(10 条)、分摊(5 条)、状态迁移(5 条)、展示与到账口径(3 条)、汇总(1 条)。全部是纯函数调用,没有网络与真实等待。本机运行结果如下:

$ node --test --test-concurrency=1 test/reconcile/refund.test.ts
✔ 可退金额扣除已退与在途 (0.65875ms)
✔ 失败退款不占用可退额度 (0.070625ms)
✔ 小于实付金额的退款是部分退 (0.066166ms)
✔ 等于实付金额的退款是整单退 (0.054333ms)
✔ 超过可退额度的退款被拒绝 (0.288333ms)
✔ 零或负退款金额被拒绝 (0.143ms)
✔ 未支付的支付单不能退款 (0.07525ms)
✔ 退款单必须挂在原支付单与原订单上 (0.14725ms)
✔ 多笔退款累计不能超过实付金额 (0.132083ms)
✔ 失败退款释放额度后可以重新申请整单退 (0.154416ms)
✔ 整单退按行金额分摊 (0.606417ms)
✔ 部分退分摊之和精确等于退款金额 (0.085334ms)
✔ 最大余数法把剩余分给小数部分更大的行 (0.0815ms)
✔ 分摊金额超过订单总额被拒绝 (0.061625ms)
✔ 空明细无法分摊退款 (0.062875ms)
✔ 审核中可以直接进入退款中 (0.042ms)
✔ 退款中到已到账时记录到账时间 (0.037208ms)
✔ 已到账的退款不能回退状态 (0.061667ms)
✔ 审核中可以直接失败并记录一次尝试 (0.034ms)
✔ 失败后可重新发起并累计失败次数 (0.048333ms)
✔ 四种退款状态有稳定的展示口径 (0.155125ms)
✔ 失败三次后引导联系客服 (0.037041ms)
✔ 按渠道给出到账时间口径 (0.037333ms)
✔ 汇总已退、在途与失败金额 (0.060042ms)
ℹ tests 24
ℹ suites 0
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 131.004875

一次「按订单金额退款」的超退复盘

某交易客户端的售后页按订单当前金额计算可退额度。订单原价 300 元,用户用了 100 元优惠券,实付 200 元。用户申请退一件 150 元的商品,端上算出「剩余可退 150 元」,允许提交。服务端按支付单实付校验,发现可退额度只有 200 元,第一笔 150 元通过,第二笔 60 元被拒绝;用户看到的是「退款失败」,客服看到的是「超额退款」,双方都无法从页面判断问题出在优惠券上。复盘发现三个缺口:可退额度用了订单金额而不是支付单实付;前端没有累计已退与在途退款;部分退款没有按行分摊,展示的是整单剩余。修复后,售后页按支付单展示实付、已退、在途与剩余可退;部分退款提交前先做行级分摊;超额时在端上直接拦截并解释原因。回归覆盖了优惠券订单、分两次退、失败后重试三个场景。这个复盘的教训是:退款额度只能从支付单的实付金额派生,订单金额与商品原价都不能作为额度依据。

小结

退款不是订单状态里的一个布尔值,而是挂在原支付单上的一组退款单。整单退与部分退是同一套状态机的两种金额结果,可退额度等于实付减去已退与在途,失败退款释放额度。端上展示只需要四种状态:审核中、退款中、已到账、失败。部分退款要按行金额比例分摊并用最大余数法补齐,到账时间按渠道给出区间,已到账后展示真实到账时间。

下一章从交易转向增长:优惠券的领取、可用性与叠加规则,如何在端上表达而不越权。

阅读导航:上一节:6.2 支付对账的数据口径 · 下一节:7.1 优惠券的可用性判定 。

常见错误与边界

  • 用一个 refunded 布尔值表达全部退款,无法区分部分退与多次退。
  • 用订单当前金额或商品原价计算可退额度,忽略优惠与实付。
  • 只校验单笔退款金额,不累计已退与在途退款。
  • 把失败退款也算进已退额度,用户永远退不满。
  • 部分退款直接四舍五入,各行分摊之和与退款金额差几分钱。
  • 状态机允许从已到账回退,刷新页面后状态来回跳。
  • 退款中统一写「1-3 个工作日」,不区分渠道清算周期。
  • 端上根据审核结果自行把退款单改成已到账,不等渠道确认。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查