以下情境为教学假设,人物与数字不代表经核实的真实案例。
月度结账不是把银行流水抄进一张表,而是让三张表互相证明:现金变动能解释期末余额,收入能解释收款与应收/递延的差,税费能解释税前利润,权益能解释净利润,资产能解释负债与权益。五件事都成立,这个月的数字才可以拿去做复盘、预算和估值。任何一件不成立,先别解释业务,先找那笔对不上的分录。
这一节只讲核对闭环,不讲具体会计软件怎么点。软件会换,勾稽关系不会换;把五步写清楚,换工具只是换录入界面。
结账不是记账,是让三张表互相证明
记账回答「发生了什么」,结账回答「这些记录能不能互相证明」。银行流水是事实,收入确认是判断,税费计提是估计,三张表是把事实、判断和估计放在一起检查。一个人公司的结账之所以容易糊弄,是因为没有第二个人问「这笔为什么在这个科目」;勾稽检查就是那个第二个人。
月度结账要有五个产出:
| 产出 | 回答的问题 | 没有它会怎样 |
|---|---|---|
| 分类后的流水 | 每一笔钱属于收入、成本、费用还是权益 | 毛利率和费用率都不可信 |
| 调整后的收入 | 应收、递延、退款之后的真实收入 | MRR 和利润表对不上 |
| 税费计提 | 这个月已经产生的纳税义务 | 现金看起来多,实际欠税 |
| 勾稽报告 | 三张表能不能互相对上 | 错了也不知道错在哪 |
| 锁定版本 | 这个月的数字以后还能不能复算 | 下个月改了口径,历史无法比较 |
五个产出对应五步:分类、调整、计提、勾稽、锁版。顺序不能反。先锁版再勾稽,等于把错误封存;先计提再调整收入,税费会跟着错。每一步都要留下证据:流水编号、发票号、合同条款、税率依据,而不是只留下一个结果。
结账的频率是月度,不是因为会计要求月度,而是因为人的记忆和业务的变化都以月为单位。季度太慢,问题会积累;每周太快,调整项还没有形成。月度关账的目标是「下个月 5 日前完成」,不是「月底最后一天通宵」。
五步闭环:分类、调整、计提、勾稽、锁版
| 步骤 | 输入 | 输出 | 失败信号 |
|---|---|---|---|
| 1. 流水分类 | 银行对账单、支付平台结算单 | 按收入、变动成本、运营费用、工资、税费、递延、应收、其他分类的流水 | 有未分类项;同一笔重复;个人账户与公司账户混用 |
| 2. 应收/递延调整 | 订阅账单、合同、退款记录 | 应收余额、递延收入余额、当月确认收入 | 收款与收入差无法用应收/递延解释 |
| 3. 税费计提 | 税前利润、税率、已收/已付税额 | 所得税费用、应交税费、GST/VAT 余额 | 税费勾稽不为零;税率与注册地不一致 |
| 4. 勾稽检查 | 三张表的期末数 | 五项检查的差异清单 | 任一差异非零;差异被「四舍五入」掉 |
| 5. 锁定版本 | 分类、调整、计提、勾稽结果 | 带版本号和日期的结账包 | 锁版后无记录地改数;历史版本被覆盖 |
第一步的关键是「分类先于判断」。先把每一笔钱按性质归类,再讨论它属于哪个月。分类错了,后面的调整和计提都会错。最常见的分类错误是把创始人的个人支出记进公司费用,或者把客户的预付款直接记成收入;前者会虚增费用,后者会虚增当月收入并漏掉递延义务。
第二步的关键是「收入不等于收款」。LedgerLite 平均账期 Net 15,年末应收 12,000;年付客户占 30%,年末递延收入 22,000。收款、开票、确认收入是三个时点:客户先付年费,公司先记递延,再按月确认;客户后付月费,公司先记应收,收到钱再冲应收。月度结账要把这三个时点的差额解释清楚。
第三步的关键是「税费是负债,不是费用」。所得税按税前利润计提,GST/VAT 按销项减进项计提;两者都不是「等报税时再说」。LedgerLite 的所得税率是 17%,GST 是 9%;GST 的具体金额取决于当月销项和进项,不能用一个固定比例拍出来。计提不足会让现金看起来比实际多,计提过多会让利润看起来比实际少。
第四步是五次勾稽,下一节用 LedgerLite 的年度数逐条走。第五步是锁版:结账完成后打一个版本号,例如「2026-10-close-v1」,任何后续修改都生成 v2 并写明原因。锁版不是为了审计,是为了下个月复盘时知道「当时看到的是哪一版数字」。没有版本号,三个月后无法判断一个决策是基于哪版报表做的。
LedgerLite 的一个月:从流水到三张表
先把年度表摊成月均,方便阅读。月均数只用于看结构,勾稽仍用年度数,避免 12 次四舍五入后对不上。
| 项目 | 年度 | 月均(÷12) | 口径 |
|---|---|---|---|
| 收入 | $240,240 | $20,020.00 | ARR ÷ 12 |
| 变动成本 | $38,000 | $3,166.67 | 云、支付、支持 |
| 毛利 | $202,240 | $16,853.33 | 84.2% |
| 运营费用 | $72,000 | $6,000.00 | 营销、工具、外包、法律保险、其他 |
| SDE | $130,240 | $10,853.33 | 毛利 − 运营费用 |
| 创始人薪酬 | $60,000 | $5,000.00 | 假设 |
| 税前利润 | $70,240 | $5,853.33 | SDE − 薪酬 |
| 所得税 | $11,941 | $995.08 | 17% × 税前利润,示例 |
| 净利润 | $58,299 | $4,858.25 | 税前 − 所得税 |
| 经营现金流 | $64,299 | $5,358.25 | 含递延 +$8,000、应收 −$2,000 |
月均表里有两个数不能直接拿来勾稽。第一,所得税年度是 11,941,按 17% 精确计算是 11,940.80;月均 995.08 是 11,941 ÷ 12 的近似。第二,净利润年度 58,299 是四舍五入后的展示值,精确值是 58,299.20。勾稽要用精确值,否则会出现 20 美分的「假差异」。
年度层面的五次勾稽如下:
| 检查 | 公式 | LedgerLite 代入 | 差异 |
|---|---|---|---|
| 现金滚动 | 期初现金 + 经营 + 投资 + 筹资 = 期末现金 | 33,701 + 64,299 − 3,000 + 0 = 95,000 | 0 |
| 收入勾稽 | 收款 + 应收增加 − 递延增加 = 收入 | 246,240 + 2,000 − 8,000 = 240,240 | 0 |
| 税费勾稽 | 正税前利润 × 税率 = 税费计提 | 70,240 × 17% = 11,940.80 | 0 |
| 权益滚动 | 期初权益 + 净利润 − 分红 = 期末权益 | 21,700.80 + 58,299.20 = 80,000 | 0 |
| 资产负债表 | 资产 = 负债 + 期末权益 | 110,000 = 30,000 + 80,000 | 0 |
这五条不是随便选的。现金滚动检查银行余额;收入勾稽检查应收和递延;税费勾稽检查计提;权益滚动检查利润有没有进入权益;资产负债表检查最后的总平衡。任何一条不为零,都能把问题缩小到一个方向:现金差异看未达账项和投资支出,收入差异看应收/递延,税费差异看税率和计提时点,权益差异看分红和期初数,资产负债表差异看科目归类。
收入勾稽的符号最容易写反。LedgerLite 的收款是 246,240,比收入 240,240 多 6,000;这 6,000 来自递延收入增加 8,000 减去应收增加 2,000。客户先付年费,现金比收入多;客户后付月费,现金比收入少。公式写成「收款 + 应收增加 − 递延增加 = 收入」,等价于「收款 = 收入 − 应收增加 + 递延增加」。两个写法都对,关键是全公司只用一种。
税费勾稽有一个边界:亏损月份不计提所得税。如果税前利润为负,税费应该是 0,而不是「负税率」;亏损结转是税务申报层面的事,不在月度计提里做。LedgerLite 的 17% 是示例税率,实际税率要按当期法规和注册地核对。
三个案例的结账差异
同一套五步,三个案例的证据不同:
| 结账难点 | LedgerLite | Tidecast | Nord Studio |
|---|---|---|---|
| 收入证据 | 订阅账单、MRR 桥 | 平台月结单、官网支付流水 | 工时表、咨询发票、产品订阅 |
| 应收/递延 | 应收 $12,000;递延 $22,000 | 平台月结 30 天;退款 4% | 应收占收入 7.3%;账期 Net 30 |
| 税费 | 所得税 17%;GST 9% | 平台代收销售税;个人边际 24% | 公司税 25%;VAT 20% |
| 关账重点 | 递延与应收的勾稽 | 季节性与退款准备 | 工时与发票、合伙人时间 |
Tidecast 的流水看起来最简单:平台月结、官网支付,收入已经扣掉退款和平台分成。但它的月度收入是脉冲,12 月 2,400 份、1 月 480 份,单月结账会把季节波动当成趋势。它的关账要多做一步「滚动三个月净得」,并把退款准备和平台结算时差单独列出来;平台月结 30 天意味着 12 月的销售可能 1 月才到账,现金滚动和收入确认不能混在一个时点。
Nord Studio 的流水最复杂:咨询按小时计费,产品按月订阅,两位合伙人的时间既是成本也是收入来源。咨询收入要拿工时表乘 $85/小时,再和发票、收款三方核对;产品收入要按 90 家 × $39 确认,月流失 3.0% 会影响递延余额。它的应收占收入 7.3%,逾期率 8%,坏账准备 2%;这些不是月底才看的数字,而是结账时要写进调整项的估计。
三个案例的共同点是「先有证据,再有分录」。LedgerLite 的证据是订阅账单和 MRR 桥,Tidecast 的证据是平台结算单,Nord 的证据是工时表和发票。没有证据的分录,即使借贷平了,也只是把差异藏起来。
把勾稽写成代码
下面的 monthly-close.ts 做两件事:先把银行流水按八类汇总,再跑五次勾稽。summarizeFlows 保留每一类的净额、总流入和总流出;reconcileClose 把现金、收入、税费、权益、资产负债表五条检查一次算完,差异不为零就保留原值,不做四舍五入。
// file: src/fin/monthly-close.ts
// tier: L1
// verify: node --test test/fin/monthly-close.test.ts
import { type Cents } from "../core/money.ts";
import { type Bps, applyRate } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type FlowCategory =
| "revenue"
| "variable_cost"
| "operating_expense"
| "salary"
| "tax"
| "deferred_revenue"
| "receivable"
| "other";
export const FLOW_CATEGORIES: FlowCategory[] = [
"revenue",
"variable_cost",
"operating_expense",
"salary",
"tax",
"deferred_revenue",
"receivable",
"other",
];
export type BankFlow = {
id: string;
category: FlowCategory;
amount: Cents;
};
export type FlowSummary = {
byCategory: Record<FlowCategory, Cents>;
inflow: Cents;
outflow: Cents;
net: Cents;
};
/**
* Classifies bank flows before any accrual. The net is the change the bank
* statement shows; the category totals are what the three statements consume.
* A flow is signed: money in is positive, money out is negative.
*/
export function summarizeFlows(flows: BankFlow[]): Result<FlowSummary> {
if (!Array.isArray(flows)) {
return fail("flows must be an array");
}
const byCategory: Record<FlowCategory, Cents> = {
revenue: 0,
variable_cost: 0,
operating_expense: 0,
salary: 0,
tax: 0,
deferred_revenue: 0,
receivable: 0,
other: 0,
};
for (const flow of flows) {
if (!flow || typeof flow.id !== "string" || flow.id.trim().length === 0) {
return fail("every flow needs an id");
}
if (!FLOW_CATEGORIES.includes(flow.category)) {
return fail(`unknown flow category: ${flow.category}`);
}
if (!Number.isInteger(flow.amount)) {
return fail("flow amounts must be integer cents");
}
byCategory[flow.category] += flow.amount;
}
const inflow = flows
.filter((flow) => flow.amount > 0)
.reduce((total, flow) => total + flow.amount, 0);
const outflow = flows
.filter((flow) => flow.amount < 0)
.reduce((total, flow) => total + flow.amount, 0);
return ok({ byCategory, inflow, outflow, net: inflow + outflow });
}
export type CloseInput = {
cashOpening: Cents;
cashClosing: Cents;
operatingCashFlow: Cents;
investingCashFlow: Cents;
financingCashFlow: Cents;
revenue: Cents;
expenses: Cents;
cashCollected: Cents;
receivableChange: Cents;
deferredRevenueChange: Cents;
taxRateBps: Bps;
taxAccrued: Cents;
equityOpening: Cents;
equityClosing: Cents;
dividends: Cents;
assets: Cents;
liabilities: Cents;
};
export type CloseCheck = {
name: string;
passed: boolean;
difference: Cents;
};
export type CloseReport = {
checks: CloseCheck[];
passed: boolean;
failed: string[];
};
function checkInput(input: CloseInput): string | null {
const integers: Array<[string, number]> = [
["cash opening", input.cashOpening],
["cash closing", input.cashClosing],
["operating cash flow", input.operatingCashFlow],
["investing cash flow", input.investingCashFlow],
["financing cash flow", input.financingCashFlow],
["revenue", input.revenue],
["expenses", input.expenses],
["cash collected", input.cashCollected],
["receivable change", input.receivableChange],
["deferred revenue change", input.deferredRevenueChange],
["tax accrued", input.taxAccrued],
["equity opening", input.equityOpening],
["equity closing", input.equityClosing],
["dividends", input.dividends],
["assets", input.assets],
["liabilities", input.liabilities],
];
for (const [name, value] of integers) {
if (!Number.isInteger(value)) {
return `${name} must be an integer number of cents`;
}
}
const nonNegative: Array<[string, number]> = [
["cash opening", input.cashOpening],
["cash closing", input.cashClosing],
["revenue", input.revenue],
["expenses", input.expenses],
["cash collected", input.cashCollected],
["tax accrued", input.taxAccrued],
["equity opening", input.equityOpening],
["equity closing", input.equityClosing],
["dividends", input.dividends],
["assets", input.assets],
["liabilities", input.liabilities],
];
for (const [name, value] of nonNegative) {
if (value < 0) {
return `${name} must not be negative`;
}
}
if (!Number.isInteger(input.taxRateBps) || input.taxRateBps < 0 || input.taxRateBps > 10000) {
return "tax rate must be between zero and 10000 bps";
}
return null;
}
function makeCheck(name: string, difference: Cents): CloseCheck {
return { name, passed: difference === 0, difference };
}
/**
* Five tie-outs, in the order a close should run:
* 1. cash roll: opening + operating + investing + financing = closing
* 2. revenue tie: cash collected + receivable increase - deferred increase
* 3. tax tie: accrued tax equals the rate applied to positive pretax income
* 4. equity roll: opening equity + net income - dividends = closing equity
* 5. balance sheet: assets = liabilities + closing equity
* A non-zero difference is kept, not rounded away; the sign says which side is
* higher, which is often enough to find the missing entry.
*/
export function reconcileClose(input: CloseInput): Result<CloseReport> {
const inputError = checkInput(input);
if (inputError) {
return fail(inputError);
}
const cashExpected =
input.cashOpening +
input.operatingCashFlow +
input.investingCashFlow +
input.financingCashFlow;
const revenueExpected =
input.cashCollected + input.receivableChange - input.deferredRevenueChange;
const pretax = input.revenue - input.expenses;
const taxExpected = pretax > 0 ? applyRate(pretax, input.taxRateBps) : 0;
const netIncome = pretax - input.taxAccrued;
const equityExpected =
input.equityOpening + netIncome - input.dividends;
const checks: CloseCheck[] = [
makeCheck("cash_roll", input.cashClosing - cashExpected),
makeCheck("revenue_tie", input.revenue - revenueExpected),
makeCheck("tax_tie", input.taxAccrued - taxExpected),
makeCheck("equity_roll", input.equityClosing - equityExpected),
makeCheck(
"balance_sheet",
input.assets - (input.liabilities + input.equityClosing),
),
];
const failed = checks
.filter((check) => !check.passed)
.map((check) => check.name);
return ok({ checks, passed: failed.length === 0, failed });
}
代码有四个细节。第一,流水金额带符号:流入为正、流出为负,分类汇总的净额就是银行账户的变动,不需要再记一套借贷方向。第二,收入勾稽用「收款 + 应收增加 − 递延增加」,和上一节的公式一致;符号写反会让差异正好是应收/递延的两倍。第三,税费只对正税前利润计提,亏损月税费为 0;这不是税务申报的完整规则,只是月度计提的下限。第四,任何一条差异非零都会进入 failed,而且保留差异的符号和金额;税费差 1 美分不仅会让 tax_tie 失败,还会让 equity_roll 跟着失败,因为净利润变了。两条同时红灯不是代码重复,而是提醒你:一个错误的计提会穿透两张表。
小结
- 月度结账是让三张表互相证明,不是把流水抄进报表;五个产出是分类流水、调整收入、税费计提、勾稽报告、锁定版本。
- 五步顺序是分类、调整、计提、勾稽、锁版;先锁版再勾稽等于封存错误,先计提再调整收入会让税费跟着错。
- LedgerLite 年度五次勾稽全部为零:现金 33,701 + 64,299 − 3,000 = 95,000;收入 246,240 + 2,000 − 8,000 = 240,240;税费 70,240 × 17% = 11,940.80;权益 21,700.80 + 58,299.20 = 80,000;资产 110,000 = 30,000 + 80,000。
- 收入不等于收款:LedgerLite 收款比收入多 6,000,来自递延增加 8,000 减应收增加 2,000;符号写反会让差异翻倍。
- 税费是负债不是费用;亏损月计提为 0,计提不足会让现金看起来比实际多。
- 锁版是为了复算:版本号、日期、修改原因三件事缺一不可,否则三个月后无法判断决策基于哪版数字。
阅读导航:上一节:10.1 五个核心指标与口径 · 下一节:10.3 复盘会与决策记录 。
常见错误与边界
- 把流水当收入。 客户预付款是负债,不是当月收入;LedgerLite 年末递延收入 22,000,直接记收入会虚增当年利润。
- 把收款当收入。 应收增加时收款少于收入,应收减少时收款多于收入;收入勾稽要同时看应收和递延。
- 分类里混入个人支出。 创始人个人开销记进公司费用,会同时虚增费用、低估利润、让毛利率和 SDE 率失真。
- 税费等报税再算。 所得税和 GST/VAT 是已经产生的义务;不计提会让现金看起来比实际多,也会让权益滚动对不上。
- 亏损月按负税率计提。 税前利润为负时税费应为 0,亏损结转留给税务申报;负税费会把亏损放大。
- 用四舍五入后的数做勾稽。 LedgerLite 的所得税展示为 11,941,精确值是 11,940.80;用展示值勾稽会出现 20 美分的假差异。
- 差异被「调平」而不是被解释。 把 1 美分差异直接塞进「其他」,等于把问题藏起来;差异要保留符号和金额,下一月继续追。
- 锁版后无记录改数。 改数不写原因,历史版本就失去意义;每次修改生成新版本,并记录改了哪一行、为什么。
- Tidecast 用单月做趋势。 12 月 2,400 份、1 月 480 份,单月结账会把季节波动当趋势;买断制要用滚动三个月。
- Nord 把工时当收入。 工时表只是证据,收入要经过合同价、发票、收款三方核对;工时乘单价不等于可确认收入。
本章的边界是「月度结账的核对闭环」:它假设银行流水完整、合同和发票可追溯、税率已知。它不处理跨境税务的具体申报、审计调整和合并报表;那些要按注册地和当期法规单独处理。结账的下一步是复盘:数字对上了,才有资格讨论「为什么」和「下个月做什么」,这正是下一节的一页纸模板要解决的问题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。