diff --git a/GENERATED_README.md b/GENERATED_README.md index f24ada1..854cfd9 100644 --- a/GENERATED_README.md +++ b/GENERATED_README.md @@ -1,7 +1,7 @@ # IPB CLI Command Reference > This documentation is auto-generated from the CLI command definitions. -> Last generated: 2026-03-24T17:50:02.946Z +> Last generated: 2026-08-05T10:41:08.758Z ## Table of Contents @@ -29,6 +29,7 @@ - [pay](#pay) - [transactions](#transactions-aliases-tx) - [beneficiaries](#beneficiaries) +- [mau](#mau) - [new](#new) - [completion](#completion) - [docs](#docs) @@ -51,6 +52,10 @@ Configure authentication credentials. Set API keys, client credentials, and card - `--card-key ` (required) - Set your card key for the Investec API - `--openai-key ` (required) - Set your OpenAI API key for AI code generation - `--sandbox-key ` (required) - Set your sandbox key for AI generation +- `--mau-api-key ` (required) - Set Mauritius (MAU) API key +- `--mau-client-id ` (required) - Set Mauritius (MAU) client ID +- `--mau-client-secret ` (required) - Set Mauritius (MAU) client secret +- `--mau-host ` (required) - Set Mauritius (MAU) API host **Subcommands:** @@ -361,6 +366,101 @@ List all beneficiaries. Shows all registered beneficiaries linked to your Invest **Usage:** `ipb beneficiaries` +## mau + +Mauritius (MAU) Open Banking commands. Uses separate MAU credentials (mauClientId, mauClientSecret, mauApiKey). + +**Usage:** `ipb mau` + +**Subcommands:** + +### accounts + +List Mauritius Investec accounts. Shows account IDs, numbers, currencies, and profile names. + +**Usage:** `ipb mau accounts` + +**Options:** + +- `--mau-api-key ` (required) - API key for the Investec Mauritius (MAU) API +- `--mau-client-id ` (required) - Client ID for the Investec Mauritius (MAU) API +- `--mau-client-secret ` (required) - Client secret for the Investec Mauritius (MAU) API +- `--mau-host ` (required) - Custom host for the Investec Mauritius (MAU) API + +### balances + +Get Mauritius account balance information including available balance and encumbrances. + +**Usage:** `ipb mau balances ` + +**Arguments:** + +- `` (required) - Numeric Mauritius account ID + +**Options:** + +- `--mau-api-key ` (required) - API key for the Investec Mauritius (MAU) API +- `--mau-client-id ` (required) - Client ID for the Investec Mauritius (MAU) API +- `--mau-client-secret ` (required) - Client secret for the Investec Mauritius (MAU) API +- `--mau-host ` (required) - Custom host for the Investec Mauritius (MAU) API + +### transactions + +Get Mauritius account transactions for a required date range (--from / --to, YYYY-MM-DD). + +**Usage:** `ipb mau transactions ` + +**Arguments:** + +- `` (required) - Numeric Mauritius account ID + +**Options:** + +- `--mau-api-key ` (required) - API key for the Investec Mauritius (MAU) API +- `--mau-client-id ` (required) - Client ID for the Investec Mauritius (MAU) API +- `--mau-client-secret ` (required) - Client secret for the Investec Mauritius (MAU) API +- `--mau-host ` (required) - Custom host for the Investec Mauritius (MAU) API +- `--from ` (required) - Start date (YYYY-MM-DD) +- `--to ` (required) - End date (YYYY-MM-DD) + +### documents + +List available Mauritius account documents (e.g. statements) for a date range. + +**Usage:** `ipb mau documents ` + +**Arguments:** + +- `` (required) - Numeric Mauritius account ID + +**Options:** + +- `--mau-api-key ` (required) - API key for the Investec Mauritius (MAU) API +- `--mau-client-id ` (required) - Client ID for the Investec Mauritius (MAU) API +- `--mau-client-secret ` (required) - Client secret for the Investec Mauritius (MAU) API +- `--mau-host ` (required) - Custom host for the Investec Mauritius (MAU) API +- `--from ` (required) - Start date (YYYY-MM-DD) +- `--to ` (required) - End date (YYYY-MM-DD) + +### statement + +Download a Mauritius account statement PDF for a document date. Defaults to statement--.pdf. + +**Usage:** `ipb mau statement ` + +**Arguments:** + +- `` (required) - Numeric Mauritius account ID +- `` (required) - Document date (YYYY-MM-DD) + +**Options:** + +- `--mau-api-key ` (required) - API key for the Investec Mauritius (MAU) API +- `--mau-client-id ` (required) - Client ID for the Investec Mauritius (MAU) API +- `--mau-client-secret ` (required) - Client secret for the Investec Mauritius (MAU) API +- `--mau-host ` (required) - Custom host for the Investec Mauritius (MAU) API + + ## new Create a new project with scaffolding. Generates a new project directory with template files and directory structure for card code development. diff --git a/README.md b/README.md index ca1ec98..e694880 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,9 @@ ipb config --client-id --client-secret --api-key # Optional card key / host — see ipb config --help ipb config --card-key +# Mauritius (MAU) credentials (separate from ZA PB/Card) +ipb config --mau-client-id --mau-client-secret --mau-api-key + # Named profiles ipb config --profile production --client-id --client-secret --api-key ipb config --profile staging --client-id --client-secret --api-key @@ -190,6 +193,19 @@ Use a profile on any command: ```sh ipb cards --profile staging ipb deploy --profile production -f main.js -c +ipb mau accounts --profile production +``` + +### How to use Mauritius (MAU) Open Banking + +MAU uses the same OAuth shape as ZA APIs but **separate credentials** (`mauClientId`, `mauClientSecret`, `mauApiKey`, optional `mauHost`). + +```sh +ipb mau accounts +ipb mau balances 5331 +ipb mau transactions 5331 --from 2024-01-01 --to 2024-01-31 +ipb mau documents 5331 --from 2025-01-01 --to 2025-01-31 +ipb mau statement 5331 2025-01-31 --output statement.pdf ``` Edit in your editor (`EDITOR`, default `nano` / `notepad.exe`): @@ -352,7 +368,8 @@ Information-oriented lookup. Authoritative flags: `ipb --help` or [GEN | `env` / `upload-env` | Card environment variables | | `logs` (`log`) | Execution logs | | `enable` / `disable` | Toggle code on a card | -| `accounts` (`acc`) / `balances` (`bal`) / `transactions` (`tx`) | Account data | +| `accounts` (`acc`) / `balances` (`bal`) / `transactions` (`tx`) | Account data (ZA PB) | +| `mau` | Mauritius Open Banking (`accounts`, `balances`, `transactions`, `documents`, `statement`) | | `beneficiaries` / `transfer` / `pay` | Beneficiaries and payments | | `countries` / `currencies` / `merchants` | Reference data | | `completion` / `docs` / `env-list` | Shell completion, docs dump, env catalogue | @@ -362,6 +379,7 @@ These commands are **disabled** and return an error: `ai`, `bank`, `register`, ` ### Shared options (most API commands) - Auth: `--client-id`, `--client-secret`, `--api-key`, `--host`, `--credentials-file`, `--profile` +- MAU auth: `--mau-client-id`, `--mau-client-secret`, `--mau-api-key`, `--mau-host` - Output: `--json`, `--yaml`, `--output `, `-v` / `--verbose` - Spinner: `--no-spinner` (preferred); `-s` / `--spinner` is deprecated - Destructive: `--yes` where supported @@ -378,6 +396,7 @@ ipb env-list --json Common categories: - **API:** `INVESTEC_HOST`, `INVESTEC_CLIENT_ID`, `INVESTEC_CLIENT_SECRET`, `INVESTEC_API_KEY`, `INVESTEC_CARD_KEY` +- **MAU API:** `INVESTEC_MAU_HOST`, `INVESTEC_MAU_CLIENT_ID`, `INVESTEC_MAU_CLIENT_SECRET`, `INVESTEC_MAU_API_KEY` - **Behaviour:** `DEBUG`, `REJECT_UNAUTHORIZED`, `NO_COLOR`, `FORCE_COLOR`, `EDITOR`, `PAGER`, `TMPDIR`, `IPB_NO_UPDATE_CHECK` ### Exit codes @@ -409,6 +428,8 @@ Messages look like `Error (E####): …`. | `E4010` | File not found | | `E4012` | Missing account ID | | `E4014` | Rate limit exceeded | +| `E4019` | Unsupported operation | +| `E4020` | Missing or invalid date range (MAU `--from` / `--to`) | | `E5001` | Deploy / API operation failed | Quick fixes: missing card key → `ipb cards` then `-c`; bad auth → `ipb config`; missing `.env.` → create the file or change `-e`. diff --git a/assets/accounts.gif b/assets/accounts.gif index db958f5..b2c7199 100644 Binary files a/assets/accounts.gif and b/assets/accounts.gif differ diff --git a/assets/balances.gif b/assets/balances.gif index 54560c2..de3843e 100644 Binary files a/assets/balances.gif and b/assets/balances.gif differ diff --git a/assets/beneficiaries.gif b/assets/beneficiaries.gif index bcc391a..aa2df09 100644 Binary files a/assets/beneficiaries.gif and b/assets/beneficiaries.gif differ diff --git a/assets/cards.gif b/assets/cards.gif index 0bdb2b9..d6fe51d 100644 Binary files a/assets/cards.gif and b/assets/cards.gif differ diff --git a/assets/deploy.gif b/assets/deploy.gif index 0e510d9..05d6f0e 100644 Binary files a/assets/deploy.gif and b/assets/deploy.gif differ diff --git a/assets/env.gif b/assets/env.gif index f55ba81..1a4b355 100644 Binary files a/assets/env.gif and b/assets/env.gif differ diff --git a/assets/fetch.gif b/assets/fetch.gif index cd8486b..6691c0a 100644 Binary files a/assets/fetch.gif and b/assets/fetch.gif differ diff --git a/assets/logs.gif b/assets/logs.gif index 9a12522..0d457d0 100644 Binary files a/assets/logs.gif and b/assets/logs.gif differ diff --git a/assets/new.gif b/assets/new.gif index 28f0c15..8b9ca59 100644 Binary files a/assets/new.gif and b/assets/new.gif differ diff --git a/assets/pay.gif b/assets/pay.gif index c91ba4e..14aef1b 100644 Binary files a/assets/pay.gif and b/assets/pay.gif differ diff --git a/assets/publish.gif b/assets/publish.gif index 800eb93..61026a5 100644 Binary files a/assets/publish.gif and b/assets/publish.gif differ diff --git a/assets/published.gif b/assets/published.gif index 516cc4e..6e68644 100644 Binary files a/assets/published.gif and b/assets/published.gif differ diff --git a/assets/run.gif b/assets/run.gif index 00849ed..02dd983 100644 Binary files a/assets/run.gif and b/assets/run.gif differ diff --git a/assets/simulate.gif b/assets/simulate.gif index e138862..6516605 100644 Binary files a/assets/simulate.gif and b/assets/simulate.gif differ diff --git a/assets/toggle.gif b/assets/toggle.gif index a55f625..e662ac8 100644 Binary files a/assets/toggle.gif and b/assets/toggle.gif differ diff --git a/assets/transactions.gif b/assets/transactions.gif index 5baad35..e194cba 100644 Binary files a/assets/transactions.gif and b/assets/transactions.gif differ diff --git a/assets/transfer.gif b/assets/transfer.gif index 04aded0..d74f875 100644 Binary files a/assets/transfer.gif and b/assets/transfer.gif differ diff --git a/assets/upload-env.gif b/assets/upload-env.gif index 38273c5..1e0bd55 100644 Binary files a/assets/upload-env.gif and b/assets/upload-env.gif differ diff --git a/assets/upload.gif b/assets/upload.gif index 7d97b7d..ed12f5e 100644 Binary files a/assets/upload.gif and b/assets/upload.gif differ diff --git a/package-lock.json b/package-lock.json index 4a0d593..0189bc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "investec-ipb", - "version": "0.9.3", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "investec-ipb", - "version": "0.9.3", + "version": "0.10.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.9.0", @@ -14,13 +14,14 @@ "cli-table3": "^0.6.5", "commander": "^14.0.2", "dotenv": "^17.2.3", - "investec-card-api": "^0.2.1", - "investec-pb-api": "^0.3.10", + "investec-card-api": "^0.2.3", + "investec-mau-api": "^0.1.0", + "investec-pb-api": "^0.3.12", "js-yaml": "^4.3.1", "node-fetch": "^3.3.2", "openai": "^4.104.0", "ora": "^9.0.0", - "programmable-card-code-emulator": "^2.0.0", + "programmable-card-code-emulator": "^2.0.1", "zod": "^3.25.76" }, "bin": { @@ -3552,18 +3553,27 @@ } }, "node_modules/investec-card-api": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/investec-card-api/-/investec-card-api-0.2.1.tgz", - "integrity": "sha512-EFxMybearPQLV6d/ReYidU5y1+gPtlxJnGr18rqq0k/KLP2VdFGFe0ESs/CQUt5XPOwnSU5/tw2E5uvScZR7qg==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/investec-card-api/-/investec-card-api-0.2.3.tgz", + "integrity": "sha512-xCQJXGAbiJG0jUdh3i0GfaKRGE9mriRBTfw8SvTeWWd8YZ0QHVfRxhCzk6mtFKz3nU96EUQd0SrebAqu679HEQ==", "license": "MIT", "dependencies": { "node-fetch": "^3.3.2" } }, + "node_modules/investec-mau-api": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/investec-mau-api/-/investec-mau-api-0.1.0.tgz", + "integrity": "sha512-lQsl18XxrJnhariKknyrXsZZdNKCinUJx1B4Ok2p0tFqc3095OnBapvcnQj+6xjqWDYereNnxMiRXE2KAut3Dw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/investec-pb-api": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/investec-pb-api/-/investec-pb-api-0.3.10.tgz", - "integrity": "sha512-RlfjSdlandyN5036Ne3WRvCaYAIsMoOoyZcSKwx6BiCcdttbpN3vViRMPJfa7fmFZ3mXpfy+DunoX+YMF7oD+w==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/investec-pb-api/-/investec-pb-api-0.3.12.tgz", + "integrity": "sha512-CUmR7BgOwenZxE2wZmgcOUj5Ejf8ajttpkW9RaCnFWBG5fEI5WM/DDViWpit05XzkG28v2CDwbNhjwcWGRcIHQ==", "license": "MIT", "dependencies": { "node-fetch": "^3.3.2" @@ -5352,9 +5362,9 @@ "license": "MIT" }, "node_modules/programmable-card-code-emulator": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/programmable-card-code-emulator/-/programmable-card-code-emulator-2.0.0.tgz", - "integrity": "sha512-rXec4RFeXz42MVuDu2poR3aXujdU/ptPHmavfrc0tnCsC6orjlVxmn/OvLc6clV3RpAf+sp+w202x6n0jq359w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/programmable-card-code-emulator/-/programmable-card-code-emulator-2.0.1.tgz", + "integrity": "sha512-9LkasEjyRZA2CXcjeopiQi/SeADUuZuEqEDPVbZed44zUDMN4YNk7E68K9za4gc8LVZ+dlweCHrvMceP3YQemg==", "license": "MIT", "dependencies": { "moment-mini": "^2.29.4", diff --git a/package.json b/package.json index 017f8a0..c033cd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "investec-ipb", - "version": "0.9.3", + "version": "0.10.0", "main": "bin/index.js", "bin": { "ipb": "./bin/index.js" @@ -65,13 +65,14 @@ "cli-table3": "^0.6.5", "commander": "^14.0.2", "dotenv": "^17.2.3", - "investec-card-api": "^0.2.1", - "investec-pb-api": "^0.3.10", + "investec-card-api": "^0.2.3", + "investec-mau-api": "^0.1.0", + "investec-pb-api": "^0.3.12", "js-yaml": "^4.3.1", "node-fetch": "^3.3.2", "openai": "^4.104.0", "ora": "^9.0.0", - "programmable-card-code-emulator": "^2.0.0", + "programmable-card-code-emulator": "^2.0.1", "zod": "^3.25.76" }, "devDependencies": { diff --git a/scripts/tapes.sh b/scripts/tapes.sh index e308eb3..c45015a 100755 --- a/scripts/tapes.sh +++ b/scripts/tapes.sh @@ -22,25 +22,25 @@ if ! ipb --version >/dev/null 2>&1; then fi tapes=( + accounts + balances + beneficiaries cards deploy env fetch logs new + pay publish published run simulate toggle - upload-env - upload - accounts - beneficiaries - balances transactions - pay transfer + upload-env + upload ) for name in "${tapes[@]}"; do diff --git a/skills/ipb/SKILL.md b/skills/ipb/SKILL.md index de189b5..c265a3e 100644 --- a/skills/ipb/SKILL.md +++ b/skills/ipb/SKILL.md @@ -3,9 +3,10 @@ name: ipb description: >- Operate the Investec Programmable Banking CLI (ipb): configure credentials, list cards/accounts, deploy and simulate card code, fetch logs, transfer/pay, - and read machine-readable JSON output. Use when the user asks to run ipb, - manage programmable cards, deploy card JavaScript, check Investec accounts or - balances, or automate Investec Programmable Banking from a terminal agent. + Mauritius (MAU) Open Banking, and read machine-readable JSON output. Use when + the user asks to run ipb, manage programmable cards, deploy card JavaScript, + check Investec accounts or balances, use MAU APIs, or automate Investec + Programmable Banking from a terminal agent. --- # Use the `ipb` CLI @@ -16,7 +17,7 @@ You are helping a user operate **investec-ipb** (`ipb`), a terminal CLI for Inve - CLI installed and on `PATH` (`ipb --version` works). - Node.js ≥ 24 if installed via npm: `npm install -g investec-ipb`. -- Investec API credentials from the [Developer Portal](https://developer.investec.com) (client id, client secret, API key). Card operations also need a card key. +- Investec API credentials from the [Developer Portal](https://developer.investec.com) (client id, client secret, API key). Card operations also need a card key. Mauritius (MAU) commands need separate MAU credentials. If `ipb` is missing, tell the user how to install it; do not assume this git repo is present. @@ -38,6 +39,9 @@ ipb config --client-id --client-secret --api-key # Optional: card key for card commands ipb config --card-key +# Optional: Mauritius (MAU) credentials (separate from ZA PB/Card) +ipb config --mau-client-id --mau-client-secret --mau-api-key + # Optional: named environments ipb config --profile production --client-id --client-secret --api-key ipb config profile set production @@ -45,7 +49,7 @@ ipb config profile set production Credentials live under `~/.ipb/` (owner-only permissions). Profiles: `ipb config profile list|set|show|delete`. Edit in `$EDITOR`: `ipb config edit`. - creds can also come from env vars (`INVESTEC_CLIENT_ID`, `INVESTEC_CLIENT_SECRET`, `INVESTEC_API_KEY`, `INVESTEC_CARD_KEY`, optional `INVESTEC_HOST`) — warn that env secrets are less ideal than the credentials file. +Creds can also come from env vars (`INVESTEC_CLIENT_ID`, `INVESTEC_CLIENT_SECRET`, `INVESTEC_API_KEY`, `INVESTEC_CARD_KEY`, optional `INVESTEC_HOST`; MAU: `INVESTEC_MAU_CLIENT_ID`, `INVESTEC_MAU_CLIENT_SECRET`, `INVESTEC_MAU_API_KEY`, optional `INVESTEC_MAU_HOST`) — warn that env secrets are less ideal than the credentials file. ## Common workflows @@ -54,11 +58,23 @@ Credentials live under `~/.ipb/` (owner-only permissions). Profiles: `ipb config ```bash ipb cards --json ipb accounts --json -ipb balances -a --json -ipb transactions -a --json +ipb balances --json +ipb transactions --json ipb beneficiaries --json ``` +### Mauritius (MAU) Open Banking + +Uses separate credentials from ZA PB. Account IDs are numeric. Transactions/documents require `--from` / `--to` (YYYY-MM-DD). + +```bash +ipb mau accounts --json +ipb mau balances 5331 --json +ipb mau transactions 5331 --from 2024-01-01 --to 2024-01-31 --json +ipb mau documents 5331 --from 2025-01-01 --to 2025-01-31 --json +ipb mau statement 5331 2025-01-31 --output statement.pdf +``` + ### Local card-code simulation (no Investec account required for `run`) ```bash @@ -116,6 +132,7 @@ Success exits `0`. Non-zero exits are typed (validation, auth, file, API, networ | Problem | What to try | |---------|-------------| | Auth / invalid credentials | Re-run `ipb config`, check profile with `ipb config profile show` | +| Missing MAU credentials | `ipb config --mau-client-id … --mau-client-secret … --mau-api-key …` | | Missing card key | `ipb cards` then pass `-c` / `ipb config --card-key` | | Rate limited | Wait and retry; use `--verbose` to see retry behavior | | Command unknown | `ipb --help` — do not invent subcommands | diff --git a/src/cmds/env-list.ts b/src/cmds/env-list.ts index 4a4cd43..b9427d1 100644 --- a/src/cmds/env-list.ts +++ b/src/cmds/env-list.ts @@ -64,6 +64,39 @@ export async function envListCommand(options: CommonOptions) { example: '123456', category: 'API Credentials', }, + { + name: 'INVESTEC_MAU_HOST', + description: + 'API host URL for the Investec Mauritius (MAU) Open Banking API. Defaults to the same production host as ZA APIs.', + required: false, + default: 'https://openapi.investec.com', + example: 'https://openapi.investec.com', + category: 'API Credentials', + }, + { + name: 'INVESTEC_MAU_CLIENT_ID', + description: + 'Client ID for authenticating with the Investec Mauritius (MAU) API. Separate from ZA PB/Card credentials.', + required: false, + example: 'your-mau-client-id', + category: 'API Credentials', + }, + { + name: 'INVESTEC_MAU_CLIENT_SECRET', + description: + 'Client secret for authenticating with the Investec Mauritius (MAU) API. Separate from ZA PB/Card credentials.', + required: false, + example: 'your-mau-client-secret', + category: 'API Credentials', + }, + { + name: 'INVESTEC_MAU_API_KEY', + description: + 'API key for accessing the Investec Mauritius (MAU) API. Separate from ZA PB/Card credentials.', + required: false, + example: 'your-mau-api-key', + category: 'API Credentials', + }, { name: 'OPENAI_API_KEY', description: @@ -92,7 +125,7 @@ export async function envListCommand(options: CommonOptions) { { name: 'IPB_MOCK_APIS', description: - 'Use in-process mock Programmable Banking and Card API clients (no network). Does not enable verbose output. For offline demos, set dummy INVESTEC_* credentials or use a local credentials file.', + 'Use in-process mock Programmable Banking, Card, and Mauritius (MAU) API clients (no network). Does not enable verbose output. For offline demos, set dummy INVESTEC_* / INVESTEC_MAU_* credentials or use a local credentials file.', required: false, default: 'false', example: '1', diff --git a/src/cmds/mau/accounts.ts b/src/cmds/mau/accounts.ts new file mode 100644 index 0000000..90bb561 --- /dev/null +++ b/src/cmds/mau/accounts.ts @@ -0,0 +1,66 @@ +import { credentials, printTitleBox } from '../../runtime-credentials.js'; +import { + createSpinner, + initializeMauApi, + isStdoutPiped, + resolveSpinnerState, + runListCommand, + withRetry, + withSpinner, +} from '../../utils.js'; +import type { MauOptions } from '../types.js'; + +/** + * List Mauritius (MAU) Investec accounts. + * @param options - CLI options including MAU credentials + */ +export async function mauAccountsCommand(options: MauOptions) { + const isPiped = isStdoutPiped(); + + if (!isPiped) { + printTitleBox(); + } + const { spinnerEnabled, verbose } = resolveSpinnerState({ + spinnerFlag: options.spinner, + verboseFlag: options.verbose, + isPiped, + }); + const spinner = createSpinner(spinnerEnabled, '💳 fetching MAU accounts...'); + let accounts: + | Array<{ + accountId: number; + accountNumber: string; + accountName?: string; + accountCurrency: string; + profileId: number; + profileName: string; + }> + | undefined; + + await withSpinner(spinner, spinnerEnabled, async () => { + const api = await initializeMauApi(credentials, options); + if (verbose && !isPiped) console.log('💳 fetching MAU accounts...'); + + const result = await withRetry(() => api.getAccounts(), { + maxRetries: 3, + verbose, + }); + accounts = result.data.accounts; + }); + + await runListCommand({ + isPiped, + items: accounts, + outputOptions: { json: options.json, yaml: options.yaml, output: options.output }, + emptyMessage: 'No MAU accounts found', + countMessage: (count) => `${count} MAU account(s) found.`, + mapSimple: (rows) => + rows.map(({ accountId, accountNumber, accountName, accountCurrency, profileName }) => ({ + accountId, + accountNumber, + accountName: accountName ?? '', + accountCurrency, + profileName, + })), + }); +} diff --git a/src/cmds/mau/balances.ts b/src/cmds/mau/balances.ts new file mode 100644 index 0000000..35111a0 --- /dev/null +++ b/src/cmds/mau/balances.ts @@ -0,0 +1,80 @@ +import { credentials, printTitleBox } from '../../runtime-credentials.js'; +import { + createSpinner, + initializeMauApi, + isStdoutPiped, + resolveSpinnerState, + runListCommand, + validateMauAccountId, + withRetry, + withSpinner, +} from '../../utils.js'; +import type { MauOptions } from '../types.js'; +import { resolveMauAccountId } from './helpers.js'; + +/** + * Fetch and display Mauritius (MAU) account balances. + * @param accountId - MAU account ID + * @param options - CLI options including MAU credentials + */ +export async function mauBalancesCommand(accountId: string, options: MauOptions) { + const isPiped = isStdoutPiped(); + const resolvedId = validateMauAccountId(await resolveMauAccountId(accountId)); + + if (!isPiped) { + printTitleBox(); + } + const { spinnerEnabled, verbose } = resolveSpinnerState({ + spinnerFlag: options.spinner, + verboseFlag: options.verbose, + isPiped, + }); + const spinner = createSpinner(spinnerEnabled, '💳 fetching MAU balances...'); + let balance: + | { + accountNumber: string; + accountType: string; + accountShortName: string; + balance: number; + availableBalance: number; + encumbrances: number; + currency: string; + debitInterestRate: number; + creditInterestRate: number; + creditInterestAccrued: number; + debitInterestAccrued: number; + overDraftLimit?: number; + } + | undefined; + + await withSpinner(spinner, spinnerEnabled, async () => { + const api = await initializeMauApi(credentials, options); + const result = await withRetry(() => api.getAccountBalances(resolvedId), { + maxRetries: 3, + verbose, + }); + balance = result.data; + }); + + if (!balance) { + return; + } + + await runListCommand({ + isPiped, + items: [balance], + outputOptions: { json: options.json, yaml: options.yaml, output: options.output }, + emptyMessage: 'No MAU balances found', + countMessage: () => '1 MAU balance record found.', + mapSimple: (rows) => + rows.map((row) => ({ + accountNumber: row.accountNumber, + accountType: row.accountType, + accountShortName: row.accountShortName, + currency: row.currency, + balance: row.balance, + availableBalance: row.availableBalance, + encumbrances: row.encumbrances, + })), + }); +} diff --git a/src/cmds/mau/documents.ts b/src/cmds/mau/documents.ts new file mode 100644 index 0000000..24e1a41 --- /dev/null +++ b/src/cmds/mau/documents.ts @@ -0,0 +1,75 @@ +import { credentials, printTitleBox } from '../../runtime-credentials.js'; +import { + createSpinner, + initializeMauApi, + isStdoutPiped, + resolveSpinnerState, + runListCommand, + validateDateRange, + validateMauAccountId, + withRetry, + withSpinner, +} from '../../utils.js'; +import type { MauOptions } from '../types.js'; +import { resolveMauAccountId } from './helpers.js'; + +interface MauDocumentsOptions extends MauOptions { + from?: string; + to?: string; +} + +/** + * List available Mauritius (MAU) account documents for a date range. + * @param accountId - MAU account ID + * @param options - CLI options including --from / --to + */ +export async function mauDocumentsCommand(accountId: string, options: MauDocumentsOptions) { + const isPiped = isStdoutPiped(); + const resolvedId = validateMauAccountId(await resolveMauAccountId(accountId)); + const { fromDate, toDate } = validateDateRange(options.from, options.to); + + if (!isPiped) { + printTitleBox(); + } + const { spinnerEnabled, verbose } = resolveSpinnerState({ + spinnerFlag: options.spinner, + verboseFlag: options.verbose, + isPiped, + }); + const spinner = createSpinner(spinnerEnabled, '💳 fetching MAU documents...'); + let documents: + | Array<{ + documentDate: string; + documentType: string; + accountNumber: string; + }> + | undefined; + + await withSpinner(spinner, spinnerEnabled, async () => { + const api = await initializeMauApi(credentials, options); + const result = await withRetry(() => api.getAccountDocuments(resolvedId, fromDate, toDate), { + maxRetries: 3, + verbose, + }); + const accountNumber = result.availableDocuments.accountNumber; + documents = result.availableDocuments.documentInformation.map((doc) => ({ + documentDate: doc.documentDate, + documentType: doc.documentType, + accountNumber, + })); + }); + + await runListCommand({ + isPiped, + items: documents, + outputOptions: { json: options.json, yaml: options.yaml, output: options.output }, + emptyMessage: 'No MAU documents found', + countMessage: (count) => `${count} MAU document(s) found.`, + mapSimple: (rows) => + rows.map(({ documentDate, documentType, accountNumber }) => ({ + documentDate, + documentType, + accountNumber, + })), + }); +} diff --git a/src/cmds/mau/helpers.ts b/src/cmds/mau/helpers.ts new file mode 100644 index 0000000..17af4b9 --- /dev/null +++ b/src/cmds/mau/helpers.ts @@ -0,0 +1,38 @@ +import { CliError, ERROR_CODES } from '../../errors.js'; +import { readStdin } from '../../utils.js'; + +/** + * Resolves a MAU account ID from an argument or stdin JSON/plain text. + * @param accountId - Optional account ID argument + * @returns Resolved account ID string (may still need validation) + */ +export async function resolveMauAccountId(accountId: string | undefined): Promise { + let resolved = accountId?.trim() ?? ''; + + if (!resolved) { + const stdinData = await readStdin(); + if (stdinData) { + try { + const parsed = JSON.parse(stdinData); + if (Array.isArray(parsed) && parsed.length > 0) { + resolved = String(parsed[0].accountId ?? ''); + } else if (parsed && typeof parsed === 'object' && 'accountId' in parsed) { + resolved = String((parsed as { accountId: unknown }).accountId); + } else if (typeof parsed === 'string' || typeof parsed === 'number') { + resolved = String(parsed); + } + } catch { + resolved = stdinData.trim(); + } + } + } + + if (!resolved) { + throw new CliError( + ERROR_CODES.MISSING_ACCOUNT_ID, + 'Account ID is required. Provide it as an argument or via stdin.' + ); + } + + return resolved; +} diff --git a/src/cmds/mau/index.ts b/src/cmds/mau/index.ts new file mode 100644 index 0000000..257bb3f --- /dev/null +++ b/src/cmds/mau/index.ts @@ -0,0 +1,5 @@ +export { mauAccountsCommand } from './accounts.js'; +export { mauBalancesCommand } from './balances.js'; +export { mauDocumentsCommand } from './documents.js'; +export { mauStatementCommand } from './statement.js'; +export { mauTransactionsCommand } from './transactions.js'; diff --git a/src/cmds/mau/statement.ts b/src/cmds/mau/statement.ts new file mode 100644 index 0000000..3cbd0f4 --- /dev/null +++ b/src/cmds/mau/statement.ts @@ -0,0 +1,59 @@ +import { writeFile } from 'node:fs/promises'; +import { credentials, printTitleBox } from '../../runtime-credentials.js'; +import { + createSpinner, + getSafeText, + initializeMauApi, + isStdoutPiped, + resolveSpinnerState, + validateFilePathForWrite, + validateIsoDate, + validateMauAccountId, + withRetry, + withSpinner, +} from '../../utils.js'; +import type { MauOptions } from '../types.js'; +import { resolveMauAccountId } from './helpers.js'; + +interface MauStatementOptions extends MauOptions { + output?: string; +} + +/** + * Download a Mauritius (MAU) account statement PDF. + * @param accountId - MAU account ID + * @param documentDate - Statement document date (YYYY-MM-DD) + * @param options - CLI options including optional --output path + */ +export async function mauStatementCommand( + accountId: string, + documentDate: string, + options: MauStatementOptions +) { + const isPiped = isStdoutPiped(); + const resolvedId = validateMauAccountId(await resolveMauAccountId(accountId)); + const date = validateIsoDate(documentDate, 'documentDate'); + const defaultPath = `statement-${resolvedId}-${date}.pdf`; + const outputPath = await validateFilePathForWrite(options.output || defaultPath, ['.pdf']); + + if (!isPiped) { + printTitleBox(); + } + const { spinnerEnabled, verbose } = resolveSpinnerState({ + spinnerFlag: options.spinner, + verboseFlag: options.verbose, + isPiped, + }); + const spinner = createSpinner(spinnerEnabled, '💳 downloading MAU statement...'); + + await withSpinner(spinner, spinnerEnabled, async () => { + const api = await initializeMauApi(credentials, options); + const pdf = await withRetry(() => api.downloadAccountStatement(resolvedId, date), { + maxRetries: 3, + verbose, + }); + await writeFile(outputPath, Buffer.from(pdf)); + }); + + console.log(getSafeText(`✅ Statement saved to ${outputPath}`)); +} diff --git a/src/cmds/mau/transactions.ts b/src/cmds/mau/transactions.ts new file mode 100644 index 0000000..41a142e --- /dev/null +++ b/src/cmds/mau/transactions.ts @@ -0,0 +1,80 @@ +import { credentials, printTitleBox } from '../../runtime-credentials.js'; +import { + createSpinner, + initializeMauApi, + isStdoutPiped, + resolveSpinnerState, + runListCommand, + validateDateRange, + validateMauAccountId, + withRetry, + withSpinner, +} from '../../utils.js'; +import type { MauOptions } from '../types.js'; +import { resolveMauAccountId } from './helpers.js'; + +interface MauTransactionsOptions extends MauOptions { + from?: string; + to?: string; +} + +/** + * Fetch and display Mauritius (MAU) account transactions for a date range. + * @param accountId - MAU account ID + * @param options - CLI options including --from / --to + */ +export async function mauTransactionsCommand(accountId: string, options: MauTransactionsOptions) { + const isPiped = isStdoutPiped(); + const resolvedId = validateMauAccountId(await resolveMauAccountId(accountId)); + const { fromDate, toDate } = validateDateRange(options.from, options.to); + + if (!isPiped) { + printTitleBox(); + } + const { spinnerEnabled, verbose } = resolveSpinnerState({ + spinnerFlag: options.spinner, + verboseFlag: options.verbose, + isPiped, + }); + const spinner = createSpinner(spinnerEnabled, '💳 fetching MAU transactions...'); + let transactions: + | Array<{ + description: string; + creditAmount: number; + debitAmount: number; + transactionDate: string; + bankReference: string; + amount: number; + runningBalance: number; + postDate?: string; + }> + | undefined; + + await withSpinner(spinner, spinnerEnabled, async () => { + const api = await initializeMauApi(credentials, options); + const result = await withRetry(() => api.getAccountTransactions(resolvedId, fromDate, toDate), { + maxRetries: 3, + verbose, + }); + transactions = result.data.transactions; + }); + + await runListCommand({ + isPiped, + items: transactions, + outputOptions: { json: options.json, yaml: options.yaml, output: options.output }, + emptyMessage: 'No MAU transactions found', + countMessage: (count) => `${count} MAU transaction(s) found.`, + mapSimple: (rows) => + rows.map( + ({ transactionDate, description, amount, creditAmount, debitAmount, bankReference }) => ({ + transactionDate, + description, + amount, + creditAmount, + debitAmount, + bankReference, + }) + ), + }); +} diff --git a/src/cmds/set.ts b/src/cmds/set.ts index bec519b..3c810e6 100644 --- a/src/cmds/set.ts +++ b/src/cmds/set.ts @@ -26,6 +26,10 @@ interface Options { cardKey: string; openaiKey: string; sandboxKey: string; + mauHost?: string; + mauClientId?: string; + mauClientSecret?: string; + mauApiKey?: string; profile?: string; verbose: boolean; spinner?: boolean; @@ -118,6 +122,18 @@ export async function configCommand(options: Options & ProfileOptions) { if (options.sandboxKey) { profileData.sandboxKey = options.sandboxKey; } + if (options.mauHost) { + profileData.mauHost = options.mauHost; + } + if (options.mauClientId) { + profileData.mauClientId = options.mauClientId; + } + if (options.mauClientSecret) { + profileData.mauClientSecret = options.mauClientSecret; + } + if (options.mauApiKey) { + profileData.mauApiKey = options.mauApiKey; + } await runWithConfigSpinner(options, `Saving profile "${profileName}"...`, () => writeProfile(profileName, profileData) @@ -149,6 +165,18 @@ export async function configCommand(options: Options & ProfileOptions) { if (options.sandboxKey) { cred.sandboxKey = options.sandboxKey; } + if (options.mauHost) { + cred.mauHost = options.mauHost; + } + if (options.mauClientId) { + cred.mauClientId = options.mauClientId; + } + if (options.mauClientSecret) { + cred.mauClientSecret = options.mauClientSecret; + } + if (options.mauApiKey) { + cred.mauApiKey = options.mauApiKey; + } await runWithConfigSpinner(options, 'Saving credentials...', () => writeCredentialsFile(credentialLocation.filename, cred) ); diff --git a/src/cmds/types.ts b/src/cmds/types.ts index 14ac334..e4596f6 100644 --- a/src/cmds/types.ts +++ b/src/cmds/types.ts @@ -22,6 +22,11 @@ export interface Credentials { cardKey: string; openaiKey: string; sandboxKey: string; + /** Investec Mauritius (MAU) API host */ + mauHost: string; + mauClientId: string; + mauClientSecret: string; + mauApiKey: string; } export interface BasicOptions { @@ -31,3 +36,28 @@ export interface BasicOptions { clientSecret: string; credentialsFile: string; } + +/** CLI options for Mauritius (MAU) API commands */ +export interface MauOptions { + mauHost?: string; + mauApiKey?: string; + mauClientId?: string; + mauClientSecret?: string; + credentialsFile?: string; + profile?: string; + verbose: boolean; + spinner?: boolean; + json?: boolean; + yaml?: boolean; + output?: string; +} + +/** Credential override options for MAU API initialization */ +export interface MauBasicOptions { + mauHost?: string; + mauApiKey?: string; + mauClientId?: string; + mauClientSecret?: string; + credentialsFile?: string; + profile?: string; +} diff --git a/src/completion.ts b/src/completion.ts index 41250d3..771565c 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -39,6 +39,7 @@ export function generateCompletionScript(shell: string): string { 'f', // alias for fetch 'logs', 'log', // alias for logs + 'mau', 'merchants', 'new', 'pay', @@ -81,8 +82,24 @@ export function generateCompletionScript(shell: string): string { beneficiaries: ['--json', '--yaml', '--output'], cards: ['--json', '--yaml', '--output'], c: ['--json', '--yaml', '--output'], // alias for cards - config: ['--card-key', '--openai-key', '--sandbox-key'], - cfg: ['--card-key', '--openai-key', '--sandbox-key'], // alias for config + config: [ + '--card-key', + '--openai-key', + '--sandbox-key', + '--mau-api-key', + '--mau-client-id', + '--mau-client-secret', + '--mau-host', + ], + cfg: [ + '--card-key', + '--openai-key', + '--sandbox-key', + '--mau-api-key', + '--mau-client-id', + '--mau-client-secret', + '--mau-host', + ], // alias for config countries: ['--json', '--yaml', '--output'], currencies: ['--json', '--yaml', '--output'], deploy: ['--filename', '--env', '--card-key', '--yes'], @@ -96,6 +113,17 @@ export function generateCompletionScript(shell: string): string { f: ['--filename', '--card-key'], // alias for fetch logs: ['--filename', '--card-key'], log: ['--filename', '--card-key'], // alias for logs + mau: [ + '--mau-api-key', + '--mau-client-id', + '--mau-client-secret', + '--mau-host', + '--from', + '--to', + '--json', + '--yaml', + '--output', + ], merchants: ['--json', '--yaml', '--output'], new: ['--template', '--force', '--verbose', '--spinner'], pay: ['--yes'], @@ -236,6 +264,17 @@ _ipb() { fi fi + # Nested: ipb mau [accounts|balances|...] + if [[ "\${cmd}" == "mau" ]]; then + if [[ \${cword} -eq 2 ]]; then + COMPREPLY=($(compgen -W "accounts balances transactions documents statement" -- "\${cur}")) + return 0 + fi + local mau_opts="${commandOptions.mau?.join(' ') || ''} ${globalOptionsList}" + COMPREPLY=($(compgen -W "\${mau_opts}" -- "\${cur}")) + return 0 + fi + # Complete options for specific commands case "\${cmd}" in ${commands diff --git a/src/errors.ts b/src/errors.ts index 002b642..742ddb8 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -44,6 +44,8 @@ export const ERROR_CODES = { COMMAND_DISABLED: 'E4018', /** Client or environment does not support the requested operation (e.g. missing API method). */ UNSUPPORTED_OPERATION: 'E4019', + /** Required date range (--from / --to) missing or invalid for MAU commands. */ + MISSING_DATE_RANGE: 'E4020', // Add more as needed }; diff --git a/src/mock-mau.ts b/src/mock-mau.ts new file mode 100644 index 0000000..32af0bf --- /dev/null +++ b/src/mock-mau.ts @@ -0,0 +1,150 @@ +import type { + AccountBalanceResponse, + AccountDocumentsResponse, + AccountResponse, + AccountTransactionResponse, + AuthResponse, +} from 'investec-mau-api'; + +/** + * Mauritius (MAU) API surface used by the CLI (real client + mock). + */ +export interface IMauApi { + getToken(): Promise; + getAccessToken(): Promise; + getAccounts(): Promise; + getAccountBalances(accountId: number | string): Promise; + getAccountTransactions( + accountId: number | string, + fromDate: string, + toDate: string + ): Promise; + getAccountDocuments( + accountId: number | string, + fromDate: string, + toDate: string + ): Promise; + downloadAccountStatement(accountId: number | string, date: string): Promise; +} + +/** + * In-process mock for Investec Mauritius Open Banking API. + */ +export class MauApi implements IMauApi { + host: string; + clientId: string; + clientSecret: string; + apiKey: string; + + constructor(clientId: string, clientSecret: string, apiKey: string, host?: string) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.apiKey = apiKey; + this.host = host || 'https://openapi.investec.com'; + } + + async getToken(): Promise { + return Promise.resolve('MOCK_MAU_TOKEN'); + } + + async getAccessToken(): Promise { + return Promise.resolve({ + access_token: 'MOCK_MAU_ACCESS_TOKEN_FOR_TESTING', + token_type: 'Bearer', + expires_in: 1799, + scope: 'accounts', + }); + } + + async getAccounts(): Promise { + return Promise.resolve({ + data: { + accounts: [ + { + profileId: 9633, + profileName: 'Mock Profile', + accountId: 5331, + accountName: 'Mock USD Account', + accountNumber: '10101010101', + accountCurrency: 'USD', + }, + ], + }, + links: { self: `${this.host}/mu/pb/v1/accounts` }, + meta: { totalPages: 1 }, + }); + } + + async getAccountBalances(accountId: number | string): Promise { + return Promise.resolve({ + data: { + accountNumber: String(accountId), + accountType: 'CALL DEPOSIT', + accountShortName: 'Mock Account', + balance: 1043966.3, + availableBalance: 635506.65, + encumbrances: 152139.76, + currency: 'USD', + debitInterestRate: 0, + creditInterestRate: 1.5, + creditInterestAccrued: 100.25, + debitInterestAccrued: 0, + overDraftLimit: 0, + }, + links: { self: `${this.host}/mu/pb/v1/accounts/${accountId}/balance` }, + meta: { totalPages: 1 }, + }); + } + + async getAccountTransactions( + accountId: number | string, + fromDate: string, + toDate: string + ): Promise { + return Promise.resolve({ + data: { + transactions: [ + { + description: 'Mock credit', + creditAmount: 100, + debitAmount: 0, + transactionDate: fromDate, + bankReference: 'MOCK-REF-1', + amount: 100, + runningBalance: 1000, + postDate: toDate, + }, + ], + }, + links: { + self: `${this.host}/mu/pb/v1/accounts/${accountId}/transactions?fromDate=${fromDate}&toDate=${toDate}`, + }, + meta: { totalPages: 1 }, + }); + } + + async getAccountDocuments( + accountId: number | string, + fromDate: string, + _toDate: string + ): Promise { + return Promise.resolve({ + availableDocuments: { + accountNumber: String(accountId), + documentInformation: [ + { + documentDate: fromDate, + documentType: 'Statement', + }, + ], + }, + }); + } + + async downloadAccountStatement(_accountId: number | string, _date: string): Promise { + const pdfBytes = new TextEncoder().encode('%PDF-1.4 mock statement'); + return Promise.resolve( + pdfBytes.buffer.slice(pdfBytes.byteOffset, pdfBytes.byteOffset + pdfBytes.byteLength) + ); + } +} diff --git a/src/register-cli-commands.ts b/src/register-cli-commands.ts index 9a99425..6b07cd8 100644 --- a/src/register-cli-commands.ts +++ b/src/register-cli-commands.ts @@ -34,6 +34,13 @@ import { uploadCommand, uploadEnvCommand, } from './cmds/index.js'; +import { + mauAccountsCommand, + mauBalancesCommand, + mauDocumentsCommand, + mauStatementCommand, + mauTransactionsCommand, +} from './cmds/mau/index.js'; import { payCommand } from './cmds/pay.js'; import { simulateCommand } from './cmds/simulate.js'; import { transactionsCommand } from './cmds/transactions.js'; @@ -75,6 +82,28 @@ function addApiCredentialOptions(cmd: Command) { .option('--output ', 'Write JSON/YAML output to file instead of stdout'); } +/** Mauritius (MAU) credential + output options for `ipb mau` commands. */ +function addMauCredentialOptions(cmd: Command) { + return cmd + .option('--mau-api-key ', 'API key for the Investec Mauritius (MAU) API') + .option('--mau-client-id ', 'Client ID for the Investec Mauritius (MAU) API') + .option( + '--mau-client-secret ', + 'Client secret for the Investec Mauritius (MAU) API' + ) + .option('--mau-host ', 'Custom host for the Investec Mauritius (MAU) API') + .option('--credentials-file ', 'Set a custom credentials file') + .option('--profile ', 'Use a configuration profile (e.g., production, staging)') + .option( + '-s,--spinner', + 'disable spinner during command execution (deprecated: use --no-spinner)' + ) + .option('-v,--verbose', 'additional debugging information') + .option('--json', 'Output raw JSON instead of formatted table') + .option('--yaml', 'Output raw YAML instead of formatted table') + .option('--output ', 'Write JSON/YAML/PDF output to file instead of stdout'); +} + /** Spinner + verbose only (for commands that do not use Investec API credential flags). */ function addSpinnerVerboseOptions(cmd: Command) { return cmd @@ -125,6 +154,7 @@ Examples: # Save to default credentials $ ipb config --client-id --client-secret --api-key $ ipb config --card-key + $ ipb config --mau-client-id --mau-client-secret --mau-api-key # Save to a profile $ ipb config --profile production --client-id --client-secret --api-key @@ -152,6 +182,10 @@ Examples: .option('--card-key ', 'Set your card key for the Investec API') .option('--openai-key ', 'Set your OpenAI API key for AI code generation') .option('--sandbox-key ', 'Set your sandbox key for AI generation') + .option('--mau-api-key ', 'Set Mauritius (MAU) API key') + .option('--mau-client-id ', 'Set Mauritius (MAU) client ID') + .option('--mau-client-secret ', 'Set Mauritius (MAU) client secret') + .option('--mau-host ', 'Set Mauritius (MAU) API host') .action(withCommandContext('config', configCommand)); // Profile subcommands @@ -654,6 +688,118 @@ Examples: ` ) ).action(withCommandContext('beneficiaries', beneficiariesCommand)); + + // Mauritius (MAU) Open Banking + const mauCmd = program + .command('mau') + .description( + 'Mauritius (MAU) Open Banking commands. Uses separate MAU credentials (mauClientId, mauClientSecret, mauApiKey).' + ) + .addHelpText( + 'after', + ` +Examples: + $ ipb mau accounts + $ ipb mau balances 5331 + $ ipb mau transactions 5331 --from 2024-01-01 --to 2024-01-31 + $ ipb mau documents 5331 --from 2025-01-01 --to 2025-01-31 + $ ipb mau statement 5331 2025-01-31 -o statement.pdf + $ ipb config --mau-client-id --mau-client-secret --mau-api-key + ` + ); + + addMauCredentialOptions( + mauCmd + .command('accounts') + .description( + 'List Mauritius Investec accounts. Shows account IDs, numbers, currencies, and profile names.' + ) + .addHelpText( + 'after', + ` +Examples: + $ ipb mau accounts + $ ipb mau accounts --json + $ ipb mau accounts --yaml --output mau-accounts.yaml + ` + ) + ).action(withCommandContext('mau accounts', mauAccountsCommand)); + + addMauCredentialOptions( + mauCmd + .command('balances') + .description( + 'Get Mauritius account balance information including available balance and encumbrances.' + ) + .addHelpText( + 'after', + ` +Examples: + $ ipb mau balances 5331 + $ ipb mau balances 5331 --json + ` + ) + ) + .argument('', 'Numeric Mauritius account ID') + .action(withCommandContext('mau balances', mauBalancesCommand)); + + addMauCredentialOptions( + mauCmd + .command('transactions') + .description( + 'Get Mauritius account transactions for a required date range (--from / --to, YYYY-MM-DD).' + ) + .addHelpText( + 'after', + ` +Examples: + $ ipb mau transactions 5331 --from 2024-01-01 --to 2024-01-31 + $ ipb mau transactions 5331 --from 2024-01-01 --to 2024-01-31 --json + ` + ) + ) + .argument('', 'Numeric Mauritius account ID') + .requiredOption('--from ', 'Start date (YYYY-MM-DD)') + .requiredOption('--to ', 'End date (YYYY-MM-DD)') + .action(withCommandContext('mau transactions', mauTransactionsCommand)); + + addMauCredentialOptions( + mauCmd + .command('documents') + .description('List available Mauritius account documents (e.g. statements) for a date range.') + .addHelpText( + 'after', + ` +Examples: + $ ipb mau documents 5331 --from 2025-01-01 --to 2025-01-31 + $ ipb mau documents 5331 --from 2025-01-01 --to 2025-01-31 --json + ` + ) + ) + .argument('', 'Numeric Mauritius account ID') + .requiredOption('--from ', 'Start date (YYYY-MM-DD)') + .requiredOption('--to ', 'End date (YYYY-MM-DD)') + .action(withCommandContext('mau documents', mauDocumentsCommand)); + + addMauCredentialOptions( + mauCmd + .command('statement') + .description( + 'Download a Mauritius account statement PDF for a document date. Defaults to statement--.pdf.' + ) + .addHelpText( + 'after', + ` +Examples: + $ ipb mau statement 5331 2025-01-31 + $ ipb mau statement 5331 2025-01-31 --output ./statement.pdf + ` + ) + ) + .argument('', 'Numeric Mauritius account ID') + .argument('', 'Document date (YYYY-MM-DD)') + .action(withCommandContext('mau statement', mauStatementCommand)); + // AI & Code Generation addSpinnerVerboseOptions( program diff --git a/src/runtime-credentials.ts b/src/runtime-credentials.ts index 49f4fe8..d4ad772 100644 --- a/src/runtime-credentials.ts +++ b/src/runtime-credentials.ts @@ -4,7 +4,7 @@ import { homedir } from 'node:os'; import process from 'node:process'; import chalk from 'chalk'; -import type { BasicOptions, Credentials } from './cmds/types.js'; +import type { BasicOptions, Credentials, MauBasicOptions } from './cmds/types.js'; import { getActiveProfile, loadCredentialsFile, @@ -32,6 +32,8 @@ const cred = readCredentialsFileSync(credentialLocation, (err) => { console.log(''); }); +const defaultHost = 'https://openapi.investec.com'; + /** Default credentials from env and optional ~/.ipb/.credentials.json (module init). */ export const credentials: Credentials = { host: process.env.INVESTEC_HOST || 'https://openapi.investec.com', @@ -41,16 +43,17 @@ export const credentials: Credentials = { cardKey: process.env.INVESTEC_CARD_KEY || cred.cardKey || '', openaiKey: process.env.OPENAI_API_KEY || cred.openaiKey || '', sandboxKey: process.env.SANDBOX_KEY || cred.sandboxKey || '', + mauHost: process.env.INVESTEC_MAU_HOST || cred.mauHost || defaultHost, + mauClientId: process.env.INVESTEC_MAU_CLIENT_ID || cred.mauClientId || '', + mauClientSecret: process.env.INVESTEC_MAU_CLIENT_SECRET || cred.mauClientSecret || '', + mauApiKey: process.env.INVESTEC_MAU_API_KEY || cred.mauApiKey || '', }; /** - * Merges CLI options with credentials, applying profile and option overrides. - * @param options - Basic options that may contain credential overrides - * @param base - Base credentials object (typically module `credentials`) - * @returns Updated credentials with overrides applied + * Resolves profile / credentials-file / active profile onto base credentials. */ -export async function optionCredentials( - options: BasicOptions & { profile?: string }, +async function resolveCredentialSource( + options: { profile?: string; credentialsFile?: string }, base: Credentials ): Promise { let creds = base; @@ -68,6 +71,21 @@ export async function optionCredentials( } } + return creds; +} + +/** + * Merges CLI options with credentials, applying profile and option overrides. + * @param options - Basic options that may contain credential overrides + * @param base - Base credentials object (typically module `credentials`) + * @returns Updated credentials with overrides applied + */ +export async function optionCredentials( + options: BasicOptions & { profile?: string }, + base: Credentials +): Promise { + const creds = await resolveCredentialSource(options, base); + if (options.apiKey) { creds.apiKey = options.apiKey; } @@ -82,3 +100,31 @@ export async function optionCredentials( } return creds; } + +/** + * Merges CLI options with credentials for Mauritius (MAU) API calls. + * Reuses profile / credentials-file resolution; applies MAU-specific overrides only. + * @param options - MAU credential override options + * @param base - Base credentials object (typically module `credentials`) + * @returns Updated credentials with MAU overrides applied + */ +export async function optionMauCredentials( + options: MauBasicOptions, + base: Credentials +): Promise { + const creds = await resolveCredentialSource(options, base); + + if (options.mauApiKey) { + creds.mauApiKey = options.mauApiKey; + } + if (options.mauClientId) { + creds.mauClientId = options.mauClientId; + } + if (options.mauClientSecret) { + creds.mauClientSecret = options.mauClientSecret; + } + if (options.mauHost) { + creds.mauHost = options.mauHost; + } + return creds; +} diff --git a/src/utils.ts b/src/utils.ts index 391307e..668c835 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { initializeApi, initializePbApi, normalizeCardKey } from './utils/api.js'; +import { initializeApi, initializeMauApi, initializePbApi, normalizeCardKey } from './utils/api.js'; import { determineExitCode, handleCliError, withCommandContext } from './utils/cli-errors.js'; import { runListCommand, runReadUploadCommand, runWriteCommand } from './utils/command-runners.js'; import { @@ -20,7 +20,7 @@ import { writeFileAtomic, writeProfile, } from './utils/credentials-store.js'; -import { validateCredentialsFile } from './utils/credentials-validation.js'; +import { validateCredentialsFile, validateMauCredentials } from './utils/credentials-validation.js'; import { formatFileSize, getFileSize } from './utils/file-size.js'; import { checkFilePermissions, @@ -35,7 +35,13 @@ import { logCommandHistory, readCommandHistory, } from './utils/history.js'; -import { validateAccountId, validateAmount } from './utils/input-validation.js'; +import { + validateAccountId, + validateAmount, + validateDateRange, + validateIsoDate, + validateMauAccountId, +} from './utils/input-validation.js'; import { confirmDestructiveOperation, getModuleDirname, @@ -202,7 +208,14 @@ export { export { formatOutput, printTable, runListCommand, runReadUploadCommand, runWriteCommand }; export type { OutputOptions, TableData, TableRow }; export { formatFileSize, getFileSize }; -export { initializeApi, initializePbApi, normalizeCardKey, validateCredentialsFile }; +export { + initializeApi, + initializeMauApi, + initializePbApi, + normalizeCardKey, + validateCredentialsFile, + validateMauCredentials, +}; export { getHistoryFilePath, logCommandHistory, readCommandHistory }; export type { CommandHistoryEntry }; @@ -214,7 +227,13 @@ export { validateFilePathForWrite, }; -export { validateAccountId, validateAmount }; +export { + validateAccountId, + validateAmount, + validateDateRange, + validateIsoDate, + validateMauAccountId, +}; /** * Prompts for user confirmation before executing a destructive operation. diff --git a/src/utils/api.ts b/src/utils/api.ts index e333313..68a3987 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -1,9 +1,10 @@ -import type { BasicOptions, Credentials } from '../cmds/types.js'; +import type { BasicOptions, Credentials, MauBasicOptions } from '../cmds/types.js'; import { CliError, ERROR_CODES } from '../errors.js'; import type { ICardApi } from '../mock-card.js'; +import type { IMauApi } from '../mock-mau.js'; import type { IPbApi } from '../mock-pb.js'; -import { optionCredentials } from '../runtime-credentials.js'; -import { validateCredentialsFile } from './credentials-validation.js'; +import { optionCredentials, optionMauCredentials } from '../runtime-credentials.js'; +import { validateCredentialsFile, validateMauCredentials } from './credentials-validation.js'; import { normalizeInvestecError } from './investec-errors.js'; import { isMockApisEnabled } from './runtime-flags.js'; @@ -117,3 +118,44 @@ export async function initializeApi( return api; } + +/** + * Initializes the Mauritius (MAU) Open Banking API client. + * @param credentials - API credentials (uses mau* fields) + * @param options - MAU credential override options + * @returns Initialized IMauApi instance + */ +export async function initializeMauApi( + credentials: Credentials, + options: MauBasicOptions +): Promise { + const resolvedCredentials = await optionMauCredentials(options, credentials); + validateMauCredentials(resolvedCredentials); + + let api: IMauApi; + if (isMockApisEnabled()) { + const { MauApi } = await import('../mock-mau.js'); + api = new MauApi( + resolvedCredentials.mauClientId, + resolvedCredentials.mauClientSecret, + resolvedCredentials.mauApiKey, + resolvedCredentials.mauHost + ); + } else { + const { InvestecMauApi } = await import('investec-mau-api'); + api = new InvestecMauApi( + resolvedCredentials.mauClientId, + resolvedCredentials.mauClientSecret, + resolvedCredentials.mauApiKey, + resolvedCredentials.mauHost + ); + } + + try { + await api.getAccessToken(); + } catch (error) { + throw normalizeInvestecError(error, 'mau-api-auth'); + } + + return api; +} diff --git a/src/utils/credentials-store.ts b/src/utils/credentials-store.ts index 865e36e..2b1f53f 100644 --- a/src/utils/credentials-store.ts +++ b/src/utils/credentials-store.ts @@ -24,6 +24,10 @@ const defaultCreds = { cardKey: '', openaiKey: '', sandboxKey: '', + mauHost: '', + mauClientId: '', + mauClientSecret: '', + mauApiKey: '', }; /** @@ -106,6 +110,10 @@ export async function loadCredentialsFile(credentials: Credentials, credentialsF 'openaiKey', 'sandboxKey', 'cardKey', + 'mauHost', + 'mauClientId', + 'mauClientSecret', + 'mauApiKey', ]; let loaded: Record = {}; diff --git a/src/utils/credentials-validation.ts b/src/utils/credentials-validation.ts index 3b35a7b..3cfd0d0 100644 --- a/src/utils/credentials-validation.ts +++ b/src/utils/credentials-validation.ts @@ -24,3 +24,24 @@ export function validateCredentialsFile( ); } } + +/** + * Validates that required Mauritius (MAU) credential fields are present. + * @param creds - Credentials object to validate + */ +export function validateMauCredentials(creds: Credentials | Record): void { + const credsRecord = creds as Record; + const requiredFields = ['mauClientId', 'mauClientSecret', 'mauApiKey']; + const missing = requiredFields.filter( + (field) => + !credsRecord[field] || + (typeof credsRecord[field] === 'string' && credsRecord[field].trim() === '') + ); + + if (missing.length > 0) { + throw new CliError( + ERROR_CODES.INVALID_CREDENTIALS, + `Missing required MAU credential fields: ${missing.join(', ')}. Run 'ipb config --mau-client-id --mau-client-secret --mau-api-key ' to set them.` + ); + } +} diff --git a/src/utils/input-validation.ts b/src/utils/input-validation.ts index 23e5fb2..5035387 100644 --- a/src/utils/input-validation.ts +++ b/src/utils/input-validation.ts @@ -56,3 +56,81 @@ export function validateAccountId(accountId: string): void { throw new CliError(ERROR_CODES.INVALID_INPUT, 'Account ID contains invalid characters'); } } + +/** + * Validates a Mauritius (MAU) account ID (numeric). + * @param accountId - Account ID as string + * @returns Parsed numeric account ID + */ +export function validateMauAccountId(accountId: string): string { + if (!accountId || accountId.trim().length === 0) { + throw new CliError(ERROR_CODES.INVALID_INPUT, 'Account ID is required and cannot be empty'); + } + + const trimmedId = accountId.trim(); + if (!/^\d+$/.test(trimmedId)) { + throw new CliError( + ERROR_CODES.INVALID_INPUT, + 'MAU account ID must be a numeric value (e.g. 5331)' + ); + } + + return trimmedId; +} + +/** + * Validates a date string in YYYY-MM-DD format. + * @param date - Date string + * @param label - Field label for error messages (e.g. --from, --to) + */ +export function validateIsoDate(date: string, label: string): string { + const trimmed = date.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + throw new CliError( + ERROR_CODES.MISSING_DATE_RANGE, + `${label} must be a date in YYYY-MM-DD format` + ); + } + + const parsed = new Date(`${trimmed}T00:00:00Z`); + if (Number.isNaN(parsed.getTime())) { + throw new CliError(ERROR_CODES.MISSING_DATE_RANGE, `${label} is not a valid date: ${trimmed}`); + } + + return trimmed; +} + +/** + * Validates a required from/to date range. + * @param fromDate - Start date option + * @param toDate - End date option + */ +export function validateDateRange( + fromDate: string | undefined, + toDate: string | undefined +): { fromDate: string; toDate: string } { + if (!fromDate || fromDate.trim() === '') { + throw new CliError( + ERROR_CODES.MISSING_DATE_RANGE, + 'Start date is required. Provide --from YYYY-MM-DD' + ); + } + if (!toDate || toDate.trim() === '') { + throw new CliError( + ERROR_CODES.MISSING_DATE_RANGE, + 'End date is required. Provide --to YYYY-MM-DD' + ); + } + + const from = validateIsoDate(fromDate, '--from'); + const to = validateIsoDate(toDate, '--to'); + + if (from > to) { + throw new CliError( + ERROR_CODES.MISSING_DATE_RANGE, + `--from (${from}) must be on or before --to (${to})` + ); + } + + return { fromDate: from, toDate: to }; +} diff --git a/src/utils/investec-errors.ts b/src/utils/investec-errors.ts index deb5576..9a68dd7 100644 --- a/src/utils/investec-errors.ts +++ b/src/utils/investec-errors.ts @@ -1,12 +1,20 @@ import { CliError, ERROR_CODES } from '../errors.js'; -type InvestecErrorContext = 'card-api-auth' | 'card-api-request' | 'pb-api-auth' | 'pb-api-request'; +type InvestecErrorContext = + | 'card-api-auth' + | 'card-api-request' + | 'pb-api-auth' + | 'pb-api-request' + | 'mau-api-auth' + | 'mau-api-request'; const INVESTEC_ERROR_CONTEXT_MESSAGES: Record = { 'card-api-auth': 'Failed to authenticate with the Investec Card API', 'card-api-request': 'Investec Card API request failed', 'pb-api-auth': 'Failed to authenticate with the Investec Programmable Banking API', 'pb-api-request': 'Investec Programmable Banking API request failed', + 'mau-api-auth': 'Failed to authenticate with the Investec Mauritius (MAU) API', + 'mau-api-request': 'Investec Mauritius (MAU) API request failed', }; function extractInvestecErrorMessage(error: unknown): string | null { diff --git a/src/utils/security.ts b/src/utils/security.ts index 1141dab..59311ad 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -12,6 +12,8 @@ const SECRET_ENV_VARS = [ 'INVESTEC_CLIENT_SECRET', 'INVESTEC_API_KEY', 'INVESTEC_CARD_KEY', + 'INVESTEC_MAU_CLIENT_SECRET', + 'INVESTEC_MAU_API_KEY', 'OPENAI_API_KEY', 'SANDBOX_KEY', ] as const; diff --git a/test/cmds/config.test.ts b/test/cmds/config.test.ts index 954129c..7e2f3e3 100644 --- a/test/cmds/config.test.ts +++ b/test/cmds/config.test.ts @@ -152,6 +152,27 @@ describe('configCommand', () => { ); }); + it('writes MAU credentials to the default credentials file', async () => { + const { writeCredentialsFile } = await import('../../src/utils.ts'); + await configCommand({ + ...baseOptions(), + mauClientId: 'mau-cid', + mauClientSecret: 'mau-secret', + mauApiKey: 'mau-key', + mauHost: 'https://openapisandbox.investec.com', + }); + + expect(writeCredentialsFile).toHaveBeenCalledWith( + '/tmp/.ipb/.credentials.json', + expect.objectContaining({ + mauClientId: 'mau-cid', + mauClientSecret: 'mau-secret', + mauApiKey: 'mau-key', + mauHost: 'https://openapisandbox.investec.com', + }) + ); + }); + it('prints message when listing profiles and none exist', async () => { mockState.profiles = []; await configCommand({ diff --git a/test/cmds/env-list.test.ts b/test/cmds/env-list.test.ts index 99c4385..33e4ce2 100644 --- a/test/cmds/env-list.test.ts +++ b/test/cmds/env-list.test.ts @@ -36,6 +36,10 @@ describe('envListCommand', () => { expect.objectContaining({ isPiped: false, outputOptions: { json: true, yaml: undefined, output: undefined }, + items: expect.arrayContaining([ + expect.objectContaining({ name: 'INVESTEC_MAU_CLIENT_ID' }), + expect.objectContaining({ name: 'INVESTEC_MAU_API_KEY' }), + ]), }) ); }); diff --git a/test/cmds/mau.test.ts b/test/cmds/mau.test.ts new file mode 100644 index 0000000..1f21ebf --- /dev/null +++ b/test/cmds/mau.test.ts @@ -0,0 +1,188 @@ +/// + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mauAccountsCommand } from '../../src/cmds/mau/accounts'; +import { mauBalancesCommand } from '../../src/cmds/mau/balances'; +import { mauDocumentsCommand } from '../../src/cmds/mau/documents'; +import { mauStatementCommand } from '../../src/cmds/mau/statement'; +import { mauTransactionsCommand } from '../../src/cmds/mau/transactions'; +import { CliError, ERROR_CODES } from '../../src/errors'; + +vi.mock('../../src/runtime-credentials.ts', async () => { + const { getRuntimeCredentialsMock } = await import('../helpers/cli-mocks.js'); + return getRuntimeCredentialsMock(); +}); + +vi.mock('../../src/utils.ts', async () => { + const actual = await vi.importActual('../../src/utils.ts'); + return { + ...actual, + initializeMauApi: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(function () { + return this; + }), + stop: vi.fn(), + })), + formatOutput: vi.fn(), + runListCommand: vi.fn(), + printTable: vi.fn(), + validateFilePathForWrite: vi.fn(async (p: string) => p), + }; +}); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + writeFile: vi.fn(async () => undefined), + }; +}); + +const { initializeMauApi, runListCommand } = await import('../../src/utils.ts'); +const { writeFile } = await import('node:fs/promises'); + +const mockApi = { + getAccounts: vi.fn(), + getAccountBalances: vi.fn(), + getAccountTransactions: vi.fn(), + getAccountDocuments: vi.fn(), + downloadAccountStatement: vi.fn(), +}; + +(initializeMauApi as vi.Mock).mockResolvedValue(mockApi); + +const baseOptions = { + verbose: false, +}; + +describe('mau commands', () => { + let consoleLogSpy: { mockRestore: () => void } | undefined; + + afterEach(() => { + vi.clearAllMocks(); + (initializeMauApi as vi.Mock).mockResolvedValue(mockApi); + if (consoleLogSpy) { + consoleLogSpy.mockRestore(); + consoleLogSpy = undefined; + } + }); + + it('mauAccountsCommand lists accounts', async () => { + const accounts = [ + { + accountId: 5331, + accountNumber: '10101010101', + accountName: 'USD', + accountCurrency: 'USD', + profileId: 1, + profileName: 'Mock', + }, + ]; + mockApi.getAccounts.mockResolvedValue({ data: { accounts } }); + + await mauAccountsCommand(baseOptions); + + expect(initializeMauApi).toHaveBeenCalled(); + expect(runListCommand).toHaveBeenCalledWith( + expect.objectContaining({ + items: accounts, + }) + ); + }); + + it('mauBalancesCommand fetches balances for a numeric account id', async () => { + mockApi.getAccountBalances.mockResolvedValue({ + data: { + accountNumber: '5331', + accountType: 'CALL DEPOSIT', + accountShortName: 'Mock', + balance: 100, + availableBalance: 90, + encumbrances: 10, + currency: 'USD', + debitInterestRate: 0, + creditInterestRate: 1, + creditInterestAccrued: 0, + debitInterestAccrued: 0, + }, + }); + + await mauBalancesCommand('5331', baseOptions); + + expect(mockApi.getAccountBalances).toHaveBeenCalledWith('5331'); + expect(runListCommand).toHaveBeenCalled(); + }); + + it('mauBalancesCommand rejects non-numeric account ids', async () => { + await expect(mauBalancesCommand('acc-123', baseOptions)).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_INPUT, + }); + }); + + it('mauTransactionsCommand requires a date range', async () => { + await expect(mauTransactionsCommand('5331', baseOptions)).rejects.toBeInstanceOf(CliError); + await expect(mauTransactionsCommand('5331', baseOptions)).rejects.toMatchObject({ + code: ERROR_CODES.MISSING_DATE_RANGE, + }); + }); + + it('mauTransactionsCommand fetches transactions', async () => { + const transactions = [ + { + description: 'Credit', + creditAmount: 10, + debitAmount: 0, + transactionDate: '2024-01-02', + bankReference: 'REF', + amount: 10, + runningBalance: 100, + }, + ]; + mockApi.getAccountTransactions.mockResolvedValue({ data: { transactions } }); + + await mauTransactionsCommand('5331', { + ...baseOptions, + from: '2024-01-01', + to: '2024-01-31', + }); + + expect(mockApi.getAccountTransactions).toHaveBeenCalledWith('5331', '2024-01-01', '2024-01-31'); + expect(runListCommand).toHaveBeenCalledWith( + expect.objectContaining({ + items: transactions, + }) + ); + }); + + it('mauDocumentsCommand lists documents', async () => { + mockApi.getAccountDocuments.mockResolvedValue({ + availableDocuments: { + accountNumber: '10101010101', + documentInformation: [{ documentDate: '2025-01-31', documentType: 'Statement' }], + }, + }); + + await mauDocumentsCommand('5331', { + ...baseOptions, + from: '2025-01-01', + to: '2025-01-31', + }); + + expect(mockApi.getAccountDocuments).toHaveBeenCalledWith('5331', '2025-01-01', '2025-01-31'); + expect(runListCommand).toHaveBeenCalled(); + }); + + it('mauStatementCommand downloads a PDF', async () => { + mockApi.downloadAccountStatement.mockResolvedValue(new ArrayBuffer(8)); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await mauStatementCommand('5331', '2025-01-31', { + ...baseOptions, + output: 'statement-5331-2025-01-31.pdf', + }); + + expect(mockApi.downloadAccountStatement).toHaveBeenCalledWith('5331', '2025-01-31'); + expect(writeFile).toHaveBeenCalled(); + }); +}); diff --git a/test/helpers/cli-mocks.ts b/test/helpers/cli-mocks.ts index 180feab..0f0a6f8 100644 --- a/test/helpers/cli-mocks.ts +++ b/test/helpers/cli-mocks.ts @@ -22,6 +22,10 @@ export const testCredentials: Credentials = { cardKey: '123', openaiKey: '', sandboxKey: '', + mauHost: 'https://openapi.investec.com', + mauClientId: 'mau-cid', + mauClientSecret: 'mau-secret', + mauApiKey: 'mau-key', }; export type RuntimeCredentialsMockOptions = { @@ -44,6 +48,7 @@ export function getRuntimeCredentialsMock(options?: RuntimeCredentialsMockOption credentials, printTitleBox: vi.fn(), optionCredentials: vi.fn(async (_o: unknown, c: Credentials) => c), + optionMauCredentials: vi.fn(async (_o: unknown, c: Credentials) => c), }; if (options?.credentialLocation !== undefined) { diff --git a/test/utils/completion.test.ts b/test/utils/completion.test.ts index 94c8375..1953618 100644 --- a/test/utils/completion.test.ts +++ b/test/utils/completion.test.ts @@ -24,4 +24,10 @@ describe('generateCompletionScript', () => { expect.objectContaining({ code: ERROR_CODES.INVALID_INPUT }) ); }); + + it('includes mau command and nested subcommands in bash', () => { + const script = generateCompletionScript('bash'); + expect(script).toContain('mau'); + expect(script).toContain('accounts balances transactions documents statement'); + }); }); diff --git a/test/utils/credentials-store.test.ts b/test/utils/credentials-store.test.ts index 3030f06..a5e609f 100644 --- a/test/utils/credentials-store.test.ts +++ b/test/utils/credentials-store.test.ts @@ -37,6 +37,10 @@ function getBaseCredentials(): Credentials { cardKey: '', openaiKey: '', sandboxKey: '', + mauHost: '', + mauClientId: '', + mauClientSecret: '', + mauApiKey: '', }; } diff --git a/test/utils/mau-validation.test.ts b/test/utils/mau-validation.test.ts new file mode 100644 index 0000000..14d83a3 --- /dev/null +++ b/test/utils/mau-validation.test.ts @@ -0,0 +1,60 @@ +/// + +import { describe, expect, it } from 'vitest'; +import { CliError, ERROR_CODES } from '../../src/errors'; +import { + validateDateRange, + validateIsoDate, + validateMauAccountId, + validateMauCredentials, +} from '../../src/utils'; + +describe('MAU validation helpers', () => { + it('validateMauAccountId accepts numeric ids', () => { + expect(validateMauAccountId('5331')).toBe('5331'); + }); + + it('validateMauAccountId rejects non-numeric ids', () => { + expect(() => validateMauAccountId('acc-1')).toThrow(CliError); + expect(() => validateMauAccountId('acc-1')).toThrow( + expect.objectContaining({ code: ERROR_CODES.INVALID_INPUT }) + ); + }); + + it('validateIsoDate accepts YYYY-MM-DD', () => { + expect(validateIsoDate('2024-01-31', '--from')).toBe('2024-01-31'); + }); + + it('validateDateRange requires from and to', () => { + expect(() => validateDateRange(undefined, '2024-01-31')).toThrow( + expect.objectContaining({ code: ERROR_CODES.MISSING_DATE_RANGE }) + ); + expect(() => validateDateRange('2024-01-01', undefined)).toThrow( + expect.objectContaining({ code: ERROR_CODES.MISSING_DATE_RANGE }) + ); + }); + + it('validateDateRange rejects inverted ranges', () => { + expect(() => validateDateRange('2024-02-01', '2024-01-01')).toThrow( + expect.objectContaining({ code: ERROR_CODES.MISSING_DATE_RANGE }) + ); + }); + + it('validateMauCredentials requires mau trio', () => { + expect(() => + validateMauCredentials({ + mauClientId: '', + mauClientSecret: 's', + mauApiKey: 'k', + }) + ).toThrow(expect.objectContaining({ code: ERROR_CODES.INVALID_CREDENTIALS })); + + expect(() => + validateMauCredentials({ + mauClientId: 'id', + mauClientSecret: 's', + mauApiKey: 'k', + }) + ).not.toThrow(); + }); +});