# PRD — lambda (IM Capital backend)

Evidence date: 2026-09-28.

Source: `git@bitbucket.org:overdrive/lambda.git`, `master` at `dfb4dca` (2024-02-02).

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from the implementation. Provenance is labelled. Credential values are omitted.

## Product purpose

**Inferred.** IM Capital is a backend for I&M Bank retail customers who hold, fund, or withdraw a Sanlam East Africa investment account. This repository does not contain a user interface. It contains HTTP Lambdas and one scheduled Lambda.

**Documented** names: service `IM-CAPITAL-BACKEND-SERVICE` (`serverless.yml`, `template.yaml`), Maven name "IM-CAPITAL backend services." (`pom.xml`).

**Unknown / Requires Confirmation:** the commercial product name shown to customers, the client application, and which of the three code trees is the live API.

## Users and roles

| Actor | Evidence | Status |
|---|---|---|
| Calling application | Not in this repo. Template IAM role trusts `amplify.amazonaws.com` | **Unknown** whether that is the client |
| Retail bank customer | Request fields `user_name`, `email`, `cif`, `member_no`, `kraPin` | **Inferred** from payloads. No login screen exists here |
| Local `user` row | Table `user`, linked to `sanlam_link` | **Implemented** |
| Whitelisted CIF | Table `white_listed` | **Implemented** lookup only. Who maintains the list is **Unknown** |
| Staff / admin role | No role check in handlers | **Not implemented** in this repository |

`created_by` and `updated_by` on a new transaction are the literal `1`. Provenance: **Implemented** in `PostCreateTransaction.java`. What user id `1` is is **Unknown**.

## Systems the product talks to

See technical documentation for hosts and parameter names. At product level the integrations are:

1. I&M identity and banking (Keycloak password grant, service agreements, product summary, OTP, preferred 2FA, customer details, within-bank payment).
2. Sanlam East Africa (token, customer create, purchase, withdrawal, charges, balance, member details, transaction list, yield).
3. MySQL database `IMCapital`.
4. AWS Secrets Manager, SSM Parameter Store, S3.

## Major workflows

### WF-1 — I&M token and account context

**Node (`GET /getToken`)** and **Kotlin (`GET /getTokenJava`)** do four steps with a hardcoded password grant, then return the product-summary body:

1. `POST` token on `test-upg-dxp.imbank.com` realm `inm-retail`, client `erc-client`, scope `profile email`.
2. `GET` service agreements (`from=0&size=30`) and take `result[0].id`.
3. `POST` that id as `serviceAgreementId`.
4. `GET` product summary.

**SAM `GET /gettoken`** only performs step 1 and returns that token response. Provenance: **Implemented** in `getToken/GetToken.java`.

**SAM `POST /serviceagreement`** (also GET and OPTIONS) reads body fields `access_token`, `session_state`, `ip`, and `cif`, loads the service agreement, posts the agreement context, writes a `Login` / `info` log, then loads the product summary. Provenance: **Implemented** in `ServiceAgreement.java`.

**SAM `GET /tokencheck`** reads query `access_token`. If it is missing, the body is `Access token is missing`. The three follow-on I&M calls are commented out, so a present token returns an empty body. Provenance: **Implemented**. **Discrepancy:** Node `GET /getTokenCheck` still runs those three calls.

### WF-2 — Register or update a local user

`POST /createuser` (GET and OPTIONS are also routed to the same handler) reads `user_name`, `email`, `name`, `given_name`, `family_name`, and `nonce`. If `cif` is present, `nonce` is replaced by `cif`. Optional `new_member`.

- If `user.user_name` is not found, insert `user` and insert `sanlam_link` with parent number `09576`.
- If the user exists, update `user_name`, `email`, `name`, `given_name`, `family_name`, and `im_customer_identifier`.
- Response body is `accepted_terms_timestamp` for that `user_name` (may be null).

Provenance: **Implemented** in `CreateUser.java`. The parent number `09576` is a hardcoded string, not a request field.

