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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ src/
config.ts - Credential storage (~/.config/lucas/)
api-client.ts - HTTP client with Bearer auth
output.ts - JSON output helpers (success/error)
body-builder.ts - Request body builder with --no-flag unset support
body-builder.ts - Request body builder with --clear-* null support
loan-domain.ts - Loan installment helpers for AI-safe flows
loan-verification.ts - Post-payment verification helpers
number-parser.ts - Strict numeric parsing for commands
subscription-enrichment.ts - Derived subscription fields for AI output
commands/
auth/
login.ts - Device authorization flow
Expand Down Expand Up @@ -43,6 +47,7 @@ src/
create.ts - Create loan (name, principal, account)
update.ts - Update loan by ID
pay.ts - Make a loan payment
mark-paid.ts - Pay the next pending installment
delete.ts - Delete loan by ID
stats/
summary.ts - Financial summary
Expand Down
29 changes: 18 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ lucas transfers update <id> \
--exchange-rate 3.75 # Update a transfer
lucas transfers update <id> \
--amount 1000 \
--no-notes # Update with unset
--clear-notes # Update with unset
lucas transfers delete <id> # Delete transfer
```

Expand All @@ -146,12 +146,17 @@ lucas subscriptions create \
lucas subscriptions update <id> \
--amount 49.90 \
--frequency YEARLY \
--no-account-id # Update subscription (unset account)
--billing-day 30 \
--clear-account-id # Update subscription (unset account)

lucas subscriptions mark-paid <id> # Mark as paid
lucas subscriptions delete <id> # Delete subscription
```

`subscriptions list` also returns AI-friendly derived fields such as
`computedStatus`, `latestChargeStatus`, `lastChargeDate`, and
`lastBillingExplanation`.

Frequencies: `WEEKLY`, `MONTHLY`, `YEARLY`

### Loans
Expand All @@ -172,12 +177,14 @@ lucas loans create \

lucas loans update <id> \
--name "Auto Loan" \
--principal 3200 \
--interest-rate 5.5 # Update a loan
lucas loans update <id> \
--no-agreed-installments \
--no-target-payment # Update with unset
--clear-agreed-installments \
--clear-target-payment # Update with unset

