262 lines
10 KiB
Markdown
262 lines
10 KiB
Markdown
# 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<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):
|
|
```typescript
|
|
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=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*
|