# PRD — Paymyfines-CronJob

Evidence date: 2026-10-07.

Source: `git@bitbucket.org:overdrive/paymyfines-cronjob.git`, `master` at `cbc8461` (2021-10-18, "Add project files."). `origin/HEAD` points at that commit. The only branch is `master`.

There is no prior PRD in the source repository or in Silicon Ark. Nothing in the tree is a requirements document. Provenance is labelled on each statement.

The scan-host checkout named on the onboarding issue was not available. Findings are from the git revision above, which is the commit the issue names. Secret values in `appsettings.json` and `MailService.cs` are not copied here.

## Product purpose

**Inferred.** Settle Pay My Fines infringement payments that are already stored in MySQL by posting them to an internal eNaTIS or iForce API, then record the source result. Notify someone when a payment that already failed at the source fails again often enough.

No sentence in the repository states that purpose. The inference is from `GET /CronJob`, the SQL, the two POST paths, and the mail subject `PMF Error Payments`.

**Documented** purpose: none.

## Users and interactions

There is no signed-in user. `Startup.Configure` calls `UseAuthorization` and does not call `AddAuthentication`. `launchSettings.json` sets IIS anonymous authentication to true and Windows authentication to false.

| Actor | What they do | Evidence | Status |
|---|---|---|---|
| Caller of `GET /CronJob` | Starts both settlement passes | `CronJobController.Get` | **Implemented**. Who the caller is: **Unknown** |
| MySQL data already written by another system | Supplies infringement, transaction, and provider rows | SQL in the controller | **Inferred** that another system writes `intPaid = 'YES'` and `Source Failed Payment`. That writer is not in this repository |
| Admin row in `user` | Intended CC recipient when retries exceed 3 | `strRole` filter in `CronJobController` | **Implemented** as a query. Whether it runs is covered under WF-1 |
| Gmail mailbox in `MailService` | Hardcoded From and To | `MailService.SendEmail` | **Implemented** |
| Fine source (eNaTIS or iForce) behind `192.168.0.41:8080` | Accepts or rejects the payment post | `PaymentService` | **Implemented** as an HTTP client. The service behind that address is not in this repository |

## Workflow

### WF-1 — Run settlement

**Implemented** in `CronJobController.Get`. Route: `[Route("[controller]")]` plus `[HttpGet]`, so `GET /CronJob`. Name on the action: `RunCronJob`.

The action always returns `Ok("Cronjob completed")` after the `try` / `catch` / `finally`, including when the `catch` ran. The `catch` body is empty apart from a commented log line. The injected `ILogger<CronJobController>` is never called. Commented lines call `_logger.WriteLog`, which is not a method on `ILogger`.

1. Open MySQL with the `DefaultConnection` string read by a new `ConfigurationBuilder` that loads only `appsettings.json`.
2. Pass 1. Select infringements whose `strStatus` is `Source Failed Payment`, joined to `transaction` on `infringement.refTrasactionID = transaction.TransactionID` and to `paymentProvider` on `transaction.refPaymentProviderID = paymentProvider.PaymentProviderID`.
3. If that query returns rows, post each row (see WF-2 and WF-3) using the overloads that accept a problem list.
4. If the problem list is non-empty, build an admin CC list and call `MailService.SendEmail`. The reader is opened before `CommandText` is set to the admin query. **Inferred:** `ExecuteReader` on a command with no text throws, the `catch` swallows it, and pass 2 does not run. This was not executed in this investigation. The call order is **Implemented**.
5. If pass 1 had rows and the mail block did not throw, clear both in-memory lists.
6. Pass 2. Select rows with `transaction.intPaid = 'YES'`, same joins, no filter on infringement status and no filter on `intSettlementTries`.
7. If that query returns rows, post them with the overloads that do not take a problem list. Those overloads do not send mail.
8. Close the reader in `finally` if it is non-null. Return 200 `Cronjob completed`.

