以下情境为教学假设,人物与数字不代表经核实的真实案例。
LedgerLite 的合同写 Net 15,Nord Studio 写 Net 30,Tidecast 面对的平台则是月结 30 天。三份合同看起来只是付款日期的差别,财务报表上却表现为三种不同的现金占用。账期越长,公司要先垫出去的钱越多;垫出去的钱不是免费的,它要么来自自有现金,要么来自借款,要么来自本该用于别处的资金。
这一节只回答一个问题:把账期从 15 天改到 30 天、再改到 60 天,究竟多占用多少现金、多花多少资金成本。答案不需要复杂的财务模型,一条公式就够了:平均占用金额等于年收入乘以账期天数再除以 365。剩下的工作,是把这条公式代进三个案例,并解释为什么合同账期和实际占用经常对不上。
账期是一笔没有利息的贷款
假设一张 10,000 美元的发票,合同写 Net 30。你在第一天交付,客户在第 30 天付款。这 30 天里,10,000 美元的生产成本、工资和云账单都由你先付。从现金流角度看,你向客户发放了一笔 10,000 美元、期限 30 天、利率为零的贷款。
这笔贷款的成本,等于这笔钱在你手里本来能产生的收益。若资金成本按 8% 计算,一张 10,000 美元的 Net 30 发票,一年占用 30 天,资金成本是:
10,000 × 30 ÷ 365 × 8% = 65.75 美元
账期翻倍,成本也翻倍。Net 15 是 32.88 美元,Net 60 是 131.51 美元。单张发票看起来很小,但把全年所有发票叠加起来,占用金额会稳定在一个与年收入成比例的水平上。
| 账期 | 单张 10,000 美元发票的占用天数 | 8% 资金成本 | 占发票金额 |
|---|---|---|---|
| Net 15 | 15 | $32.88 | 0.33% |
| Net 30 | 30 | $65.75 | 0.66% |
| Net 60 | 60 | $131.51 | 1.32% |
最后一行值得记住:在 8% 的资金成本假设下,每 15 天账期大约等于合同金额的 0.33%。这个比例与发票张数无关,只与账期和资金成本有关。它也是下一节把账期写进报价时的基准。
三个账期在两个案例上的金额
把单张发票换成全年收入,公式变成:
平均现金占用 = 年收入 × 账期天数 ÷ 365
年资金成本 = 平均现金占用 × 资金成本率
LedgerLite 年收入 240,240 美元,Nord Studio 年收入 154,320 美元。两家都按 8% 的资金成本计算,三个账期的结果如下。
| 账期 | LedgerLite 平均占用 | LedgerLite 年成本 | Nord 平均占用 | Nord 年成本 |
|---|---|---|---|---|
| Net 15 | $9,873 | $790 | $6,342 | $507 |
| Net 30 | $19,746 | $1,580 | $12,684 | $1,015 |
| Net 60 | $39,492 | $3,159 | $25,368 | $2,029 |
几个可以直接读出来的结论。
第一,Net 30 比 Net 15 多占用的现金,正好等于 15 天收入。LedgerLite 是 9,873 美元,Nord 是 6,342 美元;对应的年成本分别是 790 美元和 507 美元。这不是一次性支出,而是每年都会发生的资金占用。
第二,Net 60 比 Net 30 再多一倍。LedgerLite 的平均占用从 19,746 涨到 39,492,年成本从 1,580 涨到 3,159;Nord 从 12,684 涨到 25,368,年成本从 1,015 涨到 2,029。
第三,账期成本占收入的比例只取决于账期和资金成本。Net 15 约 0.33%,Net 30 约 0.66%,Net 60 约 1.32%。LedgerLite 的 3,159 美元是 240,240 的 1.31%,Nord 的 2,029 美元是 154,320 的 1.31%,两家几乎一样。收入规模决定绝对金额,账期决定比例。
8% 是本节的教学假设,不是市场报价,也不是任何一家公司的实际借款利率。它的作用是提供一个可复算的尺度:如果实际资金成本是 5%,把表中所有成本乘以 0.625;如果是 12%,乘以 1.5。结论的方向不变,金额会变。
合同账期不等于实际占用
上面的表假设客户恰好在到期日付款,且全年收入都按同一账期结算。真实情况通常不是这样。实际占用要看资产负债表上的应收余额,再换算成天数:
应收周转天数 = 应收余额 ÷ 年收入 × 365
LedgerLite 年末应收 12,000 美元,年收入 240,240 美元,周转天数是 18.2 天;合同写的是 Net 15。Nord 年末应收 11,264 美元,年收入 154,320 美元,周转天数是 26.6 天;合同写的是 Net 30。一个比合同长,一个比合同短。
| 案例 | 合同账期 | 理论占用 | 实际应收 | 实际天数 | 差额天数 | 差额金额 | 年成本影响 |
|---|---|---|---|---|---|---|---|
| LedgerLite | Net 15 | $9,873 | $12,000 | 18.2 | +3.2 | +$2,127 | +$170 |
| Nord Studio | Net 30 | $12,684 | $11,264 | 26.6 | −3.4 | −$1,420 | −$114 |
LedgerLite 多出来的 3.2 天不是合同写错了,而是两股力量抵消后的净结果。它有 30% 的年付客户,年付在期初一次性收款,把这部分收入的占用天数拉到接近零;同时有 6% 的应收逾期 30 天以上,把另一部分天数拉长。前者压低占用,后者抬高占用,净效果是比 Net 15 多 3.2 天。
Nord 的情况相反。它的合同是 Net 30,但 27.3% 的收入来自每月 39 美元的产品订阅,按月预付;只有 72.7% 的咨询收入走 Net 30。产品预付款把混合账龄拉到 26.6 天,低于 30 天。如果只看咨询业务,账龄会更接近 34 天,而不是合同上的 30 天。
这就是账期的第一层错觉:合同写的是上限或基准,不是实际占用。真正决定现金占用的是三个变量:合同账期、预付款比例、逾期比例。只看合同,会把 LedgerLite 的成本低估约 170 美元,把 Nord 的成本高估约 114 美元。金额不大,但方向很重要:账期管理不是改合同条款,而是管理这三个变量的组合。
应收余额本身在资产负债表上的位置,见 3.3 资产负债表 ;本节只把余额换成天数与资金成本。应收余额怎么变成账龄结构,是 3.2 节的内容。
把账期写进报价
账期既然是一笔贷款,就可以定价。假设客户要求把 Net 30 延长到 Net 60,等于让你多垫 30 天。按 8% 的资金成本,这笔额外融资的价值是:
合同年金额 × 30 ÷ 365 × 8% = 合同年金额 × 0.66%
| 合同年金额 | 延长 30 天的年成本 | 相当于合同金额 |
|---|---|---|
| $10,000 | $65.75 | 0.66% |
| $50,000 | $328.77 | 0.66% |
| $100,000 | $657.53 | 0.66% |
所以,面对 Net 60 的要求,报价上浮 0.7% 左右就能覆盖资金成本;如果客户同时要求 2% 的折扣,这笔交易在资金成本上就是亏的。反过来,把账期从 Net 30 缩短到 Net 15,等于释放 15 天的现金,价值约为合同金额的 0.33%。这些数字没有把坏账风险算进去;账期越长,逾期和坏账的概率通常也越高,那部分成本在 3.2 节和 4.1 节处理。
年付折扣是同一个逻辑的另一个方向。LedgerLite 有 30% 的年付客户,年付折扣 15%。按案例数字估算,这部分年收入是 240,240 × 30% = 72,072 美元,折扣成本是 72,072 × 15% = 10,811 美元。即使把全年收入视为提前整整一年到账,8% 的资金收益也只有 72,072 × 8% = 5,766 美元,低于折扣成本。差额买的是留存、现金安全和更低的催收成本,不是融资套利。把年付折扣当成“用折扣换现金”时,要先算清这笔账。
把账期换算成代码
下面的 payment-cost.ts 把三个动作固化成可复算的函数:给定年收入和账期,算平均占用;给定资金成本,算年成本;给定实际应收,算它与合同账期的差额。金额用整数美分,比率用基点,任何非整数输入都会被拒绝。
// file: src/contract/payment-cost.ts
// tier: L1
// verify: node --test test/contract/payment-cost.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 const DAYS_PER_YEAR = 365;
export type TermsCost = {
termsDays: number;
averageReceivable: Cents;
carryingCost: Cents;
};
export type ReceivableGap = {
actualDays: number;
contractDays: number;
excessDays: number;
excessBalance: Cents;
excessCost: Cents;
};
function checkRevenue(annualRevenue: Cents): string | null {
if (!Number.isInteger(annualRevenue) || annualRevenue < 0) {
return "annual revenue must be a non-negative integer";
}
return null;
}
function checkTerms(termsDays: number): string | null {
if (!Number.isInteger(termsDays) || termsDays < 0 || termsDays > 365) {
return "terms must be an integer between 0 and 365 days";
}
return null;
}
function checkCost(costOfCapitalBps: Bps): string | null {
if (
!Number.isInteger(costOfCapitalBps) ||
costOfCapitalBps < 0 ||
costOfCapitalBps > 10000
) {
return "cost of capital must be an integer between zero and 10000 bps";
}
return null;
}
/**
* A term of N days means the business funds N days of revenue before cash
* arrives. The 365-day denominator keeps every row recomputable from the
* annual revenue in the case table.
*/
export function averageReceivable(
annualRevenue: Cents,
termsDays: number,
): Result<Cents> {
const revenueError = checkRevenue(annualRevenue);
if (revenueError) return fail(revenueError);
const termsError = checkTerms(termsDays);
if (termsError) return fail(termsError);
return ok(Math.round((annualRevenue * termsDays) / DAYS_PER_YEAR));
}
export function carryingCost(
annualRevenue: Cents,
termsDays: number,
costOfCapitalBps: Bps,
): Result<Cents> {
const costError = checkCost(costOfCapitalBps);
if (costError) return fail(costError);
const average = averageReceivable(annualRevenue, termsDays);
if (!average.ok) return average;
return ok(applyRate(average.value, costOfCapitalBps));
}
/** Marginal cost of one extra day of terms, in cents per year. */
export function marginalCostPerDay(
annualRevenue: Cents,
costOfCapitalBps: Bps,
): Result<Cents> {
const revenueError = checkRevenue(annualRevenue);
if (revenueError) return fail(revenueError);
const costError = checkCost(costOfCapitalBps);
if (costError) return fail(costError);
const dailyRevenue = Math.round(annualRevenue / DAYS_PER_YEAR);
return ok(applyRate(dailyRevenue, costOfCapitalBps));
}
export function compareTerms(
annualRevenue: Cents,
terms: number[],
costOfCapitalBps: Bps,
): Result<TermsCost[]> {
if (terms.length === 0) {
return fail("at least one set of terms is required");
}
const rows: TermsCost[] = [];
for (const termsDays of terms) {
const average = averageReceivable(annualRevenue, termsDays);
if (!average.ok) return average;
const cost = carryingCost(annualRevenue, termsDays, costOfCapitalBps);
if (!cost.ok) return cost;
rows.push({
termsDays,
averageReceivable: average.value,
carryingCost: cost.value,
});
}
return ok(rows);
}
/**
* Compares the receivables actually carried with the balance the contract
* terms imply. A positive gap is the cash the business lends beyond the
* contract; a negative gap is cash released by prepayments.
*/
export function receivableGap(
receivables: Cents,
annualRevenue: Cents,
contractDays: number,
costOfCapitalBps: Bps,
): Result<ReceivableGap> {
if (!Number.isInteger(receivables) || receivables < 0) {
return fail("receivables must be a non-negative integer");
}
const revenueError = checkRevenue(annualRevenue);
if (revenueError) return fail(revenueError);
if (annualRevenue === 0) {
return fail("annual revenue must be positive to compute days");
}
const termsError = checkTerms(contractDays);
if (termsError) return fail(termsError);
const costError = checkCost(costOfCapitalBps);
if (costError) return fail(costError);
const implied = averageReceivable(annualRevenue, contractDays);
if (!implied.ok) return implied;
const actualDays = (receivables / annualRevenue) * DAYS_PER_YEAR;
const excessBalance = receivables - implied.value;
return ok({
actualDays: Math.round(actualDays * 10) / 10,
contractDays,
excessDays: Math.round((actualDays - contractDays) * 10) / 10,
excessBalance,
excessCost: applyRate(excessBalance, costOfCapitalBps),
});
}
把 LedgerLite 代入 compareTerms,三个账期的平均占用分别是 987,288、1,974,575、3,949,151 美分,年成本分别是 78,983、157,966、315,932 美分,与上表逐行对应。把 LedgerLite 的实际应收 1,200,000 美分代入 receivableGap,得到 18.2 天、+3.2 天、+212,712 美分余额、+17,017 美分年成本;Nord 的 1,126,400 美分得到 26.6 天、−3.4 天、−141,984 美分余额、−11,359 美分年成本。负数的含义是预付结构释放了现金,不是计算错误。
代码里有三个刻意的限制。第一,账期不能超过 365 天,超过一年就不再是账期,而是长期融资。第二,资金成本用基点表示,避免 0.08 这样的浮点数在连续计算里漂移。第三,receivableGap 同时返回天数差和金额差,因为“多了 3.2 天”和“多了 2,127 美元”是两个不同的管理指标:前者适合和合同条款对话,后者适合和银行余额对话。
账期成本最终会进入 13 周现金流表:合同账期决定收入变成现金的周次,资金成本决定这个周次值多少钱。周表的字段与滚动方法见 8.1 13 周现金流表 ,本节不重复。
小结
- 账期是一笔无息贷款:平均现金占用 = 年收入 × 账期天数 ÷ 365,年资金成本 = 平均占用 × 资金成本率。
- 按 8% 教学假设,LedgerLite 的 Net 15、30、60 平均占用分别是 9,873、19,746、39,492 美元,年成本分别是 790、1,580、3,159 美元。
- Nord Studio 的同一组账期占用 6,342、12,684、25,368 美元,年成本 507、1,015、2,029 美元;绝对额更小,占收入的比例相同。
- 每 15 天账期约等于合同金额的 0.33% 资金成本;每 30 天约 0.66%。这个比例比单张发票的金额更好用。
- 合同账期不等于实际占用:LedgerLite 实际 18.2 天,比 Net 15 多 3.2 天;Nord 实际 26.6 天,比 Net 30 少 3.4 天,差额来自预付款比例与逾期比例。
- 延长 30 天账期的定价基准是合同金额的 0.66%;年付 15% 折扣的资金收益低于折扣成本,差额买的是留存与现金安全。
- 账期成本的方向由合同决定,金额由实际回款决定;两者之间的差额就是应收管理要解释的部分。
阅读导航:上一节:2.3 SLA 与抵扣:可用性怎么变成钱 · 下一节:3.2 应收账龄表:从余额到风险分档 。
常见错误与边界
- 把账期当成免费的销售条款。 Net 60 不是“客户更满意”,而是你多垫 60 天收入;按 8% 计算,LedgerLite 一年多花 3,159 美元。
- 用收入而不是应收余额算占用。 账期成本的分母是年收入,但实际占用的分子是应收余额;LedgerLite 的 12,000 美元应收对应 18.2 天,不是合同上的 15 天。
- 忽略预付款结构。 Nord 的实际账龄 26.6 天低于 Net 30,因为 27.3% 的产品收入按月预付;只看合同会高估它的资金成本。
- 把逾期率直接当坏账率。 6% 是逾期 30 天以上的应收占比,不是最终收不回来的比例;坏账准备要看账龄分档,见 3.2 节。
- 用单一资金成本覆盖所有账期。 8% 是教学假设;实际资金成本随公司信用、币种和融资结构变化,换一个利率要整表重算。
- 把年付折扣只当成营销费用。 15% 折扣同时买现金、留存和催收效率;只按资金成本比较,会低估它的综合作用,也会低估它的成本。
- 用单张发票的金额判断账期是否值得。 10,000 美元的 Net 60 只多花 131.51 美元,但全年 240,240 美元的 Net 60 多花 3,159 美元;账期成本是规模函数。
- 忘记账期会改变现金流周次。 同样的收入,Net 60 比 Net 15 晚 45 天进周表;周表最低点会因此下移,见 8.1 节。
- 把合同账期当成客户的实际付款日。 到期日只是催收起点;实际到账时间是一个分布,3.3 节会把分布拆成开票、对账和催收节点。
- 在报价里只加账期成本,不加坏账成本。 账期越长,逾期和坏账的概率通常越高;0.66% 只覆盖资金占用,不覆盖收不回来的部分。
本节的边界是“单一账期、稳定回款、8% 资金成本”的静态模型。它假设收入均匀发生,不含季节性、不含新签增量,也不含坏账损失。它足够回答“Net 15、30、60 差多少”,但不足以回答“某个客户拖到 90 天会怎样”。后者需要把账龄结构和催收节奏拆开,分别计算准备与到账期望,这正是接下来两节的内容。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。