# 计费系统的支付账本设计

> 如何设计复式、仅追加式支付账本：账户模型、平衡入账、幂等性、冲销、结算对账和多币种处理。
- **Author**: Ayush Agarwal
- **Published**: 2026-07-29
- **Category**: Engineering, Billing, Payments
- **URL**: https://dodopayments.com/blogs/zh/payment-ledger-design

---

故障报告称，一位客户被重复扣款。你打开 `transactions` 表，找到一行 `amount = 120.00` 且 `status = 'succeeded'` 的记录，但银行账单显示有两笔扣款。数据库中的任何信息都无法解释第二笔扣款，因为一行记录如果被原地更新，就不会保留它之前的内容。

支付账本通过将资金流动记录为不可变的复式记账记录，而不是可变的状态字段，解决了这个问题。每个事件都会生成一组借贷相等的分录；分录一旦入账便永远不会被编辑；任意时点的余额都可以通过重放分录推导出来。这就是整个设计，下面的一切都是它的结果。

这是一份面向构建计费系统的工程师的架构指南，涵盖账户模型、已捕获支付如何入账、为什么应使用冲销替代编辑、幂等性、如何根据处理器结算文件进行对账，以及多币种处理。

## 为什么支付账本必须采用复式记账

复式记账不是会计上的形式要求，而是一项运行时不变量，能在客户发现问题之前捕获你的错误。

在单式记账模型中，"客户支付了 120 美元"只是某处的一个数字。如果重试导致它被写入两次，或者部分退款时扣减错误，数据模型不会提出任何异议。在复式记账模型中，同一事件会被记录为两个或多个账户之间的资金流动，借方总额必须等于贷方总额。不平衡的入账会在写入时被拒绝。

这项约束会自动带来三个特性：

- **守恒性。** 在系统内部，资金不会凭空产生或消失，只会在账户之间转移。如果你的收入账户增加了，就一定有另一个账户减少，而且你可以明确指出是哪一个。
- **可解释性。** 每个余额都可以拆解为产生它的分录。"为什么这个商户的应付金额是 8,412.19 美元？"这是一个查询问题，而不是调查工作。
- **可审计性。** 由于分录永远不会被修改，账本本身就是审计日志。你不需要额外维护一个可能与实际情况逐渐偏离的变更追踪表。

