diff --git a/CLAUDE.md b/CLAUDE.md index c486be2..10b866d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/README.md b/README.md index c8d7066..e793e5e 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ lucas transfers update \ --exchange-rate 3.75 # Update a transfer lucas transfers update \ --amount 1000 \ - --no-notes # Update with unset + --clear-notes # Update with unset lucas transfers delete # Delete transfer ``` @@ -146,12 +146,17 @@ lucas subscriptions create \ lucas subscriptions update \ --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 # Mark as paid lucas subscriptions delete # Delete subscription ``` +`subscriptions list` also returns AI-friendly derived fields such as +`computedStatus`, `latestChargeStatus`, `lastChargeDate`, and +`lastBillingExplanation`. + Frequencies: `WEEKLY`, `MONTHLY`, `YEARLY` ### Loans @@ -172,12 +177,14 @@ lucas loans create \ lucas loans update \ --name "Auto Loan" \ + --principal 3200 \ --interest-rate 5.5 # Update a loan lucas loans update \ - --no-agreed-installments \ - --no-target-payment # Update with unset + --clear-agreed-installments \ + --clear-target-payment # Update with unset -lucas loans pay --amount 750 # Make a payment +lucas loans pay --amount 750 --verified # Make a payment +lucas loans mark-paid --verified # Pay next pending installment lucas loans delete # Delete loan ``` @@ -206,14 +213,14 @@ lucas exchange-rate convert \ ### Unsetting Optional Fields -Use `--no-` to clear an optional field: +Use `--clear-` to clear an optional field: ```bash -lucas subscriptions update --no-account-id # Remove linked account -lucas transactions update --no-category-id # Clear category -lucas accounts update --no-credit-limit # Remove credit limit -lucas transfers update --no-notes # Clear notes -lucas loans update --no-agreed-installments # Remove agreed installments +lucas subscriptions update --clear-account-id # Remove linked account +lucas transactions update --clear-category-id # Clear category +lucas accounts update --clear-credit-limit # Remove credit limit +lucas transfers update --clear-notes # Clear notes +lucas loans update --clear-agreed-installments # Remove agreed installments ``` ## AI Integration diff --git a/package.json b/package.json index 6b17ed4..9275296 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/accounts/update.ts b/src/commands/accounts/update.ts index ea6ab70..6c8f2f3 100644 --- a/src/commands/accounts/update.ts +++ b/src/commands/accounts/update.ts @@ -9,11 +9,16 @@ export const updateAccountCommand = new Command("update") .option("--name ", "Account name") .option("--bank ", "Bank name") .option("--color ", "Account color") + .option("--clear-color", "Clear account color") .option("--icon ", "Account icon") + .option("--clear-icon", "Clear account icon") .option("--balance ", "Account balance") .option("--credit-limit ", "Credit limit") + .option("--clear-credit-limit", "Clear credit limit") .option("--current-debt ", "Current debt") + .option("--clear-current-debt", "Clear current debt") .option("--statement-closing-day ", "Statement closing day") + .option("--clear-statement-closing-day", "Clear statement closing day") .option("--display-order ", "Display order") .option("--excluded", "Exclude from totals") .option("--no-excluded", "Include in totals") @@ -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" }, diff --git a/src/commands/loans/mark-paid.ts b/src/commands/loans/mark-paid.ts new file mode 100644 index 0000000..b2d0b64 --- /dev/null +++ b/src/commands/loans/mark-paid.ts @@ -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> { + const loan = await apiRequest("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("", "Loan ID") + .option("--currency ", "Payment currency") + .option("--account-id ", "Account ID") + .option("--notes ", "Payment notes") + .option("--paid-at ", "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 --verified\n") + .action(runMarkPaidLoan); diff --git a/src/commands/loans/pay.ts b/src/commands/loans/pay.ts index d5e7d30..d819bbb 100644 --- a/src/commands/loans/pay.ts +++ b/src/commands/loans/pay.ts @@ -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 = { + 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 { + const body = buildPayLoanPayload(opts); + const beforeLoan = opts.verified + ? await apiRequest("GET", `/api/loans/${id}`) + : undefined; + const payment = await apiRequest("POST", `/api/loans/${id}/pay`, body); + if (!beforeLoan) return { payment }; + const afterLoan = await apiRequest( + "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("", "Loan ID") .requiredOption("--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 ", "Payment currency") + .option("--loan-amount ", "Loan currency amount") + .option("--exchange-rate ", "Exchange rate") + .option("--account-id ", "Account ID") + .option("--notes ", "Payment notes") + .option("--paid-at ", "Payment date (YYYY-MM-DD)") + .option("--verified", "Re-read the loan after paying and verify server state") + .addHelpText( + "after", + "\nExample:\n lucas loans pay --amount 750 --verified\n", + ) + .action(runPayLoan); diff --git a/src/commands/loans/update.ts b/src/commands/loans/update.ts index a440338..b20cc29 100644 --- a/src/commands/loans/update.ts +++ b/src/commands/loans/update.ts @@ -7,7 +7,9 @@ export const updateLoanCommand = new Command("update") .description("Update a loan") .argument("", "Loan ID") .option("--name ", "Loan name") + .option("--principal ", "Principal amount") .option("--account-id ", "Payment account ID") + .option("--clear-account-id", "Clear payment account ID") .option("--is-primary", "Set as primary") .option("--no-is-primary", "Unset primary") .option("--is-archived", "Archive loan") @@ -16,30 +18,59 @@ export const updateLoanCommand = new Command("update") .option("--interval-unit ", "Interval unit (DAY|WEEK|MONTH|YEAR)") .option("--interval-count ", "Interval count") .option("--agreed-installments ", "Total installments") + .option("--clear-agreed-installments", "Clear agreed installments") .option("--target-payment ", "Target payment amount") + .option("--clear-target-payment", "Clear target payment") .option("--interest-rate ", "Interest rate") + .option("--clear-interest-rate", "Clear interest rate") .option("--interest-rate-unit ", "Rate unit (ANNUAL|MONTHLY)") .option("--interest-enabled", "Enable interest") .option("--no-interest-enabled", "Disable interest") .option("--late-fee-amount ", "Late fee amount") + .option("--clear-late-fee-amount", "Clear late fee amount") .option("--late-fee-grace-days ", "Late fee grace days") .option("--late-fee-enabled", "Enable late fees") .option("--no-late-fee-enabled", "Disable late fees") .action(async (id, opts) => { const body = buildBody(opts, [ { opt: "name", body: "name" }, - { opt: "accountId", body: "paymentAccountId" }, + { opt: "principal", body: "principal", type: "number" }, + { + opt: "accountId", + body: "paymentAccountId", + clearOpt: "clearAccountId", + }, { opt: "isPrimary", body: "isPrimary", type: "boolean" }, { opt: "isArchived", body: "isArchived", type: "boolean" }, { opt: "firstDueDate", body: "firstDueDate" }, { opt: "intervalUnit", body: "intervalUnit" }, { opt: "intervalCount", body: "intervalCount", type: "number" }, - { opt: "agreedInstallments", body: "agreedInstallments", type: "number" }, - { opt: "targetPayment", body: "targetPayment", type: "number" }, - { opt: "interestRate", body: "interestRate", type: "number" }, + { + opt: "agreedInstallments", + body: "agreedInstallments", + type: "number", + clearOpt: "clearAgreedInstallments", + }, + { + opt: "targetPayment", + body: "targetPayment", + type: "number", + clearOpt: "clearTargetPayment", + }, + { + opt: "interestRate", + body: "interestRate", + type: "number", + clearOpt: "clearInterestRate", + }, { opt: "interestRateUnit", body: "interestRateUnit" }, { opt: "interestEnabled", body: "interestEnabled", type: "boolean" }, - { opt: "lateFeeAmount", body: "lateFeeAmount", type: "number" }, + { + opt: "lateFeeAmount", + body: "lateFeeAmount", + type: "number", + clearOpt: "clearLateFeeAmount", + }, { opt: "lateFeeGraceDays", body: "lateFeeGraceDays", type: "number" }, { opt: "lateFeeEnabled", body: "lateFeeEnabled", type: "boolean" }, ]); diff --git a/src/commands/subscriptions/list.ts b/src/commands/subscriptions/list.ts index 354e4ec..c698363 100644 --- a/src/commands/subscriptions/list.ts +++ b/src/commands/subscriptions/list.ts @@ -1,10 +1,18 @@ import { Command } from "commander"; import { apiRequest } from "../../lib/api-client.js"; import { output } from "../../lib/output.js"; +import { + enrichSubscriptionsWithCharges, + type SubscriptionChargeLike, + type SubscriptionLike, +} from "../../lib/subscription-enrichment.js"; export const listSubscriptionsCommand = new Command("list") - .description("List all subscriptions") + .description("List subscriptions with derived billing context for AI agents") .action(async () => { - const data = await apiRequest("GET", "/api/subscriptions"); - output.success(data); + const [subscriptions, charges] = await Promise.all([ + apiRequest("GET", "/api/subscriptions"), + apiRequest("GET", "/api/subscription-charges"), + ]); + output.success(enrichSubscriptionsWithCharges(subscriptions, charges)); }); diff --git a/src/commands/subscriptions/update.ts b/src/commands/subscriptions/update.ts index d71090a..7ec52bc 100644 --- a/src/commands/subscriptions/update.ts +++ b/src/commands/subscriptions/update.ts @@ -10,34 +10,48 @@ export const updateSubscriptionCommand = new Command("update") .option("--amount ", "Amount") .option("--frequency ", "Frequency (MONTHLY|YEARLY)") .option("--description ", "Description") + .option("--clear-description", "Clear description") .option("--currency ", "Currency code") .option("--account-id ", "Account ID") + .option("--clear-account-id", "Clear account ID") .option("--billing-day ", "Billing day of the month") .option("--billing-month ", "Billing month (for YEARLY)") .option("--icon ", "Icon") + .option("--clear-icon", "Clear icon") .option("--color ", "Color") + .option("--clear-color", "Clear color") .option("--category-id ", "Category ID") + .option("--clear-category-id", "Clear category ID") .option("--type ", "Type") .option("--start-date ", "Start date (YYYY-MM-DD)") + .option("--clear-start-date", "Clear start date") .option("--auto-record", "Enable auto-record") .option("--no-auto-record", "Disable auto-record") .option("--is-active", "Activate subscription") .option("--no-is-active", "Deactivate subscription") + .addHelpText( + "after", + "\nExample:\n lucas subscriptions update --billing-day 30\n", + ) .action(async (id: string, opts) => { const body = buildBody(opts, [ { opt: "name", body: "name" }, { opt: "amount", body: "amount", type: "number" }, { opt: "frequency", body: "frequency" }, - { opt: "description", body: "description" }, + { + opt: "description", + body: "description", + clearOpt: "clearDescription", + }, { opt: "currency", body: "currency" }, - { opt: "accountId", body: "accountId" }, + { opt: "accountId", body: "accountId", clearOpt: "clearAccountId" }, { opt: "billingDay", body: "billingDay", type: "number" }, { opt: "billingMonth", body: "billingMonth", type: "number" }, - { opt: "icon", body: "icon" }, - { opt: "color", body: "color" }, - { opt: "categoryId", body: "categoryId" }, + { opt: "icon", body: "icon", clearOpt: "clearIcon" }, + { opt: "color", body: "color", clearOpt: "clearColor" }, + { opt: "categoryId", body: "categoryId", clearOpt: "clearCategoryId" }, { opt: "type", body: "type" }, - { opt: "startDate", body: "startDate" }, + { opt: "startDate", body: "startDate", clearOpt: "clearStartDate" }, { opt: "autoRecord", body: "autoRecord", type: "boolean" }, { opt: "isActive", body: "isActive", type: "boolean" }, ]); diff --git a/src/commands/transactions/update.ts b/src/commands/transactions/update.ts index c4196e6..2dbff32 100644 --- a/src/commands/transactions/update.ts +++ b/src/commands/transactions/update.ts @@ -11,17 +11,20 @@ export const updateTransactionCommand = new Command("update") .option("--type ", "Type (INCOME|EXPENSE)") .option("--date ", "Date (YYYY-MM-DD)") .option("--category-id ", "Category ID") + .option("--clear-category-id", "Clear category") .option("--notes ", "Additional notes") + .option("--clear-notes", "Clear notes") .option("--merchant ", "Merchant name") + .option("--clear-merchant", "Clear merchant") .action(async (id: string, opts) => { const body = buildBody(opts, [ { opt: "description", body: "description" }, { opt: "amount", body: "amount", type: "number" }, { opt: "type", body: "type" }, { opt: "date", body: "date" }, - { opt: "categoryId", body: "categoryId" }, - { opt: "notes", body: "notes" }, - { opt: "merchant", body: "merchant" }, + { opt: "categoryId", body: "categoryId", clearOpt: "clearCategoryId" }, + { opt: "notes", body: "notes", clearOpt: "clearNotes" }, + { opt: "merchant", body: "merchant", clearOpt: "clearMerchant" }, ]); const data = await apiRequest("PUT", `/api/transactions/${id}`, body); output.success(data); diff --git a/src/commands/transfers/update.ts b/src/commands/transfers/update.ts index 1a6bf25..7506b69 100644 --- a/src/commands/transfers/update.ts +++ b/src/commands/transfers/update.ts @@ -8,18 +8,36 @@ export const updateTransferCommand = new Command("update") .argument("", "Transfer ID") .requiredOption("--amount ", "Transfer amount") .option("--to-amount ", "Destination amount (cross-currency)") + .option("--clear-to-amount", "Clear destination amount") .option("--description ", "Description") + .option("--clear-description", "Clear description") .option("--date ", "Date (YYYY-MM-DD)") .option("--notes ", "Notes") + .option("--clear-notes", "Clear notes") .option("--exchange-rate ", "Exchange rate") + .option("--clear-exchange-rate", "Clear exchange rate") .action(async (id, opts) => { const body = buildBody(opts, [ { opt: "amount", body: "amount", type: "number" }, - { opt: "toAmount", body: "toAmount", type: "number" }, - { opt: "description", body: "description" }, + { + opt: "toAmount", + body: "toAmount", + type: "number", + clearOpt: "clearToAmount", + }, + { + opt: "description", + body: "description", + clearOpt: "clearDescription", + }, { opt: "date", body: "date" }, - { opt: "notes", body: "notes" }, - { opt: "exchangeRate", body: "exchangeRate", type: "number" }, + { opt: "notes", body: "notes", clearOpt: "clearNotes" }, + { + opt: "exchangeRate", + body: "exchangeRate", + type: "number", + clearOpt: "clearExchangeRate", + }, ]); const data = await apiRequest("PUT", `/api/transfers/${id}`, body); output.success(data); diff --git a/src/index.ts b/src/index.ts index cdb525b..9b86005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ import { listLoansCommand } from "./commands/loans/list.js"; import { createLoanCommand } from "./commands/loans/create.js"; import { updateLoanCommand } from "./commands/loans/update.js"; import { payLoanCommand } from "./commands/loans/pay.js"; +import { markPaidLoanCommand } from "./commands/loans/mark-paid.js"; import { deleteLoanCommand } from "./commands/loans/delete.js"; // Stats @@ -54,7 +55,7 @@ const program = new Command(); program .name("lucas") .description("LucasApp CLI - Financial data management for AI agents") - .version("0.2.0"); + .version("0.3.0"); // Grupo: auth const auth = program.command("auth").description("Authentication commands"); @@ -103,6 +104,7 @@ loans.addCommand(listLoansCommand); loans.addCommand(createLoanCommand); loans.addCommand(updateLoanCommand); loans.addCommand(payLoanCommand); +loans.addCommand(markPaidLoanCommand); loans.addCommand(deleteLoanCommand); // Grupo: stats diff --git a/src/lib/body-builder.ts b/src/lib/body-builder.ts index d94f72b..2fc4b24 100644 --- a/src/lib/body-builder.ts +++ b/src/lib/body-builder.ts @@ -1,9 +1,13 @@ +import { parseFiniteNumber } from "./number-parser.js"; + type FieldType = "number" | "boolean" | "string"; interface FieldMapping { opt: string; body: string; type?: FieldType; + clearOpt?: string; + clearValue?: unknown; } export function buildBody( @@ -11,13 +15,15 @@ export function buildBody( fields: FieldMapping[], ): Record { const body: Record = {}; - for (const { opt, body: key, type } of fields) { + for (const { opt, body: key, type, clearOpt, clearValue } of fields) { + if (clearOpt && opts[clearOpt] === true) { + body[key] = clearValue ?? null; + continue; + } const val = opts[opt]; - if (val === false) { - body[key] = null; - } else if (val !== undefined) { - if (type === "number") body[key] = Number(val); - else if (type === "boolean") body[key] = true; + if (val !== undefined) { + if (type === "number") body[key] = parseFiniteNumber(val, `--${opt}`); + else if (type === "boolean") body[key] = Boolean(val); else body[key] = val; } } diff --git a/src/lib/loan-domain.ts b/src/lib/loan-domain.ts new file mode 100644 index 0000000..d40bf55 --- /dev/null +++ b/src/lib/loan-domain.ts @@ -0,0 +1,47 @@ +export interface LoanInstallmentLike { + id?: string; + sequence?: number; + dueDate: string; + dueAmount: number; + paidAmount: number; + lateFeeAdded?: number | null; + status: string; +} + +export interface LoanDetailsLike { + id?: string; + currency?: string; + installments: LoanInstallmentLike[]; +} + +export function getInstallmentRemaining( + installment: LoanInstallmentLike, +): number { + return Math.max( + 0, + installment.dueAmount + + (installment.lateFeeAdded ?? 0) - + installment.paidAmount, + ); +} + +export function getLoanRemaining(loan: LoanDetailsLike): number { + return loan.installments.reduce( + (total, installment) => total + getInstallmentRemaining(installment), + 0, + ); +} + +export function findNextPayableInstallment(loan: LoanDetailsLike) { + return [...loan.installments] + .filter((item) => !["PAID", "CANCELED"].includes(item.status)) + .filter((item) => getInstallmentRemaining(item) > 0.01) + .sort((left, right) => { + const dueDateDiff = left.dueDate.localeCompare(right.dueDate); + if (dueDateDiff !== 0) return dueDateDiff; + return ( + (left.sequence ?? Number.MAX_SAFE_INTEGER) - + (right.sequence ?? Number.MAX_SAFE_INTEGER) + ); + })[0]; +} diff --git a/src/lib/loan-verification.ts b/src/lib/loan-verification.ts new file mode 100644 index 0000000..7804167 --- /dev/null +++ b/src/lib/loan-verification.ts @@ -0,0 +1,62 @@ +import { + getInstallmentRemaining, + getLoanRemaining, + type LoanDetailsLike, +} from "./loan-domain.js"; + +export interface LoanVerificationResult { + verified: boolean; + checkedAt: string; + expectedLoanReduction?: number; + actualLoanReduction: number; + targetInstallmentId?: string; + staleInstallmentIds: string[]; + reason?: string; +} + +export function verifyLoanPayment(params: { + beforeLoan: LoanDetailsLike; + afterLoan: LoanDetailsLike; + expectedLoanReduction?: number; + targetInstallmentId?: string; +}): LoanVerificationResult { + const actualLoanReduction = + getLoanRemaining(params.beforeLoan) - getLoanRemaining(params.afterLoan); + const staleInstallmentIds = params.afterLoan.installments + .filter((item) => getInstallmentRemaining(item) <= 0.01) + .filter((item) => !["PAID", "CANCELED"].includes(item.status)) + .map((item) => item.id ?? `sequence-${item.sequence ?? "unknown"}`); + if ( + params.expectedLoanReduction !== undefined && + actualLoanReduction + 0.01 < params.expectedLoanReduction + ) { + return { + verified: false, + checkedAt: new Date().toISOString(), + expectedLoanReduction: params.expectedLoanReduction, + actualLoanReduction, + targetInstallmentId: params.targetInstallmentId, + staleInstallmentIds, + reason: "remaining_balance_did_not_drop_as_expected", + }; + } + if (staleInstallmentIds.length > 0) { + return { + verified: false, + checkedAt: new Date().toISOString(), + expectedLoanReduction: params.expectedLoanReduction, + actualLoanReduction, + targetInstallmentId: params.targetInstallmentId, + staleInstallmentIds, + reason: "fully_paid_installment_still_not_marked_paid", + }; + } + return { + verified: true, + checkedAt: new Date().toISOString(), + expectedLoanReduction: params.expectedLoanReduction, + actualLoanReduction, + targetInstallmentId: params.targetInstallmentId, + staleInstallmentIds, + }; +} diff --git a/src/lib/number-parser.ts b/src/lib/number-parser.ts new file mode 100644 index 0000000..db6a7c8 --- /dev/null +++ b/src/lib/number-parser.ts @@ -0,0 +1,16 @@ +import { output } from "./output.js"; + +export function parseFiniteNumber(value: unknown, flag: string): number { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) { + output.error(`Invalid numeric value for ${flag}`, 400, { value }); + } + return parsed; +} + +export function parseOptionalNumber( + value: unknown, + flag: string, +): number | undefined { + return value === undefined ? undefined : parseFiniteNumber(value, flag); +} diff --git a/src/lib/output.ts b/src/lib/output.ts index d4a3b9b..4d16a76 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -5,11 +5,15 @@ export const output = { console.log(JSON.stringify({ ok: true, data }, null, 2)); }, - error(message: string, statusCode?: number): void { + error(message: string, statusCode?: number, details?: unknown): never { console.error( JSON.stringify({ ok: false, - error: { message, ...(statusCode && { statusCode }) }, + error: { + message, + ...(statusCode && { statusCode }), + ...(details !== undefined && { details }), + }, }), ); process.exit(1); diff --git a/src/lib/subscription-enrichment.ts b/src/lib/subscription-enrichment.ts new file mode 100644 index 0000000..fde76d4 --- /dev/null +++ b/src/lib/subscription-enrichment.ts @@ -0,0 +1,66 @@ +export interface SubscriptionLike { + id: string; + lastBilling?: string | null; + nextBilling?: string | null; + isActive?: boolean; +} + +export interface SubscriptionChargeLike { + subscriptionId: string; + dueDate: string; + paidAt?: string | null; + status: string; +} + +function getComputedStatus( + subscription: SubscriptionLike, + charge: SubscriptionChargeLike | undefined, + now: Date, +) { + if (subscription.isActive === false) return "INACTIVE"; + if (charge?.status === "OVERDUE") return "OVERDUE"; + if (charge?.status === "PENDING") { + return new Date(charge.dueDate) <= now ? "DUE" : "PENDING"; + } + return "PAID_UP_TO_DATE"; +} + +function getLastBillingExplanation( + subscription: SubscriptionLike, + charge: SubscriptionChargeLike | undefined, +) { + if (charge?.paidAt) return "latest_charge_paid"; + if (subscription.lastBilling) return "subscription_last_billing"; + if (charge) return "charge_exists_but_has_not_been_paid"; + return "no_charge_history"; +} + +export function enrichSubscriptionsWithCharges< + TSubscription extends SubscriptionLike, + TCharge extends SubscriptionChargeLike, +>(subscriptions: TSubscription[], charges: TCharge[], now: Date = new Date()) { + const latestBySubscription = new Map(); + for (const charge of charges) { + const current = latestBySubscription.get(charge.subscriptionId); + if (!current || current.dueDate < charge.dueDate) { + latestBySubscription.set(charge.subscriptionId, charge); + } + } + return subscriptions.map((subscription) => { + const latestCharge = latestBySubscription.get(subscription.id); + return { + ...subscription, + computedStatus: getComputedStatus(subscription, latestCharge, now), + latestChargeStatus: latestCharge?.status ?? null, + lastChargeDate: latestCharge?.paidAt ?? subscription.lastBilling ?? null, + lastChargeDueDate: latestCharge?.dueDate ?? null, + lastBillingKnown: + subscription.lastBilling !== null && + subscription.lastBilling !== undefined, + lastBillingExplanation: getLastBillingExplanation( + subscription, + latestCharge, + ), + }; + }); +} diff --git a/tests/commands/loans.test.ts b/tests/commands/loans.test.ts new file mode 100644 index 0000000..c3e4e45 --- /dev/null +++ b/tests/commands/loans.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiRequest = vi.fn(); + +vi.mock("../../src/lib/api-client.js", () => ({ + apiRequest, +})); + +const { buildPayLoanPayload } = await import("../../src/commands/loans/pay.js"); +const { executeMarkPaidLoan } = + await import("../../src/commands/loans/mark-paid.js"); + +describe("loan commands", () => { + beforeEach(() => { + apiRequest.mockReset(); + }); + + it("buildPayLoanPayload sends canonical payAmount", () => { + expect( + buildPayLoanPayload({ + amount: "750", + accountId: "acc_1", + paidAt: "2026-04-02", + }), + ).toEqual({ + payAmount: 750, + accountId: "acc_1", + paidAt: "2026-04-02", + }); + }); + + it("executeMarkPaidLoan pays the next pending installment and verifies it", async () => { + const beforeLoan = { + id: "loan_1", + currency: "PEN", + installments: [ + { + id: "inst_1", + sequence: 1, + dueDate: "2026-04-01", + dueAmount: 120, + paidAmount: 30, + lateFeeAdded: 0, + status: "PARTIAL", + }, + { + id: "inst_2", + sequence: 2, + dueDate: "2026-05-01", + dueAmount: 120, + paidAmount: 0, + lateFeeAdded: 0, + status: "PENDING", + }, + ], + }; + const afterLoan = { + ...beforeLoan, + installments: [ + { + id: "inst_1", + sequence: 1, + dueDate: "2026-04-01", + dueAmount: 120, + paidAmount: 120, + lateFeeAdded: 0, + status: "PAID", + }, + beforeLoan.installments[1], + ], + }; + + let loanReads = 0; + apiRequest.mockImplementation(async (method, path, body) => { + if (method === "GET" && path === "/api/loans/loan_1") { + loanReads += 1; + return loanReads < 3 ? beforeLoan : afterLoan; + } + if (method === "POST" && path === "/api/loans/loan_1/pay") { + expect(body).toMatchObject({ + payAmount: 90, + notes: "mouse", + paidAt: "2026-04-02", + }); + return { paymentId: "pay_1" }; + } + throw new Error(`Unexpected request: ${method} ${path}`); + }); + + const result = await executeMarkPaidLoan("loan_1", { + notes: "mouse", + paidAt: "2026-04-02", + verified: true, + }); + + expect(result.markedInstallment).toEqual({ + id: "inst_1", + sequence: 1, + dueDate: "2026-04-01", + remainingAmount: 90, + }); + expect(result.verification?.verified).toBe(true); + expect(result.loan).toEqual(afterLoan); + }); +}); diff --git a/tests/lib/body-builder.test.ts b/tests/lib/body-builder.test.ts new file mode 100644 index 0000000..6915213 --- /dev/null +++ b/tests/lib/body-builder.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildBody } from "../../src/lib/body-builder.js"; +import { output } from "../../src/lib/output.js"; + +describe("buildBody", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("builds booleans, numbers, and clear flags correctly", () => { + const body = buildBody( + { + isPrimary: false, + principal: "1500.25", + clearAccountId: true, + }, + [ + { opt: "isPrimary", body: "isPrimary", type: "boolean" }, + { opt: "principal", body: "principal", type: "number" }, + { + opt: "accountId", + body: "paymentAccountId", + clearOpt: "clearAccountId", + }, + ], + ); + + expect(body).toEqual({ + isPrimary: false, + principal: 1500.25, + paymentAccountId: null, + }); + }); + + it("fails fast on invalid numeric values", () => { + vi.spyOn(output, "error").mockImplementation((message) => { + throw new Error(message); + }); + + expect(() => + buildBody({ amount: "NaN" }, [ + { opt: "amount", body: "amount", type: "number" }, + ]), + ).toThrow("Invalid numeric value for --amount"); + }); +}); diff --git a/tests/lib/loan-verification.test.ts b/tests/lib/loan-verification.test.ts new file mode 100644 index 0000000..ee5179d --- /dev/null +++ b/tests/lib/loan-verification.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { verifyLoanPayment } from "../../src/lib/loan-verification.js"; + +describe("verifyLoanPayment", () => { + it("fails when a fully paid installment still has a stale non-paid status", () => { + const result = verifyLoanPayment({ + beforeLoan: { + currency: "PEN", + installments: [ + { + id: "inst_1", + sequence: 1, + dueDate: "2026-04-01", + dueAmount: 100, + paidAmount: 0, + lateFeeAdded: 0, + status: "OVERDUE", + }, + ], + }, + afterLoan: { + currency: "PEN", + installments: [ + { + id: "inst_1", + sequence: 1, + dueDate: "2026-04-01", + dueAmount: 100, + paidAmount: 100, + lateFeeAdded: 0, + status: "OVERDUE", + }, + ], + }, + expectedLoanReduction: 100, + targetInstallmentId: "inst_1", + }); + + expect(result.verified).toBe(false); + expect(result.reason).toBe("fully_paid_installment_still_not_marked_paid"); + expect(result.staleInstallmentIds).toEqual(["inst_1"]); + }); +}); diff --git a/tests/lib/subscription-enrichment.test.ts b/tests/lib/subscription-enrichment.test.ts new file mode 100644 index 0000000..b4ae217 --- /dev/null +++ b/tests/lib/subscription-enrichment.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { enrichSubscriptionsWithCharges } from "../../src/lib/subscription-enrichment.js"; + +describe("enrichSubscriptionsWithCharges", () => { + it("explains subscriptions that have pending charges but no lastBilling", () => { + const [subscription] = enrichSubscriptionsWithCharges( + [ + { + id: "sub_1", + isActive: true, + lastBilling: null, + nextBilling: "2026-04-01", + }, + ], + [ + { + subscriptionId: "sub_1", + dueDate: "2026-04-01T12:00:00.000Z", + paidAt: null, + status: "PENDING", + }, + ], + new Date("2026-04-02T10:00:00.000Z"), + ); + + expect(subscription.computedStatus).toBe("DUE"); + expect(subscription.latestChargeStatus).toBe("PENDING"); + expect(subscription.lastChargeDate).toBeNull(); + expect(subscription.lastBillingExplanation).toBe( + "charge_exists_but_has_not_been_paid", + ); + }); + + it("uses the newest charge to expose lastChargeDate and latestChargeStatus", () => { + const [subscription] = enrichSubscriptionsWithCharges( + [ + { + id: "sub_2", + isActive: true, + lastBilling: "2026-02-01T12:00:00.000Z", + }, + ], + [ + { + subscriptionId: "sub_2", + dueDate: "2026-03-01T12:00:00.000Z", + paidAt: "2026-03-01T13:00:00.000Z", + status: "PAID", + }, + { + subscriptionId: "sub_2", + dueDate: "2026-02-01T12:00:00.000Z", + paidAt: "2026-02-01T13:00:00.000Z", + status: "PAID", + }, + ], + new Date("2026-04-02T10:00:00.000Z"), + ); + + expect(subscription.computedStatus).toBe("PAID_UP_TO_DATE"); + expect(subscription.latestChargeStatus).toBe("PAID"); + expect(subscription.lastChargeDate).toBe("2026-03-01T13:00:00.000Z"); + expect(subscription.lastBillingExplanation).toBe("latest_charge_paid"); + }); +});