Launch profiles set `launchUrl` to `CronJob` and `launchBrowser` to true. **Implemented:** starting the project from Visual Studio requests this action immediately. That is a developer launch setting, not a schedule.

### WF-2 — Choose a source list

**Implemented** in `PaymentService.LoadInfringements`.

The method signature is `(statement, iForceInfridgments, eNatisInfridgments)`.

For each row:

- If `strInfringementSource` upper-cased equals `IFORCE`, add the row to the first list and set `TransactionId` from `TransactionID`.
- Otherwise add the row to the second list. That branch does not set `TransactionId` (it stays `0`).

Both calls in the controller are:

```text
LoadInfringements(statement, eNatisInfridgments, iForceInfridgments)
```

Positional binding therefore puts IFORCE rows into the controller variable `eNatisInfridgments`, and every other source into `iForceInfridgments`.

The controller then calls `ProcessIForcePayments` on `iForceInfridgments` and `ProcessENatisPayments` on `eNatisInfridgments`.

**Implemented result:** a source of `IFORCE` is posted to `infringement/payinfringement` (the eNaTIS body), and any other source is posted to `IForce/registerpayment`. The IFORCE rows are the ones that carry a real `TransactionId`. The other rows update `TransactionID = 0` when a success or failure statement uses `row.TransactionId`.

The method names and the `if (IFORCE)` branch describe the opposite routing. **Requires Confirmation** whether the reversed arguments are unintentional.

`LoadInfringements` calls `ExecuteNonQuery` on the SELECT and then `ExecuteReader`. The lists are filled from the reader. The effect of the first call was not observed.

Comparison with `string.Equals` for `IFORCE` uses the default comparison, which is ordinal and case-sensitive. The value is upper-cased first, so `iforce` and `IFORCE` both match. Any other source, including an empty string, takes the `else` branch.

### WF-3 — Post and store the result

**Implemented** in the four `Process*` methods. Each method creates an `HttpClient` whose certificate callback is `Helper.ServerCertificateCustomValidation` (always returns true) and whose base address is `http://192.168.0.41:8080/`. No timeout is set. The client is not disposed. The methods return `true` even when individual rows failed. The controller ignores the return value.

eNaTIS body (`PayEnatisInfringement`): `infringementNoticeNumber` = notice number, `amount` = `strAmount` parsed as `double`. Posted to `infringement/payinfringement`.

iForce body (`PayIForceInfringement`): `freshFine` = `true`, `issuingAuthorityCode`, `noticeNumber`, `paymentAmount`, `paymentDate` (the `dtPaid` value as returned by the reader), `paymentProvider` = `strProviderName`, `terminalId` = `0`, `receiptNumber` = `strRecieptNumber`. Posted to `IForce/registerpayment`. A comment on `terminalId` says `what goes here??`. Comments inside the iForce methods still say `call enatis again`.

HTTP status `200` is success. Any other status is failure. The body is deserialised as `ENatisPaymentResponse` or `IForcePaymentResponse` on success, and as `ErrorResponse` (`statusCode`, `message`) on failure. A body that does not match that shape throws. The controller `catch` then stops the rest of the action.

#### Success (all four methods)

1. `UPDATE transaction SET intPaid = 'SETTLED', dtLastEdit = NOW() WHERE TransactionID = '{TransactionId}'`.
2. `UPDATE infringement SET strStatus = 'Source Approved Payment', strSourceResponse = ... WHERE InfringementID = {id}`.

eNaTIS `strSourceResponse` text: `PayResult: {payResult}. InvoiceNumber: {invoiceNumber} ReceiptNumber: {receiptNumber}`.

iForce `strSourceResponse` text: `TransactionID: {transactionId}. ResultDescription: {resultDescription}`.

`IForcePaymentResponse` also has `resultCode`, `amountPaid`, and `contemptAmountPaid`. Those three are not written to the database.

#### First-time failure (pass 2 overloads, no problem list)

eNaTIS (`ProcessENatisPayments` with one list):

