跳到主要内容

State-aware promotion & wallet rules

Status: production-ready, shipped in 0042_state_aware_promotion_rules migration. Replaces the flat-rate-only promotion configuration that shipped in 0023_seed_default_brand.

This page is the single source of truth for the three new operator-facing configuration knobs that landed together in migration 0042 and the contracts release that bumped WalletPolicyDocument. They share one brand-level setting (valid_odds_threshold) and tie together the sportsbook bet outcome model, the wallet's win-destination routing, and the promotion settle scheduler.

TL;DR for operators

ConceptWhere it livesDefaultWired today?Purpose
valid_odds_thresholdbrand_config.value (key = 'valid_odds_threshold')1.6✅ rebate + lossback scheduler + wallet bucket settleBrand-wide minimum effective odds. Used by rebate, lossback, and per-bet wallet routing.
rebate_config.{folder_state, odds_formula, enabled}new columns on rebate_config(NULL, 'ANY', TRUE)enabled SQL filter + odds-formula eligibility; folder_state persisted for future per-bet rebatePer-row "이 注 paid out?" gate. NULL/ANY/TRUE preserves legacy semantics.
lossback_config.{folder_state, odds_formula, enabled}new columns on lossback_config(NULL, 'ANY', TRUE)enabled SQL filter + per-row eligibilityPer-row lossback gate. The settle path now evaluates log rows against folder_state, parlay threshold, minimum bet, loss count, and odds formula.
WalletPolicyDocument.normal_wallet_state_ruleswallet policy JSONB[] for unified, ~22 seeded rules for split sports✅ wallet bucket settle + provider callback wiringPer-outcome routing (Won → withdrawable, Half-Lost → split, …) layered on top of the global win_destination_before_rolling_done.

The rest of this doc is for the engineers wiring these into new services or extending them.


1. valid_odds_threshold

Read via promotion_service.app.services.brand_config.get_valid_odds_threshold or via the canonical helper:

from rgb_contracts.wallet.normal_wallet_rules import (
DEFAULT_VALID_ODDS_THRESHOLD,
is_rebate_eligible,
)

DEFAULT_VALID_ODDS_THRESHOLD = Decimal("1.6") mirrors the seeded value and is what the helpers fall back to when a brand has no config row.

The threshold is applied in three distinct flows:

  • Rebate settlement (wired today) — each rebate_config row carries an odds and odds_formula. The scheduler reduces odds via apply_rebate_odds_formula and compares to valid_odds_threshold via is_rebate_eligible(...) to decide if the row pays out. Rows with enabled = FALSE are skipped at the SQL layer (see indexed partial filter in 0042). See promotion_service/app/tasks/settlement.py::_rebate_settle_for_brand.

  • Lossback settlement (wired today)lossback_config carries the same odds, odds_formula, and enabled columns. The scheduler reads player_lossback_sports_log at log-row granularity, evaluates candidate rows from lossback_config, and applies is_rebate_eligible(...) with the brand threshold before aggregating payout by player. The matched config row's percent and period cap are used at settle time; the legacy *_add_amount columns on the sports log are not trusted as the payout source. Folder state is inferred from the log's parlay and loss_cnt. Config parlay keeps the legacy threshold semantics: a row matches when log.parlay >= config.parlay, then the highest tier wins by (parlay, loss_cnt, day_percent, bet_amount_min).

  • Wallet bet settlement (wired today) — wallet_service consumes the new SettleBucketBetRequest.folder_state / bet_type / condition_state / odds / bet_amount fields, reads the brand valid_odds_threshold via app/services/brand_config_reader.py::get_valid_odds_threshold, and routes the win through policy.normal_wallet_state_rules whenever the caller supplies folder_state. The seven current provider callbacks (bti_callback.py, bt1_callback.py, digitain_callback.py, splus_callback.py, ho_callback.py, mg_callback.py, and wc_callback.py) now build this state context through game_service.app.services.bet_result.build_wallet_state_context. Odds-sensitive win states (WON, HALF_WON, HALF_WIN) require a positive odds value; when a provider row only has odds=0, the callback deliberately omits the state context so settlement falls back to the legacy path rather than misclassifying the win as low-odds.

