Pinnacle Credit Integration — Dual-Provider Accounting Model
Research document for integrating Pinnacle credits into the existing Hannibal point system and accounting architecture.
References:
- POINTS_SYSTEM_ARCHITECTURE.md — Current accounting model
- PINNACLE_INTEGRATION_PLAN.md — PAL adapter integration plan
1. Executive Summary
Hannibal's internal point system is already provider-agnostic. User balances, ledger entries, and settlement calculations all operate in points (1 point = 1 USD). Exchange-specific amounts (USD) only appear at the PAL boundary when submitting to Betfair. Adding Pinnacle as a second provider requires no fundamental changes to the core accounting model.
The key design decision is that the platform acts as an intermediary — users bet with the platform's point system, and the platform bets with its own Pinnacle account using real USD. This is identical to the existing Betfair model, just with a second provider.
What changes:
Order.bookmakerfield distinguishes'betfair-ex'vs'pinnacle'(already exists in schema)routingVenueremains'exchange'for Pinnacle (Option B — see §3.4).bookmakerfield distinguishes provider.- Pinnacle settlement job polls per-order via
GET /v3/bets?betIds=X(simpler than planned/v3/fixtures/settledapproach) PENDING_ACCEPTANCEmaps toOrder.status = 'submitted'(see §6.3)- Provider-level exposure tracking with Redis distributed lock for race condition prevention
What does NOT change:
User.balancePoints— single unified balance, no per-provider splitTransactionrecords — same types, same formatLedgerEntrydouble-entry pairs — same account types- Commission calculation — still based on net market P&L, provider-independent
- Agent settlement — same formula, same fields
- Frontend balance display — single number, unchanged
2. Current Accounting Model (Brief)
Full details in POINTS_SYSTEM_ARCHITECTURE.md
Balance Flow
Bet Placement:
User.balancePoints -= requiredAmount (points, immediate)
→ Transaction(type: 'bet_placed', amount: -requiredAmount)
→ Exchange receives USD (pointsToUsd conversion at PAL boundary)
Settlement:
WIN: User.balancePoints += stake + profit (points)
LOSE: nothing (stake already deducted)
VOID: User.balancePoints += stake (refund)
→ Transaction(type: 'settlement', amount: totalCredit)
Key Properties
- Balance debited BEFORE exchange submission ("hold" pattern)
- Balance in points (Decimal 18,4), stakes/P&L in points (Decimal 18,2)
- Atomic transactions via
prisma.$transaction Order.bookmakerfield exists (defaults'pinnacle'in schema, overridden to'betfair-ex'in code)Order.routingVenueexists:'bbook'|'exchange'|'split'- B-Book routing is transparent to user — same odds, same P&L regardless of venue
- Commission charged on net market P&L, provider-independent
3. Proposed Dual-Provider Model
3.1 Single Unified Balance — No Per-Provider Split
Decision: Keep User.balancePoints as a single unified balance.
Rationale:
- The existing system is already provider-agnostic at the accounting layer
- Users don't care (and shouldn't know) which provider executes their bet
- Adding per-provider balances would complicate every balance check, display, and settlement
- The platform absorbs provider differences — it's one pool of funds
The platform's own provider-level fund tracking is a separate concern from user balances (see Section 8: Risk & Exposure).
3.2 Platform Intermediary Model
The platform acts as intermediary for both providers:
User ←→ Platform (points) ←→ Betfair (USD, exchange model)
←→ Pinnacle (USD, bookmaker model)
- User bets with the platform using points
- Platform bets with Pinnacle using its own $1K USD credit account
- The platform's Pinnacle account balance is a platform-level concern, not visible to users
- This is identical to how Betfair works today — users never interact with Betfair directly
3.3 Order.bookmaker Field Usage
Currently: defaults to 'pinnacle' in Prisma schema but overridden to 'betfair-ex' in orderService.ts:215.
Proposed: Set dynamically based on routing:
| Provider | Order.bookmaker Value |
|---|---|
| Betfair | 'betfair-ex' |
| Pinnacle | 'pinnacle' |
This field already exists and is indexed — no schema change needed.
3.4 Order.routingVenue Extension
Current values: 'bbook' | 'exchange' | 'split'
The 'exchange' value currently always means Betfair. With Pinnacle, we have two options:
Option A: Add 'pinnacle' as a new venue value
routingVenue = 'pinnacle'for Pinnacle-routed ordersroutingVenue = 'exchange'continues to mean Betfair- Simple, backward-compatible
Option B: Keep 'exchange' for all providers, use bookmaker to distinguish
routingVenue = 'exchange'+bookmaker = 'pinnacle'for PinnacleroutingVenue = 'exchange'+bookmaker = 'betfair-ex'for Betfair- More semantically correct (both are external exchanges)
Recommendation: Option B. Pinnacle and Betfair are both "exchange" routing. The bookmaker field distinguishes which provider. The routingVenue remains about the routing strategy (bbook vs exchange vs split), not which specific exchange. This also means settlement, commission, and agent accounting code that checks routingVenue === 'exchange' continues to work without modification.
The only place that needs to know the difference between Betfair and Pinnacle is:
orderService.ts— which PAL adapter to submit to (PAL handles this via sport routing)- Settlement jobs — which polling mechanism to use
- The settlement job already queries by
bookmakerfield to know which provider to check
4. Order Flow: Pinnacle Path (Step-by-Step)
4.1 Bet Placement (Pinnacle-routed Order)
The flow is nearly identical to Betfair. Differences are marked with [PINNACLE].
Step 1: User submits bet (stake in points, display odds)
Same as Betfair — no change
Step 2: validateOrder()
[PINNACLE] Minimum stake check: Pinnacle minRiskStake = $20 USD
→ Already handled: BOOKMAKER_MIN_STAKES.pinnacle = 20 exists in orderService.ts:25
→ validateOrder() already uses bookmaker-specific min stakes (line 138)
Step 3: Routing decision (filterEngine.determineRouting())
[PINNACLE] B-Book routing still applies. A Pinnacle-destined bet can still be
partially or fully B-booked. The "exchange" portion goes to Pinnacle instead of Betfair.
→ No change to B-Book logic
Step 4: Create Order (status: 'pending')
[PINNACLE] bookmaker = 'pinnacle' (instead of 'betfair-ex')
→ routingVenue = 'exchange' (same as Betfair — see Section 3.4)
Step 5: Debit balance (atomic)
User.balancePoints -= requiredAmount
Transaction(type: 'bet_placed', amount: -requiredAmount)
→ Identical to Betfair, no change
Step 6: B-Book fill (if applicable)
→ Identical to Betfair, no change
Step 7: Exchange submission
[PINNACLE] Two-step process (different from Betfair):
7a. GET /v2/line — fetch fresh price + limits + lineId
7b. Validate: stake >= minRiskStake ($20) AND stake <= maxRiskStake
7c. POST /v2/bets/place — submit bet
7d. Handle response:
ACCEPTED → status = 'accepted'
PENDING_ACCEPTANCE → status = 'submitted' (NEW — see Section 6)
PROCESSED_WITH_ERROR → refund, status = 'cancelled'
[PINNACLE] Points-to-USD conversion:
exchangeStakeUsd = pointsToUsd(routingDecision.exchangeFill)
→ Same pattern as Betfair (orderService.ts:381)
→ Account is USD-denominated, no currency conversion needed
[PINNACLE] No cancellation:
Once placed, cannot be cancelled. If ACCEPTED, it's final.
→ cancelOrder returns failure for Pinnacle adapter (already in integration plan)
Step 8: Update order status
[PINNACLE] If PENDING_ACCEPTANCE: status = 'submitted', store uniqueRequestId
[PINNACLE] If ACCEPTED: status = 'accepted', store betId as betsApiOrderId
→ For PENDING_ACCEPTANCE, a background job resolves the status (see Section 6)
Step 9: Cache invalidation + events
→ Identical to Betfair, no change
4.2 Balance Impact Summary (Pinnacle vs Betfair)
| Step | Betfair | Pinnacle | Difference |
|---|---|---|---|
| Debit at placement | balancePoints -= stake | balancePoints -= stake | None |
| Exchange submission | PAL → Betfair (USD) | PAL → Pinnacle (USD) | Different provider |
| On failure | Full refund | Full refund | None |
| On success | Status = accepted | Status = accepted or submitted | PENDING_ACCEPTANCE |
| Settlement credit | balancePoints += totalCredit | balancePoints += totalCredit | None |
Key insight: The user's balance flow is identical. The only difference is the PENDING_ACCEPTANCE state for live Pinnacle bets.
4.3 Margin Handling for Pinnacle
Pinnacle is a bookmaker, not an exchange. The margin model differs:
Betfair margin model (current):
- Discovery: get exchange odds (e.g., 2.00 true odds)
- Apply margin: display odds = 1.96 (2% margin)
- Place at live exchange odds (may differ from discovery)
- Margin profit = stake * (placedOdds - displayOdds)
- Margin is variable — depends on slippage
Pinnacle margin model:
- Pinnacle odds already include Pinnacle's own margin (their vig)
- We can apply an additional Hannibal margin on top (configurable)
- Decision: 0% Hannibal margin on Pinnacle for MVP (configurable via
PINNACLE_MARGIN_PERCENTenv var, default0). Pinnacle's built-in vig is sufficient. Can be adjusted later without code changes. - Display odds = Pinnacle odds * (1 - hannibalMarginPercent) for back bets
- Place at Pinnacle's actual price (from
/v2/line) - Margin profit = stake * (pinnaclePrice - displayOdds)
- No exchange-style slippage — Pinnacle price is fixed at placement
What this means:
Order.trueOdds= Pinnacle's offered price (from/v4/oddsor/v2/line)Order.odds= display odds shown to user (after Hannibal margin applied)Order.placedOdds= actual Pinnacle price at placement time (from/v2/line)MarginRecordcalculation works the same:marginAmount = stake * (placedOdds - displayOdds)marginService.applyMargin()works identically — just applying a percentage reduction
No changes needed to MarginRecord schema or marginRevenueService.ts.
5. Settlement: Pinnacle Path
5.1 Settlement Data Source
Betfair settlement (current):
- Path 1: OddsPAPI polls for FINISHED fixtures
- Path 2: BETS API callback per order
- Both converge on
settlement.settleOrder()
Pinnacle settlement:
- Poll
GET /v3/fixtures/settled?sportId=X&since=Y→ per-period scores + status - Then
GET /v3/bets?betIds=X&fromDate=Y&toDate=Z→ per-bet status + winLoss - Converge on
settlement.settleOrderFromBetsApi()(already exists)
5.2 Pinnacle Settlement Job Flow
pinnacleSettlementJob.checkPinnacleSettledOrders() runs every 60s:
1. For each Pinnacle sport (29=soccer, 4=basketball, 33=tennis):
a. GET /v3/fixtures/settled?sportId=X&since=Y
b. For each settled fixture period:
- Find orders: WHERE bookmaker='pinnacle' AND status='accepted'
AND fixtureId contains the Pinnacle eventId
- If no orders → skip
c. GET /v3/bets?betIds=<ids>&fromDate=<7d>&toDate=<now>
d. For each bet:
- Map betStatus2 → SettlementResult:
WON → 'win'
LOST → 'lose'
CANCELLED/REFUNDED → 'void'
HALF_WON_HALF_PUSHED → 'half_win'
HALF_LOST_HALF_PUSHED → 'half_lose'
- Call settlementService.settleOrderFromBetsApi(orderId, result)
5.3 Reuse of Existing Settlement Infrastructure
settlementService.settleOrderFromBetsApi() (settlement.ts:727) is already designed for this:
- Accepts
orderId+ settlement result (win/lose/void/half_win/half_lose) - Internally calls
settleOrder()which handles:- B-Book settlement (if order had B-Book portion)
- P&L calculation (back/lay, same formulas)
- Balance credit (balancePoints increment)
- Transaction record
- Margin record
- Commission processing
- Notification publishing
No changes needed to settlement.ts. The Pinnacle settlement job just needs to:
- Poll Pinnacle API for settled bets
- Map Pinnacle status → our settlement result enum
- Call the existing
settleOrderFromBetsApi()method
5.4 Pinnacle Settlement Statuses Mapping
Pinnacle betStatus2 | Hannibal settlementResult | Balance Impact |
|---|---|---|
WON | 'win' | Credit: stake + profit |
LOST | 'lose' | No credit (stake already held) |
CANCELLED | 'void' | Refund: stake |
REFUNDED | 'void' | Refund: stake |
HALF_WON_HALF_PUSHED | 'half_win' | Credit: stake + (profit/2) |
HALF_LOST_HALF_PUSHED | 'half_lose' | Refund: stake/2 |
5.5 Half-Win / Half-Lose Handling
Pinnacle supports Asian handicap outcomes (HALF_WON_HALF_PUSHED, HALF_LOST_HALF_PUSHED). The existing settleOrderFromBetsApi() maps these to 'win'/'lose' (settlement.ts:776-782), which loses the "half" granularity.
Issue: A half_win should credit stake + (profit * 0.5) but the current code treats it as full win.
Recommendation: This is a pre-existing issue that affects Betfair Asian handicap bets too. It should be fixed separately — not as part of Pinnacle integration. For MVP, the current behavior (treating half as full) is acceptable since:
- Asian handicap bets are a small portion of volume
- The discrepancy is in the user's favor for half-wins (they get more)
- The discrepancy is against the user for half-losses (they lose more)
- Pinnacle's
winLossfield provides the exact P&L — we could use it as the source of truth
Future fix: Use Pinnacle's bet.winLoss as authoritative P&L instead of calculating from odds. This would be:
profitLoss = bet.winLoss // From Pinnacle API
totalCredit = max(0, stake + profitLoss)
5.6 Re-settlement Handling
Pinnacle settlement periods can have status values 2 (re-settled) and 4 (re-settled as cancelled). This means a previously settled bet can be re-settled with a different outcome.
Current system behavior: settleOrder() skips orders with non-null settlementOutcome (settlement.ts:743). This means re-settlements would be silently ignored.
Recommendation for MVP: Log a warning when a re-settlement is detected but don't auto-process it. Create a manual review queue. Re-settlements are rare and high-risk — better to handle manually initially.
Future enhancement: Implement a reversal flow:
- Detect re-settlement (period.status = 2 or 4)
- Reverse the original settlement (debit the credit, or credit the debit)
- Apply new settlement
- Log in ledger with
category: 'resettlement'
6. PENDING_ACCEPTANCE Handling
6.1 The Problem
Pinnacle live bets go through a ~6-second acceptance delay. During this time, the bet status is PENDING_ACCEPTANCE. The bet has no betId yet — only a uniqueRequestId.
Three possible outcomes:
ACCEPTED→ bet is live,betIdassignedNOT_ACCEPTED→ bet rejected, funds should be refunded- Timeout (>30 min) → treat as rejected (Pinnacle stops returning
uniqueRequestIdresults after 30 min)
6.2 Balance During PENDING_ACCEPTANCE
The balance is already held. The debit happens at Step 5 (before exchange submission). This is the "hold" pattern already used for Betfair. No additional balance logic needed.
If the bet is NOT_ACCEPTED, the refund path is the same as current exchange failure handling (orderService.ts:540-568): balancePoints { increment: requiredAmount } + Transaction(type: 'bet_refund').
6.3 Order Status Mapping
| Pinnacle Status | Hannibal Order.status | Notes |
|---|---|---|
PENDING_ACCEPTANCE | 'submitted' | Distinguishes "sent to Pinnacle, awaiting acceptance" from "created but not yet sent to exchange" |
ACCEPTED | 'accepted' | Same as Betfair |
NOT_ACCEPTED / REJECTED | 'declined' | Refund issued |
Implementation note: The 'submitted' status was chosen over 'pending' to provide clearer state distinction. The settlement job queries status IN ['submitted', 'pending'] to cover both states. The refund on rejection uses status 'declined' (not 'cancelled') to align with the PAL adapter's return value.
6.4 Background Resolution Job
resolvePendingPinnacleOrders() runs every 15s:
1. Find orders: WHERE bookmaker='pinnacle' AND betsApiOrderId IS NOT NULL
AND status IN ('submitted', 'pending')
LIMIT 50
2. For each order, check age:
If ageMs > 30 minutes → refund + UPDATE SET status='declined' (timeout)
3. Otherwise: call pinnacleAdapter.getOrder({ providerOrderId: betsApiOrderId })
→ Queries GET /v3/bets?betIds=X
4. For each resolved:
'declined' (NOT_ACCEPTED/REJECTED) → refund + UPDATE SET status='declined'
'accepted' → UPDATE SET status='accepted', placedStake, placedOdds
Still pending → skip, retry next cycle
Implementation note: Queries by betIds (not uniqueRequestIds) since betsApiOrderId is stored at submission time. The 6s initial delay is covered by the 15s polling interval.
6.5 User Experience During PENDING_ACCEPTANCE
- Bet appears as "Pending" in My Bets (same as current pending state)
- Balance already reduced (held)
- No action available (can't cancel — Pinnacle doesn't support cancellation)
- Auto-resolves within seconds (typically) or minutes (worst case)
- If rejected: balance restored, bet marked as cancelled, notification sent
7. Ledger & Transaction Changes
7.1 No New Transaction Types Needed
All existing transaction types cover Pinnacle operations:
| Operation | Transaction Type | Exists? |
|---|---|---|
| Bet placed | 'bet_placed' | Yes |
| Bet cancelled/rejected | 'bet_cancelled' or 'bet_refund' | Yes |
| Settlement (win) | 'settlement' | Yes |
| Commission | 'commission' | Yes |
| PENDING_ACCEPTANCE → NOT_ACCEPTED | 'bet_refund' | Yes |
7.2 No New Ledger Account Types Needed
Existing ledger account types are provider-agnostic:
| Account | Usage | Pinnacle Impact |
|---|---|---|
platform/stakes | Debit on bet placed | Same |
platform/payouts | Credit on settlement | Same |
user/{userId} | Per-user account | Same |
commission/platform | Commission income | Same |
margin/platform | Margin income | Same |
7.3 Optional: Provider Tag in Ledger Metadata
For reporting purposes, we could add the provider to LedgerEntry.metadata:
{ "provider": "pinnacle", "pinnacleEventId": 1623781857 }
This is purely additive — the metadata field is already Json type. No schema change needed. Enables queries like "show me all Pinnacle-related ledger entries" for financial reporting.
7.4 Transaction Description Enhancement
Implemented: "BACK bet @ 2.47 (Pinnacle)" — includes provider name in transaction description (orderService.ts:334). Also stores { provider: 'pinnacle' } in transaction metadata for query filtering.
8. Risk & Exposure Tracking
8.1 Platform-Level Provider Exposure
The platform needs to track its exposure per provider separately from user accounting. This is an operations concern (how much money is at risk with each provider), not an accounting concern (user balances).
Current state: No explicit provider-level exposure tracking. The B-Book system tracks platform exposure for B-booked bets, but exchange-routed bets' risk is implicitly the provider's.
New concern with Pinnacle: The platform's Pinnacle account has $1K USD credit. We need to know:
- How much of that $1K is currently committed (open bets)?
- What's the platform's net P&L with Pinnacle?
- Are we approaching the credit limit?
8.2 Proposed: ProviderExposure Tracking
New model: PlatformProviderState
Fields:
providerId: String ('betfair', 'pinnacle')
creditLimit: Decimal (Pinnacle: 1000 USD, Betfair: N/A or account balance)
currentExposure: Decimal (sum of open bet stakes)
realizedPnL: Decimal (cumulative settled P&L)
openBetCount: Int
lastUpdated: DateTime
This can be:
- A new Prisma model (most robust)
- A Redis hash (faster, but not persistent)
- Calculated on-demand from orders (simplest but slow)
Decision: Use Prisma model. PlatformProviderState will be a persistent Prisma model (see Section 9.2). This gives us:
- Persistent state across restarts (unlike Redis)
- Queryable history for auditing
- Atomic updates via
prisma.$transaction - Real-time balance from
GET /v1/client/balancesynced periodically (every 60s)
The availableBalance field from the Pinnacle balance API is the source of truth for credit monitoring. Combined with the Prisma model, we get both real-time API data and persistent tracking.
8.3 B-Book Interaction with Pinnacle Routing
B-Book routing is independent of the downstream provider. The filter engine decides what percentage to B-book based on user profile, market conditions, and risk limits. The "exchange" portion then goes to whichever provider PAL routes to.
User places $100 bet on Soccer match:
→ filterEngine: 40% B-Book, 60% exchange
→ B-Book fill: $40 (platform absorbs)
→ Exchange fill: $60 → PAL routes to Pinnacle (soccer is Pinnacle-primary)
User places $100 bet on Cricket match:
→ filterEngine: 40% B-Book, 60% exchange
→ B-Book fill: $40 (platform absorbs)
→ Exchange fill: $60 → PAL routes to Betfair (cricket is Betfair-only)
No change to B-Book logic. B-Book doesn't care which provider gets the exchange portion.
8.4 Pinnacle Credit Limit Guard
Before routing an order to Pinnacle, check that the platform's Pinnacle account can absorb it:
// In PinnacleAdapter.placeOrder() or in orderService before exchange submission:
const currentExposure = await getPinnacleExposure(); // cached query
const betAmount = pointsToUsd(exchangeFill);
if (currentExposure + betAmount > PINNACLE_CREDIT_LIMIT) {
// Option A: Fallback to Betfair
// Option B: Reject the bet
// Option C: B-Book the excess
}
Decision: Log a warning when exposure > 80% of credit limit. Hard-block when exposure > 90%. The $1K credit limit requires conservative management — 90% threshold provides a safety buffer for in-flight bets and settlement timing.
Implementation: The exposure guard is wrapped in a Redis distributed lock (acquireRedisLock() with 10s TTL, 3 retries) to prevent concurrent bets from passing the exposure check simultaneously (TOCTOU race condition). The lock is acquired before the exposure check and released in a finally block. If the lock cannot be acquired, the bet is rejected with "Pinnacle is processing another bet." Exposure cache is invalidated under the lock to ensure fresh data.
9. Schema Changes Required
9.1 No Schema Changes Needed (MVP)
Remarkably, the existing schema supports Pinnacle integration without modifications:
| Field | Current State | Pinnacle Usage |
|---|---|---|
Order.bookmaker | Exists, defaults 'pinnacle' | Set to 'pinnacle' for Pinnacle orders |
Order.routingVenue | 'bbook' / 'exchange' / 'split' | Use 'exchange' for Pinnacle (bookmaker distinguishes) |
Order.betsApiOrderId | BigInt, stores Betfair order ID | Store Pinnacle betId |
Order.requestUuid | UUID, used for deduplication | Store Pinnacle uniqueRequestId |
Order.placedOdds | Actual exchange odds at placement | Pinnacle's confirmed price |
Order.trueOdds | Original odds before margin | Pinnacle's offered odds |
Order.sportId | Int, optional | Used for settlement job sport filtering |
LedgerEntry.metadata | Json | Can store provider info |
Transaction.metadata | Json | Can store provider info |
9.2 Required Schema Addition — PlatformProviderState (Prisma Model)
Decision: Use Prisma model for provider state tracking (not on-demand or Redis).
// NEW: Platform-level provider state tracking
model PlatformProviderState {
id String @id @default(uuid())
providerId String @unique // 'betfair', 'pinnacle'
displayName String
// Credit/balance
creditLimit Decimal @default(0) @db.Decimal(18, 4)
accountBalance Decimal @default(0) @db.Decimal(18, 4) // Last known balance
// Exposure tracking (calculated periodically)
currentExposure Decimal @default(0) @db.Decimal(18, 4) // Sum of open bets
openBetCount Int @default(0)
// P&L
realizedPnL Decimal @default(0) @db.Decimal(18, 4)
unrealizedPnL Decimal @default(0) @db.Decimal(18, 4)
// Health
status String @default("active") // active, suspended, limit_reached
lastSyncAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("platform_provider_state")
}
This model is required for MVP. The accountBalance field is synced from GET /v1/client/balance every 60s. Exposure and P&L are updated on bet placement and settlement.
10. Open Questions & Decisions Needed
Resolved in This Document
| # | Question | Decision |
|---|---|---|
| 1 | Single balance vs per-provider? | Single unified balance. Provider-agnostic. |
| 2 | routingVenue for Pinnacle? | Use 'exchange' + bookmaker = 'pinnacle' to distinguish |
| 3 | Order status for PENDING_ACCEPTANCE? | Use 'submitted' — distinguishes "sent to Pinnacle, awaiting acceptance" from "created but not yet sent" |
| 4 | New transaction types? | No — existing types sufficient |
| 5 | New ledger account types? | No — existing types sufficient |
| 6 | Schema changes for MVP? | None — existing fields cover all needs |
| 7 | B-Book interaction? | No change — B-Book is routing-layer only, provider-independent |
| 8 | Margin model? | Same approach — apply Hannibal margin on top of Pinnacle odds |
Needs Decision
| # | Question | Options | Recommendation |
|---|---|---|---|
| 1 | Pinnacle $1K credit monitoring — How to alert when approaching limit? | (a) Admin dashboard widget, (b) Slack/email alert at 80%, (c) Auto-pause routing at 95% | Start with (c) auto-pause + logger.error. Add (b) later. |
| 2 | Half-win/half-lose accounting — Current code treats as full win/lose | (a) Fix for both providers now, (b) Fix later, (c) Use Pinnacle's winLoss as source of truth | (b) Fix later — low priority, low volume, pre-existing issue |
| 3 | Re-settlement handling — Pinnacle can re-settle bets | (a) Auto-process, (b) Manual review queue, (c) Ignore for MVP | (b) Manual queue — re-settlements are rare and high-stakes |
| 4 | Provider-level exposure model — Prisma model vs Redis vs on-demand | (a) New Prisma model, (b) Redis cache, (c) On-demand query | DECIDED: (a) Prisma model. Persistent, auditable, atomic updates. Syncs availableBalance from GET /v1/client/balance every 60s. See Section 8.2 and 9.2. |
| 5 | Pinnacle bet minimum ($20) — How to handle when B-Book takes part of stake? | If B-Book takes $30 of $50 bet, exchange portion = $20 (exactly at minimum). If B-Book takes $35, exchange = $15 (below minimum). | For split orders: if exchange portion < $20, route entirely to B-Book or entirely to exchange. Don't allow splits below Pinnacle minimum. TODO: Apply similar logic as Betfair minimum stake handling. |
| 6 | PENDING_ACCEPTANCE timeout — How long to wait? | Integration plan says 5 min. Pinnacle docs say uniqueRequestId expires after 30 min. | DECIDED: 30 minutes. Use the full window. Pinnacle stops returning uniqueRequestId results after 30 min, so this is the natural timeout. Avoids prematurely refunding bets that Pinnacle may still accept. |
Needs Investigation — RESOLVED
| # | Question | Answer | Status |
|---|---|---|---|
| 1 | Pinnacle account balance API — Is there a GET /v1/client/balance endpoint? | Yes. GET /v1/client/balance exists in Customer API. Returns: availableBalance (number, amount available for betting in account currency), outstandingTransactions (number, sum of unsettled bet amounts), givenCredit (number, total credit limit), currency (string, e.g. "USD"). All fields are required. This enables real-time credit monitoring — call this endpoint periodically (fair use: every 60s) instead of calculating exposure from orders. availableBalance = givenCredit - outstandingTransactions - realizedLosses. | RESOLVED |
| 2 | Pinnacle's winLoss field precision — How many decimal places? | IEEE 754 double-precision float (type: number, format: double in OpenAPI spec). No fixed decimal places — represents up to ~15-17 significant digits. Practically, Pinnacle stakes and P&L are in USD with cents precision. Our Decimal(18,2) is compatible — round winLoss to 2 decimal places when ingesting. Example: a $20 bet at 2.47 odds → winLoss could be 29.40 (won) or -20.00 (lost). | RESOLVED |
| 3 | Concurrent bet placement — Can two bets be placed simultaneously? | Yes, with caveats. Two POST /v2/bets/place calls CAN be in-flight simultaneously with different uniqueRequestIds on different lines/events. However, two concurrent bets on the same line may trigger RESUBMIT_REQUEST error ("more than one place bet request at the same [time] on the same line"). Mitigation: (a) each request uses a unique uniqueRequestId for deduplication, (b) DUPLICATE_UNIQUE_REQUEST_ID error prevents replays, (c) our rate limiter at 3 req/s naturally serializes most requests. For our use case (platform placing on behalf of different users), concurrent placement on different events is safe. Same-event concurrent placement should be serialized per-event in PinnacleClient. | RESOLVED |
Appendix A: Order Lifecycle Comparison
BETFAIR ORDER LIFECYCLE:
pending → [exchange submission] → accepted → [settlement] → settled
pending → [exchange failure] → cancelled (refund)
pending → [user cancels] → cancelled (refund)
PINNACLE ORDER LIFECYCLE:
pending → [exchange submission] → submitted (PENDING_ACCEPTANCE) → accepted → settled
pending → [exchange submission] → submitted (PENDING_ACCEPTANCE) → declined (NOT_ACCEPTED, refund)
pending → [exchange submission] → accepted (instant accept) → settled
pending → [exchange failure] → cancelled (refund)
submitted → [timeout > 30min] → declined (refund)
Appendix B: Files Modified for Pinnacle Credit Integration
No files need modification for the accounting model itself. The changes are in the PAL adapter layer:
| File | Change | Accounting Impact |
|---|---|---|
orderService.ts | Set bookmaker dynamically from PAL routing ('pinnacle' or 'betfair-ex'). Lay bet validation for Pinnacle. Redis distributed lock for exposure guard. Transaction description includes provider name + metadata. | Correct provider tracking |
pinnacleSettlementJob.ts (new) | Three polling jobs: settled check (60s), pending resolution (15s), running sync (30s). Maps 'push' → 'void'. | Calls existing settleOrderFromBetsApi() |
exchanges/index.ts | Register Pinnacle adapter conditionally, sport routing configs, failover when multi-provider | PAL infrastructure |
config/index.ts | Add pinnacle config block (enabled, username, password, baseUrl) | Configuration |
services/redis.ts | acquireRedisLock() + releaseRedisLock() helpers (SET NX EX + Lua CAS) | Used by exposure guard |
services/providerExposureService.ts | Exposure guard with 80% warn / 90% hard-block, Redis-cached (30s TTL) | Platform risk management |
Zero changes to: settlement.ts, agentSettlementService.ts, commissionService.ts, marginRevenueService.ts, ledger*, Transaction model, User model.