Skip to content

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:

  1. The gateway's public HTTP API on the local stack, through the integration-tests suite. This is the main seam:
  2. level and offence reference data, including the seeded identifiers
  3. organisation scoping
  4. issuing by offence
  5. the court-outcome flow
  6. payment refusal for referred fines
  7. the Kafka events these produce
  8. Each service's own API tests, at the controller and HTTP level of system-parameters-service and payments-service. These cover validation, authorization and migration behaviour that is awkward to set up end to end.
  9. 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.
  10. 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

  1. As a super admin, I want every organisation's fine levels in one list, so that I can review pricing across organisations.
  2. 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.
  3. As an authority admin, I want to see only my organisation's levels, so that the list matches what my officers can use.
  4. As a super admin, I want to create a level for a specific organisation, so that a newly onboarded council can start issuing fines.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. As a super admin, I want to change a level's currency, name and description, so that the schedule stays accurate.
  10. 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.
  11. 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.
  12. 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.
  13. As a super admin, I want a court level to have no amount, so that nobody mistakes a $0 placeholder for a real penalty.
  14. 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

  1. As an officer, I want to list the offences in a level I've selected, so that I can find the right offence quickly.
  2. 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.
  3. As an officer, I want to see an offence's legal reference, so that I can cite the statute if challenged.
  4. 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.
  5. As a super admin, I want to see offences across all organisations, so that I can maintain the whole catalogue.
  6. As a super admin, I want to filter offences by level, organisation and active status, so that I can manage a large catalogue.
  7. As a super admin, I want to add an offence to a level, so that new by-laws or regulations can be enforced.
  8. As a super admin, I want to edit an offence's name, description, legal reference and level, so that the catalogue stays correct.
  9. 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.
  10. As a super admin, I want to deactivate an offence with a reason, so that a repealed offence can no longer be issued.
  11. 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

  1. As a developer, I want ZRP's four levels seeded with fixed identifiers, so that a fresh environment can issue fines immediately.
  2. As a developer, I want the full offence catalogue seeded from a migration, so that every environment has the same offences.
  3. As an integration partner, I want the level and offence identifiers published and stable, so that I can hard-code references to them.
  4. 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.
  5. 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

  1. 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.
  2. 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.
  3. As an officer, I want issuing to be refused when the offence belongs to another organisation, so that jurisdiction is respected.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. As an officer, I want notes to be required when I use "Other court offence", so that the court record describes what actually happened.
  9. As an officer, I want notes to stay optional for catalogued offences, so that routine fines stay fast to issue.

Court outcomes

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. As an authority admin, I want to record outcomes only for my organisation's fines, so that jurisdiction is respected.
  6. As a super admin, I want to record outcomes for any organisation's fines, so that I can resolve stuck cases.
  7. 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.
  8. 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.
  9. 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

  1. 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.
  2. As a citizen, I want to be told when a fine against my vehicle is referred to court, so that I can prepare.
  3. 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.
  4. As a citizen, I want paying a referred fine to be refused, so that I can't pay before the court has ruled.
  5. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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-categories and the column fine_category_id are 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_organization stays on the level. A level belongs to exactly one organisation, and offences inherit it.
  • amount becomes nullable. A new boolean requires_court is added, NOT NULL and false by default. When requires_court is true, amount must be NULL; when it is false, amount must be set. A check constraint enforces this.
  • The global unique constraints on code and name are replaced by UNIQUE (issuing_organization, code) and UNIQUE (issuing_organization, name).
  • Code is set at creation and can't be edited. requires_court can't be edited after creation.
  • SUPER_ADMIN can create, edit, activate and deactivate levels; deactivation requires a reason. Levels can't be deleted.
  • Reads are scoped to the caller's organisation. SUPER_ADMIN sees everything and can filter by organisation.
  • Permissions: the existing FINE_CATEGORY_READ and FINE_CATEGORY_MANAGE. No new permission codes are added.
  • Code checks the requires_court flag, never the code ESCALATED.

Offences (system-parameters-service, new offences table)

  • Columns:
  • id
  • code: unique across the whole system, can't be edited
  • name, description
  • fine_category_id: FK to the level
  • legal_reference: nullable
  • is_active, deactivation_reason
  • created_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_SEATBELT and COH_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_READ and FINE_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_active flags.

Migrations and seeding (system-parameters-service, Flyway)

  1. Schema migration:
  2. reshape fine_categories as above
  3. create offences
  4. remove the existing per-offence category rows (pre-production, no backfill)
  5. Level seed: ZRP only, with fixed UUIDs. ZRP is resolved by short_name, because organisation UUIDs are random. The seed is idempotent through ON CONFLICT. Levels:
  6. LEVEL_1 "Level 1" 500 USD
  7. LEVEL_2 "Level 2" 1500 USD
  8. LEVEL_3 "Level 3" 3000 USD
  9. ESCALATED "Escalated" with no amount and requires_court
  10. Catch-all seed: ZRP_OTHER_COURT_OFFENCE, under ZRP's Escalated level.
  11. 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).

  12. The fixed level UUIDs and an INSERT template for offences are published in the Data Model doc.

  13. COH and other organisations are not seeded. SUPER_ADMIN creates their levels when they are onboarded.

Issuing fines (payments-service)

  • POST /fines takes offenceId in place of fineCategoryId. 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_id
  • fine_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: amount and currency are copied from the level, and the status is PENDING, as today.
  • Court level:
  • status REFERRED_TO_COURT
  • amount NULL, and fines.amount becomes nullable
  • currency copied from the level
  • dueDate NULL until the outcome is recorded, so due_date becomes nullable too
  • fine.created is 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}/status can neither move a fine into REFERRED_TO_COURT nor out of it.
  • New endpoint PATCH /fines/{id}/court-outcome, protected by FINE_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 optional dueDate, which defaults to 30 days after the outcome. The fine becomes PENDING.
  • Dismissed: a mandatory reason. The fine becomes CANCELLED and statusChangeReason is 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_RECORDED with the before and after state, and a new Kafka event fine.court_outcome on trafficgrid.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_outcome and tells the citizen the outcome: the amount and due date, or the dismissal.
  • Its handling of fine.created must 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
  • offenceId replaces fineCategoryId on issue
  • REFERRED_TO_COURT and 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_RECORDED and the offence actions.

Mobile app (trafficgrid-mobile, citizen)

  • The fines API types gain REFERRED_TO_COURT and a nullable amount and dueDate.
  • 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_COURT with 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-categories resource, fine_category_id or 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 INSERT template 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.