《移动游戏产品的架构·商业化与发行工程》5.3 渠道对账与收入确认

渠道结算单与内部订单账很少天然一致。《潮汐消消》内购月流水约 609 万元、《潮汐方舟》约 2,730 万元,按苹果 30%、Google Play 30%、国内安卓平均 40%、TapTap 15% 分成,本节用订单级匹配、差异分类和收入确认门禁,说明流水、结算净额与可确认收入之间的差额从哪里来。

流水、结算与收入是三本账

运营后台看到的「月流水」、渠道后台看到的「结算金额」、财务账上的「可确认收入」,是三个不同的数。它们之间的差额不是误差,而是由退款、分成、结算周期和未达账项共同构成的。

账记录什么时间口径主要用途
内部订单账玩家下单、支付、发货、退款的事实订单创建或支付时点运营分析、客服、补单
渠道结算单渠道确认的流水、分成、净额渠道结算周期对账、开票、回款
财务收入账按会计准则确认的收入与递延权责发生制报表、审计、税务

5.1 保证订单状态和发货幂等,5.2 保证掉单能被补回来。5.3 处理的是月末那一步:把内部订单账和渠道结算单逐笔对上,把差异分类,再决定这个月的结算净额能不能确认为收入。对账的目标不是让三个数完全相等。内部订单账按支付时点记,渠道结算单按结算周期记,财务收入按权责发生制记,三者天然有时间和口径差。对账的目标是让每一笔差异都有归属:是退款、是分成、是渠道漏结、是内部漏记,还是重复结算。没有归属的差异,不能靠「调平」处理。

匹配键与差异分类

订单级对账的第一步是选对匹配键。案例用 channel + channelOrderId,而不是内部订单号。原因是渠道结算单里通常只有渠道自己的交易号,内部订单号是游戏侧生成的,渠道不认;用内部订单号匹配,等于要求渠道回传一个它没有的字段。

匹配键确定后,每一笔内部订单只会有六种结果:

结果含义后续动作
matched金额、分成都与预期一致进入收入确认
amount-mismatch渠道结算流水与内部订单不一致查退款、币种、汇率或渠道改价
fee-mismatch流水一致,但分成金额不对查合同费率、阶梯返点或渠道政策
missing-in-settlement内部有订单,渠道结算单没有渠道漏结或结算延迟,挂未达账项
missing-in-ledger渠道有结算,内部没有订单内部漏记、跨月订单或渠道重复推送
duplicate-settlement同一笔订单在结算单出现多次渠道重复结算,不能直接确认收入

退款是金额差异的最大来源。渠道结算的流水通常是扣除退款后的净额,而内部订单账保留原始成交金额和退款金额。案例在比较前先算 gross - refunded,再与结算流水比;如果不做这一步,每一笔退款都会变成一条 amount-mismatch,对账队列会被噪声淹没。

分成差异要单独分类。流水对得上、分成对不上,通常不是渠道算错,而是合同里有阶梯返点、地区差异或新老政策切换。把 fee-mismatch 和 amount-mismatch 混在一起,会导致财务去查一笔根本没问题的订单金额。

分成口径

案例的分成口径按渠道合同固定,不随产品变化:

渠道分成比例每 ¥100 流水分成每 ¥100 流水净额
Apple App Store30%¥30¥70
Google Play30%¥30¥70
国内安卓渠道30%–50%,案例按平均 40% 计¥40¥60
TapTap15%¥15¥85

国内安卓渠道的 30%–50% 是一个区间,不是单一费率。案例用 40% 作为平均,是为了在预算和收入确认时有一个可计算的口径;实际对账必须按每个渠道合同的实际费率逐笔计算,不能把平均值当成每一家的费率。把区间压成一个数,会同时高估低费率渠道的成本、低估高费率渠道的成本。

Apple 的小企业计划 15% 不适用于本案例。这个细节值得单独说:很多团队会在预算里按 15% 估算苹果成本,但案例的两款产品都不满足小企业计划条件,实际按 30% 计。分成比例错了 15 个百分点,月流水 609 万元的消消会少算约 91 万元成本,收入确认直接失真。

