Currency Conversion & FP System — Specification
RETIRED HISTORICAL PROPOSAL — NOT THE CURRENT IMPLEMENTATION CONTRACT. Hannibal no longer uses FP/Forsyt Points as an accounting denomination, and
Provider.exchangeRate,fpPerUnit, andunitPerFpare not current schema fields. Use CONTEXT.md, financial-security.md,backend/prisma/schema.prisma(Order,Transaction,LedgerEntry,CurrencyRate), andbackend/src/services/currencyService.tsfor the current contract. The body below is retained only as decision history and must not guide implementation.
1. Overview
Forsyt Points (FP) is the sole internal unit across the entire platform. All balances, bets, limits, P&L, and settlements operate in FP. Real-currency conversion happens at exactly two boundaries:
- Inbound: Provider deposit (HKD/GBP/USD) → FP (when admin adds/updates provider balance)
- Outbound: FP → Provider currency (when a hedge bet is placed at Betfair/Seven)
Everything in between — player views, agent views, cascade, B-Book, ledger, Take, settlement — is FP.
┌─────────────────────────────────────────────────────────┐
│ PROVIDER LAYER │
│ Betfair (GBP) Seven (HKD) Pinnacle (USD) │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ CONVERSION BOUNDARY (Global Rates) │ │
│ │ 1 GBP = 25 FP 1 HKD = 2.5 FP ... │ │
│ └────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ FORSYT TREASURY (FP) │ │
│ │ FP Pool = Σ(provider deposits × rate) │ │
│ │ + B-Book headroom │ │
│ └────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Agent A │ │ Agent B │ │ Agent C │ │
│ │ (INR) │ │ (HKD) │ │ (USD) │ │
│ │ sees: FP │ │ sees: FP │ │ sees: FP │ │
│ │ settles │ │ settles │ │ settles │ │
│ │ in INR │ │ in HKD │ │ in USD │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ [Players] [Players] [Players] │
│ see FP only see FP only see FP only │
└─────────────────────────────────────────────────────────┘
2. Global Rate Table
Design
A single global rate table managed by admin. Not per-provider — one canonical rate per currency.
| Currency | FP per 1 unit | 1 FP = X units |
|---|---|---|
| HKD | 2.5 | 0.4 |
| INR | 0.25 | 4.0 |
| USD | 20 | 0.05 |
| GBP | 25 | 0.04 |
Rates are illustrative. Admin sets the actual values.
Key Properties
- Admin-managed, fixed rates. Not live forex. Changed manually when business decides.
- Single source of truth. Replaces the current per-provider
exchangeRatederivation inCurrencyService. - Immutable during a settlement period. Rate changes take effect at next period boundary to avoid mid-period accounting inconsistencies.
- Audit trail. Every rate change is logged with timestamp, old rate, new rate, changed_by.
Schema: CurrencyRate
model CurrencyRate {
id String @id @default(uuid())
currencyCode String @unique @map("currency_code") // "HKD", "INR", "USD", "GBP"
fpPerUnit Decimal @map("fp_per_unit") @db.Decimal(18, 6) // 1 HKD = X FP
unitPerFp Decimal @map("unit_per_fp") @db.Decimal(18, 6) // 1 FP = X HKD (denormalized inverse)
isActive Boolean @default(true) @map("is_active")
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
createdAt DateTime @default(now()) @map("created_at")
@@index([currencyCode])
@@map("currency_rates")
}
model CurrencyRateHistory {
id String @id @default(uuid())
currencyCode String @map("currency_code")
oldFpPerUnit Decimal @map("old_fp_per_unit") @db.Decimal(18, 6)
newFpPerUnit Decimal @map("new_fp_per_unit") @db.Decimal(18, 6)
changedBy String @map("changed_by")
changedAt DateTime @default(now()) @map("changed_at")
reason String?
@@index([currencyCode, changedAt])
@@map("currency_rate_history")
}
Relationship to Provider.exchangeRate
The existing Provider.exchangeRate field becomes derived from the global rate table:
Provider.exchangeRate = CurrencyRate.fpPerUnit WHERE currencyCode = Provider.currency
When admin updates the global rate for HKD, all HKD-denominated providers automatically use the new rate. The Provider.exchangeRate column can either:
- Option A: Be removed entirely, with
CurrencyServicereading fromCurrencyRatedirectly. - Option B: Be kept as a denormalized cache, synced on rate change.
Recommendation: Option A — remove the per-provider rate. It's the source of the current "multiple providers with different rates for same currency" warning in CurrencyService. One currency = one rate. Clean.
3. Agent Currency Assignment
Design
Each agent is onboarded with a settlement currency — the real-world currency they deal in with Forsyt. This determines:
- How their settlement obligations are expressed in reports (FP amount × rate = INR/HKD amount)
- What currency the optional dashboard conversion shows
It does NOT affect any internal accounting. Internal accounting is 100% FP.
Schema Change: Agent
// Add to Agent model:
settlementCurrency String @default("INR") @map("settlement_currency") // "INR", "HKD", "USD"
Inheritance Rule
- Agent's settlement currency applies to the entire downline (sub-agents + players).
- Sub-agents can have their own settlement currency only if they deal with Forsyt independently (rare).
- Default: inherit from parent. Override: explicit set on agent creation.
Agent Dashboard — Optional Currency Toggle
Players always see FP. Agents see FP by default, with an optional toggle to view amounts in their settlement currency:
Balance: 5,000 FP ← default view
Balance: 5,000 FP (₹20,000) ← toggled on (FP × unitPerFp for INR)
This is a display-only conversion. No data changes. Frontend multiplies amount × currencyRate.unitPerFp for the agent's settlementCurrency.
4. FP Pool & Generation
Current System
Treasury FP = Σ(provider.manualBalance × provider.exchangeRate)
FP is hard-capped by provider deposits. If deposits run out, no more FP can be allocated.
New System: Provider Deposits + B-Book Headroom
Available FP = Provider Pool + B-Book Headroom
Provider Pool = Σ(provider.manualBalance × currencyRate.fpPerUnit) ← real money backing
B-Book Headroom = Forsyt's risk appetite (configurable or infinite) ← virtual liquidity
How it works
- Provider deposits generate FP at the global rate. This is real-money-backed FP.
- When provider balances are exhausted (all hedge capacity used), Forsyt's B-Book itself becomes the provider. Bets are 100% retained by the B-Book cascade — no hedge needed.
- FP is NOT limited to deposits. It's limited to
provider_pool + bbook_headroom. Ifbbook_headroom = ∞, FP is effectively unlimited (Forsyt takes all risk). - Error condition: Only when BOTH provider pool is exhausted AND B-Book headroom is breached does the player see "insufficient liquidity."
Schema: Platform-level B-Book headroom config
PlatformSettings key: "bbook_headroom"
value: { "amount": 1000000, "unlimited": false }
If unlimited: true, the system never rejects bets due to FP shortage (only agent/player credit limits apply).
Treasury Balance Calculation (Updated)
Treasury.balancePoints = Provider Pool + B-Book Headroom - Downline Allocation
When a provider deposit increases → treasury grows. When B-Book headroom is raised → treasury grows. When admin allocates to agent → treasury shrinks.
Bet Placement Decision Tree
Player places bet (stake in FP)
│
├─ V3 Cascade routes bet through agent hierarchy
│ Each level retains their portion (B-Book)
│ Remainder = hedge amount
│
├─ IF hedge amount > 0:
│ ├─ Check provider balance (in provider currency)
│ │ hedge_in_currency = hedge_fp × currencyRate.unitPerFp
│ │
│ ├─ IF provider has sufficient balance:
│ │ └─ Place hedge at provider ✓
│ │
│ ├─ IF provider balance insufficient:
│ │ ├─ Check B-Book headroom
│ │ ├─ IF headroom available:
│ │ │ └─ Absorb remaining hedge into Forsyt B-Book ✓
│ │ │ (platform becomes the counterparty)
│ │ │
│ │ └─ IF headroom exhausted:
│ │ └─ REJECT bet with "insufficient liquidity" ✗
│ │
├─ IF hedge amount = 0 (100% retained by B-Book):
│ └─ Accept bet, no provider interaction needed ✓
5. Conversion Points — Where & When
| Event | Conversion | Direction | Rate Source |
|---|---|---|---|
| Admin adds provider deposit | Provider currency → FP | Inbound | CurrencyRate.fpPerUnit |
| Admin updates provider balance | Delta in provider currency → FP delta | Inbound | CurrencyRate.fpPerUnit |
| Hedge bet placed at provider | FP → Provider currency | Outbound | CurrencyRate.unitPerFp |
| Hedge bet settled (payout from provider) | Provider currency → FP | Inbound | CurrencyRate.fpPerUnit |
| Agent dashboard (optional display) | FP → Agent's settlement currency | Display only | CurrencyRate.unitPerFp |
| Settlement report generation | FP → Agent's settlement currency | Report only | CurrencyRate.unitPerFp at period snapshot |
| Player places bet | None | — | Everything in FP |
| Player sees balance/limits | None | — | Everything in FP |
| Cascade/B-Book routing | None | — | Everything in FP |
| Ledger entries | None | — | Everything in FP |
| Take calculation | None | — | Everything in FP |
| Commission calculation | None | — | Everything in FP |
Rule: conversion happens at system boundaries only. Internal = FP. Always.
6. Settlement & Currency
Agent Settlement Flow (Updated)
- Settlement period closes → Takes frozen (in FP)
- For each agent, compute settlement obligation:
settlement_fp = currentTake (from TakeBalance)
settlement_currency = settlement_fp × currencyRate.unitPerFp(agent.settlementCurrency) - Settlement report shows both: FP amount + currency equivalent
- Transfer & Settle operates in FP (internal). The currency amount is for real-world wire/payment reference.
Rate Snapshot at Settlement
When a settlement period transitions to grace:
- Snapshot the current
CurrencyRatevalues alongside the Take snapshots - This ensures the FP → currency conversion used in settlement reports matches the rate at period close
- Prevents disputes from rate changes during grace period
// Add to PeriodSettlementSnapshot or create new model:
model SettlementRateSnapshot {
id String @id @default(uuid())
periodId String @map("period_id")
currencyCode String @map("currency_code")
fpPerUnit Decimal @map("fp_per_unit") @db.Decimal(18, 6)
unitPerFp Decimal @map("unit_per_fp") @db.Decimal(18, 6)
snapshotAt DateTime @default(now()) @map("snapshot_at")
period SettlementPeriod @relation(fields: [periodId], references: [id])
@@unique([periodId, currencyCode])
@@map("settlement_rate_snapshots")
}
7. API Changes
New Endpoints
GET /admin/currency-rates — List all currency rates
PUT /admin/currency-rates/:code — Update rate for a currency
POST /admin/currency-rates — Add new currency
GET /admin/currency-rates/history — Audit trail of rate changes
Modified Endpoints
POST /admin/providers — Remove exchangeRate from input; derived from global rate
PATCH /admin/providers/:id — Same; rate comes from CurrencyRate table
GET /admin/providers/total-credit — Uses global rates for computation
POST /agents — Add optional settlement_currency field
PATCH /agents/:id — Allow updating settlement_currency
GET /agents/:id/dashboard — Include optional currency conversion in response
GET /agents/:id/settlement-report — Include currency-equivalent amounts
Agent Creation (Updated)
POST /agents
{
"name": "Agent Mumbai",
"code": "AGT-MUM-001",
"settlement_currency": "INR", // NEW — defaults to "INR" if omitted
...
}
8. CurrencyService Refactor
Current
- Reads rates from
Provider.exchangeRate(per-provider) - Multiple providers can disagree on rates for the same currency (logged as warning)
New
- Reads from
CurrencyRatetable (single source of truth) - Provider.currency → lookup in CurrencyRate → get rate
- No ambiguity, no warnings
class CurrencyService {
// Rate cache: currency_code → { fpPerUnit, unitPerFp }
private rates = new Map<string, { fpPerUnit: Decimal; unitPerFp: Decimal }>();
// Convert FP to any supported currency
fpToCurrency(fp: number, currencyCode: string): number { ... }
// Convert currency to FP
currencyToFp(amount: number, currencyCode: string): number { ... }
// Convert FP to provider's operating currency (for hedge placement)
fpToProvider(fp: number, providerId: string): number { ... }
// Convert provider currency to FP (for deposit/settlement inbound)
providerToFp(amount: number, providerId: string): number { ... }
// Get rate for agent's settlement currency display
getAgentDisplayRate(agentId: string): { currencyCode: string; unitPerFp: number } { ... }
}
9. Frontend Changes
Player-Facing
No changes. Everything already in FP. No currency symbols, no conversion.
Agent Dashboard (Optional Enhancement)
Add a toggle in agent settings:
Display Currency: FP (default) | INR | HKD | USD
When a non-FP currency is selected, amounts show as:
Balance: 5,000 FP (₹20,000)
P&L Today: +200 FP (+₹800)
Credit Limit: 10,000 FP (₹40,000)
Implementation: frontend-only multiplication using rate fetched from /admin/currency-rates. No backend changes for display.
Admin Panel
New Currency Rates section in Settings:
┌─────────────────────────────────────────────┐
│ Global Currency Rates │
│ │
│ Currency 1 Unit = FP Last Updated │
│ ───────── ────────── ───────────── │
│ HKD 2.50 FP 2026-03-15 │
│ INR 0.25 FP 2026-03-15 │
│ USD 20.00 FP 2026-03-15 │
│ GBP 25.00 FP 2026-03-15 │
│ │
│ [+ Add Currency] │
└─────────────────────────────────────────────┘
Provider creation form: remove exchangeRate input. Show derived rate from global table based on selected currency.
10. Migration Path
Phase 1: Global Rate Table (No Breaking Changes)
- Create
CurrencyRatetable - Seed with rates derived from existing
Provider.exchangeRatevalues - Update
CurrencyServiceto read fromCurrencyRateinstead ofProvider - Keep
Provider.exchangeRateas synced denormalized field (backwards compat) - Add admin UI for rate management
- Add
CurrencyRateHistoryfor audit
Phase 2: Agent Currency
- Add
settlementCurrencytoAgentmodel - Backfill existing agents (default: "INR" or ask admin)
- Add currency to agent creation flow
- Add optional currency toggle to agent dashboard
Phase 3: B-Book Headroom
- Add
bbook_headroomtoPlatformSettings - Update treasury calculation to include headroom
- Update bet placement to fall back to B-Book when provider balance exhausted
- Add headroom config to admin UI
Phase 4: Settlement Currency Reports
- Add
SettlementRateSnapshot - Snapshot rates at period close
- Include currency equivalents in settlement reports
- Agent settlement view shows both FP and local currency
11. Invariants & Safety
- All internal arithmetic is FP. No currency mixing in ledger, Take, cascade, or balance calculations.
- Rates are fixed and admin-controlled. No live forex risk.
- Rate changes are audited. Every change logged in
CurrencyRateHistory. - Settlement uses snapshot rates. No mid-period rate drift affecting settlements.
- Provider.exchangeRate is derived, not independent. One currency = one rate = no conflicts.
- B-Book headroom is configurable. Forsyt controls risk appetite. Can be infinite.
- FP = 0 decimals in player UI. Integer display. Backend uses Decimal(18,4) for precision.
- Conversion at boundaries only. If you're converting mid-pipeline, you're doing it wrong.
12. Edge Cases
| Scenario | Handling |
|---|---|
| Rate changed mid-settlement-period | Takes effect next period. Current period uses snapshot. |
| Provider in currency with no global rate | Reject provider creation. Currency must exist in CurrencyRate first. |
| Agent with no settlement currency set | Default to "INR". Migration backfills existing agents. |
| B-Book headroom exhausted + no provider balance | Bet rejected: "Insufficient platform liquidity". Player sees generic error. |
| Two providers same currency, different manual rates | Impossible — global rate table means one rate per currency. Provider only stores manualBalance. |
| Agent wants to change settlement currency | Admin can update. No effect on internal accounting (FP). Only affects reports. |
| Rate = 0 for a currency | Rejected at API level. fpPerUnit must be > 0. |
13. Example: End-to-End Flow
Setup
- Global rates: 1 HKD = 2.5 FP, 1 INR = 0.25 FP
- Provider "Betfair" (GBP): manualBalance = 10,000 GBP, rate = 25 FP/GBP → 250,000 FP
- Provider "Seven" (HKD): manualBalance = 100,000 HKD, rate = 2.5 FP/HKD → 250,000 FP
- B-Book headroom: 500,000 FP
- Total Treasury: 250,000 + 250,000 + 500,000 = 1,000,000 FP
- Agent "Mumbai" (settlementCurrency: INR, creditLimit: 100,000 FP)
- Player under Agent Mumbai (creditLimit: 10,000 FP)
Bet Placement
- Player bets 1,000 FP on cricket match
- V3 Cascade: Agent Mumbai retains 30% (300 FP), forwards 70% (700 FP) to platform
- Platform retains 50% (350 FP via B-Book), forwards 50% (350 FP) to hedge
- Hedge: 350 FP → Betfair (GBP): 350 × 0.04 = 14 GBP placed at Betfair
- Player balance: 10,000 - 1,000 = 9,000 FP
Settlement (Player Wins, Payout = 2,000 FP)
- Betfair hedge settles: payout = 28 GBP → 28 × 25 = 700 FP inbound
- B-Book platform portion: -350 FP (platform pays from retained pool)
- B-Book agent portion: -300 FP (agent pays from retained pool)
- Player receives: +2,000 FP. Balance: 9,000 + 2,000 = 11,000 FP
- Agent Take updated. At settlement period close:
- Take in FP: -300 FP (agent lost on this bet)
- Settlement report: -300 FP = -₹1,200 (at 1 FP = ₹4 INR)