# PRD — findndrive_api_net

Evidence date: 2026-10-15. Source: `git@bitbucket.org:overdrive/findndrive_api_net.git`, `master` at `a2414dd` (2023-12-12, "README.md edited online with Bitbucket").

There is no prior PRD in the repository or in Silicon Ark. Requirements below were recovered from `README.md`, controllers, `FinancingService`, `AuthenticationService`, seed data, gateways, and tests. Provenance is marked on each item. Secret values are omitted.

`README.md` is a short product note, not a full requirements document. It states three financing endpoints, TransUnion and Experian integration, .NET 6, a SQL Server backup, and that a deployed `appsettings.json` needs the database connection string.

## Product purpose

Find and Drive API estimates how much vehicle financing an individual qualifies for, then refines that estimate with Experian affordability data and a TransUnion credit score. The result is stored against an applicant that belongs to the API client that created it.

Provenance: **Documented** (README: predict financing amount; Experian and TransUnion). **Implemented** (`FinancingController`, `FinancingService`).

The API does not present a page, take a card payment, or book a vehicle. A catalogue of cars is not in this repository.

## Users and roles

| Actor | What they do | Evidence | Provenance |
|---|---|---|---|
| API client | An organisation row (`Client`) that owns applicants and API keys | `Client` entity | Implemented |
| API key holder | Exchanges key id and secret for a JWT, then calls routes allowed by the key's role | `AuthenticationController` | Implemented |
| SystemAdmin | Seeded role. Permissions: `Client`, `ApiKey`, `Role`. Does **not** include `Financing` | `SeedData` | Implemented |
| Financing | Seeded role. Permission: `Financing` only | `SeedData` | Implemented |
| Bootstrap Admin client | Created at startup when no client is named `Admin`. Receives one `SystemAdmin` key. The secret is printed to stdout and only the SHA-256 hash is stored | `Startup.CreateInitialClient` | Implemented |
| Anonymous caller | May call `POST /api/Authentication/token` only | `[AllowAnonymous]` on that action | Implemented |

No human login, password reset, or end-user account exists. The person being financed is an `Applicant` record, not a user who signs in.

**Requires Confirmation:** whether `SystemAdmin` lacking `Financing`, and `Financing` lacking admin permissions, is the intended split. The performance harness treats one key as admin and another as financing, which matches this split.

## Systems the product talks to

1. **SQL Server**, through EF Core. Connection name `DefaultConnection`. Both `appsettings.json` and `appsettings.Development.json` leave it empty. README says the deployed file must be given a connection string. The backup `database/FindAndDrive.bak` is described as an initial database with admin credentials already set. Provenance: **Documented** and **Implemented**. Which server a deployment uses is **Unknown**.
2. **Experian / Compuscan affordability.** `POST https://webservices-uat.compuscan.co.za:9443/Afford/DoAffordability` with JSON. Username and password are copied from `ExperianSettings` onto the body. Provenance: **Implemented**. The URL is hardcoded. `ExperianSettings.BaseUrl` in `appsettings.json` is `https://localhost:4000/` and is assigned as `HttpClient.BaseAddress`, then ignored because the POST uses an absolute URL.
3. **TransUnion quick credit.** SOAP 1.2 POST to `https://autotest.transunion.co.za/TUQuickCreditAndFraudv2/TransUnionQuickCrtAndFrd.asmx`. Username, password, and API key come from `TransUnionSettings` and are embedded in the SOAP XML. Provenance: **Implemented**. `TransUnionSettings.BaseUrl` is also `https://localhost:4000/` and is unused for the same reason.
4. **SonarQube**, from Bitbucket Pipelines on `master` only. Not a runtime dependency of the API.

No SMTP, queue, object storage, or monitoring SDK is referenced.

## Major workflows

### WF-1 — Obtain a bearer token

Trigger: `POST /api/Authentication/token`. Auth: none.

Inputs: `ApiKeyId`, `ApiSecret` (`TokenRequest`). Neither field has a `[Required]` attribute.

Behaviour:

1. Load an API key with that id whose `Revoked` flag is false.
2. Compare the stored SHA-256 hex hash with the hash of the posted secret.
3. On mismatch, HTTP 401 and body equal to the string `Api key not found or invalid` (the service message, not the `ServiceResponse` object).
4. On match, read the key's role and sign a JWT.