案例:两条流水的混合费率

把内购月流水和平台订单占比放在一起,可以估算两款产品的混合分成率。这里有一个明确的简化:用平台订单占比近似收入占比,仅用于演示分账算法,不代替逐笔对账。

产品内购月流水平台占比(按订单)混合分成率分成金额结算净额
《潮汐消消》≈609 万元安卓 55%、iOS 30%、TapTap 8%、海外 7%34.3%≈209 万元≈400 万元
《潮汐方舟》≈2,730 万元iOS 45%、安卓 40%、Google Play 15%34.0%≈928 万元≈1,802 万元

消消的混合费率是 34.3%:安卓 55% × 40% + iOS 30% × 30% + TapTap 8% × 15% + 海外 7% × 30%。方舟是 34.0%:iOS 45% × 30% + 安卓 40% × 40% + Google Play 15% × 30%。两款产品的混合费率接近,但构成完全不同:消消靠安卓的量,方舟靠 iOS 和安卓的双主力。

这个结果对收入确认有直接影响。消消内购月流水约 609 万元,扣掉约 209 万元分成,结算净额约 400 万元;方舟约 2,730 万元,扣掉约 928 万元分成,结算净额约 1,802 万元。再叠加广告收入,消消月流水约 987 万元(内购 35%、广告 65%),方舟约 2,820 万元(内购 97%、广告 3%)。内购占比越高,渠道分成对总收入的影响越大,方舟的分成金额绝对值也远高于消消。注意「海外 7%」在案例里没有拆成具体渠道。演示算法把它整体按 Google Play 的 30% 计算,这是一个简化;真实对账必须按每个海外商店的实际费率和结算币种分别处理。把海外当成一个渠道,会掩盖汇率、预扣税和地区定价带来的差异。

退款、坏账与收入确认

退款率在事实表里是按订单计的:消消 1.2%,方舟 0.8%。到了收入确认,退款要换算成金额。案例用流水乘退款率做一个近似:消消 609 万元 × 1.2% ≈ 7.3 万元,方舟 2,730 万元 × 0.8% ≈ 21.8 万元。这个近似假设退款订单的客单价与整体一致,真实确认应按实际退款金额计提,不能直接拿比例乘流水。

收入确认还需要一道门禁。对账完成后,不是所有结算净额都能立刻确认:

  • 差异比例过高:如果未匹配或异常订单占比超过阈值,说明本月账单结构有问题,应整体挂起,先查清差异。
  • 差异金额过大:即使比例很低,绝对金额超过容忍线也要人工复核,避免小额高频的异常被比例稀释。
  • 空账单:渠道没有返回任何结算行,不能当作「本月无收入」,应确认是渠道延迟还是接口失败。

案例代码把门禁写成两个参数:最大差异比例和最大差异金额。两者是「或」的关系,任一超限就不可确认。比例控制系统性风险,金额控制单笔重大风险;只设一个,都会留下盲区。

把对账写成可运行的纯函数

下面的实现分三层:按渠道占比估算分成、按订单键匹配两张账、按差异比例和金额决定收入能否确认。金额全部用整数分,避免浮点误差在逐笔累加后放大。

// file: src/biz/reconciliation.ts
// tier: L1
// verify: node --test test/biz/reconciliation.test.ts

export type ChannelId = "apple-ios" | "google-play" | "android-channel" | "tap-tap";

export interface ChannelShare {
  readonly channel: ChannelId;
  readonly shareBps: number;
}

const COMMISSION_BPS: Readonly<Record<ChannelId, number>> = {
  "apple-ios": 3000,
  "google-play": 3000,
  "android-channel": 4000,
  "tap-tap": 1500,
};

export function commissionBps(channel: ChannelId): number {
  return COMMISSION_BPS[channel];
}

export function expectedFeeCents(channel: ChannelId, grossCents: number): number {
  return Math.round((grossCents * commissionBps(channel)) / 10000);
}

export type ProjectionCode = "ok" | "invalid-shares" | "negative-gross";

export interface ProjectionResult {
  readonly code: ProjectionCode;
  readonly blendedFeeBps: number;
  readonly grossCents: number;
  readonly feeCents: number;
  readonly netCents: number;
}

