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", 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/balances.ts b/src/cmds/mau/balances.ts index 35111a0..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 { 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 = result.data; + balance = extractMauBalance(result); }); if (!balance) { diff --git a/src/cmds/mau/documents.ts b/src/cmds/mau/documents.ts index 24e1a41..a6c405c 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,25 @@ export async function mauDocumentsCommand(accountId: string, options: MauDocumen maxRetries: 3, verbose, }); - const accountNumber = result.availableDocuments.accountNumber; - documents = result.availableDocuments.documentInformation.map((doc) => ({ + 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; + 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..3c8c09b 100644 --- a/src/cmds/mau/helpers.ts +++ b/src/cmds/mau/helpers.ts @@ -1,6 +1,163 @@ 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 []; +} + +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 root = result as { data?: unknown }; + const data = root.data; + + if (Array.isArray(data)) { + return data as T[]; + } + + 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 MAU list responses. + * Tries `data[field]`, then top-level `field`. + * @param result - Raw API response + * @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 (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 []; +} + +/** + * Returns true when value looks like a MAU balance record. + */ +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 !== undefined) { + candidates.push(data); + } + + 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/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..757ce0f --- /dev/null +++ b/test/utils/mau-helpers.test.ts @@ -0,0 +1,84 @@ +/// + +import { describe, expect, it } from 'vitest'; +import { + extractMauAccounts, + extractMauBalance, + extractMauDataList, + extractMauRecord, + 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('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); + expect(extractMauDataList({ data: { transactions: { amount: 5 } } }, 'transactions')).toEqual([ + { 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); + }); + + 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); + }); +});