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
PlatformSettingskeypoint_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:
stakeDecimal(18,2) -- User's stake in pointsoddsDecimal(10,4) -- Display odds (with margin applied)trueOddsDecimal(10,4) -- Original Betfair odds before margin (discovery time)placedOddsDecimal(10,4) -- Actual Betfair odds at placement timepotentialReturnDecimal(18,2) -- stake * odds (for back bets)status-- pending -> accepted -> settled (or cancelled/declined)settlementOutcome-- win/lose/void/push/half_win/half_loseprofitLossDecimal(18,2) -- Final P&L after settlementbookmakerString -- defaults to "pinnacle" (field name is legacy; currently "betfair-ex")routingVenue-- bbook/exchange/splitbbookFill/exchangeFill-- how much went to each venueagentBookingPercent-- 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
balancePointsmutation - 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 poolplatform/revenue-- Platform revenue (credit side of commission)platform/margin_revenue-- Margin revenue sub-accountuser/{userId}-- Per-user accountagent/{agentId}-- Per-agent accountcommission/platform-- Commission incomemargin/platform-- Margin income
2.5 Settlement
Model: Settlement (backend/prisma/schema.prisma:224)
- Tracks market-level settlement status (per fixture+market)
settlementStatus: pending -> settledwinningOutcomeId-- 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
AgentSettlementrecords
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) orwithdrawal(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 CommissionRecordstores 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 parametersBBookGlobalState(schema:900) -- Real-time aggregate exposure and P&LBBookMarketState(schema:925) -- Per-market exposureBBookSelectionState(schema:961) -- Per-selection exposure (most granular)BBookUserState(schema:999) -- Per-user exposure and lifetime P&LBBookPosition(schema:1025) -- Individual position per orderBBookPnLRecord(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
bbookFillandexchangeFillamounts
Step 3: Capture agent booking percentage
- Reads
agent.bookingPointsand calculates:(bookingPoints - 1) / bookingPoints - Stored as
agentBookingPercenton 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()createsBBookPositionand 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_placedfor monitoring agents
3.2 Bet Cancellation (User-initiated)
File: backend/src/routes/orders.ts:160
- Only
pendingorders 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
PlatformSettingskeypoint_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:
- OddsPAPI polling (
settlement.ts:36pollSettlements()) -- polls for FINISHED fixtures - BETS API callback (
settlement.ts:727settleOrderFromBetsApi()) -- 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:
- Update Order: status -> settled, set settlementOutcome, profitLoss, settledAt
- Credit user:
balancePoints { increment: totalCredit } - 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:
- Group settled orders by user
- Calculate net P&L per user in that market
- If net P&L > 0:
commissionAmount = netMarketPL * commissionRate - Deduct from balance:
balancePoints { decrement: commissionAmount } - Create Transaction: type 'commission', amount = -commissionAmount
- Save CommissionRecord + LedgerEntry (double-entry)
4.5 Margin Revenue Recording
File: settlement.ts:507-565
Only for winning back bets where placedOdds > displayOdds:
- Calculate margin profit:
stake * (placedOdds - displayOdds) - Save
MarginRecordto database - Record in ledger: debit
margin/platform, creditplatform/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:
- Look up
BBookPositionfor the order - Determine if user won/lost based on
betTypeandwinningOutcomeId - Calculate
platformPnL:- User wins back bet:
platformPnL = -platformLiability(platform loses) - User loses back bet:
platformPnL = +platformPotentialWin(platform wins stake)
- User wins back bet:
- Update position status and selection state
- Update state trackers (exposure, P&L)
- 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 receivesbookingPoints(1.0-2.0) -- determines how much of user bet outcomes agent takescreditLimit-- 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:
- Gather all settled orders from the agent's punters
- Calculate
baseSettlement = -(totalStakes - totalPayouts)(negated net P&L) - Calculate commission:
commissionCollected = totalWinnings * commissionRate - Calculate profit share:
commissionShare = commissionCollected * (commissionSharePercent/100) - Calculate booking P&L:
agentBookingProfit = totalUserLosses * bookingPercentagentBookingLoss = totalUserWins * bookingPercentnetBookingResult = profit - loss
finalSettlement = baseSettlement + profitShare + netBookingResult- Direction: positive = platform_pays, negative = agent_pays
5.5 Settlement Confirmation
File: agentSettlementService.ts:247
When admin confirms:
- Record in ledger (double-entry)
- Update agent's
User.balancePoints(increment/decrement) - 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:
- Create
BBookPositionwith:stake-- B-book fill amountplatformSide-- opposite of user (if user backs, platform lays)platformLiability-- what platform risksplatformPotentialWin-- what platform wins if user loses
- 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 }(fromUser.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:updateevents - Persisted in localStorage via Zustand persist middleware
7.4 Display Currency
- Agent can set
displayCurrencyin their settings (stored inAgent.settingsJSON) - Frontend reads this from
/api/users/meresponse - 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.balancePointsis debited/credited in pointsTransactionrecords are in pointsLedgerEntryrecords 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)