### WF-3 — Sanlam purchase

`POST /sanlampurchaseflow` body: `auth_token`, `userName`, `currencyCode`, `amount`, `parent_no`, `trans_id`, `kraPin`, `middle_name`, optional `cif`, optional `member_no`.

1. Load `user` joined to `sanlam_link` by `user_name`.
2. If the JSON user object has no `member_no`, allocate a new member number from `sanlam_member`, call Sanlam create-customer, and insert `sanlam_link`.
3. Call Sanlam create-purchase for `member_no` and `amount`.
4. Set `transaction.process` to `complete` for `trans_id`.

An empty `auth_token` returns an empty body and does not purchase. Create-customer HTTP status other than 201 returns `Not Added`, and the handler returns `Error creating Sanlam user` without purchasing. Provenance: **Implemented**.

The local transaction row is expected to already exist (`trans_id`). Creating it is WF-5.

### WF-4 — Sanlam withdrawal

`POST /sanlamwithdrawalflow` body: `auth_token`, `userName`, `currencyCode`, `amount` (integer), `parent_no`, `trans_id` (integer), `member_no`.

The handler calls Sanlam create-withdrawal with `auth_token`, `member_no`, and `amount`, then sets `transaction.process` to `complete`. `userName`, `currencyCode`, and `parent_no` are read and not used. Provenance: **Implemented**.

### WF-5 — Local transaction ledger

`POST /transaction` inserts `transaction` (`account`, `member_number` optional, `value`, `type`, `process`, `im_customer_identifier`). `status` and `process` are both stored from the request `process` field. Response is JSON `{"id": <generated id>}`.

`POST /updatetransaction` sets `process` from the body for `trans_id`.

`POST /gettransaction` returns rows where `im_customer_identifier` matches the body. The path is `/gettransaction` (singular) while the class is `GetTransactions`.

Provenance: **Implemented**.

### WF-6 — I&M payment

`POST /initiatepayment` body: `auth_token`, `MFACode`, `userID`, `userName`, `accountNumber`, `amount`, `customerID`, `memberNumber`, `currencyCode`.

The handler builds a within-bank transfer: originator scheme `ID`, counterparty scheme `BBAN`, priority `NORM`, mode `SINGLE`, type `WITHIN_BANK_TRANSFER`, charge bearer `OUR`, customer type `RETAIL`, transfer type `within-retail`, purpose code `C-117`, bank branch `57`, bank name `I & M Bank Ltd`, BIC `IMBLKEN0XXX`, `approved: true`. Payment reference is `customerID + "_" + memberNumber`. The JSON is RSA-encrypted with the SSM public key and sent to the SSM URL `IM_Initiate_payment`, with the MFA code. Provenance: **Implemented** in `IMInitatePayment.java`.

### WF-7 — Settings

`GET /getsettings?setting=<name>` reads `settings.value` where `setting_name` equals the query value and returns a JSON array. Provenance: **Implemented**. Added in commit `dfb4dca`.

### WF-8 — Log export

`WriteS3Logs` runs every hour. It copies `logs` rows newer than `logs_exported_time` into `s3://imcapital-dev-app-logs/logs/<yyyyMMddHHmm>.log`. If no export timestamp exists, the lower bound is one year ago. Provenance: **Implemented**. No HTTP route.

## Functional requirements

### FR-001 — Deploy an HTTP API in eu-west-1

The SAM template defines API Gateway `IM-CAPITAL-BACKEND-SERVICE` with stage `dev` or `prod` (default `dev`), CORS methods GET, POST, OPTIONS, allow-headers `*`, allow-origin `*`.

Provenance: **Implemented** (`template.yaml`, `samconfig.toml`).

Implementation: Implemented.

Confidence: High for the template. Whether the deployed API matches the template is **Unknown / Requires Confirmation**.

### FR-002 — Return CORS headers on handler responses

Handlers that were read set `Access-Control-Allow-Origin: *` and `Access-Control-Allow-Credentials: true`, plus `Content-Type: application/json`.