Storage: brand_config(brand_id, key, value). JSONB value rounds-trip through Decimal(str(...)) in _resolve_decimal_rate. Values can be a JSON number (1.6) or string ("1.6"); both decode identically.

2. rebate_config / lossback_config extensions

Schema (after 0042)

ALTER TABLE rgb.rebate_config
ADD COLUMN folder_state VARCHAR(32) NULL,
ADD COLUMN odds_formula VARCHAR(32) NOT NULL DEFAULT 'ANY',
ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;

CREATE INDEX ix_rebate_config_enabled_brand
ON rgb.rebate_config (brand_id) WHERE enabled = TRUE;

-- lossback_config carries identical columns and identical defaults.

Column semantics

  • folder_state (nullable) — the bet outcome the row applies to. One of WON / LOST / HALF_WON / HALF_LOST / CASHOUT / DRAW / CANCELED / RETURN / REJECTED / HALF_RETURN / HALF_WIN. NULL means "all states" — preserves the legacy "one row per (level, provider)" behaviour exactly. For rebate, the current scheduler still operates on player_stat_day aggregates, so folder_state is persisted but not used until a per-bet rebate path exists. For lossback, the scheduler now infers the log row state and filters candidate config rows against this value before paying.

  • odds_formulaANY / EXACT / HALF_PLUS_ONE / HALF_FIXED / EXCLUDE. Drives apply_rebate_odds_formula:

    formulareductionmatched if
    ANYn/aalways
    EXACTunchanged oddsodds ≥ threshold
    HALF_PLUS_ONE(odds + 1) / 2(odds+1)/2 ≥ threshold
    HALF_FIXED0.5only if 0.5 ≥ threshold (≈ never)
    EXCLUDEn/anever
  • enabled — operator kill-switch. FALSE rows are filtered at the SQL layer (WHERE enabled = TRUE) so the scheduler never even considers them. Useful when you want to suspend a row without losing its day/week/month_percent for re-activation later. Pairs with the partial index for cheap reads.

Eligibility helper

from rgb_contracts.wallet.normal_wallet_rules import is_rebate_eligible

if is_rebate_eligible(
odds=row.odds,
threshold=brand_threshold, # Decimal from brand_config
formula=row.odds_formula,
enabled=row.enabled, # already SQL-filtered, kept for safety
):
rebate_amount = compute_rebate(regular_bet, percent * effective_factor)

is_rebate_eligible is the single point that applies the (odds_formula, threshold, enabled) decision. Any new code path that walks rebate_config / lossback_config rows must use this helper — do not re-derive the comparison locally.

Settlement integration

promotion_service/app/tasks/settlement.py::_rebate_settle_for_brand already wires this in: it loads valid_odds_threshold once per brand, SELECTs the new columns, builds a (level_id, provider_id) → list[row] map, and picks the first row whose formula passes the threshold.

promotion_service/app/tasks/settlement.py::_lossback_settle_for_brand now wires the same pattern into lossback: it loads the brand threshold, SELECTs log-level lossback rows plus enabled lossback_config rows, filters by (level_id, provider_id), folder state, parlay threshold, minimum bet, minimum loss count, and odds_formula, picks the highest legacy tier, then recomputes the lossback amount from the matching row's percent and period cap before aggregating into a player payout.

3. normal_wallet_state_rules

Pydantic model

class NormalWalletStateRule(BaseModel):
wallet_group: str # "sports" / "casino" / "unified"
bet_type: BetType = BetType.SINGLE
provider_id: int | None = None
provider_code: str | None = None
folder_state: BetFolderState
condition_state: BetConditionState = BetConditionState.ANY
odds_rule: OddsRule = OddsRule.ANY
payout_comparison: PayoutComparison = PayoutComparison.ANY
win_destination: WinDestination
note: str | None = None