export function projectSettlement(
  grossCents: number,
  shares: readonly ChannelShare[],
): ProjectionResult {
  const totalBps = shares.reduce((sum, share) => sum + share.shareBps, 0);
  const invalidShares = totalBps !== 10000 || shares.some((share) => share.shareBps < 0);
  if (grossCents < 0 || invalidShares) {
    return {
      code: grossCents < 0 ? "negative-gross" : "invalid-shares",
      blendedFeeBps: 0,
      grossCents: 0,
      feeCents: 0,
      netCents: 0,
    };
  }
  const blendedFeeBps = Math.round(
    shares.reduce(
      (sum, share) => sum + (share.shareBps * commissionBps(share.channel)) / 10000,
      0,
    ),
  );
  const feeCents = Math.round((grossCents * blendedFeeBps) / 10000);
  return {
    code: "ok",
    blendedFeeBps,
    grossCents,
    feeCents,
    netCents: grossCents - feeCents,
  };
}

export interface LedgerOrder {
  readonly channel: ChannelId;
  readonly channelOrderId: string;
  readonly grossCents: number;
  readonly refundedCents: number;
}

export interface SettlementRow {
  readonly channel: ChannelId;
  readonly channelOrderId: string;
  readonly grossCents: number;
  readonly feeCents: number;
}

export type MatchCode =
  | "matched"
  | "amount-mismatch"
  | "fee-mismatch"
  | "missing-in-settlement"
  | "missing-in-ledger"
  | "duplicate-settlement";

export interface MatchLine {
  readonly key: string;
  readonly code: MatchCode;
  readonly expectedCents: number;
  readonly actualCents: number;
  readonly feeCents: number;
}

export interface ReconciliationReport {
  readonly lines: readonly MatchLine[];
  readonly matchedCount: number;
  readonly issueCount: number;
  readonly ledgerSettledCents: number;
  readonly statementGrossCents: number;
  readonly statementFeeCents: number;
  readonly statementNetCents: number;
  readonly varianceCents: number;
}

export function settlementKey(channel: ChannelId, channelOrderId: string): string {
  return `${channel}:${channelOrderId}`;
}

export function reconcile(
  ledger: readonly LedgerOrder[],
  statement: readonly SettlementRow[],
  toleranceCents = 0,
): ReconciliationReport {
  const rowsByKey = new Map<string, SettlementRow[]>();
  for (const row of statement) {
    const key = settlementKey(row.channel, row.channelOrderId);
    const rows = rowsByKey.get(key) ?? [];
    rows.push(row);
    rowsByKey.set(key, rows);
  }
  const lines: MatchLine[] = [];
  const seen = new Set<string>();
  for (const order of ledger) {
    const key = settlementKey(order.channel, order.channelOrderId);
    seen.add(key);
    const expected = order.grossCents - order.refundedCents;
    const rows = rowsByKey.get(key) ?? [];
    if (rows.length === 0) {
      lines.push({ key, code: "missing-in-settlement", expectedCents: expected, actualCents: 0, feeCents: 0 });
      continue;
    }
    if (rows.length > 1) {
      const fee = rows.reduce((sum, row) => sum + row.feeCents, 0);
      lines.push({
        key,
        code: "duplicate-settlement",
        expectedCents: expected,
        actualCents: rows[0]?.grossCents ?? 0,
        feeCents: fee,
      });
      continue;
    }
    const row = rows[0];
    if (row === undefined) continue;
    if (Math.abs(row.grossCents - expected) > toleranceCents) {
      lines.push({
        key,
        code: "amount-mismatch",
        expectedCents: expected,
        actualCents: row.grossCents,
        feeCents: row.feeCents,
      });
      continue;
    }
    const fee = expectedFeeCents(order.channel, row.grossCents);
    if (Math.abs(row.feeCents - fee) > toleranceCents) {
      lines.push({
        key,
        code: "fee-mismatch",
        expectedCents: fee,
        actualCents: row.feeCents,
        feeCents: row.feeCents,
      });
      continue;
    }
    lines.push({
      key,
      code: "matched",
      expectedCents: expected,
      actualCents: row.grossCents,
      feeCents: row.feeCents,
    });
  }
  for (const [key, rows] of rowsByKey) {
    if (seen.has(key)) continue;
    for (const row of rows) {
      lines.push({
        key,
        code: "missing-in-ledger",
        expectedCents: 0,
        actualCents: row.grossCents,
        feeCents: row.feeCents,
      });
    }
  }
  const matchedCount = lines.filter((line) => line.code === "matched").length;
  const issueCount = lines.length - matchedCount;
  const ledgerSettledCents = ledger.reduce(
    (sum, order) => sum + order.grossCents - order.refundedCents,
    0,
  );
  const statementGrossCents = statement.reduce((sum, row) => sum + row.grossCents, 0);
  const statementFeeCents = statement.reduce((sum, row) => sum + row.feeCents, 0);
  return {
    lines,
    matchedCount,
    issueCount,
    ledgerSettledCents,
    statementGrossCents,
    statementFeeCents,
    statementNetCents: statementGrossCents - statementFeeCents,
    varianceCents: statementGrossCents - ledgerSettledCents,
  };
}

