《独立开发者的商业指南·收入与财务工程》5.1 自上而下与自下而上的预算

预算不是一张表,而是一次自上而下与自下而上的对账。本节用 LedgerLite 的年度四类计划、Tidecast 的一次性项目预算和 Nord Studio 的双轨预算,说明两套口径各自从哪里起步、在哪里打架、怎样把差额拆到可解释的科目,并给出一个可复算的预算代码模型。

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

预算要回答两个不同的问题:明年打算做到多少,以及这些钱具体从哪里来、花到哪里去。前者通常自上而下,从一个收入目标或上年基数倒推;后者通常自下而上,从合同、发票、工时和渠道一条条累加。两个答案很少第一次就一致,预算工作的大部分时间不是编数字,而是解释差额。

这一章只讲「怎么编、怎么分、怎么校准」。利润表的科目定义在 3.1 利润表 ,单位经济的四个数在 2.1 单位经济的四个数 ,本节不重复。需要先分清的是编制顺序:先有总量还是先有明细,决定了预算在什么时候会失真。

两种编制顺序不是二选一

自上而下从一个总量出发,常见形式是「收入增长 10%」或「费用不超过收入的 70%」,再把总量拆成类别封顶。自下而上从最小可执行单位出发,常见形式是「下个月 10 个新签」「云账单每月 $1,500」「营销排期 12 周」。

维度自上而下自下而上
起点收入目标、上年基数、行业比例合同、发票、工时、渠道排期
计量单位百分比、类别封顶具体条目与数量
优点快,能守住总盘可执行,能定位到人
失效点比例拍脑袋,类别之间互相挤压条目膨胀,只看见树木
适合收入目标、费用上限人力、软件、营销明细
本案例LedgerLite 的收入与费用包LedgerLite 的四类明细

两者不是投票关系,而是对账关系。自上而下给出约束,自下而上检验约束是否可执行;对不上的地方,才是预算会议真正要讨论的地方。差额的定义必须写死:

收入差额 = 自上而下收入 − 自下而上收入
成本差额 = 自上而下成本 − 自下而上成本
差额率   = 差额 ÷ 自下而上基数

收入差额为正,说明目标高于可执行 build;成本差额为正,说明费用包高于明细计划。差额为零不是目标,能解释才是目标:是漏了一条成本、比例取整、还是目标本身需要改。

LedgerLite:把年度计划拆成四类

LedgerLite 的 2027 年计划先按当前运行率编,不预设增长。收入来自 260 家客户、ARPA $77/月;成本按人力、软件与服务、营销三类归集,加上收入一共四类。这个「平预算」的好处是每一项都能回到事实表,不引入新的增长假设。

类别明细金额
收入260 家 × $77 × 12$240,240
人力创始人薪酬 $60,000 + 外包 $20,000$80,000
软件与服务云 $18,000 + 工具 $9,000 + 支付 $12,000 + 支持 $8,000 + 法务保险 $6,000 + 其他 $20,200$73,200
营销年度营销$16,800
成本合计人力 + 软件与服务 + 营销$170,000
税前结余收入 − 成本$70,240

表中最后一行是税前结余,不是 SDE。SDE $130,240 是扣除运营费用、但未扣创始人薪酬的口径,两者相差整整 $60,000;把 SDE 当预算结余,会高估可支配现金。科目口径见 3.1 利润表 。

自上而下用上年实际结构分配费用包:人力 47%、软件与服务 43%、营销 10%。这三个比例是 $80,000、$73,200、$16,800 除以 $170,000 后取整得到的,取整本身就会产生差额。

类别自上而下自下而上差额
人力$79,900$80,000−$100
软件与服务$73,100$73,200−$100
营销$17,000$16,800+$200
合计$170,000$170,000$0

总盘对上了,类别之间却各差一两百美元。这类差额通常不值得开会,但它提醒一件事:自上而下的百分比越粗,类别内部越容易被平均数掩盖。如果营销实际只有 $16,800 的排期,而费用包给了 $17,000,多出的 $200 不会自动变成有效投放,只会变成年底的「花完预算」。

反过来,如果自上而下要求收入增长,不能只把 $240,240 乘一个系数,必须同时给出客户数、价格、流失或扩张里至少一个驱动。只改收入不改驱动,预算就变成愿望。驱动之间的关系见 2.1 单位经济的四个数 。

Tidecast:项目制预算的起点是开发周期

Tidecast 是买断制游戏,预算不是「明年 12 个月」而是「这一部作品从开发到回本」。开发 14 个月,机会成本 $84,000,按每月 $6,000 计;这笔钱在 SDE 里被当作沉没成本剔除,但在项目预算里必须出现,否则会误以为游戏只花了营销和服务器。

