# PRD — paymyfines-backend

Evidence date: 2026-10-08.

Source: `git@bitbucket.org:overdrive/paymyfines-backend.git`, `master` at `5b98fa7` (2022-02-15). Full hash `5b98fa7640e9cd4d1a71da5f18b107899179c4be`.

There is no prior PRD in the source repository or in Silicon Ark for this directory. `README.md` is a Serverless .NET starter and is not a product specification. Provenance is labelled on each requirement.

## Product purpose

**Implemented.** HTTP Lambdas record a Pay My Fines user, look up infringements, take an iVeri Lite payment result, store the payment, email a receipt, and later submit the payment to iForce or eNatis.

**Documented in user-facing strings.** Mail subjects use the name "Pay My Fines" or "PMF". The constant from-address is `noreply@paymyfines.co.za`.

**Not documented.** No statement in this repository names the legal operator, the traffic authorities, or the problem statement. The service name in config is `tmt-pmf-prod`. Log type strings use `TMT`. **Inferred** from those names: the operator side is TMT. Confirmation is still required.

## Users and interactions

This API does not authenticate the HTTP caller. `serverless.yml` sets `cors: true` on every HTTP event and sets no `authorizer`. A commented `authorizer: aws_iam` sits on `RunCronJob` only.

| Actor | What the code shows | Provenance |
|---|---|---|
| HTTP caller | Anyone who can invoke API Gateway. Identity is whatever `userId`, `Id`, or `strCognitoUuid` the body or query sends | **Implemented** |
| Registered user | A row in `user`, created by `POST /register` or linked by `POST /updateregistration` | **Implemented** |
| Guest | Query parameter `Id` may be the word `GUEST` (`Model/APIModel.cs`). Several fine lookups pass that value through and use it as a log id | **Implemented** |
| Cognito | Existing user pool `tmt-pmf-prod-user-pool` invokes `GenerateCustomEmail` on Custom Message | **Implemented** trigger wiring. Pool behaviour beyond the three trigger sources below is **Unknown** |
| Settlement schedule | EventBridge invokes `RunCronJob` on `rate(2 hours)`. The same function is also `GET /runcronjob` | **Implemented** |
| Operator | `strRole` is read from `user` and returned by user-detail handlers. No handler branches on it before returning graphs, settings, or logs. The cron job looks up `strRole = ["ADMIN"]` only to collect CC addresses | **Implemented** |

`HttpService.CreateResponse` wraps most bodies as `{ "result": true|false, "payload": ... }`. Status 200–299 set `result` true. Other statuses set `result` false unless `forcedResult` is passed. `Access-Control-Allow-Origin` is `*`. `IVeriPayment` does not use this wrapper. It returns HTTP 302 with a `location` header.

The constructor opens no connection. It reads the RDS secret and builds a `MySqlConnection`. Each handler opens and closes that connection. A secret or network failure in the constructor fails the Lambda before the handler runs.

## Major workflows

### Look up a notice

1. Caller sends `GET /getfinesearch` with `noticeNumber` and `Id`.
2. If the notice contains `/`, the handler calls iForce `IForce/queryfines` with attribute `0`.
3. If the notice is digits only, the handler calls eNatis `infringement/queryinfringement`.
4. Any other format returns HTTP 200 with payload `Format: {notice} is incorrect`.
5. The body is `{ iForce, eNatis }`. The side that was not queried stays the string `Third party did not respond`.

`GET /getoutstandingfines` is iForce-only and adds a phase label plus `customerPaymentAllowed`. `GET /queryinfringements` is eNatis-only and adds the same local-payment flag.

### Look up everything for a person

`GET /getallfines` accepts either `userId` or `strIdNumber` plus `strIdType`. Optional `strIssuingAuthority` filters iForce `districtName` and eNatis `location.issAuthority.description`.

When `userId` is sent, the handler loads that user's own ID columns and every `userIdNumber` row, then queries iForce (attribute `2`) and eNatis (`infringement/queryinfringer`) for each ID.

