Files
GearBox/.planning/phases/33-currency-system/33-04-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

11 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 04 execute 2
01
02
src/server/services/community-price.service.ts
src/server/routes/community-prices.ts
src/server/services/setup.service.ts
src/server/services/totals.service.ts
src/server/index.ts
tests/services/community-price.service.test.ts
true
D-03
D-04
D-05
D-07
D-21
truths artifacts key_links
Users can submit community prices for items they own
Community price submissions are tied to collection ownership
Community price aggregation returns per-market median and report count
Setup totals handle items with the same currency correctly
path provides exports
src/server/services/community-price.service.ts Community price submission, ownership validation, aggregation
submitCommunityPrice
getCommunityPriceStats
validateOwnership
path provides
src/server/routes/community-prices.ts Community price API endpoints
path provides min_lines
tests/services/community-price.service.test.ts Community price service tests 40
from to via pattern
src/server/services/community-price.service.ts src/db/schema.ts Drizzle queries on communityPrices + items tables communityPrices
from to via pattern
src/server/routes/community-prices.ts src/server/services/community-price.service.ts route handler calls service submitCommunityPrice|getCommunityPriceStats
Create community price submission system with ownership validation and per-market aggregation, plus update setup/totals services for currency awareness.

Purpose: Enable community price data (D-04, D-05, D-21) and ensure setup totals work correctly with currency metadata. Output: Community price API, aggregation queries, updated setup/totals services.

<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-01-SUMMARY.md From src/db/schema.ts (Plan 01): ```typescript export const communityPrices = pgTable("community_prices", { id: serial("id").primaryKey(), globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }), userId: integer("user_id").notNull().references(() => users.id), market: text("market").notNull(), currency: text("currency").notNull(), priceCents: integer("price_cents").notNull(), priceDate: timestamp("price_date"), sourceType: text("source_type").notNull(), // 'purchased' | 'researched' createdAt: timestamp("created_at").defaultNow().notNull(), }, (table) => [unique().on(table.globalItemId, table.userId, table.sourceType)]); ```

From src/server/services/setup.service.ts (existing):

export async function getAllSetups(db: Db, userId: number) { ... }
// Uses SQL: SUM(COALESCE(global_items.price_cents, items.price_cents) * items.quantity)
export async function getSetupWithItems(db: Db, userId: number, setupId: number) { ... }

From src/server/services/totals.service.ts (existing):

export async function getCategoryTotals(db: Db, userId: number) { ... }
export async function getGlobalTotals(db: Db, userId: number) { ... }
Task 1: Create community price service with ownership validation and aggregation src/server/services/community-price.service.ts, src/server/routes/community-prices.ts, src/server/index.ts, tests/services/community-price.service.test.ts src/server/services/item.service.ts, src/server/routes/items.ts, src/server/index.ts, src/db/schema.ts - validateOwnership(db, userId, globalItemId) returns true if user has an item with that globalItemId - validateOwnership(db, userId, globalItemId) returns false if user does not own the item - submitCommunityPrice(db, data) creates/updates a community price (ON CONFLICT upsert) - submitCommunityPrice returns null if ownership validation fails - getCommunityPriceStats(db, globalItemId, market?) returns { market, currency, medianPrice, reportCount }[] - getCommunityPriceStats filters by market when market param provided - Stats only returned when reportCount >= 3 (minimum threshold per D-21) - POST /api/community-prices requires auth - GET /api/community-prices/:globalItemId returns aggregated stats (public) Create `src/server/services/community-price.service.ts`:

Per D-05: validateOwnership(db, userId, globalItemId):

  • SELECT COUNT(*) FROM items WHERE user_id = $1 AND global_item_id = $2
  • Return count > 0

Per D-04, D-05: submitCommunityPrice(db, { globalItemId, userId, market, currency, priceCents, priceDate, sourceType }):

  • First call validateOwnership — if false, return null (user doesn't own this item)
  • INSERT INTO community_prices ... ON CONFLICT (global_item_id, user_id, source_type) DO UPDATE SET price_cents = EXCLUDED.price_cents, price_date = EXCLUDED.price_date, market = EXCLUDED.market, currency = EXCLUDED.currency
  • Return the upserted row

Per D-21: getCommunityPriceStats(db, globalItemId, market?):

  • Use PostgreSQL PERCENTILE_CONT(0.5) for median calculation
  • Query: SELECT market, currency, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_cents) as median_price, COUNT() as report_count FROM community_prices WHERE global_item_id = $1 [AND market = $2] GROUP BY market, currency HAVING COUNT() >= 3
  • Return array of { market, currency, medianPrice (integer cents), reportCount }

