# Phase 33: Currency System - Research **Researched:** 2026-04-13 **Status:** Complete ## Executive Summary Phase 33 replaces the current symbol-only currency swap with a market-aware pricing system. The existing codebase stores all prices as integer cents in a single `priceCents` column (items, candidates, globalItems). The current `formatPrice()` simply swaps the currency symbol without conversion. This phase introduces market-specific pricing, exchange rate conversion via frankfurter.app (ECB data), community price data, and a dual-display format for converted prices. ## Current Architecture Analysis ### Price Storage (Single Currency) - **items.priceCents** — integer, user's personal item price - **items.purchasePriceCents** — integer, what the user paid (already exists, separate from MSRP) - **globalItems.priceCents** — integer, catalog reference price (currently no currency/market tag) - **threadCandidates.priceCents** — integer, candidate price during research - All prices assumed to be in the user's selected currency (symbol swap only) ### Price Display Chain 1. `useCurrency()` hook reads `currency` setting from DB via `useSetting("currency")` 2. `useFormatters()` composes `price(cents)` using `formatPrice(cents, currency)` 3. `formatPrice()` maps currency to symbol and formats cents → display string 4. All components use `const { price } = useFormatters()` — centralized formatting ### Price Aggregation (SQL) - `setup.service.ts`: `SUM(COALESCE(global_items.price_cents, items.price_cents) * items.quantity)` for setup totals - `totals.service.ts`: Same COALESCE pattern for category and global totals - `discovery.service.ts`: Returns `priceCents` from globalItems without conversion - These SQL aggregates assume all prices are in the same currency — they'll need currency-awareness ### Settings Infrastructure - `settings` table: key-value pairs per user (`userId`, `key`, `value`) - Current `currency` setting: stored as string ("USD", "EUR", etc.) - `useSetting()` / `useUpdateSetting()` hooks for read/write - Settings page: pill toggle for currency selection (6 options) ## Technical Approach ### 1. Database Schema Design **New table: `market_prices`** (recommended over JSONB on globalItems) ```sql CREATE TABLE market_prices ( id SERIAL PRIMARY KEY, global_item_id INTEGER NOT NULL REFERENCES global_items(id) ON DELETE CASCADE, market TEXT NOT NULL, -- 'EU', 'UK', 'US', etc. currency TEXT NOT NULL, -- 'EUR', 'GBP', 'USD' price_cents INTEGER NOT NULL, -- MSRP/UVP in that market's currency source TEXT, -- 'manufacturer', 'retailer', 'community' created_at TIMESTAMP DEFAULT NOW() NOT NULL, UNIQUE(global_item_id, market, currency) ); ``` Rationale: Separate table allows multiple market prices per item without schema changes to globalItems. The existing `globalItems.priceCents` becomes the "default/primary" price (EU market initially). **New table: `community_prices`** ```sql CREATE TABLE community_prices ( id SERIAL PRIMARY KEY, global_item_id INTEGER NOT NULL REFERENCES global_items(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id), market TEXT NOT NULL, currency TEXT NOT NULL, price_cents INTEGER NOT NULL, price_date TIMESTAMP, -- when bought/found source_type TEXT NOT NULL, -- 'purchased' | 'researched' created_at TIMESTAMP DEFAULT NOW() NOT NULL, UNIQUE(global_item_id, user_id, source_type) ); ``` **Modify existing tables:** - `items`: Add `price_currency TEXT DEFAULT 'EUR'` (source currency for "what I paid") - `threadCandidates`: Add `price_currency TEXT DEFAULT 'EUR'`, `found_price_cents INTEGER`, `found_price_currency TEXT`, `found_price_date TIMESTAMP` (D-06, D-07) ### 2. Exchange Rate System **frankfurter.app API:** - Base URL: `https://api.frankfurter.app` - Latest rates: `GET /latest?from=EUR&to=USD,GBP` - Response: `{ "base": "EUR", "date": "2026-04-13", "rates": { "USD": 1.08, "GBP": 0.86 } }` - Free, no API key, daily ECB data, supports 30+ currencies - Rate limit: reasonable for daily fetches (no documented limit for <100 req/day) **New service: `currency.service.ts`** ```typescript interface ExchangeRates { base: string; date: string; rates: Record; } // Cache in-memory with 24h TTL, fallback to last known rates on fetch failure let cachedRates: ExchangeRates | null = null; let cacheExpiry: number = 0; export async function getExchangeRates(): Promise { ... } export function convertPrice(cents: number, from: string, to: string, rates: ExchangeRates): number { ... } ``` **Caching strategy:** - In-memory cache with 24h TTL (ECB updates daily ~16:00 CET) - On fetch failure: use cached rates (stale but functional) - Optional: persist last-known rates to DB settings for cold-start resilience - Server-side conversion (D-09) — no client-side rate fetching ### 3. Market Mapping Currency → Market mapping (D-12): ```typescript const CURRENCY_MARKET_MAP: Record = { EUR: 'EU', USD: 'US', GBP: 'UK', JPY: 'JP', CAD: 'CA', AUD: 'AU' }; ``` The `currency` setting in the settings table implies market. No separate market setting needed. ### 4. API Changes **New endpoints:** - `GET /api/exchange-rates` — returns current rates (public, cached) - `GET /api/global-items/:id/prices` — returns market prices + community data for a catalog item **Modified endpoints:** - All endpoints returning prices should accept optional `?currency=EUR` query param - Server converts prices when currency differs from stored currency - Converted prices include `{ priceCents, currency, converted: boolean, sourceCurrency?, sourcePrice? }` **Community price submission:** - `POST /api/global-items/:id/prices` — submit "what I paid" (requires auth + item in collection) - Candidate "found price" tracked via existing candidate update endpoint with new fields ### 5. Client-Side Changes **`formatPrice()` evolution:** ```typescript // Current: formatPrice(cents, currency) → "$12.00" // New: formatPrice(cents, currency, options?) → "$12.00" or "€12.00 (~$13.00)" interface FormatPriceOptions { converted?: boolean; sourceCurrency?: string; sourcePrice?: number; showDual?: boolean; // dual display format (D-14) } ``` **`useCurrency()` evolution:** ```typescript // Current: returns Currency string // New: returns { currency, market, showConversions } interface CurrencyContext { currency: Currency; market: string; showConversions: boolean; // D-15: auto-show conversions toggle } ``` **Settings page:** - Currency picker becomes "Market & Currency" selector - Auto-suggestion on first visit (D-13): `navigator.language` → locale → suggested currency - Toggle for "Show price conversions automatically" (D-15) ### 6. Transition Strategy The existing `priceCents` on globalItems becomes the EU/default market price. No data migration needed for personal items since they already store "what I paid" in the user's chosen currency. The new `price_currency` column defaults to 'EUR' matching the current assumption. **Backward compatibility:** - All existing `priceCents` fields remain — they're the "primary" price - New market_prices table adds additional market prices - APIs that currently return `priceCents` continue to do so, with optional conversion - `useFormatters()` hook signature stays the same for basic usage ### 7. Community Price Aggregation Aggregation queries for community stats (D-21): - Use median (more robust against outliers than average) - Minimum 3 reports before showing aggregate - Filter by market for locale-specific stats - Include report count for transparency ```sql SELECT market, currency, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_cents) as median_price, COUNT(*) as report_count FROM community_prices WHERE global_item_id = $1 AND market = $2 GROUP BY market, currency HAVING COUNT(*) >= 3; ``` ### 8. MCP Tool Updates Existing MCP tools that return prices need currency context: - `list_items`, `get_item`: Include `priceCurrency` in response - `create_item`, `update_item`: Accept optional `priceCurrency` param - `get_setup`: Include currency info with totals - New tool: `get_exchange_rates` — returns current conversion rates ## Risk Assessment ### Low Risk - frankfurter.app downtime — mitigated by caching with stale-serve fallback - Schema migration — additive only (new tables + new nullable columns) - `formatPrice()` changes — backward compatible with optional params ### Medium Risk - SQL aggregate complexity — setup/totals queries need to handle mixed currencies when summing prices from items with different source currencies - Community price data quality — solved by tying submissions to collection ownership (D-05) and minimum report threshold ### High Risk - **Mixed-currency aggregation in setup totals** — when items in a setup have prices in different currencies, SUM is meaningless without conversion. Must convert all to user's currency before aggregating. This adds a server-side conversion step to every setup total query. ## Validation Architecture ### Unit Tests - `currency.service.test.ts`: Rate fetching, caching, conversion math - `formatPrice()`: Dual display format, conversion labels - Market mapping: currency → market resolution ### Integration Tests - Market prices CRUD operations - Community price submission with ownership validation - Setup totals with mixed-currency items - Exchange rate caching behavior ### E2E Tests - Settings page: market/currency selection - Global item detail: market prices display - Comparison table: normalized currency display - Setup totals: converted price display ## Implementation Order (Recommended Waves) **Wave 1 — Foundation:** 1. Schema changes (market_prices, community_prices tables, column additions) 2. Currency service (rate fetching, caching, conversion) 3. Database push **Wave 2 — Server Integration:** 4. Market prices API endpoints 5. Price conversion in existing endpoints 6. Setup/totals query updates for currency-awareness **Wave 3 — Client & Display:** 7. Formatter evolution (dual display, conversion labels) 8. Settings page market/currency selector 9. Global item detail with market prices 10. Comparison table currency normalization 11. MCP tool updates --- ## RESEARCH COMPLETE *Phase: 33-currency-system* *Research completed: 2026-04-13*