When an ID number is sent, one iForce call and one eNatis call use that number. Type `"2"` or `"02"` must match the RSA ID regex. Any other `strIdType` is sent upstream without that regex.

### Pay

1. `POST /postiveritransaction` inserts a `transaction` row (`refPaymentProviderID` 1, `intPaid` default `NO`) and one `infringement` row per item, then copies charges from iForce or eNatis.
2. iVeri redirects the payer to `POST /iveripayment` with query `veriLocation`, `status`, `transactionID` and a form body.
3. If `LITE_RESULT_DESCRIPTION` is empty, both rows are marked paid (`intPaid = YES`, `strStatus` from the query). A receipt is emailed. The Lambda responds 302 to `{veriLocation}?PaymentNotification?transactionID=...&status=...`.
4. If the description is `Transaction cancelled by customer`, rows are marked unpaid and no receipt fields are written.
5. Any other description marks the rows unpaid and attempts to store receipt, bank, and merchant fields. Those fields are only populated inside the success branch, so this branch can throw before the update. **Implemented.** The code comment on the empty-description case says to check the meaning with iVeri.
6. `GET /runcronjob` or the schedule selects `transaction.intPaid = YES` and `transaction.strStatus = success`, posts each notice to the source, and sets `intPaid = SETTLED` and `strStatus = Source Approved Payment` on success.

`customerPaymentAllowed` is false when `infringement.intPaid` is already `YES` for that notice number, or when the local check throws. It is true when no row exists or the value is not `YES`.

## Functional requirements

### FR-001 — Register a user

**Requirement.** `POST /register` creates a `user` row when neither the email nor the Cognito id is already stored.

**Who.** HTTP caller. The body supplies the Cognito id. This function does not call Cognito.

**Trigger.** `POST /register`.

**Inputs.** JSON body: `strCognitoUuid` (min length 5), `strEmail`, `strName`, `strSurname` (letters only, min length 2), `strIdType` (numeric, max length 2), `strMobileNumber` (min length 8). `strIdNumber` is required by the RSA regex only when `strIdType` is `"2"`.

**Behaviour.** Email must pass `MailAddress` parsing. Duplicate email returns 200 `"User Email Already exists"`. Duplicate Cognito id returns 200 `"User CognitoUuid Already exists"`. Insert sets `blnActive` 1, `strFirstUser` to the name, `dtFirstEdit` to `NOW()`. Success is 201 with name, surname, and email taken by column ordinal from the new row.

**Validation gap (implemented).** When type is `"2"` and the ID fails, the handler assigns a 400 response and does not return. A valid email still reaches the insert.

**Outputs.** 201 and a small dictionary, or 200 duplicate messages, or 400/500.

**Errors.** Missing body fields 400. Invalid email 400. Insert that cannot be re-read 500.

**Evidence.** `Handler.RegisterUser`, `Model/APIModel.cs`. **Implemented.**

### FR-002 — Link a Cognito id to an existing email

**Requirement.** `POST /updateregistration` sets `user.strCognitoUuid` where `strEmail` matches, then returns selected columns of that user.

**Inputs.** `strEmail`, `strCognitoUuid`.

**Behaviour.** No check that the row exists before the update. The following select returns whatever matches the new Cognito id. Response columns are taken by ordinal (index 1 and then a loop). Column names are not asserted in code.

**Evidence.** `Handler.UpdateRegistration`. **Implemented.**

### FR-003 — Read and update a profile

**Requirement.** Three routes read or write `user` and `userIdNumber`.

| Route | Lookup | Notes |
|---|---|---|
| `GET /details` | `userId` or `strCognitoUuid` | Returns one user. Does not load extra ID numbers |
| `GET /user/details` | `userId`, `strCognitoUuid`, or `strEmail` | Also returns `userIdNumber` rows for `refUserID` |
| `POST /update` | Body `userId` plus profile fields and `additionalIds` | Updates the user. Inserts or updates each extra ID by `strIDNumber`. Deletes extra IDs that are no longer in the payload |

`POST /update` runs the RSA ID regex only when `strIdType` is `"2"`. The same non-return gap as FR-001 applies. `txtBillAddress` is stored in `txtBillToAddress`.

