# Business rules — pmf-backend-ah

Evidence date: 2026-10-06.

Source: `git@bitbucket.org:overdrive/pmf-backend-ah.git`, `master` at `064c4fa` (2022-05-06).

No business-rules document exists in the source repository. Rules below are taken from code. Each rule is labelled **Implemented** or **Inferred**. None of these were confirmed with a product owner.

Secret values and personal data in `TestRequests/` are not copied here.

## Identity

### BR-001 — A user is unique by email and by Cognito uuid

**Implemented.** `RegisterUser` refuses a second insert when `user.strEmail` matches (200 `"User Email Already exists"`) or when `user.strCognitoUuid` matches (200 `"User CognitoUuid Already exists"`).

### BR-002 — Registration requires a valid email

**Implemented.** `Helper.IsAValidEmail` must succeed or the handler returns 400 `"Invalid Email"`. The same check is used when linking a Cognito uuid and when looking up by email.

### BR-003 — South African ID type is the code 2

**Inferred.** When `strIdType` is `"2"`, and on `GetAllFines` also `"02"`, `strIdNumber` must match the pattern described in code as `Invalid RSA ID Format` (13 digits with date, gender, series, citizenship, and control positions). Other id types are stored and sent upstream with no format check.

**Requires confirmation** of the full id-type catalogue. `TestRequests/postUserDetails.json` uses `"02"` on a profile update. `PostUserDetails` only runs the SA check when the type equals `"2"`, so `"02"` skips it.

### BR-004 — Failed SA ID validation does not stop registration or profile update

**Implemented.** In `RegisterUser` and `PostUserDetails`, a failed type-`2` check assigns a 400 response and then continues. This is what the code does. It is not evidence that the business wants an invalid SA ID to be saved.

### BR-005 — Additional identity numbers are replaced by the payload

**Implemented.** `PostUserDetails` inserts or updates each `additionalIds` entry for that `userId`, then deletes `userIdNumber` rows for that user whose `strIDNumber` was not in the payload.

### BR-006 — Five additional identity numbers is a reported ceiling

**Inferred.** `GetUserDetails` sets `maxNumberIds` to true when `userIdNumber` has 5 or more rows. `PostUserDetails` does not reject a sixth row.

**Requires confirmation** whether five is a business limit.

### BR-007 — A new user is stored active

**Implemented.** Insert sets `blnActive = 1`. No handler in this repository sets a user inactive or reads `blnActive` as a gate.

### BR-008 — Linking an account is by email

**Implemented.** `UpdateRegistration` sets `strCognitoUuid` on the row with the given `strEmail`. If the email is not already a user, the response is 500, not 404.

## Access

### BR-009 — HTTP routes do not check a caller credential

**Implemented.** `serverless.yml` sets no authorizer. The only `authorizer: aws_iam` line is commented on `GET /runcronjob`. Handlers trust `userId`, `strCognitoUuid`, `Id`, or `strEmail` from the query or body.

### BR-010 — `user.strRole` is stored and displayed, not enforced

**Implemented.** `GetUserDetails` returns the role column. No handler compares it to a role. The cron's lookup of users whose role is the JSON-like label `["ADMIN"]` is commented out, including a further filter `UserID = 1`.

**Requires confirmation** whether `["ADMIN"]` was the approved admin marker. A sample body in `TestRequests/postUserDetails.json` carries a role in that shape. The sample's personal fields are omitted here.

### BR-011 — Settings, graphs, logs, and the finance report require a user id and then ignore it for authorisation

**Implemented.** Those handlers validate `userId` as digits and do not use it in the SQL filter (logs use it as `log.strCognitoUuid`, which is a data filter, not a role check). `GetSettings` returns every active setting to any caller who passes a numeric `userId`.

### BR-012 — A guest token is a query value, not an account

**Implemented.** The `Id` parameter accepts the word `GUEST`. It is passed through to logs and upstream calls. There is no guest user row created by that value.

## Fines

### BR-013 — A notice that contains `/` is an iForce notice; a digits-only notice is an eNaTIS notice

**Implemented** in `GetQuickFines`. Any other notice returns 200 with a format message. Other handlers do not apply this split; they call the source the route is named for.

### BR-014 — A fine with nothing due is not returned

**Implemented.** iForce results are filtered to `totalAmountDue != 0`. eNaTIS results are filtered to `payment.amount != 0`.

### BR-015 — iForce phase codes

**Implemented** in `GetOutstandingFines` and `GetAllFines`.