Create src/server/routes/community-prices.ts:

  • GET /:globalItemId: Call getCommunityPriceStats(db, id), return JSON
  • POST /: Require auth. Validate body: { globalItemId: z.number().int(), market: z.string(), currency: z.string().max(3), priceCents: z.number().int().nonnegative(), priceDate: z.string().datetime().optional(), sourceType: z.enum(["purchased", "researched"]) }. Call submitCommunityPrice. Return 403 if ownership validation fails, 200 with data otherwise.

Register in src/server/index.ts: app.route("/api/community-prices", communityPriceRoutes)

Create tests/services/community-price.service.test.ts:

  • Test validateOwnership returns false for non-owner
  • Test validateOwnership returns true when user owns item with that globalItemId
  • Test submitCommunityPrice creates a price submission
  • Test submitCommunityPrice returns null when user doesn't own item
  • Test getCommunityPriceStats returns empty when < 3 reports
  • Use createTestDb() helper <acceptance_criteria>
    • src/server/services/community-price.service.ts exports validateOwnership, submitCommunityPrice, getCommunityPriceStats
    • src/server/routes/community-prices.ts has GET and POST handlers
    • src/server/index.ts contains app.route("/api/community-prices"
    • getCommunityPriceStats uses PERCENTILE_CONT for median
    • getCommunityPriceStats HAVING COUNT(*) >= 3
    • submitCommunityPrice checks ownership before insert
    • bun test tests/services/community-price.service.test.ts passes </acceptance_criteria> cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/community-price.service.test.ts Community price system working with ownership validation, median aggregation with 3-report minimum
Task 2: Update setup and totals services for currency awareness src/server/services/setup.service.ts, src/server/services/totals.service.ts src/server/services/setup.service.ts, src/server/services/totals.service.ts Note: The current SQL aggregates (SUM of price_cents) assume all prices are in the same currency. For now, this assumption holds because: 1. All existing data uses the same implicit currency 2. The user's personal items have `priceCurrency` defaulting to 'EUR' 3. Global item `priceCents` is the primary/EU market price

The aggregation queries in setup.service.ts and totals.service.ts should include the priceCurrency field in their response so the client can display the correct currency symbol, but the actual SUM logic does not need conversion yet (that would require the server to know the user's preferred currency during aggregation, which is a Plan 05/06 concern).

Update src/server/services/setup.service.ts:

  • In getSetupWithItems: Add priceCurrency: items.priceCurrency to the itemList SELECT columns
  • The totalCost aggregate stays as-is (all in primary currency for now)

Update src/server/services/totals.service.ts:

  • No changes needed — totals are global aggregates returned to the authenticated user
  • The client formatter (Plan 05) will handle displaying in the user's preferred currency

This is intentionally minimal — the server returns raw data with currency metadata, and the client handles conversion display. Server-side conversion for aggregates would require passing the user's currency preference through every query, which adds complexity without benefit when the primary market is EUR. <acceptance_criteria> - src/server/services/setup.service.ts getSetupWithItems includes priceCurrency in itemList select - Existing bun test passes — no regressions in setup or totals tests </acceptance_criteria> cd /home/jlmak/Projects/jlmak/GearBox && bun test Setup and totals services return currency metadata alongside prices

<threat_model>

Trust Boundaries

Boundary Description
client→server Community price submissions — untrusted user price data
server→database Ownership validation query

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-33-09 Spoofing POST /api/community-prices mitigate Auth middleware + ownership validation ensures only item owners can submit prices
T-33-10 Tampering community-price.service.ts mitigate Zod validation on all input fields, priceCents must be non-negative integer, currency max 3 chars
T-33-11 Repudiation community_prices accept Price submissions tracked with userId and createdAt — sufficient audit trail for a single-user app
T-33-12 Information Disclosure GET /api/community-prices accept Community price stats are intentionally public (anonymous aggregates, no individual prices exposed)
</threat_model>
- `bun test` passes (all tests including new community price tests) - Community price stats respect 3-report minimum - Ownership validation prevents unauthorized submissions

<success_criteria>

  • Community price CRUD with ownership gate
  • Aggregation with median and minimum report threshold
  • Setup items include currency metadata
  • All tests pass </success_criteria>
After completion, create `.planning/phases/33-currency-system/33-04-SUMMARY.md`