3.9 KiB
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.tsfor money, VAT rate, price record and calculation result types.domain/ports.tsforPricingRepositoryandPricingServicecontracts.domain/errors.tsfor missing price and invalid pricing command errors.application/pricing-service.tsimplementing calculations and price changes.infrastructure/pg-pricing-repository.tsowning PostgreSQL persistence.api/pricing.routes.tsfor explicit backend endpoints.index.tsexporting 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% andreduced= 10% for this slice. - Calculation:
netSubtotal = netUnit * quantity;vatAmount = round(netSubtotal * vatBps / 10000);total = netSubtotal + vatAmount. - Currency is fixed to
EURby database default and API response. Multi-currency is explicitly out of scope.
Data model
Add migration 012_pricing.js:
pricing_variant_pricesvariant_id uuid primary keynet_unit_amount_cents integer not null check >= 0vat_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<PriceCalculation>setVariantPrice({ variantId, netUnitAmountCents, vatRate }): Promise<VariantPrice>getVariantPrice(variantId): Promise<VariantPrice | undefined>
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 byvariantIdandquantity; schema strips/ignores extra client fields.
HTTP errors:
PRICING_PRICE_NOT_FOUNDas 404 when calculating/read has no server-side price.INVALID_PRICEas 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.