--- phase: 33-currency-system plan: 01 type: execute wave: 1 depends_on: [] files_modified: - src/db/schema.ts - src/shared/schemas.ts - src/shared/types.ts - src/server/services/currency.service.ts - tests/services/currency.service.test.ts autonomous: true requirements: [D-01, D-02, D-03, D-06, D-07, D-08, D-09] must_haves: truths: - "market_prices table exists with global_item_id, market, currency, price_cents columns" - "community_prices table exists with global_item_id, user_id, market, currency, price_cents, price_date, source_type columns" - "items table has price_currency column" - "thread_candidates table has found_price_cents, found_price_currency, found_price_date columns" - "currency service fetches exchange rates from frankfurter.app" - "currency service caches rates in memory with 24h TTL" - "currency service converts prices between currencies accurately" artifacts: - path: "src/db/schema.ts" provides: "market_prices and community_prices table definitions, new columns on items and threadCandidates" contains: "marketPrices" - path: "src/server/services/currency.service.ts" provides: "Exchange rate fetching, caching, and conversion" exports: ["getExchangeRates", "convertPrice", "CURRENCY_MARKET_MAP"] - path: "tests/services/currency.service.test.ts" provides: "Unit tests for currency service" min_lines: 40 key_links: - from: "src/server/services/currency.service.ts" to: "https://api.frankfurter.app" via: "fetch in getExchangeRates" pattern: "frankfurter" - from: "src/db/schema.ts" to: "src/shared/types.ts" via: "Drizzle inferred types" pattern: "marketPrices|communityPrices" --- Create the database schema for market-aware pricing and build the currency conversion service. Purpose: Foundation layer — all other plans depend on these tables and the conversion service. Output: New DB tables (market_prices, community_prices), new columns on items/candidates, currency service with rate fetching/caching/conversion. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/33-currency-system/33-CONTEXT.md @.planning/phases/33-currency-system/33-RESEARCH.md From src/db/schema.ts: ```typescript // Pattern: pgTable with serial id, references, timestamps export const globalItems = pgTable("global_items", { id: serial("id").primaryKey(), brand: text("brand").notNull(), model: text("model").notNull(), priceCents: integer("price_cents"), // ... }, (table) => [unique().on(table.brand, table.model)]); export const items = pgTable("items", { id: serial("id").primaryKey(), priceCents: integer("price_cents"), purchasePriceCents: integer("purchase_price_cents"), globalItemId: integer("global_item_id").references(() => globalItems.id), // ... }); export const threadCandidates = pgTable("thread_candidates", { id: serial("id").primaryKey(), priceCents: integer("price_cents"), globalItemId: integer("global_item_id").references(() => globalItems.id), // ... }); export const settings = pgTable("settings", { userId: integer("user_id").notNull().references(() => users.id), key: text("key").notNull(), value: text("value").notNull(), }, (table) => [primaryKey({ columns: [table.userId, table.key] })]); ``` From src/shared/schemas.ts: ```typescript export const createItemSchema = z.object({ priceCents: z.number().int().nonnegative().optional(), purchasePriceCents: z.number().int().nonnegative().optional(), // ... }); export const createCandidateSchema = z.object({ priceCents: z.number().int().nonnegative().optional(), // ... }); ``` Task 1: Add market_prices and community_prices tables + new columns to schema src/db/schema.ts, src/shared/schemas.ts, src/shared/types.ts src/db/schema.ts, src/shared/schemas.ts, src/shared/types.ts - marketPrices table has columns: id, globalItemId (FK to globalItems), market (text), currency (text), priceCents (integer), source (text nullable), createdAt (timestamp) - marketPrices has unique constraint on (globalItemId, market, currency) - communityPrices table has columns: id, globalItemId (FK to globalItems), userId (FK to users), market (text), currency (text), priceCents (integer), priceDate (timestamp nullable), sourceType (text, 'purchased' | 'researched'), createdAt (timestamp) - communityPrices has unique constraint on (globalItemId, userId, sourceType) - items table gets new nullable column: priceCurrency (text, default 'EUR') - threadCandidates table gets new nullable columns: foundPriceCents (integer), foundPriceCurrency (text), foundPriceDate (timestamp) - Zod schemas updated: createItemSchema gains optional priceCurrency field, createCandidateSchema gains optional foundPriceCents/foundPriceCurrency/foundPriceDate fields Per D-01, D-02: Add `marketPrices` pgTable to schema.ts with columns: `id` (serial PK), `globalItemId` (integer FK to globalItems ON DELETE CASCADE), `market` (text NOT NULL — 'EU', 'US', 'UK', etc.), `currency` (text NOT NULL — 'EUR', 'USD', 'GBP'), `priceCents` (integer NOT NULL), `source` (text nullable — 'manufacturer', 'retailer', 'community'), `createdAt` (timestamp defaultNow). Add unique constraint on (globalItemId, market, currency). Per D-04, D-05: Add `communityPrices` pgTable with: `id` (serial PK), `globalItemId` (integer FK to globalItems ON DELETE CASCADE), `userId` (integer FK to users), `market` (text NOT NULL), `currency` (text NOT NULL), `priceCents` (integer NOT NULL), `priceDate` (timestamp nullable), `sourceType` (text NOT NULL — 'purchased' or 'researched'), `createdAt` (timestamp defaultNow). Unique constraint on (globalItemId, userId, sourceType). Per D-03: Add `priceCurrency` column to `items` table: `priceCurrency: text("price_currency").default("EUR")`. Per D-06, D-07: Add to `threadCandidates` table: `foundPriceCents: integer("found_price_cents")`, `foundPriceCurrency: text("found_price_currency")`, `foundPriceDate: timestamp("found_price_date")`. Update `src/shared/schemas.ts`: - `createItemSchema`: add `priceCurrency: z.string().max(3).optional()` - `updateItemSchema`: inherits via `.partial()` - `createCandidateSchema`: add `foundPriceCents: z.number().int().nonnegative().optional()`, `foundPriceCurrency: z.string().max(3).optional()`, `foundPriceDate: z.string().datetime().optional()` - `updateCandidateSchema`: inherits via `.partial()` Update `src/shared/types.ts` if it has manual type definitions — if types are inferred from Drizzle/Zod, no changes needed. - src/db/schema.ts contains `export const marketPrices = pgTable("market_prices"` - src/db/schema.ts contains `export const communityPrices = pgTable("community_prices"` - src/db/schema.ts items table contains `priceCurrency: text("price_currency")` - src/db/schema.ts threadCandidates table contains `foundPriceCents: integer("found_price_cents")` - src/db/schema.ts threadCandidates table contains `foundPriceCurrency: text("found_price_currency")` - src/db/schema.ts threadCandidates table contains `foundPriceDate: timestamp("found_price_date")` - src/shared/schemas.ts createItemSchema contains `priceCurrency` - src/shared/schemas.ts createCandidateSchema contains `foundPriceCents` - marketPrices has unique constraint on globalItemId + market + currency - communityPrices has unique constraint on globalItemId + userId + sourceType cd /home/jlmak/Projects/jlmak/GearBox && grep -c "marketPrices\|communityPrices\|priceCurrency\|foundPriceCents" src/db/schema.ts Both new tables defined in schema with all columns and constraints, existing tables have new columns, Zod schemas updated Task 2: Create currency conversion service with exchange rate fetching and caching src/server/services/currency.service.ts, tests/services/currency.service.test.ts src/server/services/currency.service.ts (will be new), src/server/services/setup.service.ts (for service pattern) - getExchangeRates() fetches from https://api.frankfurter.app/latest?from=EUR - getExchangeRates() caches result in memory for 24 hours - getExchangeRates() returns cached rates when cache is valid - getExchangeRates() returns stale cache on fetch failure - convertPrice(1000, 'EUR', 'USD', rates) returns correct USD cents using rates.USD - convertPrice(1000, 'USD', 'EUR', rates) returns correct EUR cents using 1/rates.USD - convertPrice(1000, 'EUR', 'EUR', rates) returns 1000 (same currency = no conversion) - CURRENCY_MARKET_MAP maps EUR→EU, USD→US, GBP→UK, JPY→JP, CAD→CA, AUD→AU - getMarketForCurrency('EUR') returns 'EU' Per D-08: Create `src/server/services/currency.service.ts` with: ```typescript export interface ExchangeRates { base: string; date: string; rates: Record; } export const CURRENCY_MARKET_MAP: Record = { EUR: "EU", USD: "US", GBP: "UK", JPY: "JP", CAD: "CA", AUD: "AU", }; export function getMarketForCurrency(currency: string): string { return CURRENCY_MARKET_MAP[currency] ?? currency; } ``` Per D-08, D-09: Implement `getExchangeRates()`: - Fetch from `https://api.frankfurter.app/latest?from=EUR` - Parse response: `{ base: "EUR", date: "2026-04-13", rates: { USD: 1.08, GBP: 0.86, ... } }` - Cache in module-level variables: `let cachedRates: ExchangeRates | null = null; let cacheExpiry = 0;` - Cache TTL: 24 hours (86400000ms) - On fetch failure: return cached rates if available, throw if no cache - Always include base currency in rates: `rates.EUR = 1` (self-reference for conversion math) Implement `convertPrice(cents: number, from: string, to: string, rates: ExchangeRates): number`: - If `from === to`, return cents unchanged - Convert `from` to EUR base: `centsInEur = cents / rates[from]` - Convert EUR to `to`: `result = centsInEur * rates[to]` - Return `Math.round(result)` (integer cents) Export a `resetCache()` function for testing. Create `tests/services/currency.service.test.ts`: - Test convertPrice with known rates: EUR→USD, USD→EUR, same currency - Test getExchangeRates caching (mock fetch) - Test CURRENCY_MARKET_MAP entries - Test getMarketForCurrency - src/server/services/currency.service.ts exports getExchangeRates, convertPrice, CURRENCY_MARKET_MAP, getMarketForCurrency - convertPrice(1000, "EUR", "EUR", rates) returns 1000 - convertPrice(1000, "EUR", "USD", {base:"EUR",date:"",rates:{EUR:1,USD:1.08}}) returns 1080 - tests/services/currency.service.test.ts exists with at least 4 test cases - `bun test tests/services/currency.service.test.ts` passes cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/currency.service.test.ts Currency service with rate fetching, 24h caching, conversion math, and market mapping — all tested ## Trust Boundaries | Boundary | Description | |----------|-------------| | server→frankfurter.app | External API for exchange rates — untrusted data | | client→server | Price currency values from user input | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-33-01 | Tampering | currency.service.ts | mitigate | Validate exchange rate response shape before caching — reject if rates are missing or negative | | T-33-02 | Spoofing | schema.ts priceCurrency | mitigate | Zod validation on priceCurrency field limits to max 3 chars; server validates against known currency list | | T-33-03 | Denial of Service | currency.service.ts | mitigate | Cache rates for 24h; stale-serve on fetch failure; no user-triggered fetches | | T-33-04 | Information Disclosure | community_prices | accept | Community prices are intentionally public aggregate data — no PII beyond userId which is already public in profiles | - `bun test tests/services/currency.service.test.ts` passes - `bun run db:generate` produces a migration for the new tables/columns - schema.ts grep shows marketPrices, communityPrices, priceCurrency, foundPriceCents - New tables (market_prices, community_prices) defined in schema - Existing tables extended with currency/date columns - Currency service fetches, caches, and converts prices - All tests pass After completion, create `.planning/phases/33-currency-system/33-01-SUMMARY.md`