以下情境为教学假设,人物与数字不代表经核实的真实案例。
预算要回答两个不同的问题:明年打算做到多少,以及这些钱具体从哪里来、花到哪里去。前者通常自上而下,从一个收入目标或上年基数倒推;后者通常自下而上,从合同、发票、工时和渠道一条条累加。两个答案很少第一次就一致,预算工作的大部分时间不是编数字,而是解释差额。
这一章只讲「怎么编、怎么分、怎么校准」。利润表的科目定义在 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 费用结构与固定/变动成本 。
常见错误与边界
- 把预算当成一张收入表。 只编收入不编成本,无法判断增长是否值得;LedgerLite 的收入 $240,240 必须和成本 $170,000 一起看。
- 把 SDE 当预算结余。 SDE $130,240 未扣创始人薪酬 $60,000;预算结余是税前 $70,240。
- 自上而下只乘系数。 收入目标变化必须落到客户数、价格、流失或扩张中的至少一个驱动,否则预算不可执行。
- 用百分比分配后不检查类别。 47/43/10 的取整会产生 −$100、−$100、+$200 的类别差额;总盘为零不代表明细为零。
- 把项目预算压成年度预算。 Tidecast 的 14 个月开发不能摊进 12 个月,否则项目总投入被低估。
- 把开发机会成本从项目预算里删掉。 SDE 可以剔除沉没成本,项目决策不能;$84,000 决定这部作品是否值得做。
- 只看 SDE 利润率不看安全垫。 Tidecast 的 SDE 利润率很高,但项目保本约 7,018 份,实际 9,600 份只高出 36.8%。
- 把两条收入线合并成一个平均数。 Nord 的咨询和产品驱动不同,合并后会掩盖工时或流失的单点风险。
- 季度分摊随手四舍五入。 四个季度各自取整后总和可能不等于年度预算;把残差放进 Q4 才能闭合。
- 差额没有符号约定。 收入和成本的正负含义不同,必须先定义「谁减谁」,否则预算会议会各说各话。
预算编完只是开始。下一节把成本按固定与变动重新切一遍,回答「收入少 10% 时,利润会少多少」;这是预算表上看不出来、但决定经营杠杆的问题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。