代价是，你必须在编写代码前先从账户的角度思考。这是实际存在的成本，也是许多计费系统先从 transactions 表开始、之后再重写的原因。经历过这一过程的团队，通常能在我们关于 [计费系统迁移中的常见错误](https://dodopayments.com/blogs/billing-system-migration-mistakes) 和 [SaaS 中的收入流失](https://dodopayments.com/blogs/revenue-leakage-saas) 的笔记中认出这些症状。

## 账本不是交易表

这一区别非常重要，值得明确说明，因为两者在架构图中看起来很相似。

交易表记录的是**支付对象发生了什么变化**。它以支付为键，随着状态变化而修改，并针对回答"这笔扣款当前是什么状态"进行了优化。

账本记录的是**账户之间的资金流动**。它以分录为键，从不修改，并针对回答"截至这一时刻该账户的余额是多少，以及哪些资金流动产生了这个余额"进行了优化。

两者你都需要。交易表是处理器状态的运营视图；账本则是财务的系统记录来源。当团队把交易表当作财务事实来源时，问题就会出现，因为一个状态字段无法表达同一笔扣款上的部分退款、费用调整和汇率转换差额。

一个实用的测试是：如果删除账本，再根据交易表重建它，你能得到完全相同的余额吗？如果答案是"只要没发生什么异常就可以"，那你就没有真正的账本。我们关于 [SaaS 支付架构](https://dodopayments.com/blogs/payments-architecture-saas) 的完整介绍，会将这一点与其他技术栈组件放在一起说明。

## 支付账本的账户模型

账户分为五类：资产、负债、所有者权益、收入和费用。资产和费用在借方增加；负债、所有者权益和收入在贷方增加。你添加的每个账户都必须进行分类，因为分类决定了它的正常余额方向。

下面是一套适用于 SaaS 计费系统的账户表。

| 账户             | 类别 | 借记时机                       | 贷记时机                       |
| ---------------- | ---- | ------------------------------ | ------------------------------ |
| 客户应收款       | 资产 | 开具发票                       | 客户付款，或余额被核销         |
| 处理器应收款     | 资产 | 支付被捕获且资金仍由处理器持有 | 扣除费用、发起退款或支付结算款 |
| 银行现金         | 资产 | 结算款到账户                   | 支出或转出资金                 |
| 递延收入         | 负债 | 在服务期间确认收入             | 客户预付未来服务费用           |
| 应付税款         | 负债 | 向客户退税，或向机构缴税       | 在发票中收取税款               |
| 客户贷方余额     | 负债 | 贷方余额抵扣发票               | 向客户授予贷方余额             |
| 收入             | 收入 | 极少发生；应改用备抵账户       | 确认收入                       |
| 退款（收入备抵） | 收入 | 发起退款                       | 退款冲销                       |
| 处理费支出       | 费用 | 处理器扣除费用                 | 仅在冲销时                     |
| 平台费支出       | 费用 | 平台扣除费用                   | 仅在冲销时                     |
| 拒付损失         | 费用 | 争议败诉                       | 争议胜诉且资金退回             |
| 争议处理费支出   | 费用 | 处理器收取争议处理费           | 仅在冲销时                     |

建模时有两点需要注意。第一，将处理器应收款与现金分开。处理器确认收到的资金并不等于已经进入银行的资金；如果将两者合并，在整个结算周期内，你的现金状况都会不准确。第二，优先使用备抵账户，而不是直接借记收入，因为你需要分别报告总收入和退款，之后也一定会有人要求你提供这两项数据。相关差异请参阅 [总收入与净收入](https://dodopayments.com/blogs/gross-revenue-vs-net-revenue) 和 [账单金额与收入](https://dodopayments.com/blogs/billings-vs-revenue)。

如果你销售订阅服务，递延收入不是可选项。年度预付款会形成一项负债，并按月逐步转回，这正是 [递延收入](https://dodopayments.com/blogs/deferred-revenue-explained) 和 [SaaS 收入确认](https://dodopayments.com/blogs/saas-revenue-recognition) 所描述的情况。

## 已捕获支付的入账：必须保持平衡的分录

来看一个具体的销售案例。客户购买了一个 100.00 美元的套餐，税费为 20.00 美元，因此扣款总额为 120.00 美元。处理器收取 3.78 美元，平台收取 4.00 美元，最终结算金额为 112.22 美元。

```mermaid
flowchart LR
    A["Invoice issued
$120.00"] -->|"Dr Customer receivable"| B[Customer receivable]
    A -->|"Cr Revenue $100.00"| C[Revenue]
    A -->|"Cr Tax payable $20.00"| D[Tax payable]
    E["Payment captured
$120.00"] -->|"Cr Customer receivable"| B
    E -->|"Dr Processor receivable"| F[Processor receivable]
    G["Fees applied
$7.78"] -->|"Cr Processor receivable"| F
    G -->|"Dr Fee expense"| H[Fee expense]
    I["Payout settled
$112.22"] -->|"Cr Processor receivable"| F
    I -->|"Dr Cash"| J[Cash]
```

四笔入账如下。

**1. 开具发票。**

- 借：客户应收款 120.00
- 贷：收入 100.00
- 贷：应付税款 20.00

借方 120.00，贷方 120.00。

**2. 支付被捕获。**

- 借：处理器应收款 120.00
- 贷：客户应收款 120.00

借方 120.00，贷方 120.00。该发票的客户应收款现在为零。

**3. 费用入账。**

- 借：处理费支出 3.78
- 借：平台费支出 4.00
- 贷：处理器应收款 7.78

借方 7.78，贷方 7.78。

**4. 结算款到账。**

- 借：现金 112.22
- 贷：处理器应收款 112.22

借方 112.22，贷方 112.22。

现在检查处理器应收款账户：借记 120.00，随后贷记 7.78 和 112.22。120.00 减去 7.78，再减去 112.22，结果为 0.00。账户恰好结清，这说明你的模型在内部保持一致。

如果这张发票发生 60.00 美元的部分退款，其中包括 50.00 美元的商品金额和 10.00 美元的税费，入账如下：

- 借：退款 50.00
- 借：应付税款 10.00
- 贷：处理器应收款 60.00

借方 60.00，贷方 60.00。注意，应付税款需要借记，因为将税款退还给客户后，你应向税务机构缴纳的金额减少了。这里出错是导致多缴税款的常见原因。我们的 [退款管理](https://dodopayments.com/blogs/saas-refund-management) 指南介绍了运营层面的处理方式。

如果一笔完整的 120.00 美元交易发生争议且败诉，并产生 15.00 美元的争议处理费，入账如下：

- 借：拒付损失 120.00
- 借：争议处理费支出 15.00
- 贷：处理器应收款 135.00

借方 135.00，贷方 135.00。这与退款的区别不仅涉及会计，也会影响商业处理方式，详见 [拒付与退款的区别](https://dodopayments.com/blogs/chargeback-vs-refund) 和 [争议管理](https://dodopayments.com/blogs/dispute-management-guide)。

## 仅追加：入账冲销，永不编辑

已入账的分录是不可变的。如果分录有误，应先追加一笔冲销原分录的分录，再追加正确的分录。不要执行 `UPDATE`。

```mermaid
flowchart TD
    A[Build posting] -->|"debits != credits"| B[Rejected at write time]
    A -->|"idempotency key already seen"| C[Deduplicated, no-op]
    A -->|"balanced and new"| D[Posted]
    D --> E[Immutable forever]
    D -->|"error discovered"| F[Append reversing posting]
    F --> G[Append corrected posting]
    G --> E
```

假设你把一张实际应为 120.00 美元的发票按 130.00 美元入账：

- 原始分录：借记客户应收款 130.00，贷记收入 110.00，贷记应付税款 20.00
- 冲销分录：借记收入 110.00，借记应付税款 20.00，贷记客户应收款 130.00
- 更正分录：借记客户应收款 120.00，贷记收入 100.00，贷记应付税款 20.00

这三笔分录分别保持平衡。客户应收款的净影响为 130.00 减去 130.00，再加上 120.00，结果为 120.00。历史记录会显示发生了什么以及何时发生，这正是审计人员、客户支持人员和调试工程师都需要的信息。

三条规则可以让这一机制可靠运行：

- 每笔冲销分录都带有一个 `reverses_entry_id`，指向它所冲销的分录，这样你就能渲染出将已冲销分录对抵后的清晰视图。
- 一笔分录最多只能被冲销一次。通过对 `reverses_entry_id` 添加唯一约束来强制执行，而不要依赖应用逻辑。
- 冲销分录使用自己的时间戳。永远不要将更正ย้อนหลัง写入已经结账的会计期间；应在当前期间入账，并引用原始分录。

## 支付账本中的幂等性

账本写入必须具备幂等性，因为上游的所有环节都会重试。处理器 Webhook 会在超时时重试，后台任务会在部署时重试，你自己的 API 客户端也会在网络错误时重试。

实现方式是为入账分配一个确定性的幂等键，并在数据库中为它建立唯一索引。如果该键已经存在，写入操作就是空操作，并返回已有的分录。

```text
posting_key = hash(event_source, external_event_id, posting_type)

BEGIN
  INSERT INTO ledger_postings (posting_key, ...)
  ON CONFLICT (posting_key) DO NOTHING
  RETURNING id

  IF no row returned:
    RETURN existing posting  -- already applied, safe to ack

  INSERT INTO ledger_entries (posting_id, account_id, direction, amount) ...

  ASSERT sum(amount WHERE direction='debit')
       = sum(amount WHERE direction='credit')
COMMIT
```

有两个细节决定它是否真正有效。

幂等键必须根据源事件派生，而不是在调用时生成。对 `(provider, payment_intent_id, "capture")` 进行哈希，会在每次重试时生成相同的键。每次尝试都生成一个 UUID，则会为每次重试生成新键，这正是你想要避免的错误。

余额断言必须与插入操作在同一事务中执行。如果在之后执行，两个步骤之间发生崩溃，就会在系统记录中留下不平衡的入账。延迟约束或提交时触发器，比应用层检查更加可靠。

Webhook 传递使这一点更加具体。Dodo Payments 遵循 Standard Webhooks 规范，为每个事件发送唯一的 `webhook-id` 标头，并采用指数退避最多重试八次，因此同一事件合法地到达多次是完全可能的。[Webhook 文档](https://docs.dodopayments.com/developer-resources/webhooks) 详细说明了重试计划，[支付通知的 Webhook](https://dodopayments.com/blogs/webhooks-payment-notifications) 和 [支付 API 中的幂等键](https://dodopayments.com/blogs/idempotency-keys-payment-api) 则更深入地介绍了这一模式。

## 将支付账本与结算报告进行对账

你的账本反映的是"应该发生什么"；处理器的结算报告反映的是"实际发生什么"。对账就是证明两者一致的过程，也是账本能带来的最有价值的能力。

只要账户建模正确，检查就很直接。对于某一笔结算款，贷记处理器应收款且原因是 `payout` 的分录总额，应等于账单中的结算金额。在两笔结算款之间，所有针对处理器应收款的分录总额，应等于处理器报告的余额在同一时间窗口内的变动。

差异大致按出现频率排序，来源包括：

- **时间差。** 31 日入账的捕获交易可能在次月 2 日结算。这不是错误，但对账必须按结算日期，而不是捕获日期进行切分。
- **未建模的费用。** 汇率转换价差、跨境评估费、争议处理费和月度账户费都会改变余额。如果某种费用没有对应账户，它就会悄悄变成无法解释的差异。
- **舍入。** 使用小数汇率进行多币种转换会产生小于一分钱的余额。应为其设置专门的舍入账户，而不是将其计入收入。
- **被撤销的争议。** 争议发生时扣除资金、胜诉后退回资金，是两个独立的资金流动；如果将它们当作一对被取消的交易，就会漏掉费用。

应将对账构建为定时任务，输出差异报告，而不是依赖人工操作的电子表格。Dodo Payments 直接提供了所需数据：[余额账本分录](https://docs.dodopayments.com/api-reference/balance-ledger/list-ledger-entries) 可以按日期范围、事件类型、币种或引用对象筛选；[结算款明细端点](https://docs.dodopayments.com/api-reference/payouts/retrieve-breakup) 则会按事件类型返回结算款明细，涵盖支付、退款、争议和费用。同一数据的仪表盘视图请参阅 [余额](https://docs.dodopayments.com/features/account-summary-payout-wallet)。

关于这方面的运营流程，请参阅 [SaaS 支付对账](https://dodopayments.com/blogs/payment-reconciliation-saas) 和 [MoR（名义商户）的结算款与结算流程](https://dodopayments.com/blogs/mor-payouts-settlement-explained)。

## 多币种处理：不破坏不变量

借方等于贷方这一规则按币种分别成立，而不是跨币种成立。借记 100 EUR、贷记 108 USD 的入账并不平衡，它只是把两个不同事实强行拼在了一起。

可行的做法是，为每笔分录明确存储币种和以最小货币单位表示的金额，并且永远不要在同一笔入账中混用币种。当资金确实跨越币种时，应将其建模为由转换账户关联的两笔入账：

- 入账 A：借记外汇转换（EUR）100.00，贷记处理器应收款（EUR）100.00
- 入账 B：借记处理器应收款（USD）108.00，贷记外汇转换（USD）108.00

每笔入账都在自己的币种内保持平衡。外汇转换账户随后会在两种币种中分别保留余额，这是真实的经济敞口，应作为一项独立内容记录在账上，而不是隐藏在舍入差异中。

三条实现规则可以避免最多的麻烦：

- 按币种的最小单位将金额存储为整数。永远不要对金额使用浮点数，并记住并非所有币种都有两位小数。
- 在转换入账中记录汇率和汇率时间戳。否则，六个月后将无法重建"当时使用的是什么汇率"。
- 按账户、按币种分别维护余额。单一的"余额"字段会迫使你在读取时进行转换，从而使历史余额无法复现。

多币种定价也会带来产品层面的影响，详见 [面向全球 SaaS 的多币种定价](https://dodopayments.com/blogs/multi-currency-pricing-global-saas)。

## 数据库结构示例

最小可行结构是两张表，加上一个余额投影。

```sql
-- A posting is one balanced financial event.
CREATE TABLE ledger_postings (
  id            BIGSERIAL PRIMARY KEY,
  posting_key   TEXT NOT NULL UNIQUE,      -- idempotency
  posting_type  TEXT NOT NULL,             -- invoice_issued, capture, fee, payout, refund, dispute
  currency      CHAR(3) NOT NULL,          -- one currency per posting
  occurred_at   TIMESTAMPTZ NOT NULL,      -- business time
  recorded_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  reverses_id   BIGINT UNIQUE REFERENCES ledger_postings(id),
  external_ref  TEXT                       -- payment_id, payout_id, dispute_id
);

-- An entry is one leg of a posting. Never updated, never deleted.
CREATE TABLE ledger_entries (
  id          BIGSERIAL PRIMARY KEY,
  posting_id  BIGINT NOT NULL REFERENCES ledger_postings(id),
  account_id  BIGINT NOT NULL REFERENCES ledger_accounts(id),
  direction   TEXT NOT NULL CHECK (direction IN ('debit','credit')),
  amount      BIGINT NOT NULL CHECK (amount > 0)  -- minor units
);

CREATE INDEX ON ledger_entries (account_id, posting_id);
```

注意其中没有什么。分录上没有 `status` 字段，因为分录不存在生命周期。没有允许为负数的 `amount` 字段，因为方向字段已经承载了正负含义，而负数金额会让一笔分录伪装成自己的冲销。没有 `updated_at`，因为任何内容都不会被更新。

`occurred_at` 和 `recorded_at` 是有意分开的。业务时间是资金发生流动的时间；记录时间是你获知该事件的时间。延迟到达的 Webhook 意味着这两个时间经常不同，而报表需要能够按照任一时间轴运行。

当前余额应是基于分录构建的物化投影，并且可以从头重建。如果你无法删除余额表并准确地重新生成它，说明某些代码正在直接写入余额，而你的不变量已经被破坏。

对于正在评估自建还是采用现成方案的团队，我们关于 [面向开发者的计费 API](https://dodopayments.com/blogs/best-billing-apis-developers) 的比较，以及关于 [多租户计费架构](https://dodopayments.com/blogs/multi-tenant-billing-architecture) 的笔记，是很自然的后续阅读。托管方案的定价请参阅 [dodopayments.com/pricing](https://dodopayments.com/pricing)。

## 常见问题

### 什么是支付账本？

支付账本是对命名账户之间资金流动进行记录的、仅追加式复式记账记录，其中每个事件都会生成一组借贷相等的分录。它不同于交易表：它记录的是资金流动，而不是对象状态；它不会被原地更新；任何余额都可以通过重放分录推导出来。

### 为什么不能在支付状态变化时直接更新一行记录？

因为更新一行记录会破坏解释当前数值所需的历史。当客户对一笔扣款提出争议，或审计人员询问余额为何发生变化时，状态字段只能告诉你最终结果，无法说明过程。先发布冲销分录、再发布更正分录，可以同时保留这两部分事实。

### 如何避免重复的 Webhook 传递导致重复入账？

根据源事件确定性地派生幂等键，例如对处理器、外部事件 ID 和入账类型进行哈希，并为该键建立唯一索引。Dodo Payments 会发送唯一的 `webhook-id` 标头，并采用指数退避最多重试八次，因此重试是预期行为，而不是例外情况。

### 处理器余额和银行现金应该使用同一个账本账户吗？

不应该。处理器确认收到的资金尚未进入你的银行账户，将两者合并会导致整个结算周期内的现金状况都不准确。应保留一个处理器应收款资产账户：支付捕获时借记，发生费用、退款、争议和结算时贷记；同时单独设置一个现金账户，仅在结算款到账时借记。

### 如何处理多币种，同时不破坏复式记账？

按币种强制执行借贷相等规则，永远不要在一笔入账中混用币种。当资金确实发生兑换时，写入两笔由外汇转换账户关联的入账，每笔都在自己的币种内保持平衡，并在转换记录中保存汇率和汇率时间戳，以便历史余额能够复现。

## 结论

整个设计可以归结为四条规则：按币种记录保持平衡的分录；已入账的分录永远不修改，而是追加冲销分录；从源事件而不是从尝试本身派生每个幂等键；将处理器应收款和现金设为独立账户，让结算报告对账有明确的落点。

团队通常是在事故发生后，而不是事故发生前，才开始采用这一方案，这也是重写如此常见的原因。如果你还来得及做选择，就选择账本。额外的建模工作只需几天，而它能消除的错误类别，影响的却是客户信任。

如果你更愿意使用现成账本而不是自行构建，[Dodo Payments](https://dodopayments.com) 通过 API 提供余额账本分录和结算款明细，因此对账可以变成针对结构化数据运行的定时任务，而不是解析 CSV 导出的文件。
---
- [More Engineering articles](https://dodopayments.com/blogs/category/engineering)
- [All articles](https://dodopayments.com/blogs)