Exposure & Bet-Level Limit Settings — Backend + Enforcement Plan
Context
Hannibal needs admin/MA/agent-controlled betting limits per player, scoped by sport → tournament-tier → market-type, with two per-cell values: E = maxExposure (market-level max-win cap) and PBL = perBet (single-bet cap). Three layered sets per player (admin = mandatory baseline, MA + agent optional, max 3). Reusable presets seed sets at invite time. At placement the bet is checked against every applicable layer; any breach → reject (most-restrictive wins).
This phase = backend data model + APIs + enforcement wiring. UI is a later phase.
The math (confirmed)
The existing nettingEngine.ts already tracks the player loss side per market: Pmax, Emax=min(Pmax), ACr=oldEmax−newEmax, gated by ACr ≤ AC (canAfford). We add the mirror win side (Pmin, Wmax=max(Pmin)) and two accept-time gates, evaluated per upline level x ∈ {agent, MA, admin}:
- Per-bet:
max(Po) > PBL(x)→ reject. (Po = the bet's own PnL vector;max(Po)= the single bet's max win.) - Market exposure:
Wmax* > E(x)→ reject. (Wmax* = max(Pmin*)= this market's projected max player win = upline's max payout. GrossWmax*vs each level'sE(x)— no booking-% apportionment.) - Balance: existing
ACr ≤ AC(unchanged).
Out of scope for now (explicitly dropped): the global win-limit check Wmax* + ΣWmax'(other markets) + Take(p) > Wmax. → no global Wmax field, no cross-market ΣWmax' aggregate, no TakeBalance/Take(p) coupling.
Decisions locked
- Dimensions reuse the catalogue dictionary: sport =
Order.sportId/MarketDictionary.canonicalSportId(Int), tournament tier =CatalogueDisplayConfig.tier(1–4, null⇒tier 4), market tier =MarketDictionary.marketTier(admin-curated bucket, the UI's "Market 1..4" — NOT the fine-grainedcanonicalMarketType). No duplicated reference data. Cell column ismarketTier; at placement it's read from the matched dictionary row viacheckMarketBettable(untaggedmarketTier=null⇒ market not tier-limited). - Existing
User.perClickWinLimit / aggregateWinLimitDaily(daily realized caps) — left fully untouched; unrelated toE/PBL. - Currency: admin presets authored in USD; at MA-invite, the MA's (and downline's) sets are materialized converted to the MA
settlementCurrencyat a frozen snapshot rate. Tree currency is immutable ⇒ every bet-time comparison is same-currency, zero runtime FX. Freeze the rate so later FX drift never moves a player's limits.currencystamped on every set/preset.
Data model (Prisma — additive)
Money fields Decimal(19,4), nullable, null=uncapped / 0=block-all (never coerce). Reuse normalizeMaxExposure() (marketExposureCapService.ts:65) for both maxExposure and perBet.
New tables:
ExposureLimitPreset(header) —ownerType'admin'|'agent',ownerId('platform'|Agent.id),name,kind'named'|'build_your_own',currency,isArchived, audit cols.@@unique([ownerType, ownerId, name]).ExposureLimitPresetCell—presetId,sportIdInt,marketTypeString,tierInt? (null = "Default for All Tiers"),maxExposureDecimal?,perBetDecimal?.PlayerExposureLimitSet(header) —playerId,layer'admin'|'ma'|'agent',ownerAgentId('platform'|MA id|agent id),source'inherit'|'preset'|'custom',sourcePresetId?,currency(MAsettlementCurrency, stamped, no default), audit.@@unique([playerId, layer])→ ≤3 rows/player. No global-winlimit field (dropped).PlayerExposureLimitSetCell— same grain/fields as preset cell, FKsetId.
Modified tables (additive cols):
5. ReferralCode += seedLimitMode String?, seedLimitPresetId String? (carry invite-time selection down the chain).
6. MarketExposure += pminVector Json, wmax Decimal(18,4) — the win-side mirror of existing pmaxVector/emax.
Cell grain: (sportId, marketType, tier). tier=NULL = all-tiers default; tier∈1..4 = tier-specific. Reference keys only — no FK to dictionary. Sparse presets (e.g. "Only Cricket") omit cells → uncapped at that layer.
Migration notes:
- Partial unique indexes (raw SQL) for the null-tier rows Prisma
@@uniquecan't cover:… (preset_id, sport_id, market_type) WHERE tier IS NULL+ set-cell equivalent. - Indexes
@@index([setId, sportId, marketType])/@@index([presetId, sportId, marketType])for enforcement reads. - No backfill of existing data. Existing players get no limit sets → resolution treats them as uncapped until limits are configured; the "min 1 admin set" invariant applies to new players at invite time, not retroactively. Existing open
MarketExposurerows getpminVector/wmaxlazily initialized on their next mutation (pminVector = pmaxVectorif fully matched, else reconstructed from live order vectors at that write). Markets that never receive another bet are simply not win-gated — acceptable, since the feature is opt-in and rolls forward. - Seed only one default admin preset (a template to author from) — no per-player seeding.
Netting engine — win-side mirror (nettingEngine.ts)
Add Pmin/Wmax to every lifecycle fn (else wmax drifts after a fill/reversal). Pmin mirrors Pmax with min↔max swapped:
- accept:
Pmin[i] += max(pnl[i],0); match:Pmin[i] += min(pnl[i],0);Wmax = max(Pmin). - Cover:
onAccept,onMatch,onCancel,onAcceptAndMatch,onMatchWithOddsChange,onPartialMatch,onReverseAcceptAndMatch,reconstructPmax→addreconstructPmin. - Extend the result interfaces (
AcceptResult,MatchResult, …) to also returnpmin/wmax. Keep the existingpmax/emax/acr/rcoutputs unchanged (no behavioral change to the loss side). - Lazy-init for pre-existing markets: on any mutation, if the row's
pminVectoris null/empty (predates this feature), initialize it before applying the delta —= pmaxVectorwhen no unmatched bets, else reconstruct from the market's live order vectors. New markets start atzeroPmin.
Service layer — backend/src/domain/exposureLimits/
types.ts—LimitLayer,ResolvedCell {maxExposure, perBet},LayerResult,ResolvedLimitLayers,LimitDimension {sportId, marketType, tier}.layerKeys.ts—resolvePlayerLayerKeys(playerId)→ admin sentinel + MA (root ofparentAgentIdwalk) + directagentId+ MA currency. Redis-cached. Agent layer absent whenplayer.agentId == maId.tierResolver.ts—resolveTierForFixture(fixtureId, sportId):fixtureId→CatalogueMatchSource.providerFixtureId→CatalogueMatch.catalogueTournamentId→CatalogueDisplayConfig(entityType='tournament', entityId).tier(null⇒4). Redis-cached by fixtureId.resolveEffectiveLimits.ts—resolveEffectiveLimits(playerId, dimension): load ≤3 layer sets, per layer pick exact-tier cell → fallback null-tier cell → else uncapped. Returns per-layer{E, PBL}.presetService.ts—createPreset / upsertPresetCells / listPresets / getPreset / archivePreset. Money inputs as strings, normalized server-side.playerLimitSetService.ts—getAllPlayerLimitSets / setPlayerLimitSet. Auth mirrorseditPlayerLimit.ts(downline viaexpandDownlineAgentIds, optimisticpreviousUpdatedAtguard). Admin edits onlyadminlayer; MA onlyma; agent onlyagent.inviteSeed.ts—applyInviteLimits(playerId, referralChain): materialize admin (mandatory) + ma + agent sets from each code'sseedLimitMode/seedLimitPresetId. Inherit = snapshot (copy cells) at invite, not live-link (keeps enforcement O(1), no retroactive coupling).
Enforcement — enforce.ts (concrete math)
isPerBetBreached(po: Decimal[], perBetCap: Decimal|null): boolean // cap null⇒false; else max(po) > cap
isMarketExposureBreached(wmaxStar: Decimal, maxCap: Decimal|null): boolean // null⇒false; else wmaxStar > cap
checkExposureLimits({playerId, dimension, po, projectedWmax}): Promise<ExposureCheckResult>
// resolveEffectiveLimits → per present layer run both comparators → first breach wins
// → {allow, reason, breachedLayer, breachedKind}. Flag OFF ⇒ {allow:true}.
No global-winlimit / Take / ΣWmax' logic.
Enforcement wiring — orderService.ts
Resolve limits once; run both checks for any tier-tagged market (betMarketTier !== null).
- Per-bet — pre-transaction: in
placeOrderaftermarketMeta/useNettingresolved (~line 1060), wheresportId/fixtureId/marketTypeand the bet'sPo(buildPnlVector(...)) are available.tier = resolveTierForFixture(...).max(Po) > PBL(x)for any level →throw new ValidationError(reason)before any balance movement. - Market exposure — inside the netting tx (lines 1127–1295), after computing
pminNew/wmaxNew(the projectedWmax*) alongsidepmaxNew/emaxNew(~line 1192), before balance debit /marketExposure.upsert.wmaxNew > E(x)for any level → throw inside tx → atomic rollback (no balance moved, no order/exposure written). Sees the sameSELECT FOR UPDATE-locked state as the write → race-safe. PersistpminVector/wmaxin the samemarketExposure.upsert. - Balance: existing
ACr ≤ ACpath unchanged.
Reject surfaces as ValidationError (HTTP 400) + logger.warn audit line.
API surface
- Admin presets (
routes/agents.ts,authenticateAdmin):POST/GET/GET:id/PUT:id/cells/DELETE:id /admin/exposure-limits/presets. - MA presets (
routes/agent.ts,requireRole(['agent']), owner = caller's MA id): same five under/agent/exposure-limits/presets. - Per-player sets (both router files):
GET /…/players/:playerId/exposure-limits(all 3 layers),PUT /…/players/:playerId/exposure-limits/:layer(auth-restricted to caller's layer). - Invite seeding (
routes/referrals.ts): acceptseedLimitMode/seedLimitPresetIdon invite create; callapplyInviteLimitsin existing player-redemption path.
Feature flag
Removed — enforcement is now always on for tier-tagged markets. Untagged markets (EXPOSURE_LIMITS_ENABLED (platform config, default OFF).betMarketTier === null) are skipped; an absent layer/cell is uncapped, never a block. No backfill prerequisite (win-side state initializes lazily per market).
Cross-system impact
- backend: new tables/services;
MarketExposure+2 cols;nettingEnginewin-side mirror (loss-side outputs unchanged); 2 gated checks inorderService. Settlement/balance math untouched. - frontend / services / infra: none this phase.
- Breaking risk: low–medium. Schema additive;
ReferralCodecols nullable; checks flag-off. The one real risk is thenettingEnginesignature change — keep existing fields stable and addpmin/wmax; all current callers keep working. No backfill — pre-existing markets lazily init their win-side state on next touch.
Open items (defaults chosen; non-blocking)
maxExposuregrain = per-marketWmax*(oneMarketExposurerow) — confirmed by the math (Wmax* > E(x)).- Inherit = snapshot (not live) — assumed.
- No backfill — existing players uncapped until configured; existing markets lazily init win-side state. Confirmed.
Take(p)sign — moot for now (global check dropped); revisit if/when that check is added.
UI — separate phase (not this phase)
The Figma screens (downline list, invite flow, preset management, per-player limit editing) are a dedicated later phase, built against the stabilized API contract from this phase. Recommend splitting the UI itself across sessions/PRs by screen group to keep context tight: ① admin/MA preset CRUD, ② invite flow (Use-Default vs Set-Custom / Build-Your-Own), ③ per-player limit editing + downline list. Final designs to be provided then.
Implementation status (backend phase)
Done:
- Schema:
ExposureLimitPreset,ExposureLimitPresetCell,PlayerExposureLimitSet,PlayerExposureLimitSetCell;MarketExposure += pminVector, wmax;ReferralCode += seedLimitMode, seedLimitPresetId. Hand-written migration20260610000000_exposure_bet_level_limits(additive; partial unique indexes for null-tier rows). nettingEngine: win-side mirror —onAcceptWin/onMatchWin/onCancelWin/onAcceptAndMatchWin/onReverseAcceptAndMatchWin/onMatchWithOddsChangeWin/onPartialMatchWin/reconstructPmin/zeroPmin/calculateWmax. Loss-side untouched.domain/exposureLimits/: types, errors, layerKeys, tierResolver, resolveEffectiveLimits, enforce (per-bet + market-exposure, flag-gated), presetService, playerLimitSetService, inviteSeed.orderService.placeOrder: per-bet gate (pre-tx) + market-exposure gate (in-tx) + persistpminVector/wmax.reversePlacementBetmaintains pmin.- pmin/wmax maintained at the exposure-increasing sites (placement, Bifrost match/odds/void instant-match = pmin=pmax, Betfair match, partial-match) and the main reversal sites (placement reverse, refund-stuck reversal, lapse).
- API:
routes/exposureLimits.ts(mounted/api/exposure-limits) — preset CRUD + per-player limit get/set. - Test:
nettingEngine.winside.test.ts(win-side math + Pmin==Pmax invariant).
Follow-ups before turning the flag ON:
- Wire
applyInviteLimitsinto the referral redemption path (persistseedLimitMode/seedLimitPresetIdon invite-create; call on player creation). Service + columns ready. - pmin/wmax maintenance at the remaining exposure-decreasing sites —
reversalService.ts,settlement.tsadjustments, manual-cancel route (routes/orders.ts). Omission is conservatively safe (stale pmin stays high → enforcement over-strict, never lax), but wire for accuracy. - Run
prisma generate+ apply the migration; add resolveEffectiveLimits/enforce integration tests against a test DB.
Verification
npx prisma migrate dev→ schema applies; partial unique indexes present.- Netting unit:
Pmin/Wmaxproduced by each lifecycle fn;Pmax==Pminonce a market is fully matched;reconstructPminmatches. - Enforce unit:
isPerBetBreached(max(Po)vs cap, null/0),isMarketExposureBreached(Wmax*vs cap),resolveEffectiveLimitstier fallback (exact→null→uncapped), per-level most-restrictive. - Auth unit:
setPlayerLimitSet(admin-only-admin-layer, agent downline guard, optimistic stale guard). - Integration: preset CRUD + invite seeding materializes 3 layers; currency conversion frozen at MA-invite.
- Enforcement E2E (flag ON): bet breaching
PBL→ pre-tx decline (no balance move); bet breachingE(Wmax* > E) → in-tx rollback (no order/exposure written); flag OFF bypasses entirely. - Lazy-init: a pre-existing open market (no
pminVector) initializes correctly on its next bet;npm test(existing order/exposure/settlement suites green).