《独立开发者的商业指南·收入与财务工程》9.1 销售税/VAT/GST 的注册与代收

流转税是代收的现金,不是收入。本节用 LedgerLite 的 GST、Nord 的 VAT 和 Tidecast 的平台代收,拆解税基、含税价换算、计税时点、注册门槛与自收代收的现金流差异,并给出一个可复算的 TypeScript 模型。

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

独立开发者最容易把收到的钱都当成收入。订阅费、买断价、平台结算款进入账户时,数字看起来就是客户付的钱;但其中可能有一部分从来不属于你,只是你替税务机关暂存了一段现金。流转税——GST、VAT、销售税——就是这一类负债。它不改变你的定价能力,却会改变你在什么时候、以什么金额、向谁付款。

这一节只处理财务侧的四个问题:税基是什么、税率怎样作用在价格上、税金在什么时候变成现金义务、跨境销售在什么门槛下触发注册。申报表编号、注册网站和操作步骤不在本节展开,站内 indie 专题已有操作层说明,见 税务的最低认知 与 什么时候需要一个主体 。

流转税的税基与计税时点

流转税通常按不含税价计算,税率作用在净价上,再由卖方把税额加到发票总额里。客户看到的是含税价,卖方账上要拆成两块:收入是净价,税金是负债。这个拆分不因收款方式改变,只因辖区规则和交易性质改变。

税种教学示例税率典型税基常见计税时点谁承担申报
新加坡 GST9%不含税销售额发票、收款或交付,按规则择一注册主体
英国 VAT20%不含税销售额发票或交付注册主体
美国销售税由平台按目的地代收零售价,州与地方叠加交易完成平台或卖方,视登记与平台规则

表里的税率是教学示例,不是现行税率;实际税率、税基调整、零税率与豁免范围都要按当期法规核对。三者的共同点比差异更重要:税金不是利润表里的收入,而是资产负债表上的负债,直到申报和缴纳完成才从账上消失。

计税时点决定现金流节奏。如果规则按发票确认,你开票时就已经欠税,即使客户还没付款;如果按收款确认,客户付款才触发义务。两种口径在增长期会产生不同的现金缺口:前者要求你用自有现金先垫税,后者把风险和应收绑在一起。LedgerLite 的年收入 $240,240、月 ARPA $77,按 9% 教学税率计算,单客户月含税价是 $83.93;如果 260 家客户同时开票而回款要等 Net 15,税金部分就先于现金离开。

对账时要把「含税收入」和「不含税收入」分开记。把含税金额直接记成收入,会同时高估收入和毛利率;把税金记成费用,会低估毛利、又把负债藏进费用。正确做法是收入按净价确认,税金单列负债,退款时按原比例冲回。收入确认的时点问题见 4.1 预收、递延与确认时点 ,本节只强调流转税不参与收入确认,它只是价格的一个组成部分。

含税价与不含税价的换算

换算有两个方向,方向用错是这一章最常见的错误。已知净价加税,税额等于净价乘税率;已知含税价倒推,净价等于含税价除以一加税率,税额是含税价减净价。两个方向在四舍五入后可能差一美分,所以代码里必须固定「先算哪一边、余数归谁」。

场景起点税率税额另一端
LedgerLite 月费,净价$77.009%$6.93含税 $83.93
Nord 产品订阅,净价$39.0020%$7.80含税 $46.80
Tidecast 单价,按净价理解$19.999%$1.80含税 $21.79
Tidecast 单价,按含税价理解$19.999%$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 所得税与常设机构 。

常见错误与边界

  1. 把含税收款全部记成收入。 收入虚高、毛利率虚高,税金负债消失;LedgerLite 的 $83.93 里只有 $77 是收入。
  2. 换算方向用反。 把含税价乘税率,会把税额算大;把净价除以一加税率,会把净价算小。两个方向必须固定。
  3. 把平台结算款当成「没有税」。 平台代收只是把申报义务转移,佣金里可能已经包含税务成本;卖方仍要确认自己是否被平台规则覆盖。
  4. 只看税率不看税基。 税率相同,税基含不含税、含不含运费、折让如何处理,都会改变实际税负。
  5. 把注册当一次性动作。 门槛按滚动期间统计,销售额、库存、平台规则变化都可能重新触发或改变义务。
  6. 用流转税预测所得税。 两者税基不同;本节所有税额都不进入所得税计算,所得税数字见下一节。
  7. 忽略退款与坏账的税金处理。 退款是否退税金、坏账能否冲减税基,按辖区规则而异,不能默认自动冲回。

流转税处理完,剩下的才是利润。下一节把利润放到主体结构里,讨论 Pte Ltd、LLC 与 Ltd 的所得税落点,以及常设机构什么时候把纳税义务带到客户所在国。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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