Skip to content

期间与账务模型 ​

事件流回答「发生了什么」,账务回答「某时点确定是多少」——后者要靠投影得到,不能替代前者。


为什么需要两条时间轴 ​

传统系统只有一条时间:记录时间。但账务必须回答「这笔业务发生在哪一期」, 审计必须回答「我们是什么时候知道的」。

两者不等是常态,也是跨期调整的根源:

业务发生   2026-09-28
系统记录   2026-10-03   ← 隔了一个月才录

如果只有一条时间,跨期归属就只能靠猜。所以拆成两条:

轴字段用途
业务发生occurredAt归属期间、余额计算
系统记录at审计「何时知道」、复放顺序

并且标明时间来源的可信度:

值含义后果
declared调用方明确声明可用于账务
source来自外部系统可用于账务
inferred未提供,回退为记录时间不能进被重开过的期间

最后一条是硬约束:重开意味着「账已经结过,现在补记」,补记必须知道真实发生时间。


为什么是「按单结」而不是「自然月」 ​

一单生意常常跨月:9 月下单、10 月交付、11 月才结算。

如果按自然月归集,这一单会被拆到三个期间,每次都要做跨期调整。 所以默认按单开期间:

periodId = `${scope}:${refId}`
   order:o-123   /  task:t-123      ← 按单
   calendar:2026-10                 ← 自然月汇总(只读,不参与门禁)

结算那一刻就出分录,不攒到月末。自然月视图只是把这些分录按发生时间再分桶一次, 只读、可重算、不影响权威数据。


为什么门禁在存储层 ​

「已关闭的期间不能写」这条规则,如果放在应用层:

typescript
// ❌ 换个调用入口就绕过了
if (period.status === 'closed') throw new Error('期间已关闭');
await state.write(...);

所以它被放进唯一的写入口:

StateService.writeMany()
  → 域声明缓存
  → PeriodGuard.assertWritable()    ← 这里
  → prepareWrite()

三条细节:

规则为什么
只看 /periods/{id}/... 子路径根路径也拦的话,close() 之后无法 reopen(),自己锁死
关闭后子路径一律 409不允许「自动挪到下一期」——自动挪会掩盖错误
已重开过的期间拒绝 inferred补记必须知道真实发生时间

为什么只做四个科目 ​

完整会计体系有收入、成本、费用、税、资产、负债……那是财务软件的领域。

平台只需要回答一件事:托管池里的钱怎么分。

科目含义
escrow_held托管中、尚未归属的资金
payable_supplier应付供给方
payable_platform应付平台服务费
refundable_buyer应退买方

四个科目足够让每一笔结算可入账、可核对、可追责。再往上做,成本会指数增长, 而收益不在当前的定位里。


分录为什么是「结构保证」的 ​

借 escrow_held        amount
贷 payable_supplier   toAgent
贷 refundable_buyer   toBuyer
贷 payable_platform   toPlatform

托管层已经保证了 toAgent + toBuyer + toPlatform === amount。 所以借贷平衡不是靠记账代码自觉,而是上游不变量的直接结果。

这也是为什么记账挂在 settle() 这个唯一出口——所有结算路径都会经过它。


七条不变量 ​

#不变量
I1每笔分录 Σdebit === Σcredit
I2期间内 Σdebit === Σcredit
I3Σ结算分录 === 托管金额
I4已关闭期间不接受新分录
I5跨期调整必须指向已关闭期间
I6分录不可修改,只能冲销
I7单笔分录币种一致

边界 ​

不做原因
通用会计引擎(多准则、税务)会变成财务软件,与定位冲突
多币种换算与重估需要真实跨境单才能定口径
报表与 BI状态层可直接出数,不建分析系统
银行直连接真钱是独立决策

继续读 ​

Released under the MIT License.