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)