项金额是否计入 SDE
开发机会成本(14 个月 × $6,000)$84,000否,沉没
营销$12,000是
服务器$3,000是
工具$2,000是
项目成本合计$101,000
净得收入$138,171
扣开发机会成本后的结余$37,171

SDE $121,171 等于净得收入减营销、服务器和工具,不含 $84,000 开发机会成本;项目结余 $37,171 则是把开发机会成本也扣掉之后的结果。两个数都真实,回答的问题不同:SDE 回答「这一年经营留下多少」,项目结余回答「这部作品整体是否值得做」。

项目预算还能反推保本销量。净得收入 $138,171 对应 9,600 份,单份净得约 $14.39;项目成本 $101,000 除以 $14.39,保本约 7,018 份。实际 9,600 份只比保本高 36.8%,安全垫没有 SDE 利润率看起来那么厚。Q4 占全年销量 38% 只影响现金进来的时点,不改变全年项目预算;现金流排程见 8.1 13 周现金流表 。

项目制预算的关键是把「一次性投入」和「持续费用」分开放。把 14 个月开发摊进 12 个月年度预算,会同时高估年度费用、低估项目总投入;把营销和服务器当一次性,又会低估上线后的持续支出。

Nord Studio:两条线的预算不能合并成一个平均数

Nord Studio 有咨询和产品两条线,驱动完全不同:咨询按小时,产品按订阅客户。合并成一个「总收入 $154,320」会丢掉预算真正的控制点。

线驱动月预算年预算直接成本贡献
咨询110 小时 × $85$9,350$112,200外包 $18,000$94,200
产品90 家 × $39$3,510$42,120工具 $6,000$36,120
合计$12,860$154,320$24,000$130,320

两条线的贡献合计 $130,320,扣除两位合伙人工资 $96,000,得到 SDE $34,320。咨询线的预算控制点是可计费工时,产品线的控制点是客户数与月流失 3.0%。两者不能平均:咨询少 10% 工时,就是每月少 11 小时 × $85,全年少 $11,220,相当于 SDE 的 32.7%;产品线月流失 3.0%,90 家客户每月要新签约 2.7 家才能持平。

双轨预算的编制顺序也不同。咨询适合自下而上:按可排期工时和外包排班编,再往上汇总;产品适合自上而下加自下而上校准:先定客户数目标,再用流失率和新增渠道检验是否可达。共享的工资和工具放在两条线之下,不强行按收入比例摊回,否则一条线的亏损会被另一条线的规模掩盖。客户分层的预算口径见 7.1 客户分层 。

把两套口径写成代码

下面的 budget.ts 把编制与对账固化成五个函数:buildBottomUp 汇总四类明细,buildTopDown 只保留类别封顶,reconcile 计算收入、成本和类别差额,decomposeRevenueVariance 把收入偏差拆成价格、数量、结构,spreadQuarterly 把年度预算按权重分到四个季度并把取整残差放进 Q4。

// file: src/fin/budget.ts
// tier: L1
// verify: node --test test/fin/budget.test.ts

