From 906c8913b682c95d57676b63c9c54e378f7714fe Mon Sep 17 00:00:00 2001 From: Steven Coaila Date: Mon, 20 Apr 2026 00:09:55 -0500 Subject: [PATCH] feat(accounts): add debt-detail command, statement-closing-day flag, availableCredit Three AI-facing additions for credit-card debt reporting: - `accounts debt-detail ` wraps the existing backend breakdown endpoint with pro-AI defaults (`--mode=current_cycle --limit=100`) plus passthrough flags for `--anchor-date`, `--start-date`, `--end-date`, `--search`, `--only-pending`, pagination. - `accounts create --statement-closing-day ` reaches parity with `accounts update`. Lets AI create CREDIT cards with the cycle data needed for `debt-detail` to work without falling back to the 30-day synthetic window. The backend returns `creationWarning` on the account when the flag is omitted on a CREDIT type. - `accounts list` now derives `availableCredit = max(0, creditLimit - currentDebt)` client-side for CREDIT accounts with a non-null `creditLimit`. Omitted for other account types. Adds `DEBT_DETAIL_SMOKE.md` with 6 automated E2E scenarios and 2 manual checks (real-data validation + archived-account behavior). .gitignore picks up `*.dump`/`*.sql`/`dev-fixtures/` so no production data can slip into this public repo. Bumps to 0.5.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 5 + CHANGELOG.md | 8 + docs/DEBT_DETAIL_SMOKE.md | 187 ++++++++++++++++++++ package.json | 2 +- src/commands/accounts/create.ts | 76 ++++++-- src/commands/accounts/debt-detail.ts | 78 ++++++++ src/commands/accounts/list.ts | 37 +++- src/index.ts | 2 + src/lib/version.ts | 2 +- tests/commands/accounts-create.test.ts | 73 ++++++++ tests/commands/accounts-debt-detail.test.ts | 63 +++++++ tests/commands/accounts-list.test.ts | 28 +++ 12 files changed, 540 insertions(+), 21 deletions(-) create mode 100644 docs/DEBT_DETAIL_SMOKE.md create mode 100644 src/commands/accounts/debt-detail.ts create mode 100644 tests/commands/accounts-create.test.ts create mode 100644 tests/commands/accounts-debt-detail.test.ts create mode 100644 tests/commands/accounts-list.test.ts diff --git a/.gitignore b/.gitignore index a14702c..5caeb5c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store + +# DB dumps / dev fixtures (should never land in this public repo) +*.dump +*.sql +dev-fixtures/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c99d1d6..1dae858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [0.5.0] - 2026-04-20 + +### Added + +- `lucas accounts debt-detail ` — credit-card debt breakdown per billing cycle (pass-through over `GET /api/accounts/:id/credit-debt-breakdown`). Flags: `--mode`, `--anchor-date`, `--start-date`, `--end-date`, `--search`, `--only-pending`, `--limit`, `--offset`. Default `--mode=current_cycle --limit=100` for AI-friendly single-page responses. +- `lucas accounts create --statement-closing-day ` — parity with `accounts update`. Required for credit cycle computations. Backend returns `creationWarning` on the created account when CREDIT accounts are created without this flag. +- `lucas accounts list` now returns `availableCredit` (`max(0, creditLimit - currentDebt)`) for CREDIT accounts with a non-null `creditLimit`. Field is omitted for all other account types. + ## [0.1.0] - 2026-03-21 ### Added diff --git a/docs/DEBT_DETAIL_SMOKE.md b/docs/DEBT_DETAIL_SMOKE.md new file mode 100644 index 0000000..46e3f81 --- /dev/null +++ b/docs/DEBT_DETAIL_SMOKE.md @@ -0,0 +1,187 @@ +# `lucas accounts debt-detail` — smoke checklist + +Exercise the command against a real backend and assert each of the 8 spec scenarios. + +Prerequisites: + +- Backend dev server running on `http://localhost:3000` (or prod endpoint). +- CLI authenticated — `~/.config/lucas/credentials.json` must exist and not be expired. +- `python3` available for JSON parsing. + +All IDs captured via env vars so the reader can re-run each scenario independently. + +--- + +## Automated scenarios (run against local clone/dev) + +### Scenario 2 — Fresh CREDIT + 40 expenses, zero payments + +```bash +S2_ID=$(lucas accounts create --name "S2" --type CREDIT --bank TestBank \ + --credit-limit 5000 --statement-closing-day 5 \ + | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['id'])") +for i in $(seq 1 40); do + lucas transactions create --account-id $S2_ID --amount 10 --type EXPENSE --description "S2-$i" > /dev/null +done +lucas accounts debt-detail $S2_ID | python3 -c " +import json,sys +s=json.load(sys.stdin)['data']['summary'] +assert s['charges']==400 and s['payments']==0 and s['currentDebt']==400, s +print('S2 OK') +" +``` + +Expected: `S2 OK`. + +### Scenario 3 — 40 expenses + $20 payment + +```bash +S3_DEBIT=$(lucas accounts create --name "S3-debit" --type DEBIT --bank TestBank --balance 1000 \ + | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['id'])") +S3_ID=$(lucas accounts create --name "S3" --type CREDIT --bank TestBank \ + --credit-limit 5000 --statement-closing-day 5 \ + | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['id'])") +for i in $(seq 1 40); do + lucas transactions create --account-id $S3_ID --amount 10 --type EXPENSE --description "S3-$i" > /dev/null +done +lucas transfers create --from-account-id $S3_DEBIT --to-account-id $S3_ID --amount 20 > /dev/null +lucas accounts debt-detail $S3_ID | python3 -c " +import json,sys +s=json.load(sys.stdin)['data']['summary'] +assert s['charges']==400 and s['payments']==20 and s['net']==380, s +print('S3 OK') +" +``` + +Expected: `S3 OK`. Note: the $20 payment appears as a separate item row; individual charges are NOT marked partially paid. + +### Scenario 4 — Carry-over from prior cycle + +```bash +S4_DEBIT=$(lucas accounts create --name "S4-debit" --type DEBIT --bank TestBank --balance 500 \ + | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['id'])") +S4_ID=$(lucas accounts create --name "S4" --type CREDIT --bank TestBank \ + --credit-limit 5000 --statement-closing-day 5 \ + | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['id'])") +for i in $(seq 1 5); do + lucas transactions create --account-id $S4_ID --amount 50 --type EXPENSE --description "S4-pre-$i" --date 2026-04-01 > /dev/null +done +for i in $(seq 1 5); do + lucas transactions create --account-id $S4_ID --amount 30 --type EXPENSE --description "S4-post-$i" --date 2026-04-10 > /dev/null +done +lucas transfers create --from-account-id $S4_DEBIT --to-account-id $S4_ID --amount 50 --date 2026-04-10 > /dev/null +lucas accounts debt-detail $S4_ID | python3 -c " +import json,sys +s=json.load(sys.stdin)['data']['summary'] +inv = abs(s['currentDebt'] - (s['composedDebt'] + s['outsideRangeDebt'])) +assert s['outsideRangeDebt'] > 0, 'expected carry-over' +assert inv < 0.01, f'invariant broken: {inv}' +print('S4 OK') +" +``` + +Expected: `S4 OK`. Invariant: `currentDebt == composedDebt + outsideRangeDebt` (±0.01). + +### Scenario 5 — CREDIT without `statementClosingDay` + +```bash +S5_RESP=$(lucas accounts create --name "S5" --type CREDIT --bank TestBank --credit-limit 5000) +echo "$S5_RESP" | python3 -c " +import json,sys +d = json.load(sys.stdin)['data'] +assert 'creationWarning' in d, 'expected creationWarning' +assert 'statementClosingDay' in d['creationWarning'] +print('S5 creationWarning OK') +" +S5_ID=$(echo "$S5_RESP" | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['id'])") + +# Default mode should fail with 400 +set +e +lucas accounts debt-detail $S5_ID > /tmp/s5_default.out 2>&1 +EXIT=$? +set -e +[ $EXIT -eq 1 ] || { echo "S5 default should fail"; exit 1; } +grep -q "día de cierre es requerido" /tmp/s5_default.out || { echo "S5 wrong error"; exit 1; } + +# Custom mode should succeed +lucas accounts debt-detail $S5_ID --mode custom | python3 -c " +import json,sys +d=json.load(sys.stdin) +assert d['ok']==True and d['data']['mode']=='custom' +print('S5 custom OK') +" +``` + +### Scenario 6 — DEBIT rejected + +```bash +S6_ID=$(lucas accounts create --name "S6" --type DEBIT --bank TestBank --balance 100 \ + | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['id'])") +set +e +lucas accounts debt-detail $S6_ID > /tmp/s6.out 2>&1 +EXIT=$? +set -e +[ $EXIT -eq 1 ] || { echo "S6 should fail"; exit 1; } +grep -q "Solo disponible para cuentas de crédito" /tmp/s6.out || { echo "S6 wrong error"; exit 1; } +echo "S6 OK" +``` + +### Scenario 8 — `availableCredit` in list + +```bash +lucas accounts list | python3 -c " +import json,sys +accounts = json.load(sys.stdin)['data']['accounts'] +for a in accounts: + if a['type']=='CREDIT' and a.get('creditLimit') is not None: + assert 'availableCredit' in a, f'missing for {a[\"name\"]}' + expected = max(0, round(a['creditLimit'] - a['currentDebt'], 2)) + assert abs(a['availableCredit'] - expected) < 0.01 + else: + assert 'availableCredit' not in a, f'unexpected on {a[\"name\"]}' +print('S8 OK') +" +``` + +### Cleanup after automated scenarios + +```bash +for id in $S2_ID $S3_ID $S3_DEBIT $S4_ID $S4_DEBIT $S5_ID $S6_ID; do + lucas accounts delete $id || true +done +``` + +--- + +## Manual scenarios (prod data required) + +### Scenario 1 — Real account "iO Dólares", current cycle + +Find the real credit card ID from `lucas accounts list`, then: + +```bash +IO_ID= +CD_FROM_LIST=$(lucas accounts list | python3 -c " +import json,sys +for a in json.load(sys.stdin)['data']['accounts']: + if a['id']=='$IO_ID': + print(a['currentDebt']); break +") +CD_FROM_DETAIL=$(lucas accounts debt-detail $IO_ID | python3 -c " +import json,sys +print(json.load(sys.stdin)['data']['summary']['currentDebt']) +") +python3 -c " +assert abs(float('$CD_FROM_LIST') - float('$CD_FROM_DETAIL')) < 0.01, \ + f'mismatch: {\"$CD_FROM_LIST\"} vs {\"$CD_FROM_DETAIL\"}' +print('S1 OK') +" +``` + +Invariant: `summary.composedDebt + summary.outsideRangeDebt ≈ summary.currentDebt` (±0.01). + +### Scenario 7 — Archived CREDIT + +Observed behavior (captured on 2026-04-19): the breakdown endpoint does **not** filter archived accounts — `lucas accounts debt-detail ` still returns the breakdown. This matches the underlying `findFirst({ id, userId })` in `accounts.credit-debt-breakdown.ts:43-51`, which has no `isArchived` filter. The CLI inherits this behavior by pass-through. + +If the AI caller needs archived cards hidden, filter client-side or file a backend spec. This is **not** a change in scope for this spec. diff --git a/package.json b/package.json index 786ecdf..86f952d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lucasapp-cli", - "version": "0.4.0", + "version": "0.5.0", "description": "LucasApp CLI - Financial data management for AI agents", "author": "StevenACZ", "license": "MIT", diff --git a/src/commands/accounts/create.ts b/src/commands/accounts/create.ts index f950af5..54cff04 100644 --- a/src/commands/accounts/create.ts +++ b/src/commands/accounts/create.ts @@ -1,6 +1,60 @@ import { Command } from "commander"; import { apiRequest } from "../../lib/api-client.js"; import { output } from "../../lib/output.js"; +import { parseFiniteNumber } from "../../lib/number-parser.js"; + +export interface CreateAccountOptions { + name: string; + type: string; + bank: string; + currency?: string; + balance?: string; + creditLimit?: string; + statementClosingDay?: string; + color?: string; + icon?: string; +} + +export function buildCreateAccountBody( + opts: CreateAccountOptions, +): Record { + const body: Record = { + name: opts.name, + type: opts.type, + bank: opts.bank, + currency: opts.currency ?? "PEN", + }; + if (opts.balance !== undefined) + body.balance = parseFiniteNumber(opts.balance, "--balance"); + if (opts.creditLimit !== undefined) + body.creditLimit = parseFiniteNumber(opts.creditLimit, "--credit-limit"); + if (opts.color) body.color = opts.color; + if (opts.icon) body.icon = opts.icon; + if (opts.type === "CREDIT" && opts.statementClosingDay !== undefined) { + const day = parseFiniteNumber( + opts.statementClosingDay, + "--statement-closing-day", + ); + if (!Number.isInteger(day) || day < 1 || day > 31) { + throw new Error( + "--statement-closing-day must be an integer between 1 and 31", + ); + } + body.statementClosingDay = day; + } + return body; +} + +export async function runCreateAccount(opts: CreateAccountOptions) { + let body: Record; + try { + body = buildCreateAccountBody(opts); + } catch (e) { + output.error(e instanceof Error ? e.message : String(e), 400); + } + const data = await apiRequest("POST", "/api/accounts", body!); + output.success(data); +} export const createAccountCommand = new Command("create") .description("Create a new account") @@ -12,21 +66,11 @@ export const createAccountCommand = new Command("create") .requiredOption("--bank ", "Bank name") .option("--currency ", "Currency code", "PEN") .option("--balance ", "Initial balance") - .option("--credit-limit ", "Credit limit") + .option("--credit-limit ", "Credit limit (required for CREDIT)") + .option( + "--statement-closing-day ", + "Statement closing day (1..31, CREDIT only)", + ) .option("--color ", "Account color") .option("--icon ", "Account icon") - .action(async (opts) => { - const body: Record = { - name: opts.name, - type: opts.type, - bank: opts.bank, - currency: opts.currency, - }; - if (opts.balance) body.balance = Number(opts.balance); - if (opts.creditLimit) body.creditLimit = Number(opts.creditLimit); - if (opts.color) body.color = opts.color; - if (opts.icon) body.icon = opts.icon; - - const data = await apiRequest("POST", "/api/accounts", body); - output.success(data); - }); + .action(runCreateAccount); diff --git a/src/commands/accounts/debt-detail.ts b/src/commands/accounts/debt-detail.ts new file mode 100644 index 0000000..1839f50 --- /dev/null +++ b/src/commands/accounts/debt-detail.ts @@ -0,0 +1,78 @@ +import { Command } from "commander"; +import { apiRequest } from "../../lib/api-client.js"; +import { output } from "../../lib/output.js"; + +export interface DebtDetailOptions { + mode?: string; + anchorDate?: string; + startDate?: string; + endDate?: string; + search?: string; + onlyPending?: boolean; + limit?: string; + offset?: string; +} + +export function buildDebtDetailParams( + opts: DebtDetailOptions, +): Record { + const params: Record = { + mode: opts.mode ?? "current_cycle", + limit: opts.limit ?? "100", + offset: opts.offset ?? "0", + }; + if (opts.anchorDate) params.anchorDate = opts.anchorDate; + if (opts.startDate) params.startDate = opts.startDate; + if (opts.endDate) params.endDate = opts.endDate; + if (opts.search) params.searchText = opts.search; + if (opts.onlyPending) params.onlyPending = "true"; + return params; +} + +export async function runDebtDetail(id: string, opts: DebtDetailOptions) { + const params = buildDebtDetailParams(opts); + const data = await apiRequest( + "GET", + `/api/accounts/${id}/credit-debt-breakdown`, + undefined, + params, + ); + output.success(data); +} + +export const debtDetailCommand = new Command("debt-detail") + .description( + "Get credit card debt breakdown for a billing cycle (AI-friendly pass-through of /api/accounts/:id/credit-debt-breakdown)", + ) + .argument("", "Credit account ID") + .option( + "--mode ", + "Cycle mode: current_cycle | last_statement | custom", + "current_cycle", + ) + .option("--anchor-date ", "Anchor date (YYYY-MM-DD), defaults to today") + .option("--start-date ", "Custom mode start date (YYYY-MM-DD)") + .option("--end-date ", "Custom mode end date (YYYY-MM-DD)") + .option("--search ", "Filter by description/merchant/notes") + .option("--only-pending", "Only unpaid items") + .option("--limit ", "Items per page (1..100)", "100") + .option("--offset ", "Pagination offset", "0") + .addHelpText( + "after", + ` +Notes: + - Payments are returned as separate rows; individual charges are NOT + marked partially paid (the model does not allocate payments to specific charges). + - Modes current_cycle and last_statement require the account to have + a statementClosingDay set. Without one, use --mode custom. + - Archived accounts: behavior is the same as the underlying endpoint + (no extra filtering added by the CLI). + +Examples: + lucas accounts debt-detail acc_123 + lucas accounts debt-detail acc_123 --mode last_statement + lucas accounts debt-detail acc_123 --mode custom --start-date 2026-04-01 --end-date 2026-04-15 + lucas accounts debt-detail acc_123 --only-pending --search uber +`, + ) + .action(runDebtDetail); diff --git a/src/commands/accounts/list.ts b/src/commands/accounts/list.ts index f8deb16..4226f82 100644 --- a/src/commands/accounts/list.ts +++ b/src/commands/accounts/list.ts @@ -2,12 +2,43 @@ import { Command } from "commander"; import { apiRequest } from "../../lib/api-client.js"; import { output } from "../../lib/output.js"; +interface AccountLike { + type?: string; + creditLimit?: number | null; + currentDebt?: number | null; + [key: string]: unknown; +} + +interface AccountsSummaryLike { + accounts?: AccountLike[]; + [key: string]: unknown; +} + +export function withAvailableCredit(data: T): T { + if (!data || !Array.isArray(data.accounts)) return data; + const accounts = data.accounts.map((acc) => { + if (acc.type === "CREDIT" && acc.creditLimit != null) { + const limit = Number(acc.creditLimit); + const debt = Number(acc.currentDebt ?? 0); + const available = Math.max(0, Math.round((limit - debt) * 100) / 100); + return { ...acc, availableCredit: available }; + } + return acc; + }); + return { ...data, accounts }; +} + export const listAccountsCommand = new Command("list") - .description("List all accounts") + .description("List all accounts (CREDIT accounts include availableCredit)") .option("--include-archived", "Include archived accounts") .action(async (opts) => { const params: Record = {}; if (opts.includeArchived) params.includeArchived = "true"; - const data = await apiRequest("GET", "/api/accounts", undefined, params); - output.success(data); + const data = await apiRequest( + "GET", + "/api/accounts", + undefined, + params, + ); + output.success(withAvailableCredit(data)); }); diff --git a/src/index.ts b/src/index.ts index 1b73a38..bb26370 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { listAccountsCommand } from "./commands/accounts/list.js"; import { createAccountCommand } from "./commands/accounts/create.js"; import { updateAccountCommand } from "./commands/accounts/update.js"; import { deleteAccountCommand } from "./commands/accounts/delete.js"; +import { debtDetailCommand } from "./commands/accounts/debt-detail.js"; // Transactions import { listTransactionsCommand } from "./commands/transactions/list.js"; @@ -74,6 +75,7 @@ accounts.addCommand(listAccountsCommand); accounts.addCommand(createAccountCommand); accounts.addCommand(updateAccountCommand); accounts.addCommand(deleteAccountCommand); +accounts.addCommand(debtDetailCommand); // Grupo: transactions const transactions = program diff --git a/src/lib/version.ts b/src/lib/version.ts index 7f56e32..5f5ab24 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1 +1 @@ -export const CLI_VERSION = "0.4.0"; +export const CLI_VERSION = "0.5.0"; diff --git a/tests/commands/accounts-create.test.ts b/tests/commands/accounts-create.test.ts new file mode 100644 index 0000000..88651ab --- /dev/null +++ b/tests/commands/accounts-create.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiRequest = vi.fn(); + +vi.mock("../../src/lib/api-client.js", () => ({ + apiRequest, +})); + +const { buildCreateAccountBody } = + await import("../../src/commands/accounts/create.js"); + +describe("accounts create", () => { + beforeEach(() => { + apiRequest.mockReset(); + }); + + it("buildCreateAccountBody includes statementClosingDay for CREDIT", () => { + expect( + buildCreateAccountBody({ + name: "Visa", + type: "CREDIT", + bank: "BCP", + currency: "PEN", + creditLimit: "5000", + statementClosingDay: "5", + }), + ).toMatchObject({ + name: "Visa", + type: "CREDIT", + bank: "BCP", + currency: "PEN", + creditLimit: 5000, + statementClosingDay: 5, + }); + }); + + it("buildCreateAccountBody omits statementClosingDay for non-CREDIT", () => { + const body = buildCreateAccountBody({ + name: "Savings", + type: "DEBIT", + bank: "BCP", + currency: "PEN", + statementClosingDay: "5", + }); + expect(body).not.toHaveProperty("statementClosingDay"); + }); + + it("buildCreateAccountBody rejects non-integer statementClosingDay", () => { + expect(() => + buildCreateAccountBody({ + name: "Visa", + type: "CREDIT", + bank: "BCP", + currency: "PEN", + creditLimit: "5000", + statementClosingDay: "bad", + }), + ).toThrow(); + }); + + it("buildCreateAccountBody rejects statementClosingDay out of range", () => { + expect(() => + buildCreateAccountBody({ + name: "Visa", + type: "CREDIT", + bank: "BCP", + currency: "PEN", + creditLimit: "5000", + statementClosingDay: "32", + }), + ).toThrow(); + }); +}); diff --git a/tests/commands/accounts-debt-detail.test.ts b/tests/commands/accounts-debt-detail.test.ts new file mode 100644 index 0000000..7780c42 --- /dev/null +++ b/tests/commands/accounts-debt-detail.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiRequest = vi.fn(); + +vi.mock("../../src/lib/api-client.js", () => ({ + apiRequest, +})); + +const { buildDebtDetailParams, runDebtDetail } = + await import("../../src/commands/accounts/debt-detail.js"); + +describe("accounts debt-detail", () => { + beforeEach(() => { + apiRequest.mockReset(); + }); + + it("buildDebtDetailParams applies AI-friendly defaults", () => { + expect(buildDebtDetailParams({})).toEqual({ + mode: "current_cycle", + limit: "100", + offset: "0", + }); + }); + + it("buildDebtDetailParams forwards all explicit flags", () => { + expect( + buildDebtDetailParams({ + mode: "custom", + anchorDate: "2026-04-19", + startDate: "2026-04-01", + endDate: "2026-04-15", + search: "uber", + onlyPending: true, + limit: "50", + offset: "10", + }), + ).toEqual({ + mode: "custom", + anchorDate: "2026-04-19", + startDate: "2026-04-01", + endDate: "2026-04-15", + searchText: "uber", + onlyPending: "true", + limit: "50", + offset: "10", + }); + }); + + it("runDebtDetail calls the breakdown endpoint with id and params", async () => { + apiRequest.mockResolvedValue({ summary: { currentDebt: 186.64 } }); + await runDebtDetail("acc_1", { mode: "current_cycle" }); + expect(apiRequest).toHaveBeenCalledWith( + "GET", + "/api/accounts/acc_1/credit-debt-breakdown", + undefined, + expect.objectContaining({ + mode: "current_cycle", + limit: "100", + offset: "0", + }), + ); + }); +}); diff --git a/tests/commands/accounts-list.test.ts b/tests/commands/accounts-list.test.ts new file mode 100644 index 0000000..e0f5e95 --- /dev/null +++ b/tests/commands/accounts-list.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { withAvailableCredit } from "../../src/commands/accounts/list.js"; + +describe("accounts list availableCredit", () => { + it("adds availableCredit for CREDIT accounts with non-null creditLimit", () => { + const input = { + accounts: [ + { id: "a", type: "CREDIT", creditLimit: 5000, currentDebt: 186.64 }, + { id: "b", type: "CREDIT", creditLimit: 1000, currentDebt: 1200 }, + { id: "c", type: "CREDIT", creditLimit: null, currentDebt: 0 }, + { id: "d", type: "DEBIT", balance: 500 }, + ], + totalBalance: 0, + }; + const result = withAvailableCredit(input as never) as { + accounts: Array>; + }; + expect(result.accounts[0]).toMatchObject({ availableCredit: 4813.36 }); + expect(result.accounts[1]).toMatchObject({ availableCredit: 0 }); + expect(result.accounts[2]).not.toHaveProperty("availableCredit"); + expect(result.accounts[3]).not.toHaveProperty("availableCredit"); + }); + + it("passes through input when accounts array is missing", () => { + const input = { totalBalance: 0 }; + expect(withAvailableCredit(input as never)).toBe(input); + }); +});