《独立开发者的商业指南·收入与财务工程》10.2 月度关账流程

月度结账不是记账,而是从银行流水到三张表的核对闭环:分类、应收与递延调整、税费计提、五项勾稽、锁定版本。本节用 LedgerLite 的年度数示范五次勾稽,并对照 Tidecast 的平台月结与 Nord Studio 的工时发票,说明同一套步骤为什么需要不同的证据。

以下情境为教学假设,人物与数字不代表经核实的真实案例。

月度结账不是把银行流水抄进一张表,而是让三张表互相证明:现金变动能解释期末余额,收入能解释收款与应收/递延的差,税费能解释税前利润,权益能解释净利润,资产能解释负债与权益。五件事都成立,这个月的数字才可以拿去做复盘、预算和估值。任何一件不成立,先别解释业务,先找那笔对不上的分录。

这一节只讲核对闭环,不讲具体会计软件怎么点。软件会换,勾稽关系不会换;把五步写清楚,换工具只是换录入界面。

结账不是记账,是让三张表互相证明

记账回答「发生了什么」,结账回答「这些记录能不能互相证明」。银行流水是事实,收入确认是判断,税费计提是估计,三张表是把事实、判断和估计放在一起检查。一个人公司的结账之所以容易糊弄,是因为没有第二个人问「这笔为什么在这个科目」;勾稽检查就是那个第二个人。

月度结账要有五个产出:

产出回答的问题没有它会怎样
分类后的流水每一笔钱属于收入、成本、费用还是权益毛利率和费用率都不可信
调整后的收入应收、递延、退款之后的真实收入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.00ARR ÷ 12
变动成本$38,000$3,166.67云、支付、支持
毛利$202,240$16,853.3384.2%
运营费用$72,000$6,000.00营销、工具、外包、法律保险、其他
SDE$130,240$10,853.33毛利 − 运营费用
创始人薪酬$60,000$5,000.00假设
税前利润$70,240$5,853.33SDE − 薪酬
所得税$11,941$995.0817% × 税前利润,示例
净利润$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,0000
收入勾稽收款 + 应收增加 − 递延增加 = 收入246,240 + 2,000 − 8,000 = 240,2400
税费勾稽正税前利润 × 税率 = 税费计提70,240 × 17% = 11,940.800
权益滚动期初权益 + 净利润 − 分红 = 期末权益21,700.80 + 58,299.20 = 80,0000
资产负债表资产 = 负债 + 期末权益110,000 = 30,000 + 80,0000

这五条不是随便选的。现金滚动检查银行余额;收入勾稽检查应收和递延;税费勾稽检查计提;权益滚动检查利润有没有进入权益;资产负债表检查最后的总平衡。任何一条不为零,都能把问题缩小到一个方向:现金差异看未达账项和投资支出,收入差异看应收/递延,税费差异看税率和计提时点,权益差异看分红和期初数,资产负债表差异看科目归类。

收入勾稽的符号最容易写反。LedgerLite 的收款是 246,240,比收入 240,240 多 6,000;这 6,000 来自递延收入增加 8,000 减去应收增加 2,000。客户先付年费,现金比收入多;客户后付月费,现金比收入少。公式写成「收款 + 应收增加 − 递延增加 = 收入」,等价于「收款 = 收入 − 应收增加 + 递延增加」。两个写法都对,关键是全公司只用一种。

税费勾稽有一个边界:亏损月份不计提所得税。如果税前利润为负,税费应该是 0,而不是「负税率」;亏损结转是税务申报层面的事,不在月度计提里做。LedgerLite 的 17% 是示例税率,实际税率要按当期法规和注册地核对。

三个案例的结账差异

同一套五步,三个案例的证据不同:

结账难点LedgerLiteTidecastNord 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 复盘会与决策记录 。

常见错误与边界

  1. 把流水当收入。 客户预付款是负债,不是当月收入;LedgerLite 年末递延收入 22,000,直接记收入会虚增当年利润。
  2. 把收款当收入。 应收增加时收款少于收入,应收减少时收款多于收入;收入勾稽要同时看应收和递延。
  3. 分类里混入个人支出。 创始人个人开销记进公司费用,会同时虚增费用、低估利润、让毛利率和 SDE 率失真。
  4. 税费等报税再算。 所得税和 GST/VAT 是已经产生的义务;不计提会让现金看起来比实际多,也会让权益滚动对不上。
  5. 亏损月按负税率计提。 税前利润为负时税费应为 0,亏损结转留给税务申报;负税费会把亏损放大。
  6. 用四舍五入后的数做勾稽。 LedgerLite 的所得税展示为 11,941,精确值是 11,940.80;用展示值勾稽会出现 20 美分的假差异。
  7. 差异被「调平」而不是被解释。 把 1 美分差异直接塞进「其他」,等于把问题藏起来;差异要保留符号和金额,下一月继续追。
  8. 锁版后无记录改数。 改数不写原因,历史版本就失去意义;每次修改生成新版本,并记录改了哪一行、为什么。
  9. Tidecast 用单月做趋势。 12 月 2,400 份、1 月 480 份,单月结账会把季节波动当趋势;买断制要用滚动三个月。
  10. Nord 把工时当收入。 工时表只是证据,收入要经过合同价、发票、收款三方核对;工时乘单价不等于可确认收入。

本章的边界是「月度结账的核对闭环」:它假设银行流水完整、合同和发票可追溯、税率已知。它不处理跨境税务的具体申报、审计调整和合并报表;那些要按注册地和当期法规单独处理。结账的下一步是复盘:数字对上了,才有资格讨论「为什么」和「下个月做什么」,这正是下一节的一页纸模板要解决的问题。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查