Multicurrency Infrastructure -- Steer Document¶
Date: 2026-04-10
Author: Srikanth Donthi (CPO/CTO) + Claude Code
Status: Design Complete -- Not Yet Implemented
Companion spec: multicurrency-feature.md
Gap analysis readiness: 25% (26 gaps identified across 7 domains)
1. Problem Statement¶
Curaway operates across 7 corridors (US, UK, UAE, India, Turkey, Thailand, Spain) but all monetary handling is locked to USD. The platform stores, scores, and displays costs as if every provider bills in US dollars. This is incorrect for 5 of the 7 corridors and makes the matching engine's cost scoring unreliable.
What is broken today¶
Provider costs are not real costs. The Provider.cost_index field is a relative multiplier (0.3 = cheap, 3.0 = expensive) -- not an actual price. Provider.procedure_costs stores min_usd_cents / max_usd_cents, but these are USD-converted approximations, not the provider's actual billing currency. An Indian hospital that charges INR 5,00,000 has its cost stored as 600000 (USD cents) -- a conversion that happened at seed time with an unknown rate. When the exchange rate changes, the stored value is stale but never updated.
Patient budget is extracted but never persisted. The chat extractor (app/services/chat_extractor.py) can parse budget from conversation ("my budget is around $8,000" or "I can spend up to 6 lakh rupees"), but there is no budget_cents or budget_currency field on the Case model. The extracted budget floats in the LLM context and is lost between sessions. The matching engine has no budget-based filtering gate.
Matching engine cost scoring is currency-blind. WeightedScoringV1._score_cost() and GraphEnhancedWeightedV1._score_cost() both score cost in a single dimension -- either cost_index (a unitless multiplier) or raw cost_min_cents / cost_max_cents (assumed USD). Neither method converts costs to the patient's currency before scoring. A patient with an INR budget is scored against USD cost ranges. The cost weight (0.15) is meaningless without normalization.
Frontend hardcodes "$". The match results explanation in matching_engine.py:614 builds strings like "$6,000--$8,000 USD". The convert_procedure_costs() function in currency_service.py adds min_converted / max_converted to the response, but these are never used by the frontend for display. There is no formatCurrency() utility.
No exchange rate audit trail. currency_service.py caches rates in memory (_rate_cache) with a 24-hour TTL. When a rate is used for a conversion, it is not recorded anywhere. If a patient was quoted INR 5,00,000 on Monday and the rate shifted by Tuesday, there is no way to determine which rate was used for the original quote.
No provider base currency. The Provider model has no base_currency field. All 42 providers are implicitly USD, even those in India (INR), Turkey (TRY), Thailand (THB), Spain (EUR), and UAE (AED). The provider onboarding flow cannot capture the currency in which the provider actually bills.
Current state by the numbers¶
| Area | Status | Gap |
|---|---|---|
Patient.preferred_currency |
Exists (VARCHAR(3), default "USD") | Never populated from conversation, only from profile |
Provider.base_currency |
Missing | No field exists |
Case.budget_cents / Case.budget_currency |
Missing | Budget extracted by LLM but not persisted |
currency_service.py |
Functional | Frankfurter API + 9 pegged rates, 24hr in-memory cache, cents-based. No DB persistence of rates. |
convert_procedure_costs() |
Functional | Converts at response layer (display-time), not at scoring time |
| Exchange rate audit table | Missing | No exchange_rates table |
| Matching engine cost normalization | Missing | _score_cost() uses raw cost_index or USD cents, no currency conversion |
Frontend formatCurrency() |
Missing | No utility; explanations hardcode "$" |
| Payment models (Transaction, Invoice) | Missing | No payment infrastructure |
| Tax/VAT configuration | Missing | No tax_config table |
| KYC/AML compliance | Missing | No cross-border compliance layer |
2. Why Multicurrency Now¶
Three active workstreams are blocked or degraded by the lack of currency infrastructure:
2a. Matching engine cost scoring is meaningless without normalization¶
The cost dimension carries a 0.15 weight in both WeightedScoringV1 and GraphEnhancedWeightedV1. Today, _score_cost() normalizes cost_index (a unitless multiplier) on a 0.1--3.0 scale. For graph-enhanced scoring, it normalizes raw USD cents on a $3k--$60k scale. Neither approach accounts for the patient's currency or budget. A patient who says "my budget is 6 lakh" (INR 600,000 ~ USD 7,200) gets the same cost score as a patient who says "my budget is $15,000." The cost dimension is noise, not signal.
2b. Payments cannot launch without currency infrastructure¶
The payment service (identified as a Phase 2 microservice extraction candidate in CLAUDE.md Section 22) requires: a Transaction model with amount_cents + currency, gateway routing per corridor (Stripe for USD/EUR/GBP, Razorpay for INR), exchange rate snapshots per transaction, and platform fee calculation. None of this can be designed without first establishing the currency foundation -- provider base currency, exchange rate audit table, and cost normalization.
2c. Provider onboarding in non-USD markets requires base_currency¶
Onboarding a new Indian hospital requires knowing that their costs are in INR, not USD. The provider portal (planned) needs a base_currency field so that costs entered by the provider are stored in their local currency and converted at query time. Without this, every provider's costs must be manually converted to USD at seed time, introducing stale rates and manual error.
2d. The codebase already has 60% of the plumbing¶
currency_service.py is functional -- it fetches from Frankfurter, handles 9 pegged currencies, caches for 24 hours, and converts cents-based amounts. Patient.preferred_currency exists on the model. convert_procedure_costs() already adds converted amounts to match responses. The gap is in persistence (no DB storage of rates or budgets), scoring (conversion at display-time, not scoring-time), and frontend (no formatCurrency() utility).
3. What Exists Already¶
3a. app/services/currency_service.py¶
- Frankfurter API integration (free, no API key, ECB data)
- 9 pegged/fixed rates for currencies not covered by ECB (AED, SAR, QAR, BHD, OMR, JOD, HKD, KWD, EGP, PKR)
- In-memory cache with 24hr TTL (
_rate_cache,_cache_timestamps) convert(amount_cents, from_currency, to_currency)-- returns dict withconverted_cents,rate,rate_dateconvert_procedure_costs(procedure_costs, to_currency)-- batch conversion for provider cost JSONB- Cross-rate computation for pegged-to-pegged conversions (e.g., AED to SAR via USD)
- Graceful fallback to stale cache when Frankfurter is unreachable
3b. Patient.preferred_currency¶
- Field exists:
VARCHAR(3), default"USD", not nullable - Used in
match_service.py:386to setpatient_currencyfor response conversion - Never populated from chat conversation -- only set if the patient profile is explicitly updated
3c. Provider.procedure_costs (JSONB)¶
- Structure:
{"knee_replacement": {"min_usd_cents": 600000, "max_usd_cents": 1200000}} - All values stored as USD cents regardless of provider's actual billing currency
- Used by
convert_procedure_costs()in match response layer - Used by
GraphEnhancedWeightedV1._score_cost()when graph data includescost_min_cents/cost_max_cents
3d. Provider.cost_index¶
- Relative multiplier:
Float, default 1.0 - Scale: 0.3 (very cheap) to 3.0 (very expensive)
- Used by
WeightedScoringV1._score_cost()as the sole cost signal when graph data is unavailable - Not currency-aware -- it is a unitless comparison metric
3e. QStash scheduled task¶
- Exchange rate refresh runs daily at midnight (
0 0 * * *) - Force-refreshes Frankfurter rates for 8 base currencies
- Rates are cached in memory only -- no DB persistence
4. Design Principles¶
Principle 1: Store money as integer cents in the smallest unit¶
All monetary values are stored as INTEGER in the smallest currency unit: cents (USD, EUR, GBP), paise (INR), fils (AED), kurus (TRY), satang (THB). Never use FLOAT or DECIMAL for money. This prevents floating-point precision errors (e.g., 0.1 + 0.2 != 0.3).
Principle 2: Every monetary value has a currency code alongside it¶
No implied currency. Every column or field that stores money has a companion _currency column storing the ISO 4217 code. budget_cents is meaningless without budget_currency. procedure_costs JSONB must include currency metadata per entry.
Principle 3: Exchange rates are versioned¶
Every conversion records the rate used and the date. The exchange_rates table stores a row per rate per day, allowing reconstruction of any historical conversion. When a transaction is created, the exchange_rate_id links to the exact rate used.
Principle 4: Conversion happens at scoring time, not just display time¶
The matching engine converts provider costs to the patient's currency before computing cost scores. This ensures budget filtering and cost ranking are accurate. Display-time conversion (the current behavior) is kept as a secondary step for the response layer.
Principle 5: Frontend uses Intl.NumberFormat with patient locale¶
Currency display uses the browser's Intl.NumberFormat API, which handles symbol placement, digit grouping, and decimal separators correctly for every locale. Never hardcode "$", "Rs", or any currency symbol. The utility function takes (amountCents, currencyCode, locale?) and returns a formatted string.
Principle 6: Payment models are corridor-aware¶
Different gateways serve different corridors. Stripe handles USD, EUR, GBP. Razorpay handles INR. The payment service uses an adapter pattern -- a PaymentGateway interface with StripeGateway and RazorpayGateway implementations. Gateway selection is based on the transaction currency, not hardcoded per provider.
Principle 7: Tax is configurable per country/state¶
Tax rates, types (VAT, GST, sales tax), and applicability rules are stored in a tax_config table, not hardcoded. Each row specifies: country_code, state_code (nullable), tax_type, rate (as basis points), effective_date, and expires_at. This supports India's 18% GST, UK's 20% VAT, UAE's 5% VAT, Turkey's varying KDV rates, and US state-level sales tax.
5. Three-Tier Implementation Plan¶
Tier 1: Foundation (this spec)¶
The minimum infrastructure to make multicurrency functional in the matching engine and frontend.
| Component | Deliverable |
|---|---|
| Database | budget_cents + budget_currency on cases table |
| Database | base_currency on providers table |
| Database | exchange_rates table (audit trail) |
| Backend | exchange_rate.py model |
| Backend | currency_service.py enhanced with persist_rate() and get_historical_rate() |
| Backend | Matching engine _score_cost() converts to patient currency before scoring |
| Backend | Budget filtering gate in matching (skip providers outside budget range) |
| Backend | Chat extractor persists budget to case.budget_cents + case.budget_currency |
| Backend | case_orchestrator.py saves extracted budget to case |
| Frontend | formatCurrency.ts utility using Intl.NumberFormat |
| Frontend | All cost displays use formatCurrency() instead of hardcoded "$" |
Outcome: Patient sees costs in their currency. Matching engine scores costs correctly. Exchange rates are auditable.
Tier 2: Payment Infrastructure (scope only -- separate spec)¶
| Component | Deliverable |
|---|---|
| Database | transactions, invoices, provider_payouts, platform_fees tables |
| Backend | Payment gateway service (Stripe/Razorpay adapter pattern) |
| Backend | Commission tracking + platform fee calculation |
| Backend | Provider portal invoice view endpoints |
| Backend | Financial event tracking in events table |
| Integration | Stripe Connect for USD/EUR/GBP corridors |
| Integration | Razorpay for INR corridor |
Outcome: Platform can collect payment from patients and disburse to providers.
Tier 3: Compliance (scope only -- separate spec)¶
| Component | Deliverable |
|---|---|
| Database | tax_config table (country, state, tax_type, rate, effective_date) |
| Backend | Tax calculation service |
| Backend | KYC/AML verification integration |
| Backend | Cross-border regulatory audit trail |
| Backend | Financial reconciliation service |
| Compliance | FEMA (India), FCA (UK), CBUAE (UAE) regulatory mapping |
Outcome: Platform is compliant with tax and financial regulations in all corridors.
6. Key Design Decisions¶
Decision 1: Store provider costs in provider's local currency¶
Current: All procedure_costs values are in USD cents. An Indian hospital's INR 5,00,000 is stored as 600000 (USD cents at a point-in-time rate).
Proposed: Add base_currency to Provider model. procedure_costs JSONB schema changes from {"min_usd_cents": 600000} to {"min_cents": 5000000, "max_cents": 10000000, "currency": "INR"}. Existing USD data is migrated with base_currency = "USD".
Rationale: Storing in local currency avoids stale conversion at seed time. The exchange rate at query time is always current. Provider onboarding can accept costs in local currency.
Decision 2: Matching engine converts at scoring time¶
Current: _score_cost() uses cost_index or raw USD cents. convert_procedure_costs() runs at response time only.
Proposed: _score_cost() calls currency_service.convert() to normalize all provider costs to the patient's currency before computing the score. Budget filtering also uses converted amounts.
Rationale: Scoring in a common currency ensures the 0.15 cost weight produces meaningful rankings. A Turkish hospital at TRY 300,000 and an Indian hospital at INR 500,000 can be compared accurately only after conversion to the patient's currency.
Decision 3: Exchange rate snapshots per transaction¶
Current: Rates are in-memory only. No record of which rate was used for any conversion.
Proposed: exchange_rates table stores (from_currency, to_currency, rate, rate_date, source). Daily refresh populates this table. Each Transaction (Tier 2) links to an exchange_rate_id. Match results include rate_date for transparency.
Rationale: Auditability. A patient quoted AED 22,000 on Monday needs to see why the amount is AED 22,150 on Wednesday. Financial regulators in UAE (CBUAE) and UK (FCA) require transaction-level exchange rate records.
Decision 4: Platform fee as configurable percentage¶
Current: No platform fee model.
Proposed (Tier 2): Platform fee is a percentage of the procedure cost, configurable per service tier in platform_config (e.g., 10% standard, 8% premium, 12% basic). Stored as basis points (1000 = 10%).
Rationale: Different service tiers justify different fees. Basis points avoid floating-point arithmetic.
Decision 5: Tax via configuration table, not hardcoded¶
Current: No tax handling.
Proposed (Tier 3): tax_config table with columns: country_code, state_code, tax_type (VAT, GST, sales_tax), rate_bps (basis points), effective_date, expires_at. Tax is computed at invoice generation, not at quote time.
Rationale: Tax rates change (India GST changed 3 times since 2017). Hardcoded rates become stale. Configuration table supports historical rates and future-dated changes.
7. Connection to Microservices Readiness¶
The payment service is identified in CLAUDE.md Section 22 as a natural extraction candidate for the Phase 2 microservices plan (post-seed, pre-Series A). Multicurrency Tier 2 tables (transactions, invoices, provider_payouts, platform_fees) become the payment service's database when extracted. The adapter pattern (Stripe/Razorpay) becomes the payment service's external integration layer.
Extraction boundary: - Payment service owns: transactions, invoices, payouts, gateway integration, exchange rate snapshots for transactions - Core API owns: patient budget, provider base currency, exchange rate refresh, cost scoring - Interface: Core API calls payment service via HTTP to create transactions, check payment status, generate invoices
This aligns with the modular monolith principle -- build as isolated modules within the monolith, extract when scaling demands it.
8. Risks¶
8a. Exchange rate volatility between quote and payment¶
A patient is quoted INR 5,00,000 on Day 1. By Day 14 (payment day), the rate has shifted and the converted amount is INR 5,15,000. Mitigation: Tier 2 introduces rate locking -- at booking time, the exchange rate is frozen for 72 hours. After 72 hours, the patient sees an updated quote.
8b. Regulatory complexity per corridor¶
Each corridor has different financial regulations. India requires FEMA compliance for inward remittances. UAE requires CBUAE authorization for payment processing. UK requires FCA registration for payment services. Turkey has TCMB reporting requirements. Mitigation: Tier 3 maps each corridor's requirements. MVP launches with USD and INR only (Stripe + Razorpay).
8c. Currency conversion precision loss¶
Integer arithmetic avoids floating-point errors, but rounding at conversion boundaries can accumulate. Converting USD to INR and back may not yield the original amount. Mitigation: all conversions use round() at the final step, not intermediate steps. The exchange_rates table stores rates to 6 decimal places.
8d. Provider cost data migration¶
42 providers have procedure_costs stored as min_usd_cents / max_usd_cents. Migrating to local currency requires knowing the original currency (which was not recorded at seed time). Mitigation: for existing data, keep as USD with base_currency = "USD". Future provider onboarding captures base_currency at entry.
9. Success Criteria¶
| Criterion | Measurement |
|---|---|
| Patient sees costs in their currency throughout the flow | Match results display uses formatCurrency() with patient's preferred_currency. No hardcoded "$" in any patient-facing surface. |
| Matching engine filters by budget correctly | A patient with budget INR 8,00,000 does not see providers whose minimum cost exceeds INR 8,00,000 after conversion. |
| Cost scoring is currency-normalized | _score_cost() converts all provider costs to a common currency (patient's) before computing the 0--1 score. |
| Exchange rate is auditable | Every rate used in a conversion is stored in exchange_rates with source, date, and timestamp. |
| Payment can be collected in at least USD and INR | Tier 2 delivers Stripe (USD) + Razorpay (INR) integration with adapter pattern. |
| Provider onboarding accepts local currency | New providers can set base_currency and enter costs in their local currency. |
10. References¶
| Document | Relevance |
|---|---|
app/services/currency_service.py |
Existing currency conversion service (Frankfurter + pegged rates) |
app/services/matching_engine.py |
Cost scoring functions to be modified (_score_cost()) |
app/services/match_service.py |
Response layer that calls convert_procedure_costs() |
app/models/patient.py |
preferred_currency field (exists, underutilized) |
app/models/provider.py |
cost_index + procedure_costs fields (no base_currency) |
app/models/case.py |
No budget fields (gap) |
| CLAUDE.md Section 22 | Microservices extraction plan (payment as Phase 2 candidate) |
| CLAUDE.md Section 4.6 | Multicurrency architecture summary |
docs/specs/dao-layer-feature.md |
DAO layer spec (repositories for new models) |