# PRD — pmf-backend-ah

Evidence date: 2026-10-06.

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

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from `serverless.yml`, `Handler.cs`, `Services/`, `classes/`, `Model/`, and `TestRequests/`. Provenance is labelled. Personal identifiers in `TestRequests/` and secret values in `classes/Constants.cs` and Secrets Manager are omitted.

`README.md` is the Serverless .NET Core starter. It is not a requirements document for Pay My Fines. Where it disagrees with the code, the code is the implemented behaviour.

## Product purpose

**Inferred.** A backend for Pay My Fines (`paymyfines.co.za`): a person looks up traffic fines by identity number or notice number, pays through iVeri Lite, receives a receipt, and the service later settles that payment with the fine source (iForce or eNaTIS). Staff can read transactions, a finance extract, logs, and configurable graphs, and can edit email templates and a convenience fee.

**Implemented.** Twenty-seven Lambda entry points in `serverless.yml`: twenty-six HTTP routes and one Cognito Custom Message trigger. `deletepayment` is commented out and is not deployed.

**Documented** only by comments in `serverless.yml`, `Handler.cs`, and the services. There is no product brief.

**Unknown / Requires Confirmation:** product owner, whether this revision is still the production API, and the client that calls it.

## Users and interactions

| Actor | Evidence | Status |
|---|---|---|
| HTTP caller | Every HTTP event sets `cors: true` and no `authorizer` | **Implemented**. The caller application is not in this repo. Identity is a query or body field (`userId`, `strCognitoUuid`, `Id`), not a verified token |
| Person paying a fine | `PostIveriLiteTransaction`, `IVeriPayment`, receipt email | **Implemented** as API behaviour. No screen is in this repo |
| Registered user | `user` and `userIdNumber` tables, register and update handlers | **Implemented** |
| Cognito user pool | `generateCustomMessage` on pool `tmt-pmf-prod-user-pool` | **Implemented** as a trigger. The pool resource is `existing: true` |
| Staff user who passes a numeric `userId` | Settings, graphs, logs, finance report | **Implemented** as a required parameter. `user.strRole` is returned by `GetUserDetails` and is not checked on any route |
| Cron | `RunCronJob` on `rate(2 hours)` and `GET /runcronjob` | **Implemented** |
| iVeri Lite | Return body parsed in `IVeriPayment` | **Implemented** as a redirect handler. The hosted payment page is not in this repo |
| Operator of the internal fine API | iForce and eNaTIS paths under `Constants.IFORCE_ENATIS_API_BASE_URL` | **Implemented** as an HTTP client |

No route reads `requestContext` authorizer claims. A comment on `RunCronJob` shows `authorizer: aws_iam` commented out.

## Response contract

**Implemented** in `HttpService.CreateResponse`:

- Status 200–299: body `{ "result": true, "payload": ... }` unless `forcedResult` is set.
- Any other status: `{ "result": false, "payload": ... }`.
- Headers: `Content-Type: application/json` and `Access-Control-Allow-Origin: *`.
- If the `exception` argument is passed, the body is the exception message only, not the envelope. `GetUserDetails` uses that path on failure.

`IVeriPayment` does not use this envelope on the success and decline paths. It returns HTTP 302 and a `location` header.

## Systems the product talks to

