Skip to content

Latest commit

 

History

History
421 lines (368 loc) · 28.2 KB

File metadata and controls

421 lines (368 loc) · 28.2 KB

Architecture

Decisions, event contracts and flows. Companion to readme.md (overview) and ROADMAP.md (build order).


1. Architecture decision records

Each records what was decided, why, and what it costs.

ADR-001 — Microservices over a modular monolith

Decision: eleven deployable services split by business capability. Why: independent scaling of the checkout path versus reporting; team and deploy independence; the brief calls for it. Cost: distributed-system complexity — eventual consistency, correlation-ID tracing, more infrastructure. Mitigated by shared libraries, a strict service template and observability wired in from Phase 1.

ADR-002 — Custom auth-service rather than Keycloak

Decision: own the identity service; issue RS256 JWTs; expose JWKS. Why: the email-OTP registration flow and runtime-editable permission model are first-class product requirements, which in Keycloak need custom SPIs and themes. One fewer heavy component to operate. Cost: we own the security-critical code. Mitigated by making every other service a standard OAuth2 resource server validating via JWKS, so an external IdP is a configuration swap, not a rewrite.

ADR-003 — Schema per service in one PostgreSQL instance

Decision: one instance, one schema and one login role per service, grants scoped to that schema. Why: enforces service boundaries at the database level while keeping local dev, backups and ops cheap. Any schema can be promoted to its own instance later with a connection-string change. Cost: a shared instance is a shared failure domain and a shared resource pool. Accepted at this scale; revisit when one service's load justifies separation.

ADR-004 — Kafka with versioned JSON, not Avro + Schema Registry

Decision: JSON payloads in a shared envelope carrying schemaVersion; DTOs live in events-lib. Why: events-lib gives compile-time coupling across Java services, which is where the real contract safety comes from here. Avoids running and operating a Schema Registry. Cost: no runtime compatibility enforcement, larger messages. Mitigated by additive-only evolution, a new .v2 topic for breaking changes, and contract tests.

ADR-005 — Transactional outbox for publishing

Decision: producers write an outbox row in the same transaction as the state change; a relay publishes to Kafka. Why: publishing inside business logic loses events when the broker is down or the transaction rolls back after a send. Money and stock cannot tolerate that. Cost: publish latency of up to the poll interval, plus an outbox table per producing service.

ADR-006 — Idempotent consumers everywhere

Decision: every consumer records eventId in processed_event before acting. Why: Kafka is at-least-once. A redelivered sale-completed must not deduct stock twice. Cost: one extra write and table per consuming service. Non-negotiable.

ADR-007 — Offline-capable lane with server-side revalidation

Decision: the terminal queues sales locally and syncs with an Idempotency-Key; the server recomputes totals and reports variances rather than trusting the client. Why: a supermarket cannot stop selling because the network dropped, but it also cannot let a stale terminal define prices. Cost: a variance workflow, and a window in which stock can go negative. Handled by variance reporting plus negative-stock detection and alerting.

ADR-008 — Docker Compose for production, Kubernetes-ready

Decision: Compose on a VPS for production now; service-per-container layout that maps directly onto Kubernetes. Why: one business's traffic does not justify a cluster's operational cost. Cost: no autoscaling or self-healing scheduler. Mitigated by restart policies, healthchecks, resource limits, and keeping nothing Compose-specific in application code.

ADR-009 — Configurable tax engine, no fiscalization yet

Decision: tax classes with effective-dated rates and per-product inclusive/exclusive pricing; no country-specific fiscal device integration. Why: correct tax handling is universal; fiscal signing is jurisdiction-specific and can be added as an adapter service consuming sale-completed. Cost: a VAT-registered Kenyan retailer will need a KRA eTIMS adapter before going live. The seam is deliberately clean so that is additive work, not a refactor.

ADR-010 — Gateway is not the only line of defence

Decision: every service validates the JWT independently via JWKS. Why: a service reachable on the internal network must not be trivially callable without a valid token. Defence in depth. Cost: a small per-request validation cost and a JWKS cache in each service.