Provenance: **Implemented**.

Note: credentialed CORS with origin `*` is what the code sends. Whether browsers accept that combination is outside this repository.

### FR-003 — Successful handler responses use HTTP 200

The Java handlers that were read return `GatewayResponse` with status 200, including when the body is an error string. Uncaught exceptions become a Lambda failure.

Provenance: **Implemented**. The Node `getToken` and `getTokenCheck` also return status 200 even after a failed step. `sanlam_statement_data.js` keeps a `statusCode` variable that stays `"200"` unless a later assignment is added; the current file does not change it.

### FR-004 — Issue an I&M access token

The system requests a password-grant token from realm `inm-retail` on the test I&M host.

Provenance: **Implemented**. The username, password, and client secret are hardcoded in Node `handler.js` and in `functions/PostAccessToken.java`. Values omitted.

**Discrepancy:** OTP token code uses host `dev-upg-dxp.imbank.com` (`GetOTPAccessToken.java`). Product-summary calls in Node send authority `uat-upg-dxp.imbank.com` while the URL is the test host. Which host is correct for which environment is **Unknown / Requires Confirmation**.

### FR-005 — Accept a caller-supplied access token for the token check

Node `GET /getTokenCheck` requires query `access_token` and then runs service agreement, agreement context, and product summary.

SAM `GET /tokencheck` requires the same query parameter and does not call I&M.

Kotlin `GET /getTokenCheckJava` is wired to `com.serverless.TokenCheck`.

Provenance: **Implemented**. The three behaviours are not the same.

### FR-006 — Establish service-agreement context and return product summary

`/serviceagreement` requires body `access_token`, `session_state`, `ip`, and `cif`.

On the agreement-context step it writes log title `Login`, classification `info`, message `Successful Login, <timestamp>, <ip>`, reference `session_state`.

On product-summary failure it writes log title `Login_transactions`, classification `Warning`, reference `cif`.

Provenance: **Implemented**.

### FR-007 — Create or update the local user and return terms acceptance

Inputs and branching are WF-2.

Lookup is exact match on `user.user_name`. Terms read is `user.accepted_terms_timestamp` for that username.

Provenance: **Implemented**. Nothing in this repository sets `accepted_terms_timestamp`. Who writes that column is **Unknown / Requires Confirmation**.

### FR-008 — Resolve a user id by email

`/getuser` reads body `email` and returns the `user.id` from a case-insensitive email match, or `0` when no row matches (`CheckUser` returns `0` when the result set is empty — `CheckUserEmail` follows the same pattern and returns the id).

Provenance: **Implemented** in `GetUser.java` and `CheckUserEmail.java`.

### FR-009 — Check a CIF against the whitelist

`/checkwhitelist` reads body `cif` and returns `{"member_no":"<Member No>"}` from `white_listed` where `CIF` matches. No row yields an empty member number inside that JSON.

Provenance: **Implemented**. Column name in SQL is `Member No` (space).

### FR-010 — Allocate a Sanlam member number by currency

When a purchase must create a customer, the system reads the first row of `sanlam_member`.

- Currency `USD`: member number is `USD` plus the next `last_usd_number`, zero-padded to 5 digits.
- Any other currency: member number is the next `last_kes_number`, zero-padded to 5 digits, with no currency prefix.

Provenance: **Implemented** in `GetNewMemberNumber.java`. The code comments do not define the currency list. Only the string `USD` is special-cased, so KES is **Inferred** from the column name `last_kes_number`.

The update statement is `UPDATE sanlam_member SET last_kes_number = ? where last_usd_number = ?` and both parameters are the **already incremented** numbers. Provenance: **Implemented**. Whether that WHERE matches the stored row is **Unknown / Requires Confirmation** (the predicate compares `last_usd_number` to the new USD value).

### FR-011 — Create a Sanlam customer when the user has no member number

