Skip to main content

Pinnacle Credit Integration — Dual-Provider Accounting Model

Research document for integrating Pinnacle credits into the existing Hannibal point system and accounting architecture.

References:


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.bookmaker field distinguishes 'betfair-ex' vs 'pinnacle' (already exists in schema)
  • routingVenue remains 'exchange' for Pinnacle (Option B — see §3.4). bookmaker field distinguishes provider.
  • Pinnacle settlement job polls per-order via GET /v3/bets?betIds=X (simpler than planned /v3/fixtures/settled approach)
  • PENDING_ACCEPTANCE maps to Order.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 split
  • Transaction records — same types, same format
  • LedgerEntry double-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.bookmaker field exists (defaults 'pinnacle' in schema, overridden to 'betfair-ex' in code)
  • Order.routingVenue exists: '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:

ProviderOrder.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 orders
  • routingVenue = 'exchange' continues to mean Betfair
  • Simple, backward-compatible

Option B: Keep 'exchange' for all providers, use bookmaker to distinguish

  • routingVenue = 'exchange' + bookmaker = 'pinnacle' for Pinnacle
  • routingVenue = '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 bookmaker field 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)

StepBetfairPinnacleDifference
Debit at placementbalancePoints -= stakebalancePoints -= stakeNone
Exchange submissionPAL → Betfair (USD)PAL → Pinnacle (USD)Different provider
On failureFull refundFull refundNone
On successStatus = acceptedStatus = accepted or submittedPENDING_ACCEPTANCE
Settlement creditbalancePoints += totalCreditbalancePoints += totalCreditNone

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_PERCENT env var, default 0). 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/odds or /v2/line)
  • Order.odds = display odds shown to user (after Hannibal margin applied)
  • Order.placedOdds = actual Pinnacle price at placement time (from /v2/line)
  • MarginRecord calculation 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:

  1. Poll Pinnacle API for settled bets
  2. Map Pinnacle status → our settlement result enum
  3. Call the existing settleOrderFromBetsApi() method

5.4 Pinnacle Settlement Statuses Mapping

Pinnacle betStatus2Hannibal settlementResultBalance 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 winLoss field 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:

  1. Detect re-settlement (period.status = 2 or 4)
  2. Reverse the original settlement (debit the credit, or credit the debit)
  3. Apply new settlement
  4. 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, betId assigned
  • NOT_ACCEPTED → bet rejected, funds should be refunded
  • Timeout (>30 min) → treat as rejected (Pinnacle stops returning uniqueRequestId results 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 StatusHannibal Order.statusNotes
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:

OperationTransaction TypeExists?
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:

AccountUsagePinnacle Impact
platform/stakesDebit on bet placedSame
platform/payoutsCredit on settlementSame
user/{userId}Per-user accountSame
commission/platformCommission incomeSame
margin/platformMargin incomeSame

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:

  1. How much of that $1K is currently committed (open bets)?
  2. What's the platform's net P&L with Pinnacle?
  3. 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/balance synced 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:

FieldCurrent StatePinnacle Usage
Order.bookmakerExists, defaults 'pinnacle'Set to 'pinnacle' for Pinnacle orders
Order.routingVenue'bbook' / 'exchange' / 'split'Use 'exchange' for Pinnacle (bookmaker distinguishes)
Order.betsApiOrderIdBigInt, stores Betfair order IDStore Pinnacle betId
Order.requestUuidUUID, used for deduplicationStore Pinnacle uniqueRequestId
Order.placedOddsActual exchange odds at placementPinnacle's confirmed price
Order.trueOddsOriginal odds before marginPinnacle's offered odds
Order.sportIdInt, optionalUsed for settlement job sport filtering
LedgerEntry.metadataJsonCan store provider info
Transaction.metadataJsonCan 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

#QuestionDecision
1Single balance vs per-provider?Single unified balance. Provider-agnostic.
2routingVenue for Pinnacle?Use 'exchange' + bookmaker = 'pinnacle' to distinguish
3Order status for PENDING_ACCEPTANCE?Use 'submitted' — distinguishes "sent to Pinnacle, awaiting acceptance" from "created but not yet sent"
4New transaction types?No — existing types sufficient
5New ledger account types?No — existing types sufficient
6Schema changes for MVP?None — existing fields cover all needs
7B-Book interaction?No change — B-Book is routing-layer only, provider-independent
8Margin model?Same approach — apply Hannibal margin on top of Pinnacle odds

Needs Decision

#QuestionOptionsRecommendation
1Pinnacle $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.
2Half-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
3Re-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
4Provider-level exposure model — Prisma model vs Redis vs on-demand(a) New Prisma model, (b) Redis cache, (c) On-demand queryDECIDED: (a) Prisma model. Persistent, auditable, atomic updates. Syncs availableBalance from GET /v1/client/balance every 60s. See Section 8.2 and 9.2.
5Pinnacle 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.
6PENDING_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

#QuestionAnswerStatus
1Pinnacle 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
2Pinnacle'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
3Concurrent 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:

FileChangeAccounting Impact
orderService.tsSet 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.tsRegister Pinnacle adapter conditionally, sport routing configs, failover when multi-providerPAL infrastructure
config/index.tsAdd pinnacle config block (enabled, username, password, baseUrl)Configuration
services/redis.tsacquireRedisLock() + releaseRedisLock() helpers (SET NX EX + Lua CAS)Used by exposure guard
services/providerExposureService.tsExposure 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.