State-aware promotion & wallet rules
Status: production-ready, shipped in
0042_state_aware_promotion_rulesmigration. Replaces the flat-rate-only promotion configuration that shipped in0023_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
| Concept | Where it lives | Default | Wired today? | Purpose |
|---|---|---|---|---|
valid_odds_threshold | brand_config.value (key = 'valid_odds_threshold') | 1.6 | ✅ rebate + lossback scheduler + wallet bucket settle | Brand-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 rebate | Per-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 eligibility | Per-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_rules | wallet policy JSONB | [] for unified, ~22 seeded rules for split sports | ✅ wallet bucket settle + provider callback wiring | Per-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_configrow carries anoddsandodds_formula. The scheduler reducesoddsviaapply_rebate_odds_formulaand compares tovalid_odds_thresholdviais_rebate_eligible(...)to decide if the row pays out. Rows withenabled = FALSEare skipped at the SQL layer (see indexed partial filter in 0042). Seepromotion_service/app/tasks/settlement.py::_rebate_settle_for_brand. -
Lossback settlement (wired today) —
lossback_configcarries the sameodds,odds_formula, andenabledcolumns. The scheduler readsplayer_lossback_sports_logat log-row granularity, evaluates candidate rows fromlossback_config, and appliesis_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_amountcolumns on the sports log are not trusted as the payout source. Folder state is inferred from the log'sparlayandloss_cnt. Configparlaykeeps the legacy threshold semantics: a row matches whenlog.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_amountfields, reads the brandvalid_odds_thresholdviaapp/services/brand_config_reader.py::get_valid_odds_threshold, and routes the win throughpolicy.normal_wallet_state_ruleswhenever the caller suppliesfolder_state. The seven current provider callbacks (bti_callback.py,bt1_callback.py,digitain_callback.py,splus_callback.py,ho_callback.py,mg_callback.py, andwc_callback.py) now build this state context throughgame_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 hasodds=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 ofWON / LOST / HALF_WON / HALF_LOST / CASHOUT / DRAW / CANCELED / RETURN / REJECTED / HALF_RETURN / HALF_WIN.NULLmeans "all states" — preserves the legacy "one row per (level, provider)" behaviour exactly. For rebate, the current scheduler still operates onplayer_stat_dayaggregates, sofolder_stateis 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_formula—ANY / EXACT / HALF_PLUS_ONE / HALF_FIXED / EXCLUDE. Drivesapply_rebate_odds_formula:formula reduction matched if ANYn/a always EXACTunchanged odds odds ≥ thresholdHALF_PLUS_ONE(odds + 1) / 2(odds+1)/2 ≥ thresholdHALF_FIXED0.5only if 0.5 ≥ threshold(≈ never)EXCLUDEn/a never -
enabled— operator kill-switch.FALSErows 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 itsday/week/month_percentfor 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:
| Code | Korean (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_NORMALdefault. - UNIFIED topology — empty list. Operators opt-in by populating it later.
The seeded SPLIT rules implement Ruby's stock operator runbook:
- Won + odds ≥ valid_odds → withdraw.
- Won + odds < valid_odds → return to general.
- Half Won + (odds+1)/2 ≥ valid_odds → withdraw.
- Half Won + (odds+1)/2 < valid_odds → return to general.
- Half Lost (always) → split: stake to general, win-portion to withdrawable.
- Cashout payout < bet → split.
- Cashout payout ≥ bet → withdraw.
- 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_thresholdso 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_rulesand 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) andservers_v2/wallet_service/tests/test_wallet_topology_policy.py(state-aware destination cases). - Static config page —
docs/docusaurus/static/server-config/(served at/server-config/from the docs site, also reachable from the Brand Config navbar entry). Exposesvalid_odds_thresholdon the Brand step, an editablenormal_wallet_state_rulestable on the Wallet step, casino normal-wallet operating-mode preset cards on the Rolling step, and full row editors forrebate_config/cashback_config/lossback_configon 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.