**Evidence.** `GetLoggedInUserDetails`, `GetUserDetails`, `PostUserDetails`. **Implemented.**

### FR-004 — Verify reCAPTCHA

**Requirement.** `POST /recaptcha` posts the body field `recaptcha` and the secret in `Constants.reCAPTCHA_KEY` to Google `siteverify`.

**Behaviour.** HTTP 200 payload `"true"` or `"false"` as strings. Exceptions return 500 with the exception message. The secret is hardcoded. This document does not repeat it.

**Evidence.** `Handler.IsRecaptchaValid`, `classes/Constants.cs`. **Implemented.**

### FR-005 — Search by notice number

**Requirement.** `GET /getfinesearch` splits iForce and eNatis by notice format, as in the workflow above.

**Inputs.** `noticeNumber` (min length 3, charset letters, digits, and `/`), `Id` (digits or `GUEST`).

**Timeouts.** Lambda timeout 30 seconds. HTTP client timeout is `Constants.TimeDefaultValue` (10 seconds).

**Evidence.** `Handler.GetQuickFines`. **Implemented.**

### FR-006 — List fines for a user or an ID

**Requirement.** `GET /getallfines` returns iForce and eNatis results for every stored ID of a user, or for one supplied ID.

**Inputs.** `userId` (digits) or `strIdNumber` with `strIdType`. Optional `strIssuingAuthority`.

**Behaviour.** Missing both identifiers returns 400 `"Please provide a userId or idNumber"`. Unknown `userId` returns 200 `"userId does not exist"`. iForce phase numbers become `Notice`, `Summons`, `Court`, `Warrant`, or `Unknown`. On the logged-in path, `paymentAllowed == false` forces `customerPaymentAllowed` false. The ID-number path does not apply that override.

Upstream attribute for iForce ID search is `2`. eNatis path is `infringement/queryinfringer?IdDocumentType=&IdDocumentNumber=`.

Lambda timeout is 45 seconds. Each upstream call still times out at 10 seconds.

**Evidence.** `Handler.GetAllFines`, `GeteNatisIdsData`, `GetIForceIdsData`, `GetAllAssociateIds`. **Implemented.**

### FR-007 — Outstanding iForce fines, charges, and evidence

| Route | Upstream | Required query | Empty upstream |
|---|---|---|---|
| `GET /getoutstandingfines` | `IForce/queryfines` attribute 0 | `noticeNumber`, `Id` | Upstream status code, or 504 on timeout |
| `GET /getcharges` | `IForce/querycharge` | `noticeNumber`, `Id` | 200 `"No charges found"` |
| `GET /evidence` | `IForce/queryevidence` | `evidenceToken`, `Id`, `includeImage` (`True`/`False`, length 4–5) | 200 `"Evidence unavailable"` |
| `GET /streamevidence` | `IForce/queryevidencestream` | `sessionToken`, `evidenceToken`, `userId` | 200 `"Stream Unavailable"` |

`GetCharges` comment: "Not used alone". It is still deployed.

**Evidence.** Matching methods in `Handler.cs`. **Implemented.**

### FR-008 — eNatis query and pay proxies

| Route | Upstream | Required |
|---|---|---|
| `GET /queryinfringer` | `infringement/queryinfringer` | `strIdType`, `strIdNumber`, `userId` |
| `GET /queryinfringements` | `infringement/queryinfringement` | `noticeNumber`, `Id`. Adds `customerPaymentAllowed` |
| `POST /payinfringement` | `infringement/payinfringement` | Query `userId`. Body is forwarded as JSON |
| `POST /registerpayment` | `IForce/registerpayment` | Query `strCognitoUuid`. Body is forwarded. Comment: "this call is not being used only from cron job" |

These four routes do not write MySQL payment rows. Settlement of stored payments is FR-011.

**Evidence.** `QueryExternalInfringer`, `QueryExternalInfringement`, `PayExternalInfringement`, `PostPayment`. **Implemented.**

### FR-009 — Record an iVeri basket

