Skip to content

Spec: Fine disputes

Status: done Date: 2026-10-30

Problem Statement

A citizen who thinks a fine is wrong has no way to say so through TrafficGrid. The mobile fine detail screen has a "Dispute this fine" button that does nothing.

On the backend, DISPUTED is only a fine status. An admin sets it by hand through the general status change, with a one-line reason. Nothing records who disputed the fine, on what grounds, what they said or what evidence they gave. Nothing records who reviewed it or what they decided.

Admins have nowhere to work through disputes. They can filter the fines list to Disputed and flip a status, but they never see the citizen's side.

The payment clock is also unfair in both directions: - If filing a dispute pushed the due date back, citizens could dispute fines just to get more time to pay. - If reviewers take their time, a citizen whose dispute is rejected near the due date may have almost no time left to pay.

Solution

A citizen disputes a pending fine on one of their linked vehicles from the mobile app. They choose the grounds, write a statement, and can attach evidence. Filing opens a dispute: a record of its own, linked to the fine. The fine moves to DISPUTED and can't be paid while the dispute is open. Before the decision, the citizen can withdraw the dispute, and the fine goes back to PENDING. A fine can only ever be disputed once.

Admins of the fine's issuing organisation review open disputes from the existing Disputed tab in the admin portal. A super admin can review any dispute. The reviewer reads the grounds, statement and evidence, then upholds or rejects the dispute, with a note in both cases: - Upheld: the fine is cleared. - Rejected: the fine goes back to PENDING. If the decision came in the last 7 days before the due date, or after it, the due date moves to 7 days after the decision. The citizen always has at least a week to pay, and filing a dispute never buys time on its own.

Filing, withdrawing and deciding a dispute each publish an event, so audit has a full trail and notifications can be added later.

For now, evidence files are not stored. Only their metadata is kept, which is enough for the review to work end to end.

Test Seams

Following the agreed light testing scope, there is one seam:

  1. The payments-service FinesService, through its existing unit test with mocked repositories and clients. It covers the dispute rules: eligibility, the one-dispute limit, organisation scope for review, the due-date formula, the general status change refusing disputed fines, and payment refusal of a disputed fine.

No new seam is introduced. Existing tests that the change breaks get fixed, for example where the admin portal's status sheet offered "Disputed". No new integration-tests, portal or mobile tests are added.

User Stories

Filing a dispute (citizen, mobile)

  1. As a citizen, I want a "Dispute this fine" action on a pending fine, so that I can challenge a fine I believe is wrong.
  2. As a citizen, I want to choose why I am disputing from a short list of grounds, so that I don't have to work out how to phrase my case.
  3. As a citizen, I want "Not my vehicle" as a ground, so that I can flag a fine recorded against the wrong plate.
  4. As a citizen, I want "Incorrect details" as a ground, so that I can flag a wrong location, time or offence.
  5. As a citizen, I want "Did not commit the offence" as a ground, so that I can contest the offence itself.
  6. As a citizen, I want "Already paid" as a ground, so that I can flag a fine I settled outside the platform.
  7. As a citizen, I want an "Other" ground, so that I can dispute for reasons the list doesn't cover.
  8. As a citizen, I want to write a statement explaining my side, so that the reviewer understands my case.
  9. As a citizen, I want to be told when my statement is too long, so that I can shorten it before submitting.
  10. As a citizen, I want to attach up to three photos or documents as evidence, so that I can back up my statement.
  11. As a citizen, I want to see the evidence I picked before submitting, and remove any of it, so that I only send what I intend to.
  12. As a citizen, I want confirmation once my dispute is filed, so that I know it went through.
  13. As a citizen, I want the action hidden on fines I can't dispute (paid, cleared, cancelled, referred to court, past due, or already disputed once), so that I don't try something that will fail.
  14. As a citizen, I want a fine on a vehicle that is no longer linked to me to be refused as not found, the same as reading it, so that disputing never reveals whether a fine exists.
  15. As a citizen, I want to be stopped from disputing a fine while a payment for it is still in progress or paid but not yet settled, so that the payment and the dispute don't contradict each other.

