流水、结算与收入是三本账
运营后台看到的「月流水」、渠道后台看到的「结算金额」、财务账上的「可确认收入」,是三个不同的数。它们之间的差额不是误差,而是由退款、分成、结算周期和未达账项共同构成的。
| 账 | 记录什么 | 时间口径 | 主要用途 |
|---|---|---|---|
| 内部订单账 | 玩家下单、支付、发货、退款的事实 | 订单创建或支付时点 | 运营分析、客服、补单 |
| 渠道结算单 | 渠道确认的流水、分成、净额 | 渠道结算周期 | 对账、开票、回款 |
| 财务收入账 | 按会计准则确认的收入与递延 | 权责发生制 | 报表、审计、税务 |
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 Store | 30% | ¥30 | ¥70 |
| Google Play | 30% | ¥30 | ¥70 |
| 国内安卓渠道 | 30%–50%,案例按平均 40% 计 | ¥40 | ¥60 |
| TapTap | 15% | ¥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 广告位设计与激励视频边界 。
常见错误与边界
- 用内部订单号做渠道对账键。渠道结算单通常只有渠道交易号,内部订单号对不上;匹配键必须是渠道加渠道交易号。
- 拿原始成交金额直接比结算流水。渠道结算通常已扣退款,不先减退款会把每笔退款都变成金额差异。
- 把分成差异和金额差异混为一类。流水对得上但分成不对,通常是合同费率或返点问题,处理路径与金额差异完全不同。
- 用平均费率代替逐渠道费率。国内安卓 30%–50% 的区间被压成 40%,会同时高估低费率渠道和低估高费率渠道的成本。
- 忽略 Apple 小企业计划的适用条件。不满足条件却按 15% 估算,会让分成成本少算一半,收入确认直接失真。
- 让重复结算行覆盖前一行。同一个渠道交易号出现两次,后一行覆盖前一行会掩盖重复结算;必须单独报
duplicate-settlement。 - 把渠道没返回结算行当成零收入。空账单可能是渠道延迟或接口失败,应先确认结算周期和接口状态。
- 差异比例和差异金额只设一个阈值。只设比例会放过单笔大额异常,只设金额会放过大量小额系统性差异。
- 对账完成后直接调平。调平分录会让差异失去归属,审计时无法解释差额;应保留原始差异并按类别挂账。
- 把未达账项直接冲减已确认收入。跨月结算和跨月退款应按未达账项处理,直接冲减会让本月收入随渠道结算节奏波动。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。