Outputs: `Token` and `TokenExpires`. Expiry is UTC now plus `SecuritySettings.LifetimeInMinutes`. Both appsettings files set that to **60**. Claims are `ClientId`, `apiKeyId`, and one `Permissions` claim per role permission. Issuer and audience are not written onto the token. `SecuritySettings.Issuer` and `Audience` are empty strings. Validation of issuer and audience is off.

Provenance: **Implemented**.

Edge: if the key id is missing or revoked, `GetApiKey` returns null and the next property access throws. `ExceptionMiddleware` then returns HTTP 500 and `{ message: "Something went wrong" }`. That is not the 401 used for a bad secret. Provenance: **Implemented**.

### WF-2 — Refresh a token

Trigger: `POST /api/Authentication/refresh`. Auth: any valid JWT (`[Authorize]` with no permission policy).

Behaviour: read the bearer token, take `apiKeyId`, reject when that key is missing or revoked (HTTP 401, message `Refresh failed key has been revoked or does not exist`). Otherwise re-sign the existing claims with a new 60-minute expiry. The role is not reloaded, so a permission change on the role is not picked up until a new token from WF-1.

Provenance: **Implemented**. The "role not reloaded" step is **Inferred** from `GenerateToken(decodedToken.Claims)`.

### WF-3 — Pre-qualification

Trigger: `POST /api/Financing/Pre-Qualification`. Auth: JWT whose `Permissions` claims include `Financing`.

Inputs (`PreVettingRequest`):

| Field | Rule |
|---|---|
| `FirstName`, `LastName`, `MobileNumber`, `NetIncome` | `[Required]` |
| `MobileNumber` | Documented on the property as a South African number beginning with `+27`. Enforced in the service by regex `^(\+27)[6-8][0-9]{8}$` |
| `Deposit` | Optional. Default `0` when omitted |
| `MonthlyBudget` | Optional. Not read by `FinancingCalc` |

Behaviour:

1. Reject a mobile number that fails the regex with HTTP 400 and message `Invalid South African mobile number`.
2. Compute monthly and total amounts from net income and deposit. Formula is in `business-rules.md` (BR-1).
3. Insert `Applicant` (name, mobile, client id from the JWT) and `ApplicantFinance` (monthly, total, net income, deposit).
4. Return HTTP 200 and `PreVettingResponse`: `Message` `OK`, `StatusCode` 200, `MonthlyAmount`, `TotalAmount`, `ApplicantId`.

Provenance: **Implemented**. Controller remarks call this the initial write and "a rough estimate".

The client id is the `ClientId` claim. A token without that claim throws before the service runs (HTTP 500 via the middleware, or the developer exception page when `ASPNETCORE_ENVIRONMENT` is Development).

### WF-4 — Prediction

Trigger: `POST /api/Financing/Prediction`. Auth: `Financing` permission.

Inputs (`AnalyticsRequest`): `ApplicantId`, `IDNumber`, `GrossIncome`, `NetIncome`, `LivingExpenses` are `[Required]`. `EmailAddress` is optional. Income and expenses are `int`. The property comment says `IDNumber` must be a valid South African id.

Behaviour, in order:

1. If any `ApplicantPredicition` row exists for that applicant id, HTTP 409 and message `Prediction has already been calculated`.
2. If `IDNumber` fails the South African id regex in `FinancingService.Analytics`, HTTP 400 `Invalid Id number`.
3. If `NetIncome` is greater than 999999999, HTTP 400 `NetIncome must be less than 10 digits`.
4. If `NetIncome` or `GrossIncome` is negative, HTTP 400 with the matching message. `LivingExpenses` is not range-checked. `GrossIncome` has no upper bound.
5. Load the applicant where `ApplicantId` and `ClientId` (from the JWT) both match, including finance and the prediction-side tables.
6. POST Experian affordability using the stored first and last name, the posted id number, gross, net, and living expenses. Fixed body fields: `pRequestVersion` `1.0`, `pOriginVersion` `1.0`, `pOrigin` `Chrome`, `pOutputFormat` `json`.
7. Recompute monthly and total amounts with BR-1 using Experian's `Net_Income_Amount` and the **original** deposit.
8. POST TransUnion `doQuickCreditAndFraud` with `doFraud` false and `doCredit` true. Contact name comes from the stored applicant. `clientNumber` is the stored mobile. `clientEmail` is the posted email.
9. Run `MakePrediction` (BR-3) on Experian `Calc_Expense` and `Net_Income_Amount` plus TransUnion `Credit_Score`, `ScoreInd`, and `Reason1`.
10. Update the applicant with id number, email, and TransUnion consumer and address fields. Update finance with Experian figures. Insert credit, fraud-alert, audit-log, and prediction rows.
11. Return HTTP 200 and `AnalyticsResponse`: `StatusCode` 200, `Message` `OK`, `ApplicantId`. The chance of approval is **not** in this response. The caller reads it with WF-5.