1. AWS API Gateway and Lambda. Service `tmt-pmf-prod`, runtime `dotnetcore3.1`, stage `production`, region `eu-west-1`, profile `pmf-prod`. One zip, `bin/release/netcoreapp3.1/pay-my-fines-lambda.zip`, is the artifact for every function (`package.individually: true` and `package.artifact` together).
2. MySQL. Host, user, password, and database name come from secret `pmf-prod-rds` in account `475415325198`, region `eu-west-1`. The constructor opens that connection for every handler, including handlers that do not query MySQL.
3. AWS Cognito User Pool `tmt-pmf-prod-user-pool` (existing). Custom Message only.
4. Google `https://www.google.com/recaptcha/api/siteverify`. The secret is a literal in `Constants.reCAPTCHA_KEY`. An older literal is commented beside it. The value is not copied into this PRD.
5. Internal fine API `http://10.106.3.22:80/api/` for iForce (`IForce/...`) and eNaTIS (`infringement/...`). TLS certificate checks are disabled (`ServerCertificateCustomValidation` returns true). Default timeout is 10 seconds (`Constants.TimeDefaultValue`). Some eNaTIS calls omit that timeout.
6. HTML-to-PDF `http://10.106.3.158:8080/htmltopdf`.
7. S3 bucket `tmt-pmf-prod-invoices` in `eu-west-1`, to read the generated PDF.
8. SES SMTP `email-smtp.eu-west-1.amazonaws.com:587`, credentials in secret `pmf-prod-ses-smtp`. From address `noreply@paymyfines.co.za`.
9. EventBridge schedule `RunCronJobDev`, `rate(2 hours)`.
10. VPC security group and two subnets, listed in `serverless.yml`.

The header comments in `serverless.yml` name the base URL `https://puru95wxc8.execute-api.eu-west-1.amazonaws.com/production`. No custom domain is in the template. **Requires confirmation** that this URL is still live.

`GET /reports` (finance) is implemented and is missing from that header comment list.

## Major workflows

### WF-1 — Register a user

**Implemented** in `RegisterUser`, `POST /register`.

1. Body must include `strCognitoUuid` (min length 5), `strEmail`, `strName`, `strSurname`, `strIdType` (numeric, max length 2), `strMobileNumber` (min length 8).
2. If `strIdType` is `"2"`, `strIdNumber` is checked against the South African ID pattern in `ValidData`. **Implemented defect:** a failed check builds a 400 response and does not return, so execution continues.
3. Email must parse as a mail address.
4. If `user.strEmail` already exists, status 200 and payload `"User Email Already exists"`.
5. If `user.strCognitoUuid` already exists, status 200 and payload `"User CognitoUuid Already exists"`.
6. Otherwise insert `user` with `blnActive = 1`, `strFirstUser` set to the name, `dtFirstEdit = NOW()`, and return 201 with name, surname, and email.
7. The insert reads `strIdNumber` even when step 2 did not require it. A missing field throws and returns 500.

`UpdateRegistration`, `POST /updateregistration`, sets `user.strCognitoUuid` where `strEmail` matches, then returns UserId, Cognito id, name, surname, and email. If no row matches, status 500 and payload `"An error was encountered while updating user: Check if the email is registered"`.

### WF-2 — Read and update a profile

**Implemented.**

`GET /details` (`GetLoggedInUserDetails`) requires `userId` (digits) or `strCognitoUuid`. Returns UserID, name, surname, email, and the column at ordinal 14 (commented as `strIdNumber`). Missing user: status 200, `result: false`, payload `"User not found"`.

`GET /user/details` (`GetUserDetails`) accepts `userId`, `strCognitoUuid`, or `strEmail`. Returns a wider user row (including `strRole`, mobile, business account, terms, bill-to address, notes, dates, id number) plus `additionalIds` from `userIdNumber`. `maxNumberIds` is the string `"True"` when that list has 5 or more rows, otherwise `"False"`. Missing user: status 200, `result: false`, payload `"User does not exist"`.

`POST /update` (`PostUserDetails`) requires `userId`, `strName`, `strSurname`, `strIdType`, `strMobileNumber`, `txtBillAddress`, `additionalIds`. It updates the user row, upserts each additional id by `strIDNumber` and `refUserID`, and deletes `userIdNumber` rows for that user that were not in the body. The five-id flag from WF-2 is not enforced here. The same non-returning SA ID check as WF-1 applies when `strIdType` is `"2"`. Unknown user: 404 `"User does not exist"`.

### WF-3 — Cognito email text

**Implemented** in `GenerateCustomEmail` (Cognito trigger, not HTTP) and `PostUpdateSettings`.

Trigger sources:

| `triggerSource` | Subject | Template key |
|---|---|---|
| `CustomMessage_SignUp` | Pay My Fines: Sign Up | `CustomMessage_SignUp` |
| `CustomMessage_ResendCode` | Pay My Fines: Resent code | `CustomMessage_ResendCode` |
| `CustomMessage_ForgotPassword` | Pay My Fines: Forgot Password | `CustomMessage_ForgotPassword` |

