Skip to content

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 with converted_cents, rate, rate_date
  • convert_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:386 to set patient_currency for 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 includes cost_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)