Provenance: **Implemented**.

Failure from the bureaus:

| Condition | HTTP | Message |
|---|---|---|
| Experian HTTP not success | 504 | Reason phrase, or a fallback that includes the status code |
| Experian `Transaction_Completed` false | 400 | Includes `Err_Code` and `Err_String` |
| Experian `Ret_Data` not parseable | 400 | `Response from Experian couldnt be parsed` |
| TransUnion HTTP not success | 504 | Reason phrase or fallback |
| TransUnion body not parseable | 504 | `Failed to parse Transunion data, with exception: ` plus the exception message |
| Credit score is `-`, or address, consumer, or audit log is missing | 400 | Specific "no data" messages in `TransUnionGateway.DoCreditInfo` |
| TransUnion date fields not parseable with format `yyyymmdd` | 500 | `Something went wrong`. The format string uses `mm` (minutes), not `MM` (months). Provenance: **Implemented**. Whether bureau dates survive this parse is **Unknown** |
| Applicant not found for this client | 500 | Null reference becomes `Something went wrong`. A TODO comment names this case. No 404 |

`Int32.Parse` on the credit score throws the same generic 500 when the score is not an integer.

### WF-5 — Read an applicant

Trigger: `GET /api/Financing/GetApplicantById?id={guid}`. Auth: `Financing`.

Behaviour: load the applicant for the JWT client id. Return HTTP 200 and `GetApplicantByIdResponse` with `StatusCode` 200, `Message` `OK`, and `Response` holding applicant identity and address, a reduced finance block, credit reason text, prediction amounts and chance, and the income-disparity percentage.

Not returned, although stored or computed in the method:

- Audit log (`ApplicantAuditLogResponse` is built and never assigned).
- Credit score, score indicator, risk band.
- Monthly and total financing amounts.
- Income-market name, fraud rating, disparity description.
- Predictor confidence.

Provenance: **Implemented**.

A missing applicant, or one owned by another client, throws and becomes HTTP 500. There is no 404.

### WF-6 — Direct bureau calls

`POST /api/Financing/Experian` and `POST /api/Financing/Transunion`. Auth: `Financing`. Both are marked `IgnoreApi = true`, so they are absent from Swagger and still routed.

Experian independent call returns the raw gateway body. TransUnion independent call sets `doFraud` true and `doCredit` true (the prediction path sets fraud to false) and returns the raw body.

Provenance: **Implemented**. Why they are hidden is **Unknown**.

### WF-7 — Clients, keys, and roles

These controllers are also `IgnoreApi = true`. They still require a JWT.

| Route | Permission | Behaviour |
|---|---|---|
| `POST /api/Client` | `Client` | Insert an active client with the posted `ClientName` |
| `GET /api/Client` | `Client` | List clients where `Active` is true. Name, ids, timestamps. No keys |
| `GET /api/Client/{clientId}` | `Client` | One client, including inactive. No active filter |
| `PATCH /api/Client` | `Client` | Change `ClientName` only. Does not change `Active` |
| `POST /api/ApiKey` | `ApiKey` | Create a key for `ClientId` and `RoleId`. Response includes the plaintext `KeySecret` once |
| `GET /api/ApiKey/{clientId}` | `ApiKey` | List keys for that client. Hash and secret are not included |
| `POST /api/ApiKey/revoke` | `ApiKey` | Set `Revoked` true |
| `POST /api/ApiKey/reinstate` | `ApiKey` | Set `Revoked` false |
| `GET /api/Role` | `Role` | List roles |

Provenance: **Implemented**.

No route deletes a client, deactivates a client, or checks that the client id and role id exist before creating a key. A missing row on update or revoke throws and becomes HTTP 500.

`CreateClient` does not create a key. The performance scenario creates a client, then a `Financing` key for it.

## Functional requirements

### FR-001 — Financing estimate