The body is `setting.strValue` for that key. `{code}` is replaced with Cognito's code parameter. `{email}` is replaced only for sign-up, using `userAttributes.email`. On exception the original Cognito event is returned and a log row is written.

`POST /postsettings` updates those three keys and `convenience`. Email HTML is stored without `CleanInput`. A custom-message value must contain the literal `%7Bcode%7D` (the URL-encoded form of `{code}`) or the handler returns 400 and does not save that key. `convenience` must parse as a double. `userId` is required and is not used in the SQL.

`GET /getsettings` requires `userId` and returns every `setting` row with `Active = 1` as `{ strKey, strValue }`. It does not filter by user. Empty table: 200 `"No settings found"`.

### WF-4 — Look up fines

**Implemented.** All of these call the internal API. Fines with a zero amount due are dropped. `customerPaymentAllowed` comes from `PaymentChecker`: `false` when a local `infringement` row for that notice has `intPaid = 'YES'` or when the check throws; `true` when the row is absent or `intPaid` is not `YES`.

Notice-number routing in `GetQuickFines` (`GET /getfinesearch`): a notice containing `/` is sent to iForce `IForce/queryfines?Attribute=0`; a digits-only notice is sent to eNaTIS `infringement/queryinfringement`. Any other format returns 200 `"Format: {notice} is incorrect"`. Requires `noticeNumber` and `Id`. `Id` may be digits or the word `GUEST` (case-insensitive). Timeout 30 seconds.

`GET /getoutstandingfines` queries iForce by notice (`Attribute=0`), maps phase `1` Notice, `2` Summons, `3` Court, `4` Warrant, otherwise `Unknown`, and keeps fines whose `totalAmountDue` is not 0. Timeout 30 seconds. Upstream HTTP failure returns 504 `"Third party did not respond"`.

`GET /getallfines` (timeout 45 seconds) accepts either:

- `strIdNumber` plus `strIdType`. Types `"2"` and `"02"` must match the SA ID pattern. Other types are not format-checked. Optional `strIssuingAuthority` filters iForce by `districtName` and eNaTIS by `location.issAuthority.description`.
- or `userId`, which loads that user's id (column ordinal 14) and every `userIdNumber` row, then queries both sources for each id. iForce attribute is `"2"` (commented as ID). If `paymentAllowed` is false, `customerPaymentAllowed` is forced false. That force is not applied on the direct id-number branch.

`GET /queryinfringer` proxies eNaTIS `infringement/queryinfringer` for `strIdType`, `strIdNumber`, `userId`.

`GET /queryinfringements` proxies eNaTIS `infringement/queryinfringement` for `noticeNumber` and `Id`, drops zero amounts, and adds `customerPaymentAllowed`.

`GET /getcharges` proxies iForce `IForce/querycharge`. Non-OK becomes 200 `"No charges found"`. Comment in the handler: "Not used alone".

`GET /evidence` proxies `IForce/queryevidence` with `evidenceToken`, `includeImage` (`true`/`false`, length 4 or 5), and `Id`. Non-OK becomes 200 `"Evidence unavailable"`.

`GET /streamevidence` proxies `IForce/queryevidencestream` with `sessionToken`, `evidenceToken`, and `userId`. Non-OK becomes 200 `"Stream Unavailable"`. `sessionToken` is required but has no format rule in `ValidData`.

### WF-5 — Record an iVeri payment and return from iVeri

**Implemented.**

`POST /postiveritransaction` (`PostIveriLiteTransaction`, timeout 30 seconds) requires body `striVeriNumber`, `userId`, `strStatus`, and reads `strAmount` and `infringements` without a presence check.