Purchase flow calls the SSM URL `Sanlam_Create_Customer` as multipart form: `member_no`, `email`, `surname` (from `family_name`), `middle_name`, `first_name` (from `given_name`), `mobile_no` (from `phone_number` when present), `postal_address` (when present), `tax_no` (request `kraPin`). Authorization is `Bearer` plus the request `auth_token`. HTTP 201 is success. Any other status is `Not Added`.

Provenance: **Implemented**.

The comment in purchase flow says "don't create new account if user has member number (could change in future)". Provenance: **Documented** as a code comment, **Implemented** as the `hasMemberNo` branch.

### FR-012 — Create a Sanlam purchase and mark the local transaction complete

After customer handling, the flow calls SSM `Sanlam_Create_Purchase_Order` and then sets process `complete`.

If `createPurchase` throws, the handler returns the error text and does not set `complete`. If `createPurchase` returns a failure body without throwing, the handler still sets `complete`.

Provenance: **Implemented**.

### FR-013 — Create a Sanlam withdrawal and mark the local transaction complete

Same success/failure shape as FR-012, using SSM `Sanlam_Create_Withdrawal_Order` and integer `amount`.

Provenance: **Implemented**.

### FR-014 — Record and list local transactions

See WF-5. List filter is `im_customer_identifier` equality. There is no paging, date filter, or status filter in `GetTransaction.java`.

Provenance: **Implemented**.

### FR-015 — Initiate an I&M within-bank payment

See WF-6. RSA/ECB/PKCS1Padding, 117-byte blocks, public key SSM `IM_public_key`.

Provenance: **Implemented**.

`getCookie` inside the payment helper calls the service-agreement steps with a hardcoded `session_state`. The value is omitted here. Provenance: **Implemented**.

### FR-016 — Request and validate an OTP

`/requestotp` calls SSM `IM_Request_OTP` via `GetRequestOTP`.

`/validateotp` calls SSM `IM_Validate_OTP` via `GetValidateOTP`.

`/preferred2fa` calls SSM `IM_Preferred_2FA`.

Provenance: **Implemented** as delegations. The request and response contracts of those upstream APIs are **Unknown** (the URLs are not in git).

### FR-017 — Read a named setting

See WF-7. The SQL concatenates `setting` into the statement. No allow-list of setting names is in code.

Provenance: **Implemented**.

### FR-018 — Sanlam statement / member transaction list

Node `GET /getStatements` logs in to `http://105.27.230.181:3002/login` and posts `statementData` with hardcoded `groupmemberid` `00006` and `tdate` `2022-05-06`.

SAM `/statementdata` reads body `member_no` and `access_token` and calls SSM `Sanlam_Transaction_list`. It does not use the Node login or the fixed date.

SAM `GET /usertoken` logs in through `GetStatementData.postGetToken()` and then requests statement data for hardcoded member `00638`.

Provenance: **Implemented**. These three entry points are not the same operation.

### FR-019 — Sanlam subgroup members

Node `GET /getMembers` posts `main_member_no` `08000` to `http://105.27.230.181:3002/subGroupMembers` after the same login as FR-018.

SAM `/subgroupmembers` is handler `SanlamSubGroupMembers`. SAM `/getsanlammembers` is handler `GetSanlamMembers`.

Provenance: **Implemented** for the Node path and the route bindings. The SAM handlers were not line-compared with the Node client in this review beyond the route table.

### FR-020 — Account balance, yield, profile image, customer, statements

| Route | Handler behaviour that was read |
|---|---|
| `GET /accountbalance` | GET `http://api-uat.sanlameastafrica.com/im/api/account/balance/` plus a hardcoded account number. The input event is unused |
| `/sanlamaccountbalance` | SSM `Sanlam_Get_Account_Balance` via `SanlamGetAccountBalance` |
| `/dailyyield` | Body `auth_token`, then SSM `Sanlam_Yield` |
| `/profileimage` | SSM `IM_Get_Profile_Image` |
| `/imcustomerdetails` | SSM `IM_Get_Customer_Details` |
| `/imgetcustomer` | SSM `IM_request_ref_number` is read in `GetIMCustomer`; a customer-details URL in that file is commented out |
| `/imstatement` | SSM `Sanlam_Get_Customer_Statements` |
| `GET /sanlamgettoken` | `SanlamFunctions.GetToken` |
| `/sanlamwitdrawalcharge` | SSM `Sanlam_Withdrawal_charges`. The path spelling is `witdrawal` |

