Skip to main content

Points System & Credit Accounting Architecture

Research document for the existing Hannibal point system, ledger, and order lifecycle accounting. This will inform the Pinnacle credit integration design.


1. Overview

Hannibal uses an internal point system where 1 point = 1 USD. All betting, settlements, and accounting are denominated in points. The system is provider-agnostic at the accounting layer -- points are debited/credited regardless of which exchange executes the bet.

Key characteristics:

  • Single balance field (User.balancePoints) for all users (punters, agents, admins)
  • Points flow downward: Platform -> Agent -> Punter (via PointAllocation)
  • Points flow upward via WithdrawalRequest (requires approval)
  • Double-entry ledger (LedgerEntry) for audit trail
  • Transaction log (Transaction) for user-facing history
  • Points-to-USD conversion happens at the PAL boundary (exchange submission)
  • Configurable point value via PlatformSettings key point_value_usd

2. Data Models

2.1 User Balance

Model: User (backend/prisma/schema.prisma:72)`

balancePoints  Decimal @default(0) @db.Decimal(18, 4)
  • 18 digits total, 4 decimal places
  • Single source of truth for spendable balance
  • Used for both punters AND agents (agents are just users with role: 'agent')
  • Balance is eagerly debited on bet placement and credited on settlement/refund

2.2 Order

Model: Order (backend/prisma/schema.prisma:124)

Key fields for accounting:

  • stake Decimal(18,2) -- User's stake in points
  • odds Decimal(10,4) -- Display odds (with margin applied)
  • trueOdds Decimal(10,4) -- Original Betfair odds before margin (discovery time)
  • placedOdds Decimal(10,4) -- Actual Betfair odds at placement time
  • potentialReturn Decimal(18,2) -- stake * odds (for back bets)
  • status -- pending -> accepted -> settled (or cancelled/declined)
  • settlementOutcome -- win/lose/void/push/half_win/half_lose
  • profitLoss Decimal(18,2) -- Final P&L after settlement
  • bookmaker String -- defaults to "pinnacle" (field name is legacy; currently "betfair-ex")
  • routingVenue -- bbook/exchange/split
  • bbookFill / exchangeFill -- how much went to each venue
  • agentBookingPercent -- snapshot of agent's booking % at bet time

2.3 Transaction (Audit Log)

Model: Transaction (backend/prisma/schema.prisma:204)

type: deposit | withdrawal | bet_placed | bet_cancelled | settlement |
admin_adjustment | bet_refund | commission
amount: Decimal(18,2) -- positive = credit, negative = debit
  • User-facing audit trail
  • NOT double-entry -- just a simple log
  • Created alongside every balancePoints mutation
  • Types observed in code: bet_placed (negative), bet_refund (positive), settlement (positive), commission (negative)

2.4 LedgerEntry (Double-Entry)

Model: LedgerEntry (backend/prisma/schema.prisma:357)

transactionRef  String  -- Groups debit/credit pair
accountType String -- platform | agent | user | commission | margin
accountId String? -- Agent ID, User ID, or sub-account like "stakes", "payouts"
entryType String -- debit | credit
amount Decimal(18,4) -- positive for debit, negative for credit
balanceAfter Decimal(18,4) -- running balance for the account
category String -- bet_placed | bet_settled | settlement | adjustment |
deposit | withdrawal | point_allocation | commission | margin

Account types used:

  • platform/stakes -- Platform stake pool (debit on bet placed)
  • platform/payouts -- Platform payout pool (credit on bet settled)
  • platform/settlements -- Agent settlement pool
  • platform/revenue -- Platform revenue (credit side of commission)
  • platform/margin_revenue -- Margin revenue sub-account
  • user/{userId} -- Per-user account
  • agent/{agentId} -- Per-agent account
  • commission/platform -- Commission income
  • margin/platform -- Margin income

2.5 Settlement

Model: Settlement (backend/prisma/schema.prisma:224)

  • Tracks market-level settlement status (per fixture+market)
  • settlementStatus: pending -> settled
  • winningOutcomeId -- which selection won
  • Deduplication via @@unique([fixtureId, marketId])

2.6 SettlementPeriod

Model: SettlementPeriod (backend/prisma/schema.prisma:260)

  • Weekly cycles: open -> grace -> finalized
  • Contains aggregated totals (stakes, payouts, commission)
  • Parent for AgentSettlement records

2.7 AgentSettlement

Model: AgentSettlement (backend/prisma/schema.prisma:298)

  • Per-agent per-period settlement record
  • Contains: betting activity, booking P&L, commission share, final settlement amount
  • settlementDirection: agent_pays | platform_pays | zero

2.8 PointAllocation

Model: PointAllocation (backend/prisma/schema.prisma:481)

  • Records point transfers between hierarchy levels
  • Types: allocation (downward) or withdrawal (upward)
  • Direction tracked via fromType/fromId -> toType/toId

2.9 CommissionRecord & CommissionConfig

Models: CommissionConfig (schema:1111), CommissionRecord (schema:1145)

  • Betfair-style commission: charged on net market winnings (not per bet)
  • Default rate: 2% (DEFAULT_COMMISSION_RATE = 0.02)
  • Configurable per sport/market type via CommissionConfig
  • CommissionRecord stores per-user per-market commission calculation

2.10 MarginRecord

Model: MarginRecord (backend/prisma/schema.prisma:1191)

  • Tracks margin profit per order
  • Captures: displayOdds (user sees), placedOdds (Betfair actual), trueOdds (discovery)
  • marginAmount = stake * (placedOdds - displayOdds)
  • Slippage tracking: positive (platform profits), negative (odds worsened)

2.11 B-Book Models

  • BBookConfig (schema:862) -- Global config, pool limits, risk parameters
  • BBookGlobalState (schema:900) -- Real-time aggregate exposure and P&L
  • BBookMarketState (schema:925) -- Per-market exposure
  • BBookSelectionState (schema:961) -- Per-selection exposure (most granular)
  • BBookUserState (schema:999) -- Per-user exposure and lifetime P&L
  • BBookPosition (schema:1025) -- Individual position per order
  • BBookPnLRecord (schema:1234) -- Daily/weekly/monthly P&L reporting

3. Order Lifecycle Accounting

3.1 Bet Placement Flow

File: backend/src/services/orderService.ts:203 (placeOrder)

Step 1: Calculate required amount (in points)

Back bet: requiredAmount = stake
Lay bet: requiredAmount = stake * (odds - 1)

Step 2: Routing decision

  • filterEngine.determineRouting() decides: bbook / exchange / split
  • Returns bbookFill and exchangeFill amounts

Step 3: Capture agent booking percentage

  • Reads agent.bookingPoints and calculates: (bookingPoints - 1) / bookingPoints
  • Stored as agentBookingPercent on the Order (snapshot for settlement)

Step 4: Create order record (status: pending)

  • All order fields persisted including routing, booking %, display info

Step 5: Debit balance IMMEDIATELY (atomic transaction)

// orderService.ts:305-319
prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: { balancePoints: { decrement: requiredAmount } },
}),
prisma.transaction.create({
data: { userId, type: 'bet_placed', amount: -requiredAmount, orderId, ... },
}),
]);

CRITICAL: Balance is deducted BEFORE exchange submission. This is a "hold" pattern -- stake is locked immediately.

Step 6: B-Book fill (if applicable)

  • bbookFillService.executeFill() creates BBookPosition and updates state trackers
  • If B-Book fill fails, order is rerouted entirely to exchange

Step 7: Exchange submission (if applicable)

  • Points converted to USD: exchangeStakeUsdFinal = this.pointsToUsd(routingDecision.exchangeFill)
  • Margin applied to live odds
  • Order placed via exchangeCoordinator.placeOrder() to Betfair
  • If exchange fails and B-Book succeeded: partial refund of exchange portion
  • If exchange fails and no B-Book: full refund

Step 8: Update order status

  • On success: status -> accepted, store placedOdds/placedStake from Betfair
  • On failure: status -> cancelled, full refund via balancePoints { increment: requiredAmount }

Step 9: Invalidate cache and publish events

  • Redis cache userBalance(userId) invalidated
  • Redis pubsub punter:bet_placed for monitoring agents

3.2 Bet Cancellation (User-initiated)

File: backend/src/routes/orders.ts:160

  • Only pending orders can be cancelled
  • Refund: balancePoints { increment: refundAmount } + Transaction(type: 'bet_cancelled')
  • Refund calculation same as required amount

3.3 Points-to-USD Conversion

File: backend/src/services/orderService.ts:119-126

pointsToUsd(points: number): number { return points * this.pointValueUsd; }
usdToPoints(usd: number): number { return usd / this.pointValueUsd; }
  • Default: 1 point = 1 USD
  • Configurable via PlatformSettings key point_value_usd
  • Conversion only happens at exchange boundary (sending to Betfair)
  • Internal accounting always in points

4. Settlement Accounting

4.1 Settlement Triggers

Two paths to settlement:

  1. OddsPAPI polling (settlement.ts:36 pollSettlements()) -- polls for FINISHED fixtures
  2. BETS API callback (settlement.ts:727 settleOrderFromBetsApi()) -- direct per-order settlement

4.2 Settlement P&L Calculation

File: backend/src/services/settlement.ts:357 (settleOrder)

Back bet:

win:  profitLoss = potentialReturn - stake = stake * (odds - 1)
lose: profitLoss = -stake

Lay bet:

win (selection lost):  profitLoss = stake
lose (selection won): profitLoss = -(stake * (odds - 1))

Void:

profitLoss = 0, refundAmount = stake

4.3 Balance Credit on Settlement

File: settlement.ts:443-487

totalCredit = refundAmount + winAmount
where:
refundAmount = stake (if void) else 0
winAmount = profitLoss + stake (if win) else 0

Atomic transaction:

  1. Update Order: status -> settled, set settlementOutcome, profitLoss, settledAt
  2. Credit user: balancePoints { increment: totalCredit }
  3. Create Transaction: type 'settlement', amount = totalCredit

IMPORTANT: On a loss, totalCredit = 0 -- nothing is credited because the stake was already deducted at placement. The loss is the absence of a credit.

4.4 Commission Deduction (Post-Settlement)

File: settlement.ts:260 (processMarketCommission)

After all orders in a market are settled:

  1. Group settled orders by user
  2. Calculate net P&L per user in that market
  3. If net P&L > 0: commissionAmount = netMarketPL * commissionRate
  4. Deduct from balance: balancePoints { decrement: commissionAmount }
  5. Create Transaction: type 'commission', amount = -commissionAmount
  6. Save CommissionRecord + LedgerEntry (double-entry)

4.5 Margin Revenue Recording

File: settlement.ts:507-565

Only for winning back bets where placedOdds > displayOdds:

  1. Calculate margin profit: stake * (placedOdds - displayOdds)
  2. Save MarginRecord to database
  3. Record in ledger: debit margin/platform, credit platform/margin_revenue

Note: Margin profit is NOT deducted from user balance -- it's the difference between what Betfair pays the platform and what the platform pays the user.

4.6 B-Book Settlement

File: backend/src/services/bbook/bbookSettlementService.ts:34

Runs BEFORE the main settlement for orders with bbookFill > 0:

  1. Look up BBookPosition for the order
  2. Determine if user won/lost based on betType and winningOutcomeId
  3. Calculate platformPnL:
    • User wins back bet: platformPnL = -platformLiability (platform loses)
    • User loses back bet: platformPnL = +platformPotentialWin (platform wins stake)
  4. Update position status and selection state
  5. Update state trackers (exposure, P&L)
  6. Record daily P&L in BBookPnLRecord

B-Book settlement does NOT affect user balance directly -- the user's payout comes from the main settlement flow regardless of routing venue.


5. Agent Commission Flow

5.1 Agent Hierarchy

Platform (super_admin user)
-> Agent (has User record with role: 'agent')
-> Punter (User with agentId pointing to Agent)
  • Agents are self-referential: Agent.parentAgentId -> Agent.id
  • Point flow: Platform -> Agent User -> Punter User (via PointAllocation)

5.2 Agent Configuration

Key fields on Agent:

  • commissionSharePercent (0-100) -- share of commission the agent receives
  • bookingPoints (1.0-2.0) -- determines how much of user bet outcomes agent takes
  • creditLimit -- max points an agent can hold

5.3 Booking Points Model

File: backend/src/accounting/calculations.ts:74

bookingPercent = (bookingPoints - 1) / bookingPoints

1.0 points = 0% booking (no risk)
1.25 points = 20% booking
1.5 points = 33% booking
2.0 points = 50% booking

The booking percent is captured at bet placement time (Order.agentBookingPercent) so the agent can't change their risk after bets are placed.

5.4 Agent Settlement Calculation

File: backend/src/accounting/agentSettlementService.ts:134 + calculations.ts:119

Per period, per agent:

  1. Gather all settled orders from the agent's punters
  2. Calculate baseSettlement = -(totalStakes - totalPayouts) (negated net P&L)
  3. Calculate commission: commissionCollected = totalWinnings * commissionRate
  4. Calculate profit share: commissionShare = commissionCollected * (commissionSharePercent/100)
  5. Calculate booking P&L:
    • agentBookingProfit = totalUserLosses * bookingPercent
    • agentBookingLoss = totalUserWins * bookingPercent
    • netBookingResult = profit - loss
  6. finalSettlement = baseSettlement + profitShare + netBookingResult
  7. Direction: positive = platform_pays, negative = agent_pays

5.5 Settlement Confirmation

File: agentSettlementService.ts:247

When admin confirms:

  1. Record in ledger (double-entry)
  2. Update agent's User.balancePoints (increment/decrement)
  3. Status: calculated -> confirmed

6. B-Book Accounting

6.1 Position Tracking

File: backend/src/services/bbook/bbookFillService.ts:39

When a bet is B-booked:

  1. Create BBookPosition with:
    • stake -- B-book fill amount
    • platformSide -- opposite of user (if user backs, platform lays)
    • platformLiability -- what platform risks
    • platformPotentialWin -- what platform wins if user loses
  2. Update state hierarchy: Global -> Market -> Selection -> User

6.2 Liability Calculation

Back bet (user backs, platform lays):
platformLiability = stake * (odds - 1) -- platform pays profit if user wins
platformPotentialWin = stake -- platform keeps stake if user loses

Lay bet (user lays, platform backs):
platformLiability = stake -- platform pays stake if user wins
platformPotentialWin = stake * (odds - 1) -- platform keeps liability if user loses

6.3 B-Book P&L on Settlement

User wins: platformPnL = -platformLiability (loss)
User loses: platformPnL = +platformPotentialWin (profit)

Daily aggregated into BBookPnLRecord.

6.4 Key Insight for Multi-Provider

B-Book is routing-layer only. It decides how much of a bet the platform absorbs vs sends to exchange. The user's accounting is identical regardless -- they see the same odds, same stake, same P&L. Only the platform's internal risk changes.


7. API & Frontend

7.1 Balance Endpoints

GET /api/users/me/balance (backend/src/routes/users.ts:113)

  • Returns { balance: number } (from User.balancePoints)
  • Cached in Redis for 60s (cache.keys.userBalance(userId))
  • Cache invalidated on every balance mutation

GET /api/users/me (users.ts:23)

  • Returns full profile including balancePoints
  • For agents: also returns agentPoolBalance, agentCreditLimit

GET /api/users/me/transactions (users.ts:152)

  • Returns Transaction[] with pagination

7.2 Real-Time Balance Updates

WebSocket events (via Socket.IO):

  • balance:update -- { userId, newBalance, change, reason, timestamp }
  • settlement:update -- { userId, orderId, outcome, profitLoss, newBalance, ... }

Published via notificationService.publishBalanceUpdate() and publishSettlement().

7.3 Frontend State

Zustand store: frontend/src/store/authStore.ts

interface User {
balancePoints: number;
agentPoolBalance?: number | null;
agentCreditLimit?: number | null;
displayCurrency?: string;
}

updateBalance: (balance: number) => set(state => ({
user: state.user ? { ...state.user, balancePoints: balance } : null,
}))
  • Balance displayed from user.balancePoints
  • Updated via WebSocket balance:update events
  • Persisted in localStorage via Zustand persist middleware

7.4 Display Currency

  • Agent can set displayCurrency in their settings (stored in Agent.settings JSON)
  • Frontend reads this from /api/users/me response
  • Currently informational only -- all amounts are in points (= USD)

8. Key Findings for Multi-Provider Integration

8.1 Provider-Agnostic Internal Accounting

The point system is already completely decoupled from the exchange provider:

  • User.balancePoints is debited/credited in points
  • Transaction records are in points
  • LedgerEntry records are in points
  • Exchange-specific amounts (USD) only appear at the PAL boundary

Implication: Adding Pinnacle as a provider should NOT require changes to the core accounting. The same debit-on-placement / credit-on-settlement flow applies.

8.2 Order.bookmaker Field

Currently defaults to "pinnacle" in the schema but is overridden to "betfair-ex" in code (orderService.ts:215). This field can be used to track which provider executed the order.

8.3 Margin System is Provider-Specific

The margin system (MarginRecord, marginService) tracks the difference between display odds and placed odds. This is inherently provider-specific:

  • For Betfair: margin = displayOdds vs placedOdds (exchange odds can differ)
  • For Pinnacle: margin concept may differ (Pinnacle is a fixed-odds bookmaker, no back/lay)

8.4 Points-to-USD Conversion

The pointsToUsd() / usdToPoints() conversion happens once at exchange submission. If Pinnacle uses different currencies or stake formats, this conversion point is where adaptation happens.

8.5 B-Book Routing Decision

The filter engine (filterEngine.determineRouting()) currently decides between bbook/exchange. This is the natural extension point for multi-provider routing (bbook/betfair/pinnacle/split).

8.6 Commission is Provider-Independent

Commission is calculated on net market P&L regardless of routing venue. A bet routed to Pinnacle should generate the same commission as one routed to Betfair.

8.7 Settlement is Already Multi-Path

Two settlement paths exist (OddsPAPI polling + BETS API callback). Adding a Pinnacle settlement path is architecturally consistent.

8.8 No Deposit/Fiat Integration

There is no on-chain deposit/withdrawal implementation in the codebase currently. Points are allocated administratively (platform -> agent -> punter). There's no crypto-to-points bridge despite wallet addresses being stored.

8.9 Concurrent Balance Safety

Balance mutations use Prisma's { increment } / { decrement } operators which translate to atomic SQL operations (UPDATE SET balance = balance + ?). This is safe for concurrent updates but there's no explicit locking or serializable transactions -- a race condition window exists between the balance check in validateOrder() and the actual debit in placeOrder().

8.10 Schema Precision

  • Balance: Decimal(18,4) -- 4 decimal places
  • Stakes/P&L: Decimal(18,2) -- 2 decimal places
  • Odds: Decimal(10,4) -- 4 decimal places
  • Internal calculations: Decimal.js with 20 digits precision
  • Storage rounding: 4 decimal places (ROUND_HALF_UP)