1. Insert `transaction` with `refPaymentProviderID = 1`, `dtPayment = NOW()`, `intPaid` from body or `"NO"`, and `dtPaid` set to the literal `'NO'` (the `dtPaid` variable is not written on this insert).
2. New id is `SELECT MAX(TransactionID)`.
3. For each infringement, insert `infringement` (source, id number, date, client, notice, amounts, vehicle registration, demerit points, status copied from `strStatus`).
4. If source equals `iforce` (case-insensitive), load charges from iForce and insert `charge` with demerit points `0`. Otherwise load eNaTIS charges and store `demeritPoints` and `description`.
5. Return 200 with a subset of the new transaction columns.

`POST /iveripayment` (`IVeriPayment`, timeout 45 seconds) is the return from iVeri. Query fields used: `veriLocation`, `status`, `transactionID`. The body is split on `&` after `__EVENTVALIDATION`.

- If `LITE_RESULT_DESCRIPTION` is missing or empty, the code treats the payment as successful: `transaction.intPaid = 'YES'`, `strStatus` from the query, receipt from `ECOM_CONSUMERORDERID`, bank ref from `LITE_BANKREFERENCE`, merchant ref from `MERCHANTREFERENCE`, `dtIVeriSettlement = NOW()`. Matching infringements get `intPaid = 'YES'` and the same status. A convenience amount is read from `setting` key `convenience`. A receipt is generated and mailed. Response is 302 to `{veriLocation}?PaymentNotification?transactionID={id}&status={status}`.
- If the description equals `Transaction cancelled by customer`, transaction and infringements are set `intPaid = 'NO'` and the description is stored. No receipt fields are parsed.
- Any other description also sets `intPaid = 'NO'`, and still tries to parse consumer id, bank ref, and merchant ref from defaults `"Not returned by iVeri"` when those fields were not extracted. **Implemented:** that substring can throw; the catch returns 500.
- The decline redirect adds `&liteResultDescription=...`.
- Empty request returns 500 `"Error"`.

Column name in the UPDATE is `strIVeriNUmber` (capital U). The INSERT column is `striVeriNumber`. Whether MySQL folds those names is **Unknown / Requires Confirmation**.

### WF-6 — Settle with the fine source

**Implemented** in `RunCronJob` and `PaymentService`. Also invoked by `GET /runcronjob` with no authorizer. The function always returns 200 `"Cronjob completed"`, including after an exception (the exception is logged).

Pass 1 — infringements whose `strStatus` is `Source Failed Payment`, joined to `transaction` and `paymentProvider`. Rows whose source is `IFORCE` (upper case) go to iForce; every other source goes to eNaTIS. On HTTP failure the retry count increments. iForce writes `strStatus = 'Source Failed Payment'`. The eNaTIS retry update increments `intSettlementTries` and writes `strSourceResponse` and does not set `strStatus` in that statement. When the new try count is greater than 3, the row is added to a problem list. If that list is non-empty, `Mailer.SendEmail` runs. The admin-role query is commented out, so the CC list is empty. The To address is `csenekal@office.tmtservices.co.za`. Subject `PMF Error Payments`.

Pass 2 — infringements with `intPaid = 'YES'` and `strStatus = 'success'`. The commented SQL above it filtered `transaction.intPaid = 'YES'`, `transaction.strStatus = 'success'`, and `intSettlementTries = 0`. The active SQL does not. First-time failure sets `intSettlementTries = 1` and `strStatus = 'Source Failed Payment'`.

Success on either source sets infringement `strStatus = 'Source Approved Payment'` and `strSourceResponse`. Updates that would set `transaction.intPaid = 'SETTLED'` are commented out, with the note "commented out on Loyed's recommendation after call with SO" on the eNaTIS first-pass success path.

iForce settlement JSON uses `freshFine: false`, `paymentProvider: 13`, `terminalId: 0`. The `terminalId` line is commented `what goes here??`. eNaTIS settlement JSON is `infringementNoticeNumber` and `amount` only.

`POST /registerpayment` (`PostPayment`) forwards the raw body to `IForce/registerpayment`. A comment says "this call is not being used only from cron job". It still requires query `strCognitoUuid`, then reads `userId` from the query for logging. `POST /payinfringement` forwards the raw body to `infringement/payinfringement` and requires query `userId`.

### WF-7 — Transactions, finance, graphs

**Implemented.**

