Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "investec-ipb",
"version": "0.10.0",
"version": "0.10.1",
"main": "bin/index.js",
"bin": {
"ipb": "./bin/index.js"
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/cmds/mau/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -45,7 +46,7 @@ export async function mauAccountsCommand(options: MauOptions) {
maxRetries: 3,
verbose,
});
accounts = result.data.accounts;
accounts = extractMauAccounts(result);
});

await runListCommand({
Expand Down
4 changes: 2 additions & 2 deletions src/cmds/mau/balances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -53,7 +53,7 @@ export async function mauBalancesCommand(accountId: string, options: MauOptions)
maxRetries: 3,
verbose,
});
balance = result.data;
balance = extractMauBalance(result);
});

if (!balance) {
Expand Down
23 changes: 20 additions & 3 deletions src/cmds/mau/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
157 changes: 157 additions & 0 deletions src/cmds/mau/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(value: unknown): T[] {
if (Array.isArray(value)) {
return value as T[];
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
// XML-style wrappers: { account: [...] } / { account: {...} }
for (const key of ['account', 'transaction', 'documentInformation', 'document']) {
if (key in record) {
return normalizeToArray<T>(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<string, unknown>;
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<T extends object>(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<T>(nestedAccounts);
}

const topLevelAccounts = readNamedField(result, 'accounts');
if (topLevelAccounts !== undefined) {
return normalizeToArray<T>(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<T extends object>(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<T>(nested);
}

const topLevel = readNamedField(result, field);
if (topLevel !== undefined) {
return normalizeToArray<T>(topLevel);
}

return [];
}

/**
* Returns true when value looks like a MAU balance record.
*/
function looksLikeBalanceRecord(value: unknown): value is Record<string, unknown> {
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<T extends object>(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<T extends object>(result: unknown): T | undefined {
return extractMauBalance<T>(result);
}

/**
* Resolves a MAU account ID from an argument or stdin JSON/plain text.
* @param accountId - Optional account ID argument
Expand Down
4 changes: 2 additions & 2 deletions src/cmds/mau/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion src/utils/command-runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function runListCommand<TFull, TSimple = TFull>(
): Promise<void> {
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 {
Expand Down
20 changes: 20 additions & 0 deletions test/cmds/mau.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading