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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` — 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 <n>` — 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
Expand Down
187 changes: 187 additions & 0 deletions docs/DEBT_DETAIL_SMOKE.md
Original file line number Diff line number Diff line change
@@ -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=<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 <archived-id>` 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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
76 changes: 60 additions & 16 deletions src/commands/accounts/create.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const body: Record<string, unknown> = {
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<string, unknown>;
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")
Expand All @@ -12,21 +66,11 @@ export const createAccountCommand = new Command("create")
.requiredOption("--bank <bank>", "Bank name")
.option("--currency <currency>", "Currency code", "PEN")
.option("--balance <balance>", "Initial balance")
.option("--credit-limit <limit>", "Credit limit")
.option("--credit-limit <limit>", "Credit limit (required for CREDIT)")
.option(
"--statement-closing-day <day>",
"Statement closing day (1..31, CREDIT only)",
)
.option("--color <color>", "Account color")
.option("--icon <icon>", "Account icon")
.action(async (opts) => {
const body: Record<string, unknown> = {
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);
78 changes: 78 additions & 0 deletions src/commands/accounts/debt-detail.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const params: Record<string, string> = {
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("<id>", "Credit account ID")
.option(
"--mode <mode>",
"Cycle mode: current_cycle | last_statement | custom",
"current_cycle",
)
.option("--anchor-date <date>", "Anchor date (YYYY-MM-DD), defaults to today")
.option("--start-date <date>", "Custom mode start date (YYYY-MM-DD)")
.option("--end-date <date>", "Custom mode end date (YYYY-MM-DD)")
.option("--search <text>", "Filter by description/merchant/notes")
.option("--only-pending", "Only unpaid items")
.option("--limit <n>", "Items per page (1..100)", "100")
.option("--offset <n>", "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);
Loading