While a dispute is open (citizen, mobile)

  1. As a citizen, I want the fine to show as disputed straight away, so that I can see my dispute took effect.
  2. As a citizen, I want a "Dispute under review" card on the fine, showing my grounds, statement, evidence names and filing date, so that I can recall what I submitted.
  3. As a citizen, I want the pay action hidden while my dispute is open, so that I don't pay for a fine I am contesting.
  4. As a citizen, I want to withdraw my dispute, so that I can just pay if I change my mind.
  5. As a citizen, I want to be warned that withdrawing means I can't dispute this fine again, so that I don't give up my dispute by accident.
  6. As a citizen, I want my original due date to stay in place while the dispute is open, so that I know where I stand.
  7. As a citizen, I want disputed fines counted and totalled separately on my home dashboard and vehicle screen, so that I can see how much is under dispute.

After a decision (citizen, mobile)

  1. As a citizen, I want to see whether my dispute was upheld or rejected, so that I know the outcome.
  2. As a citizen, I want to read the reviewer's note, so that I understand the reasoning.
  3. As a citizen, I want to see which organisation reviewed my dispute and when, so that I know who decided.
  4. As a citizen, I want an upheld dispute to clear my fine, so that I owe nothing.
  5. As a citizen, I want a rejected fine to become payable again, so that I can settle it.
  6. As a citizen, I want at least seven days to pay after a rejection, so that a late review doesn't leave me no time to pay.
  7. As a citizen, I want my due date unchanged when the review came well before it, so that the rules are predictable.

Reviewing disputes (admin portal)

  1. As an authority admin, I want the Disputed tab to list my organisation's open disputes, so that I have a review queue.
  2. As a super admin, I want the Disputed tab to list disputes across all organisations, so that I can oversee and step in.
  3. As an authority admin, I want disputes on other organisations' fines kept out of my queue, so that I only review what my organisation issued.
  4. As a reviewer, I want to open a disputed fine and see the dispute panel with grounds, statement, evidence and filing date, so that I have everything I need to decide.
  5. As a reviewer, I want to see who filed the dispute, so that I can check it against the vehicle and fine.
  6. As a reviewer, I want to see the evidence file names, with a placeholder where a preview would go, so that I know what the citizen submitted while file storage isn't built yet.
  7. As a reviewer, I want to uphold a dispute with a required note, so that a wrongly issued fine is cleared with an explanation.
  8. As a reviewer, I want to reject a dispute with a required note, so that the citizen knows why the fine stands.
  9. As a reviewer, I want to be told when a rejection will extend the due date, so that I understand what my decision does.
  10. As a reviewer, I want a clear error if someone else has already decided, or the citizen withdrew, so that I don't overwrite a final outcome.
  11. As a reviewer, I want the general status sheet to stop offering "Disputed", and to refuse changes on a disputed fine, so that dispute outcomes only come through the review.
  12. As an authority admin, I want the overview's Disputed card to keep linking to the queue, so that I can reach open disputes from the dashboard.

Legacy data

  1. As a reviewer, I want fines that admins marked as disputed before this feature to show up as open disputes, so that I can decide them through the new flow instead of leaving them stuck.
  2. As a reviewer, I want such a legacy dispute to show its original reason as the statement and be marked as having no citizen filer, so that I know it came from the old flow.

Audit, events and operations

  1. As an auditor, I want every filing, withdrawal and decision recorded with the actor and a before/after state, so that disputes are fully traceable.
  2. As a future notifications consumer, I want separate events for a dispute being opened, decided and withdrawn, so that I can message citizens and reviewers.
  3. As a consumer of fine events, I want each dispute event to carry the fine before and after, so that I can see the status change a dispute caused without a separate status-updated event.
  4. As an operator, I want a payment that settles a fine already disputed to be logged rather than retried forever, so that a race never blocks the payments consumer.

Implementation Decisions

Terminology

  • A dispute is the citizen's formal challenge to one fine. It has grounds, a statement, optional evidence, a filer, and, once decided, a reviewer, an outcome and a decision note.
  • The fine's status DISPUTED means "this fine has an open dispute". It is no longer an admin-set status.

