以下情境为教学假设,人物与数字不代表经核实的真实案例。
付款条件通常被写成一句「Net 30」,好像账期就是全部。实际上,账期只回答「发票开出后多久付」,没有回答「发票什么时候开」「什么情况下必须付」「不付会怎样」。这四个问题合起来才是付款条件:金额、时点、前提、违约责任;金额里还包括调价,时点里还包括开票日和到期日。少一个,现金路径就有一个洞。
LedgerLite 的付款条件相对简单:ARR 240,240 美元,Net 15;年末应收 12,000 美元,占年收入 5.0%,折合 18 天;逾期 30 天以上占应收 6%,坏账准备 1%;递延收入 22,000 美元;年付客户 30%,月付客户 70%,年付折扣 15%。它的现金周期短,不是因为客户格外守约,而是因为订阅按月计费、单笔金额小、验收被拆散到每个客户的开通环节。
Nord Studio 的咨询合同是 Net 30,付款前提是验收;应收占年收入 7.3%,约 11,265 美元,逾期率 8%,坏账准备 2%。它的 SDE 只有 34,320 美元,账期每多 30 天,占用的现金相当于 SDE 的 37.0%。
Nord 的两位合伙人各持 50%,没有书面退出条款;一笔 11,265 美元的应收,已经相当于 SDE 的 32.8%。付款条件对两人工作室不是财务细节,而是合伙人现金分配的前提。
付款条件的四个要素
把付款条件拆成四个要素,才能把「Net 30」翻译成现金数字。下表给出每个要素要写什么、对应什么财务量,以及两个案例的落点。
| 要素 | 合同里要写什么 | 可计算的量 | LedgerLite | Nord Studio |
|---|---|---|---|---|
| 金额 | 单价、数量、折扣、税费、币种、是否含税 | 合同金额与净额 | ARPA 77 美元/月 | 85 美元/小时 × 110 小时 |
| 时点 | 发票日、到期日、账期、节假日顺延 | 现金占用天数 | Net 15 | Net 30 |
| 前提 | 交付、验收、里程碑、合规发票或 PO | 现金时钟起点 | 订阅开通或服务期间 | 验收通过 |
| 违约 | 逾期利息、暂停服务、加速到期、催收费用 | 逾期金额与坏账 | 逾期 30 天以上占 6%,坏账准备 1% | 逾期率 8%,坏账准备 2% |
金额看起来最简单,实际最容易留下口径争议。合同金额是含税还是不含税、用什么币种、折扣按年还是按月、调价什么时候生效,都会改变实际到账。LedgerLite 的年付折扣是 15%,这是用价格换提前到账的现金;年付客户占 30%,月付占 70%,折扣和账期共同决定了递延收入 22,000 美元。Nord 按 85 美元/小时、每月 110 小时计费,小时费率每提高 1 美元,按当前工时计算,年收入增加 1,320 美元;调价通知期决定新费率从哪个月开始生效。
时点要从发票日算,不是从交付日算。Net 15 的实际回款日 = 发票日 + 15 天;如果发票日比交付日晚 7 天,客户感知的账期是 22 天。很多合同只写「验收后付款」,没有写「验收后几天开票」,时点就没有起点。把开票时限写进去,账期才有意义。
前提决定现金时钟什么时候开始走。订阅合同的起点通常是开通或服务期间,咨询合同的起点是验收,项目合同可以是里程碑。第 1.2 节已经算过:Nord 的验收等待是 30 天,开票后回款 27 天,真实现金周期是 57 天;合同只写 Net 30,会让人误以为 30 天就能回款。
违约条款把逾期变成成本。常见工具包括逾期利息、暂停服务、加速到期、催收费用和终止权。这里要区分两个百分比:LedgerLite 的 6% 和 Nord 的 8% 是逾期应收占全部应收的比例,不是逾期费率;合同里的逾期费率要单独写。把逾期率当成逾期费率,会严重低估或高估违约条款的金额。
四个要素里,金额和时点最容易写,前提和违约最容易漏。漏掉前提,时点就没有起点;漏掉违约,逾期就没有成本。一个只有金额和账期的合同,只完成了付款条件的一半。
Net 15、Net 30、Net 60 的现金占用
账期对现金的影响是线性的:常年应收余额 = 年收入 × 账期天数 / 365。这个余额不是某一张发票的金额,而是企业在正常经营中一直垫在客户手里的钱。账期翻倍,占用翻倍;Net 60 相对 Net 15 是 4 倍。
| 账期 | 占用天数 | LedgerLite 占用余额 | 占其 ARR | Nord Studio 占用余额 | 占其 ARR |
|---|---|---|---|---|---|
| Net 15 | 15 | 9,873 美元 | 4.1% | 6,342 美元 | 4.1% |
| Net 30 | 30 | 19,746 美元 | 8.2% | 12,684 美元 | 8.2% |
| Net 60 | 60 | 39,492 美元 | 16.4% | 25,368 美元 | 16.4% |
表中 Nord 的 Net 15 和 Net 60 是假设值,用来比较同一账期下的静态占用;它的实际合同是 Net 30。百分比只取决于账期:15 天是 4.1%,30 天是 8.2%,60 天是 16.4%,与收入规模无关。美元金额随收入放大,但真正决定承受力的是它占 SDE 的比例。
| 账期 | 占 LedgerLite SDE | 占 Nord Studio SDE |
|---|---|---|
| Net 15 | 7.6% | 18.5% |
| Net 30 | 15.2% | 37.0% |
| Net 60 | 30.3% | 73.9% |
同样是 Net 30,LedgerLite 占用的是 SDE 的 15.2%,Nord 占用的是 37.0%。Nord 的 SDE 只有 34,320 美元,一笔 12,684 美元的常年占用已经超过三分之一年的可支配收益;如果把账期放到 Net 60,占用达到 25,368 美元,接近四分之三。对两人工作室,这不是「晚一点收钱」,而是「今年的可分配现金少了一大块」。
合同账期还不等于实际 DSO。DSO 的计算是:应收 ÷ 年收入 × 365。下表把两个案例的合同账期和年末应收放在一起。
| 案例 | 合同账期 | 年末应收 | 占年收入 | 实际 DSO | 差额 |
|---|---|---|---|---|---|
| LedgerLite | Net 15 | 12,000 美元 | 5.0% | 18 天 | +3 天 |
| Nord Studio | Net 30 | 11,265 美元 | 7.3% | 27 天 | -3 天 |
LedgerLite 的实际 DSO 比合同多 3 天,来自开票批次、银行转账和客户付款习惯;Nord 的实际 DSO 比合同少 3 天,因为应收账龄只统计已开票未收回的部分,验收等待在开票之前,不在账龄里。账期是合同变量,DSO 是运营结果;把两者混为一谈,会高估或低估现金占用。
如果你只有一张年末资产负债表,不要用它推算全年 DSO。年末应收可能刚好赶上大客户提前付款,也可能刚好堆着几张未到期发票;用 12 个月平均应收,或者至少用四个季度末的平均值,才能接近真实占用。
违约条款:把逾期变成可计算的成本
违约条款的财务量不是「有没有罚款」,而是「逾期金额有多大、准备计提多少、最坏损失多少」。下表把两个案例的应收、逾期比例和坏账准备放在一起。
| 项目 | LedgerLite | Nord Studio |
|---|---|---|
| 应收 | 12,000 美元 | 11,265 美元 |
| 逾期 30 天以上占比 | 6% | 8% |
| 逾期金额 | 720 美元 | 901 美元 |
| 坏账准备率 | 1% | 2% |
| 坏账准备 | 120 美元 | 225 美元 |
| 坏账准备占 SDE | 0.09% | 0.66% |
坏账准备不是损失上限。Nord 的 2% 准备只覆盖 225 美元;如果 11,265 美元全部收不回,净损失约 11,040 美元,相当于 SDE 的 32.2%。准备是会计上的分摊,不是风险封顶。真正决定风险的是单笔应收占 SDE 的比例,以及客户集中度。
逾期费能补偿一部分资金成本,但不能解决可收回性。计算方式是:逾期费 = 未付金额 × 月费率 × 逾期天数 / 30。以 Nord 的 11,265 美元为例,月费率每 1 个百分点对应每月约 113 美元;即使逾期三个月,逾期费也只有本金的 3% 左右,远小于本金收不回的风险。费率写得太高可能无法执行,写得太低又覆盖不了资金成本,合同要给一个能算的数。
暂停服务是另一种催收工具,但要看单位经济。LedgerLite 的 ARPA 是 77 美元/月,为了 77 美元暂停一个客户,可能损失整个客户终身价值(LTV);Nord 的 11,265 美元应收,暂停服务更合理。违约条款的力度应该和单笔应收金额匹配:小额订阅靠自动扣款和自动催款(dunning),大额咨询靠里程碑付款和暂停权。
加速到期把未来分期变成一次性到期。对订阅合同,它可以把剩余合同价值全部变成应收,但也会提高坏账风险;对咨询合同,它能把未付的里程碑款提前收回,但客户可能直接终止。加速到期不是越早越好,它要把「提前收回的现金」和「失去的续约」放在一起算。
违约条款不是越狠越好。过高的逾期费可能无法执行,也可能直接把客户推向终止;过低则覆盖不了资金成本。可计算的量是逾期金额 × 费率 × 天数,先算这个数,再决定力度。具体条款是否可执行,由适用法律和合同文本决定,本节只算财务后果。
代码:把付款条件拆成金额、时点、前提与违约
下面的 payment-terms.ts 把四个要素固化成模型。assessPaymentTerm 校验金额、账期、前提和月费率;cashClock 把验收等待加到账期上;netTermTable 生成 Net 15/30/60 的占用表;dsoDays 和 dsoGapDays 比较实际 DSO 与合同账期;lateFeeCents 按天计算简单逾期费。
// file: src/contract/payment-terms.ts
// tier: L1
// verify: node --test test/contract/payment-terms.test.ts
import { type Cents } from "../core/money.ts";
import { type Bps, applyRate, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type Precondition = "invoice" | "delivery" | "acceptance" | "milestone";
export const PRECONDITIONS: Precondition[] = [
"invoice",
"delivery",
"acceptance",
"milestone",
];
export type PaymentTerm = {
amountCents: Cents;
netDays: number;
precondition: Precondition;
lateFeeBpsPerMonth: Bps;
};
export type TermAssessment = {
amountCents: Cents;
netDays: number;
precondition: Precondition;
lateFeeBpsPerMonth: Bps;
dueOffsetDays: number;
lateFeePer30DaysCents: Cents;
};
export type CashClock = {
precondition: Precondition;
appliedAcceptanceDays: number;
netDays: number;
totalDays: number;
};
export type NetTermRow = {
netDays: number;
balanceCents: Cents;
shareOfRevenueBps: Bps;
};
function isBps(value: number): boolean {
return Number.isInteger(value) && value >= 0 && value <= 10000;
}
/**
* Validate the four elements of a payment term. The amount is always gross
* of tax; currency and tax treatment belong in the contract text, not in
* this arithmetic model.
*/
export function assessPaymentTerm(
term: PaymentTerm,
): Result<TermAssessment> {
if (!Number.isInteger(term.amountCents) || term.amountCents <= 0) {
return fail("amount must be a positive integer amount in cents");
}
if (
!Number.isInteger(term.netDays) ||
term.netDays < 0 ||
term.netDays > 365
) {
return fail("net days must be an integer between 0 and 365");
}
if (!PRECONDITIONS.includes(term.precondition)) {
return fail(`unknown precondition: ${term.precondition}`);
}
if (!isBps(term.lateFeeBpsPerMonth)) {
return fail("late fee must be between 0 and 10000 bps per month");
}
return ok({
amountCents: term.amountCents,
netDays: term.netDays,
precondition: term.precondition,
lateFeeBpsPerMonth: term.lateFeeBpsPerMonth,
dueOffsetDays: term.netDays,
lateFeePer30DaysCents: applyRate(
term.amountCents,
term.lateFeeBpsPerMonth,
),
});
}
/**
* The cash clock starts when the precondition is met. For an acceptance-based
* term, the acceptance wait is added before the net days; for invoice and
* delivery terms the net days are the whole clock.
*/
export function cashClock(
term: PaymentTerm,
acceptanceDays: number,
): Result<CashClock> {
const checked = assessPaymentTerm(term);
if (!checked.ok) {
return checked;
}
if (!Number.isInteger(acceptanceDays) || acceptanceDays < 0 || acceptanceDays > 365) {
return fail("acceptance days must be an integer between 0 and 365");
}
const appliedAcceptanceDays =
term.precondition === "acceptance" ? acceptanceDays : 0;
return ok({
precondition: term.precondition,
appliedAcceptanceDays,
netDays: term.netDays,
totalDays: appliedAcceptanceDays + term.netDays,
});
}
/**
* A contractual net term translated into a static receivable balance:
* annual revenue x net days / 365. This is the balance the business would
* carry if every invoice took exactly the stated term to collect.
*/
export function netTermTable(
annualRevenueCents: Cents,
netDaysList: number[],
): Result<NetTermRow[]> {
if (!Number.isInteger(annualRevenueCents) || annualRevenueCents <= 0) {
return fail("annual revenue must be a positive integer amount in cents");
}
if (netDaysList.length === 0) {
return fail("net term list must not be empty");
}
const seen = new Set<number>();
const rows: NetTermRow[] = [];
for (const netDays of netDaysList) {
if (!Number.isInteger(netDays) || netDays < 0 || netDays > 365) {
return fail("each net term must be an integer between 0 and 365");
}
if (seen.has(netDays)) {
return fail(`duplicate net term: ${netDays}`);
}
seen.add(netDays);
const balanceCents = Math.round((annualRevenueCents * netDays) / 365);
rows.push({
netDays,
balanceCents,
shareOfRevenueBps: divide(balanceCents, annualRevenueCents),
});
}
return ok(rows);
}
/** Days sales outstanding: receivables / annual revenue x 365. */
export function dsoDays(
receivableCents: Cents,
annualRevenueCents: Cents,
): Result<number> {
if (!Number.isInteger(receivableCents) || receivableCents < 0) {
return fail("receivable must be a non-negative integer amount in cents");
}
if (!Number.isInteger(annualRevenueCents) || annualRevenueCents <= 0) {
return fail("annual revenue must be a positive integer amount in cents");
}
return ok(Math.round((receivableCents * 365) / annualRevenueCents));
}
/** Positive means actual collection is slower than the stated net term. */
export function dsoGapDays(
actualDsoDays: number,
netDays: number,
): Result<number> {
if (!Number.isInteger(actualDsoDays) || actualDsoDays < 0) {
return fail("actual DSO must be a non-negative integer");
}
if (!Number.isInteger(netDays) || netDays < 0) {
return fail("net days must be a non-negative integer");
}
return ok(actualDsoDays - netDays);
}
/**
* Simple, pro-rata late fee: amount x monthly rate x days late / 30. The
* contract must state whether the fee is simple or compounding; this helper
* only covers the simple case.
*/
export function lateFeeCents(
amountCents: Cents,
lateFeeBpsPerMonth: Bps,
daysLate: number,
): Result<Cents> {
if (!Number.isInteger(amountCents) || amountCents <= 0) {
return fail("amount must be a positive integer amount in cents");
}
if (!isBps(lateFeeBpsPerMonth)) {
return fail("late fee must be between 0 and 10000 bps per month");
}
if (!Number.isInteger(daysLate) || daysLate < 0) {
return fail("days late must be a non-negative integer");
}
return ok(
Math.round((amountCents * lateFeeBpsPerMonth * daysLate) / 300000),
);
}
assessPaymentTerm 把「Net 15」拆成到期偏移天数和每月逾期费。以 12,000 美元、月费率 100 个基点为例,每月逾期费是 120 美元;这个 100 个基点是代码测试用的示例费率,不是案例合同费率,真实合同要把费率写清楚。
cashClock 处理前提:如果是验收后付款,总天数 = 验收等待 + 账期;如果是发票后付款,验收等待不进入时钟。Nord 的 30 天验收加 30 天账期等于 60 天;但它的应收 DSO 只有 27 天,所以实际现金周期约为 30 + 27 = 57 天。合同看起来是 30 天,实际接近 60 天。
netTermTable 生成上表的占用余额:LedgerLite 在 Net 15、30、60 下分别是 9,873、19,746、39,492 美元,占 ARR 的 4.1%、8.2%、16.4%。dsoDays 把年末应收换回天数:LedgerLite 12,000 美元对应 18 天,Nord 11,265 美元对应 27 天。dsoGapDays 返回实际 DSO 与合同账期的差:LedgerLite 是 +3 天,Nord 是 -3 天。
lateFeeCents 只计算简单、按天折算的逾期费:12,000 美元、月费率 100 个基点、逾期 45 天,费用是 180 美元。合同如果约定复利、固定罚金或按年费率计算,这个函数不适用;口径必须在合同里写清楚,代码不能替合同解释。
代码不能判断条款是否可执行,也不能替你决定要不要接受 Net 60。它只做一件事:把「Net 30」翻译成占用余额、DSO 和逾期费,让谈判桌上的每个百分比都有一个现金数字对应。
小结
- 付款条件是金额、时点、前提、违约四个要素的组合,不是一句 Net 30。
- 金额要写清含税与不含税、币种、折扣和调价;调价通知期决定新价格何时生效。
- 时点从发票日算,不是从交付日算;前提决定现金时钟什么时候开始走。
- Net 15/30/60 的占用是线性的:15 天占 ARR 4.1%,30 天 8.2%,60 天 16.4%。
- 对 Nord,Net 30 占用 SDE 的 37.0%,Net 60 占用 73.9%;对 LedgerLite,分别是 15.2% 和 30.3%。
- 合同账期不等于实际 DSO;LedgerLite 是 18 天对 Net 15,Nord 是 27 天对 Net 30。
- 逾期率是应收占比,不是逾期费率;坏账准备是会计分摊,不是损失上限。
- 违约条款的力度要和单笔应收金额匹配;代码只算数,不替代法律判断。
阅读导航:上一节:1.2 范围、交付与验收:把「做完」写成可判定条件 · 下一节:2.1 责任上限:最坏损失怎么算 。
常见错误与边界
- 只写金额不写前提。 金额是分子,前提决定分子什么时候变成现金;没有前提的付款条款只是一个金额。
- 把 Net 30 当成实际 DSO。 合同账期是条款,DSO 是运营结果;LedgerLite 的 Net 15 实际是 18 天,Nord 的 Net 30 实际是 27 天。
- 验收后付款但没有验收期限。 验收可以无限期悬空,账期就没有起点;必须写清验收时限或视为通过条款。
- 逾期费按年写、按月收。 年费率和月费率的金额差 12 倍;合同必须写清计费基数和周期。
- 没有暂停服务或加速到期条款。 客户逾期后只有催收一条路;对大额应收,暂停权和加速到期是更直接的现金工具。
- 用一张发票推算全年现金。 单张发票的账期不构成常年占用;要用年收入 × 账期 / 365 计算余额。
- 忽略税费和币种。 含税与不含税、本币与外币的差额可能吞掉利润;跨境付款还要考虑换汇和手续费。
- 把坏账准备当成损失上限。 2% 的准备只覆盖 225 美元,一笔 11,265 美元的应收收不回,净损失仍然接近 SDE 的三分之一。
- 调价没有通知期。 没有通知期,客户可以按旧价付到合同结束;调价条款要写清通知天数、生效日和是否适用存量合同。
- 把逾期率当成逾期费率。 逾期率说明多少应收已经逾期,逾期费率说明逾期后每天收多少;两者不能互相替代。
下一节进入责任条款:付款条件决定现金什么时候到账,责任上限决定最坏情况下要赔多少。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。