1. `UPDATE transaction SET strStatus = 'Source Failed Payment', dtLastEdit = NOW()` for that `TransactionId`.
2. `UPDATE infringement SET intSettlementTries = 1, strStatus = 'Source Failed Payment', strSourceResponse = 'StatusCode: {statusCode}. Description: {message}'`.

iForce (`ProcessIForcePayments` with one list):

1. Update the infringement the same way (`intSettlementTries = 1`, status, source response), and execute that statement.
2. Then `UPDATE transaction SET strStatus = 'Source Failed Payment'` for that `TransactionId`. This statement does not set `dtLastEdit`. It does not change `intPaid`.

Neither first-time path adds the row to a problem list. Neither sends mail.

#### Retry failure (pass 1 overloads, with problem list)

eNaTIS: does not update `transaction`. Sets `intSettlementTries` to the previous count plus one and writes `strSourceResponse`. Does not set `strStatus` in that statement. If the new count is greater than 3, adds the row to the problem list.

iForce: sets `transaction.strStatus = 'Source Failed Payment'` (no `dtLastEdit`), then sets infringement `intSettlementTries`, `strStatus = 'Source Failed Payment'`, and `strSourceResponse`. If the new count is greater than 3, adds the row to the problem list.

The problem list is the only input to mail (WF-4).

### WF-4 — Mail payments that keep failing

**Implemented** in `MailService.SendEmail`, called only from pass 1 when the problem list has at least one row, and only if the admin query block is reached.

Body text, one line per row: `The Cronjob could not complete the following payments:` then `infringementID: {id} NoticeNumber: {notice}`. A comment says the format still needs work.

SMTP: host `smtp.gmail.com`, port `587`, SSL enabled, network delivery, default credentials off. Username, From address, and To address are the same personal Gmail address that authored the commits (display name `Lloyd`). Password in source is the placeholder `xxxxxx`.

The controller builds `adminLabel` as quote characters around `ADMIN` inside brackets, then queries `user` where `strRole` equals `["ADMIN"]` including those quote characters. Each `GetString(0)` value is added as CC. Which column is ordinal 0 is **Unknown** (the query is `SELECT *`). The To address does not come from that query.

`SendEmail` rethrows after a failed send (`throw ex`). Combined with WF-1, a send failure is swallowed by the controller and pass 2 is skipped.

## Requirements recovered from the tree

### FR-001 — One HTTP call runs settlement

The system exposes `GET /CronJob` and performs pass 1 then pass 2.

Requirement: an unauthenticated GET starts the job.

Evidence: `CronJobController`, `Startup` (no authentication service).

Implementation: Implemented.

Confidence: High.

### FR-002 — Previously failed infringements are retried first

Pass 1 selects `infringement.strStatus = 'Source Failed Payment'`.

Evidence: first SQL string in `CronJobController.Get`.

Implementation: Implemented.

Confidence: High.

### FR-003 — Payments marked YES are submitted next

Pass 2 selects `transaction.intPaid = 'YES'`. It does not require a status of `success` and does not require `intSettlementTries = 0`.

Evidence: second SQL string in `CronJobController.Get`.

Implementation: Implemented.

Confidence: High.

### FR-004 — Source IFORCE is classified inside the loader

`LoadInfringements` puts upper-cased source `IFORCE` on its first list and every other source on its second list.

Evidence: `PaymentService.LoadInfringements`.

Implementation: Implemented.

Confidence: High. The controller then binds those lists in reverse. See FR-005.

### FR-005 — The controller binds the lists in reverse

IFORCE rows are passed to `ProcessENatisPayments`. Other rows are passed to `ProcessIForcePayments`.

Evidence: both `LoadInfringements` calls and the following `Process*` calls in `CronJobController.Get`.

Implementation: Implemented.

Confidence: High for the binding. Whether it was intended: **Requires Confirmation**.

### FR-006 — A successful post marks the transaction settled and the infringement approved

