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