`GET /transactions` requires `userId` and/or `strIdNumber` plus `strIdType` (SA ID check when type is `"2"`). Joins `user`, `transaction`, `infringement`, and `charge`, grouped by `strIVeriNumber`. No rows: 200 `"No Transactions Available"`.

`GET /reports` (`GetFinanceReportData`) requires `issuingAuthority`, `startSettlementDate`, `endSettlementDate` (presence only; those names have no format rules). Returns settlement time, issuing authority, channel (`strInfringementSource`), bank ref, notice number, and transaction amount where `transaction.intPaid = 'SETTLED'` and the settlement timestamp is between the dates. `issuingAuthority=ALL` skips the authority filter. No rows: 200 `"No settings found"` (the same string as the settings handler). Because WF-6 no longer writes `SETTLED`, this report's filter does not match the status the cron writes. **Requires confirmation** whether another system sets `SETTLED`.

`GET /getgraphs` requires `userId`, `startDate`, and `endDate`. Loads `graph` rows with `blnActive = 1` and runs the SQL stored in `strGraphData`, substituting `{startDate}` and `{endDate}`. Types handled: `PieGraph`, `Table`, `CounterTab`, `StackGraph`, `VerticalBarGraph`, `LineMarkGraph`, `AreaGraph`, `RadarGraph`. A table whose title contains `NedBank` uses `GetNedBankTable` and ignores the dates. Failure: 500 `"Graphs can not be generated. Please check the active sql queries"`. `GraphService` comments that the shape is tied to the frontend.

`GET /getgraphsettings` returns every `graph` row (active or not) for a supplied `userId`. The user id is not used in the SQL.

`POST /postgraphsettings` requires `userId` and `graphSettings`. `Delete` on a row with `GraphID` deletes that graph and returns immediately, before later rows in the same body. New and updated queries are executed once with the dates replaced by `2012-02-02`, except title `NedBankTransactions`. Invalid SQL returns 500 `"The sql statement entered is invalid"`. Graph ids are `MAX(GraphID)` after insert.

`GET /logs` (`GetAPILogs`) requires `userId` and selects `log` where `strCognitoUuid` equals that value. The method comment says "For testing only". The route is deployed. No rows: 400 `"User not found"`.

### WF-8 — reCAPTCHA

**Implemented** in `IsRecaptchaValid`, `POST /recaptcha`. Body field `recaptcha` is posted to Google with the hardcoded secret. Success token: 200 payload `"true"`. Google `success: false`: 200 payload `"false"`. Both use `result: true` because the HTTP status is 200. Exceptions: 500.

## Validation summary

| Input | Rule | Where |
|---|---|---|
| `noticeNumber` | Min length 3, charset letters, digits, and `/` | `ValidData` |
| `strIdNumber` | 13-digit SA ID shape | `ValidData`. Applied only when the handler asks, and only for id type `2` or, on get-all-fines, `02` |
| `strIdType` | Digits, max length 2 | `ValidData` |
| `strCognitoUuid` | Min length 5 | `ValidData` |
| `includeImage` | Length 4 or 5 | `ValidData` (intended true/false) |
| `strMobileNumber` | Min length 8. A phone regex is commented out | `ValidData` |
| `evidenceToken` | Min length 2 | `ValidData` |
| `userId` | Digits | `ValidData` |
| `Id` | Digits or `GUEST` | `ValidData` |
| `strName`, `strSurname` | Min length 2, no digits | `ValidData` |
| Email | `MailAddress` parse | `Helper.IsAValidEmail` |
| `CleanInput` | Strips HTML tags, trims, doubles single quotes | `Helper`. Not parameterised SQL |
| Missing query object | 400 `"No parameters provided"` | `Helper.IsRequestEmpty` |

## Error behaviour

| Condition | Result |
|---|---|
| Missing required field | 400 and a message that includes `Missing Paramter(s):` (spelled that way in code) |
| Unhandled exception | 500 and `ex.Message`, and a row in `log` when the handler calls `Logger` |
| Fine API timeout or connection failure | 504 `"Third party did not respond"`, or a payload of that string inside a 200 body on `GetQuickFines` |
| Fine API non-OK | Mixed: some routes pass the upstream status, some return 200 with "No fines found" / "Evidence unavailable" / "No charges found" |
| Cron exception | Logged; HTTP response is still 200 `"Cronjob completed"` |
| Receipt PDF failure | HTML-only mail is attempted when an S3 path was returned; otherwise the receipt method returns false and a log line `"mail not sent"` is written |