import { type Cents, add, sub } from "../core/money.ts";
import { type Bps, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";

export type BudgetCategory = "revenue" | "people" | "software" | "marketing";

export type BudgetLine = {
  label: string;
  category: BudgetCategory;
  annual: Cents;
};

export type CategoryTotals = Record<BudgetCategory, Cents>;

export type Budget = {
  lines: BudgetLine[];
  revenue: Cents;
  costs: Cents;
  byCategory: CategoryTotals;
};

function emptyTotals(): CategoryTotals {
  return { revenue: 0, people: 0, software: 0, marketing: 0 };
}

function isCents(value: number): boolean {
  return Number.isInteger(value) && value >= 0;
}

/**
 * Bottom-up: every line comes from a contract, invoice or explicit driver.
 * Revenue lines are summed as income; people, software and marketing lines
 * are summed as costs. The four categories must cover every budgeted dollar.
 */
export function buildBottomUp(lines: BudgetLine[]): Result<Budget> {
  if (lines.length === 0) {
    return fail("at least one budget line is required");
  }
  const byCategory = emptyTotals();
  for (const line of lines) {
    if (line.label.trim() === "") {
      return fail("budget line label must not be empty");
    }
    if (!Object.hasOwn(byCategory, line.category)) {
      return fail(`unknown budget category: ${line.category}`);
    }
    if (!isCents(line.annual)) {
      return fail(`budget line ${line.label} must be a non-negative integer`);
    }
    byCategory[line.category] = add(byCategory[line.category], line.annual);
  }
  const revenue = byCategory.revenue;
  const costs = add(
    add(byCategory.people, byCategory.software),
    byCategory.marketing,
  );
  return ok({ lines: [...lines], revenue, costs, byCategory });
}

export type TopDownInput = {
  revenue: Cents;
  people: Cents;
  software: Cents;
  marketing: Cents;
};

/**
 * Top-down: revenue target and cost envelopes are agreed first, usually as
 * percentages of revenue or as prior-year ratios. Lines are not itemised.
 */
export function buildTopDown(input: TopDownInput): Result<Budget> {
  const values = [input.revenue, input.people, input.software, input.marketing];
  if (values.some((value) => !isCents(value))) {
    return fail("top-down amounts must be non-negative integers");
  }
  const byCategory: CategoryTotals = {
    revenue: input.revenue,
    people: input.people,
    software: input.software,
    marketing: input.marketing,
  };
  return ok({
    lines: [],
    revenue: input.revenue,
    costs: add(add(input.people, input.software), input.marketing),
    byCategory,
  });
}

export type BudgetReconciliation = {
  revenueGap: Cents;
  revenueGapBps: Bps;
  costGap: Cents;
  costGapBps: Bps;
  categoryGaps: CategoryTotals;
};

/**
 * Compares a top-down envelope with a bottom-up build. Gaps are top minus
 * bottom: a positive revenue gap means the target is above the build, while a
 * positive cost gap means the envelope is above the itemised plan.
 */
export function reconcile(top: Budget, bottom: Budget): Result<BudgetReconciliation> {
  if (bottom.revenue === 0 || bottom.costs === 0) {
    return fail("bottom-up revenue and costs must be non-zero");
  }
  const revenueGap = sub(top.revenue, bottom.revenue);
  const costGap = sub(top.costs, bottom.costs);
  return ok({
    revenueGap,
    revenueGapBps: divide(revenueGap, bottom.revenue),
    costGap,
    costGapBps: divide(costGap, bottom.costs),
    categoryGaps: {
      revenue: sub(top.byCategory.revenue, bottom.byCategory.revenue),
      people: sub(top.byCategory.people, bottom.byCategory.people),
      software: sub(top.byCategory.software, bottom.byCategory.software),
      marketing: sub(top.byCategory.marketing, bottom.byCategory.marketing),
    },
  });
}

export type ProductPlan = {
  label: string;
  price: Cents;
  quantity: number;
};

export type RevenueVariance = {
  total: Cents;
  price: Cents;
  volume: Cents;
  mix: Cents;
};

function planMap(
  plans: ProductPlan[],
  side: string,
): Result<Map<string, ProductPlan>> {
  const map = new Map<string, ProductPlan>();
  for (const plan of plans) {
    if (plan.label.trim() === "") {
      return fail(`${side} product label must not be empty`);
    }
    if (!isCents(plan.price)) {
      return fail(`${side} price must be a non-negative integer`);
    }
    if (!Number.isInteger(plan.quantity) || plan.quantity < 0) {
      return fail(`${side} quantity must be a non-negative integer`);
    }
    if (map.has(plan.label)) {
      return fail(`${side} product label ${plan.label} is duplicated`);
    }
    map.set(plan.label, plan);
  }
  return ok(map);
}

/**
 * Revenue variance split into price, volume and mix:
 *   price  = sum((actual price - budget price) * budget quantity)
 *   volume = sum(budget price * (actual quantity - budget quantity))
 *   mix    = total - price - volume
 * The mix part absorbs the price-volume interaction term, so the three parts
 * always add up exactly to the total variance.
 */
export function decomposeRevenueVariance(
  budgetPlans: ProductPlan[],
  actualPlans: ProductPlan[],
): Result<RevenueVariance> {
  if (budgetPlans.length === 0) {
    return fail("at least one budget product is required");
  }
  if (budgetPlans.length !== actualPlans.length) {
    return fail("budget and actual must have the same number of products");
  }
  const budget = planMap(budgetPlans, "budget");
  if (!budget.ok) {
    return budget;
  }
  const actual = planMap(actualPlans, "actual");
  if (!actual.ok) {
    return actual;
  }

  let budgetTotal = 0;
  let actualTotal = 0;
  let price = 0;
  let volume = 0;
  for (const plan of budgetPlans) {
    const match = actual.value.get(plan.label);
    if (!match) {
      return fail(`actual is missing product ${plan.label}`);
    }
    budgetTotal += plan.price * plan.quantity;
    actualTotal += match.price * match.quantity;
    price += (match.price - plan.price) * plan.quantity;
    volume += plan.price * (match.quantity - plan.quantity);
  }
  const total = actualTotal - budgetTotal;
  return ok({ total, price, volume, mix: total - price - volume });
}

/**
 * Spreads an annual budget across four quarters with explicit weights.
 * The first three quarters are rounded and the fourth absorbs the rounding
 * residual, so the four amounts always sum to the annual total.
 */
export function spreadQuarterly(annual: Cents, weightsBps: Bps[]): Result<Cents[]> {
  if (!isCents(annual)) {
    return fail("annual budget must be a non-negative integer");
  }
  if (weightsBps.length !== 4) {
    return fail("exactly four quarter weights are required");
  }
  let totalWeight = 0;
  for (const weight of weightsBps) {
    if (!Number.isInteger(weight) || weight < 0) {
      return fail("quarter weights must be non-negative integers");
    }
    totalWeight += weight;
  }
  if (totalWeight !== 10000) {
    return fail(`quarter weights must sum to 10000 bps, got ${totalWeight}`);
  }
  const quarters: Cents[] = [];
  for (let index = 0; index < 3; index += 1) {
    quarters.push(Math.round((annual * weightsBps[index]) / 10000));
  }
  const firstThree = quarters.reduce((total, value) => total + value, 0);
  quarters.push(annual - firstThree);
  return ok(quarters);
}

代码里有四个细节。第一,buildBottomUp 要求四类覆盖所有预算金额,未知类别直接报错;预算表最怕的不是数字错,而是有一条成本没有归处。第二,reconcile 的差额统一用「自上而下减自下而上」,收入与成本的正负含义因此一致,不会出现「收入超了是好事、成本超了也是好事」的符号混乱。第三,decomposeRevenueVariance 的价格项按预算数量计算、数量项按预算价格计算,结构项吸收价格与数量的交互残差,三项相加严格等于总差额。第四,spreadQuarterly 只对前三季度取整,第四季度用年度总额减前三季度,保证四个季度之和等于年度预算,不会因为四舍五入多出或少掉几美分。

把 LedgerLite 的平预算代入:收入 $240,240、成本 $170,000,自上而下与自下而上的总差额都是 0;类别差额是 −$100、−$100、+$200。换成 Tidecast 的项目预算,decomposeRevenueVariance 可以拆出「少卖」和「降价」各自贡献多少;换成 Nord 的季度预算,spreadQuarterly 可以把咨询收入按旺淡季权重分配。执行层的月度关账不在本节展开,见 月度关账 。

小结

  • 自上而下给约束,自下而上给可执行性;预算不是二选一,而是把两者的差额解释清楚。
  • LedgerLite 的平预算可以完整回到事实表:收入 $240,240、成本 $170,000、税前结余 $70,240;SDE $130,240 不是预算结余。
  • 自上而下的 47/43/10 分配合计时会产生类别差额,总盘对上不代表类别内部没有挤压。
  • Tidecast 的项目预算必须包含 14 个月、$84,000 开发机会成本;项目成本 $101,000,扣掉后结余 $37,171,保本约 7,018 份。
  • Nord Studio 的咨询按工时、产品按客户数,两条线分开编;咨询工时少 10% 就少 $11,220,相当于 SDE 的 32.7%。
  • 代码把编制、对账、偏差分解和季度分摊固化,所有金额用整数美分,所有比率用基点。

阅读导航:上一节:4.3 一次性收入与经常性收入的估值差 · 下一节:5.2 费用结构与固定/变动成本 。

常见错误与边界

  1. 把预算当成一张收入表。 只编收入不编成本,无法判断增长是否值得;LedgerLite 的收入 $240,240 必须和成本 $170,000 一起看。
  2. 把 SDE 当预算结余。 SDE $130,240 未扣创始人薪酬 $60,000;预算结余是税前 $70,240。
  3. 自上而下只乘系数。 收入目标变化必须落到客户数、价格、流失或扩张中的至少一个驱动,否则预算不可执行。
  4. 用百分比分配后不检查类别。 47/43/10 的取整会产生 −$100、−$100、+$200 的类别差额;总盘为零不代表明细为零。
  5. 把项目预算压成年度预算。 Tidecast 的 14 个月开发不能摊进 12 个月,否则项目总投入被低估。
  6. 把开发机会成本从项目预算里删掉。 SDE 可以剔除沉没成本,项目决策不能;$84,000 决定这部作品是否值得做。
  7. 只看 SDE 利润率不看安全垫。 Tidecast 的 SDE 利润率很高,但项目保本约 7,018 份,实际 9,600 份只高出 36.8%。
  8. 把两条收入线合并成一个平均数。 Nord 的咨询和产品驱动不同,合并后会掩盖工时或流失的单点风险。
  9. 季度分摊随手四舍五入。 四个季度各自取整后总和可能不等于年度预算;把残差放进 Q4 才能闭合。
  10. 差额没有符号约定。 收入和成本的正负含义不同,必须先定义「谁减谁」,否则预算会议会各说各话。

预算编完只是开始。下一节把成本按固定与变动重新切一遍,回答「收入少 10% 时,利润会少多少」;这是预算表上看不出来、但决定经营杠杆的问题。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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