Spec: Citizen fine payments¶
Status: ready-for-agent Date: 2026-11-02
Problem Statement¶
A citizen can't actually pay a fine from the mobile app. The payment flow looks finished, but none of it is real:
- Nothing reaches the backend. Tapping "Pay now" waits 1.8 seconds and always "succeeds". No payment is created, so fines never change status and the next screen still shows them as outstanding.
- "Pay all" pays nothing. The pay page looks up fines in a list of mock fines that holds made-up references. Real fine references match none of them, so the summary card reads "Paying 0 fines", shows a $0.00 subtotal and an empty plate, and still adds a fee.
- The service fee is wrong. It is $1.50. The fee should be $0.50, and it has never been part of what a fine costs.
- The receipt is fake. Every payment shows the same hardcoded receipt: the same reference, amount, plate, date, two fines that may not be the citizen's, and a fee line.
- "Back to {plate}" doesn't go to the vehicle. It always goes Home, whichever vehicle the citizen paid for.
- There is no safe retry. Every attempt uses the same hardcoded idempotency key. The backend's duplicate protection is designed for retries, but the app never retries.
On the backend, POST /payments already takes several fines in one payment and is idempotent. The EcoCash mock, though, leaves every payment PENDING until someone calls the manual mock callback by hand. Even a correct client would wait forever, and fines would never become PAID unless someone called it.
Solution¶
A citizen pays one fine, all the outstanding fines on one vehicle, or all their outstanding fines across every vehicle, as one payment with one reference and one receipt.
The Pay now page loads the real fines being paid. It shows how many there are, the plate (or "several vehicles"), the fines' subtotal, a flat service fee of USD 0.50, and the total. When the fines are in ZWG, the fee is converted at the current ZWG rate. The fee is shown only on this page. It is not sent to the backend, not stored, and not on the receipt.
EcoCash is the only method a citizen can pay with. After the citizen taps Pay, the app creates the payment and waits on the page ("Approve on your phone…") while it checks the payment's status. Network failures are retried safely with the same idempotency key, so a citizen is never charged twice.
On the backend, a mock EcoCash provider stands in for the real one. A few seconds after a payment is created, it confirms the payment by itself. The fines then become PAID through the existing event flow, exactly as they will with the real webhook. A test phone number fails the first attempts and passes on a later retry, so the failure and retry path can be shown end to end.
The receipt shows the real payment: - reference, amount, currency, status, method, payer phone, date paid, and the EcoCash transaction ID; - for each fine: the offence, fine reference, plate and amount.
Its back button takes the citizen to the vehicle they paid for. When the payment covered several vehicles, it takes them to the Fines tab.
Test Seams¶
No tests are written for this work, at the user's request. No new unit, integration, portal or mobile tests are added, and existing test code is not changed.
Verification is limited to: - compiling payments-service; - running the mobile app's typecheck and lint.
The integration suite drives payments through the manual mock callback, so the integration stack's configuration sets a long mock confirmation delay. That keeps the new mock provider from racing those tests. Only configuration changes, not test code.
User Stories¶
Paying (citizen, mobile)¶
- As a citizen, I want to pay a single fine from its detail screen, so that I can clear one fine without paying the others.
- As a citizen, I want a "Pay all" on a vehicle's fines page that pays every outstanding fine on that vehicle, so that I can clear one car's fines in one go.
- As a citizen, I want a "Pay all" on the Fines tab that pays every outstanding fine on all my vehicles, so that I can clear everything at once.
- As a citizen, I want "Pay all" to make one payment with one EcoCash prompt, so that I approve once and not once per fine.
- As a citizen, I want the Pay now page to list the real fines I'm paying, so that I know exactly what I'm being charged for.
- As a citizen, I want the summary card to show how many fines I'm paying and for which plate, or that they cover several vehicles, so that I can check before I pay.
- As a citizen, I want the summary card to show the fines' subtotal, the service fee and the total correctly, so that I know what will leave my wallet.
- As a citizen, I want the service fee to be USD 0.50, so that paying online is not unreasonably expensive.
- As a citizen paying ZWG fines, I want the fee shown in ZWG at the current rate, so that all amounts on the page are in one currency.
- As a citizen, I want to see a loading state while my fines load, so that I never see a misleading "Paying 0 fines".
- As a citizen, I want a clear message if there is nothing left to pay, so that I'm not left on an empty page.
- As a citizen, I want a clear message if the fines I selected are in different currencies, so that I understand why I can't pay them together.
- As a citizen, I want EcoCash to be the only method offered, so that I'm not shown methods I can't use.
- As a citizen, I want the payment to be sent to TrafficGrid for real, so that my fines are actually settled.
Waiting for confirmation (citizen, mobile)¶
- As a citizen, I want to see an "Approve on your phone" state after I tap Pay, so that I know the app is waiting for EcoCash.
- As a citizen, I want the app to move to my receipt by itself once the payment succeeds, so that I don't have to refresh.
- As a citizen whose payment failed, I want to see why and a "Try again" button on the same page, so that I can retry straight away.
- As a citizen retrying a failed payment, I want the retry to be a fresh attempt, so that it can succeed even though the first one failed.
- As a citizen whose confirmation is taking long, I want to be taken to a pending receipt that I can refresh, so that I'm not stuck on a spinner.
- As a citizen who taps Pay while a payment for the same fines is still pending, I want to be told a payment is already in progress, so that I don't pay twice.
Safe retry (citizen, mobile)¶
- As a citizen on a bad connection, I want the app to retry sending my payment automatically, so that a dropped request doesn't fail my payment.
- As a citizen, I want automatic retries to reuse the same idempotency key, so that a retried request can never charge me twice.
- As a citizen, I want retries to back off with jitter, so that the app doesn't hammer a struggling server.
- As a citizen, I want a "duplicate payment" answer from the server treated as my existing payment, so that a retry after a lost response still ends on my receipt.
- As a citizen, I want errors that retrying can't fix (such as a fine no longer payable) shown to me right away, so that I'm not left waiting on pointless retries.
Receipt (citizen, mobile)¶
- As a citizen, I want my receipt to show the real payment reference, so that I can quote it if there's a problem.
- As a citizen, I want my receipt to show the real amount and currency I paid, so that it matches my EcoCash statement.
- As a citizen, I want my receipt to show the payment method and the phone number I paid from, so that I know which wallet was charged.
- As a citizen, I want my receipt to show when the payment was made, so that I have proof of the date.
- As a citizen, I want my receipt to show the EcoCash transaction ID, so that I can match it with EcoCash if needed.
- As a citizen, I want my receipt to list each fine with its offence, reference, plate and amount, so that I can see exactly what was settled.
- As a citizen, I want each fine on the receipt marked Paid once the payment succeeds, so that the receipt doesn't wrongly show a fine as outstanding while the status catches up.
- As a citizen, I want my receipt to show whether the payment is Pending, Paid or Failed, so that I know where it stands.
- As a citizen, I want a receipt opened later from my payment history to show the same detail, so that history is as useful as the moment of payment.
- As a citizen, I want no service fee line on the receipt, so that the receipt only states what TrafficGrid recorded.
- As a citizen, I don't want a "Download PDF" button that does nothing, so that the app doesn't promise what it can't do.
Getting back (citizen, mobile)¶
- As a citizen who paid for one vehicle, I want "Back to {plate}" to open that vehicle's details page, so that I can see its fines are cleared.
- As a citizen who paid for several vehicles, I want a "Back to fines" button that opens the Fines tab, so that I land somewhere that covers all of them.
- As a citizen, I want the fines and vehicle screens to show my fines as paid after a successful payment, so that the app doesn't show stale outstanding fines.
Mock provider (backend, developer, tester)¶
- As a developer, I want EcoCash payments confirmed automatically by a mock provider after a short delay, so that the app works end to end before the real EcoCash integration exists.
- As a developer, I want the mock to settle payments asynchronously, as real EcoCash will, so that the mobile flow doesn't change when the real webhook replaces it.
- As a developer, I want a mock-confirmed payment to mark its fines
PAIDthrough the existing event flow, so that the mock tests the same path production will use. - As a tester, I want a phone number that fails the first payment attempts and succeeds on a later retry, so that I can show the failure and retry flow.
- As a tester, I want the confirmation delay and the number of failures before success to be configurable, so that I can tune them per environment.
- As a developer, I want the mock to pick up payments that were still pending when the service restarted, so that no payment stays pending forever.
- As a tester, I want the manual mock callback to keep working, so that the integration suite can still drive payment outcomes directly.
- As a tester, I want the integration stack to use a long confirmation delay, so that the mock provider never races the suite's manual callbacks.
Payment API (backend)¶
- As a client developer, I want each payment item in the response to carry the fine's offence, plate, vehicle id and status, so that a receipt can be rendered without extra calls.
- As a client developer, I want those details on every payment read (create, by id, by reference, search), so that history and receipts show the same thing.
- As an admin portal user, I want existing payment fields unchanged, so that the portal keeps working.
Documentation¶
- As a developer, I want API Contracts to describe the new item fields and the mock provider's behaviour and config, so that clients and testers know what to expect.
- As a developer, I want the Implementation Tracker to show the citizen payment flow and mock provider as done, so that project status is accurate.
- As a developer, I want this spec listed under Specs in the docs site, so that it can be found.
Implementation Decisions¶
Payment shape¶
- One payment covers N fines. The existing
POST /paymentscontract is unchanged:items[]withpayableType: FINE,payableId, andamountin cents equal to the fine's amount. The backend's existing rules still apply: one currency per payment, each fine at most once, each finePENDINGwith no open or successful payment. - "Pay all" scope depends on where it is tapped. On a vehicle's fines page it covers that vehicle's outstanding fines. On the Fines tab it covers all the citizen's outstanding fines across vehicles. Fine detail pays a single fine.
Service fee¶
- Flat USD 0.50 per payment, display only. It is a constant in the mobile app. It is never sent to the backend, so the payment
amountstays the sum of the fines and nothing about the fee is stored. - The fee follows the fines' currency. USD fines show USD 0.50. ZWG fines show 0.50 × the ZWG
ratefrom the system-parameters currencies endpoint (units per USD), rounded to 2 decimals. Citizens already holdCURRENCY_READ. Citizens can't choose a payment currency; converting fine amounts is out of scope. - The fee is not on the receipt.
Mock EcoCash provider (payments-service)¶
- It follows the vehicle-service mock CVR pattern. A scheduled poller sweeps
PENDINGECOCASH payments older than the confirmation delay and settles each one through the same logic as the existing mock callback. That logic setsSUCCESS/FAILED, the provider transaction id and message, the callback time andpaidAt, and publishesSUCCEED_PAYMENT/FAIL_PAYMENTafter commit. The existing payment event listener then settles fines toPAID. Because it sweeps rather than scheduling a one-off timer per payment, it survives restarts. - Configuration, in payments-service's config-server file:
trafficgrid.ecocash.confirmation-delay, envECOCASH_CONFIRMATION_DELAY, defaultPT3S.trafficgrid.ecocash.mock-failures-before-success, default2.- A poll interval, kept short (about 2 seconds).
- Outcome rule.
- A payer phone ending in
0000→FAILEDwhile the payment's fines have fewer than mock-failures-before-success earlierFAILEDpayments; otherwiseSUCCESS. - Every other phone →
SUCCESS. - Transaction ids look like
MOCK-TXN-XXXXXXXX; the provider message is "Approved" or "Declined". - With the defaults, attempts 1 and 2 on a
0000number fail and attempt 3 passes. - The poller only touches payments still
PENDING. The manualPOST /payments/mock/{ref}/callbackstays as it is, and the two can't collide in a way that matters: whichever settles first wins, and the other one sees a non-PENDINGpayment. - The integration stack sets
ECOCASH_CONFIRMATION_DELAYtoPT10M. The suite keeps driving outcomes through the manual callback.
Payment response enrichment (payments-service)¶
- Each item in
PaymentResponseDto.items[]gains read-only fields, filled in when the payment is read from the local fines table (fines live in payments-service): offenceNamevehicleNumberPlatevehicleIdfineStatus- No schema change or migration. The fields are looked up in one batch per page of payments, not one query per item.
- The fields appear on every read: create, by id, by reference and search. Existing fields are unchanged, and the new fields are null for non-fine payables.
details(payer, provider, cash receipt, bank transfer) is already in the response. The mobile app's payment type gains it.
Mobile: payment page¶
- The route takes fine ids instead of references. The fines are loaded from the live citizen fines API. The mock-backed lookup by reference is deleted.
- The page has explicit loading, empty and error states. A missing route param no longer crashes the page.
- Only EcoCash is offered, mapped to the backend's
ECOCASH. The payer phone is sent aspayer.phoneNumber. - Amounts are converted from dollars back to cents when building the request (the UI fine model holds dollars).
- Mixed currencies are blocked in the client with a clear message, before any request is sent.
Mobile: safe retry and polling¶
- One attempt = one UUID idempotency key, created when the citizen taps Pay.
- Automatic resends:
- Network errors, timeouts and 5xx resend the same key up to 3 times, with exponential backoff and jitter (about 1s, 2s, 4s), as described in Failure Handling's retry guidance.
- 409 is treated as success; its
datais the existing payment. - Any other 4xx is not retried and its message is shown.
- Polling: the app checks
GET /payments/{id}every 2 seconds for up to 60 seconds. The poll requests use the same transient-error retry. - Outcomes:
| Payment result | What the app does |
|---|---|
SUCCESS |
Replace the screen with the receipt. |
FAILED |
Stay on the page, show the provider message and "Try again". Try again is a new attempt with a new key. |
| Timeout | Go to the receipt in its Pending state, which has a refresh. |
| 422 "payment in progress" | Show the message inline. |
- After
SUCCESS, the fines, vehicles and payment-history queries are invalidated.
Mobile: receipt¶
- The receipt is loaded by payment id or reference from the real payments API and rendered from the real response.
- What it shows:
- reference;
- amount in the payment's currency;
- status;
- method with payer phone;
paidAt;- EcoCash transaction ID when present;
- one row per item: offence, fine reference and plate, amount.
- Item status follows the payment status, not
fineStatus, because fines are settled asynchronously and may lag. - Removed: the made-up summary sentence, the fee line, and the no-op "Download PDF" button.
- Back button: the receipt collects the distinct
vehicleIds from its items. - One vehicle: it shows "Back to {plate}" and navigates to the vehicle details route. The citizen-vehicle link id comes from matching
vehicleIdagainst the linked-vehicles list. - Several vehicles, or no match: it shows "Back to fines" and goes to the Fines tab.
This works for receipts opened from history too, which have no navigation params. - The mocks used by this flow are removed: the hardcoded receipt, the receipt lookup, the $1.50 fee constant, the hardcoded idempotency key, and the mock payment-methods use on this page.
Documentation¶
- API Contracts: document the new item fields on payment responses. Document the mock provider: auto-confirmation, delay, the
0000failure-then-success rule, configuration, and that the manual callback remains. - Implementation Tracker: record the citizen payment flow (mobile, live) and the auto-confirming mock provider.
- Docs site: add this spec to the Specs section of the docs navigation.
Testing Decisions¶
- No tests are written or changed, by explicit user decision.
- A good test, if one is added later, exercises external behaviour: the
POST /payments→ mock confirmation → finePAIDflow through the API. It does not test poller internals. - Prior art for later tests:
- payments-service
PaymentsApiTest(API level); - vehicle-service's mock CVR tests, which set the delay to zero;
- the integration suite's payments and fine lifecycle tests.
- Verification for this work: payments-service compiles, and the mobile app passes its typecheck and lint.
Out of Scope¶
- The real EcoCash integration and webhook.
- Bank transfer and cash payments from the citizen app.
- A citizen-chosen payment currency, and converting fine amounts between currencies.
- Storing or charging the service fee on the backend.
- Receipt PDF download.
- Selecting a subset of fines for "Pay all"; it always pays every outstanding fine in its scope.
- Admin portal changes.
- Any new or changed tests.
Further Notes¶
- Fines become
PAIDasynchronously after the payment'sSUCCESSvia Kafka. Screens refetch after success, but a fine may briefly still show as pending; the receipt avoids this by deriving item status from the payment. - If a citizen leaves while a payment is
PENDING, the mock still settles it. The fines are blocked from new payments only until then (≈3 seconds by default). - The
0000rule counts earlierFAILEDpayments that share any fine with the new payment, so retrying with the same fines makes progress towards success.