export interface RecognitionPolicy {
  readonly maxIssueRatioBps: number;
  readonly maxVarianceCents: number;
}

export interface RevenueRecognition {
  readonly recognizable: boolean;
  readonly reason: string;
  readonly netCents: number;
  readonly issueRatioBps: number;
}

export function recognizeRevenue(
  report: ReconciliationReport,
  policy: RecognitionPolicy,
): RevenueRecognition {
  const total = report.matchedCount + report.issueCount;
  const issueRatioBps = total === 0 ? 0 : Math.round((report.issueCount * 10000) / total);
  const result = (recognizable: boolean, reason: string): RevenueRecognition => ({
    recognizable,
    reason,
    netCents: report.statementNetCents,
    issueRatioBps,
  });
  if (total === 0) return result(false, "empty statement");
  if (issueRatioBps > policy.maxIssueRatioBps) {
    return result(false, `issue ratio ${issueRatioBps}bps`);
  }
  if (Math.abs(report.varianceCents) > policy.maxVarianceCents) {
    return result(false, `variance ${report.varianceCents}c`);
  }
  return result(true, "reconciled");
}

export function formatReport(report: ReconciliationReport): string {
  return `matched=${report.matchedCount} issues=${report.issueCount} gross=${report.statementGrossCents} fee=${report.statementFeeCents} net=${report.statementNetCents} variance=${report.varianceCents}`;
}

代码里的三个层次

projectSettlement 处理预算侧的问题:给定总流水和渠道占比,算出混合费率、分成金额和结算净额。它要求占比之和恰好是 10000 个基点,小于或大于都返回 invalid-shares。这个校验看起来苛刻,但能挡住「渠道占比加起来 98%」这类常见配置错误;剩下 2% 不会自动归入某个渠道,而是让调用方显式修正。

reconcile 处理对账侧的问题:先按 channel:channelOrderId 把结算行分组,再遍历内部订单。分组的好处是能识别重复结算:同一个键出现两行,直接报 duplicate-settlement,而不是让后一行覆盖前一行。对于只有一行的键,先比退款调整后的金额,再比分成;金额差异优先于分成差异,因为金额不对时,分成差异通常只是金额差异的派生结果。

recognizeRevenue 处理确认侧的问题:差异比例和差异金额任一超限,就返回不可确认,并给出原因。它不修改对账结果,也不做调平,只回答「这个月的结算净额能不能进收入账」。把确认和调平分开,是为了保留原始差异证据;一旦调平写回,后续审计就看不到差异是怎么消失的。

取舍:确认越晚越准,越晚越没用

对账做得越细,收入确认越准,但出表越晚。渠道结算单通常在次月才完整,退款又可能跨月到达;如果坚持所有差异归零再确认,财务可能要等到次月中下旬才能出数,经营分析已经失去时效。

