Skip to main content

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}:

  1. Per-bet: max(Po) > PBL(x) → reject. (Po = the bet's own PnL vector; max(Po) = the single bet's max win.)
  2. Market exposure: Wmax* > E(x) → reject. (Wmax* = max(Pmin*) = this market's projected max player win = upline's max payout. Gross Wmax* vs each level's E(x)no booking-% apportionment.)
  3. 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-grained canonicalMarketType). No duplicated reference data. Cell column is marketTier; at placement it's read from the matched dictionary row via checkMarketBettable (untagged marketTier=null ⇒ market not tier-limited).
  • Existing User.perClickWinLimit / aggregateWinLimitDaily (daily realized caps) — left fully untouched; unrelated to E/PBL.
  • Currency: admin presets authored in USD; at MA-invite, the MA's (and downline's) sets are materialized converted to the MA settlementCurrency at 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. currency stamped 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:

  1. ExposureLimitPreset (header) — ownerType 'admin'|'agent', ownerId ('platform' | Agent.id), name, kind 'named'|'build_your_own', currency, isArchived, audit cols. @@unique([ownerType, ownerId, name]).
  2. ExposureLimitPresetCellpresetId, sportId Int, marketType String, tier Int? (null = "Default for All Tiers"), maxExposure Decimal?, perBet Decimal?.
  3. PlayerExposureLimitSet (header) — playerId, layer 'admin'|'ma'|'agent', ownerAgentId ('platform'|MA id|agent id), source 'inherit'|'preset'|'custom', sourcePresetId?, currency (MA settlementCurrency, stamped, no default), audit. @@unique([playerId, layer]) → ≤3 rows/player. No global-winlimit field (dropped).
  4. PlayerExposureLimitSetCell — same grain/fields as preset cell, FK setId.

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 @@unique can'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 MarketExposure rows get pminVector/wmax lazily initialized on their next mutation (pminVector = pmaxVector if 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→add reconstructPmin.
  • Extend the result interfaces (AcceptResult, MatchResult, …) to also return pmin/wmax. Keep the existing pmax/emax/acr/rc outputs unchanged (no behavioral change to the loss side).
  • Lazy-init for pre-existing markets: on any mutation, if the row's pminVector is null/empty (predates this feature), initialize it before applying the delta — = pmaxVector when no unmatched bets, else reconstruct from the market's live order vectors. New markets start at zeroPmin.

Service layer — backend/src/domain/exposureLimits/

  • types.tsLimitLayer, ResolvedCell {maxExposure, perBet}, LayerResult, ResolvedLimitLayers, LimitDimension {sportId, marketType, tier}.
  • layerKeys.tsresolvePlayerLayerKeys(playerId) → admin sentinel + MA (root of parentAgentId walk) + direct agentId + MA currency. Redis-cached. Agent layer absent when player.agentId == maId.
  • tierResolver.tsresolveTierForFixture(fixtureId, sportId): fixtureIdCatalogueMatchSource.providerFixtureIdCatalogueMatch.catalogueTournamentIdCatalogueDisplayConfig(entityType='tournament', entityId).tier (null⇒4). Redis-cached by fixtureId.
  • resolveEffectiveLimits.tsresolveEffectiveLimits(playerId, dimension): load ≤3 layer sets, per layer pick exact-tier cell → fallback null-tier cell → else uncapped. Returns per-layer {E, PBL}.
  • presetService.tscreatePreset / upsertPresetCells / listPresets / getPreset / archivePreset. Money inputs as strings, normalized server-side.
  • playerLimitSetService.tsgetAllPlayerLimitSets / setPlayerLimitSet. Auth mirrors editPlayerLimit.ts (downline via expandDownlineAgentIds, optimistic previousUpdatedAt guard). Admin edits only admin layer; MA only ma; agent only agent.
  • inviteSeed.tsapplyInviteLimits(playerId, referralChain): materialize admin (mandatory) + ma + agent sets from each code's seedLimitMode/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 placeOrder after marketMeta/useNetting resolved (~line 1060), where sportId/fixtureId/marketType and the bet's Po (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 projected Wmax*) alongside pmaxNew/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 same SELECT FOR UPDATE-locked state as the write → race-safe. Persist pminVector/wmax in the same marketExposure.upsert.
  • Balance: existing ACr ≤ AC path 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): accept seedLimitMode/seedLimitPresetId on invite create; call applyInviteLimits in existing player-redemption path.

Feature flag

EXPOSURE_LIMITS_ENABLED (platform config, default OFF). Removed — enforcement is now always on for tier-tagged markets. Untagged markets (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; nettingEngine win-side mirror (loss-side outputs unchanged); 2 gated checks in orderService. Settlement/balance math untouched.
  • frontend / services / infra: none this phase.
  • Breaking risk: low–medium. Schema additive; ReferralCode cols nullable; checks flag-off. The one real risk is the nettingEngine signature change — keep existing fields stable and add pmin/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)

  1. maxExposure grain = per-market Wmax* (one MarketExposure row) — confirmed by the math (Wmax* > E(x)).
  2. Inherit = snapshot (not live) — assumed.
  3. No backfill — existing players uncapped until configured; existing markets lazily init win-side state. Confirmed.
  4. 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 migration 20260610000000_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) + persist pminVector/wmax. reversePlacementBet maintains 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 applyInviteLimits into the referral redemption path (persist seedLimitMode/seedLimitPresetId on invite-create; call on player creation). Service + columns ready.
  • pmin/wmax maintenance at the remaining exposure-decreasing sites — reversalService.ts, settlement.ts adjustments, 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/Wmax produced by each lifecycle fn; Pmax==Pmin once a market is fully matched; reconstructPmin matches.
  • Enforce unit: isPerBetBreached (max(Po) vs cap, null/0), isMarketExposureBreached (Wmax* vs cap), resolveEffectiveLimits tier 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 breaching E (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).