**Requirement.** `POST /postiveritransaction` stores the basket before the card result arrives.

**Inputs.** JSON: `striVeriNumber`, `userId`, `strStatus`, optional `intPaid` (default `NO`), optional `strAmount`, and `infringements[]` with source, ID number, date, notice number, issuing authority, vehicle registration, convenience amount, demerit points.

**Behaviour.** Inserts `transaction` with `refPaymentProviderID` 1, `dtPaid` literal `'NO'`, and an empty `strVeriResponse`. `intPaid` from the body is stored. For each infringement, source `iforce` (case-insensitive) loads charges from `IForce/querycharge` and stores demerit points as 0. Any other source loads eNatis charges from `infringement/queryinfringement` and stores `demeritPoints` and `description`. New IDs are `SELECT MAX(...)`.

**Outputs.** 200 with a subset of the new transaction columns, or 400/500.

**Evidence.** `Handler.PostIveriLiteTransaction`. **Implemented.**

### FR-010 — Accept the iVeri return and email a receipt

**Requirement.** `POST /iveripayment` updates the transaction keyed by `strIVeriNumber` (one update spells the column `strIVeriNUmber`) and redirects the browser.

**Trigger.** iVeri return. Query: `veriLocation`, `status`, `transactionID`. Body is split on `&` after `__EVENTVALIDATION`.

**Success behaviour (empty `LITE_RESULT_DESCRIPTION`).** Sets transaction and infringements `intPaid = YES`, copies `status` into `strStatus`, stores consumer order id as `strRecieptNumber`, bank reference, and merchant reference. Reads `setting.strKey = convenience`. Emails the user from `user.strEmail`. Receipt HTML is posted to the PDF service. After 1.5 seconds the PDF key is read from S3 bucket `tmt-pmf-prod-invoices` and attached. If that read fails and the PDF service returned a path, HTML is emailed instead. Subject `PMF Online Payment Receipt`. From `noreply@paymyfines.co.za`, display name `PMF`.

**Cancel.** Description `Transaction cancelled by customer` sets `intPaid = NO` and does not write receipt fields.

**Other descriptions.** Sets `intPaid = NO` and still reads receipt fields that were left at their defaults unless the success branch ran.

**Output.** 302, header `location` (lowercase). No CORS header on this response. The comment says a CORS header interferes with the redirect.

**Evidence.** `Handler.IVeriPayment`, `Services/PaymentReceiptService.cs`. **Implemented.** Whether an empty result description is iVeri's success signal is **Requires Confirmation**.

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

**Requirement.** `RunCronJob` posts stored payments to iForce and eNatis.

**Trigger.** `GET /runcronjob` with no auth check, and EventBridge rule `RunCronJobDev` at `rate(2 hours)`. The YAML comment says the 2-hour rate is for testing and that 8 hours is recommended. The comment and the rate disagree.

**Pass 1 — retries.** Selects infringements with `strStatus = 'Source Failed Payment'`, joined to `transaction` and `paymentProvider`. Source `IFORCE` (uppercase compare) goes to iForce. Every other source goes to eNatis.

**Pass 2 — new payments.** Selects `transaction.intPaid = 'YES'` and `transaction.strStatus = 'success'`. iVeri success in FR-010 copies the query `status` into `strStatus`. Pass 2 therefore runs only when that query value is `success`.

**iForce body.** `freshFine` true, `issuingAuthorityCode`, `noticeNumber`, `paymentAmount`, `paymentDate` round-trip `o` format, `paymentProvider` 1, `terminalId` 0, `receiptNumber`. Comment in code: "what goes here??" on `terminalId`. Endpoint `IForce/registerpayment`.

**eNatis body.** `infringementNoticeNumber`, `amount`. Endpoint `infringement/payinfringement`.

**Success.** Transaction `intPaid = SETTLED`, `strStatus = Source Approved Payment`. Infringement `strStatus = Source Approved Payment` and a `strSourceResponse` that includes the upstream receipt or transaction id.

