Files
GearBox/.planning/phases/33-currency-system/33-01-PLAN.md
Jean-Luc Makiola 7a696f39a5 docs(33): create phase plans for currency system
6 plans across 3 waves covering market-aware pricing, exchange rates,
community price data, and currency-normalized display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:58:37 +02:00

13 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
33-currency-system 01 execute 1
src/db/schema.ts
src/shared/schemas.ts
src/shared/types.ts
src/server/services/currency.service.ts
tests/services/currency.service.test.ts
true
D-01
D-02
D-03
D-06
D-07
D-08
D-09
truths artifacts key_links
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
path provides contains
src/db/schema.ts market_prices and community_prices table definitions, new columns on items and threadCandidates marketPrices
path provides exports
src/server/services/currency.service.ts Exchange rate fetching, caching, and conversion
getExchangeRates
convertPrice
CURRENCY_MARKET_MAP
path provides min_lines
tests/services/currency.service.test.ts Unit tests for currency service 40
from to via pattern
src/server/services/currency.service.ts https://api.frankfurter.app fetch in getExchangeRates frankfurter
from to via pattern
src/db/schema.ts src/shared/types.ts Drizzle inferred types 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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. <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> 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:
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 <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> 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

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

<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>
After completion, create `.planning/phases/33-currency-system/33-01-SUMMARY.md`