可对账的计费账本模式:发票与付款
学习如何设计一个把发票、付款、贷项和调整分开存储的可对账计费账本架构,使财务能轻松对账和审计总额。

为什么计费数据会无法对账
对财务来说,“对账”是一个简单的承诺:报表中的总额与源记录匹配,并且每个数字都能被追溯。如果某个月显示已收 $12,430,你应该能指出确切的付款(以及任何退款),看到它们对应哪些发票,并用带日期的记录解释每一项差异。
计费数据通常在数据库存储的是“结果”而不是“事实”时开始无法对账。像 paid_amount、balance 或 amount_due 这样的列会被应用逻辑随时间更新。一次 bug、一次重试或一次人工“修正”就能悄然改变历史。几周后,发票表显示某张发票“已付”,但付款行并不相加,或者存在没有匹配贷项的退款。
另一个常见原因是把不同文档类型混在一起。发票不是付款;贷项单不是退款;调整不是折扣。当这些都被塞进一张带大量可选字段的“transactions”行时,报表就变成了猜测,审计也会变成争论。
根本的不匹配很简单:应用通常关心当前状态(“访问是否仍然激活?”),而财务关心的是轨迹(“发生了什么、何时以及为什么?”)。计费账本模式必须同时支持两者,但可追溯性必须占优。
为此设计时要达到的目标:
- 每个客户、每张发票和每个会计期间都有清晰的总额
- 每次变动都作为新行记录(而不是覆盖旧值)
- 发票到付款、贷项、退款和调整之间有完整链路
- 能从原始条目重新计算总额并得到相同结果
举例:如果客户付了 $100,然后获得 $20 的贷项,你的报表应该显示收到 $100、贷项 $20、净额 $80,而不去修改原始发票金额。
将发票、付款、贷项和调整分开存储
如果你想要一个能对账的计费账本架构,就把每种文档类型当作不同的事件。把它们混到一张“transactions”表看起来整洁,但会模糊含义。
一张发票是一个索赔:“客户欠我们钱”。把它作为一个文档存储,包含头信息(客户、发票号、开具日期、到期日、币种、总额)和独立的行项目(售卖内容、数量、单价、税类)。为了性能可以保存头部总额,但你应该总能从行项目解释这些总额。
一笔付款是钱的移动:"现金从客户流向我们"。在卡片流程中,你常会见到授权(银行批准)和捕获(实际扣款)。许多系统把授权作为运营记录,只把已捕获的付款放到账本里,以免高估现金报告。
贷项单减少客户欠款但不一定退钱。退款是现金流出。它们常同时发生,但并不相同。
- 发票:增加应收和收入(或递延收入)
- 付款:增加现金并减少应收
- 贷项单:减少应收
- 退款:减少现金
调整是团队在现实与记录不符时做出的更正。调整需要上下文以便财务能信任它们。记录是谁创建的、何时过账、原因代码和简短说明。示例:"因舍入写下 0.03",或"迁移遗留余额"。
一个实用规则:问自己,“如果没有人犯错,这个记录还会存在吗?”发票、付款、贷项单和退款仍会存在。调整应很少见、清晰标注且易于审查。
选择一个财务可审计的账本模型
可对账的计费账本架构从一个想法开始:文档描述发生了什么,账本分录证明总额。发票、付款或贷项单是文档;账本是一组相加得到期望总额的分录。
文档与分录(两者都存)
保留文档(发票头与行、付款收据、贷项单),因为人们需要阅读它们。但不要仅依赖文档总额作为对账的唯一真相。
相反,把每个文档以一条或多条不可变的分录过到账本表中。这样财务可以按账户、客户、币种和过账日期汇总分录,并每次得到相同答案。
一个简单且审计友好的模型遵循若干规则:
- 不可变分录:已过账金额不可编辑;变更产生新条目。
- 明确的过账事件:每个文档创建一个带唯一引用的过账批次。
- 平衡逻辑:分录在公司层面净和正确(通常借贷相等)。
- 分离日期:保留文档日期(客户看到的)和过账日期(计入报表的时间)。
- 稳定引用:在内部 ID 外同时存外部引用(发票号、支付处理器 ID)。
自然键 vs 代号 ID
在连接和性能上使用代号 ID,但也存一个在迁移和重新导入后仍然稳定的自然键。财务会在数据库 ID 变化很久之后仍然问“发票 INV-10483 在哪儿?”把发票号和提供方 ID(比如支付处理器的 charge ID)作为一等字段对待。
不删除历史的冲销
需要撤销时,不要删除或覆盖。过账一个冲销:新分录对原始金额取相反符号,并关联回原过账。
示例:一笔 $100 的付款被记错到错误发票,处理方式是:先冲销错误的分录,再把付款应用到正确的发票。
逐步架构蓝图(表和键)
当每种文档类型都有自己的表,并通过显式的分配记录连接(而不是以后猜测关系)时,计费账本更容易对账。
从一组核心表开始,每张表都有明确的主键(UUID 或 bigserial)和必要的外键:
- customers:
customer_id(PK),以及像external_ref这样的稳定标识(唯一) - invoices:
invoice_id(PK)、customer_id(FK)、invoice_number(唯一)、issue_date、due_date、currency - invoice_lines:
invoice_line_id(PK)、invoice_id(FK)、line_type、description、qty、unit_price、tax_code、amount - payments:
payment_id(PK)、customer_id(FK)、payment_date、method、currency、gross_amount - credits:
credit_id(PK)、customer_id(FK)、credit_number(唯一)、credit_date、currency、amount
然后添加使总额可审计的表:分配(allocations)。一笔付款或贷项可以覆盖多张发票,一张发票也可以由多笔付款支付。
使用带自己主键的关联表(而非仅用复合键):
- payment_allocations:
payment_allocation_id(PK)、payment_id(FK)、invoice_id(FK)、allocated_amount、posted_at - credit_allocations:
credit_allocation_id(PK)、credit_id(FK)、invoice_id(FK)、allocated_amount、posted_at
最后,把调整单独保存,这样财务可以看到发生了什么以及为什么。adjustments 表可以用 invoice_id(可空)引用目标记录并存储金额的增量,而不改写历史。
在所有与钱相关的过账处都添加审计字段:
created_at、created_byreason_code(坏账核销、舍入、善意减免、退单)source_system(manual、import、Stripe、support tool)
贷项、退款与坏账核销而不破坏总额
大多数对账问题始于把贷项和退款记录为“负付款”,或把坏账核销混入发票行。一套干净的计费账本让每种文档保持独立,交互仅通过显式分配进行。
贷项应说明为何减少客户欠款。如果它应用于一张发票,记录一张贷项单并把它分配到该发票;如果它跨多张发票生效,把同一贷项单分配到多张发票。贷项保持为单一文档,拥有多条分配记录。
退款是类似付款的事件,不是负付款。退款是现金流出,所以把它当成独立记录(通常会关联到原始付款作参考),然后像处理付款一样对其进行分配。这样当银行对账单同时显示入账付款与出账退款时,审计轨迹依然清晰。
部分付款和部分贷项的做法相同:在付款或贷项行保留总额,并用分配行记录它被应用到每张发票的金额。
防止重复计数的过账规则
这些规则能消除大多数“神秘差异”:
- 永远不要存负付款。用退款记录替代。
- 永远不要在过账后减少发票总额。使用贷项单或调整单。
- 文档一旦过账(带
posted_at时间戳)就不要编辑金额。 - 唯一改变发票余额的,是已过账分配的和。
- 坏账核销是带原因代码的调整,像贷项一样分配到发票。
税收、费用、币种与舍入的选择
大多数对账问题源于无法重建的总额。最安全的规则很简单:保存生成账单的原始行,同时也保存你向客户展示的总额。
税费:行级存储
在每个行项目上存税费金额,而不仅仅是发票级汇总。不同产品可能有不同税率,费用可能计税或不计税,豁免常常只适用于某部分发票。如果只存一个 tax_total,你最终会遇到无法解释的情况。
保留:
- 原始行(售卖内容、数量、单价、折扣)
- 计算后的行总额(
line_subtotal、line_tax、line_total) - 发票汇总(
subtotal、tax_total、total) - 使用的税率和税种
- 作为独立行的费用(如“支付处理费”)
这让财务能重建总额并确认税计算方法一致。
多币种:存发生了什么以及如何报告
如果支持多币种,请同时记录交易币种和报告币种的值。一个实用的最小集合是:在每个货币文档上存 currency_code,在过账时存 fx_rate,以及用于报表的兑换后金额(例如 amount_reporting)如果你的账簿以单一币种结算的话。
示例:客户以 100.00 EUR 开票,另加 20.00 EUR 的 VAT。存这些 EUR 的行与总额,以及过账时使用的 fx_rate 和换算后的报表总额。
舍入值得单独处理。选定一种舍入规则(按行或按发票)并坚持。当舍入产生差异时,把它显式记录为舍入调整行(或小额调整条目),而不是悄悄改动总额。
状态、过账日期以及不应作为真相存储的内容
当“状态”被用作会计真相的捷径时,对账会变得混乱。把状态当作工作流标签,而把已过账的账本分录作为真相。
让状态严格且无聊。每个状态都应回答:这个文档现在能影响总额吗?
- Draft:仅内部使用,未过账,不应出现在报表中
- Issued:已定稿并发送,准备过账(或已过账)
- Void:已取消;如果曾过账,必须冲销
- Paid:通过已过账的付款和贷项完全结清
- Refunded:通过已过账退款把钱退回
日期比多数团队预期的更重要。财务会问:“这笔属于哪个月份?”你的答案不该依赖 UI 活动日志。
issued_at:发票何时定稿posted_at:何时计入会计报表settled_at:何时资金清算或付款确认voided_at/refunded_at:冲销何时生效
不应把下列派生数字视为事实:你无法从账本重建的字段。例如 balance_due、is_overdue、customer_lifetime_value 仅作为缓存视图是可以的,但前提是你始终能从发票、付款、贷项、分配和调整中重新计算它们。
一个小例子:一次付款重试对接入网关发起两次请求。如果没有幂等键,你会存两条付款、把发票标为“已付”,然后财务会看到多出的 $100。为每次外部扣费尝试存唯一的 idempotency_key,并在数据库层拒绝重复。
财务期望从第一天就能得到的报表
当财务能快速回答基础问题并每次得到相同总额时,计费账本就证明了它的价值。
大多数团队从以下报表开始:
- 应收账款账龄:按客户和账龄区间(0-30、31-60 等)显示仍未清的金额
- 收到的现金:按付款过账日期按日、周、月统计的入账资金
- 收入 vs 现金:发票过账 vs 付款过账
- 导出审计路径:从一条 GL 导出行能钻回到生成它的确切文档和分配行
账龄报告是分配最关键的地方。账龄不是“发票总额减付款总额”,而是“某日期某张发票还有多少未结”。这需要存每笔付款、贷项或调整如何应用到具体发票以及这些分配何时过账。
收到的现金应由 payments 表驱动,而不是依赖发票状态。客户可以提前、延后或部分付款。
收入 vs 现金说明了发票与付款必须分离。例如:你在 3 月 30 日开了一张 $1,000 发票,4 月 5 日收到 $600,4 月 20 日开了 $100 的贷项。收入属于 3 月(发票过账),现金属于 4 月(付款过账),贷项在过账时减少应收。分配将这些联系起来。
示例场景:一个客户,四种文档类型
一个客户,一个月,四种文档类型。每种文档只存一次,资金通过分配表流动(有时称作“applications”)。这样最终余额易于重算且易于审计。
假设客户 C-1001 (Acme Co.)。
你创建的记录
invoices
| invoice_id | customer_id | invoice_date | posted_at | currency | total |
|---|---|---|---|---|---|
| INV-10 | C-1001 | 2026-01-05 | 2026-01-05 | USD | 120.00 |
payments
| payment_id | customer_id | received_at | posted_at | method | amount |
|---|---|---|---|---|---|
| PAY-77 | C-1001 | 2026-01-10 | 2026-01-10 | card | 70.00 |
credits(贷项单、善意贷项等)
| credit_id | customer_id | credit_date | posted_at | reason | amount |
|---|---|---|---|---|---|
| CR-5 | C-1001 | 2026-01-12 | 2026-01-12 | service issue | 20.00 |
adjustments(事后更正,而非新销售)
| adjustment_id | customer_id | adjustment_date | posted_at | note | amount |
|---|---|---|---|---|---|
| ADJ-3 | C-1001 | 2026-01-15 | 2026-01-15 | underbilled fee | 5.00 |
allocations(这才是真正使余额对上的东西)
| allocation_id | doc_type_from | doc_id_from | doc_type_to | doc_id_to | posted_at | amount |
|---|---|---|---|---|---|---|
| AL-900 | payment | PAY-77 | invoice | INV-10 | 2026-01-10 | 70.00 |
| AL-901 | credit | CR-5 | invoice | INV-10 | 2026-01-12 | 20.00 |
如何计算发票余额
对 INV-10,审计者可以从源行重算未结余额:
open_balance = invoice.total + sum(adjustments) - sum(allocations)
因此:120.00 + 5.00 - (70.00 + 20.00) = 35.00 未结。
要追溯这 35.00:
- 从发票总额(INV-10)开始
- 加上与该发票关联的已过账调整(ADJ-3)
- 减去每一条应用到发票的已过账分配(AL-900、AL-901)
- 确认每条分配都指向真实的来源文档(PAY-77、CR-5)
- 核验日期和
posted_at以解释时间线
破坏对账的常见错误
大多数对账问题不是“算术错误”。它们是规则缺失,因此同一现实事件会因为不同的人处理而用不同方式记录。
一个常见陷阱是使用负行作为捷径。负的发票行、负的付款和负的税行可能代表不同含义。如果允许负值,就必须定义一个严格的冲销策略(比如:只使用引用原始行的冲销行,且不要把冲销语义与折扣混用)。
另一个频繁原因是改写历史。如果发票已开,不要为了匹配新价格或更正地址而后续编辑它。保留原始文档并过账调整或贷项来解释变更。
通常会破坏总额的模式:
- 允许负行但没有严格的冲销规则及引用原始行
- 在发票发布后编辑旧发票而不是过账调整或贷项单
- 没有映射表就混用网关交易 ID 与内部 ID,且未明确唯一真相
- 依赖应用代码计算总额,而缺少支撑行(税、费、舍入、分配)
- 不把“钱移动”(现金流)与“钱被分配到哪张发票”(分配)分开存
最后一点引起最多混淆。示例:客户付 $100,然后你把 $60 应用到发票 A,$40 应用到发票 B。付款是一次现金移动,但产生两条分配。如果你只存“付款 = 发票”,就无法支持部分付款、超额付款或重新分配。
检查表与下一步
在添加更多功能前,先确保基础稳固。当每个总额都能追溯到具体行,且每次变更都有审计轨迹时,计费账本才算对账。
快速对账检查
在一个小样本(一个客户、一个月)上运行这些检查,然后在全量数据上验证:
- 报表上的每个已过账数字都能追溯到源行(发票行、付款、贷项单、调整)并带有过账日期和币种。
- 分配总额不超过它们所应用的文档(付款分配总和 ≤ 付款总额;贷项同理)。
- 不删除记录。错误条目用理由冲销,然后用新的已过账行更正。
- 未结余额可推导,而不是存为真相(发票未结 = 发票总额 - 已过账分配和贷项)。
- 文档总额与其行匹配(发票头总额等于行、税和费用之和,按你的舍入规则)。
上线前的下一步
当你的架构稳固后,围绕它构建操作性工作流:
- 管理界面以创建、过账和冲销发票、付款、贷项和调整,要求填写必需说明
- 一个对账视图并排显示文档与分配,包括谁何时过账
- 财务期望的导出(按过账日期、按客户、若有 GL 映射则按 GL)
- 期间结账工作流:锁定已结月份的过账日期,晚来的更正必须以冲销条目处理
- 测试场景(退款、部分付款、坏账核销)并确保结果与预期总额一致
如果你想更快构建内部财务门户,AppMaster (appmaster.io) 可以帮助你建模 PostgreSQL 架构、生成 API,并从同一来源构建管理界面,这样过账和分配规则在应用演进时保持一致。
常见问题
对账意味着报告中的每一个总额都可以从源记录重建并追溯到有日期的条目。如果报告显示你收到 $12,430,你应该能指出正好加起来的已过账付款和退款,而不依赖被覆盖的字段。
最常见的原因是把会变化的“结果”字段(例如 paid_amount 或 balance_due)当作事实来存储。若这些字段被重试、错误或人工修改更新,就会丢失历史轨迹,最终总额不再反映实际发生的事情。
因为每种文档代表不同的现实事件和会计含义。把它们挤在一条带很多可选字段的“transactions”记录里会模糊语义,导致报表变成猜测、审计变成争论。
贷项单会减少客户应付金额,但不一定把钱退回。退款是现金流出,通常与先前的付款关联。把两者混为一谈(或当作负付款)会让现金报表和银行对账变得很困难。
不要编辑或删除历史记录,而要过账冲销。创建与原始金额相反的新条目,并将其关联回原始过账,然后再过账正确的分配,这样审计轨迹会显示具体改动和原因。
使用明确的分配记录(applications)把付款或贷项连接到一个或多个发票,每条记录包含分配金额和过账日期。发票的未结余额应可由发票总额加上调整减去已过账分配计算得出。
既要存文档日期,也要存过账日期。文档日期是客户看到的时间,过账日期决定它何时出现在财务报表和期间结算中,这样月末总额不会因为有人后来在 UI 编辑记录而改变。
在行级别存税和费用的详细信息,并同时保留呈现给客户的确切总额。如果你只保留发票级别的 tax_total,在有混合税率或豁免的情况下最终会遇到无法解释的情况。
在交易币种中存金额,同时保存用于过账时的汇率及换算后的报表币种金额。选定一种舍入规则(按行或按发票)并坚持执行,把舍入差异显式记录为舍入调整条目,以便精确重建总额。
把状态作为工作流标签(Draft、Issued、Void、Paid 等),会计事实还是以已过账的账本条目和分配为准。状态可能出错;不可变的已过账条目让财务能以相同方式重算总额。