**First failure.** Infringement `intSettlementTries = 1`, `strStatus = Source Failed Payment`. Transaction `strStatus = Source Failed Payment`. The eNatis first-failure path sets the transaction status in the same statement list. The iForce first-failure path updates the infringement, then the transaction.

**Retry failure.** iForce increments `intSettlementTries`, keeps `Source Failed Payment`, and when tries exceed 3 adds the row to the problem list. eNatis increments tries and writes `strSourceResponse` but does not set `strStatus` on that statement. When tries exceed 3 the row is added to the problem list.

**Mail.** If the problem list is not empty, select users with `strRole` equal to `["ADMIN"]` (quote characters included). `Mailer.SendEmail` always sends To `lloyd@overdrive.co.za` and CCs those users. Subject `PMF Error Payments`. SMTP secret `pmf-prod-ses-smtp`.

**Output.** Always HTTP 200 `"Cronjob completed"`, including after an exception that was logged.

**Evidence.** `Handler.RunCronJob`, `Services/PaymentService.cs`, `Services/MailService.cs`. **Implemented.** The 2-hour rate versus the 8-hour comment is **Requires Confirmation**.

### FR-012 — Settings and Cognito mail templates

**Requirement.** Active settings are readable. Four keys are writable. Cognito mail uses three of those keys.

| Route | Behaviour |
|---|---|
| `GET /getsettings` | `SELECT strKey, strValue FROM setting WHERE Active = 1`. Requires query `userId` |
| `POST /postsettings` | Requires body `userId`. Accepts `CustomMessage_SignUp`, `CustomMessage_ResendCode`, `CustomMessage_ForgotPassword`, and `convenience`. Email HTML is not passed through `CleanInput`. `convenience` must parse as a double. Email values must contain the placeholder `%7Bcode%7D` (URL-encoded `{code}`), which is then unescaped and stored |

`GenerateCustomEmail` handles `CustomMessage_SignUp`, `CustomMessage_ResendCode`, and `CustomMessage_ForgotPassword`. Subjects are the three strings in the product-purpose section, including the spelling "Forgot Passowrd" and "Resent code". The body is `setting.strValue` with `{code}` replaced by Cognito's `codeParameter`. Other trigger sources are returned unchanged. On exception the input is returned and the error is logged.

**Evidence.** `GetSettings`, `PostUpdateSettings`, `GenerateCustomEmail`. **Implemented.**

### FR-013 — Graphs

**Requirement.** Graph definitions live in MySQL table `graph`. `GET /getgraphs` runs the active ones.

**Inputs.** Query `userId`, `startDate`, `endDate`. Dates are required or the response is 400.

**Behaviour.** Loads `graph` where `blnActive = 1`. Types handled: `PieGraph`, `Table`, `CounterTab`, `StackGraph`, `VerticalBarGraph`, `LineMarkGraph`, `AreaGraph`, `RadarGraph`. A table whose title contains `NedBank` uses a fixed transaction query and ignores the date range. Other types run `strGraphData` after replacing `{startDate}` and `{endDate}`. SQL text is stored in the database. This repository does not show who may write it beyond FR-014.

**Errors.** 500 payload `"Graphs can not be generated. Please check the active sql queries"`.

**Evidence.** `Handler.GetGraphs`, `Services/GraphService.cs`. **Implemented.**

### FR-014 — Edit graph definitions

**Requirement.** `GET /getgraphsettings` returns every `graph` row for a numeric `userId`. `POST /postgraphsettings` inserts, updates, or deletes rows from body `graphSettings`.

**Behaviour.** A record with `GraphID` and `Delete` is deleted and the handler returns immediately, skipping later records in the same body. New and updated queries are executed once with `{startDate}` and `{endDate}` replaced by `2012-02-02` before the row is saved. The comment calls that a demo date.

**Evidence.** `GetGraphSettings`, `PostGraphSettings`. **Implemented.**

### FR-015 — Transactions and API logs

**Requirement.** `GET /transactions` returns payment history. `GET /logs` returns `log` rows.

