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>
271 lines
13 KiB
Markdown
271 lines
13 KiB
Markdown
---
|
|
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>
|