Provenance: **Implemented**.

`SanlamFunctions.GetToken` sets `boolean taxable = true` and the comment says "will change depending on user type". The non-taxable SSM keys exist and are not selected. Provenance: **Implemented** (taxable path only) and **Planned** only as that comment.

### FR-021 — Write an error log from the caller

`/logerror` body fields `title`, `classification`, `message`, and `ref` are inserted into `logs` (`log_title`, `classification`, `message`, `reference`). Success body is `Done writing to logs`. Failure body is `Error writing to logs`.

Provenance: **Implemented**.

### FR-022 — Export logs to S3 hourly

See WF-8. The bucket name is the dev bucket even when the SAM stage parameter is `prod`.

Provenance: **Implemented**.

### FR-023 — Connect to MySQL using Secrets Manager

`SECRET_NAME` and `REGION` environment variables select the secret. The secret JSON must contain `host`, `username`, `password`, `port`. JDBC database name is `IMCapital`. The connection must pass `isValid`.

Provenance: **Implemented**. The template sets `SECRET_NAME` to `imcapital-dev-mysql-aurora` for every environment.

### FR-024 — Read integration URLs and secrets from SSM SecureString parameters

`GetParameter` requests the named parameter with decryption. It returns the value only when the type is `SecureString`. Otherwise it prints that the parameter is not a secure string and returns empty. `SsmException` calls `System.exit(1)`.

Provenance: **Implemented**.

### FR-025 — Place functions in a fixed VPC

Security group `sg-0089502251d79aa23` and subnets `subnet-08882af1374cdd5d2`, `subnet-0b9b9e77b66be7a4d`. Functions may create and describe network interfaces.

Provenance: **Implemented** in all three stack files.

### FR-026 — SonarQube on master

A pipeline on branch `master` runs `sonarsource/sonarqube-scan:2.0.1` using repository variables `SONAR_HOST_URL` and `SONAR_TOKEN`. Project key `overdrive_lambda_AYqpvp-fuNwRc2P2S9SZ`.

Provenance: **Implemented**. The pipeline does not build or deploy.

## Validation and errors

No shared schema validator exists. Missing JSON fields throw while reading `.asText()` / `.asInt()` and the handler turns that into a Lambda error, except where a specific function catches and concatenates `err.getMessage()` into a 200 body.

Empty collections are not consistently guarded. Purchase flow calls `userList.getJSONObject(0)` after `SelectUser`. Service-agreement and token flows assume the first agreement exists in the Node code (`result[0]`).

Provenance: **Implemented**.

## Constraints

- Lambda timeout 30 seconds, memory 1536 MB, architecture `x86_64`, runtime `java17` for SAM globals.
- Node provider runtime `nodejs18.x`, stage `DEV`, framework version 3.
- Kotlin runtime `java11`, stage `dev`.
- API auth block sets `DefaultAuthorizer` type `AWS_IAM` and `AuthorizerId` to `MyApiGatewayRole`. That role trusts Amplify and allows `apigateway:*` on `*`. Whether this block actually requires SigV4 is **Unknown / Requires Confirmation**. The Node Serverless routes declare no authorizer.

## Requirements that are assumptions

- That "IM Capital" is a customer-facing wealth product joining I&M accounts to Sanlam investments. **Inferred**, not written down.
- That KES is the non-USD currency. **Inferred** from `last_kes_number`.
- That the SAM API is the current product and the Node and Kotlin trees are earlier copies. **Inferred** from dates and feature coverage. Requires confirmation before anyone treats the Node routes as unused.
