《独立开发者的商业指南·合同、应收与风险》1.3 价格、调价与付款条款:金额、时点、前提与违约责任

付款条件不是一句 Net 30,而是金额、时点、前提与违约责任四个要素的组合。本节用 LedgerLite 的 Net 15 与 Nord Studio 的 Net 30 拆开现金占用,给出 Net 15/30/60 的占用表,说明验收后付款如何把真实现金周期拉长,并把逾期率和坏账准备换算成可计提的金额。

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

付款条件通常被写成一句「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」翻译成现金数字。下表给出每个要素要写什么、对应什么财务量,以及两个案例的落点。

要素合同里要写什么可计算的量LedgerLiteNord Studio
金额单价、数量、折扣、税费、币种、是否含税合同金额与净额ARPA 77 美元/月85 美元/小时 × 110 小时
时点发票日、到期日、账期、节假日顺延现金占用天数Net 15Net 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 占用余额占其 ARRNord Studio 占用余额占其 ARR
Net 15159,873 美元4.1%6,342 美元4.1%
Net 303019,746 美元8.2%12,684 美元8.2%
Net 606039,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 157.6%18.5%
Net 3015.2%37.0%
Net 6030.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差额
LedgerLiteNet 1512,000 美元5.0%18 天+3 天
Nord StudioNet 3011,265 美元7.3%27 天-3 天

LedgerLite 的实际 DSO 比合同多 3 天,来自开票批次、银行转账和客户付款习惯;Nord 的实际 DSO 比合同少 3 天,因为应收账龄只统计已开票未收回的部分,验收等待在开票之前,不在账龄里。账期是合同变量,DSO 是运营结果;把两者混为一谈,会高估或低估现金占用。

如果你只有一张年末资产负债表,不要用它推算全年 DSO。年末应收可能刚好赶上大客户提前付款,也可能刚好堆着几张未到期发票;用 12 个月平均应收,或者至少用四个季度末的平均值,才能接近真实占用。

违约条款:把逾期变成可计算的成本

违约条款的财务量不是「有没有罚款」,而是「逾期金额有多大、准备计提多少、最坏损失多少」。下表把两个案例的应收、逾期比例和坏账准备放在一起。

项目LedgerLiteNord Studio
应收12,000 美元11,265 美元
逾期 30 天以上占比6%8%
逾期金额720 美元901 美元
坏账准备率1%2%
坏账准备120 美元225 美元
坏账准备占 SDE0.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 责任上限:最坏损失怎么算 。

常见错误与边界

  1. 只写金额不写前提。 金额是分子,前提决定分子什么时候变成现金;没有前提的付款条款只是一个金额。
  2. 把 Net 30 当成实际 DSO。 合同账期是条款,DSO 是运营结果;LedgerLite 的 Net 15 实际是 18 天,Nord 的 Net 30 实际是 27 天。
  3. 验收后付款但没有验收期限。 验收可以无限期悬空,账期就没有起点;必须写清验收时限或视为通过条款。
  4. 逾期费按年写、按月收。 年费率和月费率的金额差 12 倍;合同必须写清计费基数和周期。
  5. 没有暂停服务或加速到期条款。 客户逾期后只有催收一条路;对大额应收,暂停权和加速到期是更直接的现金工具。
  6. 用一张发票推算全年现金。 单张发票的账期不构成常年占用;要用年收入 × 账期 / 365 计算余额。
  7. 忽略税费和币种。 含税与不含税、本币与外币的差额可能吞掉利润;跨境付款还要考虑换汇和手续费。
  8. 把坏账准备当成损失上限。 2% 的准备只覆盖 225 美元,一笔 11,265 美元的应收收不回,净损失仍然接近 SDE 的三分之一。
  9. 调价没有通知期。 没有通知期,客户可以按旧价付到合同结束;调价条款要写清通知天数、生效日和是否适用存量合同。
  10. 把逾期率当成逾期费率。 逾期率说明多少应收已经逾期,逾期费率说明逾期后每天收多少;两者不能互相替代。

下一节进入责任条款:付款条件决定现金什么时候到账,责任上限决定最坏情况下要赔多少。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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