The system shall accept a named person, a `+27` mobile in the pattern above, a net income, and an optional deposit, and shall return a monthly amount, a total amount, and a new applicant id owned by the token's client.

Evidence: `FinancingController.Financing`, `FinancingService.FinancingCalc`. Implementation: **Implemented**. Confidence: **High**.

### FR-002 — One prediction per applicant

The system shall refuse a second prediction for an applicant id that already has a prediction row, with HTTP 409.

Evidence: `PredictionAlreadyExistsException`, `ExceptionMiddleware`. Implementation: **Implemented**. Confidence: **High**.

The check is global on applicant id, not limited to the caller's client. A prediction row blocks every caller. Provenance: **Implemented**. Whether that is intended is **Requires Confirmation**.

### FR-003 — Bureau-backed prediction

The system shall call Experian and TransUnion, store consumer, address, finance, credit, fraud-alert, audit, and prediction data, and return the applicant id.

Evidence: `FinancingService.Analytics`. Implementation: **Implemented**. Confidence: **High** for the call sequence. The numeric rules are **Requires Confirmation** where tests disagree (see FR-004).

### FR-004 — Chance of approval

The system shall set `ChancesOfApproval` from net income, a debt-to-income figure, a disposable-income figure, and the TransUnion score, using the order in `business-rules.md` BR-3.

Evidence: `MakePrediction`. Implementation: **Implemented**. Confidence: **Medium**. Unit tests `MakePrediciton_Scenario1` and `MakePrediciton_Scenario4` expect different outcomes for inputs the method does not produce. See `business-rules.md`.

### FR-005 — Client-scoped read

The system shall return stored applicant data only when the applicant's `ClientId` equals the token `ClientId`.

Evidence: `ApplicantRepository.Get`. Implementation: **Implemented**. Confidence: **High** for the filter. The HTTP status on a miss is 500, not 404. Confidence: **High**.

### FR-006 — API-key authentication

The system shall issue a JWT only for a non-revoked key whose secret hash matches, and shall authorise each controller by a permission claim.

Evidence: `AuthenticationService`, `Startup` authorisation policies. Implementation: **Implemented**. Confidence: **High**.

### FR-007 — Admin maintenance

A token with `Client`, `ApiKey`, and `Role` permissions shall be able to create clients, mint and revoke keys, and list roles. The seeded `SystemAdmin` role has those three permissions.

Evidence: controllers and `SeedData`. Implementation: **Implemented**. Confidence: **High**.

### FR-008 — Schema on startup

The process shall apply EF migrations and upsert the two seed roles on every start, and shall create the `Admin` client once.

Evidence: `Startup.Configure`. Implementation: **Implemented**. Confidence: **High**.

## Important constraints

- .NET 6 class libraries. Pipeline SDK image is 7.0.
- JWT signing key is required at startup (`Encoding.UTF8.GetBytes` on `SecuritySettings.Key`). A key is present in appsettings. Value omitted.
- HTTPS redirection is enabled. JWT `RequireHttpsMetadata` is false.
- Swagger UI is enabled in every environment, not only Development. Document title `FindAndDrive Api`.
- South African clock for created timestamps is `DateTime.UtcNow.AddHours(2)` with no daylight-saving table.
- Applicant string lengths are capped in `ApplicantConfiguration` (for example first name 50, id number 20, province 13, title 5).
- `Province` max length 13 cannot store the enum label `KWA-ZULUNATAL` (14 characters) or `KWA-ZULU NATAL` (15). The entity property is a string, not the `Province` enum. Provenance: **Implemented**.

## Error contract

`ExceptionMiddleware` returns JSON `{ Message, StatusCode }` for:

| Exception | HTTP |
|---|---|
| `BadRequestException` | 400 |
| `PredictionAlreadyExistsException` | 409 |
| `GatewayErrorException` | 504 |
| anything else | 500, message `Something went wrong` |

Model-validation failures from `[ApiController]` are ASP.NET Core's default 400 and are not passed through this middleware.

In Development, `UseDeveloperExceptionPage` is registered before the middleware, so unhandled exceptions in Development are more likely to be the HTML developer page than the JSON body. Provenance: **Inferred** from middleware order.

## Non-goals visible by absence

No endpoint prices a specific vehicle, stores a chosen car, or talks to the web portal's session tables. No endpoint sends email or SMS. `MonthlyBudget` and `PredictionRequest.AdditionalIncome` are unused by the calculation methods.