| Code | Label |
|---|---|
| 1 | Notice |
| 2 | Summons |
| 3 | Court |
| 4 | Warrant |
| anything else | Unknown |

### BR-016 — The customer may pay only when the source allows it and this database has not already marked the notice paid

**Inferred** from `customerPaymentAllowed`.

`PaymentChecker` returns false when `infringement.intPaid` is `YES` for that notice number, and true when the column is not `YES` or the notice is absent. On exception it returns false.

On the user-id branch of `GetAllFines`, if iForce `paymentAllowed` is false, `customerPaymentAllowed` is set false even when the local row is unpaid. The id-number branch and `GetOutstandingFines` do not apply that extra force.

### BR-017 — Optional issuing-authority filter

**Implemented** on `GetAllFines` only. Query `strIssuingAuthority` keeps iForce fines whose `districtName` equals it, and eNaTIS fines whose `location.issAuthority.description` equals it. It is not validated.

### BR-018 — iForce charges stored locally have no demerit points

**Implemented.** When a transaction is recorded and the source is `iforce`, inserted `charge.intDemeritPoints` is `0`. The comment says demerit points are not available for iForce. eNaTIS charges store the upstream `demeritPoints`.

### BR-019 — Evidence and charge misses are successful HTTP responses

**Implemented.** Missing evidence, missing stream, and missing charges return HTTP 200 with a text payload, not the upstream error status. `GetOutstandingFines` and the eNaTIS proxies do pass an upstream status code on some failures.

## Payments

### BR-020 — A new transaction is stored against payment provider 1

**Implemented.** `PostIveriLiteTransaction` inserts `refPaymentProviderID = 1`.

### BR-021 — Settlement tells iForce the provider code is 13

**Implemented.** `PaymentService` sets `paymentProvider = 13` with the comment "iveri only for now". `terminalId` is `0`. `freshFine` is false.

**Requires confirmation** of both provider codes and of `terminalId`.

### BR-022 — iVeri success is an empty result description

**Inferred** from the comment "No result in here payment went through" and the branch that runs when `LITE_RESULT_DESCRIPTION` is null or empty.

That branch sets `transaction.intPaid` and `infringement.intPaid` to `YES`, copies the query `status` into `strStatus`, stores receipt, bank reference, and merchant reference, and sets `dtIVeriSettlement` to now.

### BR-023 — Customer cancellation is a distinct decline

**Implemented.** Description `Transaction cancelled by customer` sets `intPaid` to `NO` on the transaction and its infringements and does not read receipt fields. Any other non-empty description also sets `intPaid` to `NO` and does attempt to store reference fields.

### BR-024 — A convenience fee is a global setting

**Implemented.** Key `convenience` in `setting`. `IVeriPayment` reads it as a double when building the receipt. `PostUpdateSettings` rejects a non-numeric value with `"convience amount must be a numerical value"`. The receipt shows each infringement amount minus this convenience figure. **Inferred:** convenience is a fee subtracted for the line display. The total calculation is in `PaymentReceiptService` and was not re-derived as a separate product rule.

### BR-025 — The receipt is part of a successful iVeri return

**Implemented.** After the paid update, the handler loads infringements, the payer email from `user`, and calls `GenerateReceipt`. Enquiries on the receipt are `010 500 0077` or `payment@paymyfines.co.za`, and `help@paymyfines.co.za`. From address is `noreply@paymyfines.co.za`. Failure to send is logged and does not change the 302 redirect.

### BR-026 — The browser is sent back to the caller-supplied location

**Implemented.** `IVeriPayment` returns 302 to `{veriLocation}?PaymentNotification?...`. `veriLocation` comes from the query string. There is no allow-list.

### BR-027 — Settlement runs on a timer and on an open HTTP route

**Implemented.** Schedule name `RunCronJobDev`, rate `rate(2 hours)`. Comments in the same block say every 8 hours and "2 hours for testing". `GET /runcronjob` calls the same handler with no authorizer.

**Requires confirmation** of the intended production cadence.

### BR-028 — What the cron tries to settle

**Implemented.**

1. Infringements already in `strStatus = 'Source Failed Payment'`.
2. Infringements with `intPaid = 'YES'` and `strStatus = 'success'`.

Source `IFORCE` is posted to `IForce/registerpayment`. Every other source is posted to `infringement/payinfringement`.

The commented query for pass 2 required `transaction.intPaid = 'YES'`, `transaction.strStatus = 'success'`, and `intSettlementTries = 0`. The active query does not.

