From bb8adb6455ff568e185cf40ba56607d869fadaa1 Mon Sep 17 00:00:00 2001 From: Devin Date: Wed, 5 Aug 2026 13:59:59 +0200 Subject: [PATCH 1/4] fix: normalize MAU list responses before table rendering Singleton account/transaction/document objects were treated as lists, causing rows.map is not a function on mau accounts. Co-authored-by: Cursor --- src/cmds/mau/accounts.ts | 3 +- src/cmds/mau/documents.ts | 11 ++++-- src/cmds/mau/helpers.ts | 61 ++++++++++++++++++++++++++++++++++ src/cmds/mau/transactions.ts | 4 +-- src/utils/command-runners.ts | 2 +- test/cmds/mau.test.ts | 20 +++++++++++ test/utils/mau-helpers.test.ts | 51 ++++++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 test/utils/mau-helpers.test.ts diff --git a/src/cmds/mau/accounts.ts b/src/cmds/mau/accounts.ts index 90bb561..8fbce2e 100644 --- a/src/cmds/mau/accounts.ts +++ b/src/cmds/mau/accounts.ts @@ -9,6 +9,7 @@ import { withSpinner, } from '../../utils.js'; import type { MauOptions } from '../types.js'; +import { extractMauAccounts } from './helpers.js'; /** * List Mauritius (MAU) Investec accounts. @@ -45,7 +46,7 @@ export async function mauAccountsCommand(options: MauOptions) { maxRetries: 3, verbose, }); - accounts = result.data.accounts; + accounts = extractMauAccounts(result); }); await runListCommand({ diff --git a/src/cmds/mau/documents.ts b/src/cmds/mau/documents.ts index 24e1a41..cbae9a6 100644 --- a/src/cmds/mau/documents.ts +++ b/src/cmds/mau/documents.ts @@ -11,7 +11,7 @@ import { withSpinner, } from '../../utils.js'; import type { MauOptions } from '../types.js'; -import { resolveMauAccountId } from './helpers.js'; +import { normalizeToArray, resolveMauAccountId } from './helpers.js'; interface MauDocumentsOptions extends MauOptions { from?: string; @@ -51,8 +51,13 @@ export async function mauDocumentsCommand(accountId: string, options: MauDocumen maxRetries: 3, verbose, }); - const accountNumber = result.availableDocuments.accountNumber; - documents = result.availableDocuments.documentInformation.map((doc) => ({ + const available = result.availableDocuments; + const accountNumber = available?.accountNumber ?? ''; + const documentInformation = normalizeToArray<{ + documentDate: string; + documentType: string; + }>(available?.documentInformation); + documents = documentInformation.map((doc) => ({ documentDate: doc.documentDate, documentType: doc.documentType, accountNumber, diff --git a/src/cmds/mau/helpers.ts b/src/cmds/mau/helpers.ts index 17af4b9..c813b98 100644 --- a/src/cmds/mau/helpers.ts +++ b/src/cmds/mau/helpers.ts @@ -1,6 +1,67 @@ import { CliError, ERROR_CODES } from '../../errors.js'; import { readStdin } from '../../utils.js'; +/** + * Coerces API list payloads into a real array. + * Handles singleton objects (common when APIs serialize one-item lists as objects). + * @param value - Candidate list value from an API response + */ +export function normalizeToArray(value: unknown): T[] { + if (Array.isArray(value)) { + return value as T[]; + } + if (value && typeof value === 'object') { + const record = value as Record; + // XML-style wrappers: { account: [...] } / { account: {...} } + for (const key of ['account', 'transaction', 'documentInformation', 'document']) { + if (key in record) { + return normalizeToArray(record[key]); + } + } + return [value as T]; + } + return []; +} + +/** + * Extracts the accounts list from a Mauritius getAccounts response. + * @param result - Raw getAccounts response + */ +export function extractMauAccounts(result: unknown): T[] { + if (!result || typeof result !== 'object') { + return []; + } + const data = (result as { data?: unknown }).data; + if (Array.isArray(data)) { + return data as T[]; + } + if (data && typeof data === 'object') { + const accounts = + (data as { accounts?: unknown; Accounts?: unknown }).accounts ?? + (data as { Accounts?: unknown }).Accounts; + if (accounts !== undefined) { + return normalizeToArray(accounts); + } + } + return []; +} + +/** + * Extracts a named list field from `{ data: { [field]: ... } }` responses. + * @param result - Raw API response + * @param field - Field name under `data` (e.g. transactions) + */ +export function extractMauDataList(result: unknown, field: string): T[] { + if (!result || typeof result !== 'object') { + return []; + } + const data = (result as { data?: unknown }).data; + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return Array.isArray(data) ? (data as T[]) : []; + } + return normalizeToArray((data as Record)[field]); +} + /** * Resolves a MAU account ID from an argument or stdin JSON/plain text. * @param accountId - Optional account ID argument diff --git a/src/cmds/mau/transactions.ts b/src/cmds/mau/transactions.ts index 41a142e..4aa8f05 100644 --- a/src/cmds/mau/transactions.ts +++ b/src/cmds/mau/transactions.ts @@ -11,7 +11,7 @@ import { withSpinner, } from '../../utils.js'; import type { MauOptions } from '../types.js'; -import { resolveMauAccountId } from './helpers.js'; +import { extractMauDataList, resolveMauAccountId } from './helpers.js'; interface MauTransactionsOptions extends MauOptions { from?: string; @@ -56,7 +56,7 @@ export async function mauTransactionsCommand(accountId: string, options: MauTran maxRetries: 3, verbose, }); - transactions = result.data.transactions; + transactions = extractMauDataList(result, 'transactions'); }); await runListCommand({ diff --git a/src/utils/command-runners.ts b/src/utils/command-runners.ts index 0096584..b80472c 100644 --- a/src/utils/command-runners.ts +++ b/src/utils/command-runners.ts @@ -40,7 +40,7 @@ export async function runListCommand( ): Promise { const { isPiped, items, outputOptions, emptyMessage, countMessage, mapSimple } = options; - if (!items || items.length === 0) { + if (!Array.isArray(items) || items.length === 0) { if (!isPiped) { console.log(emptyMessage); } else { diff --git a/test/cmds/mau.test.ts b/test/cmds/mau.test.ts index 1f21ebf..dff101b 100644 --- a/test/cmds/mau.test.ts +++ b/test/cmds/mau.test.ts @@ -91,6 +91,26 @@ describe('mau commands', () => { ); }); + it('mauAccountsCommand normalizes a singleton accounts object', async () => { + const account = { + accountId: 5331, + accountNumber: '10101010101', + accountName: 'USD', + accountCurrency: 'USD', + profileId: 1, + profileName: 'Mock', + }; + mockApi.getAccounts.mockResolvedValue({ data: { accounts: account } }); + + await mauAccountsCommand(baseOptions); + + expect(runListCommand).toHaveBeenCalledWith( + expect.objectContaining({ + items: [account], + }) + ); + }); + it('mauBalancesCommand fetches balances for a numeric account id', async () => { mockApi.getAccountBalances.mockResolvedValue({ data: { diff --git a/test/utils/mau-helpers.test.ts b/test/utils/mau-helpers.test.ts new file mode 100644 index 0000000..af54b31 --- /dev/null +++ b/test/utils/mau-helpers.test.ts @@ -0,0 +1,51 @@ +/// + +import { describe, expect, it } from 'vitest'; +import { + extractMauAccounts, + extractMauDataList, + normalizeToArray, +} from '../../src/cmds/mau/helpers'; + +describe('MAU response normalization', () => { + it('normalizeToArray keeps arrays', () => { + expect(normalizeToArray([{ a: 1 }])).toEqual([{ a: 1 }]); + }); + + it('normalizeToArray wraps singleton objects', () => { + expect(normalizeToArray({ accountId: 5331, accountNumber: '1' })).toEqual([ + { accountId: 5331, accountNumber: '1' }, + ]); + }); + + it('normalizeToArray unwraps XML-style account wrappers', () => { + expect(normalizeToArray({ account: { accountId: 1 } })).toEqual([{ accountId: 1 }]); + expect(normalizeToArray({ account: [{ accountId: 1 }, { accountId: 2 }] })).toEqual([ + { accountId: 1 }, + { accountId: 2 }, + ]); + }); + + it('extractMauAccounts reads data.accounts arrays', () => { + const accounts = [{ accountId: 1 }, { accountId: 2 }]; + expect(extractMauAccounts({ data: { accounts } })).toEqual(accounts); + }); + + it('extractMauAccounts wraps a singleton accounts object', () => { + const account = { accountId: 5331, accountNumber: '1010', accountCurrency: 'USD' }; + expect(extractMauAccounts({ data: { accounts: account } })).toEqual([account]); + }); + + it('extractMauAccounts accepts data as a bare array', () => { + const accounts = [{ accountId: 1 }]; + expect(extractMauAccounts({ data: accounts })).toEqual(accounts); + }); + + it('extractMauDataList reads nested list fields', () => { + const transactions = [{ amount: 10 }]; + expect(extractMauDataList({ data: { transactions } }, 'transactions')).toEqual(transactions); + expect(extractMauDataList({ data: { transactions: { amount: 5 } } }, 'transactions')).toEqual([ + { amount: 5 }, + ]); + }); +}); From b0ee3e0389d2f1d9254d1e91993a6ddd75be38ef Mon Sep 17 00:00:00 2001 From: Devin Date: Wed, 5 Aug 2026 14:07:20 +0200 Subject: [PATCH 2/4] fix: accept MAU responses without a data wrapper Live Mauritius payloads can put accounts/transactions at the top level instead of under data; normalize both shapes. Co-authored-by: Cursor --- src/cmds/mau/balances.ts | 4 +- src/cmds/mau/documents.ts | 14 +++++- src/cmds/mau/helpers.ts | 82 ++++++++++++++++++++++++++++------ test/utils/mau-helpers.test.ts | 19 ++++++++ 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/src/cmds/mau/balances.ts b/src/cmds/mau/balances.ts index 35111a0..fe99443 100644 --- a/src/cmds/mau/balances.ts +++ b/src/cmds/mau/balances.ts @@ -10,7 +10,7 @@ import { withSpinner, } from '../../utils.js'; import type { MauOptions } from '../types.js'; -import { resolveMauAccountId } from './helpers.js'; +import { extractMauRecord, resolveMauAccountId } from './helpers.js'; /** * Fetch and display Mauritius (MAU) account balances. @@ -53,7 +53,7 @@ export async function mauBalancesCommand(accountId: string, options: MauOptions) maxRetries: 3, verbose, }); - balance = result.data; + balance = extractMauRecord(result); }); if (!balance) { diff --git a/src/cmds/mau/documents.ts b/src/cmds/mau/documents.ts index cbae9a6..a6c405c 100644 --- a/src/cmds/mau/documents.ts +++ b/src/cmds/mau/documents.ts @@ -51,7 +51,19 @@ export async function mauDocumentsCommand(accountId: string, options: MauDocumen maxRetries: 3, verbose, }); - const available = result.availableDocuments; + const root = result as { + availableDocuments?: { + accountNumber?: string; + documentInformation?: unknown; + }; + data?: { + availableDocuments?: { + accountNumber?: string; + documentInformation?: unknown; + }; + }; + }; + const available = root.availableDocuments ?? root.data?.availableDocuments; const accountNumber = available?.accountNumber ?? ''; const documentInformation = normalizeToArray<{ documentDate: string; diff --git a/src/cmds/mau/helpers.ts b/src/cmds/mau/helpers.ts index c813b98..af78c74 100644 --- a/src/cmds/mau/helpers.ts +++ b/src/cmds/mau/helpers.ts @@ -23,43 +23,99 @@ export function normalizeToArray(value: unknown): T[] { return []; } +function readNamedField(source: unknown, field: string): unknown { + if (!source || typeof source !== 'object' || Array.isArray(source)) { + return undefined; + } + const record = source as Record; + if (field in record) { + return record[field]; + } + const capitalized = field.charAt(0).toUpperCase() + field.slice(1); + if (capitalized in record) { + return record[capitalized]; + } + return undefined; +} + /** * Extracts the accounts list from a Mauritius getAccounts response. + * Supports `{ data: { accounts } }`, top-level `{ accounts }`, and singleton objects. * @param result - Raw getAccounts response */ export function extractMauAccounts(result: unknown): T[] { if (!result || typeof result !== 'object') { return []; } - const data = (result as { data?: unknown }).data; + const root = result as { data?: unknown }; + const data = root.data; + if (Array.isArray(data)) { return data as T[]; } - if (data && typeof data === 'object') { - const accounts = - (data as { accounts?: unknown; Accounts?: unknown }).accounts ?? - (data as { Accounts?: unknown }).Accounts; - if (accounts !== undefined) { - return normalizeToArray(accounts); - } + + const nestedAccounts = readNamedField(data, 'accounts'); + if (nestedAccounts !== undefined) { + return normalizeToArray(nestedAccounts); + } + + const topLevelAccounts = readNamedField(result, 'accounts'); + if (topLevelAccounts !== undefined) { + return normalizeToArray(topLevelAccounts); } + return []; } /** - * Extracts a named list field from `{ data: { [field]: ... } }` responses. + * Extracts a named list field from MAU list responses. + * Tries `data[field]`, then top-level `field`. * @param result - Raw API response - * @param field - Field name under `data` (e.g. transactions) + * @param field - Field name (e.g. transactions) */ export function extractMauDataList(result: unknown, field: string): T[] { if (!result || typeof result !== 'object') { return []; } const data = (result as { data?: unknown }).data; - if (!data || typeof data !== 'object' || Array.isArray(data)) { - return Array.isArray(data) ? (data as T[]) : []; + if (Array.isArray(data)) { + return data as T[]; + } + + const nested = readNamedField(data, field); + if (nested !== undefined) { + return normalizeToArray(nested); + } + + const topLevel = readNamedField(result, field); + if (topLevel !== undefined) { + return normalizeToArray(topLevel); + } + + return []; +} + +/** + * Extracts a single MAU record (e.g. balance) from `{ data: {...} }` or a bare object. + * @param result - Raw API response + */ +export function extractMauRecord(result: unknown): T | undefined { + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return undefined; + } + const data = (result as { data?: unknown }).data; + if (data && typeof data === 'object' && !Array.isArray(data)) { + // Prefer nested data when it looks like the record (not a list wrapper) + const record = data as Record; + if (!('accounts' in record) && !('transactions' in record)) { + return data as T; + } + } + const root = result as Record; + if ('accountNumber' in root || 'balance' in root || 'availableBalance' in root) { + return result as T; } - return normalizeToArray((data as Record)[field]); + return undefined; } /** diff --git a/test/utils/mau-helpers.test.ts b/test/utils/mau-helpers.test.ts index af54b31..c677612 100644 --- a/test/utils/mau-helpers.test.ts +++ b/test/utils/mau-helpers.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { extractMauAccounts, extractMauDataList, + extractMauRecord, normalizeToArray, } from '../../src/cmds/mau/helpers'; @@ -41,6 +42,12 @@ describe('MAU response normalization', () => { expect(extractMauAccounts({ data: accounts })).toEqual(accounts); }); + it('extractMauAccounts reads top-level accounts without a data wrapper', () => { + const accounts = [{ accountId: 1 }, { accountId: 2 }]; + expect(extractMauAccounts({ accounts })).toEqual(accounts); + expect(extractMauAccounts({ accounts: { accountId: 9 } })).toEqual([{ accountId: 9 }]); + }); + it('extractMauDataList reads nested list fields', () => { const transactions = [{ amount: 10 }]; expect(extractMauDataList({ data: { transactions } }, 'transactions')).toEqual(transactions); @@ -48,4 +55,16 @@ describe('MAU response normalization', () => { { amount: 5 }, ]); }); + + it('extractMauDataList reads top-level list fields', () => { + expect(extractMauDataList({ transactions: [{ amount: 1 }] }, 'transactions')).toEqual([ + { amount: 1 }, + ]); + }); + + it('extractMauRecord reads data or bare balance objects', () => { + const balance = { accountNumber: '1', balance: 10, availableBalance: 9 }; + expect(extractMauRecord({ data: balance })).toEqual(balance); + expect(extractMauRecord(balance)).toEqual(balance); + }); }); From 59cd68897b5c0140c3fe62e34cefdaf8e15afa6c Mon Sep 17 00:00:00 2001 From: Devin Date: Wed, 5 Aug 2026 14:12:57 +0200 Subject: [PATCH 3/4] fix: unwrap MAU balances from accounts.balance nesting Live balance responses nest the record under accounts.balance; expose the inner balance object for table/JSON output. Co-authored-by: Cursor --- src/cmds/mau/balances.ts | 4 +-- src/cmds/mau/helpers.ts | 66 +++++++++++++++++++++++++++------- test/utils/mau-helpers.test.ts | 14 ++++++++ 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/cmds/mau/balances.ts b/src/cmds/mau/balances.ts index fe99443..337b8d9 100644 --- a/src/cmds/mau/balances.ts +++ b/src/cmds/mau/balances.ts @@ -10,7 +10,7 @@ import { withSpinner, } from '../../utils.js'; import type { MauOptions } from '../types.js'; -import { extractMauRecord, resolveMauAccountId } from './helpers.js'; +import { extractMauBalance, resolveMauAccountId } from './helpers.js'; /** * Fetch and display Mauritius (MAU) account balances. @@ -53,7 +53,7 @@ export async function mauBalancesCommand(accountId: string, options: MauOptions) maxRetries: 3, verbose, }); - balance = extractMauRecord(result); + balance = extractMauBalance(result); }); if (!balance) { diff --git a/src/cmds/mau/helpers.ts b/src/cmds/mau/helpers.ts index af78c74..3c8c09b 100644 --- a/src/cmds/mau/helpers.ts +++ b/src/cmds/mau/helpers.ts @@ -96,28 +96,68 @@ export function extractMauDataList(result: unknown, field: str } /** - * Extracts a single MAU record (e.g. balance) from `{ data: {...} }` or a bare object. - * @param result - Raw API response + * Returns true when value looks like a MAU balance record. */ -export function extractMauRecord(result: unknown): T | undefined { - if (!result || typeof result !== 'object' || Array.isArray(result)) { +function looksLikeBalanceRecord(value: unknown): value is Record { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + ('accountNumber' in value || 'availableBalance' in value || 'accountShortName' in value) + ); +} + +/** + * Extracts a MAU balance record from documented and live response shapes. + * Live Mauritius balance payloads often look like `{ accounts: { balance: {...} } }` + * (with or without a `data` wrapper) instead of `{ data: { accountNumber, ... } }`. + * @param result - Raw getAccountBalances response + */ +export function extractMauBalance(result: unknown): T | undefined { + if (!result || typeof result !== 'object') { return undefined; } + + const candidates: unknown[] = [result]; const data = (result as { data?: unknown }).data; - if (data && typeof data === 'object' && !Array.isArray(data)) { - // Prefer nested data when it looks like the record (not a list wrapper) - const record = data as Record; - if (!('accounts' in record) && !('transactions' in record)) { - return data as T; - } + if (data !== undefined) { + candidates.push(data); } - const root = result as Record; - if ('accountNumber' in root || 'balance' in root || 'availableBalance' in root) { - return result as T; + + for (const candidate of candidates) { + if (looksLikeBalanceRecord(candidate)) { + return candidate as T; + } + + const accounts = readNamedField(candidate, 'accounts'); + const nestedBalance = readNamedField(accounts, 'balance'); + if (looksLikeBalanceRecord(nestedBalance)) { + return nestedBalance as T; + } + + // accounts may itself be the balance object in some payloads + if (looksLikeBalanceRecord(accounts)) { + return accounts as T; + } + + const directBalance = readNamedField(candidate, 'balance'); + if (looksLikeBalanceRecord(directBalance)) { + return directBalance as T; + } } + return undefined; } +/** + * Extracts a single MAU record (generic) from `{ data: {...} }` or a bare object. + * Prefer {@link extractMauBalance} for balance endpoints. + * @param result - Raw API response + */ +export function extractMauRecord(result: unknown): T | undefined { + return extractMauBalance(result); +} + /** * Resolves a MAU account ID from an argument or stdin JSON/plain text. * @param accountId - Optional account ID argument diff --git a/test/utils/mau-helpers.test.ts b/test/utils/mau-helpers.test.ts index c677612..757ce0f 100644 --- a/test/utils/mau-helpers.test.ts +++ b/test/utils/mau-helpers.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { extractMauAccounts, + extractMauBalance, extractMauDataList, extractMauRecord, normalizeToArray, @@ -67,4 +68,17 @@ describe('MAU response normalization', () => { expect(extractMauRecord({ data: balance })).toEqual(balance); expect(extractMauRecord(balance)).toEqual(balance); }); + + it('extractMauBalance unwraps accounts.balance nesting', () => { + const balance = { + accountNumber: '10101010101', + accountType: 'CALL DEPOSIT', + availableBalance: 100, + balance: 200, + currency: 'USD', + }; + expect(extractMauBalance({ accounts: { balance } })).toEqual(balance); + expect(extractMauBalance({ data: { accounts: { balance } } })).toEqual(balance); + expect(extractMauBalance({ data: { balance } })).toEqual(balance); + }); }); From 367fdbcdf61cd1ac3b68fa95ef76174695d3c441 Mon Sep 17 00:00:00 2001 From: Devin Date: Wed, 5 Aug 2026 14:14:47 +0200 Subject: [PATCH 4/4] chore: bump to 0.10.1 and investec-mau-api 0.1.1 Co-authored-by: Cursor --- package-lock.json | 12 ++++++------ package.json | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0189bc4..42a7e4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "investec-ipb", - "version": "0.10.0", + "version": "0.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "investec-ipb", - "version": "0.10.0", + "version": "0.10.1", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.9.0", @@ -15,7 +15,7 @@ "commander": "^14.0.2", "dotenv": "^17.2.3", "investec-card-api": "^0.2.3", - "investec-mau-api": "^0.1.0", + "investec-mau-api": "^0.1.1", "investec-pb-api": "^0.3.12", "js-yaml": "^4.3.1", "node-fetch": "^3.3.2", @@ -3562,9 +3562,9 @@ } }, "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==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/investec-mau-api/-/investec-mau-api-0.1.1.tgz", + "integrity": "sha512-7BmF97EtXmGfCjHfLp6y1RU9KeQnnZ69FmnwM/5ch2K9rhZG8J0ISnT1204aUGuAM3Wj7MB55q1qFC1FeaPT2g==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index c033cd3..caff4b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "investec-ipb", - "version": "0.10.0", + "version": "0.10.1", "main": "bin/index.js", "bin": { "ipb": "./bin/index.js" @@ -66,7 +66,7 @@ "commander": "^14.0.2", "dotenv": "^17.2.3", "investec-card-api": "^0.2.3", - "investec-mau-api": "^0.1.0", + "investec-mau-api": "^0.1.1", "investec-pb-api": "^0.3.12", "js-yaml": "^4.3.1", "node-fetch": "^3.3.2",