# Business rules — findndrive_pwa

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

No business-rules document exists in the repository. Every rule below is **Inferred** from implementation unless the row says the words appear on screen (**Documented** copy). None of these figures were confirmed against a current rate card.

## Access

| Rule | Detail | Provenance |
|---|---|---|
| Journey requires a truthy `step0_login` response | `App.jsx` | Inferred |
| A falsy login response shows no message | `Login.jsx` checks `if (data)` | Inferred |
| Login is not remembered | `loged_in` starts false and is not written to storage | Inferred |
| This client does not check the password | Any string, including empty, is posted | Inferred |

Which accounts the server accepts is not defined here. `findndrive_webportal` documents two hardcoded usernames. That rule belongs to the server.

## Catalogue presentation

The home disclaimer says the cars are limited, are not in stock, and are for calculation only. Provenance: **Documented** copy in `FndDisclaimer.jsx`.

Tier explanation copy in `FndTierInfoModal.jsx` (Documented):

| Tier | On-screen limit |
|---|---|
| A | Max. 2 years; 45,000 km |
| B | Max. 5 years; 90,000 km |
| C | Max. 7 years; 160,000 km |
| D | 8 years+; 160,001 km+ |

The cards do not use those limits as data. They use fixed samples. Provenance: **Inferred** from `CostOfOwnership1.jsx` and `CostOfOwnershipRecommended.jsx`. Condition is always "Pre-owned".

| Tier | Year written in code | Mileage written in code |
|---|---|---|
| A | 2023 | 3150 |
| B | 2020 | 56248 |
| C | 2018 | 104152 |
| D | 2016 | 168124 |

Price and image come from `tiera` / `tiera_image_path` (and B, C, D). A price of `0`, `null`, or `undefined` drops that tier.

Body-style cards assume this response order. Provenance: **Inferred**.

| Card | `data.data` indexes |
|---|---|
| Hatchback | 0, 1, 2 |
| Sedan | 3 |
| Bakkie | 6, 7 |
| SUV | 4, 5, 8 |

On the model list, tiers are tested D, then C, then B, then A, and each success overwrites the card. The visitor sees one card per model: the highest letter that still fits the instalment. On the cost screens, every fitting tier is its own card, and the first card is selected automatically.

## Money inputs

| Rule | Detail | Provenance |
|---|---|---|
| Currency prefix | `R` | Inferred, `FndInput` |
| Slider stops for the monthly maximum | 0, 5000, 7500, 10000, 12500, 15000 | Inferred, `FndSlider.jsx` |
| Typed amount | Kept as typed. The slider only snaps its own position | Inferred |
| Continue on step 1 | Needs a body style and a monthly amount | Inferred |
| Deposit default | String `"0"`, which counts as present | Inferred, `FormContext.jsx` |
| Financial-details continue | Salary, debt, living expenses, and savings must all be truthy. `0` does not pass | Inferred |
| Total expenses | Debt expenses plus living expenses, after comma stripping | Inferred, `ChancesOfApproval.jsx` |

## Finance formula

`financeCalculation` in `FinanceCalculate.js`. Provenance: **Inferred**.

Inputs: payment term title, vehicle price, deposit, balloon percent, annual interest percent.

- Term months are the first two characters of the title. The titles are `"24  months"` through `"72  months"`, so this yields 24, 36, 48, 60, or 72.
- Initiation fee is `1207.5`.
- Monthly service fee is `69`.
- Finance required = price − deposit + initiation fee.
- Balloon amount = finance required × balloon percent / 100.
- Instalment is the standard amortising payment on (finance required − the discounted balloon), plus the service fee.
- Total interest shown = instalment × months − principal + balloon amount.

Model lists and cost cards call this with term `"72"`, deposit `"0"`, balloon `"0"`, and interest `12.75` (`FormContext` default `primeInterestRate`).

The calculator uses a different interest:

| Credit statement | Rate | Risk label stored |
|---|---|---|
| I pay all my accounts in full and on time | 11.75 + 1 | Good |
| I've missed a payment, but I am now up to date | 11.75 + 3 | Average |
| I do not have any store accounts, credit cards or loans | 11.75 + 5 | Below Average |
| I have missed one or more payments | 11.75 + 5 | Below Average |
| I have defaulted on my account | 11.75 + 5 | Below Average |