### BR-029 — Source approval and failure statuses

**Implemented.**

| Outcome | Infringement change |
|---|---|
| Upstream HTTP OK | `strStatus = 'Source Approved Payment'` and `strSourceResponse` built from the upstream receipt or transaction id |
| First failure of a new payment | `intSettlementTries = 1`, `strStatus = 'Source Failed Payment'` |
| Later iForce failure | `intSettlementTries` incremented, `strStatus = 'Source Failed Payment'` |
| Later eNaTIS failure | `intSettlementTries` incremented and `strSourceResponse` updated. That statement does not set `strStatus` |

`transaction.intPaid = 'SETTLED'` is only present in commented SQL. The finance report still filters on `SETTLED`. **Requires confirmation** which status is the business definition of a settled payment.

### BR-030 — Three failures then an email

**Implemented** on the retry overload only (`problemInfridgments` argument). When `intSettlementTries + 1 > 3`, the infringement is included in the email. The first-failure overload sets tries to 1 and does not email. The message lists infringement id and notice number. To: `csenekal@office.tmtservices.co.za`. A previous To address `lloyd@overdrive.co.za` is commented. CC from the admin query is not populated.

A transport exception inside the cron is logged and the loop uses `continue`, so that infringement is left unchanged on a connection failure.

### BR-031 — Recording a transaction does not mark it paid

**Implemented.** `PostIveriLiteTransaction` defaults `intPaid` to `NO`. The transaction insert writes the `dtPaid` column as the literal `'NO'` rather than the `dtPaid` variable. The infringement insert does use the variable. Provider id on the insert is 1, not 13.

### BR-032 — New keys are the current maximum id

**Implemented.** Transaction, infringement, and graph inserts then `SELECT MAX(...)`. Two concurrent inserts can observe the same max. There is no transaction isolation around that read.

## Finance and graphs

### BR-033 — The finance extract is settled iVeri rows in a date range

**Implemented.** `GET /reports` returns infringement and transaction columns where `transaction.dtIVeriSettlement` is between the two dates and `transaction.intPaid = 'SETTLED'`. `issuingAuthority` of `ALL` means every authority. Any other value must equal `infringement.strIssuingAuthority`.

See BR-029: this status is not written by the settlement code in this revision.

### BR-034 — Graphs are SQL stored in the database

**Implemented.** Active graphs (`blnActive = 1`) run `strGraphData` with `{startDate}` and `{endDate}` replaced. Saving a graph trial-runs that SQL with both dates set to `2012-02-02`, except the title `NedBankTransactions`. A table whose title contains `NedBank` is rendered by `GetNedBankTable` and does not take the request dates.

`GraphService` states the output shape is linked to the frontend and should not change unless the frontend changes.

### BR-035 — Deleting one graph abandons the rest of the save

**Implemented.** `PostGraphSettings` returns as soon as it deletes the first row marked `Delete`. Later rows in `graphSettings` are not applied.

## Messages and captcha

### BR-036 — Cognito subjects are fixed

**Implemented.**

| Trigger | Subject |
|---|---|
| Sign up | Pay My Fines: Sign Up |
| Resend code | Pay My Fines: Resent code |
| Forgot password | Pay My Fines: Forgot Password |

Bodies come from `setting`. `{code}` is replaced. `{email}` is replaced on sign-up only. A saved custom message must contain `%7Bcode%7D` or the update is rejected.

### BR-037 — reCAPTCHA failure is still HTTP 200

**Implemented.** Payload `"true"` or `"false"` with status 200. The envelope `result` is true in both cases because the status is 200.

## Data the handlers assume

**Inferred** from SQL. These tables are not created in the repository:

`user`, `userIdNumber`, `transaction`, `infringement`, `charge`, `paymentProvider`, `setting`, `graph`, `log`.

Column comments in `GetLoggedInUserDetails` and `GetUserDetails` fix a column order (ordinals 0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 17). A schema change that reorders columns changes the API. The code comment says "Watch out when the db changes".

`log` insert columns: `strCognitoUuid`, `txtQuery`, `txtResponse`, `strType`. Types used in calls include `DB`, `TMT`, `iForce`, `eNatis`, `eNATiS`.

## Rules that are not implemented

- No rate limit.
- No check that the Cognito uuid belongs to the caller.
- No check that `userId` owns the notice being paid.
- No unique constraint is declared in this repository for notice numbers.
- `blnActive` on the user is not a login gate.
- The commented `deletepayment` route has no rule in code.
