PAYATHON 2026

现有 web based application 的 Monthly Bill Payment Workflow

支付老李

明确结论

没有一套适用于所有 Web 应用的“月度账单支付工作流标准”。如果系统不接入 payment gateway,只负责录入交易,可以将模块设计为一套可审计的应收账款流程:

生成账单 → 确认账单 → 记录付款 → 核销账单 → 处理差额 → 关闭账期

设计时应将账单、付款记录和核销关系分开保存。不要只在账单表里增加一个 paid = true 字段,因为实际业务中常有部分付款、合并付款、重复录入、退款、冲销和账单调整。

对于内部登记工具,这套流程通常已经够用。如果数据还要用于正式会计核算、税务申报或受监管行业,则需要结合所在地法规和企业财务制度进一步确认,不能只依赖通用的软件设计经验。

推荐的状态与流程

1. 生成月度账单

系统按账期汇总客户当月的应付项目,并生成草稿账单:

DRAFT

草稿阶段可以增删账单明细、修改金额,但账单不应进入应收统计,也不能接受付款核销。

账单至少要包含:

  • 客户或账户标识
  • 唯一账单编号
  • 账期起止日期
  • 开票日期和到期日期
  • 币种
  • 账单明细
  • 税额、折扣和调整金额
  • 应付总额
  • 已核销金额
  • 未付余额
  • 当前状态

金额应根据明细重新计算,不能依赖前端提交的总额。

2. 确认账单

检查完成后确认账单:

DRAFT → ISSUED

确认后的账单应作为业务凭证保存。如果需要修改金额,最好新增调整项、贷项记录或替代账单,不要直接覆盖原始数据。

账单到期后仍有余额,可以显示为:

OVERDUE

OVERDUE 不一定要作为持久化状态保存,也可以根据 due_date 和未付余额动态计算,避免定时任务失败导致状态不一致。

3. 录入付款交易

收到线下转账、现金、支票或其他外部付款后,创建一条独立的付款记录:

RECORDED

付款记录建议保存以下信息:

  • 唯一交易编号
  • 客户标识
  • 金额和币种
  • 实际付款日期
  • 付款方式
  • 外部参考号,例如银行流水号
  • 录入人和录入时间
  • 备注
  • 状态
  • 幂等键

即使没有 payment gateway,也要区分“付款日期”和“系统录入时间”。补录历史交易时,这两个时间往往不同。

4. 将付款核销到账单

付款和账单通常不是简单的一对一关系:

  • 一笔付款可能用于支付多张账单。
  • 一张账单可能由多笔付款付清。
  • 付款金额可能小于或大于账单余额。

因此,需要单独建立核销表:

Payment 1 ──< PaymentAllocation >── 1 Invoice

完成核销后,根据剩余金额计算账单状态:

余额 = 账单应付总额 - 有效核销金额

状态可以按以下规则确定:

余额 = 应付总额  → ISSUED 或 OVERDUE
0 < 余额 < 应付总额 → PARTIALLY_PAID
余额 = 0 → PAID

一般不应允许余额小于零。超额付款可以保留为客户账户余额,供后续账单使用,也可以转入人工处理流程。

5. 冲销和更正

不要直接删除错误交易。更稳妥的处理方式是将原交易标记为已冲销,再创建一条方向相反的记录:

RECORDED → VOIDED

如果付款已经完成核销,应先撤销对应的核销记录,再处理付款记录。每次操作都要保留操作者、时间、原因以及操作前后的数据。

一个简化的数据模型

下面的 PostgreSQL 示例包含账单、付款和核销三类核心数据。实际使用时,还要按照税务、币种和权限要求扩展字段。

CREATE TABLE invoices (
    id               BIGSERIAL PRIMARY KEY,
    invoice_number   VARCHAR(50) NOT NULL UNIQUE,
    customer_id      BIGINT NOT NULL,
    period_start     DATE NOT NULL,
    period_end       DATE NOT NULL,
    issue_date       DATE,
    due_date         DATE,
    currency         CHAR(3) NOT NULL,
    total_amount     NUMERIC(18, 2) NOT NULL CHECK (total_amount >= 0),
    status           VARCHAR(30) NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (period_end >= period_start)
);

