# Business rules — findndrive_api_net

Evidence date: 2026-10-15. Source: `git@bitbucket.org:overdrive/findndrive_api_net.git`, `master` at `a2414dd`.

Rules below are taken from code. Each rule is marked **Inferred** when the code behaves that way and no README sentence states it. Where a unit test expects something else, both sides are recorded. Neither side is treated as an approved business decision.

Secret values are omitted.

## BR-1 — Pre-qualification amounts

**Inferred** from `FinancingService.getMonthlyandTotalAmounts`.

```text
total = round( (netIncome * 0.30 * 0.70 * 48.4 + deposit) / 1000 ) * 1000
monthly = (int)(total / 44.4351)
```

`round` is `Math.Round` (banker's rounding to nearest integer on the value already divided by 1000). `deposit` null is not guarded inside this method. The request DTO defaults `Deposit` to `0`. `FinancingCalc` passes `Deposit.Value`, so an explicit JSON `null` throws before the formula.

`MonthlyBudget` is not an input to the formula.

The same formula runs again during prediction, with Experian `Net_Income_Amount` and the deposit stored at pre-qualification. The new monthly and total replace the pre-qualification figures on `ApplicantFinance`.

### Conflict with the unit test

`FinancingCalc_TotalAmount` uses net income `300000` and deposit `20000` and expects total `83000` and monthly `1867`.

`83000` is what the formula produces if `* 48.4` is removed:

```text
round( (300000 * 0.30 * 0.70 + 20000) / 1000 ) * 1000 = 83000
(int)(83000 / 44.4351) = 1867
```

With `* 48.4` left in, the same inputs produce a total in the millions, not `83000`.

**Requires Confirmation:** which formula is approved. The method is **Implemented**. The test expectation is conflicting evidence in `FinancingServiceTests`.

`FinancingCalc_ShouldSucceed_WhenDepositIsNull` does not set `Deposit`. The property default is `0`, so the test does not pass a null deposit. It only asserts status `200`.

## BR-2 — Income market and spend split

**Inferred** from the band block in `FinancingService.Analytics`. Bands use Experian `Net_Income_Amount`. `financeSplit` and `insuranceSplit` start at `0`.

| Net income | `incomeMarket` stored on `ApplicantFraudAlert.RatingDescription` | Finance split | Insurance split |
|---|---|---|---|
| `7500 < x <= 9083` | Low Emerging | 0.70 | 0.30 |
| `9084 < x <= 19500` | Emerging Middle | 0.70 | 0.30 |
| `19501 < x <= 35000` | Lower Middle | 0.80 | 0.20 |
| `35001 < x <= 65250` | Upper Middle | 0.85 | 0.15 |
| `65251 < x <= 141083` | Emerging Affluent | 0.90 | 0.10 |
| `x > 141083` | Affluent | 0.90 | 0.10 |

Amounts that match no row keep an empty market name and zero splits. That includes `x <= 7500`, and the gaps `9083 < x <= 9084`, `19500 < x <= 19501`, `35000 < x <= 35001`, and `65250 < x <= 65251`.

`ApplicantFraudAlert.Rating` is always `1`.

Then:

```text
financeSpend = round(disposableIncome * financeSplit, 0)
EstimatedFinanceSpend = financeSpend
EstimatedInsuranceSpend = disposableIncome * insuranceSplit
EstimatedApprovalAmount = financeSpend * 46.7089
```

`disposableIncome` stored on `ApplicantFinance.DisposableIncome` is:

```text
round(ptiRatio * Experian Cal_Net_Income_Amount, 0)
```

`ptiRatio` comes from BR-4 applied to Experian `Bureau_Expenses` and `Cal_Net_Income_Amount`.

The chance-of-approval path (BR-3) uses a different pair: Experian `Calc_Expense` and `Net_Income_Amount`. The stored disposable income and the disposable income inside `MakePrediction` can therefore differ.

A comment asks whether `EstimatedApprovalAmount` is monthly or yearly. The code does not say. **Unknown / Requires Confirmation.**

## BR-3 — Chance of approval

**Inferred** from `FinancingService.MakePrediction`. First matching branch wins. `ChancesOfApproval` values: `Low`, `Medium`, `High`, `Undetermined`.

Score indicator and reason code are left-padded with `0` when their length is 1.

Disposable income inside this method:

```text
disposableIncome = (netIncome * PTIInt) / 100
```

`PTIInt` is from BR-4 using this method's expense and income arguments (on the live path: `Calc_Expense` and `Net_Income_Amount`).

| Order | Condition | Result | Reason | Longer text |
|---|---|---|---|---|
| 1 | Net income `< 7500` | Low | Affordability | empty |
| 2a | Net income `> 7500` and credit score `<= 613`, score indicator in `01`, `02`, `03`, and reason in `07`, `09`, `19` | Low | Credit History | TransUnion is unable to provide you with a credit score because your trade information is insufficient. |
| 2b | Same score ceiling, score indicator in `02`, `03`, and reason in `01`–`06`, `08`, `10`–`12`, `21`–`27` | Low | Credit Behaviour | You have a high amount outstanding on your accounts. A high outstanding balance may signal to potential lenders that you are spending more than you can afford. |
| 2c | Same score ceiling, any other indicator or reason | Low | Low Credit Score | empty |
| 3 | Net income `> 7500`, credit score `>= 613`, DTI integer `>= 65` | Low | High Debt | empty |
| 4 | Net income `> 7500`, credit score `>= 613`, DTI integer `< 90`, disposable income `< 2250` | Low | Low Affordability | empty |
| 5 | Net income `> 7500`, disposable `> 2250`, DTI integer `> 40` and `<= 65`, credit score `> 613` and `<= 689` | Medium | empty | empty |
| 6 | Same as 5 except credit score `>= 689` and `<= 999` | Medium | empty | empty |
| 7 | Net income `> 7500`, disposable `> 2250`, DTI integer `<= 40`, credit score `>= 689` and `<= 999` | High | empty | empty |
| 8 | anything else | Undetermined | Could not make prediction (Could not match case) | empty |

Branches 5 and 6 both return Medium. The score split at 689 does not change the label.

`netIncome == 7500` matches neither `< 7500` nor `> 7500`, so it falls through to Undetermined unless a later branch's net-income check passes, which it does not.

`disposableIncome == 2250` matches neither `< 2250` nor `> 2250`.

A credit score from 614 through 688 with DTI integer `<= 40` and disposable income `> 2250` is not Medium (Medium in that score band requires DTI `> 40`) and not High (High requires score `>= 689`). It becomes Undetermined.

A local `creditScoreBool` is set false when the score is under 614 or null, and is never read. A local `disposableIncomeBool` (`>= 2250`) is never read.

`AdditionalIncome` on `PredictionRequest` is never read.

### Conflict with unit tests

`FinancingServiceTests` scenarios, using expenses `15000` except scenario 3:

| Test | Net | Score | Other | Test expects | Method returns for those inputs |
|---|---|---|---|---|---|
| Scenario 1 | 30000 | 681 | | High, empty reason | Medium. DTI is 50, disposable is 4500, score is inside branch 5 |
| Scenario 2 | 30000 | 614 | | Medium | Medium. Agrees |
| Scenario 3 | 6500 | 614 | | Low, Affordability | Low, Affordability. Agrees |
| Scenario 4 | 30000 | 614 | same figures as scenario 2 | Low, reason `High debt` | Medium. Also the code reason is `High Debt`, not `High debt` |
| Scenario 5 | 30000 | 0 | indicator `01`, reason `07` | Low, Credit History, long insufficient-trade text | Agrees |
| Scenario 6 | 30000 | 0 | indicator `02`, reason `01` | Low, Credit Behaviour, long outstanding-balance text | Agrees |

**Requires Confirmation:** scenarios 1 and 4. The method is what runs in the API. The tests were not treated as a second source of truth.

## BR-4 — Debt-to-income and payment-to-income

**Inferred** from `GetPredicationVariables`. A comment above the method says:

- if DTI is greater than 64, PTI = 100 − DTI
- if 60 < DTI < 90, Find and Drive does not recommend financing
- if DTI > 90, chances of approval will be low

The code does not implement that comment. Implemented steps:

```text
DTI = (totalExpenses / totalIncome) * 100, or 0 when that is null
DTIInt = round(DTI, 0)
if DTIInt >= 90:        PTI = 100 - DTI          (unrounded DTI)
else if 65 < DTIInt < 90: PTI = 80 - DTIInt
else:                   PTI = 65 - DTI           (unrounded DTI)
if PTI > 30:            PTI = 30
PTIInt = round(PTI, 0)
if PTIInt < 0:          PTIInt = 0
ptiRatio = PTIInt / 100
```

Division by zero when `totalIncome` is 0 is not guarded.

**Requires Confirmation:** the comment and the code disagree. The "does not recommend financing" sentence is not a separate return value. Low chance for high DTI is BR-3 branch 3 (`DTIInt >= 65`), which is wider than the comment's "DTI > 90".

## BR-5 — Income disparity

**Inferred** from `Analytics`.

```text
disparity = ((preQualificationNetIncome - Experian Cal_Net_Income_Amount) / Experian Cal_Net_Income_Amount) * 100
description = disparity > 12 ? "Disparity very high" : "No irregularities"
```

`preQualificationNetIncome` is `applicantData.ApplicantFinance.NetIncome`, which pre-qualification set from the caller's posted net. Prediction later overwrites `ApplicantFinance.NetIncome` with Experian `Net_Income_Amount` (not `Cal_Net_Income_Amount`).

The read API returns `Disparity` as `IncomeDisparityPercentage` and does not return the description.

Zero `Cal_Net_Income_Amount` divides by zero.

## BR-6 — Mobile number

**Inferred**, with a property comment that states the South African `+27` requirement.

Accepted shape: `+27`, then a first digit `6`, `7`, or `8`, then exactly eight digits. Example shape `+27821234567`. The regex is case-insensitive and anchored.

Anything else on pre-qualification is HTTP 400 `Invalid South African mobile number`.

Prediction does not re-check the mobile. It sends the stored number to TransUnion as `clientNumber`.

## BR-7 — South African ID number

**Inferred.** Property comment: "Must be a valid South African Id number."

The service regex is the 13-digit form used in the method (date portion `YYMMDD` with a month/day alternation, including a leap-day alternative, then a four-digit sequence, a digit, and a three-digit sequence, with optional spaces or hyphens). It is applied with `Regex.Match`, not `IsMatch` on the whole string. `Regex.Match` succeeds when the pattern matches a substring. The pattern itself contains groups that can cover a full 13-digit number. Whether a longer string with an embedded match is accepted was not executed. **Requires Confirmation** if a full-string anchor was intended. The pattern variable is not wrapped in `^` and `$`.

Failure is HTTP 400 `Invalid Id number`, after the duplicate-prediction check.

## BR-8 — Income bounds on prediction

**Inferred.**

- `NetIncome > 999999999` → 400 `NetIncome must be less than 10 digits`.
- `NetIncome < 0` → 400 `NetIncome must be a positive number`.
- `GrossIncome < 0` → 400 `GrossIncome must be a positive number`.
- Zero is allowed for both (the checks are `< 0`, not `<= 0`).
- `LivingExpenses` has no sign check.
- Pre-qualification `NetIncome` is a `decimal` with `[Required]` and no range check.

## BR-9 — One prediction row

**Inferred.**

Any existing `ApplicantPredicition` for the applicant id stops the call with HTTP 409 `Prediction has already been calculated`, before id-number and income checks, and before the client-ownership load.

There is no recalculate route.

## BR-10 — Applicant belongs to the token client

**Inferred.**

Create stamps `ClientId` from the JWT. Read and prediction load `ApplicantId` plus that `ClientId`. Another client's id does not load. The failure is a null reference and HTTP 500, not 403 or 404.

Prediction's duplicate check (BR-9) does not apply the client filter.

## BR-11 — API key and token

**Inferred** except where noted.

- A key is 32 cryptographic random bytes, base64, with `/`, `+`, and `=` stripped. Only the uppercase hex SHA-256 is stored.
- Login rejects a hash mismatch with HTTP 401 and the text `Api key not found or invalid`.
- Login ignores revoked keys by filtering them out. A revoked or unknown id then hits a null reference and HTTP 500, not 401. Refresh of a revoked key is HTTP 401.
- Token lifetime is 60 minutes from UTC (`SecuritySettings.LifetimeInMinutes` in both appsettings files).
- Permissions on the token are the role's list at login. Refresh keeps the old claims.
- `SystemAdmin` may manage clients, keys, and roles, and may not call financing routes.
- `Financing` may call financing routes, and may not manage clients, keys, or roles.
- The bootstrap client name is exactly `Admin`. A second client with that name is not created. The first secret is written to the console. README says the `.bak` already contains admin credentials. If a client named `Admin` is already in the database, startup does not print a new secret. How the backup's credentials relate to this hash is **Unknown** (the backup was not opened).

## BR-12 — Clients

**Inferred.**

- New clients are `Active` true.
- List returns active clients only.
- Get-by-id does not filter `Active`.
- Rename does not change `Active`. No API sets `Active` false.
- Client name is not checked for uniqueness, except the startup rule for the literal name `Admin`.

## BR-13 — Timestamps

**Inferred.**

`DateTimeProvider.GetSouthAfricanDateTime` is UTC plus 2 hours. It is used for client, key, role, and audit timestamps. Token expiry uses UTC.

There is no time-zone database. A South African daylight-saving change would not be followed. South Africa does not currently observe daylight saving; that fact is outside the repository. The code is a fixed offset.

## BR-14 — Bureau request shape

**Inferred.**

Experian body always sets origin `Chrome`, request version `1.0`, origin version `1.0`, output `json`, plus the configured username and password.

Prediction TransUnion SOAP sets `doFraud` false and `doCredit` true. The hidden TransUnion route sets both true.

Credit score `-` from TransUnion is treated as no score (HTTP 400) before prediction runs.

TransUnion consumer `DateOfBirth` and address `InformationDate` are parsed with format `yyyymmdd`. In .NET custom formats, `mm` is minutes and `MM` is months. A bureau date in `yyyyMMdd` will not match. **Requires Confirmation** of the bureau's real format. A failed parse is HTTP 500.

`SpouseName` null from TransUnion is stored as an empty string.

## BR-15 — What the read model hides

**Inferred.**

The get-by-id payload includes identity, address, deposit, incomes, bureau expenses, disposable income, credit reason text, disparity percentage, chance of approval, estimated amounts, and the two improvement strings.

It omits the audit log even though the method builds one, and it omits credit score, risk band, monthly amount, total amount, and the disparity description.

## BR-16 — Field lengths

**Inferred** from `ApplicantConfiguration`.

| Field | Max length |
|---|---|
| Title | 5 |
| First name, last name, spouse, address lines, suburb, city | 50 |
| Id number, mobile | 20 |
| Email | 255 |
| Postal code | 12 |
| Province | 13 |
| Gender, marital status | 10 |
| Residential status | 50 |

`ChancesOfApproval` is stored as a string with max length 50 (`ApplicantPredicitionConfiguration`). `PredictorConfidence` is stored as a string with max length 15.

`PredictorConfidence` enum names: `LOW`, `MEDIUM`, `HIGH`, `NOTAPPLICABLE` with JSON value `NOT APPLICABLE`. That value is 14 characters and fits in 15. It is mapped with a string enum converter. The finance column uses EF `HasConversion<string>()`. Whether incoming Experian text `NOT APPLICABLE` deserializes onto the entity was not executed. **Unknown** if a space-containing value fails the enum converter.

## BR-17 — Error text shown to callers

**Inferred.**

Known domain failures use the messages in the PRD error table. All other exceptions, including parse failures and null applicants, return `Something went wrong` and HTTP 500. The server log receives the exception and the request path. The response does not include the exception text, except `GatewayErrorException` from TransUnion parse, which puts `ex.Message` into the 504 body.