lucas loans pay <id> --amount 750 # Make a payment
lucas loans pay <id> --amount 750 --verified # Make a payment
lucas loans mark-paid <id> --verified # Pay next pending installment
lucas loans delete <id> # Delete loan
```

Expand Down Expand Up @@ -206,14 +213,14 @@ lucas exchange-rate convert \

### Unsetting Optional Fields

Use `--no-<field>` to clear an optional field:
Use `--clear-<field>` to clear an optional field:

```bash
lucas subscriptions update <id> --no-account-id # Remove linked account
lucas transactions update <id> --no-category-id # Clear category
lucas accounts update <id> --no-credit-limit # Remove credit limit
lucas transfers update <id> --no-notes # Clear notes
lucas loans update <id> --no-agreed-installments # Remove agreed installments
lucas subscriptions update <id> --clear-account-id # Remove linked account
lucas transactions update <id> --clear-category-id # Clear category
lucas accounts update <id> --clear-credit-limit # Remove credit limit
lucas transfers update <id> --clear-notes # Clear notes
lucas loans update <id> --clear-agreed-installments # Remove agreed installments
```

## AI Integration
Expand Down
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.2.0",
"version": "0.3.0",
"description": "LucasApp CLI - Financial data management for AI agents",
"author": "StevenACZ",
"license": "MIT",
Expand Down
24 changes: 20 additions & 4 deletions src/commands/accounts/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ export const updateAccountCommand = new Command("update")
.option("--name <name>", "Account name")
.option("--bank <bank>", "Bank name")
.option("--color <color>", "Account color")
.option("--clear-color", "Clear account color")
.option("--icon <icon>", "Account icon")
.option("--clear-icon", "Clear account icon")
.option("--balance <amount>", "Account balance")
.option("--credit-limit <amount>", "Credit limit")
.option("--clear-credit-limit", "Clear credit limit")
.option("--current-debt <amount>", "Current debt")
.option("--clear-current-debt", "Clear current debt")
.option("--statement-closing-day <day>", "Statement closing day")
.option("--clear-statement-closing-day", "Clear statement closing day")
.option("--display-order <n>", "Display order")
.option("--excluded", "Exclude from totals")
.option("--no-excluded", "Include in totals")
Expand All @@ -23,15 +28,26 @@ export const updateAccountCommand = new Command("update")
const body = buildBody(opts, [
{ opt: "name", body: "name" },
{ opt: "bank", body: "bank" },
{ opt: "color", body: "color" },
{ opt: "icon", body: "icon" },
{ opt: "color", body: "color", clearOpt: "clearColor" },
{ opt: "icon", body: "icon", clearOpt: "clearIcon" },
{ opt: "balance", body: "balance", type: "number" },
{ opt: "creditLimit", body: "creditLimit", type: "number" },
{ opt: "currentDebt", body: "currentDebt", type: "number" },
{
opt: "creditLimit",
body: "creditLimit",
type: "number",
clearOpt: "clearCreditLimit",
},
{
opt: "currentDebt",
body: "currentDebt",
type: "number",
clearOpt: "clearCurrentDebt",
},
{
opt: "statementClosingDay",
body: "statementClosingDay",
type: "number",
clearOpt: "clearStatementClosingDay",
},
{ opt: "displayOrder", body: "displayOrder", type: "number" },
{ opt: "excluded", body: "excluded", type: "boolean" },
Expand Down
76 changes: 76 additions & 0 deletions src/commands/loans/mark-paid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Command } from "commander";
import {
findNextPayableInstallment,
getInstallmentRemaining,
type LoanDetailsLike,
} from "../../lib/loan-domain.js";
import { output } from "../../lib/output.js";
import {
executePayLoan,
type PayLoanExecutionResult,
type PayLoanOptions,
} from "./pay.js";
import { apiRequest } from "../../lib/api-client.js";

export interface MarkPaidLoanOptions {
currency?: string;
accountId?: string;
notes?: string;
paidAt?: string;
verified?: boolean;
}

export async function executeMarkPaidLoan(
id: string,
opts: MarkPaidLoanOptions,
): Promise<PayLoanExecutionResult & Record<string, unknown>> {
const loan = await apiRequest<LoanDetailsLike>("GET", `/api/loans/${id}`);
const installment = findNextPayableInstallment(loan);
if (!installment) {
output.error("No pending installment found for this loan", 400, {
loanId: id,
});
}
const payOpts: PayLoanOptions = {
amount: getInstallmentRemaining(installment),
currency: opts.currency,
accountId: opts.accountId,
notes: opts.notes,
paidAt: opts.paidAt,
verified: opts.verified,
};
const result = await executePayLoan(id, payOpts);
return {
...result,
loanId: id,
markedInstallment: {
id: installment.id,
sequence: installment.sequence,
dueDate: installment.dueDate,
remainingAmount: payOpts.amount,
},
};
}

export async function runMarkPaidLoan(id: string, opts: MarkPaidLoanOptions) {
const result = await executeMarkPaidLoan(id, opts);
if (result.verification && !result.verification.verified) {
output.error(
"Server state verification failed after mark-paid",
409,
result,
);
}
output.success(result);
}

export const markPaidLoanCommand = new Command("mark-paid")
.description("Mark the next pending loan installment as paid")
.argument("<id>", "Loan ID")
.option("--currency <code>", "Payment currency")
.option("--account-id <id>", "Account ID")
.option("--notes <notes>", "Payment notes")
.option("--paid-at <date>", "Payment date (YYYY-MM-DD)")
.option("--verified", "Re-read the loan after paying and verify server state")
.addHelpText("after", "\nExample:\n lucas loans mark-paid <id> --verified\n")
.action(runMarkPaidLoan);
105 changes: 100 additions & 5 deletions src/commands/loans/pay.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,108 @@
import { Command } from "commander";
import { apiRequest } from "../../lib/api-client.js";
import {
findNextPayableInstallment,
type LoanDetailsLike,
} from "../../lib/loan-domain.js";
import {
verifyLoanPayment,
type LoanVerificationResult,
} from "../../lib/loan-verification.js";
import {
parseFiniteNumber,
parseOptionalNumber,
} from "../../lib/number-parser.js";
import { output } from "../../lib/output.js";

export interface PayLoanOptions {
amount: number | string;
currency?: string;
loanAmount?: number | string;
exchangeRate?: number | string;
accountId?: string;
notes?: string;
paidAt?: string;
verified?: boolean;
}

export interface PayLoanExecutionResult {
payment: unknown;
loan?: LoanDetailsLike;
verification?: LoanVerificationResult;
}

export function buildPayLoanPayload(opts: PayLoanOptions) {
const body: Record<string, unknown> = {
payAmount: parseFiniteNumber(opts.amount, "--amount"),
};
const loanAmount = parseOptionalNumber(opts.loanAmount, "--loan-amount");
const exchangeRate = parseOptionalNumber(
opts.exchangeRate,
"--exchange-rate",
);
if (opts.currency) body.payCurrency = opts.currency;
if (loanAmount !== undefined) body.loanAmount = loanAmount;
if (exchangeRate !== undefined) body.exchangeRate = exchangeRate;
if (opts.accountId) body.accountId = opts.accountId;
if (opts.notes) body.notes = opts.notes;
if (opts.paidAt) body.paidAt = opts.paidAt;
return body;
}

export async function executePayLoan(
id: string,
opts: PayLoanOptions,
): Promise<PayLoanExecutionResult> {
const body = buildPayLoanPayload(opts);
const beforeLoan = opts.verified
? await apiRequest<LoanDetailsLike>("GET", `/api/loans/${id}`)
: undefined;
const payment = await apiRequest("POST", `/api/loans/${id}/pay`, body);
if (!beforeLoan) return { payment };
const afterLoan = await apiRequest<LoanDetailsLike>(
"GET",
`/api/loans/${id}`,
);
const targetInstallment = findNextPayableInstallment(beforeLoan);
const expectedLoanReduction =
typeof body.loanAmount === "number"
? body.loanAmount
: !body.payCurrency || body.payCurrency === beforeLoan.currency
? (body.payAmount as number)
: undefined;
return {
payment,
loan: afterLoan,
verification: verifyLoanPayment({
beforeLoan,
afterLoan,
expectedLoanReduction,
targetInstallmentId: targetInstallment?.id,
}),
};
}

export async function runPayLoan(id: string, opts: PayLoanOptions) {
const result = await executePayLoan(id, opts);
if (result.verification && !result.verification.verified) {
output.error("Server state verification failed after payment", 409, result);
}
output.success(result.verification ? result : result.payment);
}

export const payLoanCommand = new Command("pay")
.description("Make a loan payment")
.argument("<id>", "Loan ID")
.requiredOption("--amount <amount>", "Payment amount")
.action(async (id: string, opts) => {
const body = { amount: Number(opts.amount) };
const data = await apiRequest("POST", `/api/loans/${id}/pay`, body);
output.success(data);
});
.option("--currency <code>", "Payment currency")
.option("--loan-amount <amount>", "Loan currency amount")
.option("--exchange-rate <rate>", "Exchange rate")
.option("--account-id <id>", "Account ID")
.option("--notes <notes>", "Payment notes")
.option("--paid-at <date>", "Payment date (YYYY-MM-DD)")
.option("--verified", "Re-read the loan after paying and verify server state")
.addHelpText(
"after",
"\nExample:\n lucas loans pay <id> --amount 750 --verified\n",
)
.action(runPayLoan);
Loading
Loading