# Architect — F-017 Pricing module ## Feature F-017 adds server-side pricing and VAT calculation behind a public `PricingService`, so future cart/checkout flows never trust frontend-supplied prices. ## Design ### Module boundaries Create `project/src/modules/pricing/` following existing module structure: - `domain/price.ts` for money, VAT rate, price record and calculation result types. - `domain/ports.ts` for `PricingRepository` and `PricingService` contracts. - `domain/errors.ts` for missing price and invalid pricing command errors. - `application/pricing-service.ts` implementing calculations and price changes. - `infrastructure/pg-pricing-repository.ts` owning PostgreSQL persistence. - `api/pricing.routes.ts` for explicit backend endpoints. - `index.ts` exporting only the public pricing API. Catalog remains independent. Pricing stores `variant_id` as an opaque UUID reference and does not join or import catalog internals. Future cart/checkout must depend on `PricingService`, injected at composition root. ### Money and VAT rules Use integer cents only — no floating point money. - Stored base price: `net_unit_amount_cents` (pre-VAT). - VAT rate: enum `general` = 21% and `reduced` = 10% for this slice. - Calculation: `netSubtotal = netUnit * quantity`; `vatAmount = round(netSubtotal * vatBps / 10000)`; `total = netSubtotal + vatAmount`. - Currency is fixed to `EUR` by database default and API response. Multi-currency is explicitly out of scope. ### Data model Add migration `012_pricing.js`: - `pricing_variant_prices` - `variant_id uuid primary key` - `net_unit_amount_cents integer not null check >= 0` - `vat_rate text not null check in ('general', 'reduced')` - `currency text not null default 'EUR' check = 'EUR'` - timestamps - `pricing_price_history` - append-only audit rows for every price change - stores previous and new net amount/rate/currency, plus timestamp - first price creation also writes a history row with previous fields null ### Public service interface Expose `PricingService` from module index: - `calculate({ variantId, quantity }): Promise` - `setVariantPrice({ variantId, netUnitAmountCents, vatRate }): Promise` - `getVariantPrice(variantId): Promise` `calculate` never accepts or uses client unit price, subtotal, VAT, discount, or total fields. That is the core rule: callers provide product identity and quantity; pricing returns truth. ### API slice Add routes: - `GET /pricing/variants/:variantId` — public read of current server-side price. - `PUT /pricing/variants/:variantId` — admin-only price change. - `POST /pricing/calculate` — calculation by `variantId` and `quantity`; schema strips/ignores extra client fields. HTTP errors: - `PRICING_PRICE_NOT_FOUND` as 404 when calculating/read has no server-side price. - `INVALID_PRICE` as 422 for invalid commands. ### Tests - Unit tests for VAT calculation for general/reduced rates and invalid quantity/amount. - Unit/API test proving client-supplied price fields are ignored by schema/service and server price is used. - Integration test proving every price change writes a history row, including create and update. - Boundary test proving catalog does not reference `pricing_` tables or pricing internals. - Migration suite remains the safety net for up/down/idempotency. ## Acceptance trace - PricingService.calculate returns totals with VAT from persisted server price. - Client price ignored: calculate only uses variant id + quantity; schema strips extra price fields and tests send bogus client values. - Price history: repository writes append-only rows on creation and update. - `verify.sh`: must pass after gates. ## Risks / constraints - Do not add decimal/money dependencies; integer cents are sufficient and safer. - Do not couple pricing to catalog by FK or join in this slice; variant existence validation is a later orchestration concern. - Do not store or trust client totals anywhere.