Skip to content

Multicurrency Infrastructure -- Feature Spec

Status: Implemented in Session 42 Companion steer: ai-steer/multicurrency-steer.md

This is the implementation plan for Tier 1 only (the foundation). Read the steer first -- it covers the problem statement, design principles, three-tier plan, key decisions, risks, and success criteria. Tiers 2 and 3 are outlined at the end of this document but are not detailed.


Overview

Tier 1 establishes the currency foundation: budget storage on cases, provider base currency, cost normalization in the matching engine, an exchange rate audit table, and a frontend formatCurrency() utility. No payment models, no tax tables, no gateway integration.

Estimated effort: 3--4 sessions Dependencies: None (builds on existing currency_service.py)


File-by-file change list

Database migrations

Migration 1: Case budget fields

Add two columns to the cases table.

ALTER TABLE cases ADD COLUMN budget_cents INTEGER;
ALTER TABLE cases ADD COLUMN budget_currency VARCHAR(3) DEFAULT 'USD';
  • budget_cents: patient's stated budget in smallest currency unit (nullable -- not every patient states a budget)
  • budget_currency: ISO 4217 code, defaults to "USD", not nullable once budget_cents is set

Alembic file: alembic/versions/xxxx_add_case_budget_fields.py

Migration 2: Provider base currency

Add one column to the providers table.

ALTER TABLE providers ADD COLUMN base_currency VARCHAR(3) DEFAULT 'USD' NOT NULL;
  • base_currency: the currency in which the provider bills. Default "USD" for backward compatibility with all 42 existing providers.

Alembic file: alembic/versions/xxxx_add_provider_base_currency.py

Migration 3: Exchange rates table

Create the exchange_rates audit table.