ADR-011 — Reporting rebuilds from its own event log, not from topic replay

Decision: reporting-service writes every event it consumes, verbatim, to an append-only event_log before projecting it. A rebuild truncates the fact tables and replays that log in the order it was received. Stock is valued by inventory, which publishes paged stock-valued snapshots; reporting never recomputes on-hand from deductions. Why: topic retention is days, the books are years. A rebuild that depends on Kafka still holding last March's sales works in a demo and fails the first time anyone needs it. The same row is the consumer's idempotency record, so there is one table to trust, not two. Valuing stock from deductions alone would miss receipts, adjustments, transfers and write-offs - inventory already knows the answer. Cost: the log grows with the business and is the one reporting table that must be backed up. Fact tables are order-independent (each event writes only its own rows; figures are aggregated at read time), so a replay in receipt order reproduces the incremental result exactly.

ADR-012 — The web tier holds the session; the browser holds no token

Decision: the Next.js app is a backend-for-frontend. Its route handlers sign in against auth-service and keep the access and refresh tokens in encrypted (JWE) httpOnly, Secure, SameSite=Strict cookies; browser code reaches the services through /api/gateway/*, and the BFF attaches the token on the server. proxy.ts is the only place a session is refreshed, with a single-flight exchange. Why: a token in JavaScript's reach is one XSS away from being stolen, and a lane runs all day on a shared machine. Stateless cookies keep the web tier free of a session store. One refresher, because auth-service revokes a whole session when a refresh token is used twice. Cost: the single-flight map is per process, so more than one web replica needs sticky sessions or a shared store; and every credential call must forward the browser's address, or the gateway's per-address login limit would count the BFF instead of people.

ADR-013 — Supervisor approval is a PIN that mints a one-action token

Decision: a supervisor sets a 4-6 digit PIN (auth-service, hashed, locked for 15 minutes after five wrong entries - separately from the login lockout). At a lane, the cashier's session asks POST /auth/approvals with the supervisor's id and PIN, and gets a JWT for the supervisor, carrying one permission and one branch, living two minutes, with act naming the cashier. The BFF spends it on the one call it was requested for - an allow-list maps each approvable permission (price:override, sale:void, sale:refund, cash:intraday) to its method and paths (cash:intraday: a deposit, a replenishment or the closing handover) - and never returns it to the browser. Why: no service changes. The approved call arrives as an ordinary request from the supervisor, so every existing @PreAuthorize, branch check and audit record names the person who approved. A password typed at a shared till would be seen; signing the cashier out and back in wastes the queue's time. Cost: a cashier can lock a supervisor's PIN by guessing (each failure is audited, and the lock never touches the supervisor's sign-in); an approval token may be spent more than once in its two minutes, which is why the BFF, not the browser, holds it.

ADR-014 — The drawer is a ledger of notes and coins

Decision: a shift opened with its float counted note by note is tracked by denomination: every movement is a row in drawer_movements (float, notes received and change given per cash sale, voids, refunds, deposits, replenishments), and what the drawer holds is their sum. Change is made by a bounded search over what the drawer actually holds, customer's notes included - never greedy - and a tender the drawer cannot change is refused before any money moves. The branch's intraday cash, held by the supervisor, is a second ledger of the same kind: deposits go into it, replenishments come out of it (only notes it holds), and it is topped up from and banked to the bank. Cash limits per branch with per-person overrides warn past the limit and refuse cash at the ceiling (default 120%). Tills are numbered per branch in order of first use. Why: a cashier balancing by total cannot tell a miscount from a shortage; by note, a shortfall at close points at the note it is in. Handovers are where cash goes missing, so both sides of each are recorded, and a supervisor's PIN confirms them. The money totals and the shift-closed arithmetic are unchanged - replenishments are float in, deposits are drops - so reporting's mirror of the till needs no change. Every movement between a till and the intraday - deposit, replenishment, and the closing handover - needs cash:intraday from someone other than the cashier on the shift (till.approver_is_cashier), so the lane asks for a PIN even when a supervisor is selling. The close is a process: the till stops (CLOSING), the drawer is counted blind, the supervisor confirms with their PIN what they received (POST /till-sessions/{id}/handover, which puts the notes into the intraday as TILL_CLOSE), and only then can the cashier close; what was received is the shift's counted cash. The cash position (/cash-positions) adds the two ledgers up for whoever holds the intraday: each till still on a shift at its expected cash (nothing once handed over), the intraday, and the branch total; the administrator sees every branch. The intraday is one pot per branch, not per supervisor - decided with the user. The cashier may count out the change their own way; the server accepts it only when it tallies with the change due and is in the drawer. An exchange (notes for notes of the same total) writes an in and an out that balance, changing the drawer's make-up and never its money. Cost: a shift opened without a note count (older clients, the seed) stays on totals, and the calculator says so. An offline sale is never refused for want of change: its notes are entered as the lane counted them.


ADR-015 — Product images in object storage

Decision: a product's picture is uploaded to catalog-service, which keeps it in S3 (an S3-compatible store, RustFS, in development - MinIO no longer publishes images) under a key on the product, and serves it back at /api/v1/products/{id}/image?v=<key>. The type is read from the file's bytes (JPEG, PNG, WebP; 2 MB), never from what the browser declares; a replaced picture's object is deleted only after the new key commits. Without a bucket configured the catalog still runs and uploads answer 503. Why: decided with the user - images do not bloat the database or its backups, and production already lives on AWS. Serving through the catalog (not a public bucket URL) keeps pictures behind the same sign-in as everything else, and the versioned URL lets browsers cache them for good. Cost: a second store to back up, and one more service in development.

ADR-016 — Costs without VAT, price reviews, and net profit

Decision: the business is VAT-registered, so every cost is held without VAT: a delivery or an order keyed in as invoiced has its VAT taken out by catalog's POST /pricing/cost-check (one tax engine), and purchasing keeps what was typed, the rate and the input VAT beside the net cost. Catalog consumes goods-received and compares each line's landed cost with the branch's regular price: below the product's target margin (its own, else its category's, else the parent's) or below cost, it opens a price review suggesting a price rounded up to the shilling; a manager with price:manage sets it (the branch's list price if a list set it, else the base price) or keeps the old one for a reason. Nothing reprices by itself. Expenses live in purchasing - above an approval limit they count once someone other than the recorder approves them - and reporting's profit and loss is net sales less cost of sales, losses and approved expenses, head office's counted once in the business-wide figure. Why: decided with the user. Margin compares a price net of output VAT with a cost net of input VAT, or every standard-rated item looks 16% better than it is. A price is a commercial decision, so a delivery proposes and a person decides. The checks happen where the data lives: the price in catalog, the money spent in purchasing. Cost: purchasing calls catalog synchronously when costs are keyed in (a 503 when it is down - nothing is recorded), and catalog now consumes an event. A supplier who is not VAT-registered charges no VAT, but the input VAT is still worked out from the product's rate; name a rate of zero on the order for such a supplier.


2. Event catalogue

Topic naming: pos.<domain>.<event>.v<n>, dead-letter: same + .dlt. Key = aggregate id.

Topic Producer Consumers Payload essentials
pos.auth.otp-requested.v1 auth notification email, otpCode, purpose, expiresAt
pos.auth.user-registered.v1 auth notification, reporting userId, email, name, roles
pos.auth.password-reset-requested.v1 auth notification email, resetToken, expiresAt
pos.auth.user-role-changed.v1 auth reporting userId, added[], removed[], actorId
pos.catalog.product-changed.v1 catalog inventory (kept per product in product_details, so a first stock row is named), reporting, sales productId, sku, name, taxClassId, sellByWeight, active
pos.catalog.price-changed.v1 catalog (none yet) productId, branchId (a price list's branch; null for the base price or a group-wide list), previousPrice, newPrice
pos.purchasing.po-approved.v1 purchasing notification, reporting poId, supplierId, total, approvedBy
pos.purchasing.goods-received.v1 purchasing inventory, reporting, catalog grnId, branchId, lines[{productId, qty, batchNo, expiry, unitCost — landed, without VAT}]; catalog opens a price review when it leaves an item below target
pos.purchasing.expense-changed.v1 purchasing reporting expenseId, expenseNumber, branchId (null = head office), category, incurredOn, amount and taxAmount (without VAT / the VAT), status, revision - the whole expense each time; readers keep the highest revision. Compacted
pos.purchasing.supplier-return-sent.v1 purchasing inventory returnId, returnNumber, supplierId, branchId, reasonCode, lines[{productId, batchNumber?, quantity, unitCost}] - the goods leave the named batch first, then soonest-expiring
pos.purchasing.supplier-cost-changed.v1 purchasing catalog, reporting supplierId, productId, previousUnitCost, newUnitCost, sourceType
pos.payments.payment-requested.v1 sales payment, customer paymentIntentId (the dedupe key), saleId, branchId, method, amount, phoneNumber (M-Pesa only), terminalReference (card only), customerId (loyalty only) — one event per tender, taken by whichever service settles that method
pos.payments.payment-authorized.v1 payment, customer (loyalty) sales, reporting paymentIntentId, saleId, method, amountAuthorized (the intent amount when the whole-shilling M-Pesa charge was paid), providerReference, approvalCode
pos.payments.payment-failed.v1 payment, customer (loyalty) sales, notification paymentIntentId, saleId, method, reasonCode (e.g. CANCELLED_BY_USER, TIMEOUT, PROVIDER_UNAVAILABLE), providerMessage
pos.payments.payment-refunded.v1 payment reporting refundId, paymentIntentId, saleId, returnId, method, amount, providerReference — emitted only once the provider (or a person) confirms it
pos.sales.sale-completed.v1 sales inventory, customer, reporting saleId, receiptNumber, branchId, registerId, shiftId, cashierId, customerId?, lines[] (as charged, with tax class), net/tax/grand totals, payments[{method, amount}] (authorised tenders, cash before change), cartId? (the reservation reference inventory consumes)
pos.sales.sale-voided.v1 sales inventory, customer, reporting saleId, reason, actorId
pos.sales.sale-cancelled.v1 sales inventory, customer saleId, branchId, cartId? (whose stock holds inventory releases), reason
pos.sales.return-processed.v1 sales inventory, payment, customer, reporting returnId, saleId, lines[{productId, qty, resaleable, batchNo?}], refundTotal, refundMethod (non-cash is refunded by payment), tillSessionId (the open shift that paid it; required for cash)
pos.sales.shift-closed.v1 sales reporting, notification shiftId, branchId, registerId, expected, declared, variance
pos.sales.receipt-email-requested.v1 sales notification saleId, receiptId, receiptNumber, email, recipientName?, lines[] as charged, taxBreakdown[] as stored on the receipt, payments[], totals, amountTendered?, changeGiven?, receiptText? (the branch's header/footer, address, phone, tax PIN) - the address travels on the event only; sales keeps none
pos.customers.loyalty-accrued.v1 customer reporting, notification customerId, saleId, points, balanceAfter, eligibleSpend, tierCode, expiresAt
pos.customers.tier-changed.v1 customer reporting, notification customerId, previousTierCode, tierCode, rollingSpend, upgrade (it falls as well as rises)
pos.inventory.stock-deducted.v1 inventory reporting saleId, branchId, lines[{productId, qty, batchAllocations[]}]
pos.inventory.low-stock.v1 inventory notification, purchasing productId, branchId, onHand, reorderPoint
pos.inventory.batch-expiring.v1 inventory notification, reporting batchId, productId, branchId, expiry, qty, value
pos.inventory.stock-valued.v1 inventory reporting snapshotId, branchId, valuedAt, page, pageCount, lines[{productId, sku, quantityOnHand, valueAtCost, currency}] — nightly and on demand; a snapshot counts only once every page has arrived
pos.inventory.negative-stock-detected.v1 inventory notification, reporting productId, branchId, onHand, triggeredBy
pos.inventory.adjustment-posted.v1 inventory reporting adjustmentId, branchId, reasonCode (a stock take posts its differences as STOCK_TAKE), lines[{productId, quantityDelta, valueAtCost - signed, what stock taken off was worth at its batches' cost}] - reporting's shrinkage
pos.customers.loyalty-accrued.v1 customer notification, reporting customerId, saleId, points, balance
pos.customers.tier-changed.v1 customer notification customerId, fromTier, toTier

Envelope (every message):

{
  "eventId": "01J8ZQ...",
  "eventType": "sales.sale-completed",
  "schemaVersion": 1,
  "occurredAt": "2026-09-20T10:15:30.123Z",
  "correlationId": "req-7f3a…",
  "causationId": "01J8ZQ…",
  "branchId": "…",
  "actorId": "…",
  "payload": { }
}

correlationId originates at the gateway and threads through every HTTP hop and every event, so one identifier follows a sale from the scan to the receipt email in Grafana.


3. Key flows

3.1 Registration with email OTP

Client          gateway        auth-service        Kafka        notification
  │ register ────▶ │ ──────────────▶ │
  │                │      create user PENDING + outbox row (one transaction)
  │                │                 │ ──relay──▶ otp-requested ──▶ │
  │                │                 │                              │ SMTP send, log
  │ ◀── 202 "check your email" ──────┤
  │ verify-otp ───▶ │ ─────────────▶ │ compare hash, attempts, expiry
  │                │      status=ACTIVE, outbox: user-registered ──▶ │ welcome email
  │ login ────────▶ │ ─────────────▶ │ access(15m) + refresh(7d)

The OTP is hashed at rest and compared in constant time. Verification responses are uniform whether the email exists or not.

3.2 Checkout saga

sales: create sale PENDING, reserve stock (soft)
  └─▶ payments.payment-requested
        payment: cash | M-Pesa STK | card terminal reference
          ├─ success ─▶ payments.payment-authorized
          │               sales: sale PAID, receipt number assigned
          │                 └─▶ sales.sale-completed
          │                       ├─▶ inventory: FEFO deduct, release reservation
          │                       ├─▶ customer:  accrue loyalty
          │                       ├─▶ reporting: project read models
          │                       └─▶ notification: email receipt (if requested)
          └─ failure/timeout ─▶ payments.payment-failed
                          sales: release reservation, sale CANCELLED

Compensation is explicit — there is no distributed transaction. A timeout on the payment step is treated as a failure, and a late payment-authorized for a cancelled sale is detected and routed to manual reconciliation rather than silently applied.

3.3 Offline sale synchronisation

Terminal offline            Terminal reconnects            sales-service
  scan, price from             POST /sales/sync              for each sale:
  cached catalog                 batch of queued sales         dedupe on clientSaleId
  sale stored in Dexie           Idempotency-Key header        revalidate price & tax now
  with clientSaleId (UUIDv7)                                   compare to client totals
                                                               ├─ match  → accept
                                                               └─ differ → accept + flag variance
                                                               deduct stock (may go negative → alert)
                            ◀── per-sale result + variances

Duplicate submission of the same batch is a no-op. Nothing is ever dropped silently: a sale that cannot be accepted surfaces in the terminal UI for supervisor action.

On the lane (Phase 14): the catalogue, this branch's prices and the scale-label formats (GET /scale-barcode-rules) are cached in IndexedDB while online. Connectivity is a ping through the BFF to the gateway, not navigator.onLine. A basket in progress when the network drops carries on offline with the same goods; the server cart is abandoned on reconnection. An offline sale takes one tender, cash or card. A batch's Idempotency-Key is written to its sales before the first send, so a batch whose answer was lost is resent as the same batch with the same key. A service worker (Serwist) precaches the app and keeps the last good copy of the lane's pages; it never caches an API call, which would make an offline till look online.

3.3c Supervisor approval at a lane

cashier F4 → price, reason ─▶ BFF /api/lane/approved {approverId, pin, permission, action}
                               │ action on the allow-list for that permission? else 400
                               ├─▶ auth  POST /auth/approvals (cashier's token)
                               │         PIN checked; wrong → counted in its own transaction
                               │   ◀──── 2-minute JWT: sub=supervisor, perms=[one], branches=[one], act=cashier
                               ├─▶ sales POST /carts/{id}/lines/{line}/price-override (approval JWT)
                               │         audited as the supervisor
                               ◀── {approverName, result}  (the token stays on the server)

3.3b Paying with loyalty points

sales --payment-requested(LOYALTY, customerId)--> customer-service
                                                    | spend points, soonest-to-expire lot first
        <--------payment-authorized-----------------+
        (or payment-failed: NO_CUSTOMER, NO_LOYALTY_ACCOUNT, INSUFFICIENT_POINTS)

payment-service ignores the method: it is not its tender.

A tender is settled by whoever holds the value behind it. Points live in customer-service, so no other service can spend them or say whether they were spent - and the settling runs in a Kafka listener, which has no caller token to call anyone with. A cancelled or voided sale returns the points to the lots they came out of, with the expiry they had.

3.4 M-Pesa payment

sales ──payment-requested──▶ payment-service: record the intent (the listener only records)
                                    │
                    dispatcher, after that commit, outside any transaction
                                    │
                             STK Push ──▶ Daraja ──prompt──▶ customer phone
                                    │                          │ enters PIN
        callback ◀── (may duplicate, beat the push's own response, or never arrive) ──┘
        │ authenticated by a secret path token; idempotent on CheckoutRequestID
        │ a callback for a push not yet recorded is parked, and applied when it is
        │
        └─ status sweep: pushes with no answer are queried; the query is the authority
           past the give-up point the intent fails as TIMEOUT so the lane moves on,
           and money that still arrives is recorded as a late payment, never dropped

Daraja takes whole shillings, so the push is the amount rounded HALF_UP; paying exactly that charge settles the four-decimal sale amount, and the difference is kept as rounding on the payment. A push that timed out is never resent: it may have reached the phone.

Refunds go back by the method they came in. A full M-Pesa payment is reversed through Daraja's Reversal API; part of one cannot be (M-Pesa reverses whole transactions), so it is raised for a person, who settles it another way and records how. Daraja has no statement API: the day's export from the M-Pesa organisation portal is uploaded and reconciled receipt by receipt.


4. Data ownership map

Schema Owner Anything else needing this data
auth auth-service Reads identity claims from the JWT — never the tables
catalog catalog-service sales caches product/price via events; inventory caches product metadata
inventory inventory-service sales sees availability via API; reporting projects from events
purchasing purchasing-service inventory receives stock via goods-received events; catalog checks their cost against price; reporting takes expenses from expense-changed
sales sales-service reporting projects from events; payment correlates by saleId
payment payment-service sales learns outcomes via events only
customer customer-service sales attaches customerId; loyalty resolved via events/API
notification notification-service —
reporting reporting-service Read-only projections; rebuildable from its own append-only event_log (ADR-011)

Grants are per-role and per-schema, so a cross-schema query fails at the database, not at review time.


5. Port and topic reference

Component Port
web (Next.js) 3000
api-gateway 8080
auth-service 8081
catalog-service 8082
inventory-service 8083
purchasing-service 8084
sales-service 8085
payment-service 8086
customer-service 8087
notification-service 8088
reporting-service 8089
postgres 5432
kafka 9092
redis 6379
prometheus 9090
grafana 3001
tempo 3200
loki 3100

Topic defaults: 3 partitions, replication 1 in dev and 3 in production, 7-day retention for transactional events, 30 days for sales.* and stock-valued (a consumer outage window - reporting rebuilds from its own log, not from the topics), compaction for catalog.product-changed.