`transaction.intPaid` becomes `SETTLED` and `dtLastEdit` becomes now. `infringement.strStatus` becomes `Source Approved Payment`. `strSourceResponse` stores the receipt or iForce result fields named in WF-3.

Evidence: success branch of each `Process*` method.

Implementation: Implemented. Which `TransactionID` is updated follows `InfringementPayment.TransactionId`, which pass 2 / pass 1 only fills for IFORCE rows (FR-004, FR-005).

Confidence: High.

### FR-007 — A first failure marks the infringement as a source failure with one try

`intSettlementTries` is set to `1` and `strStatus` to `Source Failed Payment`. `strSourceResponse` stores `statusCode` and `message` from `ErrorResponse`.

The transaction status update differs by method. See WF-3.

Evidence: single-list `ProcessENatisPayments` and `ProcessIForcePayments`.

Implementation: Implemented.

Confidence: High.

### FR-008 — A later failure increments the try count and mails after more than three

Retry methods add one to `IntSettlementTries`. When the new value is greater than 3, the row is included in the problem mail.

Evidence: two-list overloads and `MailService.SendEmail`.

Implementation: Implemented. The mail block's command order makes it **Inferred** that the send is not reached (WF-1 step 4).

Confidence: High for the threshold. Medium for whether the email leaves the process.

### FR-009 — The HTTP caller always receives success text

The action returns 200 `Cronjob completed` after a swallowed exception.

Evidence: `return Ok("Cronjob completed")` placed after the `try`.

Implementation: Implemented.

Confidence: High.

### FR-010 — iForce posts send a fixed terminal and a fresh-fine flag

`terminalId` is `0`. `freshFine` is `true`.

Evidence: both `ProcessIForcePayments` methods. Comment: `what goes here??`.

Implementation: Implemented. The correct terminal id: **Unknown / Requires Confirmation**.

Confidence: High for the literals.

## Integrations

| System | Direction | Trigger | Payload | Auth in this repo | Failure |
|---|---|---|---|---|---|
| MySQL `paymyfines_qa` on RDS host `paymyfines-instance-1.cehgxa1eerul.eu-west-1.rds.amazonaws.com` | Read and write | Start of `GET /CronJob` | SQL in the controller and `PaymentService` | User id and password in `appsettings.json`. Omitted here | Exception swallowed. Response still 200 |
| Fine API `http://192.168.0.41:8080/` | Outbound POST | Each loaded row | eNaTIS or iForce JSON in WF-3 | None in the client | Non-200 updates failure columns. Bad JSON throws into the empty `catch` |
| Gmail SMTP `smtp.gmail.com:587` | Outbound | Problem list non-empty on pass 1 | Plain text, subject `PMF Error Payments` | Username in source, password placeholder `xxxxxx` | Rethrow, then swallowed by the controller |

Certificate validation for the fine API accepts every certificate. The base URL is `http`, so the callback may not run. **Inferred.**

## Data touched

No migration or `CREATE TABLE` is in the repository. Names below are the identifiers the SQL uses.

| Table | Columns the SQL names | Notes |
|---|---|---|
| `infringement` | `InfringementID`, `strInfringementNumber`, `strStatus`, `refTrasactionID`, `strIssuingAuthority`, `strRecieptNumber`, `strInfringementSource`, `intSettlementTries`, `strSourceResponse` | `refTrasactionID` and `strRecieptNumber` are spelled that way in the SQL |
| `transaction` | `TransactionID`, `strStatus`, `strAmount`, `dtPaid`, `intPaid`, `dtLastEdit`, `refPaymentProviderID` | `intPaid` is compared to `'YES'` and set to `'SETTLED'` |
| `paymentProvider` | `PaymentProviderID`, `strProviderName` | Joined, not updated |
| `user` | `strRole`, plus ordinal 0 | Ordinal 0 is passed to `MailAddress` as a CC |

Status and flag literals written or selected:

| Literal | Column | When |
|---|---|---|
| `Source Failed Payment` | `infringement.strStatus` | Pass 1 filter. Also written on first failure and on iForce retry failure |
| `Source Failed Payment` | `transaction.strStatus` | First-time failure (both sources) and iForce retry failure |
| `Source Approved Payment` | `infringement.strStatus` | HTTP 200 from the source |
| `YES` | `transaction.intPaid` | Pass 2 filter. This repository does not write `YES` |
| `SETTLED` | `transaction.intPaid` | HTTP 200 from the source |

`infringement.intPaid` is never read or written here.

## Constraints and validation

No request body and no query parameters are read. Nothing validates notice numbers, amounts, or dates before the POST.

`Convert.ToInt32`, `Convert.ToDouble`, and `JsonConvert.DeserializeObject` throw on unexpected cell or body values. Those throws leave the action and become a 200 with no further rows processed.

SQL values are interpolated. There are no command parameters. **Implemented.**

`AllowedHosts` is `*`.

## Error behaviour

| Condition | Result |
|---|---|
| Exception anywhere in `Get` | Empty `catch`. HTTP 200 `Cronjob completed`. No log (logger unused; `WriteLog` lines are comments) |
| Pass 1 returns no rows | String variable set to `NO Failed Payments Found`. No log. Pass 2 still runs |
| Pass 2 returns no rows | String variable set to `NO New Payments Found`. No log |
| Upstream status is not 200 | Failure updates in WF-3. Loop continues with the next row unless deserialisation throws |
| Upstream body is not the expected JSON | Throw. Remaining rows in that pass, and pass 2 if this was pass 1, do not run |
| Problem list non-empty | Admin query block runs before pass 2. **Inferred** throw before `SendEmail` |
| SMTP failure | `throw ex` from `MailService`, then the controller `catch` |
| `intSettlementTries` null | `Convert.ToInt32` throws while loading. Not executed here. **Inferred** |

## Conflict with the pmf-backend-ah record

Silicon Ark `pmf-backend-ah/prd.md` (WF-6, evidence date 2026-10-06) describes a later settlement routine. It is a different repository. Differences that matter if someone treats them as one product:

| Topic | This repository (`cbc8461`) | `pmf-backend-ah` record |
|---|---|---|
| Host | ASP.NET Core `GET /CronJob` | Lambda `RunCronJob` and `GET /runcronjob`, schedule `rate(2 hours)` |
| Fine API base | `http://192.168.0.41:8080/` | `http://10.106.3.22:80/api/` |
| List routing | Arguments reversed at the call (FR-005) | Documented as IFORCE to iForce and every other source to eNaTIS |
| Pass 2 filter | `transaction.intPaid = 'YES'` | `intPaid = 'YES'` and `strStatus = 'success'` on the infringement |
| `transaction.intPaid = 'SETTLED'` | Written on HTTP 200 | Documented as commented out |
| iForce `freshFine` | `true` | Documented as `false` |
| iForce `paymentProvider` | `strProviderName` from the row | Documented as literal `13` |
| Failure mail To | Gmail address in `MailService` | `csenekal@office.tmtservices.co.za` |
| Admin CC query | Present, role literal `["ADMIN"]` | Documented as commented out |

Do not copy a rule from that PRD into this one without confirmation. The code in this repository is the evidence for the rows above in the left column.

## Unknown / Requires Confirmation

- The caller and the schedule.
- Whether FR-005 is a defect.
- Whether `192.168.0.41` and `paymyfines_qa` are still the right targets.
- Live spellings of `refTrasactionID` and `strRecieptNumber`.
- The `user` column at ordinal 0, and whether any row has `strRole` equal to `["ADMIN"]` including the quote characters.
- The value that should be sent as `terminalId`.
- Whether this application was superseded by `pmf-backend-ah`.
- Contents of `sonar-project.properties` and `sonarqube_scan.bat` if they exist only on the scan host.