CREATE TABLE exchange_rates (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    from_currency VARCHAR(3) NOT NULL,
    to_currency VARCHAR(3) NOT NULL,
    rate DOUBLE PRECISION NOT NULL,
    rate_date DATE NOT NULL,
    source VARCHAR(50) NOT NULL DEFAULT 'frankfurter',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX ix_exchange_rates_pair_date
    ON exchange_rates (from_currency, to_currency, rate_date);
  • rate: stored to 6 decimal places (sufficient for all currency pairs)
  • rate_date: the date the rate was published by the source (not created_at)
  • source: "frankfurter" for ECB data, "pegged" for fixed-rate currencies, "manual" for overrides

Alembic file: alembic/versions/xxxx_create_exchange_rates_table.py

Migration 4: Procedure costs JSONB schema update

No DDL change -- procedure_costs remains a JSONB column on providers. The schema of the JSON content evolves. A data migration script updates existing rows.

Before (current):

{
  "knee_replacement": {
    "min_usd_cents": 600000,
    "max_usd_cents": 1200000
  }
}

After:

{
  "knee_replacement": {
    "min_cents": 600000,
    "max_cents": 1200000,
    "currency": "USD"
  }
}

The legacy keys (min_usd_cents, max_usd_cents) are retained alongside the new keys for backward compatibility during the transition. Once all consumers are updated, the legacy keys are dropped in a follow-up migration.

Alembic file: alembic/versions/xxxx_migrate_procedure_costs_schema.py (data migration, not DDL)


Backend changes

app/models/case.py

Change Detail Tier tag
Add budget_cents field Mapped[int \| None] = mapped_column(Integer) Sonnet
Add budget_currency field Mapped[str \| None] = mapped_column(String(3), default="USD") Sonnet

Two new SQLAlchemy mapped columns. No logic changes -- just field definitions.

app/models/provider.py

Change Detail Tier tag
Add base_currency field Mapped[str] = mapped_column(String(3), default="USD", nullable=False) Sonnet
Add import from sqlalchemy import ... String (already imported) Sonnet
Update docstring Add base_currency to the class docstring Sonnet

One new field. Existing cost_index and procedure_costs fields remain unchanged.

app/models/exchange_rate.py -- NEW

New SQLAlchemy model for the exchange_rates table.

class ExchangeRate(Base, UUIDPrimaryKeyMixin):
    __tablename__ = "exchange_rates"

    from_currency: Mapped[str] = mapped_column(String(3), nullable=False, index=True)
    to_currency: Mapped[str] = mapped_column(String(3), nullable=False, index=True)
    rate: Mapped[float] = mapped_column(Float, nullable=False)
    rate_date: Mapped[date] = mapped_column(Date, nullable=False)
    source: Mapped[str] = mapped_column(String(50), nullable=False, default="frankfurter")
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
Item Tier tag
Model definition Sonnet
Composite index on (from_currency, to_currency, rate_date) Sonnet

app/services/currency_service.py

Change Detail Tier tag
Add persist_rate() Saves a rate to the exchange_rates table. Called by the daily refresh job after fetching from Frankfurter. Sonnet
Add get_historical_rate() Queries exchange_rates table for a specific pair + date. Used for audit reconstruction. Sonnet
Modify _get_rates() After fetching from Frankfurter, call persist_rate() for each currency pair in the response. Best-effort -- DB failure does not block the in-memory cache update. Sonnet
Add convert_with_audit() Same as convert() but also returns exchange_rate_id (the UUID of the stored rate row). Used by match_service when building responses. Opus

Estimated new lines: ~60

app/services/matching_engine.py

Change Detail Tier tag
Modify MatchInput dataclass Add patient_budget_cents: int \| None = None, patient_currency: str = "USD" fields Sonnet
Modify WeightedScoringV1._score_cost() Convert provider costs to patient currency using currency_service.convert() before scoring. If provider has procedure_costs with the needed procedure, use converted min_cents/max_cents averaged. Fall back to cost_index if no procedure-specific cost. Opus
Modify GraphEnhancedWeightedV1._score_cost() Same conversion for graph-sourced cost_min_cents/cost_max_cents. Use provider's base_currency (or "USD" if graph data does not specify). Opus
Add _budget_filter() method Pre-filter: remove providers whose converted minimum cost exceeds patient_budget_cents * 1.2 (20% tolerance). If no budget set, skip filter. Opus
Modify score_providers() in both strategies Call _budget_filter() before scoring. Pass match_input.patient_currency to _score_cost(). Opus
Modify _build_explanation() in both strategies Use converted cost range in explanation text. No hardcoded "$" -- format as "{min}--{max} {currency}" (frontend handles symbol). Sonnet

Estimated modified lines: ~80

Key design note: The matching engine does not import the database session or call persist_rate() directly. It calls currency_service.convert() which returns the rate and rate_date. The match_service (orchestrator) is responsible for persisting audit data.

app/services/match_service.py

Change Detail Tier tag
Pass patient budget to MatchInput Read case.budget_cents and case.budget_currency (if available) and set on MatchInput. Sonnet
Pass patient_currency to MatchInput Already reads patient.preferred_currency at line 386. Now passes it to MatchInput constructor. Sonnet
Update response builder Include rate_date from currency conversion in ScoredProvider response for transparency. Sonnet

Estimated modified lines: ~15

app/services/chat_extractor.py

Change Detail Tier tag
Ensure budget extraction returns structured data The extractor already parses budget. Verify it returns {"budget_amount": 8000, "budget_currency": "USD"} or equivalent. If not, add budget parsing rules. Sonnet
Handle multi-currency budget expressions "6 lakh rupees" -> {"budget_amount": 600000, "budget_currency": "INR"}. "8000 dollars" -> {"budget_amount": 800000, "budget_currency": "USD"} (in cents). "50,000 AED" -> {"budget_amount": 5000000, "budget_currency": "AED"}. Opus

Estimated modified lines: ~20

config/prompts/base/chat_extractor_v2.yaml

Change Detail Tier tag
Enhance budget extraction rules Add examples for INR (lakh/crore notation), AED, EUR, GBP, TRY, THB. Instruct the LLM to always output budget_cents (integer, smallest unit) + budget_currency (ISO 4217). Sonnet
Add currency detection heuristics "$" -> USD, "Rs" / "INR" / "lakh" -> INR, "AED" / "dirhams" -> AED, "pounds" / "GBP" -> GBP, "euros" / "EUR" -> EUR, "lira" / "TL" / "TRY" -> TRY, "baht" / "THB" -> THB. Sonnet

app/agents/case_orchestrator.py

Change Detail Tier tag
Save extracted budget to case After chat extractor returns budget data, update case.budget_cents and case.budget_currency via case_service. Sonnet
Infer currency from patient country If patient says "8000" with no currency indicator, infer from patient.country_of_residence: USA -> USD, GBR -> GBP, ARE -> AED, IND -> INR, TUR -> TRY, THA -> THB, ESP -> EUR. Default: USD. Sonnet

Estimated modified lines: ~25


Frontend changes

All frontend files are in the curaway-health-navigator repository.

src/utils/formatCurrency.ts -- NEW

New utility function using the browser's Intl.NumberFormat API.

/**
 * Format an amount in cents to a locale-appropriate currency string.
 *
 * @param amountCents - Amount in smallest currency unit (cents/paise/fils)
 * @param currencyCode - ISO 4217 currency code (e.g., "USD", "INR", "AED")
 * @param locale - Optional BCP 47 locale (defaults to navigator.language)
 * @returns Formatted string (e.g., "$6,000.00", "Rs 5,00,000.00", "AED 22,038.00")
 */
export function formatCurrency(
  amountCents: number,
  currencyCode: string = "USD",
  locale?: string,
): string {
  const amount = amountCents / 100;
  const resolvedLocale = locale || navigator.language || "en-US";

  return new Intl.NumberFormat(resolvedLocale, {
    style: "currency",
    currency: currencyCode,
    minimumFractionDigits: 0,
    maximumFractionDigits: 0,
  }).format(amount);
}

/**
 * Format a cost range (min--max) in a locale-appropriate currency string.
 */
export function formatCostRange(
  minCents: number,
  maxCents: number,
  currencyCode: string = "USD",
  locale?: string,
): string {
  const min = formatCurrency(minCents, currencyCode, locale);
  const max = formatCurrency(maxCents, currencyCode, locale);
  return `${min} -- ${max}`;
}
Item Tier tag
formatCurrency() function Sonnet
formatCostRange() function Sonnet
Unit tests (Vitest) Sonnet

src/components/MatchCard.tsx (or equivalent match result component)

Change Detail Tier tag
Import formatCurrency Replace any hardcoded $ or toLocaleString() calls with formatCurrency(). Sonnet
Use display_currency from API response The API already returns display_currency on ScoredProvider. Pass it to formatCurrency(). Sonnet
Format procedure_costs_converted If present, use min_converted / max_converted with the currency field. Sonnet

src/pages/storefront/ProviderProfile.tsx

Change Detail Tier tag
Import formatCurrency Replace hardcoded cost displays with formatCurrency(). Sonnet
Use provider's base_currency for local pricing Display both local currency and patient's preferred currency where available. Sonnet

src/pages/ConversationApp.tsx

Change Detail Tier tag
Cost displays in match results rich card Use formatCurrency() for any cost values rendered inline in the conversation. Sonnet

Seed data updates

app/seed_data.py (or equivalent)

Change Detail Tier tag
Set base_currency for all 42 providers India providers: "INR", Turkey: "TRY", Thailand: "THB", Spain: "EUR", UAE: "AED", UK: "GBP", US: "USD". Sonnet
Update procedure_costs JSONB Convert existing USD-based costs to local currency equivalents using current rates. Add currency key. Keep legacy min_usd_cents / max_usd_cents keys for backward compat. Sonnet
Seed exchange_rates table Insert current rates for all corridor pairs (USD/INR, USD/AED, USD/GBP, USD/EUR, USD/TRY, USD/THB + reverse pairs). Sonnet

Tier 2 outline: Payment Infrastructure

Scope only -- detailed spec will be written separately when Tier 1 is deployed and validated.

New models

Model Key fields Purpose
Transaction id, case_id, patient_id, provider_id, amount_cents, currency, exchange_rate_id, gateway, gateway_ref, status, type (deposit/full/installment) Records every payment attempt
Invoice id, transaction_id, provider_id, line_items (JSONB), subtotal_cents, tax_cents, total_cents, currency, status Provider-facing invoice
ProviderPayout id, provider_id, invoice_id, amount_cents, currency, payout_method, status, settled_at Tracks disbursement to provider
PlatformFee id, transaction_id, fee_cents, currency, fee_rate_bps, service_tier Platform commission per transaction

Payment gateway service

  • PaymentGateway abstract base class with create_charge(), capture(), refund(), get_status()
  • StripeGateway implementation for USD, EUR, GBP
  • RazorpayGateway implementation for INR
  • Gateway selection via currency -> gateway mapping in config/payment_gateways.yaml
  • Webhook handlers for async payment status updates (Stripe webhooks, Razorpay webhooks)

Provider portal

  • GET /api/v1/providers/{id}/invoices -- list invoices
  • GET /api/v1/providers/{id}/invoices/{invoice_id} -- invoice detail
  • GET /api/v1/providers/{id}/payouts -- payout history

Financial events

All payment lifecycle events written to the events table: payment.initiated, payment.succeeded, payment.failed, payment.refunded, invoice.generated, payout.initiated, payout.settled.


Tier 3 outline: Compliance

Scope only -- detailed spec will be written separately.

Tax configuration

  • tax_config table: country_code, state_code, tax_type (VAT/GST/sales_tax), rate_bps, effective_date, expires_at
  • Tax calculation at invoice generation (not at quote time)
  • Seeded with: India 18% GST, UK 20% VAT (medical services may be exempt), UAE 5% VAT, Turkey 8-18% KDV, Thailand 7% VAT, Spain 21% IVA, US (state-by-state)

KYC/AML

  • Integration with identity verification provider (Onfido or similar)
  • Risk scoring per transaction (amount, corridor, patient history)
  • Enhanced due diligence for transactions above corridor thresholds

Cross-border regulatory

  • FEMA compliance for India (inward remittance documentation)
  • FCA compliance for UK (payment services authorization)
  • CBUAE compliance for UAE (payment processing authorization)
  • Per-corridor document requirements stored in corridor_compliance config

Reconciliation

  • Daily reconciliation job comparing gateway records vs internal transactions
  • Discrepancy alerting via events table + notification system
  • Monthly settlement reports per provider

Test plan

Unit tests

Test What it validates File Tier tag
test_convert_with_known_rates currency_service.convert() returns correct converted_cents for fixed test rates (mock Frankfurter) tests/test_currency_service.py Sonnet
test_persist_rate persist_rate() writes a row to exchange_rates table with correct fields tests/test_currency_service.py Sonnet
test_get_historical_rate get_historical_rate() retrieves the correct rate for a past date tests/test_currency_service.py Sonnet
test_budget_filter_removes_expensive _budget_filter() removes providers whose min cost (converted) exceeds budget * 1.2 tests/test_matching_engine.py Opus
test_budget_filter_skips_when_no_budget _budget_filter() passes all providers through when patient_budget_cents is None tests/test_matching_engine.py Sonnet
test_score_cost_converts_currency _score_cost() converts provider cost from INR to USD (or vice versa) before scoring tests/test_matching_engine.py Opus
test_score_cost_normalization_consistent Two providers with equivalent costs in different currencies get the same cost score tests/test_matching_engine.py Opus
test_chat_extractor_budget_usd Extracts {"budget_cents": 800000, "budget_currency": "USD"} from "my budget is $8,000" tests/test_chat_extractor.py Sonnet
test_chat_extractor_budget_inr Extracts {"budget_cents": 60000000, "budget_currency": "INR"} from "6 lakh rupees" tests/test_chat_extractor.py Sonnet
test_chat_extractor_budget_aed Extracts {"budget_cents": 5000000, "budget_currency": "AED"} from "50,000 AED" tests/test_chat_extractor.py Sonnet
test_case_budget_persistence Orchestrator saves budget to case.budget_cents + case.budget_currency tests/test_case_orchestrator.py Sonnet
test_exchange_rate_model ExchangeRate model creates/reads correctly with all fields tests/test_models.py Sonnet

Integration tests

Test What it validates File Tier tag
test_matching_multicurrency_providers End-to-end: patient (INR preferred) matched against providers in INR, USD, TRY. Cost scores are normalized. Provider ranking is currency-independent. tests/integration/test_matching_multicurrency.py Opus
test_budget_gate_filters_correctly Patient with INR 8,00,000 budget. Providers: one at INR 5,00,000 (passes), one at INR 12,00,000 (filtered), one at USD 15,000 (converted to INR, evaluated against budget). tests/integration/test_matching_multicurrency.py Opus
test_match_response_includes_conversion API response includes display_currency, procedure_costs_converted, and rate_date. tests/integration/test_match_api.py Sonnet

Frontend tests

Test What it validates File Tier tag
test_format_currency_usd formatCurrency(600000, "USD") returns "$6,000" src/utils/__tests__/formatCurrency.test.ts Sonnet
test_format_currency_inr formatCurrency(50000000, "INR") returns locale-appropriate INR string src/utils/__tests__/formatCurrency.test.ts Sonnet
test_format_currency_aed formatCurrency(2203800, "AED") returns locale-appropriate AED string src/utils/__tests__/formatCurrency.test.ts Sonnet
test_format_currency_eur formatCurrency(550000, "EUR") returns locale-appropriate EUR string src/utils/__tests__/formatCurrency.test.ts Sonnet
test_format_currency_gbp formatCurrency(470000, "GBP") returns locale-appropriate GBP string src/utils/__tests__/formatCurrency.test.ts Sonnet
test_format_currency_try formatCurrency(19200000, "TRY") returns locale-appropriate TRY string src/utils/__tests__/formatCurrency.test.ts Sonnet
test_format_currency_thb formatCurrency(21600000, "THB") returns locale-appropriate THB string src/utils/__tests__/formatCurrency.test.ts Sonnet
test_format_cost_range formatCostRange(600000, 1200000, "USD") returns "$6,000 -- $12,000" src/utils/__tests__/formatCurrency.test.ts Sonnet

Edge Cases

Edge Case Scenario Handling Severity
Exchange rate API (Frankfurter) is down during matching The daily cron job refreshes rates at midnight, but the cache has expired or rates were never fetched for a new currency pair. currency_service.convert() calls Frankfurter and gets a timeout or 5xx. The matching engine cannot score costs without conversion. currency_service.convert() already has an in-memory cache. Add a fallback chain: (1) in-memory cache (hot), (2) exchange_rates table (last persisted rate), (3) hardcoded stale rates from config/fallback_rates.yaml (refreshed manually each quarter). If all three fail, _score_cost() falls back to cost_index normalization (the v1 behavior) and logs a currency.fallback event. The match proceeds with degraded cost accuracy rather than failing entirely. High
Patient budget in an unsupported currency Patient says "my budget is 500,000 Naira" (NGN). Frankfurter (ECB data) does not cover NGN. convert() returns an error for the NGN→USD pair. currency_service.convert() should return a clear UnsupportedCurrencyError (not a generic exception). The orchestrator catches this and asks the patient to restate their budget in USD, EUR, or one of the 6 supported corridor currencies. The budget is stored as-is (budget_cents=50000000, budget_currency=NGN) but _budget_filter() skips filtering when conversion fails (passes all providers through). Log a currency.unsupported event with the currency code for future coverage decisions. Medium
Provider cost_index is 0 or negative Data quality issue — a provider's cost_index is set to 0 (division by zero in normalization) or negative (nonsensical). This could crash _score_cost() or produce inverted rankings. Add a guard in _score_cost(): if cost_index <= 0, treat the provider as having no cost data (skip cost scoring for that provider, redistribute weight to other factors via weight_redistribution.py). Log a data_quality.invalid_cost_index warning event with the provider ID. Add a seed data validation check that asserts all cost_index values are > 0. Medium
Budget specified without currency ("my budget is 50000") The chat extractor parses budget_amount: 50000 but cannot determine budget_currency. No currency symbol, no keyword, no unit — just a bare number. Apply the country-based inference rule from Part 16 of this spec: use patient.country_of_residence to infer currency (USA→USD, IND→INR, ARE→AED, etc.). If country is also unknown, default to USD and add a follow-up question to the LLM prompt: "Just to confirm — is your budget of 50,000 in US dollars?" Store the inferred currency with a source: "inferred" flag so it can be corrected later. Medium
Exchange rate changes between quote generation and patient viewing Patient triggers matching at 10am (USD/INR = 83.5). They view results at 6pm (USD/INR = 84.2). The displayed cost ranges are stale by the rate that was used at match time. Store the exchange_rate_id (from convert_with_audit()) on the match result. Display the rate_date alongside converted costs in the frontend: "Costs converted at USD/INR 83.50 (April 10, 2026)". If the patient re-runs matching, fresh rates are used. Do not auto-refresh displayed results — that would change rankings without user action. Add a "Rates as of {date}" footnote to the MatchCard component. Low
Rounding errors across multiple conversions Provider cost is in TRY. Patient currency is INR. The system converts TRY→USD→INR (two hops via the USD base). Each hop introduces rounding. Over a range (min→max), the accumulated error could be noticeable (e.g., displayed range off by hundreds of rupees). Always convert directly between the pair when Frankfurter supports it (TRY→INR is available via ECB cross-rates). Only use USD as an intermediate when a direct rate is unavailable. All intermediate math uses Decimal (not float) with 6 decimal places. Final display rounds to the nearest whole currency unit (no sub-unit display for large amounts). Add a unit test that converts USD→INR→USD and asserts the round-trip error is < 0.01%. Medium
Provider has no cost data at all (procedure_costs is null) A newly onboarded provider has no procedure_costs JSONB and cost_index is also null or 0. Should they appear in match results? Yes — they should still appear. _score_cost() returns a neutral score (0.5) when no cost data exists, and the cost weight is redistributed to other factors. The MatchCard displays "Cost information not yet available" instead of a range. _budget_filter() skips providers with no cost data (they pass through — we don't filter out a provider just because we don't know their price). This prevents new providers from being invisible until cost data is entered. Low
Frontend Intl.NumberFormat with unsupported locale formatCurrency() uses navigator.language which could be a locale that Intl.NumberFormat doesn't fully support for the given currency (e.g., locale sw-TZ formatting AED). The browser may throw a RangeError or fall back to a generic format. Wrap the Intl.NumberFormat constructor in a try-catch. On failure, fall back to en-US locale with manual currency code append: "50,000 AED" instead of a locale-specific format. Add the fallback to formatCurrency.ts and add a Vitest case that passes an obscure locale + currency combination to verify graceful degradation. Low

Implementation checklist

Tier 1 tasks with model tier tags

# Task Tier Est. lines Depends on
1 Migration: add budget_cents + budget_currency to cases Sonnet ~20 --
2 Migration: add base_currency to providers Sonnet ~15 --
3 Migration: create exchange_rates table Sonnet ~30 --
4 Migration: procedure_costs JSONB schema data migration Sonnet ~50 2
5 Model: Case -- add budget_cents, budget_currency fields Sonnet ~5 1
6 Model: Provider -- add base_currency field Sonnet ~3 2
7 Model: ExchangeRate -- new model Sonnet ~25 3
8 Service: currency_service.py -- add persist_rate(), get_historical_rate(), convert_with_audit() Sonnet ~60 7
9 Service: matching_engine.py -- modify MatchInput with budget/currency fields Sonnet ~5 --
10 Service: matching_engine.py -- modify _score_cost() for currency conversion Opus ~40 8, 9
11 Service: matching_engine.py -- add _budget_filter() method Opus ~25 9
12 Service: matching_engine.py -- integrate budget filter into score_providers() Opus ~15 10, 11
13 Service: match_service.py -- pass budget + currency to MatchInput Sonnet ~15 5, 9
14 Service: chat_extractor.py -- ensure multi-currency budget extraction Sonnet ~20 --
15 Config: chat_extractor_v2.yaml -- enhance budget extraction prompt Sonnet ~30 --
16 Agent: case_orchestrator.py -- save budget to case Sonnet ~25 5, 14
17 Seed: set base_currency for all 42 providers Sonnet ~50 6
18 Seed: update procedure_costs JSONB with currency key Sonnet ~80 4, 17
19 Seed: insert current exchange rates into exchange_rates table Sonnet ~40 7
20 Frontend: formatCurrency.ts utility Sonnet ~40 --
21 Frontend: match result components use formatCurrency() Sonnet ~20 20
22 Frontend: storefront pages use formatCurrency() Sonnet ~15 20
23 Frontend: conversation cost displays use formatCurrency() Sonnet ~10 20
24 Tests: currency_service unit tests (persist, historical, convert) Sonnet ~80 8
25 Tests: matching engine cost normalization + budget filter Opus ~100 10, 11, 12
26 Tests: chat extractor budget extraction (USD, INR, AED) Sonnet ~60 14
27 Tests: ExchangeRate model tests Sonnet ~30 7
28 Tests: integration -- multicurrency matching end-to-end Opus ~80 10, 11, 13
29 Tests: frontend formatCurrency (7 currencies + range) Sonnet ~50 20

Summary: 6 Opus tasks (matching engine cost normalization, budget filter, integration, exchange rate audit design), 23 Sonnet tasks (migrations, model fields, seed data, frontend utility, prompt config, unit tests).


References

Document Relevance
ai-steer/multicurrency-steer.md Problem statement, design principles, key decisions, risks, success criteria
app/services/currency_service.py Existing conversion service to be extended
app/services/matching_engine.py _score_cost() methods to be modified for currency normalization
app/services/match_service.py Orchestrator that passes patient currency to response builder
app/models/case.py Case model -- budget fields to be added
app/models/provider.py Provider model -- base_currency field to be added
app/services/chat_extractor.py Budget extraction from conversation
app/agents/case_orchestrator.py Saves extracted data to case
dao-layer-feature.md DAO repositories for new models (ExchangeRate)
CLAUDE.md Section 22 Microservices extraction plan -- payment service as Phase 2 candidate
CLAUDE.md Section 4.6 Multicurrency architecture summary