案例的取舍是「分层确认」:

  • 已匹配部分先确认。金额和分成都对上的订单,占绝大多数,可以先按结算净额确认。
  • 未达账项挂起。missing-in-settlement 和跨月退款进入未达账项,按渠道和历史回收率计提,不直接冲减已确认收入。
  • 异常部分人工复核。amount-mismatch、fee-mismatch 和 duplicate-settlement 进入人工队列,查清后再调整。
  • 设置容忍线。差异比例和差异金额都设阈值,阈值内的差异按月汇总复核,阈值外的逐笔查。

代价是收入确认不再是单一数字,而是一组「已确认 + 未达 + 待查」的组合。财务需要维护三个口径,业务方看到的「收入」也可能与结算净额不一致。换来的是出表时效和差异可追溯:每一笔未确认的金额都有归属,而不是被一个调平分录抹掉。

另一个代价是自动化边界。对账可以自动匹配大部分订单,但差异分类和收入确认的最终判断仍需要人。案例把「匹配」和「确认」分开,就是为了让机器做确定性工作,让人处理不确定部分。把确认也全自动,短期省人力,长期会在审计时付出更高成本。

本章收口:从一笔订单到一个月的账

第 5 章的三节是一条完整的链路。5.1 定义订单是什么:状态、行项目、幂等发货,保证一笔支付事实最多转化成一次权益。5.2 处理订单没有按预期推进时怎么办:查单、补拉、申诉分诊,保证掉单不会变成沉默流失。5.3 把一个月的订单汇总成渠道结算和可确认收入,保证流水、净额和收入之间的差额有归属。三节共享同一组事实:消消月订单 42 万笔、内购月流水约 609 万元;方舟月订单 15 万笔、内购月流水约 2,730 万元。订单量决定补单是流水线而不是手工活,客单价决定自动补发的阈值,内购占比决定渠道分成对收入的影响。把这些数字放在一张图上,才能判断哪个环节值得投入工程资源。

小结

  • 流水、结算净额和可确认收入是三本账,差异来自退款、分成、结算周期和未达账项,不能靠调平抹掉。
  • 订单级对账用 channel + channelOrderId 匹配,结果分为匹配、金额差异、分成差异、两向缺失和重复结算六类。
  • 分成口径:苹果 30%、Google Play 30%、国内安卓平均 40%(区间 30%–50%)、TapTap 15%。
  • 消消内购月流水约 609 万元、混合分成率 34.3%、结算净额约 400 万元;方舟约 2,730 万元、34.0%、约 1,802 万元。
  • 收入确认由差异比例和差异金额双门禁控制,已匹配先确认、未达挂起、异常人工复核。

阅读导航:上一节:5.2 补单、掉单与用户申诉 · 下一节:6.1 广告位设计与激励视频边界 。

常见错误与边界

  1. 用内部订单号做渠道对账键。渠道结算单通常只有渠道交易号,内部订单号对不上;匹配键必须是渠道加渠道交易号。
  2. 拿原始成交金额直接比结算流水。渠道结算通常已扣退款,不先减退款会把每笔退款都变成金额差异。
  3. 把分成差异和金额差异混为一类。流水对得上但分成不对,通常是合同费率或返点问题,处理路径与金额差异完全不同。
  4. 用平均费率代替逐渠道费率。国内安卓 30%–50% 的区间被压成 40%,会同时高估低费率渠道和低估高费率渠道的成本。
  5. 忽略 Apple 小企业计划的适用条件。不满足条件却按 15% 估算,会让分成成本少算一半,收入确认直接失真。
  6. 让重复结算行覆盖前一行。同一个渠道交易号出现两次,后一行覆盖前一行会掩盖重复结算;必须单独报 duplicate-settlement。
  7. 把渠道没返回结算行当成零收入。空账单可能是渠道延迟或接口失败,应先确认结算周期和接口状态。
  8. 差异比例和差异金额只设一个阈值。只设比例会放过单笔大额异常,只设金额会放过大量小额系统性差异。
  9. 对账完成后直接调平。调平分录会让差异失去归属,审计时无法解释差额;应保留原始差异并按类别挂账。
  10. 把未达账项直接冲减已确认收入。跨月结算和跨月退款应按未达账项处理,直接冲减会让本月收入随渠道结算节奏波动。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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