10 KiB
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
useCurrency()hook readscurrencysetting from DB viauseSetting("currency")useFormatters()composesprice(cents)usingformatPrice(cents, currency)formatPrice()maps currency to symbol and formats cents → display string- 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 totalstotals.service.ts: Same COALESCE pattern for category and global totalsdiscovery.service.ts: ReturnspriceCentsfrom globalItems without conversion- These SQL aggregates assume all prices are in the same currency — they'll need currency-awareness
Settings Infrastructure
settingstable: key-value pairs per user (userId,key,value)- Current
currencysetting: 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)
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
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: Addprice_currency TEXT DEFAULT 'EUR'(source currency for "what I paid")threadCandidates: Addprice_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
interface ExchangeRates {
base: string;
date: string;
rates: Record<string, number>;
}
// 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<ExchangeRates> { ... }
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):
const CURRENCY_MARKET_MAP: Record<string, string> = {
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=EURquery 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:
// 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:
// 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
priceCentsfields remain — they're the "primary" price - New market_prices table adds additional market prices
- APIs that currently return
priceCentscontinue 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
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: IncludepriceCurrencyin responsecreate_item,update_item: Accept optionalpriceCurrencyparamget_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 mathformatPrice(): 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:
- Schema changes (market_prices, community_prices tables, column additions)
- Currency service (rate fetching, caching, conversion)
- 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