TrafficGrid — Data Model & System Design Reference¶
Version 1.0 | 2026
This document describes the complete data model for the TrafficGrid platform — a unified traffic and vehicle management system consolidating vehicle records, traffic offences, parking fines, and payments across the Zimbabwe Republic Police (ZRP), ZINARA, VTS, City Parking, and City Council.
It is intended as the authoritative reference for developers, system architects, and technical stakeholders. It covers all database entities, their attributes, relationships, constraints, indexes, and design decisions including deliberate denormalization choices.
Table of Contents¶
- System Overview
- Entity Reference
- Entity Relationships
- Key Design Decisions
- Pending Items & Future Work
1. System Overview¶
TrafficGrid is built as a modular monolith using Java 25, Spring Boot 4, PostgreSQL 18 as the primary database, and Redis for caching and token management. The architecture is designed to be extractable into microservices as the system scales, but begins as a single deployable unit to reduce operational complexity during the initial build phase.
1.1 Stakeholders & Integrations¶
| Organisation | Role | Integration Status |
|---|---|---|
| ZRP (ZIMBABWE REPUBLIC POLICE) | Issues traffic offences and spot fines. Officers log fines via mobile. | Manual entry — no existing digital system |
| ZINARA | Source of vehicle licence and registration data. Triggers renewal reminders. | API integration planned |
| VTS (Vehicle Theft Squad) | Verifies VIN numbers and registration details. Falls under ZRP. Not involved in ownership. | API integration planned |
| CVR (Central Vehicle Registry) | The ownership registry: verifies that a citizen who claims a vehicle actually owns it (see §2.4). | Mocked inside vehicle-service — real integration pending |
| City Parking | Issues parking tickets and manages parking sessions. | API integration planned |
| City Council (Harare) | Issues council-related fines and violations. | Manual entry — no existing digital system |
| System Provider (Us) | Builds, owns, and administers the platform. Holds super-admin access. | Full administrative access |
| Expo push service | Receives push notifications from notification-service and relays them to the citizen app on each platform's delivery network. | Integrated — needs the app's EAS project |
| Google Firebase Cloud Messaging (FCM) | Delivers Android push notifications; Expo hands every Android message to FCM. Needs a Firebase project, its google-services.json in the app build and an FCM v1 service-account key uploaded to Expo. No Firebase SDK is used in the app or the backend. |
Setup pending |
| Apple Push Notification service (APNs) | Delivers iOS push notifications, again through Expo. Needs an Apple developer account and an APNs key uploaded to Expo. | Deferred — Android first |
Out of Scope (Phase 1): ZIMRA (vehicle import duties), foreign vehicle registration, insurance provider API integrations.
1.2 Schema Ownership¶
| Service | Owns Schemas / Tables |
|---|---|
| auth-service | users, organisations |
| vehicle-service | vehicles, citizen_vehicles, vehicle_documents, mock_cvr_verifications (until the real CVR is integrated) |
| payments-service | fines, fine_disputes, fine_dispute_evidence, parking_tickets (endpoints planned), payments, payment_items (planned) |
| notifications-service | notifications, notification_preferences, device_tokens |
| audit-service | audit_logs |
| system-parameters | system_parameters schema consisting of currencies, organizations, fine categories (levels), offences, notification_types and payment methods tables |
fines-service is retired. It was planned as a dedicated service for fines and parking tickets but was never built. payments-service now owns the
finesandparking_ticketstables permanently, alongsidepaymentsandpayment_items. Fines and the payments that settle them share one service and one schema, so a payment can mark its finesPAIDwithout a cross-service call. Thefine_categoriesreference table is unchanged: it stays in system-parameters-service, and payments-service reads it via OpenFeign when issuing a fine. See §4.11 for the rationale.
1.3 User Roles¶
| Role | Description |
|---|---|
CITIZEN |
Self-registered member of the public. Can view own vehicles and fines, pay online, receive renewal reminders. |
ZRP_OFFICER |
Zimbabwe Republic Police officer. Can issue fines and search vehicle history via mobile interface. |
COUNCIL_OFFICER |
City Council officer. Can issue council fines and query vehicles. |
PARKING_OFFICER |
City Parking officer. Can manage parking tickets. |
AUTHORITY_ADMIN |
Organisation-level administrator. Can view reports scoped to their organisation. |
SUPER_ADMIN |
Full system access. Can create officer accounts, manage organisations, view all audit logs. |
Note:
AUTHORITY_ADMINaccounts are created bySUPER_ADMINonly.OFFICERaccounts are created byAUTHORITY_ADMINonly. OnlyCITIZENScan self-register.
2. Entity Reference¶
All primary keys are UUIDs rather than auto-increment integers to prevent enumeration attacks and to support future distributed deployment.
2.1 users¶
Owned By: auth-service
The central entity of the system. Represents all human actors — citizens, officers of various types, and administrators. Citizens self-register. Officer accounts are created by a SUPER_ADMIN.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | UUID | PK | Auto-generated. |
| full_name | VARCHAR(255) | NOT NULL | Full legal name. |
| VARCHAR(255) | NOT NULL, UNIQUE | Login credential. Indexed. | |
| phone_number | VARCHAR(50) | NOT NULL, UNIQUE | Used for SMS notifications. Indexed. |
| national_id | VARCHAR(100) | NOT NULL, UNIQUE | Prevents duplicate registrations. Indexed. |
| password_hash | VARCHAR(255) | NOT NULL | BCrypt hash. Plain text never stored. |
| role | ENUM | NOT NULL | See UserRole below. Domain-local to auth-service. |
| organisation_id | UUID | organisations.id, NULLABLE | NULL for citizens. References officer's organisation. Set from organizationId on user creation and carried in the access token's organizationId claim. |
| is_active | BOOLEAN | NOT NULL, DEFAULT true | Soft delete flag. Deactivated users cannot log in or refresh. |
| deactivation_reason | VARCHAR(500) | NULLABLE | Why the account was deactivated. Cleared on reactivation. |
| password_changed_at | TIMESTAMP | NULLABLE | When the password was last reset (whole seconds). Refresh tokens issued at or before it are refused. |
| created_by | UUID | user.id | Admin who created this account. NULL for self-registered citizens. |
| created_at | TIMESTAMP | NOT NULL | Set on insert via @PrePersist. |
| updated_at | TIMESTAMP | NOT NULL | Updated on every change via @PreUpdate. |
Indexes:
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_national_id ON users(national_id);
CREATE INDEX idx_users_phone_number ON users(phone_number);
Enum: UserRole¶
| Value | Description |
|---|---|
CITIZEN |
Self-registered member of the public. |
ZRP_OFFICER |
Zimbabwe Republic Police officer. |
COUNCIL_OFFICER |
City Council officer. |
PARKING_OFFICER |
City Parking officer. |
AUTHORITY_ADMIN |
Organisation-level administrator. |
SUPER_ADMIN |
Full system access. |
password_reset_tokens¶
One-time password-reset links, also owned by auth-service: from /auth/forgot-password, and for staff accounts created without a password.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | UUID | PK | Auto-generated. |
| user_id | UUID | NOT NULL, users.id, ON DELETE CASCADE | Indexed. |
| token_hash | VARCHAR(64) | NOT NULL, UNIQUE | SHA-256 of the token. The token itself is never stored. |
| expires_at | TIMESTAMP | NOT NULL | One hour for a reset; three days for a new account's link. |
| used_at | TIMESTAMP | NULLABLE | Set when used. Using one link spends every other open link for the user. |
| created_at | TIMESTAMP | NOT NULL |
2.2 organisations¶
Owned by system-parameters-service
Represents the government bodies and agencies that interact with TrafficGrid. Officers belong to an organisation. Fine levels (fine categories), and the offences under them, are scoped by issuing organisation.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
name |
VARCHAR(255) |
NOT NULL | Full name. E.g. Zimbabwe Republic Police. |
short_name |
VARCHAR(50) |
NOT NULL | Abbreviated name. E.g. ZRP. |
type |
ENUM |
NOT NULL | See OrganisationType below. |
is_active |
BOOLEAN |
NOT NULL, DEFAULT true | Soft delete flag. |
deactivation_reason |
VARCHAR(500) |
NULLABLE | Why it was deactivated. Cleared on activate. |
created_at |
TIMESTAMP |
NOT NULL | Set on insert. |
updated_at |
TIMESTAMP |
NOT NULL |
The physical table is system_parameters.organizations. Fines snapshot short_name at issue time (fines.issuing_organisation_name).
Enum: OrganisationType¶
| Value | Description |
|---|---|
LAW_ENFORCEMENT |
E.g. Zimbabwe Republic Police. |
COUNCIL |
E.g. Harare City Council. |
PARKING |
E.g. City Parking. |
REGISTRY |
E.g. ZINARA, VTS. |
SYSTEM_PROVIDER |
ShiftFocus Technologies. |
2.3 vehicles¶
Owned by: vehicle-service
Represents registered vehicles. A vehicle exists independently of any citizen link — it is searchable by officers even if no citizen has linked it. Verification of the registration against VTS and ZINARA records is planned.
is_verified is about the vehicle's registration, not about who owns it. A citizen's ownership claim is verified separately, by CVR, and recorded on the link as citizen_vehicles.verification_status (§2.4). The two are unrelated: a vehicle can be unverified and have a verified owner, or the reverse.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
number_plate |
VARCHAR(20) |
NOT NULL, UNIQUE | Primary search field for officers in the field. Indexed. |
vin |
VARCHAR(50) |
UNIQUE, NULLABLE | Vehicle Identification Number. |
engine_number |
VARCHAR(50) |
NULLABLE | |
make |
VARCHAR(100) |
NULLABLE | E.g. Toyota, Ford. |
model |
VARCHAR(100) |
NULLABLE | E.g. Hilux, Ranger. |
year |
INTEGER |
NULLABLE | Manufacturing year. |
color |
VARCHAR(50) |
NULLABLE | Searchable (GET /vehicles?color=). |
vehicle_type |
ENUM |
NOT NULL | See VehicleType below. |
is_verified |
BOOLEAN |
NOT NULL, DEFAULT false | Set to true once the registration is verified against VTS/ZINARA API. Not link (ownership) verification. |
created_at |
TIMESTAMP |
NOT NULL | |
updated_at |
TIMESTAMP |
NOT NULL |
Indexes:
CREATE UNIQUE INDEX idx_vehicles_number_plate ON vehicles(number_plate);
CREATE UNIQUE INDEX idx_vehicles_vin ON vehicles(vin);
Enum: VehicleType¶
***Owned by: `vehicle-service
| Value | Description |
|---|---|
SEDAN |
Standard passenger car. |
HATCHBACK |
Hatchback passenger car. |
SUV |
Sport utility vehicle. |
TRUCK |
Light or heavy commercial truck. |
MOTORCYCLE |
Two-wheeled motor vehicle. |
BUS |
Passenger bus or minibus. |
OTHER |
Any vehicle type not covered above. |
2.4 citizen_vehicles¶
***Owned by: `vehicle-service
A citizen's claim to own a vehicle. Linking a vehicle creates a claim, which the Central Vehicle Registry (CVR) verifies. A citizen can claim several vehicles, but a vehicle has at most one owner: the citizen holding its one VERIFIED link. While a vehicle has a pending or verified claim, nobody else can claim it. The is_primary flag designates which vehicle appears first on the citizen's dashboard.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
citizen_id |
UUID |
NOT NULL, FK → users | Must reference a user with role CITIZEN. |
vehicle_id |
UUID |
NOT NULL, FK → vehicles | At most one PENDING_VERIFICATION or VERIFIED row per vehicle (partial unique index below). |
is_primary |
BOOLEAN |
NOT NULL, DEFAULT false | Only one vehicle per citizen should have this set to true. |
linked_at |
TIMESTAMP |
NOT NULL | When the citizen claimed this vehicle. |
verification_status |
VARCHAR(30) |
NOT NULL, CHECK | See VerificationStatus below. V6. |
cvr_request_id |
UUID |
NULLABLE | CVR's id for the verification request. NULL until CVR has accepted the claim (if CVR was unreachable when the claim was made, the poller submits it later). V6. |
verified_at |
TIMESTAMP |
NULLABLE | When CVR verified the claim. V6. |
Indexes and constraints (V6):
-- One active claim per vehicle, whoever holds it.
CREATE UNIQUE INDEX uq_citizen_vehicles_active_claim
ON citizen_vehicles (vehicle_id)
WHERE verification_status IN ('PENDING_VERIFICATION', 'VERIFIED');
-- Serves the poller.
CREATE INDEX idx_citizen_vehicles_pending
ON citizen_vehicles (verification_status)
WHERE verification_status = 'PENDING_VERIFICATION';
V6 dropped the old (citizen_id, vehicle_id) unique constraint, because rejected rows are kept for audit and a citizen whose claim was rejected may claim the vehicle again. Every link that existed before V6 was migrated as VERIFIED, with verified_at set to linked_at.
Lifecycle:
- A citizen claims a vehicle by plate. The row is inserted as
PENDING_VERIFICATIONand submitted to CVR, storingcvr_request_id. If another citizen already holds a pending or verified claim, the claim is refused with409(the partial unique index also catches a race between two claims). - vehicle-service's
CvrVerificationPoller(everytrafficgrid.cvr.poll-interval, default 30 seconds) asks CVR about each pending row, one transaction per row. CVR's answer moves it toVERIFIED(settingverified_at) orREJECTED, and publishesVERIFY_CITIZEN_VEHICLEorREJECT_CITIZEN_VEHICLE. A CVR error leaves it pending for the next run. - Unlinking deletes a pending or verified row and frees the vehicle. A
REJECTEDrow is never deleted and never shown to the citizen.
Only VERIFIED rows grant anything: access to the vehicle's documents, the owner lookup (§3), and the vehicles payments-service uses to scope a citizen's fines and disputes. The citizen's own vehicle list returns pending and verified rows, so the app can show a pending one as "Verifying…".
Enum: VerificationStatus¶
Owned by: vehicle-service
| Value | Description |
|---|---|
PENDING_VERIFICATION |
Claimed; waiting for CVR. Reserves the vehicle. |
VERIFIED |
CVR confirmed the citizen owns the vehicle. Reserves the vehicle. |
REJECTED |
CVR refused the claim. Kept for audit; frees the vehicle. |
mock_cvr_verifications¶
Requests made to the mock CVR that vehicle-service serves at /api/v1/mock/cvr/verifications (see API Contracts.md §6.4.1), also owned by vehicle-service (V7). Stored so a pending verification survives a restart. The outcome is not stored: it is decided when the request is read, once trafficgrid.cvr.verification-delay (default 2 minutes) has passed, rejecting plates whose last character is 0 and verifying all others. The table is dropped once the real CVR is integrated.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | UUID | PK, DEFAULT gen_random_uuid() |
The request id returned to the caller and stored as citizen_vehicles.cvr_request_id. |
| number_plate | VARCHAR(20) | NOT NULL | Trimmed and in upper case. |
| citizen_id | UUID | NOT NULL | The claiming citizen. |
| requested_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP |
The delay is counted from here. |
2.5 vehicle_documents¶
Owned by: vehicle-service
Tracks expiry dates for regulatory documents associated with a vehicle. Citizens can self-declare documents manually. Integration with ZINARA will allow automatic population and verification. The notification system uses this table to send renewal reminders.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
vehicle_id |
UUID |
NOT NULL, FK → vehicles | |
document_type |
ENUM |
NOT NULL | See DocumentType below. |
expiry_date |
DATE |
NOT NULL | Used to trigger renewal reminders. |
is_self_declared |
BOOLEAN |
NOT NULL | true if manually entered by citizen. Kept for older clients; always agrees with source. |
source |
VARCHAR(20) |
NOT NULL, DEFAULT SELF_DECLARED |
SELF_DECLARED (added by a citizen) or ZINARA (added by an officer from ZINARA's records). Backfilled from is_self_declared. |
created_at |
TIMESTAMP |
NOT NULL | |
updated_at |
TIMESTAMP |
NOT NULL |
A document's expiryStatus (VALID, EXPIRING within 30 days, EXPIRED) is computed on read and not stored.
Enum: DocumentType¶
***Owned by: `vehicle-service
| Value | Description |
|---|---|
VEHICLE_LICENSE |
Annual vehicle licence disc issued by ZINARA. |
INSURANCE |
Third-party vehicle insurance certificate. |
ZBC_LICENSE |
Zimbabwe Broadcasting Corporation licence fee. |
VEHICLE_FITNESS |
Vehicle fitness certificate issued by VTS. Required annually for vehicles over a certain age. |
2.6 fine_categories (levels)¶
Owned by: system-parameters-service
Reference table of fine levels: price bands that each belong to one issuing organisation, e.g. ZRP's Level 1 ($5), Level 2 ($15), Level 3 ($30) and Escalated. "Level" is the user-facing name; the table, the fine-categories API resource and fines.fine_category_id keep the fine category name. The offences officers choose from sit under the levels (§2.6.1) and take their price, currency and organisation from them. Maintained by a Super Admin. Amounts here are the current standard: fines store their own amount snapshot, so a price change only affects fines issued afterwards.
A level marked requires_court (ZRP's Escalated) has no amount: the court sets the penalty. Every other level must have one. Levels are never deleted, only deactivated.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated, or fixed for seeded levels (below). |
code |
VARCHAR(50) |
NOT NULL, UNIQUE per organisation | Machine-readable code. E.g. LEVEL_1, ESCALATED. Not editable. |
name |
VARCHAR(255) |
NOT NULL, UNIQUE per organisation | Human-readable name. E.g. Level 1. Each organisation names its own levels, so ZRP and a council can both have a "Level 1". |
description |
TEXT |
NULLABLE | What kind of offence the level covers. |
amount |
INTEGER |
NULLABLE | Current amount in cents. NULL exactly when requires_court is true (check constraint). Copied onto fines at issue time. |
currency |
VARCHAR(5) |
NOT NULL, DEFAULT USD |
Code of an active currency (checked on create and edit). Copied onto fines at issue time. |
requires_court |
BOOLEAN |
NOT NULL, DEFAULT false | A court level: fines under it go to court, which sets the penalty. Not editable. Code checks this flag, never the code ESCALATED. |
issuing_organization |
UUID |
NOT NULL, FK → organizations | The organisation that owns the level. Only its officers may issue fines under it, and only its users (and a Super Admin) can read it. Not editable. |
is_active |
BOOLEAN |
NOT NULL, DEFAULT true | Inactive levels cannot be used to issue new fines. Deactivating a level does not change its offences' own is_active. |
deactivation_reason |
VARCHAR(500) |
NULLABLE | Why it was deactivated. Cleared on activate. |
created_at |
TIMESTAMP |
NOT NULL | |
updated_at |
TIMESTAMP |
NOT NULL |
Constraints:
CONSTRAINT ck_fine_categories_court_amount
CHECK ((requires_court AND amount IS NULL) OR (NOT requires_court AND amount IS NOT NULL))
CONSTRAINT uk_fine_categories_organization_code UNIQUE (issuing_organization, code)
CONSTRAINT uk_fine_categories_organization_name UNIQUE (issuing_organization, name)
V3 created this table without name; V11 adds name (backfilled from the description, else the code), currency and deactivation_reason. V12 turns categories into levels: it deletes every existing row (they were one category per offence, from before levels existed; the system is pre-production, so they are not converted and issued fines keep their snapshots), makes amount nullable, adds requires_court and the check constraint, and replaces the system-wide unique constraints and indexes on code and name with the per-organisation ones above. It drops those by looking them up, so it also runs on a database whose constraints Hibernate's ddl-auto: update had already named.
Seeded levels¶
V13 seeds ZRP's levels with fixed ids, so offence seeds, other systems and the integration suite can refer to them. Organisation ids are random, so the seed finds ZRP by short_name. It is idempotent (ON CONFLICT DO NOTHING). No other organisation is seeded: a Super Admin creates a council's levels when it is onboarded.
id |
code |
name |
amount (cents) |
currency |
requires_court |
|---|---|---|---|---|---|
f1000000-0000-4000-8000-000000000001 |
LEVEL_1 |
Level 1 | 500 | USD | false |
f1000000-0000-4000-8000-000000000002 |
LEVEL_2 |
Level 2 | 1500 | USD | false |
f1000000-0000-4000-8000-000000000003 |
LEVEL_3 |
Level 3 | 3000 | USD | false |
f1000000-0000-4000-8000-000000000004 |
ESCALATED |
Escalated | NULL | USD | true |
2.6.1 offences¶
Owned by: system-parameters-service
The offence catalogue. Each offence belongs to exactly one level and takes its price, currency and issuing organisation from it: it never stores its own, so two offences in the same level can never be priced differently. Officers pick a level, then an offence in it. Maintained by a Super Admin; reads follow the level's organisation.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated, or fixed for seeded offences. |
code |
VARCHAR(50) |
NOT NULL, UNIQUE | Unique across every organisation, because integrations key on it. Not editable. The same offence under two organisations gets two codes, e.g. ZRP_NO_SEATBELT and COH_NO_SEATBELT. |
name |
VARCHAR(255) |
NOT NULL | Human-readable name. E.g. Driving without a seatbelt. |
description |
TEXT |
NULLABLE | |
legal_reference |
VARCHAR(255) |
NULLABLE | The statute or regulation, for officers to cite. |
fine_category_id |
UUID |
NOT NULL, FK → fine_categories | The offence's level. Editable: moving an offence moves it to the new level's price. |
is_active |
BOOLEAN |
NOT NULL, DEFAULT true | Inactive offences cannot be used to issue new fines. Independent of the level's flag. |
deactivation_reason |
VARCHAR(500) |
NULLABLE | Why it was deactivated. Cleared on activate. |
created_at |
TIMESTAMP |
NOT NULL | |
updated_at |
TIMESTAMP |
NOT NULL |
There is no amount, currency or organisation column.
Constraints and indexes:
CONSTRAINT uk_offences_code UNIQUE (code)
CONSTRAINT fk_offences_fine_category
FOREIGN KEY (fine_category_id) REFERENCES system_parameters.fine_categories (id)
CREATE INDEX idx_offences_fine_category ON system_parameters.offences (fine_category_id);
V14 creates the table. V15 seeds the court catch-all under ZRP's Escalated level, for court matters that are not in the catalogue (officers must then describe the offence in the fine's notes):
id |
code |
name |
Level |
|---|---|---|---|
0ff00000-0000-4000-8000-000000000001 |
ZRP_OTHER_COURT_OFFENCE |
Other court offence | f1000000-0000-4000-8000-000000000004 (ZRP Escalated) |
Seeding offences¶
The offence catalogue is seeded by its own Flyway migration in system-parameters-service. Copy this template for each offence:
INSERT INTO system_parameters.offences
(id, code, name, description, legal_reference, fine_category_id, is_active, created_at, updated_at)
VALUES
(gen_random_uuid(), 'ZRP_NO_SEATBELT', 'Driving without a seatbelt',
'Driver or passenger not wearing a seatbelt.', NULL,
'f1000000-0000-4000-8000-000000000001', true, NOW(), NOW()),
(gen_random_uuid(), 'ZRP_NO_THIRD_PARTY_INSURANCE', 'No third-party insurance',
'Driving a vehicle without valid third-party insurance.', NULL,
'f1000000-0000-4000-8000-000000000003', true, NOW(), NOW())
ON CONFLICT (code) DO NOTHING;
fine_category_idis one of the seeded level ids above: Level 1...0001, Level 2...0002, Level 3...0003, Escalated...0004.codemust be unique across every organisation, not just ZRP. Prefix it with the organisation (ZRP_) and use UPPER_SNAKE case.ON CONFLICT (code) DO NOTHINGmakes the migration safe to run again.- Give an offence a fixed UUID in place of
gen_random_uuid()when other systems need to refer to it by id.
V16 seeds ZRP's catalogue this way: 85 offences from the National Schedule of Deposit Fines, each under the level the schedule gives it, with fixed ids 0ff00000-0000-4000-8000-000000000002 to ...000000000086 in code order (ZRP_LIC_001 to ZRP_LIC_031, ZRP_RTA_001 to ZRP_RTA_022, ZRP_MOV_001 to ZRP_MOV_015, ZRP_SIG_001 to ZRP_SIG_004, ZRP_EQU_001 to ZRP_EQU_008, ZRP_SPD_001 to ZRP_SPD_005). ZRP_SPD_001 (1 to 5 km/h over) is seeded inactive, because the schedule only cautions for it. Speeding more than 50 km/h over goes to court under "Other court offence".
- Don't add an amount or currency: they come from the level.
- Write names and descriptions in plain text, with no markup (e.g. "no third-party insurance", not LaTeX). Double any single quote (driver''s).
2.7 fines¶
Owned by: payments-service
Records of traffic and council offences issued against vehicles. Each fine has a unique human-readable reference number. The amount is copied from the fine category at the time of issue — this is a deliberate denormalization to preserve historical accuracy regardless of future category changes.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
reference_number |
VARCHAR(50) |
NOT NULL, UNIQUE | Format: TG-YYYY-NNNNN. E.g. TG-2026-00123. Indexed. |
vehicle_id |
UUID |
NOT NULL, FK → vehicles | The vehicle the fine was issued against. Indexed. |
fine_category_id |
UUID |
NOT NULL, FK → fine_categories | The level (fine category) the fine was issued under. Plain UUID — fine_categories lives in system-parameters-service. |
issued_by |
UUID |
NOT NULL, FK → users | The officer who issued the fine. |
issuing_organization |
UUID |
NOT NULL | The issuing organisation. Snapshot for reporting. |
amount |
INTEGER |
NOT NULL | In cents. Copied from the level's fine_categories.amount at issue time. Unaffected by future level changes. |
currency |
VARCHAR(10) |
NOT NULL, DEFAULT USD |
Copied from fine_categories.currency at issue time. |
status |
ENUM |
NOT NULL, DEFAULT PENDING | See FineStatus below. |
status_change_reason |
VARCHAR(500) |
NULLABLE | Reason given with the latest manual status change, or the note of a dispute decision. Cleared when a dispute is filed. |
location |
TEXT |
NULLABLE | Where the offence occurred. |
notes |
TEXT |
NULLABLE | Officer notes on the incident. |
due_date |
TIMESTAMP |
NOT NULL | Default 30 days from issue. Used for overdue reminders. |
vehicle_number_plate |
VARCHAR(50) |
NULLABLE | Snapshot of the vehicle at issue time (from vehicle-service). Searched ignoring spaces and case. |
vehicle_make |
VARCHAR(100) |
NULLABLE | Snapshot. |
vehicle_model |
VARCHAR(100) |
NULLABLE | Snapshot. |
fine_category_code |
VARCHAR(100) |
NULLABLE | Snapshot of the level at issue time. |
fine_category_name |
VARCHAR(255) |
NULLABLE | Snapshot. |
issued_by_name |
VARCHAR(255) |
NULLABLE | The issuing officer's name at issue time. |
issuing_organisation_name |
VARCHAR(100) |
NULLABLE | The issuing organisation's short name at issue time. NULL if the lookup failed. |
created_at |
TIMESTAMP |
NOT NULL | When the fine was issued (returned as both createdAt and issuedAt). |
updated_at |
TIMESTAMP |
NOT NULL |
The snapshot columns (V13) are written once at issue time and never looked up on read; fines issued before V13 have them NULL.
Indexes:
CREATE INDEX idx_fines_vehicle_id ON fines(vehicle_id);
CREATE INDEX idx_fines_status ON fines(status);
CREATE UNIQUE INDEX idx_fines_reference_number ON fines(reference_number);
CREATE INDEX idx_fines_plate_normalised ON fines(lower(replace(vehicle_number_plate, ' ', '')));
Enum: FineStatus¶
| Value | Description |
|---|---|
PENDING |
Default state. Fine has been issued and payment is outstanding. |
PAID |
Full payment received and confirmed. |
CLEARED |
Administratively cleared by an authorised admin (e.g. duplicate entry, wrongful issue). |
DISPUTED |
The fine has an OPEN dispute (see §2.7.1). Can't be paid or changed through the status change. Set only by filing a dispute, and left only by withdrawing it (back to PENDING) or deciding it (CLEARED if upheld, PENDING if rejected). |
CANCELLED |
Fine cancelled before payment. Officer or admin action. |
2.7.1 fine_disputes¶
Owned by: payments-service
A citizen's challenge to one fine (V16). A fine has at most one dispute, ever: a withdrawn or decided dispute still counts, so the fine can't be disputed again.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
fine_id |
UUID |
NOT NULL, UNIQUE, FK → fines | The disputed fine. UNIQUE enforces one dispute per fine. |
grounds |
VARCHAR(30) |
NOT NULL, CHECK | See DisputeGrounds below. |
statement |
VARCHAR(1000) |
NOT NULL | The citizen's account. |
status |
VARCHAR(20) |
NOT NULL, CHECK | See DisputeStatus below. |
filed_by |
UUID |
NULLABLE | The citizen who filed. NULL for disputes backfilled by V16. |
filed_by_name |
VARCHAR(255) |
NULLABLE | The filer's name at filing time. |
filed_at |
TIMESTAMP |
NOT NULL | |
reviewed_by |
UUID |
NULLABLE | The admin who decided. NULL until decided, and for a withdrawn dispute. |
reviewed_by_name |
VARCHAR(255) |
NULLABLE | The reviewer's name at decision time. |
reviewing_organization |
UUID |
NULLABLE | The organisation the reviewer acted for: always the fine's issuing organisation, a super admin included. |
reviewing_organisation_name |
VARCHAR(100) |
NULLABLE | Snapshot of its short name, copied from the fine. |
decision_note |
VARCHAR(500) |
NULLABLE | The reviewer's note, also copied to the fine's status_change_reason. |
decided_at |
TIMESTAMP |
NULLABLE | When the dispute was decided or withdrawn. |
created_at |
TIMESTAMP |
||
updated_at |
TIMESTAMP |
V16 backfilled an OPEN dispute for every fine already DISPUTED, so fines an admin had marked disputed by hand can be decided through the dispute flow: grounds OTHER, the fine's status_change_reason as the statement (or a fixed placeholder if it was empty), no filer, and filed_at set to the fine's updated_at.
Indexes:
CREATE INDEX idx_fine_disputes_status ON fine_disputes(status);
Enum: DisputeGrounds¶
| Value | Description |
|---|---|
NOT_MY_VEHICLE |
The fine was recorded against the wrong vehicle. |
INCORRECT_DETAILS |
The location, time or offence is wrong. |
DID_NOT_COMMIT |
The citizen contests the offence itself. |
ALREADY_PAID |
Settled outside the platform. |
OTHER |
Any other reason. Also used for backfilled disputes. |
Enum: DisputeStatus¶
| Value | Description |
|---|---|
OPEN |
Awaiting review. The fine is DISPUTED. |
UPHELD |
Final. The fine was cleared. |
REJECTED |
Final. The fine is PENDING again, with at least 7 days from the decision to pay. |
WITHDRAWN |
Final. Withdrawn by the filer; the fine is PENDING again with its due date unchanged. |
2.7.2 fine_dispute_evidence¶
Owned by: payments-service
Files the citizen described as evidence for a dispute (V16), at most 3 per dispute (checked by the API). Only the details are kept: nothing is uploaded yet.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
dispute_id |
UUID |
NOT NULL, FK → fine_disputes ON DELETE CASCADE | Indexed. |
file_name |
VARCHAR(255) |
NOT NULL | |
content_type |
VARCHAR(100) |
NOT NULL | image/jpeg, image/png or application/pdf (checked by the API). |
size_bytes |
BIGINT |
NOT NULL | At most 5 MB (checked by the API). |
storage_key |
VARCHAR(255) |
NOT NULL | A placeholder, unstored/<uuid>, until file storage exists. |
2.8 parking_tickets¶
Owned by: payments-service
⚠️ Status: Planned — pending City Parking API integration specification.
Parking tickets are managed separately from traffic fines because they originate from City Parking's existing system and follow a different operational process. They share the payments infrastructure through the payment_items bridge table.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
reference_number |
VARCHAR(50) |
NOT NULL, UNIQUE | Format: PKT-YYYY-NNNNN. E.g. PKT-2026-00456. |
vehicle_id |
UUID |
NOT NULL, FK → vehicles | |
issued_by |
UUID |
NOT NULL, FK → users | The parking officer. |
location |
TEXT |
NOT NULL | Parking zone or address. |
amount |
INTEGER |
NOT NULL | In cents. |
status |
ENUM |
NOT NULL, DEFAULT PENDING | See ParkingTicketStatus below. |
external_reference |
TEXT |
NULLABLE | Reference number from City Parking's own system for reconciliation. |
due_date |
DATE |
NOT NULL | |
issued_at |
TIMESTAMP |
NOT NULL | |
updated_at |
TIMESTAMP |
NOT NULL |
Enum: ParkingTicketStatus¶
| Value | Description |
|---|---|
PENDING |
Ticket issued, payment outstanding. |
PAID |
Payment received. |
CLEARED |
Administratively cleared. |
CANCELLED |
Ticket cancelled. |
2.9 payments¶
Owned by: payments-service
Records payment transactions. A single payment can cover multiple fines and/or parking tickets through the payment_items bridge table. The idempotency_key prevents duplicate processing — if the same payment request arrives twice due to a network retry, the second request is safely ignored.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
reference_number |
VARCHAR(50) |
NOT NULL, UNIQUE | Format: TGPAY-YYYY-NNNNN. E.g. TGPAY-2026-00789. |
paid_by |
UUID |
NULLABLE | The user who paid (ECOCASH, BANK_TRANSFER). NULL for CASH: the payer is a walk-in person with no account. V14 cleared the vehicle ids earlier builds stored here for cash payments. |
paid_by_name |
VARCHAR(255) |
NULLABLE | The payer's name at payment time (the caller's name, or the walk-in payer's for CASH). |
payment_method |
ENUM |
NOT NULL | See PaymentMethod below. |
amount |
INTEGER |
NOT NULL | Total in cents. Equals the sum of payment_items amounts. |
currency |
VARCHAR(10) |
NOT NULL, DEFAULT USD |
The items' currency; every item in a payment shares it. |
status |
ENUM |
NOT NULL | See PaymentStatus below. |
provider_reference |
TEXT |
NULLABLE | Transaction reference from EcoCash or bank. Used for reconciliation. |
recorded_by |
UUID |
NULLABLE | Set only for CASH payments logged by an officer. NULL for online payments. |
recorded_by_name |
VARCHAR(255) |
NULLABLE | The recording officer's name at payment time. |
payer_name |
VARCHAR(255) |
NULLABLE | Payer contact as given (details.payer). |
payer_phone |
VARCHAR(50) |
NULLABLE | Required for ECOCASH. |
provider_transaction_id |
VARCHAR(255) |
NULLABLE | From the provider callback. |
provider_status_message |
VARCHAR(500) |
NULLABLE | From the provider callback. |
provider_callback_at |
TIMESTAMP |
NULLABLE | When the provider called back. |
cash_receipt_number |
VARCHAR(100) |
NULLABLE | CASH only. Given by the officer, or TGRCT-YYYY-NNNNN from cash_receipt_seq. |
cash_receipt_location |
VARCHAR(255) |
NULLABLE | CASH only. |
bank_name |
VARCHAR(255) |
NULLABLE | BANK_TRANSFER only (required). |
bank_account_reference |
VARCHAR(255) |
NULLABLE | BANK_TRANSFER only (required). |
bank_proof_reference |
VARCHAR(255) |
NULLABLE | BANK_TRANSFER only. |
idempotency_key |
TEXT |
NOT NULL, UNIQUE | Client-generated key to prevent duplicate payment processing on retries. |
paid_at |
TIMESTAMP |
NULLABLE | Set when payment status becomes SUCCESS. |
created_at |
TIMESTAMP |
NOT NULL | |
updated_at |
TIMESTAMP |
NOT NULL |
The name, contact, provider, cash and bank columns were added by V14. Numbers come from sequences in the payments_service schema: payment_reference_seq (payment references), cash_receipt_seq (cash receipt numbers, V14) and fine_reference_seq (fine references).
Enum: PaymentMethod¶
| Value | Description |
|---|---|
ECOCASH |
EcoCash mobile money. Primary online payment method. |
BANK_TRANSFER |
Direct bank transfer. Requires manual reconciliation. |
CASH |
Cash payment recorded by an officer at a payment point. recorded_by is set. |
Enum: PaymentStatus¶
| Value | Description |
|---|---|
PENDING |
Payment initiated but not yet confirmed by the payment provider. |
SUCCESS |
Payment confirmed. paid_at is set. Associated fines are marked PAID. |
FAILED |
Payment was declined or failed at the provider level. |
REVERSED |
Payment was reversed after initial success (e.g. EcoCash reversal or chargeback). |
2.10 payment_items¶
Owned by: payments-service
Bridge table linking a payment to the specific fines or parking tickets it covers. This many-to-many structure allows a single payment to settle multiple outstanding items in one transaction.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
payment_id |
UUID |
NOT NULL, FK → payments | The parent payment. |
payable_type |
ENUM |
NOT NULL | See PayableType below. Identifies which table payable_id references. |
payable_id |
UUID |
NOT NULL | References either fines.id or parking_tickets.id depending on payable_type. |
reference |
VARCHAR(255) |
NULLABLE | The payable's reference number (a fine's reference_number), snapshotted when the payment is created. V14 backfilled existing items. |
amount |
INTEGER |
NOT NULL | In cents. The portion of the total payment allocated to this item. |
Enum: PayableType¶
| Value | Description |
|---|---|
FINE |
The payment item references a record in the fines table. |
PARKING_TICKET |
The payment item references a record in the parking_tickets table. |
2.11 notifications¶
Owned by: notifications-service
One record per notification: one event for one citizen. notification-service creates it from a Kafka event, then pushes it to the citizen's devices; the record exists, and the citizen sees it in the app's Alerts tab, whatever the push outcome. Records are never deleted. V1 created the table; V2 added read state, the display payload, event de-duplication and the new types.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
user_id |
UUID |
NOT NULL, FK → users | The recipient. |
type |
VARCHAR(40) |
NOT NULL, CHECK | See NotificationType below. Determines the category (FINES, PAYMENTS, VEHICLES) the API filters by. |
channel |
VARCHAR(10) |
NOT NULL, CHECK | See NotificationChannel below. Always PUSH for now. |
title |
VARCHAR(255) |
NOT NULL | |
message |
TEXT |
NOT NULL | The full message body (body in the API). |
status |
VARCHAR(10) |
NOT NULL, CHECK | The push attempt. See NotificationStatus below. |
reference_id |
UUID |
NULLABLE | ID of the related entity (fine, payment or vehicle). |
reference_type |
VARCHAR(30) |
NULLABLE, CHECK | One of: FINE, PARKING_TICKET, VEHICLE_DOCUMENT, VEHICLE, PAYMENT (VEHICLE and PAYMENT added in V2). |
sent_at |
TIMESTAMP WITH TIME ZONE |
NULLABLE | Set when at least one device accepted the push. |
created_at |
TIMESTAMP WITH TIME ZONE |
NOT NULL | |
read_at |
TIMESTAMP WITH TIME ZONE |
NULLABLE | When the citizen read it; NULL while unread. Set once: marking it read again keeps the first time. V2. |
payload |
JSONB |
NULLABLE | Deep-link target and display data, such as the fine id, plate, amount and due date. Keys per type in API Contracts.md §6.9. V2. |
event_id |
UUID |
NULLABLE | The eventId of the Kafka event that caused it. V2. |
Indexes and constraints:
CREATE INDEX notifications_user_history_idx ON notifications (user_id, created_at DESC, id DESC);
-- V2: one notification per event per recipient, so a redelivered event is not notified twice.
ALTER TABLE notifications ADD CONSTRAINT notifications_event_user_uk UNIQUE (event_id, user_id);
-- V2: serves the unread count and read-all.
CREATE INDEX notifications_user_unread_idx ON notifications (user_id) WHERE read_at IS NULL;
Enum: NotificationType¶
Owned by: notifications-service
| Value | Category | Description |
|---|---|---|
VEHICLE_VERIFIED |
VEHICLES |
Sent to the claiming citizen when CVR verifies their claim to a vehicle. |
VEHICLE_VERIFICATION_FAILED |
VEHICLES |
Sent to the claiming citizen when CVR rejects their claim, with CVR's reason. |
FINE_ISSUED |
FINES |
Sent to the vehicle owner when a fine is issued on their vehicle (court wording when it is referred to court). |
FINE_PAID |
FINES |
Sent to the vehicle owner when someone else paid their fine. |
FINE_DISPUTE_DECIDED |
FINES |
Sent to the vehicle owner when a dispute is upheld or rejected (with the new due date when rejected). |
FINE_COURT_OUTCOME |
FINES |
Sent to the vehicle owner when a court sets a penalty or dismisses the fine. |
FINE_STATUS_CHANGED |
FINES |
Sent to the vehicle owner when an admin clears or cancels a fine. |
PAYMENT_SUCCESS |
PAYMENTS |
Sent to the payer when a payment succeeds, listing the fines it covered. |
PAYMENT_FAILED |
PAYMENTS |
Sent to the payer when a payment fails, with the provider's message. |
DOCUMENT_EXPIRY_REMINDER |
VEHICLES |
Planned: a vehicle document is approaching expiry. Nothing produces it yet. |
FINE_DUE_REMINDER |
FINES |
Planned: an unpaid fine is approaching its due date. Nothing produces it yet. |
The "vehicle owner" is the citizen holding the vehicle's VERIFIED link (§2.4), looked up when the event is processed. The full event-to-notification mapping is in Kafka Design.md §3.
Enum: NotificationChannel¶
***Owned by: `system-parameters-service
| Value | Description |
|---|---|
SMS |
Text message to the user's registered phone number. Not delivered yet. |
EMAIL |
Email to the user's registered email address. Not delivered yet. |
PUSH |
Mobile push notification via the TrafficGrid app. The only channel used today. |
Enum: NotificationStatus¶
| Value | Description |
|---|---|
PENDING |
Recorded; the push has not been attempted yet. |
SENT |
At least one of the recipient's devices was accepted by Expo's push service. sent_at is set. |
FAILED |
No device accepted the push: the recipient has no registered devices, Expo refused every device, or Expo could not be reached. The notification is still listed. Failed pushes are not retried. |
2.11.1 device_tokens¶
Owned by: notifications-service (V3)
The citizen's phones, by Expo push token. A citizen can have several, and each gets every push. A token belongs to one user at a time: registering it again refreshes last_seen_at, and a different user registering it (another citizen signing in on the same phone) takes it over. Signing out deletes it. A token Expo reports as DeviceNotRegistered (the app was uninstalled) is deleted on the next push.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
user_id |
UUID |
NOT NULL | The citizen the phone is registered to. Indexed. |
expo_push_token |
VARCHAR(255) |
NOT NULL, UNIQUE | e.g. ExponentPushToken[...]. |
platform |
VARCHAR(10) |
NOT NULL, CHECK | ANDROID or IOS. |
last_seen_at |
TIMESTAMP WITH TIME ZONE |
NOT NULL | Last registration or refresh (every app start while signed in). |
created_at |
TIMESTAMP WITH TIME ZONE |
NOT NULL |
CREATE INDEX device_tokens_user_idx ON device_tokens (user_id);
2.12 notification_preferences¶
Stores a user's preferred notification channels. One record per user, created with SMS and EMAIL the first time it is read. The channels themselves are in notification_preference_channels (preference_id, channel).
Kept, but not used when sending: every notification is recorded and pushed whatever the preferences say.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
user_id |
UUID |
NOT NULL, UNIQUE | One-to-one with users. |
channel |
ENUM |
NOT NULL | See NotificationChannel under notifications. Stored as rows of notification_preference_channels. |
updated_at |
TIMESTAMP |
NOT NULL |
2.13 audit_logs¶
Owned by: audit-service
Immutable record of every state-changing action in the system. Records are never updated or deleted. The before_state and after_state columns store full JSON snapshots of the affected entity, providing a complete audit trail for compliance and forensic investigation.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PK | Auto-generated. |
actor_id |
UUID |
NOT NULL, FK → users | The user who performed the action. |
actor_role |
VARCHAR(50) |
NULLABLE | Snapshot of the actor's role at time of action. Stored separately so role changes don't alter historical records. NULL if the event carried an unknown role. |
actor_name |
VARCHAR(255) |
NULLABLE | Snapshot of the actor's full name, from the event's actorName (V3). NULL for older entries. |
organisation_id |
UUID |
NULLABLE | Snapshot of the actor's organisation. NULL for actors outside any organisation (citizens, the super admin). |
action |
TEXT |
NOT NULL | Description of what happened. E.g. FINE_CREATED, PAYMENT_RECORDED, USER_DEACTIVATED. |
entity_type |
VARCHAR(100) |
NOT NULL | The table that was affected. E.g. fines, users, payments. |
entity_id |
VARCHAR(100) |
NOT NULL | ID of the affected record. Stored as VARCHAR to support any entity type without foreign key coupling. |
before_state |
JSONB |
NULLABLE | Full JSON snapshot of the entity before the change. NULL for create operations. |
after_state |
JSONB |
NULLABLE | Full JSON snapshot of the entity after the change. NULL for delete operations. |
ip_address |
VARCHAR(50) |
NULLABLE | IP address of the request. |
user_agent |
TEXT |
NULLABLE | Client user agent string. |
created_at |
TIMESTAMP |
NOT NULL | Immutable. Never updated. |
2.14 Reference tables in system-parameters-service¶
Owned by: system-parameters-service (schema system_parameters)
Alongside organizations, fine_categories and offences, the service owns three reference tables. Each has id, is_active, created_at and updated_at, plus:
| Table | Columns |
|---|---|
currencies |
name (UNIQUE), code VARCHAR(5) (UNIQUE, e.g. USD, ZWG), rate INTEGER (units per US dollar), deactivation_reason VARCHAR(500) |
payment_methods |
code VARCHAR(50) (NOT NULL, UNIQUE), name, description, deactivation_reason VARCHAR(500) |
notification_channels |
code VARCHAR(50) (NOT NULL, UNIQUE), name, description, deactivation_reason VARCHAR(500) |
code on payment methods and notification channels is a stable UPPER_SNAKE identifier. V11 added it, backfilled from the name (Bank transfer → BANK_TRANSFER; names that collapse to the same code get _2, _3, ...). deactivation_reason on all five tables (V11) is set on deactivate and cleared on activate.
3. Cross-Service Relationships¶
Because services own separate schemas, traditional FK constraints do not cross service boundaries. The table below shows logical relationships and how they are enforced at the application layer.
| From | To | Relationship | Enforcement |
|---|---|---|---|
| citizen_vehicles.citizen_id | users.id (auth-service) | Many citizens → one user | Application validates via auth-service API on link creation |
| citizen_vehicles.vehicle_id | vehicles.id | Many links → one vehicle; at most one pending or verified | FK within vehicle-service schema; partial unique index uq_citizen_vehicles_active_claim |
| vehicle owner (of a fine's vehicle) | citizen_vehicles (VERIFIED row, vehicle-service) |
One vehicle → zero or one owner | No copy is stored. vehicle-service answers GET /vehicles/{id}/owner (users with VEHICLE_READ) and GET /internal/v1/vehicles/{id}/owner (services, with the shared X-Service-Key); 404 when there is no verified owner. The owner is looked up when needed, so it is whoever owns the vehicle at that moment |
| vehicle_documents.vehicle_id | vehicles.id | Many docs → one vehicle | FK within vehicle-service schema |
| fines.vehicle_id | vehicles.id (vehicle-service) | Many fines → one vehicle | payments-service calls vehicle-service via OpenFeign to verify existence before creating fine |
| fines.fine_category_id | fine_categories.id (system-parameters-service) | Many fines → one level | payments-service calls system-parameters-service via OpenFeign to resolve the category (amount, issuing organisation, active status) at issue time |
| parking_tickets.vehicle_id | vehicles.id (vehicle-service) | Many tickets → one vehicle | Same pattern as fines.vehicle_id |
| payments.paid_by | users.id (auth-service) | Many payments → one user | User ID extracted from JWT claims — no API call needed |
| payment_items.payment_id | payments.id | Many items → one payment | FK within payments-service schema |
| payment_items.payable_id | fines.id or parking_tickets.id | Many items → one payable | FK within payments-service schema — fines, parking_tickets, and payment_items are now co-located, so this is an in-process lookup, not a cross-service call; payable status is updated locally when a payment succeeds |
| notifications.user_id | users.id (auth-service) | Many notifications → one user | Taken from the Kafka event: the claiming citizen (vehicle events), the payer's paidBy.id (payment events), or the vehicle's verified owner (fine events), looked up through vehicle-service's internal owner endpoint when the event is processed |
| device_tokens.user_id | users.id (auth-service) | Many devices → one user | The caller's id from the gateway's identity headers when the app registers the device |
| audit_logs.actor_id | users.id (auth-service) | Many logs → one actor | actor_id extracted from Kafka event payload. No FK — audit_logs must never be constrained by user existence. |
4. Key Design Decisions¶
4.1 UUIDs as Primary Keys¶
All primary keys use UUIDs rather than auto-incrementing integers. This prevents enumeration attacks (guessing sequential IDs to access other users' records), supports future distributed database deployments where multiple instances might generate IDs independently, and avoids leaking record counts to external parties.
4.2 No cross service foreign keys¶
Services own their own schema. When service A needs to reference an entity from service B, it stores the UUID as a plain column (no database-level FK). Referential integrity is enforced at the application layer via OpenFeign API calls before writes, or by consuming events from Kafka. This is mandatory for service independence — a service must be deployable without requiring another service's database to be available.
4.3 Deliberate Amount Denormalization on Fines¶
The fine amount is copied from fine_categories (system-parameters-service) onto the fines record at the time of issue. Fine amounts are set by regulation and can change over time. A driver fined $200 in 2024 must still show $200 on their record even if the government later changes the speeding fine to $250. The currency is copied with the amount. The same principle applies to actor_role and organisation_id in audit_logs.
All amounts are stored as INTEGER cents (20000 is $200.00), never as decimals.
4.4 Soft Deletes¶
Users, vehicles, organisations, and fine categories use is_active flags for deactivation rather than hard deletes. Hard deletes would break the logical relationships maintained at the application layer. audit_logs is append-only and is never soft-deleted or hard-deleted under any circumstances.
4.5 Idempotency on Payments¶
The payments table has an idempotency_key column with a UNIQUE constraint. When a client submits a payment request it generates a unique key. If the network drops and the client retries, the server returns the existing payment record instead of creating a duplicate charge.
4.6 Polymorphic Reference in payment_items¶
The payment_items table uses payable_type + payable_id to reference either a fine or a parking ticket without requiring two separate FK columns. fines, parking_tickets, and payment_items are now all owned by payments-service (see §4.11), so routing on payable_type is an in-process repository lookup, not a cross-service call, and payable status is updated locally when a payment succeeds.
4.7 JSONB for Audit Snapshots¶
The before_state and after_state columns in audit_logs use PostgreSQL's JSONB type rather than plain TEXT. JSONB stores JSON in a binary format that supports indexing and querying inside the JSON structure. This means audit records can be searched by specific field values when investigating incidents.
4.8 Domain-local enums¶
Every enum type is defined in the service that owns the domain it describes. No enum is placed in core-service or shared across service codebases. This ensures each service can evolve its domain independently without coupling through a shared library. For example, FineStatus lives in payments-service; NotificationStatus lives in notifications-service.
4.9 notifications-service as a dedicated service¶
notifications-service is a standalone running process on port 8088 (previously co-located in payments-service in v2.0). It is the live consumer of trafficgrid.fines, trafficgrid.payments and trafficgrid.vehicles, in consumer group notifications-service: it turns the events those services already publish into citizen notifications, records them, and pushes them through Expo. It also exposes the citizen's own REST endpoints for the notification list, read state, devices and preferences (API Contracts.md §6.9).
The services that own vehicles, fines and payments don't know notifications exist: they publish domain events as before, and all notification logic (who is told, and what) lives here. The cost is that notification-service parses each event's afterState JSON, and de-duplicates on the event's eventId. This separation keeps payments-service focused on financial transactions and gives notifications its own deployable unit.
4.10 System-parameters-service¶
system-parameters is a live running service (port 8083) that owns currencies, notification channels, organisations, and operational configuration. Other services call it via OpenFeign at runtime when they need reference data. it is home for all aspects of the system that are to be configured at system initialization
4.11 Fines consolidated into payments-service¶
fines-service was planned as its own deployed process but was never built — it's retired from the architecture rather than merely delayed. fines and parking_tickets (schema, enums, and business logic) are instead a permanent part of payments-service. This wasn't just filling a gap with the nearest available service: payment success has to mark a fine PAID, so fines and payments were always the two most tightly coupled domains in the system. Co-locating them removes what would otherwise be a cross-service call on that single most frequent interaction, while fine_categories correctly stays external in system-parameters-service as reference data shared with other concerns (currencies, payment methods, notification channels). Fines also adopted the platform's permission-code (PBAC) model from day one as part of this move — see Roles and Permissions.md for the FINE_CREATE/FINE_READ/FINE_STATUS_UPDATE codes — rather than the role-based gating an earlier, now-superseded revision of API Contracts.md describes.
4.12 Name and Label Snapshots¶
Records that name a person or show another service's data store a copy taken at write time instead of looking it up on read: fines store the vehicle's plate, make and model, the category's code and name, the officer's name and the organisation's short name; payments store the payer's and recording officer's names; payment items store the fine's reference; audit logs store the actor's name. Lists and detail views therefore need no cross-service calls, and history does not change when a name does. The actor's name reaches each service in the gateway's X-User-Name header (see API Contracts.md §2).
5. Pending Items¶
5.1 Schema Pending Implementation¶
- parking_tickets — table relocated / schema provisioned in payments-service; endpoints pending City Parking API integration specification
- users.created_by — to be added via Flyway migration when SUPER_ADMIN account creation flow is built
5.2 External Integrations Pending¶
- VTS API — vehicle fitness and roadworthiness verification
- Central Vehicle Registry (CVR) API — real ownership verification; vehicle-service's mock CVR stands in, and only
CvrVerificationClientchanges when it is integrated - ZINARA API — vehicle registration and document verification
- City Parking API — parking ticket import and synchronisation
- EcoCash payment gateway — real-time payment processing
- Bank transfer webhook integration
- SMS and email delivery for notifications (no provider yet; notifications are push only, and the
SMS/EMAILchannels andnotification_preferencesare not used when sending) - Firebase project and FCM v1 key for Android push (the code is in place; see
specs/Notifications.md) - APNs key for iOS push. The app and notification-service are platform-neutral, so iOS needs only the key uploaded to Expo
- Expo push receipts: notification-service records Expo's immediate ticket (
SENT/FAILED) but does not poll the delayed delivery receipts, and does not retry failed pushes