The `11.75` literal is commented "Prime itrest rate" in `Calculator.jsx`. The model filter never reads it. Two primes in one journey is **Requires Confirmation**.

### Balloon ceiling

Start at 40. If a term is selected, subtract the term penalty, then an age adder. Age is `1 + (current year − sample year)`.

| Term | Penalty (`baloonPenalty`) |
|---|---|
| 24 months | 0 |
| 36 months | 1.5 |
| 48 months | 3 |
| 60 months | 5 |
| 72 months | 7.5 |

| Age | Extra subtracted after the term penalty |
|---|---|
| Under 2 | 0 |
| 2 | 1.5 |
| 3 | 3 |
| 4 | 5 |
| 5 | 7.5 |
| Over 5 | Ceiling becomes 25, and the penalties above are not applied |

The result is rounded to the nearest 10 (5 and below rounds down). The slider then offers 0, 5, 10, … up to that maximum. If the current value is not on a step, it is moved to the top step.

With the sample years, tier A (year 2023, inspected in 2026) is age 4. Tier D (2016) is over 5, so its ceiling is 25 before rounding.

## Insurance and fuel

Insurance is a percent of the instalment, by the API `car_type` string. Unlisted types use 30. Provenance: **Inferred**, `CalculateInsurance.js`.

| `car_type` | Percent of instalment |
|---|---|
| Budget Car | 30 |
| Economy Hatch | 25 |
| Premium Hatch | 15 |
| Sedan | 20 |
| Compact SUV | 20 |
| Family SUV | 12 |
| Premium SUV | 13 |
| Double Cab | 10 |
| Single Cab | 10 |
| anything else | 30 |

Fuel: litres per month = `petrol_consumption × upper km band / 100`. Cost = litres × `22.46`. Provenance: **Inferred**, `calculateFuelSpent.js`. The price is hardcoded.

Estimated monthly cost on the card = instalment + insurance + fuel.

A card is kept only when the 72-month zero-deposit instalment is less than or equal to the ceiling. On the first cost screen the ceiling is the visitor's maximum. On the recommended cost screen the ceiling is the recommended range top.

## Monthly kilometres

The bands are fixed. Math uses the upper bound: 999, 1499, 1999, 2499, 2999, or 3499. Provenance: **Inferred**.

## Maintenance and services timing

Rows come from the API object. Keys in the skip list (`id`, `car_id`, `total`, timestamps, and the other names in the two util files) are not shown. A zero value is not shown.

Maintenance interval: spark plugs 45,000 km, every other shown part 15,000 km. Months left = ceil(next multiple of that interval / monthly km).

Service interval: radiator 150,000 km; cam belt, timing chain, and fan belt 120,000 km; every other shown part 90,000 km. Months left = floor(next multiple / monthly km).

Front and rear brake pads, front and rear brake discs, and front shock absorbers have their cost doubled. The rear-shock check looks for the key `" rear_shock_absorbers"` with a leading space, so a normal `rear_shock_absorbers` key is not doubled.

Commented code that would cap maintenance at 6 months, services at 36 months, and zero the delay under 75,000 km does not run.

Provenance: **Inferred**.

## Risk bands

`riskLevelCalculation`. Amounts are numbers after commas and `R` are removed. Provenance: **Inferred**. Commit `156de99` says debt level uses debt expenses only, and the code matches that: debt ratio is `(debt expenses + instalment) / salary`, not total expenses.

