Files
GearBox/.planning/phases/33-currency-system/33-RESEARCH.md
2026-04-13 17:52:18 +02:00

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

  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)

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: 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

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=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:

// 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 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
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

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