以下情境为教学假设,人物与数字不代表经核实的真实案例。
独立开发者最容易把收到的钱都当成收入。订阅费、买断价、平台结算款进入账户时,数字看起来就是客户付的钱;但其中可能有一部分从来不属于你,只是你替税务机关暂存了一段现金。流转税——GST、VAT、销售税——就是这一类负债。它不改变你的定价能力,却会改变你在什么时候、以什么金额、向谁付款。
这一节只处理财务侧的四个问题:税基是什么、税率怎样作用在价格上、税金在什么时候变成现金义务、跨境销售在什么门槛下触发注册。申报表编号、注册网站和操作步骤不在本节展开,站内 indie 专题已有操作层说明,见 税务的最低认知
与 什么时候需要一个主体
。
流转税的税基与计税时点
流转税通常按不含税价计算,税率作用在净价上,再由卖方把税额加到发票总额里。客户看到的是含税价,卖方账上要拆成两块:收入是净价,税金是负债。这个拆分不因收款方式改变,只因辖区规则和交易性质改变。
| 税种 | 教学示例税率 | 典型税基 | 常见计税时点 | 谁承担申报 |
|---|---|---|---|---|
| 新加坡 GST | 9% | 不含税销售额 | 发票、收款或交付,按规则择一 | 注册主体 |
| 英国 VAT | 20% | 不含税销售额 | 发票或交付 | 注册主体 |
| 美国销售税 | 由平台按目的地代收 | 零售价,州与地方叠加 | 交易完成 | 平台或卖方,视登记与平台规则 |
表里的税率是教学示例,不是现行税率;实际税率、税基调整、零税率与豁免范围都要按当期法规核对。三者的共同点比差异更重要:税金不是利润表里的收入,而是资产负债表上的负债,直到申报和缴纳完成才从账上消失。
计税时点决定现金流节奏。如果规则按发票确认,你开票时就已经欠税,即使客户还没付款;如果按收款确认,客户付款才触发义务。两种口径在增长期会产生不同的现金缺口:前者要求你用自有现金先垫税,后者把风险和应收绑在一起。LedgerLite 的年收入 $240,240、月 ARPA $77,按 9% 教学税率计算,单客户月含税价是 $83.93;如果 260 家客户同时开票而回款要等 Net 15,税金部分就先于现金离开。
对账时要把「含税收入」和「不含税收入」分开记。把含税金额直接记成收入,会同时高估收入和毛利率;把税金记成费用,会低估毛利、又把负债藏进费用。正确做法是收入按净价确认,税金单列负债,退款时按原比例冲回。收入确认的时点问题见 4.1 预收、递延与确认时点 ,本节只强调流转税不参与收入确认,它只是价格的一个组成部分。
含税价与不含税价的换算
换算有两个方向,方向用错是这一章最常见的错误。已知净价加税,税额等于净价乘税率;已知含税价倒推,净价等于含税价除以一加税率,税额是含税价减净价。两个方向在四舍五入后可能差一美分,所以代码里必须固定「先算哪一边、余数归谁」。
| 场景 | 起点 | 税率 | 税额 | 另一端 |
|---|---|---|---|---|
| LedgerLite 月费,净价 | $77.00 | 9% | $6.93 | 含税 $83.93 |
| Nord 产品订阅,净价 | $39.00 | 20% | $7.80 | 含税 $46.80 |
| Tidecast 单价,按净价理解 | $19.99 | 9% | $1.80 | 含税 $21.79 |
| Tidecast 单价,按含税价理解 | $19.99 | 9% | $1.65 | 净价 $18.34 |
同一个 $19.99,作为净价和作为含税价会得到不同的税额。这不是计算错误,而是标价口径不同:面向消费者的标价通常含税,面向企业的报价通常不含税。独立开发者必须在价目表、发票和支付页上保持同一口径,否则客户看到的价格、你确认的收入和申报的税基会三套数字。
Nord Studio 的订阅价 $39 在英国 VAT 20% 下含税为 $46.80。如果 Nord 对 90 家产品客户都按含税价收款,月收款是 $4,212,其中 $702 是税金,收入仍是 $3,510。把这 $702 当成收入,会让产品线的毛利率虚高;把它当成费用,又会让产品线的贡献被低估。两种错法都会影响 7.1 客户分层 里的客户价值判断。
Tidecast 的情况更绕。Steam 渠道的标价、退款、分成和销售税由平台规则处理,开发者拿到的净得 $138,171 已经扣过退款、平台分成和支付费;官网渠道则要自己处理支付费与可能的销售税。两条渠道的净得率不同,不只是因为分成比例,也因为税金和费用的承担位置不同。渠道结构的完整拆解见 1.2 收入模型组合 ,这里只留下一个判断:比较渠道时,要比「税后、费后、退款后」的净得,而不是比标价。
平台代收与自收:谁持有税金
平台代收销售税时,买方付含税价,平台把税金留下并申报,卖方收到的是净价减平台佣金。卖方账上不出现税金负债,现金流更干净,但佣金通常按净价计算,且平台可能把税务合规成本折进费率。自收则相反:卖方收到含税价,支付费按总额扣,税金在申报前留在账上,看起来现金更多,实际有一部分是待缴负债。
把 Tidecast 的渠道费率代入一组教学税率,可以看到两种模式的差别。Steam 分成 30%、官网支付费 5%,这两个数字来自案例事实表;9% 只是为了比较而设的教学税率,不是 Tidecast 的实际税负。
| 项目 | 平台代收 | 自收 |
|---|---|---|
| 买方支付含税价 | $19.99 | $19.99 |
| 其中税金 | $1.65 | $1.65 |
| 净价 | $18.34 | $18.34 |
| 平台佣金或支付费 | $5.50 | $1.00 |
| 卖方收到现金 | $12.84 | $17.34 |
| 卖方申报缴纳 | $0 | $1.65 |
| 缴税后净得 | $12.84 | $15.69 |
自收在缴税后仍多 $2.85,但卖方要承担注册、申报、汇率和滞纳风险;平台代收少了这笔现金,却把税金和大部分合规责任转移出去。这个比较不能只看一单,而要看全年:Tidecast 年销 9,600 份,Steam 占 80%,每份差 $2.85 就是约 $21,888 的净得差;反过来,自收要把 1,920 份官网订单的税金、支付费和申报工作全部接住。渠道选择因此不是「哪种费率低」,而是「哪种现金结构你能承受」。
平台代收还有一个容易被忽略的财务影响:退款。退款发生时,平台通常按原含税价退回,税金和佣金一起冲回;自收退款则要自己处理税金退回和支付费是否退还。Tidecast 的退款率 4%,在 9,600 份上约 384 份;每份的税金处理差异会被放大成一笔可观的的对账工作。退款对净得的影响见 4.2 收入质量 。
注册门槛:跨过之前与跨过之后
跨境销售不会因为你在本国没有实体就自动免税。许多辖区按累计销售额、交易笔数或本地库存触发注册义务;数字服务、低价值货物和平台销售还各有特殊规则。门槛的数值、统计期间、是否含税、是否按目的地拆分,都因辖区而异,本节不写死任何一个数字,代码把门槛作为输入。
| 触发因素 | 典型事实 | 财务后果 |
|---|---|---|
| 累计销售额 | 连续 12 个月或日历年的本地销售额超过门槛 | 注册后价格需含税,税金成为负债 |
| 交易笔数 | 本地订单数达到规则上限 | 即使金额小也可能触发 |
| 本地库存 | 在客户所在国持有存货 | 可能同时触发流转税与所得税义务 |
| 平台销售 | 平台作为视为供应商代收 | 卖方可能免于注册,但佣金上升 |
| 数字服务 | 向消费者提供软件、订阅或内容 | 目的地规则可能要求本地注册 |
门槛的财务意义不在「要不要注册」这个二元答案,而在跨过前后的价格与现金变化。跨过之前,你可以按净价收款,竞争价格更干净;跨过之后,含税价不变则收入被税金压缩,含税价上调则需求可能下降。LedgerLite 的 ARR $240,240 如果落在某个教学门槛之下,注册会把 9% 的税金从「不存在」变成「每笔都要处理」;如果已经在门槛之上,拖延注册只是把负债留在未来,且可能伴随滞纳金。
注册还会改变现金流预测的起点。未注册时,经营现金流约等于收款减费用;注册后,收款里要先拆出税金,申报周期内这笔钱不能用于发工资或投放。13 周现金流表要把税金单独列一行,而不是并进「应付」。现金流表的做法见 8.1 13 周现金流表 ,税费预留的做法见 税费预提 。
把换算与代收写成代码
下面的 sales-tax.ts 把四件事固化:addTax 从净价加税,extractTax 从含税价倒推,checkRegistration 用累计销售额与门槛判断是否触发,settleCollection 比较平台代收与自收在佣金、支付费和税金申报上的现金差异。所有金额用整数美分,所有税率用基点。
// file: src/tax/sales-tax.ts
// tier: L1
// verify: node --test test/tax/sales-tax.test.ts
import { type Cents, add, sub } from "../core/money.ts";
import { type Bps, applyRate } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type TaxedPrice = {
net: Cents;
tax: Cents;
gross: Cents;
};
function isCents(value: number): boolean {
return Number.isInteger(value) && value >= 0;
}
function isValidRate(rate: Bps): boolean {
return Number.isInteger(rate) && rate >= 0 && rate < 10000;
}
/**
* Adds tax to a net (tax-exclusive) price. The tax is rounded once, at the
* invoice level, so the gross amount is always net plus the booked tax.
*/
export function addTax(net: Cents, rate: Bps): Result<TaxedPrice> {
if (!isCents(net)) {
return fail("net price must be a non-negative integer in cents");
}
if (!isValidRate(rate)) {
return fail("tax rate must be an integer between 0 and 9999 bps");
}
const tax = applyRate(net, rate);
return ok({ net, tax, gross: add(net, tax) });
}
/**
* Extracts tax from a gross (tax-inclusive) price. The net amount is derived
* first and the tax is the remainder, so net plus tax equals gross exactly.
*/
export function extractTax(gross: Cents, rate: Bps): Result<TaxedPrice> {
if (!isCents(gross)) {
return fail("gross price must be a non-negative integer in cents");
}
if (!isValidRate(rate)) {
return fail("tax rate must be an integer between 0 and 9999 bps");
}
const net = Math.round((gross * 10000) / (10000 + rate));
return ok({ net, tax: sub(gross, net), gross });
}
export type RegistrationCheck = {
required: boolean;
taxableSales: Cents;
threshold: Cents;
headroom: Cents;
};
/**
* Compares cumulative taxable sales with a registration threshold. The
* threshold is an input because it depends on jurisdiction, period and the
* nature of the supply. A negative headroom means the threshold is exceeded.
*/
export function checkRegistration(
taxableSales: Cents,
threshold: Cents,
): Result<RegistrationCheck> {
if (!isCents(taxableSales)) {
return fail("taxable sales must be a non-negative integer in cents");
}
if (!Number.isInteger(threshold) || threshold <= 0) {
return fail("threshold must be a positive integer in cents");
}
return ok({
required: taxableSales >= threshold,
taxableSales,
threshold,
headroom: sub(threshold, taxableSales),
});
}
export type CollectionMode = "platform" | "direct";
export type CollectionInput = {
gross: Cents;
rate: Bps;
commissionBps: Bps;
feeBps: Bps;
mode: CollectionMode;
};
export type CollectionResult = {
mode: CollectionMode;
gross: Cents;
net: Cents;
tax: Cents;
commission: Cents;
fee: Cents;
received: Cents;
taxRemittedBySeller: Cents;
};
function isValidCostRate(rate: Bps): boolean {
return Number.isInteger(rate) && rate >= 0 && rate <= 10000;
}
/**
* Compares two ways of collecting consumption tax. Under platform collection
* the marketplace extracts the tax, remits it and pays the seller net minus
* commission; the seller never holds the tax. Under direct collection the
* seller receives the gross price, pays a payment fee and still owes the tax
* to the authority until it is remitted.
*/
export function settleCollection(input: CollectionInput): Result<CollectionResult> {
if (!isCents(input.gross)) {
return fail("gross price must be a non-negative integer in cents");
}
if (input.mode !== "platform" && input.mode !== "direct") {
return fail(`unknown collection mode: ${input.mode}`);
}
if (!isValidCostRate(input.commissionBps) || !isValidCostRate(input.feeBps)) {
return fail("commission and fee rates must be integers between 0 and 10000 bps");
}
const priced = extractTax(input.gross, input.rate);
if (!priced.ok) {
return priced;
}
const commission =
input.mode === "platform" ? applyRate(priced.value.net, input.commissionBps) : 0;
const fee = input.mode === "direct" ? applyRate(input.gross, input.feeBps) : 0;
const received = sub(sub(sub(input.gross, priced.value.tax), commission), fee);
return ok({
mode: input.mode,
gross: input.gross,
net: priced.value.net,
tax: priced.value.tax,
commission,
fee,
received,
taxRemittedBySeller: input.mode === "direct" ? priced.value.tax : 0,
});
}
代码里有三个边界。第一,addTax 只对税额取整一次,extractTax 先算净价、再用含税价减净价得到税额,保证两个方向都不会出现「净价加税额不等于含税价」。第二,checkRegistration 的 headroom 在超过门槛后为负数,调用方可以直接看出超出多少,而不是再写一次比较。第三,settleCollection 把平台佣金按净价计算、把自收支付费按含税总额计算,并把 taxRemittedBySeller 单独返回;平台模式下这个值是 0,自收模式下它等于税金,提醒你「收到的现金」和「缴税后的净得」不是同一个数。
把 LedgerLite 的 $77 代入 addTax,得到税额 $6.93、含税价 $83.93;把 Nord 的 $39 按 20% 代入,得到 $7.80 和 $46.80;把 Tidecast 的 $19.99 按含税价代入 extractTax,得到净价 $18.34、税额 $1.65。这三个结果都可以在代码测试里复算,不依赖任何手工四舍五入。
小结
- 流转税是代收的现金负债,不是收入;收入按净价确认,税金单列,退款按原比例冲回。
- 9% GST、20% VAT 和平台代收销售税都是教学示例,税率、税基和计税时点必须按当期法规核对。
- 含税价与不含税价的换算方向不能混用;同一个 $19.99 作为净价和含税价会得到不同税额。
- LedgerLite 的 $77 月费在 9% 下含税 $83.93,Nord 的 $39 在 20% 下含税 $46.80,税金部分都不是收入。
- 平台代收把税金和大部分合规责任转移出去,自收保留更多现金但承担申报与滞纳风险;比较要看缴税后的净得。
- 注册门槛是输入而不是常数;跨过门槛会同时改变价格、现金和申报节奏。
- 本节只算流转税。LedgerLite 的所得税 $11,941、Nord 的 $8,580、Tidecast 的 $29,081 是所得税口径的示例数字,下一节展开。
阅读导航:上一节:8.3 runway 与最低现金水位 · 下一节:9.2 所得税与常设机构 。
常见错误与边界
- 把含税收款全部记成收入。 收入虚高、毛利率虚高,税金负债消失;LedgerLite 的 $83.93 里只有 $77 是收入。
- 换算方向用反。 把含税价乘税率,会把税额算大;把净价除以一加税率,会把净价算小。两个方向必须固定。
- 把平台结算款当成「没有税」。 平台代收只是把申报义务转移,佣金里可能已经包含税务成本;卖方仍要确认自己是否被平台规则覆盖。
- 只看税率不看税基。 税率相同,税基含不含税、含不含运费、折让如何处理,都会改变实际税负。
- 把注册当一次性动作。 门槛按滚动期间统计,销售额、库存、平台规则变化都可能重新触发或改变义务。
- 用流转税预测所得税。 两者税基不同;本节所有税额都不进入所得税计算,所得税数字见下一节。
- 忽略退款与坏账的税金处理。 退款是否退税金、坏账能否冲减税基,按辖区规则而异,不能默认自动冲回。
流转税处理完,剩下的才是利润。下一节把利润放到主体结构里,讨论 Pte Ltd、LLC 与 Ltd 的所得税落点,以及常设机构什么时候把纳税义务带到客户所在国。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。