CREATE TABLE payments (
    id                 BIGSERIAL PRIMARY KEY,
    transaction_number VARCHAR(50) NOT NULL UNIQUE,
    customer_id        BIGINT NOT NULL,
    payment_date       DATE NOT NULL,
    currency           CHAR(3) NOT NULL,
    amount             NUMERIC(18, 2) NOT NULL CHECK (amount > 0),
    payment_method     VARCHAR(30) NOT NULL,
    external_reference VARCHAR(100),
    idempotency_key    VARCHAR(100) UNIQUE,
    status             VARCHAR(30) NOT NULL,
    created_by         BIGINT NOT NULL,
    created_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE payment_allocations (
    id          BIGSERIAL PRIMARY KEY,
    payment_id  BIGINT NOT NULL REFERENCES payments(id),
    invoice_id  BIGINT NOT NULL REFERENCES invoices(id),
    amount      NUMERIC(18, 2) NOT NULL CHECK (amount > 0),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (payment_id, invoice_id)
);

生产环境还需要通过数据库事务或服务层校验保证:

某笔付款的核销总额 ≤ 付款金额
某张账单的有效核销总额 ≤ 账单应付总额
付款客户 = 账单客户
付款币种 = 账单币种

普通的 CHECK 约束无法可靠检查跨行汇总,因此需要在事务中锁定相关记录并完成验证。

付款录入示例

下面的 TypeScript 伪代码主要展示事务、幂等性和并发控制。具体的 ORM API 需要根据项目所用框架调整。

type AllocationInput = {
  invoiceId: number;
  amount: string;
};

type RecordPaymentInput = {
  customerId: number;
  paymentDate: string;
  currency: string;
  amount: string;
  paymentMethod: string;
  externalReference?: string;
  idempotencyKey: string;
  allocations: AllocationInput[];
};

async function recordPayment(input: RecordPaymentInput, userId: number) {
  return db.transaction(async (tx) => {
    const existing = await tx.payment.findUnique({
      where: { idempotencyKey: input.idempotencyKey }
    });

    if (existing) {
      return existing;
    }

    const paymentAmount = new Decimal(input.amount);
    const allocatedAmount = input.allocations.reduce(
      (sum, item) => sum.plus(item.amount),
      new Decimal(0)
    );

    if (allocatedAmount.greaterThan(paymentAmount)) {
      throw new Error("Allocated amount exceeds payment amount");
    }

    const payment = await tx.payment.create({
      data: {
        customerId: input.customerId,
        paymentDate: input.paymentDate,
        currency: input.currency,
        amount: paymentAmount.toFixed(2),
        paymentMethod: input.paymentMethod,
        externalReference: input.externalReference,
        idempotencyKey: input.idempotencyKey,
        status: "RECORDED",
        createdBy: userId
      }
    });

    for (const item of input.allocations) {
      const invoice = await tx.invoice.findForUpdate(item.invoiceId);

      if (!invoice || invoice.customerId !== input.customerId) {
        throw new Error("Invoice does not belong to the customer");
      }

      if (invoice.currency !== input.currency) {
        throw new Error("Currency mismatch");
      }

      if (!["ISSUED", "PARTIALLY_PAID", "OVERDUE"].includes(invoice.status)) {
        throw new Error("Invoice cannot accept payment");
      }

      const outstanding = new Decimal(invoice.outstandingAmount);

      if (new Decimal(item.amount).greaterThan(outstanding)) {
        throw new Error("Allocation exceeds invoice balance");
      }

      await tx.paymentAllocation.create({
        data: {
          paymentId: payment.id,
          invoiceId: invoice.id,
          amount: item.amount
        }
      });

      const newBalance = outstanding.minus(item.amount);

      await tx.invoice.update({
        where: { id: invoice.id },
        data: {
          status: newBalance.isZero() ? "PAID" : "PARTIALLY_PAID"
        }
      });
    }

    await tx.auditLog.create({
      data: {
        entityType: "PAYMENT",
        entityId: payment.id,
        action: "PAYMENT_RECORDED",
        actorId: userId
      }
    });

    return payment;
  });
}

金额计算应使用数据库的 NUMERIC、Java 的 BigDecimal,或者 JavaScript 中可靠的 decimal library。不要用二进制浮点数直接处理金额,例如:

0.1 + 0.2 !== 0.3

API 设计建议

账单和付款可以设计为两类独立资源:

POST /api/invoices
POST /api/invoices/{invoiceId}/issue
GET  /api/invoices/{invoiceId}

POST /api/payments
POST /api/payments/{paymentId}/void
GET  /api/payments/{paymentId}

付款录入请求可以采用以下结构:

{
  "customerId": 10001,
  "paymentDate": "2026-08-31",
  "currency": "CNY",
  "amount": "1200.00",
  "paymentMethod": "BANK_TRANSFER",
  "externalReference": "BANK-20260831-001",
  "idempotencyKey": "payment-import-7f9b8f67",
  "allocations": [
    {
      "invoiceId": 501,
      "amount": "800.00"
    },
    {
      "invoiceId": 502,
      "amount": "400.00"
    }
  ]
}

服务端应忽略客户端提交的账单状态和未付余额,统一根据有效账单和核销记录计算。

通用规则和实践

保证幂等性

用户可能重复点击提交,批量导入任务也可能重复执行。每次录入都应携带唯一的 idempotencyKey。收到相同键的重复请求时,系统应返回原结果,而不是再创建一笔付款。

外部参考号不一定天然唯一。例如,不同银行可能生成相同编号。是否为该字段设置唯一约束,要根据具体来源决定。

保留审计记录

至少记录:

  • 谁创建、确认、修改或冲销了记录
  • 操作发生时间
  • 修改前后的值
  • 操作原因
  • 请求或批次标识

审计日志应由服务端生成,普通用户不能直接修改。

使用不可变凭证思路

已确认的账单和已录入的付款应尽量避免原地修改。发生错误时,通过调整、撤销或冲销记录处理。这样既能说明任意时点的余额为何发生变化,也便于财务对账。

做好并发控制

两名操作员可能同时为同一张账单录入付款。读取余额和写入核销记录必须放在同一个数据库事务中,并使用行锁或乐观锁,否则可能发生超额核销。

区分业务日期和系统时间

建议同时保留:

  • payment_date:业务上实际发生付款的日期
  • created_at:记录进入系统的时间
  • created_by:录入人员

账单也要区分账期、出账日期、到期日期和创建时间。

设置期间关闭机制

月度数据确认后,可以关闭账期。账期关闭后,只有具备特殊权限的人员才能通过调整分录修正历史记录,避免历史报表被随意改动。

定期对账

即使没有 payment gateway,也应提供对账页面,并支持按日期、客户、付款方式和外部参考号查询。至少要检查:

期初余额 + 本期账单 - 本期有效付款 ± 调整 = 期末余额

如果付款数据来自银行流水或其他系统,还要保留导入批次号和来源文件摘要,方便追踪重复导入或漏录。

容易遗漏的边界情况

设计和测试时应覆盖:

  • 部分付款
  • 一笔付款核销多张账单
  • 多笔付款核销一张账单
  • 未指定账单的预收款
  • 超额付款
  • 重复录入
  • 错误付款及冲销
  • 账单金额调整
  • 付款日期早于录入日期
  • 到期日落在节假日
  • 退款或负向调整
  • 不同币种付款
  • 月末关账后的补录
  • 并发核销同一张账单

如果系统不支持其中某种情况,应明确拒绝请求并给出容易理解的错误提示,不能静默修改金额。

实施顺序

可以先交付一个范围明确的最小版本:

  1. 建立账单、付款、核销和审计四类数据。
  2. 支持生成和确认草稿账单。
  3. 支持手工录入付款及部分核销。
  4. 使用事务、幂等键和并发控制保护数据。
  5. 支持付款冲销,不提供物理删除。
  6. 增加未付、部分支付、已支付和逾期查询。
  7. 增加月度汇总及对账报表。
  8. 根据实际业务补充预收款、退款、多币种和账期关闭。

这类模块的重点不在界面上设置了多少状态,而在于每一笔余额变化都有记录可查,重复操作不会造成重复记账,并且系统在任何时点都能根据原始记录重新计算出相同结果。

备注:内容仅供参考。