Dispute model (payments-service)

  • A new dispute table, payments_service.fine_disputes, owned by payments-service, linked to the fine by a foreign key. A unique constraint on the fine id allows at most one dispute per fine, ever. Database CHECKs limit grounds and status to the values below.
  • Columns:
  • id
  • fine id
  • grounds
  • statement
  • filed by (user id, display name; null for legacy rows) and filed at
  • status
  • reviewed by (user id, display name), reviewing organisation (id and short name: always the fine's issuing organisation, a super admin included) and decided at (also set on withdrawal)
  • decision note
  • created and updated timestamps
  • Grounds enum: NOT_MY_VEHICLE, INCORRECT_DETAILS, DID_NOT_COMMIT, ALREADY_PAID, OTHER.
  • Statement: required, not blank, at most 1000 characters.
  • Dispute status: OPEN → UPHELD | REJECTED | WITHDRAWN. The last three are final.
  • Decision note: required for both outcomes, not blank, at most 500 characters to match the existing status-change reason.
  • A new evidence table, payments_service.fine_dispute_evidence, linked to the dispute (deleted with it), holds at most 3 rows per dispute: file name (at most 255 characters), content type, size in bytes, and a storage key. The storage key is a generated placeholder, unstored/<uuid>, until real storage exists. Allowed content types are image/jpeg, image/png and application/pdf, up to 5 MB each. These limits are only validated by the API on the metadata, because nothing is uploaded.

Rules

  • Filing is allowed only when all of these hold:
  • the caller has the CITIZEN role (checked with the permission, so a super admin can't file)
  • the fine's vehicle is in the caller's linked vehicles (from vehicle-service, the same lookup the citizen fines list uses). Otherwise the fine is reported as not found (404), exactly as when reading it, so its existence isn't revealed.
  • the fine is PENDING
  • now is on or before the due date
  • the fine has no dispute of any status
  • the fine has no payment item in a PENDING or SUCCESS payment

An unknown fine or one off the caller's vehicles is 404; any other failed check is 422. Filing sets the fine to DISPUTED, leaves the due date alone, and clears the fine's previous status-change reason (the dispute now holds the citizen's reasons).

The payment check closes the race where an EcoCash payment is in flight, or has succeeded but not yet been settled (settlement is asynchronous), when the dispute is filed. Without it, settling the payment would land on a disputed fine. - Withdrawal: only the filer, only while the dispute is OPEN and the fine is DISPUTED. It is authorised by who filed the dispute, not by the caller's currently linked vehicles, so unlinking the vehicle doesn't strand the dispute; anyone else is told the dispute doesn't exist (404). The dispute becomes WITHDRAWN with its decided-at time set, and the fine goes back to PENDING with the due date unchanged. A withdrawn dispute still counts as the fine's one dispute. - Decision: the reviewer needs the review permission, and must belong to the fine's issuing organisation unless they are a super admin (otherwise 422). The dispute must be OPEN and the fine DISPUTED (otherwise 422). The reviewer, decision time and reviewing organisation (the fine's issuing organisation) are recorded. - Upheld: the fine becomes CLEARED and its status-change reason is the decision note. - Rejected: the fine becomes PENDING and its status-change reason is the decision note. The due date becomes the later of the original due date and decision time + 7 days. So a review earlier than the last week before the due date leaves the date as it is, and a review in the last week, or after the due date, gives exactly 7 days from the decision. The 7 days is a constant for now. - Locking: the dispute's fine is loaded with the existing pessimistic fine lock for filing, withdrawal and decision, the same way status changes and settlement do. This stops two reviewers, or a reviewer and a withdrawing citizen, from racing. - Payments: payment creation already refuses any fine that isn't PENDING, so a disputed fine can't be paid. No change is needed beyond the check above. Settlement's existing "not PENDING → log a warning" path still covers the narrow race. - General status change: DISPUTED is removed as a target, and the DISPUTED source row is removed from the transition table. A fine that is DISPUTED can't be changed there. The 422 message points to the dispute decision. PENDING can still move to CLEARED or CANCELLED there. - Court referral is not affected. REFERRED_TO_COURT fines can't be disputed.

API (payments-service, through the gateway)

  • POST /fines/{id}/dispute, needs the CITIZEN role and FINE_DISPUTE_CREATE. Body: grounds, statement (required, at most 1000 characters), and an optional evidence list of at most 3 (each with file name, content type and size in bytes). Returns 201 with the dispute.
  • POST /fines/{id}/dispute/withdraw, needs the CITIZEN role and FINE_DISPUTE_CREATE. Returns 200 with the dispute.
  • POST /fines/{id}/dispute/decision, needs FINE_DISPUTE_REVIEW. Body: outcome (UPHELD or REJECTED) and note (required, at most 500 characters). Returns 200 with the updated fine, including its dispute.
  • GET /fines/disputes?status=&page=&size=&sort=, needs FINE_DISPUTE_REVIEW. A page of fine objects, each carrying its dispute, filtered by dispute status (default OPEN). Sort is filedAt (the default, oldest first) or decidedAt; any other field is a 400. It returns core-service's standard paged response (one-based page, no statusCounts). It is limited to the caller's organisation unless the caller is a super admin. The admin Disputed tab can keep using the fines list filtered to DISPUTED, since each fine now carries its dispute. This endpoint exists for a dedicated queue and for the docs.
  • The fine object gains a nullable dispute field holding the fine's dispute:
  • id
  • grounds
  • statement
  • evidence (each with id, fileName, contentType and sizeBytes)
  • status
  • filedAt and filedByName
  • decidedAt
  • decisionNote
  • reviewingOrganisation (short name) and reviewedByName

filedByName and reviewedByName are null for citizen callers. Citizens see the reviewing organisation, not the person. - Errors follow the shared core-service envelope and exceptions: validation → 400, unknown fine (or, for a citizen filing, a fine off their linked vehicles) → 404, rule violations → 422 through the service's existing unprocessable-entity exception. Two concurrent decisions serialize on the fine lock, and the second gets 422 because the dispute is no longer OPEN.

Permissions (auth-service)

  • auth-service migration V8 (and PermissionCatalogue) adds FINE_DISPUTE_CREATE "Dispute Fine", granted to CITIZEN and SUPER_ADMIN, and FINE_DISPUTE_REVIEW "Review Fine Disputes", granted to AUTHORITY_ADMIN and SUPER_ADMIN. Both are in a new FINE_DISPUTE permission group. Filing and withdrawing also require the CITIZEN role, so the super admin's FINE_DISPUTE_CREATE grant doesn't let them dispute.
  • FINE_STATUS_UPDATE is unchanged.

Events (Kafka)

  • New event types on trafficgrid.fines: fine.dispute.opened, fine.dispute.withdrawn and fine.dispute.decided. As built, each is one audit-event message with the action OPEN_FINE_DISPUTE, WITHDRAW_FINE_DISPUTE or DECIDE_FINE_DISPUTE, entity type Fine, the actor (id, role, name, organisation), and the fine before and after as JSON (each carrying its dispute, so the decided event shows the outcome and any new due date).
  • No separate fine.status.updated (UPDATE_FINE_STATUS) event is published for the fine status change a dispute causes: the dispute event's before and after states carry it.
  • Events are published after commit through the existing publishing support. Each carries the actor's name for audit, as the other fine events do.

Migration (payments-service, Flyway)

  • Creates the dispute and evidence tables and the one-dispute-per-fine constraint.
  • Backfills an OPEN dispute for each fine that is already DISPUTED, with grounds OTHER, the fine's status-change reason as the statement (or a fixed placeholder if it is empty), a null filer, and the filing time set to the fine's last update time.

Admin portal (trafficgrid-admin-portal)

  • The API client and mock client gain the dispute types, the dispute field on fines, the decision call and the disputes list. The mock data's disputed fines get dispute records.
  • Opening a fine from the Disputed tab shows a dispute panel with the grounds, statement, evidence names (each with a "preview unavailable" placeholder), filer and filing date.
  • The panel has Uphold and Reject actions. Each asks for a note in a sheet, following the existing status-change sheet. Reject warns when the due date will be extended.
  • The status-change sheet no longer offers "Disputed" and isn't offered on a disputed fine.
  • The overview's Disputed card and the tab counts stay as they are.

Mobile app (traffic-grid-mobile)

  • "Dispute this fine" opens a new dispute screen under the fine's route. It has a grounds picker, a statement field with a character limit, an evidence picker (up to 3 images from the library, of which only metadata is sent), and submit.
  • The action only shows when the fine is PENDING, not past due, and has no dispute.
  • The fine detail shows a dispute card:
  • Open: "Dispute under review", the grounds, statement, evidence names and filing date, and a Withdraw action with a confirmation that warns it can't be redone.
  • Decided or withdrawn: the outcome, decision note, reviewing organisation and decision date.
  • Pay is hidden while the dispute is open. The fine model gains the dispute field, and the display helpers use it for the disputed subtitle, e.g. "dispute under review since 29 Aug".
  • After filing, withdrawing or a refresh, the fine, fines list and vehicle summary queries are invalidated.

Documentation (project-docs)

  • API Contracts: the three dispute endpoints, the disputes list, the dispute field on the fine object, and the changed transition rules for the status change.
  • Data Model: the dispute and evidence tables, and the updated DISPUTED meaning.
  • Kafka Design and System Design: the three new event types.
  • Roles and Permissions: the two new permissions.
  • Implementation Tracker: fine disputes added, and evidence storage and dispute notifications added as Deferred.

Testing Decisions

  • What a good test is: a test checks outcomes a caller can observe, such as the returned fine and dispute, the exception thrown, the published event, or the saved fine's status and due date. It never checks which private helper ran. It should survive a rewrite of the service internals.
  • payments-service FinesService unit tests (the only new tests), with repositories and the citizen vehicle client mocked:
  • filing on a pending fine of a linked vehicle opens a dispute and moves the fine to DISPUTED with the due date unchanged
  • filing is refused for an unlinked vehicle, a non-pending fine, a fine past its due date, a fine with any earlier dispute (including a withdrawn one), or a fine with an open payment
  • rejection keeps the due date when decided more than 7 days before it, and sets it to decision + 7 days when decided within the last 7 days or after the due date
  • upholding clears the fine with the note as the reason
  • a reviewer from another organisation is refused, and a super admin is allowed
  • deciding a dispute that isn't open is refused
  • withdrawal returns the fine to PENDING
  • the general status change refuses DISPUTED as a target and refuses any change to a disputed fine
  • Prior art: the existing FinesServiceTest, especially the status change and court outcome cases.
  • Existing tests the change breaks get updated. That includes payments-service API tests and integration tests that set DISPUTED through the status change, and portal status-sheet tests that expect a "Disputed" choice. No other new tests are added in this pass.

Out of Scope

  • Real evidence storage and upload: object storage, upload endpoints, previews, virus scanning.
  • Notifications to citizens or reviewers. The events are published, but nothing consumes them yet.
  • Disputes filed by admins or officers on a citizen's behalf.
  • More than one dispute per fine, appeals of a decision, and reopening a decided dispute.
  • Partial outcomes such as reducing the amount. Amounts stay frozen at issue.
  • Making the 7-day extension a system parameter.
  • Reviewer assignment, SLAs or escalation of overdue reviews.
  • Disputing parking tickets or fines referred to court.
  • New integration-tests, admin portal or mobile tests beyond fixing the ones that break.

Further Notes

  • The 7-day rule balances two risks. Citizens can't extend their payment window just by filing, and reviewers can't run down a citizen's time to pay by sitting on a dispute.
  • The open-payment check when filing wasn't discussed when the feature was scoped. It was added while writing this spec because the existing settlement code already logs a warning when a payment settles a disputed fine.
  • On mobile, the dispute screen nests under the fine route, so the current single fine-detail route file becomes a folder with an index screen and the dispute screen.