`GET /transactions` needs `userId` and/or `strIdNumber` plus `strIdType`. Type `"2"` must match the RSA ID regex. Type `"02"` does not, unlike FR-006. Results join `user`, `transaction`, `infringement`, and `charge`, grouped by `strIVeriNumber`. No rows returns 200 `"No Transactions Available"`.

`GET /logs` requires `userId` and selects `log` where `strCognitoUuid` equals that value. The method comment says "For testing only". The route is deployed.

**Evidence.** `GetTransactions`, `GetAPILogs`, `classes/Logger.cs`. **Implemented.**

### FR-016 — Response and input cleaning

**Requirement.** Successful and failed JSON responses use the envelope in the users section, except the iVeri redirect and the case where `CreateResponse` is passed an `exception` argument (body becomes the exception message only).

`Helper.CleanInput` trims, strips a set of backslash escapes, removes HTML tags, and doubles single quotes. It is not parameterised SQL. Several statements interpolate values that were not passed through `CleanInput` (email on register, graph SQL, iVeri form fields, convenience HTML templates).

TLS for upstream calls uses `ServerCertificateCustomValidation`, which returns true for every certificate.

**Evidence.** `Services/ResponseService.cs`, `classes/Helper.cs`. **Implemented.**

## Integrations

| System | Direction | Trigger | Data | Auth in this repo | Failure |
|---|---|---|---|---|---|
| MySQL | Read/write | Almost every handler | Users, IDs, settings, logs, graphs, transactions, infringements, charges, payment providers | Username, password, host, and database name from secret `pmf-prod-rds-6mUVsR` | 500 with exception text, or a logged swallow in `Logger.WriteLog` |
| iForce and eNatis HTTP | Outbound | Search, charges, pay, cron | Notice, ID, payment JSON | None in this code. Base URL is a private IP | String `Third party did not respond`, HTTP 504, or a "no fines" string. Certificate checks are disabled |
| Google reCAPTCHA | Outbound | `POST /recaptcha` | Token plus hardcoded secret | Secret in source | 500 or `false` |
| Cognito | Inbound trigger | Custom Message on pool `tmt-pmf-prod-user-pool` | Trigger source and code parameter | Lambda resource policy from Serverless `existing: true` | Returns the original event |
| iVeri Lite | Inbound HTTP | `POST /iveripayment` | Form body and query status | None | 500, or 302 back to `veriLocation` |
| PDF service | Outbound | After a successful iVeri return | HTML receipt | None. Private IP | HTML-only mail, or receipt method returns false |
| S3 `tmt-pmf-prod-invoices` | Read | After the PDF service returns a filename | Object key | Lambda role (not declared in this repo). `SaveToS3` is never called | Falls back to HTML mail |
| SES SMTP | Outbound | Receipt and cron failure | Mail | Secret `pmf-prod-ses-smtp-0oR1H2` | Logged; cron mail rethrows |

Region for AWS clients is `eu-west-1`.

## Constraints

- Runtime `dotnetcore3.1`. That runtime is end of support at Microsoft. This repository does not record a replacement.
- Lambdas run in VPC security group `sg-0ef1507ae1fcd0913` and subnets `subnet-0c1aaf57b8d9fdb93`, `subnet-0827171db5e6ea7bb`.
- No authorizer on HTTP routes.
- Client timeout 10 seconds. Only some functions raise the Lambda timeout above the Serverless default.
- `Id` may be `GUEST`. That value is a log identifier, not a user row.
- Column names in SQL include spellings `strVechicleRegistration`, `refTrasactionID`, `strRecieptNumber`.

## Unknown / requires confirmation

- Whether this revision is still deployed, and whether `paymyfines-backend-new` replaced it.
- The meaning of each `strIdType` code, and whether `"2"` and `"02"` are the same RSA type.
- Whether an empty iVeri `LITE_RESULT_DESCRIPTION` means success.
- Whether `transaction.strStatus` is always the literal `success` after a paid return. Pass 2 of the cron depends on that.
- The MySQL schema, indexes, and the real contents of `setting` and `graph`.
- Who is allowed to call graph, settings, log, and cron routes.
- Whether `terminalId` 0 is accepted by iForce.
- Contents of remote branches other than `master`.