Stored as plain dicts inside WalletPolicyDocument.normal_wallet_state_rules. The list order is the matching order — the resolver returns the first matching rule's win_destination, else falls back to normal_wallets[group].win_destination_before_rolling_done.

Resolver

from rgb_contracts.wallet.normal_wallet_rules import resolve_win_destination

dest = resolve_win_destination(
policy.normal_wallet_state_rules, # iterable of dicts
wallet_group="sports",
bet_type="SINGLE", # str or BetType
folder_state="WON", # str or BetFolderState
condition_state="ANY",
provider_id=30008,
odds=1.8,
payout=0,
bet=100,
threshold=brand_threshold, # Decimal
fallback="WIN_TO_WITHDRAWABLE",
)

dest is a WinDestination string code:

CodeKorean (rubyconfig)Effect
WIN_TO_WITHDRAWABLE적중금 → 출금가능지갑winnings → withdrawable bucket
WIN_TO_NORMAL적중금 → 일반지갑 반환winnings stay in / return to the same normal bucket
BET_TO_NORMAL_WIN_TO_WITHDRAWABLE배팅금 → 일반지갑, 적중금 → 출금지갑refund stake to normal, ship surplus to withdrawable

Wallet-service integration

wallet_service/app/services/wallet_topology_policy.py::resolve_state_aware_destination wraps the resolver. Bet-settle code paths that need state-aware routing should call this function rather than the underlying contract helper directly — it handles the legacy WITHDRAWABLE / *_NORMAL fallback translation in one place.

Seeded defaults

  • SPLIT topology — 22 rules (single + parlay × 11 outcome rows for the sports normal wallet). Casino normal wallet uses the global CASINO_NORMAL default.
  • UNIFIED topology — empty list. Operators opt-in by populating it later.

The seeded SPLIT rules implement Ruby's stock operator runbook:

  1. Won + odds ≥ valid_odds → withdraw.
  2. Won + odds < valid_odds → return to general.
  3. Half Won + (odds+1)/2 ≥ valid_odds → withdraw.
  4. Half Won + (odds+1)/2 < valid_odds → return to general.
  5. Half Lost (always) → split: stake to general, win-portion to withdrawable.
  6. Cashout payout < bet → split.
  7. Cashout payout ≥ bet → withdraw.
  8. Draw / Canceled / Return / Rejected → return to general.

4. Cross-cutting invariants

  • One threshold, three consumers — promotion's rebate/lossback scheduler and wallet's normal-wallet rule resolver all read the same valid_odds_threshold so changing it once cascades across the platform.
  • Backward compatibility — the migration ships defaults that preserve every legacy behaviour byte-for-byte. Existing rebate rows default to (NULL, 'ANY', TRUE), which is exactly the old "applies to every state, no odds check, enabled" contract.
  • Pure-function discipline — all resolvers and eligibility checks live in rgb_contracts.wallet.normal_wallet_rules and are pure. Services pass thresholds and brand context in as parameters.
  • Test coverage — see servers_v2/shared/contracts/tests/test_normal_wallet_rules.py (43 pure tests) and servers_v2/wallet_service/tests/test_wallet_topology_policy.py (state-aware destination cases).
  • Static config pagedocs/docusaurus/static/server-config/ (served at /server-config/ from the docs site, also reachable from the Brand Config navbar entry). Exposes valid_odds_threshold on the Brand step, an editable normal_wallet_state_rules table on the Wallet step, casino normal-wallet operating-mode preset cards on the Rolling step, and full row editors for rebate_config / cashback_config / lossback_config on the Promotion Rules step. Export JSON keys match the canonical DB column sets so a dev import can splat each row dict straight into INSERT VALUES without filtering.