D3 Design Order and Credit Services - AY2627S1-CS3219-P23/foc GitHub Wiki
This page answers the D3 progress-check questions (instructions). It covers all five D3 parts: the Order Service, the Credit Service, the asynchronous workflow, containerization, and the nice-to-have plan. The demo is in Week 10 — 15 minutes, then 30 minutes of Q&A. Related: architecture.md · credit-service.md · notification-service.md · Notification Service D3 page · D2 design · demo script
flowchart LR
subgraph M["Merged (as of 2026-10-20)"]
UA["user-service · supplier-service ·<br/>web SPA · compose stack ·<br/>recovery/reset #94 · revoke #178"]
GW["API gateway + logout denylist<br/>#151 · PR #176 · G1–G12"]
ORD["order-service: lifecycle · saga ·<br/>outbox · reads · auth<br/>#187 #202 #208 #213 #214"]
CRD["credit-service: operations · listener ·<br/>reply outbox · REST · schedulers<br/>#166–#175 #188–#190 #193 #210 #224"]
NA["notification: consumer pipeline ·<br/>REST API #205 · web center #199 ·<br/>STOMP client #185 · names D32 #222"]
CO["foc-contracts: 10 request + 2 credit<br/>events · registry · JSON fixtures"]
CI["CI pipeline + Claude PR review #194"]
end
subgraph P["In flight / remaining"]
WI["web ↔ order integration PR #217<br/>(order pages run on a mock until it merges)"]
SUPV["Order → Supplier: pickup validation F1.1.1 ·<br/>1 km rule F8.1 — not yet wired"]
N2H["N2H implementations N1 N3 N4 N5 N8"]
end
classDef ok fill:#1a7f37,color:#ffffff
classDef plan fill:#9a6700,color:#ffffff
class UA,GW,ORD,CRD,NA,CO,CI ok
class WI,SUPV,N2H plan
ℹ️ How to read this page: everything marked (chosen: …) cites the PR, issue or meeting where the team decided it. Everything marked (open) is a pending team decision, presented as a factual comparison table. A "repo lean" row only states what the existing record already does; it is not a recommendation.
The standing allocation from the README table, crossed with what has actually merged (checked 2026-10-20). There are no GitHub milestones; scheduling lives in the sprint: and priority: labels.
| Member | Core service (D3 work, merged) | Remaining before the session | N2H | Writeup/demo sections owned |
|---|---|---|---|---|
| Aung Ko Khant |
Order Service built: scaffold #187, lifecycle + auth #202, events + timers #208, read endpoints #213, request.dropped #214
|
supplier validation F1.1.1 + 1 km rule F8.1 (no Order→Supplier call exists yet); web integration PR #217 | N4 centralized logging #47 | Part 1; Part 3 §1/§3 (producer side) |
| Ryan Ang Jun Wen | Credit Service complete: scaffold #166, topology #168, operations #175, reply outbox #188, REST API #189, listener #190, expiry + redistribution #193, web balance/history/admin pool #195, upcoming expiries #210, outbox retention #224 | account-closure decision (§P2.5) |
N3 dispute resolution + failed-order management #46 (labeled service: order + service: credit — the Order half coordinates with Aung; design in §P5.2.1) |
Part 2 |
| Leong Wei Zhi | Notification complete: web STOMP client #185, REST API + name design #205/#222, Notification Center #199, dedupe purge #201, chime #215 | — | N1 ratings #44 (labeled service: user — coordinate with Kwey) |
Part 3 §2/§4–§6; notification page; demo script; page upkeep |
| Kwey Xiu Xi | User Service done for D3: gateway + logout denylist #176, recovery/reset #179/#180, session revoke on demote/remove #183, CI pipeline #194, Docker-less test skip #197 | — | N5 cloud deployment / CI-CD #48 (pairs with Part 4; the CI half landed in #194) | Part 4 |
| Alastair Tan Choon Wei | Supplier NFRs: caching #36, capacity #37 — still open | the two NFR issues | N8 supplier bookmarks (decided — issues #225–#227, design in supplier-service.md D8–D13 via PR #228) | Part 1 §2; Part 5 |
✍️ Flags for the team:
- Order → Supplier integration is the one unimplemented D3-mandatory edge (F1.1.1 pickup validation, F8.1 proximity).
/createcurrently trusts the client-sentsupplierId/supplierName. Decide whether it lands before the session or is presented as "remaining workflow, design below" (§P1.2).- The web's order pages ship behind PR #217. Until it merges, the demo's UI half for orders runs on a mock — the curl script (D3-Demo-Script) is the backend-proof path either way.
- Alastair's N2H is resolved (N8). His supplier NFR issues #36/#37 are now the only unstarted core-service items.
This is the D3 slice of architecture.md: the two new services, their databases, the calls they make, and the async paths. One domain and one database per service; services interact only through published APIs (database-per-service). Since the gateway cutover (PR #176, merged 2026-10-11), the OpenResty API gateway on ${GATEWAY_PORT:-8090} is the only published API entry point. It verifies JWTs and rejects denylisted (logged-out, reset or demoted) tokens on every route (#151/#178; decisions G1–G12 in docs/gateway.md), and the backend containers have no published host ports. Every WEB edge below runs through it (/api/order/**, /api/credit/**, and so on).
flowchart LR
WEB["Web SPA"]
GW["API gateway<br/>JWT + denylist G1–G12"]
ORD["Order Service<br/>schedulers: pending sweep,<br/>expiry, auto-complete"]
CRD["Credit Service<br/>schedulers: lot expiry, redistribution,<br/>outbox publisher + purge"]
SUP["Supplier Service"]
NOT["Notification Service"]
MQ[/"RabbitMQ<br/>request-events · credit-events"/]
ODB[("order-db<br/>PostgreSQL 17")]
CDB[("credit-db<br/>PostgreSQL 17")]
WEB --> GW
GW --> ORD
GW --> CRD
ORD -.->|"validate pickup F1.1.1 ·<br/>1 km rule F8.1 — not yet wired"| SUP
MQ ==>|"submitted · completed ·<br/>cancelled · expired"| CRD
CRD ==>|"credit.reserved ·<br/>credit.reservation-rejected"| MQ
MQ ==>|"reservation replies"| ORD
ORD ==>|"event per transition F0.2<br/>(transactional outbox)"| MQ
MQ ==> NOT
NOT ==>|"STOMP ≤5 s NFR1"| WEB
ORD --- ODB
CRD --- CDB
A note on vocabulary: the domain object is a request. Decision D22 in notification-service.md renamed order→request everywhere — events, packages, API nouns. "Order Service" survives only as the service's name.
The Order Service was built across PRs #187 (scaffold), #202 (lifecycle, ownership and auth), #208 (events, expiry and auto-complete timers), #213 (read endpoints, filters and sort) and #214 (request.dropped). The service record is the order-service README. The web pages exist (#192) but run against a mock until PR #217 integrates them.
(chosen: PostgreSQL 17 + Spring Data JPA — Ko-Khan via options Q&A, 2026-10-10, when order-db was wired into compose; recorded in architecture.md.) The comparison that decided it, kept here for the mentor's "why" question:
| PostgreSQL + Spring Data JPA (chosen) | MongoDB + Spring Data MongoDB | |
|---|---|---|
| Multi-row ACID transaction (state flip + timestamps + outbox row — now real, §3) | yes, default | multi-document transactions require a replica set |
| Atomic single-winner transitions (§6) | JPA @Version / conditional UPDATE
|
single-document findAndModify equivalent |
| Request shape | fixed columns — stable, flat (see below) | flexible documents |
| List/filter queries (state, supplier, courier, credit range, dates, paging — F2, §4) | SQL + JPA paging, as in supplier-service | equivalent capability |
| Team precedent | the other four services all run PostgreSQL | none |
| Compose footprint | one more postgres container |
first mongo container + new ops knowledge |
The consistency row is the one that decided it: a transition commits the state flip, its timestamp and its outbox event row in a single transaction. The schema is derived by JPA (ddl-auto: update — see §P4.4). The implemented requests table:
erDiagram
requests {
bigint id PK "req"
bigint requester_id "req - JWT sub of creator"
bigint courier_id "opt - null until accepted"
bigint supplier_id "req - pickup, Supplier service id"
text supplier_name "req - snapshot at creation (see §2)"
text dropoff_location "req - free text (F1.1)"
text item "req - what to fetch (F1.1)"
int credit_amount "req - integer >= 1 (F1.1.3)"
text status "req - the nine states of §3 (F0.1 + saga)"
timestamptz deadline "req - must be future at creation (F1.1.2)"
timestamptz accepted_at "opt"
timestamptz collected_at "opt"
timestamptz arrived_at "opt - starts the 1 h auto-complete clock (F7.2)"
timestamptz created_at "req"
bigint version "req - optimistic lock (§6)"
}
outbox_event {
uuid event_id PK
varchar event_type
text payload
timestamptz created_at
timestamptz sent_at "null = not yet published"
}
credit_amount is the reward reserved against the requester (Credit F2.1). status holds D1's six states plus the saga's PENDING/REJECTED and the courier-arrival ARRIVED (§3). No credit balance is stored here: balances live only in the Credit Service (database-per-service). outbox_event is the transactional outbox (§P2.5), the same shape as credit-service's D10.
A request references a supplier (the pickup) and two users (requester and courier) by their service-owned IDs. The JWT sub is the same platform user ID that appears in event parties[].
(chosen in implementation: ID + name snapshot — PRs #202/#213.) /create stores supplier_id plus a required supplier_name taken at creation time — the hybrid column in the table below. With this, listings never fan out to the Supplier Service, a later rename or hard delete leaves history readable, and the ID is still there for live lookups when acting on the request. The comparison the decision came from:
| ID reference only | Full snapshot at creation | Hybrid: ID + name snapshot (implemented) | |
|---|---|---|---|
| Supplier renamed while request active | listing shows new name (live lookup) | shows name as at creation | snapshot shown; ID resolves live where needed |
| Supplier hard-deleted (guard rejects only outstanding orders — completed ones survive, supplier-service.md D5) | completed request can no longer resolve the name | unaffected | unaffected |
| User soft-deleted then purged on day 31 (user-service README) | order history shows an unresolvable ID | unaffected | unaffected |
| Runtime coupling when listing requests | every list call fans out to Supplier/User | none | none |
| Storage | minimal | duplicates every display field | duplicates one field |
✍️ Gap (Order owner + Alastair): today
supplierIdandsupplierNameare client-supplied and unvalidated — no Order→Supplier call exists yet. F1.1.1 (the pickup must be a known supplier) and F8.1 (the 1 km proximity rule, which needs the supplier coordinates that supplier-service.md D6 stores) are the remaining mandatory integration; architecture.md keeps the edge in its diagram. Until it lands, the F8.1 row in §5's table is design, not code.
As implemented in OrderStateMachine (#202/#208; the saga states follow credit-service.md D7–D8). Two states were added to D1's six for the saga (PENDING, REJECTED), and ARRIVED was promoted to a real state. D1 treated courier-arrival as a non-state event; the implementation needs it as a state, both because it is the only state COMPLETE is allowed from and because it anchors the F7.2 auto-complete clock:
stateDiagram-v2
[*] --> PENDING : POST /create (request.submitted via outbox)
PENDING --> CREATED : credit.reserved
PENDING --> REJECTED : credit.reservation-rejected
PENDING --> CANCELLED : no reply in 30 s (pending sweep)
CREATED --> ACCEPTED : courier accepts (F3.1)
ACCEPTED --> CREATED : courier drops (F5.2, relist — request.dropped)
ACCEPTED --> COLLECTED : courier marks collected (F4.1)
COLLECTED --> ARRIVED : courier at drop-off (F4.1.2)
ARRIVED --> COMPLETED : requester confirms (F7.1)<br/>or auto 1 h after arrival (F7.2)
CREATED --> CANCELLED : requester cancels (F5.1)
CREATED --> EXPIRED : deadline passes (F6.1)
ACCEPTED --> EXPIRED : deadline passes before collect (F6.2)
REJECTED --> [*]
COMPLETED --> [*]
CANCELLED --> [*]
EXPIRED --> [*]
(chosen: the credit side of every transition is a saga by choreography — credit-service.md D7–D9, #163; both halves now run.) Order makes no synchronous call to the Credit Service. It publishes request events through its transactional outbox: the event row commits with the state flip, and a poller publishes it with publisher confirms (#208, copying credit-service's D10). It consumes the replies on its own queue, order-service.credit-events, with a 2 s × 5 retry leg and a DLQ (credit's D11 layout). A new request starts as PENDING and stays hidden from couriers until the reservation is confirmed. If it is still PENDING after ORDER_PENDING_TIMEOUT_MS (30 s, swept every 15 s), it is cancelled, so that a late reservation gets released.
Every transition is atomic inside the service (one @Version-guarded commit, §6); the loser of any race gets a 409 naming the actual state. Every transition publishes exactly one event (F0.2). The implemented table:
| From → To | Trigger | Who may trigger | Credit effect (async, keyed by request ID) | Event published | Invalid attempt → |
|---|---|---|---|---|---|
| — → PENDING | POST /create |
any authenticated user (becomes requester) | reserve, or record the refusal | request.submitted |
400 listing every problem at once (missing fields, creditAmount < 1, past deadline) |
| PENDING → CREATED | credit.reserved |
system (reply listener) | — (now held) | request.created |
a reply for a request no longer PENDING is ignored |
| PENDING → REJECTED (final) | credit.reservation-rejected |
system | — (nothing held) |
request.rejected (with Credit's reason) |
same |
| PENDING → CANCELLED | pending sweep, no reply in 30 s | system | release (an amount-0 RELEASED record if nothing was reserved, so a late reservation cannot stick) |
request.cancelled |
— |
| CREATED → ACCEPTED | POST /accept |
any user except the requester | — (reservation already held) | request.accepted |
409 self-accept · 409 wrong state · 409 past deadline (before the sweep ran) |
| ACCEPTED → COLLECTED | POST /collect |
assigned courier only | — | request.collected |
404 not the courier (masking, §5) · 409 wrong state |
| ACCEPTED → CREATED | POST /drop |
assigned courier, before collection | — (reservation stays held for the next courier) |
request.dropped (#214 — a distinct type: request.cancelled would release the reward) |
404 / 409 |
| COLLECTED → ARRIVED | POST /arrive |
assigned courier only | — (starts the F7.2 clock) | request.courier-arrived |
404 / 409 |
| ARRIVED → COMPLETED |
POST /complete or auto-complete sweep 1 h after arrival (F7.2) |
requester, or system | transfer → courier | request.completed |
404 not the requester · 409 wrong state |
| CREATED → CANCELLED | POST /cancel |
requester | release | request.cancelled |
404 / 409 (e.g. accepted first, F5.1.1) |
| CREATED / ACCEPTED → EXPIRED | expiry sweep (every 5 min) past deadline
|
system | release | request.expired |
— (a late accept gets 409) |
COLLECTED and ARRIVED never expire (F6.3). F6.4's re-release of an expired request is simply a brand-new POST /create — new reservation, new ID. The ACCEPTED→CREATED event question this page used to carry as (open) was answered by #214: a courier drop is its own event type, request.dropped, with parties set to the requester only. The point of the distinct type is that the Credit Service releases on request.cancelled — it must never see a drop as final.
✍️ Still owed to the D1 document (from credit-service.md "Requirements to revise"):
PENDING,REJECTEDandARRIVEDare three states beyond D1's six, and an insufficient balance is no longer a synchronous 422 on create — the request simply ends upREJECTEDshortly after. Order F0.1, F1.1, F1.1.4 and Credit F2.1.3 need rewording by the team.
As implemented (order-service README is the contract of record). All routes sit behind the gateway (/api/order/**, prefix stripped in transit) and require the platform JWT. The transition actions are the POSTs in §3's table — POST /create|accept|collect|arrive|drop|cancel|complete — each taking a small JSON body (requestId, plus the six request fields on /create) and answering {"message": "…"}.
Queries are paged like the supplier listing (page from 0, size 1–100 with default 20, a {content, page, size, totalElements, totalPages} envelope), newest first:
| Endpoint | Returns | Access |
|---|---|---|
GET /requests?state=CREATED&page=&size= |
open errands for couriers (F2.1); only CREATED is accepted as state, and past-deadline requests are hidden even before the expiry sweep runs (#213) |
any logged-in user |
GET /requests/{id} |
the request in full (every column incl. requesterId, courierId, step timestamps) |
requester, assigned courier, or ADMIN/OWNER — anyone else gets 404 (§5) |
GET /requests/mine?role=requester&state= |
caller's requests as requester | the caller |
GET /requests/mine?role=courier&state= |
caller's assigned deliveries | the caller |
GET /users/{id}/requests?state= |
any user's requests as requester or courier (admin view) | ADMIN/OWNER; others 403 |
Every list also takes optional filters, AND-combined: supplierId, minCredit/maxCredit (inclusive, ≥ 1), deadlineAfter/deadlineBefore and createdFrom/createdTo (ISO-8601 instants with offset), and sort = createdAt (default) or deadline (nearest first). An inverted range, an unknown sort, or a wrong-typed value gets a 400 naming the parameter. List items are trimmed to the display fields. Validation conventions: /create returns one 400 listing all field problems at once, and a missing or invalid token gets a 401 problem+json.
Two checks run on every mutating call, both enforced in the backend (#202; never only hidden UI controls — the D2 pattern):
-
Identity comes from the JWT
sub(verified locally with the shared secret; the gateway has already checked signature, expiry and the denylist). -
Relationship comes from the request row:
requester_id/courier_idcompared againstsub.
One deliberate twist on the D2 convention: a step by the wrong person answers 404, not 403 — the same answer as a nonexistent ID — so a stranger cannot probe whether a request exists (no existence oracle; notification-service made the same choice in D26). Self-accept is the exception. The requester can already see their own request, so /accept answers 409 ("you cannot accept your own request") rather than masking.
| Action | USER (unrelated) | Requester | Assigned courier | ADMIN/OWNER |
|---|---|---|---|---|
| accept | ✅ (becomes courier) | ❌ 409 self-accept | n/a | role alone grants nothing — admins don't deliver |
| collect / arrive / drop | ❌ 404 | ❌ 404 | ✅ | ❌ 404 |
| complete | ❌ 404 | ✅ | ❌ 404 | ❌ 404 |
| cancel | ❌ 404 | ✅ from CREATED | ❌ 404 (a courier drops instead) | ❌ 404 |
| view detail | ❌ 404 | ✅ | ✅ | ✅ (read-only oversight) |
GET /users/{id}/requests |
❌ 403 | — | — | ✅ |
The same student as requester and courier at once (a mentor question): requester and courier are modes, not roles (D2 §1). One account may hold open requests and deliveries at the same time; the only restriction is per-request (caller ≠ requester on accept, F6.1.1). The F8.1 proximity rule will be computed entirely server-side, against the supplier coordinates that supplier-service.md D6 stores — no client-supplied location is trusted. It is not yet implemented (§2's gap).
(chosen: optimistic locking via JPA @Version — the requests entity carries a version column, #202; user-service precedent #135.) The options that were compared:
| Conditional UPDATE (check rows-affected) | Optimistic @Version (chosen) |
Pessimistic SELECT … FOR UPDATE
|
|
|---|---|---|---|
| Two couriers accept simultaneously | exactly one row updated; other sees 0 rows → 409 | one commit wins; the loser's stale version fails the commit → 409 | second blocks until first commits, then fails the state check |
| Accept vs cancel/expiry at the same instant | same single-statement guarantee | same, via version bump | same, via lock ordering |
| Extra reads/retries | none | none needed — the loser is simply refused (the state check would fail a retry anyway) | lock held across the transaction |
| Repo precedent (factual) | — | user-service (#135) | credit-service.md D2 (pessimistic) — chosen there because credit ops must queue, not fail |
Corner cases. Each is one atomic, state-checked commit, so the answers follow mechanically:
-
Acceptance arrives together with cancellation or expiry. Both race on
status=CREATED. Exactly one wins; the other returns a 409 carrying the actual state. If expiry wins,request.expiredis already in the outbox of the same commit, so the Credit Service releases. A late accept cannot resurrect the request, because the state is terminal./acceptalso refuses a past-deadline request outright, which shrinks the race window to the sweep interval. -
Completion submitted twice (a double-click, or a manual confirm racing the F7.2 auto-complete). The state machine admits one
ARRIVED→COMPLETED, so only the winner writesrequest.completedto the outbox. Even a redelivered broker copy changes nothing in the Credit Service — the reservation is alreadyTRANSFERRED(Credit NFR2.2.1). One payment, ever. - Courier drops while the requester confirms completion. Drop requires ACCEPTED; complete requires ARRIVED. The preconditions are disjoint, so no interleaving produces both.
-
A reply for a request that moved on. A
credit.reservedarriving after the 30 s pending sweep has cancelled the request is ignored by the state check — and the sweep'srequest.cancelledhas already told Credit to release. The saga converges.
The Credit Service is complete and running end to end: scaffold #166, exchange topology #168, operations #175, reply outbox and publisher #188, read API #189, request-event listener #190, expiry and monthly redistribution scheduler #193 (D12), upcoming-expiries route #210, web balance/history/admin-pool pages #195, and the outbox retention purge #224 (D13). The design of record is credit-service.md (decisions D1–D13).
(chosen: PostgreSQL + Spring Data JPA — credit-service.md D1, with @Transactional for F5/NFR2.1.) SQL over NoSQL because the core operations are multi-row, invariant-preserving transactions: a transfer touches two accounts, a held-credits record, credit lots and two history rows in one commit. The closed-economy invariant (Σ available + reserved + pool = credits ever created) is checked over exactly these rows.
The data model below is transcribed from credit-service.md "Database". It is the Flyway V1 schema merged in #166, amended for #169 so a slice references its lot and a drained lot is kept; V2 adds outbox_event.next_attempt_at (#188) and V3 adds a partial index on outbox_event.sent_at for the retention purge (#224). User and request IDs are opaque strings, because they belong to other services:
erDiagram
credit_account ||--o{ credit_reservation : "requester of"
credit_account ||--o{ credit_lot : owns
credit_account ||--o{ credit_history : "has"
credit_reservation ||--o{ credit_reservation_slice : "taken from"
credit_lot ||--o{ credit_reservation_slice : "funded"
credit_account {
varchar user_id PK
integer available "CHECK >= 0"
integer reserved "CHECK >= 0"
timestamptz created_at
}
credit_reservation {
varchar request_ref PK "the order's ID"
varchar requester_id FK
varchar courier_id FK "null until transfer, required when TRANSFERRED"
integer amount "CHECK > 0 while HELD"
varchar status "HELD, REJECTED, TRANSFERRED, RELEASED"
timestamptz created_at
timestamptz settled_at "null while HELD"
}
credit_reservation_slice {
bigint id PK
varchar request_ref FK
bigint lot_id FK "the lot the credits came from"
integer amount "CHECK > 0"
timestamptz earned_at
timestamptz expires_at "null = never"
}
credit_lot {
bigint id PK
varchar owner_id FK
integer remaining "CHECK >= 0"
timestamptz earned_at
timestamptz expires_at "null = never"
}
credit_history {
bigint id PK
varchar user_id FK
varchar type
integer amount "CHECK > 0"
varchar request_ref "null if not for a request"
timestamptz occurred_at
}
common_pool {
smallint id PK "always 1"
integer balance "CHECK >= 0"
}
redistribution_run {
char period PK "e.g. 2026-10"
integer distributed
integer remainder
timestamptz ran_at
}
outbox_event {
uuid event_id PK
varchar event_type
text payload
timestamptz created_at
timestamptz sent_at "null = not yet published"
timestamptz next_attempt_at "null = due at once"
}
Beyond the account, the held-credits record, the lots and the history, the schema holds: the slices a reservation drew from each lot (each slice references its lot, and a lot spent to zero is kept so a release can refill it); the common pool; one row per month already redistributed (redistribution_run); and the outbox of replies waiting to be published (outbox_event, D10). A held-credits record has four statuses: HELD, REJECTED (a refusal, recorded so a redelivery gets the same answer), TRANSFERRED and RELEASED. A transfer writes two history rows: TRANSFER_OUT for the requester, TRANSFER_IN for the courier.
Reserved vs total consistency. available and reserved are columns on one account row, updated in the same transaction as the held-credits record and its lot slices — a reservation is never reflected in one place and not the other. Total is never stored; it is computed as available + reserved on read. Pessimistic row locks, taken in a fixed order (the record, then accounts by ascending user ID, then the common pool), make competing operations queue instead of reading stale balances (D2). A user's lots are read and changed only under that user's account lock. The request_ref primary key makes every reserve, release and transfer happen once per request (D3, NFR2.2).
(chosen: get-or-create inside the Credit Service, idempotent by the unique user ID — credit-service.md D6, #163.) The User Service makes no call at sign-up. The account is created — available = 5, reserved = 0, as one non-expiring lot — the first time a trusted user ID is seen. (The amount is configuration, CREDIT_SIGN_UP_CREDITS, default 5, and applies to accounts created after it is set.) "First seen" means either a read of any of the three /credits/me routes (the JWT's user), or the first request event that names the user: reserve and release create the requester's account, transfer creates the courier's. The user_id primary key makes this happen once per user — a repeat finds the existing row and allocates nothing.
(chosen: 5 — credit-service.md D6, as D1 F1.1 committed.) The amounts that were compared:
| 5 (as per D1 F1.1) | 20 | 50 | |
|---|---|---|---|
| Errands affordable at minimum reward 1 before earning | 5 | 20 | 50 |
| D1 document change needed | none | amend F1.1/F1.1.2 | amend F1.1/F1.1.2 |
| Effect on closed economy | smaller circulating pool; new users must courier early to spend more | mid | larger float per account |
(chosen: provision on first sight of the user, the third column — credit-service.md D6.) With no sign-up call, there is no "sign-up succeeded but provisioning failed" step left to handle. The options that were compared:
| Fail the sign-up verify step | Accept sign-up, retry provisioning async | Lazy-provision on first credit read | |
|---|---|---|---|
| User visible effect | sign-up errors until Credit is back | account works; balance appears when retry lands | account works; first profile view provisions |
| Window with an account but no credit row | none | until retry succeeds | until first read |
| Extra moving parts | none | retry queue/scheduler in user-service | provisioning path in a read endpoint |
| Invariant | every verified account has an account row | eventually | eventually |
As decided, "first credit read" is actually wider than the column title: the first request event naming the user creates the account too. A user who submits a request before ever opening their balance page is still provisioned.
(chosen: a saga between the Order and Credit Services, by choreography — credit-service.md D7–D9, #163.) There is no synchronous call between the two services. Each operation is one @Transactional unit in the Credit Service, started by a request event and keyed by the request ID. A reservation's reply is written to an outbox table in the same transaction and published from there (D10).
-
Reserve (
request.submitted). With enough available balance (F2.1.2/F2.1.3, checked in-transaction under the account's row lock), the amount moves from available to reserved, aHELDrecord is written with one slice per lot it drew from (soonest expiry first, the non-expiring sign-up lot last — D4), andcredit.reservedis published. Otherwise aREJECTEDrecord is written,credit.reservation-rejectedis published, and Order ends the request asREJECTED(Order F1.1.4). Either way, the answer is no longer immediate — the request waits asPENDING(credit-service.md "Requirements to revise"). -
Release (
request.cancelled,request.expired).HELD → RELEASED. Each slice returns to the lot it was taken from, so no new lot is created and the credits keep their expiry and their place in the spend order. A repeat changes nothing. If no record exists yet, an amount-0RELEASEDrecord is written so a laterequest.submittedis ignored. -
Transfer (
request.completed).HELD → TRANSFERRED: the requester'sreservedgoes down, the courier'savailablegoes up as a new lot earned now (the 3-month expiry clock starts — D4; the lifetime is configuration,CREDIT_EARNED_CREDIT_LIFETIME_MONTHS), and two history rows are written. A repeat changes nothing (NFR2.2.1). A crash mid-transfer rolls back whole, and the unacknowledged event is redelivered (F5.1.1). -
A redelivered
request.submittedon aHELDorREJECTEDrecord sends its reply again, rebuilt from the event with the decision the record holds. A refusal stays a refusal even if the balance has since grown (#165, #212).
The rest is specified in credit-service.md, and all of it is live. What each event does in every record status is its table "How each event is handled"; how lots are spent, returned and expired, with a worked example, is the section of that name; the failure matrix is "Failure and retry behaviour". The implementations: the operations (#175); the reply outbox and its publisher (#188, D10 — publisher confirms plus the mandatory flag, a 1 s poll, unroutable replies parked 30 s); the listener (#190, D11 — 2 s × 5 retry then DLQ, where a conflicting event dead-letters and a too-early request.completed retries); and the daily expiry/redistribution scheduler (#193, D12 — 00:00 Asia/Singapore and at startup, with a missed month completed by the next run). Sent outbox rows are purged after 30 days (#224, D13). The reservations, lots and history are the ledger — they are never purged.
sequenceDiagram
participant R as Requester (web)
participant O as Order Service
participant B as RabbitMQ
participant C as Credit Service
R->>O: POST /requests/{id}/complete
O->>O: CAS COLLECTED→COMPLETED (one winner)
O-->>R: 200
O-)B: request.completed (request-events)
B-)C: delivered to Credit's own queue
C->>C: one tx: HELD→TRANSFERRED, reserved−, available+ (new lot), 2 history rows
C-->>B: ack after commit (balances visible ≤5 s, NFR1.1)
Note over B,C: same event redelivered → record already TRANSFERRED, no second payment
Expiry and redistribution (F6.1–F6.1.2 — credit-service.md D4, D12; #193 for #173). One @Scheduled routine runs daily at 00:00 Asia/Singapore, and once at startup:
-
Expiry sweep, one transaction per account: whatever remains in each lot past its
expires_atmoves to the common pool, with anEXPIRYhistory row. Held credits are untouched; if they are released later they return to their lot, and the next sweep moves them. -
Redistribution, if the current Singapore month has no
redistribution_runrow yet: each account getsfloor(pool / accounts), and the remainder goes out one credit each to randomly chosen accounts, all in one transaction. Each share is a new lot whose expiry starts the day it is received. A month is recorded even when the pool is empty, and the uniqueperiodkey lets only one run per month commit.
The sweep runs first, so whatever expired is in the pool before the pool is shared out — the month's redistribution happens at 00:00 on the 1st, straight after that night's sweep. A month that was missed (service down) or whose run failed is completed by the next run, at startup or the next day. One account's failed expiry rolls back only that account. On the web, the credits page lists upcoming expiries and highlights the next 7 days (#196, #210).
| Interface | Operations | Callers |
|---|---|---|
Credit account REST API (read-only, via the gateway /api/credit/**) |
GET /credits/me — own balances (F4.1) · GET /credits/me/history — own transaction history, paged, amount and date-range filters (F4.1.2–3) · GET /credits/me/expiries — the caller's expiring lots, soonest first (#210, F6.1) |
Web (the JWT caller's own data only) |
| Admin REST routes (read-only) |
GET /credits/admin/pool — pool balance, account count, a share/remainder preview of a redistribution now, the last recorded run, the next run time · GET /credits/admin/redistributions — recorded months, paged (#193/#195) |
Web admin dashboard; role must be ADMIN/OWNER, 403 otherwise |
Request events consumed (request-events exchange) |
request.submitted → reserve · request.completed → transfer · request.cancelled / request.expired → release |
published by the Order Service |
Reply events published (credit-events exchange) |
credit.reserved · credit.reservation-rejected
|
consumed by the Order Service |
There is no internal operations API: the User Service does not provision (§2), and the Order Service does not call reserve, release or transfer (§3). The REST API changes no balance, apart from creating the caller's account on first read (§2). Neither admin route writes anything — there is deliberately no admin credit-adjustment endpoint in committed scope (that is N7's territory).
The merged contract (#189; of record in credit-service.md "Read API contract"):
GET /credits/me
{ "available": 5, "reserved": 0, "total": 5 }
GET /credits/me/history?page=&size=&minAmount=&maxAmount=&from=&to=
{
"content": [
{ "id": 42, "type": "RESERVE", "amount": 3, "requestRef": "r-1", "occurredAt": "2026-10-10T08:00:00Z" }
],
"page": 0, "size": 20, "totalElements": 1, "totalPages": 1
}
GET /credits/me/expiries
{ "items": [ { "amount": 3, "expiresAt": "2027-01-12T07:41:09Z" } ] }
GET /credits/admin/pool
{
"poolBalance": 37, "accounts": 10, "share": 3, "remainder": 7,
"lastRun": { "period": "2026-10", "distributed": 12, "remainder": 2, "ranAt": "2026-09-30T16:00:00Z" },
"nextRunAt": "2026-10-31T16:00:00Z"
}
GET /credits/admin/redistributions?page=&size=
{
"content": [ { "period": "2026-10", "distributed": 12, "remainder": 2, "ranAt": "2026-09-30T16:00:00Z" } ],
"page": 0, "size": 20, "totalElements": 1, "totalPages": 1
}- The page is supplier-service's
PageResponseshape:pagefrom 0,sizedefault 20, 1 to 100. Both are hand-validated rather than SpringPageable, so out-of-range values get a 400 instead of being silently clamped. A page past the last one is a 200 with emptycontent. - History order is fixed:
occurredAtdescending, theniddescending. There is no sort parameter. Filters are optional and combine:minAmount/maxAmountinclusive,frominclusive,toexclusive — full ISO-8601 instants with an offset (2026-10-10T08:00:00Z); a date alone is a 400. The web page sends UTC. -
amountis always positive; gain or loss follows fromtype.requestRefis null for rows not about a request. - Expiries list every lot still holding credits,
expiresAtascending. The never-expiring sign-up lot, drained lots and held (reserved) credits are left out. An already-pastexpiresAtmeans the lot is waiting for that night's expiry run. - Admin pool:
accountscounts every credit account, which can be fewer than the registered users.shareandremainderpreview a redistribution run now (both 0 with no accounts; the run itself counts again under its locks).lastRunis null before the first run;nextRunAtfollows the D12 schedule. In the redistributions page, newestperiodcomes first, anddistributedcounts every credit taken from the pool, the remainder included. - 400 problem+json for an invalid filter/page/size; 401 for a missing or invalid token; 403 only on the admin routes. Never 404: an account always exists, and no history is just an empty page.
The two mentor scenarios, and what detects and repairs each:
| Scenario | What happens | Detection & resolution (as built) |
|---|---|---|
| A request is saved, but its reservation never completes (the event or the reply is lost or delayed, or Credit is down) | the request stays PENDING, hidden from couriers |
Order commits the request row and the request.submitted outbox row together, so a reservation never exists without a request behind it. Credit down: the event waits in Credit's durable queue. No reply within 30 s: the pending sweep cancels the request and request.cancelled releases a reservation that happened anyway (credit-service.md failure table) |
| Request completed, but Credit temporarily down for the transfer | request row says COMPLETED, payment pending | Order completes regardless; request.completed waits in Credit's durable queue and the transfer runs when Credit is back. A failure while processing goes to Credit's retry queue (2 s × 5), then its dead-letter queue for inspection (D11). A redelivered event is safe: the record's status lets the transfer happen once |
| Account deleted with non-zero balance (User F3.1.3 purge, F6.1.3 admin removal) | credits would vanish — breaks the closed economy | (open) — credit-service.md open item, table below |
(chosen and built on both sides: transactional outbox + relay.) Credit's replies go through its outbox_event table, published with publisher confirms and the mandatory flag (D10, #188). Order copied the same pattern for all its request events (#208): the outbox row commits with the state flip, a poller publishes and marks rows sent, and a 30-day retention purge removes sent rows (Credit D13, #224). The options that were compared while this was still open:
| Transactional outbox + relay (chosen) | Publish/call after commit + periodic reconciliation | Manual repair via DLQ/ops | |
|---|---|---|---|
| Event lost after DB commit possible? | no (outbox row commits with the state flip; relay retries) | yes, in the crash window — the reconciler re-emits | yes, until noticed |
| Duplicate events produced | yes (at-least-once) — safe: every consumer dedupes (eventId inbox in notification, reservation status in credit, state check in order) |
yes — same safety | n/a |
| New infrastructure | outbox table + poller per producer | sweep job comparing states | none |
Credit's failure rules, as built (credit-service.md D10, D11, D13 and "Failure and retry behaviour"):
-
Reply publisher (D10). Runs every 1 s with a fixed delay, so polls never overlap, and takes at most 100 due replies per poll. Each reply is marked sent only once the broker confirms it without returning it. A reply returned as unroutable (no queue bound to
credit-events— say, Order not started) is not due again for 30 s, so it cannot hold back the replies behind it. Any other failure — broker unreachable, connection lost, a nack, no confirm within 5 s — stops that poll, and the unsent replies wait for the next one. There is no cap on attempts. -
Event retries (D11). Credit's queues are
credit-service.request-eventsplus its.retryand.dlq, with the four consumed routing keys bound one by one. The retry delay is 2 s with 5 attempts (CREDIT_RETRY_TTL_MS,CREDIT_RETRY_MAX_ATTEMPTS; kept below 5 s so one retry still meets NFR1.1). An event that conflicts with the recorded state is dead-lettered at once. If the broker returns the retry copy as unroutable, the original is dead-lettered; if it nacks the copy or does not confirm it within 5 s, the original is requeued after a 5 s pause without using up an attempt. Credit declares both exchanges itself, so it starts fine without the Order Service. -
Outbox retention (D13, #218). Daily at 03:00 Asia/Singapore, sent replies older than 30 days are deleted (
CREDIT_OUTBOX_RETENTION_DAYS, at least 1;CREDIT_OUTBOX_PURGE_CRON). An unsent reply is never deleted — Order is still waiting for it.
(open) — account closure → credit cleanup:
user.deleted event consumed by Credit |
Sync call from the user-service purge scheduler | Periodic reconciliation job in Credit | |
|---|---|---|---|
| Where the trigger lives | new event type + queue (publisher changes not needed per Notif F1.3's convention, but Credit grows a consumer) | the day-31 purge (user-service README) already iterates deletable accounts | credit-side sweep asks User which of its accountIds still exist |
| Failure window | until event consumed (retry/DLQ protects) | purge blocks on Credit availability | up to one sweep period |
| Closed-economy effect | balance → common pool on consumption | balance → common pool in the purge transaction boundary | pool credited at sweep |
The Notification Service — the consumer end of this part — has its own full writeup in the Parts-1/2 structure: D3 Design — Notification Service. This part answers the D3 brief's workflow questions.
(chosen: request state-change fan-out — Order publishes one event per transition; Notification consumes, stores, and pushes to both parties.) Running end to end since #208: the producer outbox in Order, the consumer pipeline, REST #205, the web STOMP client #185 and the Notification Center #199. The Order↔Credit saga (Part 2 §3) is a second event-driven workflow on the same broker. The candidates considered in the team record:
| Workflow | Sync or async | Why |
|---|---|---|
| Request state change → notify requester & courier | async (this one) | the business op must not wait for, or fail because of, notification delivery (Notif F1.4); fan-out to parties the producer shouldn't know about (F1.2–F1.3); ≤5 s visibility (Order NFR1) needs push, not polling |
| Credit reserve / transfer / release | async (saga by choreography — D7–D9 in credit-service.md) | no synchronous call exists between Order and Credit: Order publishes request.submitted and the request waits as PENDING for credit.reserved or credit.reservation-rejected; transfer and release follow request.completed / request.cancelled / request.expired. Rejecting creation on insufficient balance (F1.1.4) is therefore no longer immediate (credit-service.md "Requirements to revise") |
| Sign-up credit provisioning | none — no call is made | the Credit Service creates the account the first time it sees the user ID (D6 in credit-service.md) |
Participants: order-service (producer) → RabbitMQ → notification-service (consumer) → web (STOMP push). Technology (chosen: RabbitMQ — notification-service.md D1): it gives at-least-once delivery with persistent messages and durable queues (NFR1.1–1.2), plus broker-native TTL retry and dead-lettering (D6/D7) — reliability features the team would otherwise have had to hand-build. The operational cost is one extra container in compose. The saga adds credit-service as a second consumer of request-events (on its own queue, bound to the four events it acts on) and as the producer on a second topic exchange, credit-events, whose replies order-service consumes.
This is event-driven, not message-based. The producer states a fact (request.accepted) on the request-events topic exchange, routing key = event type, and does not know its consumers (D16). New consumers bind without any publisher change (Notif F1.3). A message/command design — "send notification X to user Y" — would have coupled Order to notification concerns, and was not taken.
Events are flat typed records with no envelope (D17). Each has one canonical identity in EventTypeRegistry, and dispatch is on the body's eventType, never on broker headers (D18). The canonical fixtures live in foc-contracts src/main/resources/contracts/; this is request-created.example.json:
{
"eventType": "request.created",
"eventId": "9b2f6b3c-1e9d-4d55-8f6a-0c2f4a7d9e21",
"occurredAt": "2026-09-19T08:00:00Z",
"producer": "order-service",
"correlationId": "corr-7f3a1c9e",
"parties": ["usr-req-1001"],
"requestId": "req-20260919-0042",
"requesterId": "usr-req-1001",
"pickupLocation": "Techno Edge",
"dropoffLocation": "COM3-01-19",
"note": "Chicken rice, less chilli"
}An event is produced when the matching transition commits in Order (§P1.3's table — the outbox row commits with the state flip, and the poller publishes it). notification-service consumes it, writes one notification per party in parties[], and pushes each over STOMP to that user's destination. Twelve event types exist, one record class and one fixture each: the request events request.created/accepted/collected/courier-arrived/completed/cancelled/dropped/expired (request.dropped was added by #214, because a courier drop must not look like a cancel — a cancel releases credits), the saga's request.submitted and request.rejected, and the credit events credit.reserved and credit.reservation-rejected. Events carry party IDs only; the web resolves display names at read time through the gateway, with the caller's own JWT (D32, #222, which replaced an ingest-time lookup and its internal service token).
-
Repeated messages are recognized by the unique
eventId. The consumer inserts it in the same transaction as the notification row (D4), so a redelivery hits the unique key and is acked without a second notification. This is what lets the producer side be freely at-least-once — which the outbox pollers are (§P2.5). -
Does ordering matter? Little, by design. Each event is a self-contained fact, and the notification text does not depend on prior events. Out-of-order arrival (say
collectedbeforeaccepted, after a retry) produces notifications out of order but no wrong state — the Order DB, not the event stream, is the source of truth the UI reads on click. The team removed per-entity sequencing (D19; D1's F2.4 was retired with it) after deciding dedupe alone was sufficient. Within one queue, RabbitMQ delivers FIFO per publisher channel anyway, so the normal path is ordered.
-
A service saves but cannot publish (the mentor's example). Answered by the transactional outbox on both producers (§P2.5 — Order #208, Credit D10/#188): the event row commits with the state change, so "saved but never published" cannot happen — a crash before publishing only delays the poller's next attempt. The re-emission that at-least-once publishing allows is harmless, because the consumer's
eventIddedupe absorbs it. The system converges. - Consumer processing fails. Broker-native retry: the message dead-letters into a TTL retry queue and redelivers after 10 s, three attempts (D6). Exhausted or unconvertible messages land in the durable DLQ (D7, D20–D21) for inspection and replay, rather than being lost or poisoning the consumer.
- Delayed processing. Notifications arrive late but exactly once, and nothing downstream depends on their timing (F1.4).
In place today: consumer logs per event (received, dispatched, deduped, dead-lettered); the RabbitMQ management UI (localhost:15672 — queue depths, retry/DLQ counts for all three consumers' queue sets); and the DLQs themselves as durable records of exhausted retries (F2.3). The Credit Service logs one line per reserve, transfer and release — the request, the users, the amount and the outcome — and one per account it creates (#169). The outbox publishers log unroutable and unconfirmed replies with their event IDs, and a failed redistribution is logged at ERROR on each day it repeats. The demo (scripted in D3-Demo-Script): drive a real request through the lifecycle and watch the ≤5 s STOMP pushes; publish a fixture event twice from the management UI and get one notification, with the second delivery deduped in the log; publish a malformed body and watch it go straight to the DLQ on first rejection.
One container per service, each built from its own Dockerfile — all five Java services plus the gateway and the web build and run this way. The Java services use a multi-stage build: a maven:3.9.11-eclipse-temurin-21 build stage, which installs foc-contracts into the local Maven repo first (that is why the build context is the repo root), then a slim eclipse-temurin:21-jre runtime stage holding just the fat jar. The web is a node:22.22-alpine Vite build whose static files are served by nginx:alpine with SPA fallback. The VITE_* service URLs are build args because Vite inlines them into the bundle; since the gateway cutover they all default to http://localhost:${GATEWAY_PORT:-8090}/api/<service>. The gateway is the one non-Java custom image: OpenResty with its Lua JWT/denylist modules and nginx.conf baked in (gateway/Dockerfile).
Independent development and deployment follow from this. Each service is its own compose unit with its own image, database and port, so docker compose up order-service order-db runs one service alone, and a change to one service rebuilds one image. The N2H plan rides the same property: N5's CI/CD builds per-service images, and every N2H lands inside an existing service's container (Part 5).
| Component | Image (Docker Hub) | Custom Dockerfile? |
|---|---|---|
| user/notification/order/credit DBs |
postgres:17 (official) |
no |
| supplier DB |
postgres:16 (official) |
no |
| Broker |
rabbitmq:4-management (official; management UI included) |
no |
| Token denylist store |
redis:8.8-alpine (official; AOF + named volume, G7) |
no |
| Local SMTP | axllent/mailpit:v1.31 |
no |
Reproducibility: the Maven wrapper (./mvnw) pins the build tool, pom.xml pins dependency versions, package-lock.json + npm ci pin the web tree, and the base images are tag-pinned above. Any teammate reproduces the stack with cp .env.example .env followed by docker compose up.
Configuration enters containers as environment variables, declared in compose.yaml and sourced from .env; .env.example documents every knob with placeholder values. Secrets (JWT_SECRET, SMTP credentials, OWNER_SETUP_TOKEN, DB passwords) exist only in the uncommitted .env. They are not in source and not baked into images — the only build args are the public VITE_* URLs — and services log neither env dumps nor credentials.
-
Schema: split down the middle, as a per-service choice — Flyway in user-service (
V1–V6, #147) and credit-service (V1__baselineplus the outbox amendments, #166/#224); JPAddl-auto: updatein notification-service and order-service. (open) — whether to standardize on Flyway:
| Flyway everywhere | Per-service choice stays (current state) | |
|---|---|---|
| Fresh-container init | deterministic, versioned |
update derives from entities |
| Schema change on redeploy | explicit migration file, reviewable | implicit; never drops columns — dev volume resets documented in notification README |
| Repo state (factual) | user + credit | notification + order |
-
Seed data: the supplier CSV seeder (#102, skips rows already present) and the
USER_SEED_DEMOdemo-account seeder (#155, off by default). -
Persistence: every DB and the broker mount a named volume.
docker compose stop/start/restartkeeps data;recreatekeeps data too (the volume survives the container); onlydocker compose down -vdestroys it. Mailpit is deliberately memory-only.
Inside the stack, services address each other by compose DNS service name (http://credit-service:8080) on the default network — no service registry is needed at this scale. External vs internal — the mentor's "communication isolation" question — is answered by the gateway cutover (PR #176, G3): the application's only published API surface is the web (${WEB_PORT:-5173}) and the gateway (${GATEWAY_PORT:-8090}). The five backend services have no host ports at all. They are reachable only on the compose network, through the gateway's /api/<service>/** routes — which also verify JWTs and the denylist, so revocation cannot be bypassed from the browser. The dev conveniences that remain published bind to 127.0.0.1 only — the five DBs (5433–5437), Redis (6379), the RabbitMQ management UI (15672) and Mailpit (8025) — so nothing is reachable from outside the machine.
✍️ Full isolation of the DBs and broker onto an internal-only compose network (dropping the loopback dev ports) is still owed as a compose-profile change before any non-local deployment (N5).
Every service container declares depends_on: condition: service_healthy on its DB (and the broker, where used), with healthchecks (pg_isready, rabbitmq-diagnostics ping) in compose.yaml — a service simply is not started before its dependencies pass. If a dependency dies later, Spring fails the affected operations (connection errors surface as 5xx problem+json, and the AMQP listener reconnects with backoff), container restart policies bring dead containers back, and the retry/DLQ topology absorbs the broker-side gap. One command reproduces the whole stack: docker compose up -d --build.
The CI half landed in PR #194 (part of N5, #48, Kwey). .github/workflows/ci.yml runs on every PR: a per-module Java matrix (Temurin 21, installs foc-contracts first, ./mvnw verify per service), a web build, and a per-image Docker build matrix (no push) — plus claude-review.yml, an automated Claude review on each PR. Two earlier design choices made this cheap: every service builds from the repo-root context, and the Testcontainers suites skip cleanly when Docker is absent, so the same commands run locally and in the runner (#197 closed the one user-service gap). The remaining N5 scope is image publishing, a cloud compose target, and K8s as a stretch (N5.4).
| N2H | Problem it addresses | Value to FoC | Owner |
|---|---|---|---|
| N1 Ratings #44 | trust between strangers exchanging favours | reputation signal on public profiles; report path for bad actors | Leong |
| N3 Dispute resolution + failed-order management #46 | an order stuck after collection (never delivered, or delivered but not marked) has no exit but an automatic payout; cross-service failures (order finished, credits still held) are visible only in logs and the DLQ | requester disputes while the reward is still held, admins settle it (pay courier / refund / split); admin views of failed orders and of stuck reservations, with a "retry settlement" action | Ryan |
| N4 Centralized logging #47 | per-container logs are siloed | one queryable record (3-month window) for admins/debugging | Aung |
| N5 Cloud deployment & CI/CD #48 | manual local-only deploy | automated build/test/deploy; cloud target; K8s stretch — CI half merged (#194, §P4.7) | Kwey |
| N8 Supplier bookmarks #225–#227 | favourite stalls buried in the full supplier list | bookmark/un-bookmark saved to the account (max 5, enforced front & back with a 409); bookmarked suppliers flagged and listed first when no search/filter applies | Alastair |
✍️ The fifth-feature decision this page used to carry as open was made on 2026-10-19: Alastair picked N8 supplier bookmarks over N3 disputes, N6 chat and N7 admin dashboard. Requirements N8.1–N8.5 and decisions D8–D13 live in supplier-service.md (design PR #228). One sub-item stays open: how bookmarks are cleaned up when an account is purged (N8.5 — architecture.md open decisions).
✍️ 2026-10-21: Ryan's N2H changed from N2 request history #45 to N3 dispute resolution #46, extended with failed-order management. N2 is no longer on the selected list.
-
N1 Ratings — a new
ratingstable keyed by (requestId, raterId). Writes are allowed only for the request's two parties after COMPLETED (the relationship check reuses §P1.5's pattern). The public profile (User F8.1.1) aggregates the star average; the report form writes a flagged row for admin review. - N3 Dispute resolution + failed-order management — no new service: two new order states (DISPUTED, RESOLVED), two new events, a split-settlement operation in Credit, and admin views over data both services already hold. Full design in §2.1 below.
-
N4 Centralized logging — containers already log to stdout; aggregate via a log shipper container into one store with a 3-month retention purge (the same
@Scheduledpurge pattern as notification retention D9), plus an admin-only query endpoint (N4.4). - N5 Cloud/CI-CD — the GitHub Actions per-service matrix (build, test incl. Testcontainers, image build) is merged (#194). Remaining: push images, a compose-based cloud target, K8s (N5.4) as a stretch. Feeds Part 4 §7.
-
N8 Supplier bookmarks — lives inside supplier-service (its data, its listing to re-order): a
bookmarkstable keyed by (userId, supplierId), a 5-per-user cap enforced in the backend (409) and the frontend, a bookmark removed when its supplier is deleted, and bookmarked suppliers flagged in the list response — ordered first only when no search or filter applies (supplier-service.md D8–D13).
Design decisions
- Disputes only while the reward is still held. A dispute can only be raised before the courier is paid, so the reward is still HELD in the Credit Service and nothing has to be taken back from the courier. Disputing after payment would need clawbacks from a courier who may already have spent the credits, so it is not allowed.
- No automatic refund for collected-but-not-delivered orders. COLLECTED orders could expire and refund the requester automatically, like CREATED and ACCEPTED orders. They deliberately don't, because once an item has been collected the system can't tell whether the courier delivered but forgot to tap "arrived", paid the supplier out of their own pocket, or really disappeared with the item. A person needs to decide, so these orders go to a dispute instead.
When an order can become DISPUTED
stateDiagram-v2
ARRIVED --> DISPUTED: requester disputes (before the 1 h auto-complete)
COLLECTED --> DISPUTED: requester disputes (deadline passed)
COLLECTED --> DISPUTED: sweep, still COLLECTED a grace period after the deadline ("not delivered")
DISPUTED --> RESOLVED: admin only
| From | Who | Condition |
|---|---|---|
| ARRIVED | requester | before the 1-hour auto-complete |
| COLLECTED | requester | the deadline has passed (before it, the courier may legitimately still be on the way) |
| COLLECTED | expiry sweep (automatic) | still COLLECTED a grace period (e.g. 1 hour) after the deadline; reason "not delivered". The order reaches the admin queue even if the requester never opens the app |
| DISPUTED → RESOLVED | admin | not an admin who is the order's requester or courier |
Order Service changes
- New states DISPUTED and RESOLVED, with the transitions above.
- The dispute action checks the caller is the requester and, for a COLLECTED order, that the deadline has passed — the same kind of check
accept()already does with the deadline. - The existing expiry sweep is extended to escalate overdue COLLECTED orders.
- Auto-complete skips DISPUTED orders, so the courier is not paid while a dispute is open.
- The existing state machine and
@Versioncheck reject racing transitions (e.g. a dispute and an auto-complete at the same moment): only one wins. - Events, written to the outbox in the same transaction as the state change:
| Event | Payload | Consumer |
|---|---|---|
request.disputed |
the request, the reason | Notification Service: tells the courier and admins |
request.dispute-resolved |
{ requestId, courierAmount, requesterAmount } |
Credit Service: how to settle |
Credit Service changes
- A new split settlement operation for
request.dispute-resolved:- the reservation must be HELD, and
courierAmount + requesterAmountmust equal the reward; -
courierAmountgoes to the courier like a transfer, as a new lot;requesterAmountgoes back to the requester's original lots like a release, through the reservation slices; - the reservation status becomes RESOLVED, and history rows are written for both sides, so both users see the outcome in their credit history.
- the reservation must be HELD, and
- The same reliability as the existing events: one transaction, row locks, ack after commit, a status check so a repeated event does nothing, plus retry and dead-letter.
- No difference between disputes raised from ARRIVED and from COLLECTED: in both cases the credits are still HELD.
Typical resolutions
| Situation | Resolution |
|---|---|
| Courier never delivered | full refund to the requester |
| Delivered but not marked | pay the courier in full |
| Unclear or partly delivered | split |
Rules
- An admin can't resolve a dispute on an order where they are the requester or courier.
- A resolution never creates or destroys credits: the admin only decides how the reward that is already held is divided, so the closed economy is preserved.
Web
- A "Report a problem" button on an arrived order, and on a collected order once it is past its deadline (the
DisputeModalalready exists). - An admin dispute queue: order details, the reason (including the auto-escalated "not delivered"), and the resolve options (pay courier / refund requester / split).
-
Failed orders (view only): an admin list of REJECTED, timed-out and EXPIRED orders, with reason and time, filterable by date and user. Built on Order Service's existing admin list endpoint (
GET /users/{id}/requests), plus an all-users version. -
Stuck orders (view and act):
- Credit Service:
GET /credits/admin/reservations?status=HELD&olderThan=...lists reservations held for a long time. - The dashboard checks each against Order. If the order is already COMPLETED, CANCELLED or EXPIRED but the credits are still HELD, it is shown as stuck.
- A "Retry settlement" button: Order publishes the order's final event again (
request.completed/request.cancelled/request.expired). This is safe because Credit's handlers are idempotent: if the credits were in fact already settled, the repeat does nothing. - This closes today's gap, where incomplete cross-service operations are only visible in logs and the dead-letter queue.
- Credit Service:
Every way an order can get stuck now has an exit:
| Stuck where | Exit |
|---|---|
| in the order flow (never delivered, or delivery disputed) | disputes, resolved by an admin |
| between services (order finished, credits still held) | retry settlement |
Both reuse what the core services already do: the outbox, at-least-once delivery, idempotent handlers, row locks, and closed-economy conservation.
Each member owns one core service's D3 work plus one N2H (the table at the top of this page). N2H implementation lands after the Order/Credit cores are demoable, matching the D1 Gantt (N2H sprints in weeks 9–12). The allocation table at the top is the document of record for the final presentation requirement ("document this information and present it during the final presentation").
The full runnable walkthrough is D3-Demo-Script — the same shape as D2's: curl against the live stack, with the UI stopped where that proves backend enforcement. Rubric → what to show:
| # | Rubric | Show |
|---|---|---|
| 1 | P1 DB & schema |
requests table live (localhost:5437); one row per state; the decided comparisons (Postgres 17, snapshot, @Version) |
| 2 | P1 state machine | full happy path create→(reserved)→accept→collect→arrive→complete with balances before/after; a drop relisting the request with the reward still held |
| 3 | P1 authz | self-accept 409 · unrelated-user step 404 (existence masking) · admin list 403 for a USER — via curl through the gateway, web stopped |
| 4 | P1 concurrency | two concurrent accepts → one 200, one 409 (scripted parallel curl) |
| 5 | P2 allocation | new user's first balance read creates the account with 5 credits; a second read allocates nothing (idempotent) |
| 6 | P2 reserve/transfer/release | create (reserve, PENDING→CREATED) → cancel (release) → re-create → complete (transfer); duplicate complete → 409 and no second payment; /credits/me/expiries shows the courier's new 3-month lot |
| 7 | P3 async | live STOMP push ≤5 s in the Notification Center; fixture event published twice in the management UI → deduped; malformed body → DLQ on first rejection |
| 8 | P4 |
docker compose up -d --build from clean; backends unreachable except via gateway (curl localhost:8087 fails, /api/user works); stop a DB mid-demo → healthcheck + recovery |
| 9 | P5 | N2H table + owners (all five decided) |
- Per-service:
./mvnw test(install foc-contracts first:cd foc-contracts && ./mvnw install). Integration tests use Testcontainers (Postgres, RabbitMQ) and skip cleanly without Docker — the pattern order/credit inherited; CI runs the same commands per module (#194). - The contract fixtures in foc-contracts are the canonical payloads; consumer tests replay them, and Order's producer emits the same JSON.
- Manual harnesses for the saga live in
order-service/:order-e2e.ps1(two real accounts through the gateway, 11 PASS/FAIL checks incl. wrong-person attempts),order-curl.ps1,credit-stub.ps1(stands in for Credit) andorder-event.ps1(drivesorder-dbdirectly). - Stack audit:
docker compose up -d --build, then the D3-Demo-Script top to bottom; every call prints its HTTP status.
-
Decisions owed (team): account closure → credit cleanup (§P2.5, table above) · migration standardization (§P4.4) · N8.5 bookmark cleanup on account purge (§P5.1) · shared messaging code — whether the broker-message converter and the outbox publisher are copied or shared across services (credit-service.md open item). (Resolved since the 2026-10-09 draft: Order DB engine — Postgres 17; snapshot vs reference — ID + name snapshot; single-winner mechanism —
@Version; drop-event type —request.dropped; Alastair's N2H — N8.) - Implementation owed: Order → Supplier pickup validation F1.1.1 + 1 km rule F8.1 (§P1.2 — the one unbuilt mandatory integration) · web order integration PR #217 · supplier NFR issues #36/#37 · the internal-vs-external network split before N5's cloud deploy (§P4.5).
- D1-document rewording owed: the saga states (PENDING/REJECTED/ARRIVED) and the asynchronous insufficient-balance answer (§P1.3 flag).
- Prepared answers if probed: why a gateway (stateless JWTs made logout unenforceable without per-service denylist checks; the gateway rejects revoked tokens platform-wide — G1–G12, gateway.md, PRs #176/#183) · why credit moved from sync calls to an event-driven saga (§P3.1, credit-service.md D7) · why a courier drop is not a cancel (§P1.3 — the reward must stay held) · why 404 masks ownership on order steps but self-accept is 409 (§P1.5) · the NFR1.2 email-hashing inconsistency (D2 carry-over).
Drafted 2026-10-09; refreshed 2026-10-20 against the merged record (order, credit and notification complete; gateway and CI in). Diffs live on the PRs. The D3-Demo-Script was rewritten the same day against the real endpoints.