## Constraints

- Runtime is `dotnetcore3.1`, which is out of Microsoft support. **Implemented**, not a product decision.
- HTTP routes do not authenticate the caller. **Implemented.**
- SQL is concatenated. `CleanInput` is the only sanitising step, and several values (email templates, iVeri body fragments, graph SQL, finance parameters that miss the cleaner on the way into a parameter — finance dates are passed as MySql parameters) are handled differently. Graph queries are stored and executed.
- One MySQL connection is opened in the Lambda constructor from Secrets Manager on every cold start.
- `SELECT MAX(id)` is used as the new key for transactions, infringements, and graphs.

## Functional requirements (traceable)

### FR-001 — Register and link a user

The system stores a Pay My Fines user keyed by email and Cognito uuid, and can attach a Cognito uuid to an existing email.

Evidence: `RegisterUser`, `UpdateRegistration`, `serverless.yml` paths `register` and `updateregistration`.

Implementation: Implemented.

Confidence: High.

### FR-002 — Profile and additional identity numbers

The system reads and updates the user profile and replaces the set of additional identity numbers sent by the caller.

Evidence: `GetLoggedInUserDetails`, `GetUserDetails`, `PostUserDetails`, table `userIdNumber`.

Implementation: Implemented. The five-id ceiling is reported, not enforced on write.

Confidence: High for the write behaviour. The ceiling as a rule **Requires Confirmation**.

### FR-003 — Fine search across iForce and eNaTIS

The system queries the internal fine API by notice number or identity number and tells the caller whether a local paid row already exists.

Evidence: `GetQuickFines`, `GetAllFines`, `GetOutstandingFines`, `QueryExternalInfringement`, `QueryExternalInfringer`, `PaymentChecker`.

Implementation: Implemented.

Confidence: High.

### FR-004 — Evidence and charges

The system proxies iForce evidence, evidence stream, and charge queries.

Evidence: `GetEvidence`, `GetStreamEvidence`, `GetCharges`.

Implementation: Implemented.

Confidence: High.

### FR-005 — Record a card payment and receipt

The system stores an iVeri transaction and its infringements, and on a successful return marks them paid and emails a PDF receipt.

Evidence: `PostIveriLiteTransaction`, `IVeriPayment`, `PaymentReceiptService`.

Implementation: Implemented.

Confidence: High for the code path. The iVeri field contract **Requires Confirmation** with iVeri.

### FR-006 — Settle payments with the source

A schedule loads paid or previously failed infringements and posts them to iForce or eNaTIS, then emails a fixed address if a retry count passes 3.

Evidence: `RunCronJob`, `PaymentService`, `Mailer`.

Implementation: Implemented. `transaction.intPaid = 'SETTLED'` is not written by this code.

Confidence: High.

### FR-007 — Staff reports and settings

A caller who supplies `userId` can read logs, transactions, a settlement extract, graph data, and settings, and can replace graph definitions, email templates, and the convenience amount.

Evidence: `GetAPILogs`, `GetTransactions`, `GetFinanceReportData`, `GetGraphs`, `GetSettings`, `PostUpdateSettings`, `PostGraphSettings`.

Implementation: Implemented. No role check.

Confidence: High.

### FR-008 — Cognito message templates

Cognito sign-up, resend, and forgot-password messages use subjects fixed in code and bodies stored in `setting`.

Evidence: `GenerateCustomEmail`, `ReplaceEmailPlaceHolders`.

Implementation: Implemented.

Confidence: High.

### FR-009 — reCAPTCHA check

The system verifies a reCAPTCHA token with Google and returns `"true"` or `"false"`.

Evidence: `IsRecaptchaValid`, `Constants.reCAPTCHA_URL`.

Implementation: Implemented.

Confidence: High.
