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

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

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.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

<interfaces>
<!-- From src/db/schema.ts — existing table patterns -->
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(),
  // ...
});
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add market_prices and community_prices tables + new columns to schema</name>
  <files>src/db/schema.ts, src/shared/schemas.ts, src/shared/types.ts</files>
  <read_first>src/db/schema.ts, src/shared/schemas.ts, src/shared/types.ts</read_first>
  <behavior>
    - 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
  </behavior>
  <action>
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.
  </action>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <verify>
    <automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "marketPrices\|communityPrices\|priceCurrency\|foundPriceCents" src/db/schema.ts</automated>
  </verify>
  <done>Both new tables defined in schema with all columns and constraints, existing tables have new columns, Zod schemas updated</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Create currency conversion service with exchange rate fetching and caching</name>
  <files>src/server/services/currency.service.ts, tests/services/currency.service.test.ts</files>
  <read_first>src/server/services/currency.service.ts (will be new), src/server/services/setup.service.ts (for service pattern)</read_first>
  <behavior>
    - 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'
  </behavior>
  <action>
Per D-08: Create `src/server/services/currency.service.ts` with:

```typescript
export interface ExchangeRates {
  base: string;
  date: string;
  rates: Record<string, number>;
}

export const CURRENCY_MARKET_MAP: Record<string, string> = {
  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
  </action>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <verify>
    <automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/currency.service.test.ts</automated>
  </verify>
  <done>Currency service with rate fetching, 24h caching, conversion math, and market mapping — all tested</done>
</task>

</tasks>

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

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

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

<output>
After completion, create `.planning/phases/33-currency-system/33-01-SUMMARY.md`
</output>
