Spec: Fine levels and the offence catalogue¶
Status: ready-for-agent Date: 2026-10-27
Problem Statement¶
Officers issue fines by choosing a fine category in system-parameters-service. Each category is really a single offence with its own amount, created freely by an admin through the API. Nothing stops prices from drifting between similar offences. Nothing groups offences into the fixed price bands that the issuing authority actually uses. There is also no seeded catalogue of offences, so every fresh environment, test run or integration starts with no offences to issue against.
The system also has no way to handle offences that must go to court. Every fine needs a fixed amount when it is issued. An offence whose penalty is set by a magistrate can't be represented at all. The only workaround would be to let officers type in amounts, which is exactly what the authority does not want. Officers should only describe an offence by hand when it is going to court.
Solution¶
Fine categories become levels: price bands owned by an issuing organisation. ZRP gets these levels:
| Level | Amount (USD) | Overview |
|---|---|---|
| Level 1 | $5 | Minor documentation gaps, minor lighting issues, safety gear, basic cycle/pedestrian rules, simple display errors |
| Level 2 | $15 | Moderate safety/equipment defects, licensing administrative failures, intermediate moving violations, improper passenger/goods carriage |
| Level 3 | $30 | Severe moving violations, dangerous driving, major documentation/insurance fraud, heavy vehicle violations, mobile phone use |
| Escalated | none, set by the court | Offences referred to court |
A new offence catalogue sits under the levels. Every offence belongs to exactly one level and takes its price, currency and issuing organisation from that level. It never stores its own. Levels and offences are seeded by migrations with fixed, published identifiers, so other systems and the integration suite can rely on them.
To issue a fine, an officer picks a level and then an offence in that level. The officer never types an amount. If the offence is in a court level (Escalated), the fine is issued with no amount and the status Referred to court. It can't be paid. When the court rules, an admin records the outcome. Either the fine gets an amount and a due date and becomes payable like any other fine, or it is cancelled with a reason. Each Escalated level has an "Other court offence" catch-all, so officers can still issue court offences that aren't in the catalogue, but they must describe what happened in the notes.
Test Seams¶
The tests use the seams that already exist, at the highest level available:
- The gateway's public HTTP API on the local stack, through the
integration-testssuite. This is the main seam: - level and offence reference data, including the seeded identifiers
- organisation scoping
- issuing by offence
- the court-outcome flow
- payment refusal for referred fines
- the Kafka events these produce
- Each service's own API tests, at the controller and HTTP level of
system-parameters-serviceandpayments-service. These cover validation, authorization and migration behaviour that is awkward to set up end to end. - The admin portal's API client and mock client, plus screen tests. These cover level and offence management, issuing by offence, and the court-outcome action.
- The mobile app's pure formatting and display helpers. These cover how a referred fine with no amount is shown.
No test reaches into another service's database, and no new seam is introduced.
User Stories¶
Levels¶
- As a super admin, I want every organisation's fine levels in one list, so that I can review pricing across organisations.
- As an officer, I want to see only my own organisation's levels, so that I am never offered a level I can't issue under.
- As an authority admin, I want to see only my organisation's levels, so that the list matches what my officers can use.
- As a super admin, I want to create a level for a specific organisation, so that a newly onboarded council can start issuing fines.
- As a super admin, I want each organisation to name its own levels, e.g. "Level 1" or "Band A", so that the system uses each authority's terminology.
- As a super admin, I want two organisations to be able to share a level name and code, so that ZRP and a council can each have a "Level 1" without clashing.
- As a super admin, I want to change a level's amount, so that a price change applies to every offence in that level at once.
- As a super admin, I want a price change to affect only fines issued afterwards, so that past fines keep the amount the motorist was told.
- As a super admin, I want to change a level's currency, name and description, so that the schedule stays accurate.
- As a super admin, I want to deactivate a level with a reason, so that no new fines can be issued under it while it is under review.
- As a super admin, I want reactivating a level to restore its offences without touching each one, so that I don't have to remember which offences were active.
- As a super admin, I want a court level to be marked as requiring court, and that marking to be unchangeable, so that a priced level can't silently turn into a court level or the other way round.
- As a super admin, I want a court level to have no amount, so that nobody mistakes a $0 placeholder for a real penalty.
- As a super admin, I don't want levels to be deletable, so that historical fines never point at a level that no longer exists.
Offences¶
- As an officer, I want to list the offences in a level I've selected, so that I can find the right offence quickly.
- As an officer, I want to see each offence's price, currency and level next to it, so that I can tell the motorist the penalty before issuing.
- As an officer, I want to see an offence's legal reference, so that I can cite the statute if challenged.
- As an officer, I want to see only offences in my organisation's levels, so that I never pick one I'm not allowed to issue.
- As a super admin, I want to see offences across all organisations, so that I can maintain the whole catalogue.
- As a super admin, I want to filter offences by level, organisation and active status, so that I can manage a large catalogue.
- As a super admin, I want to add an offence to a level, so that new by-laws or regulations can be enforced.
- As a super admin, I want to edit an offence's name, description, legal reference and level, so that the catalogue stays correct.
- As a super admin, I want an offence's code to be permanent and unique across the whole system, so that integrations can rely on it.
- As a super admin, I want to deactivate an offence with a reason, so that a repealed offence can no longer be issued.
- As a super admin, I want an offence to have no price of its own, so that two offences in the same level can never be priced differently.
Seeding and integration¶
- As a developer, I want ZRP's four levels seeded with fixed identifiers, so that a fresh environment can issue fines immediately.
- As a developer, I want the full offence catalogue seeded from a migration, so that every environment has the same offences.
- As an integration partner, I want the level and offence identifiers published and stable, so that I can hard-code references to them.
- As a developer, I want the seeds to run safely on a database that Hibernate has already altered, so that existing local stacks upgrade cleanly.
- As a developer, I want the old one-category-per-offence rows replaced instead of migrated, so that the pre-production data model is clean.
Issuing fines¶
- As an officer, I want to issue a fine by choosing an offence, so that the amount and currency are set by the schedule and not by me.
- As an officer, I want issuing to be refused when the offence or its level is inactive, so that withdrawn offences can't be enforced.
- As an officer, I want issuing to be refused when the offence belongs to another organisation, so that jurisdiction is respected.
- As a super admin, I want to issue a fine for any organisation's offence, so that I can act on behalf of an organisation.
- As an officer, I want the issued fine to record the offence and level as they were at issue time, so that later catalogue edits never change a past fine.
- As an officer, I want to issue a court offence and have it marked "Referred to court" with no amount, so that the court can set the penalty.
- As an officer, I want an "Other court offence" option, so that I can still record a court matter that isn't in the catalogue.
- As an officer, I want notes to be required when I use "Other court offence", so that the court record describes what actually happened.
- As an officer, I want notes to stay optional for catalogued offences, so that routine fines stay fast to issue.
Court outcomes¶
- As an authority admin, I want to record a court outcome that sets the fine's amount, so that the motorist can pay what the court ordered.
- As an authority admin, I want the outcome's due date to default to 30 days after the ruling and to be able to override it, so that the motorist has a fair window to pay.
- As an authority admin, I want the court-ordered amount to use the currency the fine was issued in, so that the currency stays consistent.
- As an authority admin, I want to record that the court dismissed the case, with a reason, so that the fine is cancelled and closed.
- As an authority admin, I want to record outcomes only for my organisation's fines, so that jurisdiction is respected.
- As a super admin, I want to record outcomes for any organisation's fines, so that I can resolve stuck cases.
- As an authority admin, I want recording an outcome to be refused unless the fine is "Referred to court", so that payable or closed fines can't be repriced.
- As an authority admin, I don't want the general status change to move a fine into or out of "Referred to court", so that court decisions only enter the system through the court-outcome action.
- As an auditor, I want each court outcome logged as its own audit action, with the before and after state, so that court-driven changes can be traced.
Citizens and payments¶
- As a citizen, I want a referred fine to appear in my fines as "Referred to court" with no amount, so that I know it exists but has no price yet.
- As a citizen, I want to be told when a fine against my vehicle is referred to court, so that I can prepare.
- As a citizen, I want to be told when the court outcome is recorded, with the amount and due date, so that I know what to pay and by when.
- As a citizen, I want paying a referred fine to be refused, so that I can't pay before the court has ruled.
- As a citizen, I want a fine to become payable as normal once the court sets an amount, so that settling it works like any other fine.
Admin portal¶
- As a super admin, I want the fine categories screen to manage levels, showing each level's organisation, amount, currency and court flag, so that the schedule is managed in one place.
- As a super admin, I want an offences screen where I pick a level and manage its offences, so that the catalogue mirrors how officers browse it.
- As a console user issuing a fine, I want to choose a level and then an offence, so that the console works the same way as the officer flow.
- As an authority admin, I want a "Record court outcome" action on referred fines, so that I can enter the ruling without leaving the fine.
- As a console user, I want fine lists and details to show "Referred to court" and "no amount" clearly, so that referred fines aren't mistaken for $0 fines.
Implementation Decisions¶
Terminology¶
- Level is the user-facing name for what the code and API still call a fine category. The resource name
fine-categoriesand the columnfine_category_idare kept, to avoid an extra rename across every service and client. - An offence is a catalogued wrongdoing. It belongs to one level.
Levels (system-parameters-service, fine_categories)¶
issuing_organizationstays on the level. A level belongs to exactly one organisation, and offences inherit it.amountbecomes nullable. A new booleanrequires_courtis added, NOT NULL and false by default. Whenrequires_courtis true,amountmust be NULL; when it is false,amountmust be set. A check constraint enforces this.- The global unique constraints on
codeandnameare replaced byUNIQUE (issuing_organization, code)andUNIQUE (issuing_organization, name). - Code is set at creation and can't be edited.
requires_courtcan't be edited after creation. SUPER_ADMINcan create, edit, activate and deactivate levels; deactivation requires a reason. Levels can't be deleted.- Reads are scoped to the caller's organisation.
SUPER_ADMINsees everything and can filter by organisation. - Permissions: the existing
FINE_CATEGORY_READandFINE_CATEGORY_MANAGE. No new permission codes are added. - Code checks the
requires_courtflag, never the codeESCALATED.
Offences (system-parameters-service, new offences table)¶
- Columns:
idcode: unique across the whole system, can't be editedname,descriptionfine_category_id: FK to the levellegal_reference: nullableis_active,deactivation_reasoncreated_at,updated_at- There is no amount, currency or organisation column. All three come from the level.
- When the same offence exists under two organisations, it gets two distinct codes, e.g.
ZRP_NO_SEATBELTandCOH_NO_SEATBELT. - API
/offences: - create, edit, get by id, and activate/deactivate with a reason
- a list filtered by
fineCategoryId, organisation and active status - Read scoping and permissions are the same as for levels: the organisation rule,
FINE_CATEGORY_READandFINE_CATEGORY_MANAGE. - Each offence response includes a level summary:
id,code,name,amount,currency,requiresCourt,issuingOrganization,isActive. - Deactivating a level doesn't cascade to its offences'
is_activeflags.
Migrations and seeding (system-parameters-service, Flyway)¶
- Schema migration:
- reshape
fine_categoriesas above - create
offences - remove the existing per-offence category rows (pre-production, no backfill)
- Level seed: ZRP only, with fixed UUIDs. ZRP is resolved by
short_name, because organisation UUIDs are random. The seed is idempotent throughON CONFLICT. Levels: LEVEL_1"Level 1" 500 USDLEVEL_2"Level 2" 1500 USDLEVEL_3"Level 3" 3000 USDESCALATED"Escalated" with no amount andrequires_court- Catch-all seed:
ZRP_OTHER_COURT_OFFENCE, under ZRP's Escalated level. -
Offence catalogue seed: a separate migration built from SQL the project lead will supply. It refers to levels by their published fixed UUIDs, and is idempotent through
ON CONFLICT (code). -
The fixed level UUIDs and an
INSERTtemplate for offences are published in the Data Model doc. - COH and other organisations are not seeded.
SUPER_ADMINcreates their levels when they are onboarded.
Issuing fines (payments-service)¶
POST /finestakesoffenceIdin place offineCategoryId. This is a clean break with no compatibility path.- Payments looks up the offence, including its level summary, through a Feign client to system-parameters-service. This replaces the category client.
- Validation:
- offence not found → 404
- offence or level inactive → 422
- offence's level belongs to another organisation and the caller is not a super admin → 422
- "Other court offence" without notes → 400 or 422, following the existing validation convention
- The fine records:
offence_idfine_category_id, which is now the level- snapshots of the offence code and name and the level code and name, next to the existing vehicle, officer and organisation snapshots
- Priced level:
amountandcurrencyare copied from the level, and the status isPENDING, as today. - Court level:
- status
REFERRED_TO_COURT amountNULL, andfines.amountbecomes nullablecurrencycopied from the leveldueDateNULL until the outcome is recorded, sodue_datebecomes nullable toofine.createdis published for both kinds of fine.
Fine status and court outcome (payments-service)¶
- New
FineStatus.REFERRED_TO_COURT, described as "Fine referred to court; amount to be set by the court outcome". - The general
PATCH /fines/{id}/statuscan neither move a fine intoREFERRED_TO_COURTnor out of it. - New endpoint
PATCH /fines/{id}/court-outcome, protected byFINE_STATUS_UPDATE, with the same organisation rule as the status change (super admins are exempt). The request is one of two shapes: - Penalty: an
amount(in cents, greater than 0) and an optionaldueDate, which defaults to 30 days after the outcome. The fine becomesPENDING. - Dismissed: a mandatory
reason. The fine becomesCANCELLEDandstatusChangeReasonis set. - The fine is locked for update in the same way as the existing status change.
- If the fine is not
REFERRED_TO_COURT, the call returns 422. - Publishes the audit action
COURT_OUTCOME_RECORDEDwith the before and after state, and a new Kafka eventfine.court_outcomeontrafficgrid.fines. - Payments already require a fine to be
PENDING, so a referred fine can't be paid and no change is needed in the payment flow.
Notifications¶
- notification-service handles
fine.court_outcomeand tells the citizen the outcome: the amount and due date, or the dismissal. - Its handling of
fine.createdmust tolerate a NULL amount and should say "referred to court" for that status.
Admin portal (traffigrid-admin-portal)¶
- The fine categories screen and category dialog become level management:
- organisation, name, code (fixed after creation), amount (hidden or disabled for court levels), currency, the requires-court flag (fixed after creation), and activate/deactivate with a reason
- A new offences screen: pick a level, then list, create, edit and activate/deactivate its offences.
- The API client:
- schemas, the real client, the hybrid client and the mock database gain levels and offences
offenceIdreplacesfineCategoryIdon issueREFERRED_TO_COURTand a nullable amount are added- a court-outcome call is added
- Fine list and detail screens show "Referred to court" and "—" for the amount. Referred fines get a "Record court outcome" action, offered when the user holds
FINE_STATUS_UPDATE. - Audit labels gain
COURT_OUTCOME_RECORDEDand the offence actions.
Mobile app (trafficgrid-mobile, citizen)¶
- The fines API types gain
REFERRED_TO_COURTand a nullableamountanddueDate. - The fine row and detail show "Referred to court" with no amount and no pay action.
Documentation (traffic-grid/project-docs)¶
These docs are updated:
- Data Model: levels, offences, the fixed seed UUIDs and the offence INSERT template
- API Contracts: levels, offences, POST /fines and court-outcome
- Roles and Permissions: the scoping notes
- Kafka Design: fine.court_outcome
- Implementation Tracker
The test case catalogue gains cases for every story above.
Testing Decisions¶
- What a good test is: tests check behaviour that can be observed through a public interface: HTTP status and body, published events and audit records, rendered UI. They never check internal method calls or repository state. A test should still pass after the implementation behind the seam is rewritten.
- integration-tests (main seam):
- seeded ZRP levels and catch-all present with their published UUIDs
- organisation scoping of level and offence reads
- super-admin-only management
- per-organisation uniqueness
- issuing by offence: the price comes from the level, inactive offence or level → 422, other organisation → 422
- a court offence issues as
REFERRED_TO_COURTwith no amount - payment of a referred fine is refused
- court outcome with an amount → payable, then pay successfully
- court outcome dismissed → cancelled
- outcome on a non-referred fine → 422
- the general status change can't touch referred fines
- the audit trail shows
COURT_OUTCOME_RECORDED
Prior art: the existing fines and fine-categories tests and the Fines support helper.
- system-parameters-service:
- migration tests: the reshaped constraints, the court/amount check constraint, the seeds running cleanly on both a fresh database and a Hibernate-altered one
- authorization tests for the offence and level endpoints
Prior art: GapsMigrationTest, FineCategoryControllerAuthorizationTest, SystemParametersApiTest.
- payments-service:
- API and service tests for issuing by offence and the court-outcome endpoint, with the offence client stubbed
- a concurrency test for court outcome against payment
Prior art: PaymentsApiTest, FinesServiceTest, FeignFineCategoryClientTest, FineConcurrencyIntegrationTest.
- admin portal: API client and mock client specs for levels, offences and court outcome; screen tests for level and offence management and the outcome action. Prior art: reference.spec, mock-reference.spec, fines.spec, reference-data.spec.
- mobile: tests for the display helper that formats a fine with no amount and the referred status. Prior art: format.test.
Out of Scope¶
- Seeding levels or offences for COH or any organisation other than ZRP.
- An officer-facing mobile app or issuing screen. The citizen app only displays fines.
- Many-to-many offence ↔ organisation sharing.
- Migrating existing per-offence category rows, or re-linking existing fines to offences.
- Renaming the
fine-categoriesresource,fine_category_idor the category permission codes. - Court case management beyond recording a single outcome: hearing dates, case numbers, appeals.
- Partial payments or instalments of court-ordered amounts.
- Parking tickets, which keep their own flow.
Further Notes¶
- The offence catalogue SQL comes from the project lead after the level UUIDs and the
INSERTtemplate are published. The Level 3 example "no third-party insurance" should be written in plain text, without the LaTeX markup it had in the source table. - The example offences per level, from the source schedule:
- Level 1: missing/defective spare wheel, jack or spanner; no seatbelt (driver or passenger); missing small fire extinguisher in a light vehicle; minor registration plate display issues; motorcycle rider or passenger without a helmet; minor speeding (6–15 km/h over).
- Level 2: failure to display current vehicle licence or plates; unregistered change of ownership; defective headlights, side lights or indicators; improper overtaking or failure to signal; cracked windscreen or window mechanism defect; moderate speeding (16–25 km/h over).
- Level 3: mobile phone while driving; running a red light or ignoring a stop sign; inefficient or failing brakes; no valid driver's licence or no third-party insurance; failing to stop after a minor accident; PSV without a Certificate of Fitness or Route Authority; significant speeding (26–50 km/h over).
- Speeding beyond 50 km/h over the limit, and other serious offences, are expected to belong under Escalated.
- Amounts are stored in cents, as everywhere else in the system.