From 77c235f9f5ef232a4fa6a3a132bbb9db15ce971c Mon Sep 17 00:00:00 2001 From: Steven Coaila Date: Wed, 27 May 2026 23:20:43 -0500 Subject: [PATCH] feat(cli): expose investments and subscription parity --- AGENTS.md | 6 + CHANGELOG.md | 17 +++ README.md | 20 ++- src/commands/accounts/update.ts | 2 + src/commands/investments/archive.ts | 114 ++++++++++++++ src/commands/investments/cash.ts | 81 ++++++++++ src/commands/investments/index.ts | 60 ++++++++ src/commands/investments/instruments.ts | 83 +++++++++++ src/commands/investments/portfolio.ts | 82 +++++++++++ src/commands/investments/refresh.ts | 34 +++++ src/commands/investments/trades.ts | 102 +++++++++++++ src/commands/subscription-charges/index.ts | 93 ++++++++++++ src/commands/subscription-groups/index.ts | 92 ++++++++++++ src/commands/subscriptions/calendar.ts | 37 +++++ src/commands/subscriptions/create.ts | 2 + src/commands/subscriptions/update.ts | 3 + src/index.ts | 12 ++ tests/commands/accounts-create.test.ts | 10 ++ tests/commands/investments.test.ts | 155 ++++++++++++++++++++ tests/commands/subscription-charges.test.ts | 12 ++ tests/commands/subscription-groups.test.ts | 12 ++ tests/commands/subscriptions-list.test.ts | 17 +++ 22 files changed, 1045 insertions(+), 1 deletion(-) create mode 100644 src/commands/investments/archive.ts create mode 100644 src/commands/investments/cash.ts create mode 100644 src/commands/investments/index.ts create mode 100644 src/commands/investments/instruments.ts create mode 100644 src/commands/investments/portfolio.ts create mode 100644 src/commands/investments/refresh.ts create mode 100644 src/commands/investments/trades.ts create mode 100644 src/commands/subscription-charges/index.ts create mode 100644 src/commands/subscription-groups/index.ts create mode 100644 src/commands/subscriptions/calendar.ts create mode 100644 tests/commands/investments.test.ts create mode 100644 tests/commands/subscription-charges.test.ts create mode 100644 tests/commands/subscription-groups.test.ts diff --git a/AGENTS.md b/AGENTS.md index a995a0f..ed1b192 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ safe to publish. setup. - Default production API is `https://api.lucasapp.app`; browser approval lives on `https://dashboard.lucasapp.app/cli`. +- Keep `investments` commands as thin API clients; backend owns quotes, + catalog search, Premium gating, cash validation, and archive semantics. +- Keep subscription calendar and group commands as thin API clients; backend + owns Premium gating, billing dates, group ownership, and reorder semantics. +- Keep subscription charge commands as thin API clients; backend owns charge + generation, transaction side effects, confirmation, and SSE semantics. - Match public list commands to the backend response shape, including pagination wrappers such as `{ items, summary, pagination }`. - Build API paths with `resourcePath()` when an ID appears in the URL path. diff --git a/CHANGELOG.md b/CHANGELOG.md index 10729b7..12e7985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,27 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Added `lucas investments` commands for instrument discovery, portfolio + overview, positions, activity, trades, cash adjustments, and archived + investment recovery. +- Added investment history and permanent archive cleanup commands. +- Added `lucas investments refresh` for backend catalog/EOD/snapshot refresh + jobs. +- Added `lucas subscriptions calendar` for the backend monthly billing + calendar. +- Added `lucas subscription-groups` list/create/update/delete/reorder commands. +- Added `lucas subscription-charges` commands for pending charges, account + charges, pay, confirm, and manual paid actions. + ### Changed - Changed the default production API URL to `https://api.lucasapp.app` and the CLI landing/approval URL to `https://dashboard.lucasapp.app/cli`. +- `accounts update` now supports `--currency` to match backend account currency + edits. +- `subscriptions create` and `subscriptions update` now support `--group-id`. ## [0.6.6] - 2026-05-20 diff --git a/README.md b/README.md index e7f861e..a4793df 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,16 @@ lucas transfers list --limit 10 --offset 0 lucas transfers create --from-account-id --to-account-id --amount 500 lucas subscriptions list --type SERVICE --limit 20 +lucas subscriptions calendar --month 2026-05 --type SUBSCRIPTION --frequency MONTHLY lucas subscriptions mark-paid +lucas subscription-groups list +lucas subscription-charges pending --limit 10 +lucas subscription-charges pay + +lucas investments instruments --rank popular --limit 20 +lucas investments refresh --action eod +lucas investments trade --instrument-id --side BUY --quantity 1 --price 100 +lucas investments history --range 90d lucas loans list lucas loans pay --amount 750 --verified @@ -54,7 +63,9 @@ lucas ai insights "How am I doing this month?" --period month --currency PEN ``` Command groups: `auth`, `accounts`, `transactions`, `transfers`, -`subscriptions`, `loans`, `stats`, `categories`, `exchange-rate`, and `ai`. +`investments`, `subscriptions`, `subscription-groups`, +`subscription-charges`, `loans`, `stats`, `categories`, `exchange-rate`, and +`ai`. The `ai` group is intentionally limited to usage, text/image expense parsing, and read-only insights. @@ -67,6 +78,13 @@ List commands are intentionally agent-friendly: transfer pair still returns its two transaction rows. - `subscriptions list` supports `--limit`, `--offset`, `--frequency`, `--type`, and `--group-id`. +- `subscriptions calendar` mirrors the backend monthly billing calendar, and + `subscription-groups` exposes group list/create/update/delete/reorder. +- `subscription-charges` exposes generated charges, pending-charge pagination, + account-scoped charges, and pay/confirm/manual-paid actions. +- `investments` mirrors the Premium backend contract for instrument discovery, + portfolio overview, positions, activity, history, trades, cash adjustments, + backend refresh jobs, and archived investment recovery. - `accounts list --include-archived` includes archived accounts in the account array and adds `archivedAccounts` metadata. Balance/debt totals remain the active-account totals returned by LucasApp. diff --git a/src/commands/accounts/update.ts b/src/commands/accounts/update.ts index 0ab1aaa..afdacea 100644 --- a/src/commands/accounts/update.ts +++ b/src/commands/accounts/update.ts @@ -9,6 +9,7 @@ export const updateAccountCommand = new Command("update") .argument("", "Account ID") .option("--name ", "Account name") .option("--bank ", "Bank name") + .option("--currency ", "Currency code") .option("--color ", "Account color") .option("--clear-color", "Clear account color") .option("--icon ", "Account icon") @@ -29,6 +30,7 @@ export const updateAccountCommand = new Command("update") const body = buildBody(opts, [ { opt: "name", body: "name" }, { opt: "bank", body: "bank" }, + { opt: "currency", body: "currency" }, { opt: "color", body: "color", clearOpt: "clearColor" }, { opt: "icon", body: "icon", clearOpt: "clearIcon" }, { opt: "balance", body: "balance", type: "number" }, diff --git a/src/commands/investments/archive.ts b/src/commands/investments/archive.ts new file mode 100644 index 0000000..285720f --- /dev/null +++ b/src/commands/investments/archive.ts @@ -0,0 +1,114 @@ +import { Command } from "commander"; +import { apiRequest } from "../../lib/api-client.js"; +import { output } from "../../lib/output.js"; +import { resourcePath } from "../../lib/resource-path.js"; + +interface ArchivedItemsOptions { + limit?: string; + offset?: string; + kind?: string; +} + +export function buildArchivedItemsParams( + opts: ArchivedItemsOptions, +): Record | undefined { + const params: Record = {}; + if (opts.limit) params.limit = opts.limit; + if (opts.offset) params.offset = opts.offset; + if (opts.kind) params.kind = opts.kind; + return Object.keys(params).length > 0 ? params : undefined; +} + +export const listArchivedInvestmentsCommand = new Command("archived") + .description("List archived investment accounts summary") + .action(async () => { + const data = await apiRequest("GET", "/api/investments/archived"); + output.success(data); + }); + +export const listArchivedInvestmentItemsCommand = new Command("archived-items") + .description("List archived investment trades and cash adjustments") + .option("--limit ", "Items per page (1..100)") + .option("--offset ", "Pagination offset") + .option("--kind ", "Item kind (all|trade|cash-adjustment)") + .action(async (opts: ArchivedItemsOptions) => { + const data = await apiRequest( + "GET", + "/api/investments/archived/items", + undefined, + buildArchivedItemsParams(opts), + ); + output.success(data); + }); + +export const restoreArchivedInvestmentsCommand = new Command("restore-archived") + .description("Restore all archived investments for an account") + .argument("", "Investment account ID") + .action(async (accountId: string) => { + const data = await apiRequest( + "POST", + resourcePath("/api/investments/archived", accountId, "restore"), + ); + output.success(data); + }); + +export const restoreTradeCommand = new Command("trade-restore") + .description("Restore an archived investment trade") + .argument("", "Trade ID") + .action(async (tradeId: string) => { + const data = await apiRequest( + "POST", + resourcePath("/api/investments/trades", tradeId, "restore"), + ); + output.success(data); + }); + +export const restoreCashAdjustmentCommand = new Command("cash-restore") + .description("Restore an archived investment cash adjustment") + .argument("", "Cash adjustment ID") + .action(async (adjustmentId: string) => { + const data = await apiRequest( + "POST", + resourcePath( + "/api/investments/cash-adjustments", + adjustmentId, + "restore", + ), + ); + output.success(data); + }); + +export const permanentDeleteTradeCommand = new Command("trade-permanent-delete") + .description("Permanently delete an archived investment trade") + .argument("", "Trade ID") + .action(async (tradeId: string) => { + const data = await apiRequest( + "DELETE", + resourcePath("/api/investments/trades", tradeId, "permanent"), + ); + output.success(data); + }); + +export const permanentDeleteCashAdjustmentCommand = new Command( + "cash-permanent-delete", +) + .description("Permanently delete an archived investment cash adjustment") + .argument("", "Cash adjustment ID") + .action(async (adjustmentId: string) => { + const data = await apiRequest( + "DELETE", + resourcePath( + "/api/investments/cash-adjustments", + adjustmentId, + "permanent", + ), + ); + output.success(data); + }); + +export const emptyArchivedInvestmentsCommand = new Command("archived-empty") + .description("Permanently delete all archived investment items") + .action(async () => { + const data = await apiRequest("DELETE", "/api/investments/archived"); + output.success(data); + }); diff --git a/src/commands/investments/cash.ts b/src/commands/investments/cash.ts new file mode 100644 index 0000000..66c7fd5 --- /dev/null +++ b/src/commands/investments/cash.ts @@ -0,0 +1,81 @@ +import { Command } from "commander"; +import { apiRequest } from "../../lib/api-client.js"; +import { buildBody } from "../../lib/body-builder.js"; +import { parseFiniteNumber } from "../../lib/number-parser.js"; +import { output } from "../../lib/output.js"; +import { resourcePath } from "../../lib/resource-path.js"; + +interface CreateCashAdjustmentOptions { + amount: string; + occurredAt?: string; + note?: string; +} + +interface UpdateCashAdjustmentOptions { + amount?: string; + occurredAt?: string; + note?: string; + clearNote?: boolean; +} + +export function buildCreateCashAdjustmentBody( + opts: CreateCashAdjustmentOptions, +): Record { + return { + amount: parseFiniteNumber(opts.amount, "--amount"), + ...(opts.occurredAt && { occurredAt: opts.occurredAt }), + ...(opts.note && { note: opts.note }), + }; +} + +export function buildUpdateCashAdjustmentBody( + opts: UpdateCashAdjustmentOptions, +): Record { + return buildBody(opts as Record, [ + { opt: "amount", body: "amount", type: "number" }, + { opt: "occurredAt", body: "occurredAt" }, + { opt: "note", body: "note", clearOpt: "clearNote" }, + ]); +} + +export const createCashAdjustmentCommand = new Command("cash") + .description("Create an investment cash adjustment") + .argument("", "Investment account ID") + .requiredOption("--amount ", "Signed cash amount") + .option("--occurred-at ", "Adjustment datetime (ISO 8601)") + .option("--note ", "Adjustment note") + .action(async (accountId: string, opts: CreateCashAdjustmentOptions) => { + const data = await apiRequest( + "POST", + resourcePath("/api/investments/accounts", accountId, "cash-adjustments"), + buildCreateCashAdjustmentBody(opts), + ); + output.success(data); + }); + +export const updateCashAdjustmentCommand = new Command("cash-update") + .description("Update an investment cash adjustment") + .argument("", "Cash adjustment ID") + .option("--amount ", "Signed cash amount") + .option("--occurred-at ", "Adjustment datetime (ISO 8601)") + .option("--note ", "Adjustment note") + .option("--clear-note", "Clear adjustment note") + .action(async (adjustmentId: string, opts: UpdateCashAdjustmentOptions) => { + const data = await apiRequest( + "PUT", + resourcePath("/api/investments/cash-adjustments", adjustmentId), + buildUpdateCashAdjustmentBody(opts), + ); + output.success(data); + }); + +export const deleteCashAdjustmentCommand = new Command("cash-delete") + .description("Delete an investment cash adjustment") + .argument("", "Cash adjustment ID") + .action(async (adjustmentId: string) => { + const data = await apiRequest( + "DELETE", + resourcePath("/api/investments/cash-adjustments", adjustmentId), + ); + output.success(data); + }); diff --git a/src/commands/investments/index.ts b/src/commands/investments/index.ts new file mode 100644 index 0000000..f55285d --- /dev/null +++ b/src/commands/investments/index.ts @@ -0,0 +1,60 @@ +import { Command } from "commander"; +import { + getInstrumentCommand, + listInstrumentsCommand, + searchInstrumentsCommand, +} from "./instruments.js"; +import { refreshInvestmentsCommand } from "./refresh.js"; +import { + investmentActivityCommand, + investmentHistoryCommand, + investmentOverviewCommand, + investmentPositionsCommand, +} from "./portfolio.js"; +import { + createTradeCommand, + deleteTradeCommand, + updateTradeCommand, +} from "./trades.js"; +import { + createCashAdjustmentCommand, + deleteCashAdjustmentCommand, + updateCashAdjustmentCommand, +} from "./cash.js"; +import { + listArchivedInvestmentItemsCommand, + listArchivedInvestmentsCommand, + restoreArchivedInvestmentsCommand, + restoreCashAdjustmentCommand, + restoreTradeCommand, + permanentDeleteCashAdjustmentCommand, + permanentDeleteTradeCommand, + emptyArchivedInvestmentsCommand, +} from "./archive.js"; + +export const investmentsCommand = new Command("investments").description( + "Manage investment accounts, positions, trades, and catalog", +); + +investmentsCommand.addCommand(listInstrumentsCommand); +investmentsCommand.addCommand(searchInstrumentsCommand); +investmentsCommand.addCommand(getInstrumentCommand); +investmentsCommand.addCommand(refreshInvestmentsCommand); +investmentsCommand.addCommand(investmentOverviewCommand); +investmentsCommand.addCommand(investmentPositionsCommand); +investmentsCommand.addCommand(investmentActivityCommand); +investmentsCommand.addCommand(investmentHistoryCommand); +investmentsCommand.addCommand(createTradeCommand); +investmentsCommand.addCommand(updateTradeCommand); +investmentsCommand.addCommand(deleteTradeCommand); +investmentsCommand.addCommand(createCashAdjustmentCommand); +investmentsCommand.addCommand(updateCashAdjustmentCommand); +investmentsCommand.addCommand(deleteCashAdjustmentCommand); +investmentsCommand.addCommand(listArchivedInvestmentsCommand); +investmentsCommand.addCommand(listArchivedInvestmentItemsCommand); +investmentsCommand.addCommand(restoreArchivedInvestmentsCommand); +investmentsCommand.addCommand(restoreTradeCommand); +investmentsCommand.addCommand(restoreCashAdjustmentCommand); +investmentsCommand.addCommand(permanentDeleteTradeCommand); +investmentsCommand.addCommand(permanentDeleteCashAdjustmentCommand); +investmentsCommand.addCommand(emptyArchivedInvestmentsCommand); diff --git a/src/commands/investments/instruments.ts b/src/commands/investments/instruments.ts new file mode 100644 index 0000000..fd2cef0 --- /dev/null +++ b/src/commands/investments/instruments.ts @@ -0,0 +1,83 @@ +import { Command } from "commander"; +import { apiRequest } from "../../lib/api-client.js"; +import { output } from "../../lib/output.js"; +import { resourcePath } from "../../lib/resource-path.js"; + +interface InstrumentListOptions { + limit?: string; + offset?: string; + rank?: string; + type?: string; + exchange?: string; + inactive?: boolean; +} + +interface InstrumentSearchOptions { + limit?: string; +} + +export function buildInstrumentListParams( + opts: InstrumentListOptions, +): Record | undefined { + const params: Record = {}; + if (opts.limit) params.limit = opts.limit; + if (opts.offset) params.offset = opts.offset; + if (opts.rank) params.rank = opts.rank; + if (opts.type) params.type = opts.type; + if (opts.exchange) params.exchange = opts.exchange; + if (opts.inactive) params.isActive = "false"; + return Object.keys(params).length > 0 ? params : undefined; +} + +export function buildInstrumentSearchParams( + query: string, + opts: InstrumentSearchOptions, +): Record { + return { + q: query, + ...(opts.limit && { limit: opts.limit }), + }; +} + +export const listInstrumentsCommand = new Command("instruments") + .description("List investment instruments") + .option("--limit ", "Items per page (1..200)") + .option("--offset ", "Pagination offset") + .option("--rank ", "Ranking filter (popular)") + .option("--type ", "Instrument type (STOCK|ETF|CRYPTO|BOND)") + .option("--exchange ", "Exchange code") + .option("--inactive", "Include inactive instruments only") + .action(async (opts: InstrumentListOptions) => { + const data = await apiRequest( + "GET", + "/api/investments/instruments", + undefined, + buildInstrumentListParams(opts), + ); + output.success(data); + }); + +export const searchInstrumentsCommand = new Command("search") + .description("Search investment instruments") + .argument("", "Search text, ticker, or provider symbol") + .option("--limit ", "Max results (1..50)") + .action(async (query: string, opts: InstrumentSearchOptions) => { + const data = await apiRequest( + "GET", + "/api/investments/instruments/search", + undefined, + buildInstrumentSearchParams(query, opts), + ); + output.success(data); + }); + +export const getInstrumentCommand = new Command("instrument") + .description("Get investment instrument detail") + .argument("", "Instrument ID") + .action(async (id: string) => { + const data = await apiRequest( + "GET", + resourcePath("/api/investments/instruments", id), + ); + output.success(data); + }); diff --git a/src/commands/investments/portfolio.ts b/src/commands/investments/portfolio.ts new file mode 100644 index 0000000..2786295 --- /dev/null +++ b/src/commands/investments/portfolio.ts @@ -0,0 +1,82 @@ +import { Command } from "commander"; +import { apiRequest } from "../../lib/api-client.js"; +import { output } from "../../lib/output.js"; +import { resourcePath } from "../../lib/resource-path.js"; + +interface ActivityOptions { + limit?: string; + offset?: string; + kind?: string; +} + +interface HistoryOptions { + range?: string; +} + +export function buildInvestmentActivityParams( + opts: ActivityOptions, +): Record | undefined { + const params: Record = {}; + if (opts.limit) params.limit = opts.limit; + if (opts.offset) params.offset = opts.offset; + if (opts.kind) params.kind = opts.kind; + return Object.keys(params).length > 0 ? params : undefined; +} + +export function buildInvestmentHistoryParams( + opts: HistoryOptions, +): Record | undefined { + return opts.range ? { range: opts.range } : undefined; +} + +export const investmentOverviewCommand = new Command("overview") + .description("Get investment account overview") + .argument("", "Investment account ID") + .action(async (accountId: string) => { + const data = await apiRequest( + "GET", + resourcePath("/api/investments/accounts", accountId, "overview"), + ); + output.success(data); + }); + +export const investmentPositionsCommand = new Command("positions") + .description("List positions for an investment account") + .argument("", "Investment account ID") + .action(async (accountId: string) => { + const data = await apiRequest( + "GET", + resourcePath("/api/investments/accounts", accountId, "positions"), + ); + output.success(data); + }); + +export const investmentActivityCommand = new Command("activity") + .description("List investment account activity") + .argument("", "Investment account ID") + .option("--limit ", "Items per page (1..100)") + .option("--offset ", "Pagination offset") + .option("--kind ", "Activity kind (trades|adjustments|transfers|all)") + .action(async (accountId: string, opts: ActivityOptions) => { + const data = await apiRequest( + "GET", + resourcePath("/api/investments/accounts", accountId, "activity"), + undefined, + buildInvestmentActivityParams(opts), + ); + output.success(data); + }); + +export const investmentHistoryCommand = new Command("history") + .description("List investment account history points") + .argument("", "Investment account ID") + .option("--range ", "History range (7d|30d|90d|1y|ytd)") + .action(async (accountId: string, opts: HistoryOptions) => { + const data = await apiRequest( + "GET", + resourcePath("/api/investments/accounts", accountId, "history"), + undefined, + buildInvestmentHistoryParams(opts), + ); + output.success(data); + }); diff --git a/src/commands/investments/refresh.ts b/src/commands/investments/refresh.ts new file mode 100644 index 0000000..58ed6c5 --- /dev/null +++ b/src/commands/investments/refresh.ts @@ -0,0 +1,34 @@ +import { Command } from "commander"; +import { apiRequest } from "../../lib/api-client.js"; +import { output } from "../../lib/output.js"; + +interface InvestmentRefreshOptions { + action: string; + exchanges?: string; + country?: string; +} + +export function buildInvestmentRefreshParams( + opts: InvestmentRefreshOptions, +): Record { + return { + action: opts.action, + ...(opts.exchanges && { exchanges: opts.exchanges }), + ...(opts.country && { country: opts.country }), + }; +} + +export const refreshInvestmentsCommand = new Command("refresh") + .description("Run a backend investment refresh job") + .requiredOption("--action ", "Refresh action (catalog|eod|snapshot)") + .option("--exchanges ", "Catalog exchanges, comma-separated") + .option("--country ", "Catalog country filter") + .action(async (opts: InvestmentRefreshOptions) => { + const data = await apiRequest( + "POST", + "/api/investments/quotes/refresh", + undefined, + buildInvestmentRefreshParams(opts), + ); + output.success(data); + }); diff --git a/src/commands/investments/trades.ts b/src/commands/investments/trades.ts new file mode 100644 index 0000000..9fab2b7 --- /dev/null +++ b/src/commands/investments/trades.ts @@ -0,0 +1,102 @@ +import { Command } from "commander"; +import { apiRequest } from "../../lib/api-client.js"; +import { buildBody } from "../../lib/body-builder.js"; +import { parseFiniteNumber } from "../../lib/number-parser.js"; +import { output } from "../../lib/output.js"; +import { resourcePath } from "../../lib/resource-path.js"; + +interface CreateTradeOptions { + instrumentId: string; + side: string; + quantity: string; + price: string; + fee?: string; + executedAt?: string; + notes?: string; +} + +interface UpdateTradeOptions { + side?: string; + quantity?: string; + price?: string; + fee?: string; + executedAt?: string; + notes?: string; + clearNotes?: boolean; +} + +export function buildCreateTradeBody( + opts: CreateTradeOptions, +): Record { + return { + instrumentId: opts.instrumentId, + side: opts.side.toUpperCase(), + quantity: parseFiniteNumber(opts.quantity, "--quantity"), + price: parseFiniteNumber(opts.price, "--price"), + fee: opts.fee === undefined ? 0 : parseFiniteNumber(opts.fee, "--fee"), + executedAt: opts.executedAt ?? new Date().toISOString(), + ...(opts.notes && { notes: opts.notes }), + }; +} + +export function buildUpdateTradeBody( + opts: UpdateTradeOptions, +): Record { + return buildBody(opts as Record, [ + { opt: "side", body: "side" }, + { opt: "quantity", body: "quantity", type: "number" }, + { opt: "price", body: "price", type: "number" }, + { opt: "fee", body: "fee", type: "number" }, + { opt: "executedAt", body: "executedAt" }, + { opt: "notes", body: "notes", clearOpt: "clearNotes" }, + ]); +} + +export const createTradeCommand = new Command("trade") + .description("Create an investment trade") + .argument("", "Investment account ID") + .requiredOption("--instrument-id ", "Investment instrument ID") + .requiredOption("--side ", "Trade side (BUY|SELL)") + .requiredOption("--quantity ", "Trade quantity") + .requiredOption("--price ", "Execution price") + .option("--fee ", "Trade fee", "0") + .option("--executed-at ", "Execution datetime (ISO 8601)") + .option("--notes ", "Trade notes") + .action(async (accountId: string, opts: CreateTradeOptions) => { + const data = await apiRequest( + "POST", + resourcePath("/api/investments/accounts", accountId, "trades"), + buildCreateTradeBody(opts), + ); + output.success(data); + }); + +export const updateTradeCommand = new Command("trade-update") + .description("Update an investment trade") + .argument("", "Trade ID") + .option("--side ", "Trade side (BUY|SELL)") + .option("--quantity ", "Trade quantity") + .option("--price ", "Execution price") + .option("--fee ", "Trade fee") + .option("--executed-at ", "Execution datetime (ISO 8601)") + .option("--notes ", "Trade notes") + .option("--clear-notes", "Clear trade notes") + .action(async (tradeId: string, opts: UpdateTradeOptions) => { + const data = await apiRequest( + "PUT", + resourcePath("/api/investments/trades", tradeId), + buildUpdateTradeBody(opts), + ); + output.success(data); + }); + +export const deleteTradeCommand = new Command("trade-delete") + .description("Delete an investment trade") + .argument("", "Trade ID") + .action(async (tradeId: string) => { + const data = await apiRequest( + "DELETE", + resourcePath("/api/investments/trades", tradeId), + ); + output.success(data); + }); diff --git a/src/commands/subscription-charges/index.ts b/src/commands/subscription-charges/index.ts new file mode 100644 index 0000000..882f0d4 --- /dev/null +++ b/src/commands/subscription-charges/index.ts @@ -0,0 +1,93 @@ +import { Command } from "commander"; +import { apiRequest } from "../../lib/api-client.js"; +import { output } from "../../lib/output.js"; +import { resourcePath } from "../../lib/resource-path.js"; + +interface PendingChargesOptions { + limit?: string; + offset?: string; +} + +export function buildPendingChargesParams( + opts: PendingChargesOptions, +): Record | undefined { + const params: Record = {}; + if (opts.limit) params.limit = opts.limit; + if (opts.offset) params.offset = opts.offset; + return Object.keys(params).length > 0 ? params : undefined; +} + +export const subscriptionChargesCommand = new Command( + "subscription-charges", +).description("Manage subscription billing charges"); + +subscriptionChargesCommand + .command("list") + .description("List all generated subscription charges") + .action(async () => { + const data = await apiRequest("GET", "/api/subscription-charges"); + output.success(data); + }); + +subscriptionChargesCommand + .command("pending") + .description("List pending subscription charges") + .option("--limit ", "Items per page") + .option("--offset ", "Pagination offset") + .action(async (opts: PendingChargesOptions) => { + const data = await apiRequest( + "GET", + "/api/subscription-charges/pending", + undefined, + buildPendingChargesParams(opts), + ); + output.success(data); + }); + +subscriptionChargesCommand + .command("by-account") + .description("List subscription charges for an account") + .argument("", "Account ID") + .action(async (accountId: string) => { + const data = await apiRequest( + "GET", + resourcePath("/api/subscription-charges/by-account", accountId), + ); + output.success(data); + }); + +subscriptionChargesCommand + .command("pay") + .description("Pay a subscription charge using its linked account") + .argument("", "Subscription charge ID") + .action(async (chargeId: string) => { + const data = await apiRequest( + "POST", + resourcePath("/api/subscription-charges", chargeId, "pay"), + ); + output.success(data); + }); + +subscriptionChargesCommand + .command("confirm") + .description("Confirm a subscription charge transaction") + .argument("", "Subscription charge ID") + .action(async (chargeId: string) => { + const data = await apiRequest( + "POST", + resourcePath("/api/subscription-charges", chargeId, "confirm"), + ); + output.success(data); + }); + +subscriptionChargesCommand + .command("mark-paid") + .description("Mark a subscription charge paid manually") + .argument("", "Subscription charge ID") + .action(async (chargeId: string) => { + const data = await apiRequest( + "POST", + resourcePath("/api/subscription-charges", chargeId, "mark-paid"), + ); + output.success(data); + }); diff --git a/src/commands/subscription-groups/index.ts b/src/commands/subscription-groups/index.ts new file mode 100644 index 0000000..426aeac --- /dev/null +++ b/src/commands/subscription-groups/index.ts @@ -0,0 +1,92 @@ +import { Command } from "commander"; +import { buildBody } from "../../lib/body-builder.js"; +import { apiRequest } from "../../lib/api-client.js"; +import { output } from "../../lib/output.js"; +import { resourcePath } from "../../lib/resource-path.js"; + +interface ReorderOptions { + ids: string; +} + +export function parseGroupIds(value: string): string[] { + return value + .split(",") + .map((id) => id.trim()) + .filter(Boolean); +} + +export const subscriptionGroupsCommand = new Command( + "subscription-groups", +).description("Manage subscription groups"); + +subscriptionGroupsCommand + .command("list") + .description("List subscription groups") + .action(async () => { + const data = await apiRequest("GET", "/api/subscription-groups"); + output.success(data); + }); + +subscriptionGroupsCommand + .command("create") + .description("Create a subscription group") + .requiredOption("--name ", "Group name") + .option("--color ", "Group color (#RRGGBB)") + .option("--icon ", "Group icon") + .action(async (opts) => { + const body = buildBody(opts, [ + { opt: "name", body: "name" }, + { opt: "color", body: "color" }, + { opt: "icon", body: "icon" }, + ]); + const data = await apiRequest("POST", "/api/subscription-groups", body); + output.success(data); + }); + +subscriptionGroupsCommand + .command("update") + .description("Update a subscription group") + .argument("", "Subscription group ID") + .option("--name ", "Group name") + .option("--color ", "Group color (#RRGGBB)") + .option("--clear-color", "Clear group color") + .option("--icon ", "Group icon") + .option("--clear-icon", "Clear group icon") + .option("--display-order ", "Display order") + .action(async (id: string, opts) => { + const body = buildBody(opts, [ + { opt: "name", body: "name" }, + { opt: "color", body: "color", clearOpt: "clearColor" }, + { opt: "icon", body: "icon", clearOpt: "clearIcon" }, + { opt: "displayOrder", body: "displayOrder", type: "number" }, + ]); + const data = await apiRequest( + "PUT", + resourcePath("/api/subscription-groups", id), + body, + ); + output.success(data); + }); + +subscriptionGroupsCommand + .command("delete") + .description("Delete a subscription group") + .argument("", "Subscription group ID") + .action(async (id: string) => { + const data = await apiRequest( + "DELETE", + resourcePath("/api/subscription-groups", id), + ); + output.success(data); + }); + +subscriptionGroupsCommand + .command("reorder") + .description("Reorder subscription groups") + .requiredOption("--ids ", "Comma-separated group IDs in display order") + .action(async (opts: ReorderOptions) => { + const data = await apiRequest("POST", "/api/subscription-groups/reorder", { + ids: parseGroupIds(opts.ids), + }); + output.success(data); + }); diff --git a/src/commands/subscriptions/calendar.ts b/src/commands/subscriptions/calendar.ts new file mode 100644 index 0000000..25ebdad --- /dev/null +++ b/src/commands/subscriptions/calendar.ts @@ -0,0 +1,37 @@ +import { Command } from "commander"; +import { apiRequest } from "../../lib/api-client.js"; +import { output } from "../../lib/output.js"; + +interface SubscriptionCalendarOptions { + month?: string; + type?: string; + frequency?: string; + groupId?: string; +} + +export function buildSubscriptionCalendarParams( + opts: SubscriptionCalendarOptions, +): Record | undefined { + const params: Record = {}; + if (opts.month) params.month = opts.month; + if (opts.type) params.type = opts.type; + if (opts.frequency) params.frequency = opts.frequency; + if (opts.groupId) params.groupId = opts.groupId; + return Object.keys(params).length > 0 ? params : undefined; +} + +export const subscriptionCalendarCommand = new Command("calendar") + .description("Show the monthly subscription calendar") + .option("--month ", "Calendar month (YYYY-MM)") + .option("--type ", "Filter by type (SUBSCRIPTION|SERVICE|ALL)") + .option("--frequency ", "Filter by frequency (MONTHLY|YEARLY|ALL)") + .option("--group-id ", "Filter by subscription group ID") + .action(async (opts: SubscriptionCalendarOptions) => { + const data = await apiRequest( + "GET", + "/api/subscriptions/calendar", + undefined, + buildSubscriptionCalendarParams(opts), + ); + output.success(data); + }); diff --git a/src/commands/subscriptions/create.ts b/src/commands/subscriptions/create.ts index ecf0a2c..1597873 100644 --- a/src/commands/subscriptions/create.ts +++ b/src/commands/subscriptions/create.ts @@ -15,6 +15,7 @@ export const createSubscriptionCommand = new Command("create") .option("--icon ", "Icon") .option("--color ", "Color") .option("--category-id ", "Category ID") + .option("--group-id ", "Subscription group ID") .option("--type ", "Type") .option("--auto-record", "Enable auto-record") .option("--start-date ", "Start date (YYYY-MM-DD)") @@ -31,6 +32,7 @@ export const createSubscriptionCommand = new Command("create") { opt: "icon", body: "icon" }, { opt: "color", body: "color" }, { opt: "categoryId", body: "categoryId" }, + { opt: "groupId", body: "groupId" }, { opt: "type", body: "type" }, { opt: "autoRecord", body: "autoRecord", type: "boolean" }, { opt: "startDate", body: "startDate" }, diff --git a/src/commands/subscriptions/update.ts b/src/commands/subscriptions/update.ts index 5bdc103..2366a52 100644 --- a/src/commands/subscriptions/update.ts +++ b/src/commands/subscriptions/update.ts @@ -23,6 +23,8 @@ export const updateSubscriptionCommand = new Command("update") .option("--clear-color", "Clear color") .option("--category-id ", "Category ID") .option("--clear-category-id", "Clear category ID") + .option("--group-id ", "Subscription group ID") + .option("--clear-group-id", "Clear subscription group") .option("--type ", "Type") .option("--start-date ", "Start date (YYYY-MM-DD)") .option("--clear-start-date", "Clear start date") @@ -51,6 +53,7 @@ export const updateSubscriptionCommand = new Command("update") { opt: "icon", body: "icon", clearOpt: "clearIcon" }, { opt: "color", body: "color", clearOpt: "clearColor" }, { opt: "categoryId", body: "categoryId", clearOpt: "clearCategoryId" }, + { opt: "groupId", body: "groupId", clearOpt: "clearGroupId" }, { opt: "type", body: "type" }, { opt: "startDate", body: "startDate", clearOpt: "clearStartDate" }, { opt: "autoRecord", body: "autoRecord", type: "boolean" }, diff --git a/src/index.ts b/src/index.ts index 58fc2e4..5ae13d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,12 +27,18 @@ import { createTransferCommand } from "./commands/transfers/create.js"; import { updateTransferCommand } from "./commands/transfers/update.js"; import { deleteTransferCommand } from "./commands/transfers/delete.js"; +// Investments +import { investmentsCommand } from "./commands/investments/index.js"; + // Subscriptions import { listSubscriptionsCommand } from "./commands/subscriptions/list.js"; import { createSubscriptionCommand } from "./commands/subscriptions/create.js"; import { updateSubscriptionCommand } from "./commands/subscriptions/update.js"; import { deleteSubscriptionCommand } from "./commands/subscriptions/delete.js"; import { markPaidCommand } from "./commands/subscriptions/mark-paid.js"; +import { subscriptionCalendarCommand } from "./commands/subscriptions/calendar.js"; +import { subscriptionChargesCommand } from "./commands/subscription-charges/index.js"; +import { subscriptionGroupsCommand } from "./commands/subscription-groups/index.js"; // Loans import { listLoansCommand } from "./commands/loans/list.js"; @@ -99,6 +105,8 @@ transfers.addCommand(createTransferCommand); transfers.addCommand(updateTransferCommand); transfers.addCommand(deleteTransferCommand); +program.addCommand(investmentsCommand); + // Grupo: subscriptions const subscriptions = program .command("subscriptions") @@ -108,6 +116,10 @@ subscriptions.addCommand(createSubscriptionCommand); subscriptions.addCommand(updateSubscriptionCommand); subscriptions.addCommand(deleteSubscriptionCommand); subscriptions.addCommand(markPaidCommand); +subscriptions.addCommand(subscriptionCalendarCommand); + +program.addCommand(subscriptionGroupsCommand); +program.addCommand(subscriptionChargesCommand); // Grupo: loans const loans = program.command("loans").description("Manage loans"); diff --git a/tests/commands/accounts-create.test.ts b/tests/commands/accounts-create.test.ts index 88651ab..4ee376b 100644 --- a/tests/commands/accounts-create.test.ts +++ b/tests/commands/accounts-create.test.ts @@ -8,6 +8,8 @@ vi.mock("../../src/lib/api-client.js", () => ({ const { buildCreateAccountBody } = await import("../../src/commands/accounts/create.js"); +const { updateAccountCommand } = + await import("../../src/commands/accounts/update.js"); describe("accounts create", () => { beforeEach(() => { @@ -70,4 +72,12 @@ describe("accounts create", () => { }), ).toThrow(); }); + + it("account update exposes backend currency edits", () => { + const currencyOption = updateAccountCommand.options.find( + (option) => option.long === "--currency", + ); + + expect(currencyOption?.description).toBe("Currency code"); + }); }); diff --git a/tests/commands/investments.test.ts b/tests/commands/investments.test.ts new file mode 100644 index 0000000..5d65574 --- /dev/null +++ b/tests/commands/investments.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { buildArchivedItemsParams } from "../../src/commands/investments/archive.js"; +import { + buildCreateCashAdjustmentBody, + buildUpdateCashAdjustmentBody, +} from "../../src/commands/investments/cash.js"; +import { + buildInstrumentListParams, + buildInstrumentSearchParams, +} from "../../src/commands/investments/instruments.js"; +import { + buildInvestmentActivityParams, + buildInvestmentHistoryParams, +} from "../../src/commands/investments/portfolio.js"; +import { buildInvestmentRefreshParams } from "../../src/commands/investments/refresh.js"; +import { + buildCreateTradeBody, + buildUpdateTradeBody, +} from "../../src/commands/investments/trades.js"; + +describe("investments commands", () => { + it("builds instrument list filters", () => { + expect( + buildInstrumentListParams({ + limit: "25", + offset: "10", + rank: "popular", + type: "ETF", + exchange: "NASDAQ", + inactive: true, + }), + ).toEqual({ + limit: "25", + offset: "10", + rank: "popular", + type: "ETF", + exchange: "NASDAQ", + isActive: "false", + }); + }); + + it("builds instrument search params", () => { + expect(buildInstrumentSearchParams("NVDA", { limit: "5" })).toEqual({ + q: "NVDA", + limit: "5", + }); + }); + + it("builds activity filters", () => { + expect( + buildInvestmentActivityParams({ + limit: "20", + offset: "40", + kind: "trades", + }), + ).toEqual({ + limit: "20", + offset: "40", + kind: "trades", + }); + }); + + it("builds history filters", () => { + expect(buildInvestmentHistoryParams({ range: "90d" })).toEqual({ + range: "90d", + }); + expect(buildInvestmentHistoryParams({})).toBeUndefined(); + }); + + it("builds create trade payload", () => { + expect( + buildCreateTradeBody({ + instrumentId: "inst-1", + side: "buy", + quantity: "2.5", + price: "100", + fee: "1.25", + executedAt: "2026-05-28T03:00:00.000Z", + notes: "Initial position", + }), + ).toEqual({ + instrumentId: "inst-1", + side: "BUY", + quantity: 2.5, + price: 100, + fee: 1.25, + executedAt: "2026-05-28T03:00:00.000Z", + notes: "Initial position", + }); + }); + + it("builds update trade payload and clear notes", () => { + expect( + buildUpdateTradeBody({ + price: "102.5", + clearNotes: true, + }), + ).toEqual({ + price: 102.5, + notes: null, + }); + }); + + it("builds cash adjustment payloads", () => { + expect( + buildCreateCashAdjustmentBody({ + amount: "250", + occurredAt: "2026-05-28T03:00:00.000Z", + note: "Deposit", + }), + ).toEqual({ + amount: 250, + occurredAt: "2026-05-28T03:00:00.000Z", + note: "Deposit", + }); + + expect( + buildUpdateCashAdjustmentBody({ + amount: "-50", + clearNote: true, + }), + ).toEqual({ + amount: -50, + note: null, + }); + }); + + it("builds archived item filters", () => { + expect( + buildArchivedItemsParams({ + limit: "10", + offset: "30", + kind: "trade", + }), + ).toEqual({ + limit: "10", + offset: "30", + kind: "trade", + }); + }); + + it("builds refresh params", () => { + expect( + buildInvestmentRefreshParams({ + action: "catalog", + exchanges: "NYSE,NASDAQ", + country: "United States", + }), + ).toEqual({ + action: "catalog", + exchanges: "NYSE,NASDAQ", + country: "United States", + }); + }); +}); diff --git a/tests/commands/subscription-charges.test.ts b/tests/commands/subscription-charges.test.ts new file mode 100644 index 0000000..b6ffba5 --- /dev/null +++ b/tests/commands/subscription-charges.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { buildPendingChargesParams } from "../../src/commands/subscription-charges/index.js"; + +describe("subscription charges commands", () => { + it("builds pending charge pagination params", () => { + expect(buildPendingChargesParams({ limit: "10", offset: "20" })).toEqual({ + limit: "10", + offset: "20", + }); + expect(buildPendingChargesParams({})).toBeUndefined(); + }); +}); diff --git a/tests/commands/subscription-groups.test.ts b/tests/commands/subscription-groups.test.ts new file mode 100644 index 0000000..f7fb10d --- /dev/null +++ b/tests/commands/subscription-groups.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { parseGroupIds } from "../../src/commands/subscription-groups/index.js"; + +describe("subscription groups commands", () => { + it("parses comma-separated group ids", () => { + expect(parseGroupIds("group-1, group-2,,group-3")).toEqual([ + "group-1", + "group-2", + "group-3", + ]); + }); +}); diff --git a/tests/commands/subscriptions-list.test.ts b/tests/commands/subscriptions-list.test.ts index 8fb4f87..9f4de0a 100644 --- a/tests/commands/subscriptions-list.test.ts +++ b/tests/commands/subscriptions-list.test.ts @@ -3,6 +3,7 @@ import { buildSubscriptionListParams, getSubscriptionItems, } from "../../src/commands/subscriptions/list.js"; +import { buildSubscriptionCalendarParams } from "../../src/commands/subscriptions/calendar.js"; describe("subscriptions list", () => { it("accepts legacy array responses", () => { @@ -40,4 +41,20 @@ describe("subscriptions list", () => { groupId: "group-1", }); }); + + it("builds monthly calendar filters", () => { + expect( + buildSubscriptionCalendarParams({ + month: "2026-05", + frequency: "MONTHLY", + type: "SUBSCRIPTION", + groupId: "group-1", + }), + ).toEqual({ + month: "2026-05", + frequency: "MONTHLY", + type: "SUBSCRIPTION", + groupId: "group-1", + }); + }); });