| Band | High | Medium | Low (or other) |
|---|---|---|---|
| Monthly repayment / salary | 36% and above → "Very low" | 26% to below 36% → "Maybe" | below 26% → "Just right" |
| (Instalment + insurance) / salary | 50% and above → "Very low" | 36% to below 50% → "Maybe" | below 36% → "Just right" |
| Cash left / salary | 4% or below → "Very low" | above 4% through 14% → "Maybe" | above 14% → "Just right" |
| Debt ratio | 61% and above → "Too high" | 41% to below 61% → "At Risk" | 21% to below 41% → "Fair"; below 21% → "Good" |
| Vehicle age (years) | 8 and above → "Old" | 6–7 → "Older" | 3–5 → "Fair Age"; 0–2 → "Good Age" |
| Mileage | above 160,000 → "High Mileage" | above 90,000 through 160,000 → "Out of Service Plan" | above 45,000 through 90,000 → "Fair Mileage"; through 45,000 → "Good Mileage" |
| Service plan label | mileage above 60,000 → "Inactive" | — | 60,000 or below → "Active" |

Credit behaviour is taken from the statement: Good → low, Average → medium, anything else (including "Below Average") → high / "Poor".

Overall affordability level follows only the monthly-repayment band. The insurance and cashflow comparisons that would have joined it are commented out.

Overall vehicle level follows only vehicle age. The mileage comparison is commented out. "Fair" age is treated as low, because only medium and high change the level.

Overall risk is low only when affordability, credit, debt, and vehicle are all low. Debt "Fair" is treated as low for this roll-up. It is high when affordability, credit, or debt is high. The vehicle-high check reads `vehicleLevel.level`, but `vehicleLevel` is a string, so that comparison does not see the vehicle band. Otherwise the overall level is medium.

Savings versus service cost, and cashflow versus maintenance cost, are calculated (`Sufficient` / `Insufficient`, and a medium branch). The accordion does not render those two results. The medium maintenance branch is unreachable: the outer check requires cashflow at or above 15, and the inner check then requires at least 5, which is already true.

### Whether the visitor may apply

`maxAllowed` is set in every branch to 25% of salary, rounded up to the next 100. If that figure plus expenses exceeds salary, it becomes salary minus expenses, also rounded up to the next 100. An earlier headroom figure based on `0.6 − expenses/salary` is overwritten before it is used as the ceiling. It is still used as a condition: `minDeptLevel <= 0` means expenses are at least 60% of salary.

| Condition | `canApply` |
|---|---|
| Tier A or B, and expenses are at least 60% of salary | true when instalment is not above `maxAllowed` |
| Tier C or D, expenses are at least 60% of salary, and savings do not cover the service-table total | false |
| Every other case | true when instalment is not above `maxAllowed` |

The recommended range shown on the next screen runs from 0 to the smaller of `maxAllowed` (or 0 if negative) and the visitor's original monthly maximum.

## Result buttons

On the first result screen (`ResultOptions.jsx`):

| Button | Shown when |
|---|---|
| Explore other options | Risk is not low, and the recommended top is not below 1700 |
| Apply now | (risk is low or medium, or `canApply` is truthy) and `canApply` is not `false` |
| Fix my credit | (risk is high or `canApply` is `false`) and `canApply` is not `true` |

On the recommended result screen, Explore is hidden only when risk is low. The 1700 test is not applied. Apply and Fix my credit use the same conditions.

`canApply === false` hides Apply even if the headline risk is low. Provenance: **Inferred**.

## Completion posts

| Action | Posted flag | Provenance |
|---|---|---|
| Apply | `apply: 1` on `step4_congratulations` | Inferred |
| Fix my credit | `apply: 1` and `fix_my_credit: ""` on `step4_fix_my_credit` | Inferred |

The server document in `findndrive_webportal` says the fix-credit write looks for `fix_my_credit ` with a trailing space. This client does not send that key. **Requires Confirmation** whether the stored flag matches what the staff dashboard counts.

Start again opens `/select_a_car` and does not reset context, so the previous session id and amounts are still in memory.

## Copy that is not a calculated rule

- Calculator disclaimer: results are assumptions, no warranty, no liability, contact the business to verify. **Documented** in `Calculator.jsx`.
- Fix-my-credit: the assistance tool is not available yet. **Documented** in `FixMyCredit.jsx`.
- Feedback address on both completion screens: `absa-simulator@findndrive.co.za`.
- The fix-my-credit link href is `https://www.absa.zo.za`. The sentence beside it says absa.co.za. **Requires Confirmation**.
