# 结算与返水金额规则 本文档为 **Reference(参考)**:只描述**金额如何计算**,不涉及管理端操作步骤。各玩法定义与判赢规则见 [投注玩法说明.md](./投注玩法说明.md);赛事结算流程见 [settlement-and-fund-flow-analysis.md](./settlement-and-fund-flow-analysis.md)。 --- ## 1. 说明与术语 | 术语 | 含义 | |------|------| | **stake** | 投注本金(`Bet.stake`) | | **odds** | 欧赔,含本金系数(`BetSelection.odds` 或 leg 快照) | | **payout** | 派彩总额,**含本金**(函数 `calculatePayout` / `calculateParlayPayout` 的返回值) | | **actualReturn** | 确认结算后写入注单的派彩,与 `payout` 一致 | | **净盈亏** | `payout - stake`(LOSE 时 payout=0,净亏 = -stake) | | **rate** | 返水比例,**小数**(`0.01` = 1%) | | **lineAmount** | 单笔注单返水:`stake × rate` | **精度**:业务金额在库中为 `Decimal(18,4)`;返水比例为 `Decimal(8,4)`。计算链使用 Prisma `Decimal` 全精度,**无额外 round**(Admin UI 百分比输入另有 `rate-percent.ts` 转换,不影响 API 计算)。 ### 代码真源索引 | 主题 | 文件 | |------|------| | 单关/串关派彩、四分之一盘 | `apps/api/src/domains/settlement/domain/settlement-calculator.ts` | | 确认结算、`actualReturn`、注单状态 | `apps/api/src/domains/settlement/settlement.service.ts` | | 钱包冻结/结算/重结算 | `apps/api/src/domains/ledger/wallet.service.ts` | | 返水费率 | `apps/api/src/domains/operations/cashback/cashback-rate.resolver.ts` | | 返水聚合/批次/入账 | `apps/api/src/domains/operations/cashback/cashback.service.ts` | | 下注限额、`potentialReturn` | `apps/api/src/domains/betting/betting-limits.service.ts`、`bets.service.ts` | | 代理授信 | `apps/api/src/domains/agent/agent-credit.service.ts` | | 串关选盘限制 | `packages/shared/src/betting-rules.ts` | | 可执行数值对照 | `apps/api/src/infrastructure/database/run-settlement-audit.ts` | --- ## 2. 单关派彩公式 函数:`calculatePayout(stake, odds, result)` | 腿级结果 `SelectionResult` | 公式 | 说明 | |---------------------------|------|------| | **WIN** | `stake × odds` | 全赢 | | **HALF_WIN** | `stake/2 × odds + stake/2` | 等价于 `stake × (odds + 1) / 2` | | **PUSH** | `stake` | 走水,退本 | | **VOID** | `stake` | 作废,退本 | | **HALF_LOSE** | `stake / 2` | 半输,退一半本金 | | **LOSE** | `0` | 全输 | ### 数值示例(stake = 100) | 用例 ID | 场景 | odds | 结果 | payout | 净盈亏 | |---------|------|------|------|--------|--------| | BF001 | 1X2 主胜全赢 | 2.0 | WIN | **200.00** | +100.00 | | BF002 | 1X2 和局选项全输 | — | LOSE | **0.00** | -100.00 | | S009 | 让球 -1 全赢 | 1.85 | WIN | **185.00** | +85.00 | | S010 | 让球 -1 走水 | 1.85 | PUSH | **100.00** | 0.00 | | S011 | 让球 -0.25 半输 @ 0-0 | 1.85 | HALF_LOSE | **50.00** | -50.00 | | S011B | 半赢派彩系数 | 1.85 | HALF_WIN | **142.50** | +42.50 | | S012 | 让球 -0.5 全输 @ 0-0 | 1.85 | LOSE | **0.00** | -100.00 | | S015 | 大小 小球 0-0 | 1.95 | WIN | **195.00** | +95.00 | --- ## 3. 串关派彩公式 函数:`calculateParlayPayout(stake, legs[])` 返回:`{ betResult, payout, effectiveOdds }` ```mermaid flowchart TD start[开始] --> anyLose{任一脚 LOSE?} anyLose -->|是| lost["payout=0, betResult=LOST"] anyLose -->|否| combine[连乘有效因子] combine --> allPush{全部 PUSH 或 VOID?} allPush -->|是| push["payout=stake, betResult=PUSH"] allPush -->|否| won["payout=stake×combinedOdds, betResult=WON"] ``` ### 各腿有效因子 | 腿级结果 | 连乘因子 | |----------|----------| | WIN | × `odds` | | HALF_WIN | × `(odds + 1) / 2` | | HALF_LOSE | × `0.5` | | PUSH / VOID | × `1.0`(不参与升赔) | | LOSE | 整单终止,payout = 0 | **最终**:`payout = stake × combinedOdds`(除非 LOST 或全 PUSH/VOID)。 ### 数值示例(stake = 100) | 用例 ID | 腿组合 | effectiveOdds | payout | 净盈亏 | |---------|--------|---------------|--------|--------| | S016 | 1.8 WIN × 2.0 WIN | 3.6000 | **360.00** | +260.00 | | S017 | 1.8 WIN × 2.0 LOSE | 0 | **0.00** | -100.00 | | S018 | 1.8 WIN × 2.0 PUSH × 1.9 WIN | 3.4200 | **342.00** | +242.00 | | S019 | 1.8 PUSH × 2.0 VOID | 1.0000 | **100.00** | 0.00 | ### 串关下注限制 串关**禁止**四分之一让球/大小盘(`.25` / `.75` 线),见 `canSelectForParlay` → 错误码 `QUARTER_LINE`。单关可使用四分之一盘。 ### 跨场结算时序(影响何时产生 payout) 1. 结算**某一场比赛**时,只更新该场对应腿的 `resultStatus`。 2. 若其他场腿尚无结果 → 注单仍为 **PENDING**,此时无最终 `payout`。 3. **任一脚在本场结算为 LOSE** 且其余腿已有结果 → 整单 **LOST**,`payout = 0`。 4. 全部腿均有结果后 → 调用 `calculateParlayPayout` 一次,写入 `actualReturn` 并入账。 --- ## 4. 四分之一盘(.25 / .75) 让球/大小盘为 `.25` 或 `.75` 时,`settleHandicap` / `settleOverUnder` 拆成**两条半盘**分别判定 WIN/PUSH/LOSE,再合并: | 两半组合 | 合并结果 | |----------|----------| | 两赢 | WIN | | 两输 | LOSE | | 一赢一平 | HALF_WIN | | 一输一平 | HALF_LOSE | | 一赢一输 | PUSH | **示例(S011)**:主让 -0.25,比分 0-0 → **HALF_LOSE** → payout = 50(stake=100)。 --- ## 5. `actualReturn` 与注单 `status` 映射 确认结算时(`SettlementService.confirmSettlement`): - **单关(1 腿)**:`result = settleSelection(...)` → `payout = calculatePayout(...)` → **`actualReturn = payout`** - **单关多腿 / 串关**:各腿结果齐备后 → **`actualReturn = calculateParlayPayout(...).payout`** 注单状态由 `betStatusFromSelection` 映射: | 腿级 / 整单结果 | `Bet.status` | |-----------------|--------------| | LOSE | **LOST** | | PUSH、VOID | **PUSH** | | WIN、HALF_WIN、**HALF_LOSE** | **WON** | ### 易错点 | 场景 | actualReturn | Bet.status | |------|--------------|------------| | 单关半输(HALF_LOSE) | `stake/2`(如 50) | **WON**(部分返还仍记赢单) | | 串关任一脚 LOSE | 0 | **LOST** | | 串关全 PUSH/VOID | stake | **PUSH** | 串关确认入账时,钱包侧 `result` 简化为:整单 LOST → `LOSE`;整单 PUSH → `PUSH`;整单 WON → `WIN`(不区分腿级半赢半输)。 --- ## 6. 钱包金额变动(结算侧) ### 下注冻结(`freezeForBet`) ``` availableBalance -= stake frozenBalance += stake transactionType = BET_FREEZE ``` 余额不足(`availableBalance < stake`)→ `INSUFFICIENT_BALANCE`。 ### 结算入账(`settleBet`) ``` frozenBalance -= stake availableBalance += payout // payout 即 actualReturn ``` | 传入 result | 流水类型 | payout 典型值 | |-------------|----------|---------------| | WIN | BET_SETTLE_WIN | stake × odds | | HALF_WIN | BET_SETTLE_WIN | 半赢派彩 | | LOSE | BET_SETTLE_LOSE | 0 | | HALF_LOSE | BET_SETTLE_LOSE | stake / 2 | | PUSH | BET_SETTLE_PUSH | stake | | VOID | BET_VOID_REFUND | stake | 幂等键:`businessKey = settle:{batchNo}:{betNo}`。 ### 作废 / 取消比赛 - `Bet.status = VOID`,**`actualReturn = stake`** - 调用 `settleBet(..., payout=stake, result='VOID')` → **BET_VOID_REFUND** ### 重结算(`confirmResettlement`) ``` delta = 新 payout - 旧 actualReturn ``` - `delta > 0` → `applyResettleDelta`,流水 **BET_SETTLE_WIN** - `delta < 0` → 流水 **RESETTLE_REVERSE**(可扣至**负余额**) - 注单 `settlementStatus = RESETTLED`,`actualReturn` 更新为新 payout ### 端到端钱包示例 | 用例 ID | 流程 | 钱包变化 | actualReturn | |---------|------|----------|--------------| | BF001 | 单关赢 100@2.0,2-1 | 1000 → 900 avail + 100 frozen → **1100 avail** | 200 | | BF002 | 单关输(和局+2-1) | 1000 → **900 avail** | 0 | | BF003 | 幂等 50 注 | 500 → **450 avail + 50 frozen** | — | | BF004 | 余额不足 | **50 不变** | — | | BF005 | 代理线玩家输 100 | 玩家 **900 avail** | 0 | BF001 流水顺序:`MANUAL_DEPOSIT` → `BET_FREEZE` → `BET_SETTLE_WIN`。 --- ## 7. 下注时金额校验(与派彩相关) ### potentialReturn(下单时估算) | 类型 | 公式 | |------|------| | 单关 | `stake × odds` | | 串关 | `stake × ∏(各腿 odds)` | 按**全赢**估算,不含半赢/走水;用于 `maxPayout*` 校验。 ### 默认限额(`BettingLimitsService`,可被 `system_config` 覆盖) | 配置键 | 默认值 | |--------|--------| | `bet.min_stake` | 1 | | `bet.max_stake_single` | 50,000 | | `bet.max_stake_parlay` | 20,000 | | `bet.max_payout_single` | 500,000 | | `bet.max_payout_parlay` | 1,000,000 | | `bet.daily_stake_limit` | 200,000(按 `placedAt` 当日,排除 VOID/CANCELLED) | 返水基数 `stake` 为通过上述校验后的下注金额,**与返水 rate 无联动**。 --- ## 8. 返水金额算法 ### 8.1 单笔返水 ``` lineAmount = stake × rate ``` - **stake**:整单本金;串关**不按 leg 拆分**。 - 实现:`cashback.service.ts` → `bet.stake.mul(rate)`。 ### 8.2 费率解析(`resolveCashbackRateForBet`) **规则表 `CashbackRule` 优先级**(数值越大越优先): | 优先级 | targetType | 匹配条件 | |--------|------------|----------| | 3 | USER | `targetId === userId` | | 2 | AGENT | `targetId === agentId`(玩家直属代理) | | 1 | GLOBAL | 无 targetId 限制 | - 规则带 **marketType** 时,注单**任一** `BetSelection.marketType` 命中才适用;否则跳过该规则。 - **同优先级**:遍历规则时 `priority > best.priority`(**严格大于**),先写入的同优先级规则不会被后者覆盖。 - **无规则命中** → 使用 `agentDefaultRate`(见下节)。 ### 8.3 默认费率 `agentDefaultRate`(无 CashbackRule 时) | 玩家类型 | 来源 | |----------|------| | 有 `parentId`(代理线下) | 直属代理 `AgentProfile.cashbackRate`;无 profile → **0** | | 无 parent,邀请人 sponsor 为 ADMIN | `system_config` → `cashback.admin_invite_rate` | | 无 parent 的其他平台直属 | `system_config` → `cashback.platform_direct_rate` | 未配置时平台直属/邀请费率默认 **0**。子代理 `cashbackRate` 不得高于父代理。 ### 8.4 合格注单(进入返水批次) 查询条件(`aggregatePeriod`): ``` status IN ('WON', 'LOST') settledAt 落在 [periodStart 00:00:00.000, periodEnd 23:59:59.999] rate > 0 未被其他 PREVIEW / CONFIRMED 批次的 cashback_bets 占用 ``` **不包含**:PENDING、VOID、CANCELLED、**PUSH** 等。 周期按 **`settledAt`**,不是 `placedAt`。 ### 8.5 批次汇总 | 层级 | 公式 | |------|------| | 单笔 | `lineAmount = stake × rate` | | 玩家 | `amount = Σ lineAmount`,`effectiveStake = Σ stake` | | 展示 rate | `amount / effectiveStake`(加权平均,仅展示) | | 平台批次 | `totalAmount = Σ 玩家 amount` | ### 8.6 发放入账 确认批次(`confirmBatch`)时: | 字段 | 值 | |------|-----| | transactionType | **CASHBACK_DEPOSIT** | | amount | 玩家批次 `item.amount`(正数) | | businessKey | `cashback:{batchNo}:{userId}` | | 效果 | `availableBalance += amount` | **平台直充,不扣代理 credit**;confirm **不**调用 `recalculateUsedCredit`。 同时将批次内注单 `Bet.isCashbacked = true`。 ### 8.7 数值示例(费率 → 到账) | 用例 ID | 场景 | rate | stake 100 | stake 500 | stake 1000 | |---------|------|------|-----------|-----------|------------| | CB001 | 玩家专属规则 | 0.03 (3%) | 3.00 | 15.00 | 30.00 | | CB002 | 玩法专属 FT_HANDICAP | 0.005 (0.5%) | 0.50 | 2.50 | 5.00 | | CB003 | 无规则,代理默认 | 0.02 (2%) | 2.00 | 10.00 | 20.00 | | CB004 | 玩法不匹配回退默认 | 0.01 (1%) | 1.00 | — | — | ### 8.8 本地 dev seed - `agent1` / `agent2` 的 `AgentProfile.cashbackRate` 默认为 schema **0**,seed 未显式设置。 - seed **不创建** `CashbackRule`,**不写入** 平台返水 `system_config`。 - 本地要产生返水批次,需在管理端配置代理默认比例、CashbackRule 或平台直属费率。 --- ## 9. 代理授信与金额的间接关系 ``` usedCredit = directPlayerLiability + childExposure availableCredit = creditLimit - usedCredit ``` - **directPlayerLiability** = Σ(直属玩家的 `availableBalance + frozenBalance`) - **childExposure** = Σ max(子代理 `creditLimit`, 子代理 `usedCredit`) | 事件 | 与返水/结算关系 | |------|----------------| | 玩家结算输/赢 | 改变余额 → 结算后重算 usedCredit | | 返水 confirm | **不**扣代理 credit,**不**即时重算;余额增加后**下次重算**计入负债 | | 代理给玩家上分 | 检查 `availableCredit ≥ amount` | **BF005**:玩家输 100 → 玩家 avail **900**;代理 `usedCredit` **1000 → 900**(结算后重算)。 --- ## 10. 数值对照总表与验证 ### 10.1 派彩速查(stake = 100) | 类型 | 场景 | payout | |------|------|--------| | 单关 | 1X2 @2.0 WIN | 200.00 | | 单关 | LOSE | 0.00 | | 单关 | 让球 -1 @1.85 WIN | 185.00 | | 单关 | PUSH | 100.00 | | 单关 | HALF_LOSE | 50.00 | | 单关 | HALF_WIN @1.85 | 142.50 | | 单关 | 小球 @1.95 WIN | 195.00 | | 串关 | 1.8×2.0 全中 | 360.00 | | 串关 | 一关 LOSE | 0.00 | | 串关 | 含 PUSH | 342.00 | | 串关 | 全 PUSH/VOID | 100.00 | ### 10.2 返水速查(rate × stake) | rate | stake 100 | stake 1000 | |------|-----------|------------| | 3% (0.03) | 3.00 | 30.00 | | 2% (0.02) | 2.00 | 20.00 | | 0.5% (0.005) | 0.50 | 5.00 | ### 10.3 维护与回归命令 ```bash # 打印与本文一致的金额对照表 pnpm --filter @thebet365/api audit:settlement # 结算引擎单测(含 S016–S019 串关) pnpm --filter @thebet365/api exec jest settlement-calculator.spec.ts --runInBand # 全量 smoke(含 S009–S019、CB001–CB004、BF001–BF005) pnpm test:smoke ``` 修改 `settlement-calculator.ts`、`cashback-rate.resolver.ts` 或 smoke 期望金额时,**须同步更新本文档**并重新运行上述命令。 ### 10.4 Smoke / 单测用例索引 | 套件 | ID 范围 | 内容 | |------|---------|------| | settlement | S009–S019 | 让球/大小/串关派彩数值 | | settlement | S011B | HALF_WIN 系数 | | cashback | CB001–CB004 | 费率解析 | | bet-flow | BF001–BF005 | 钱包 + 代理额度端到端 | --- ## 附录:支持的 marketType(结算判定入口) 金额规则与下列盘口共用 `settleSelection`;具体比分判定逻辑见 `settlement-calculator.ts`,本文不展开。 `FT_1X2`、`HT_1X2`、`FT_ODD_EVEN`、`FT_HANDICAP`、`HT_HANDICAP`、`FT_OVER_UNDER`、`HT_OVER_UNDER`、`FT_CORRECT_SCORE`、`HT_CORRECT_SCORE`、`SH_CORRECT_SCORE`、`HT_FT`、`FT_TOTAL_GOALS`、球队进球、角球/牌数相关盘、`OUTRIGHT_WINNER` 等。 未知 marketType 默认腿级结果 **LOSE**(payout = 0)。