ledgerline
Version:
A modern interactive CLI to track expenses, stored locally in JSON.
189 lines (187 loc) • 6.91 kB
JavaScript
import { Command } from "commander";
import inquirer from "inquirer";
import chalk from "chalk";
import { nanoid } from "nanoid";
import { loadExpenses, saveExpenses, getDataFilePath } from "./storage.js";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const pkg = require("../package.json");
const program = new Command();
program
.name("ledgerline")
.description("Track expenses in a local JSON store")
.version(pkg.version)
.showHelpAfterError()
.addHelpText("afterAll", `
List filters (for 'list' command):
-c, --category <category> Filter by category
--since <YYYY-MM-DD> Start date inclusive
--until <YYYY-MM-DD> End date inclusive
-l, --limit <n> Limit number of results
--json Output raw JSON
Examples:
ledgerline add
ledgerline add --amount 12.5 --category Food --date 2025-08-07 --note "Lunch"
ledgerline list --category Food --since 2025-08-01 --until 2025-08-31
`);
program
.command("add")
.description("Add a new expense")
.option("-a, --amount <number>", "Amount", (v) => parseFloat(v))
.option("-c, --category <category>", "Category")
.option("-d, --date <YYYY-MM-DD>", "Date in YYYY-MM-DD")
.option("-n, --note <note>", "Note")
.option("--currency <currency>", "Currency (ISO code)", "EUR")
.option("-i, --interactive", "Prompt for values", false)
.action(async (opts) => {
const shouldPrompt = opts.interactive || opts.amount == null || !opts.category || !opts.date;
let amount = opts.amount;
let category = opts.category;
let date = opts.date;
let note = opts.note;
let currency = opts.currency;
if (shouldPrompt) {
const answers = await inquirer.prompt([
{
type: "input",
name: "amount",
message: "Amount",
when: () => amount == null,
validate: (v) => isNaN(parseFloat(v)) ? "Enter a number" : true,
filter: (v) => parseFloat(v)
},
{
type: "list",
name: "category",
message: "Category",
when: () => !category,
choices: [
"Food",
"Transport",
"Groceries",
"Utilities",
"Rent",
"Entertainment",
"Health",
"Shopping",
"Travel",
"Other"
],
default: "Other"
},
{
type: "input",
name: "date",
message: "Date (YYYY-MM-DD)",
when: () => !date,
default: new Date().toISOString().slice(0, 10),
validate: (v) => /^\d{4}-\d{2}-\d{2}$/.test(v) ? true : "Use YYYY-MM-DD"
},
{
type: "input",
name: "note",
message: "Note (optional)",
when: () => !note
},
{
type: "input",
name: "currency",
message: "Currency (ISO)",
when: () => !currency,
default: "EUR"
}
]);
amount = amount ?? answers.amount;
category = category ?? answers.category;
date = date ?? answers.date;
note = note ?? answers.note;
currency = currency ?? answers.currency;
}
if (typeof amount !== "number" || isNaN(amount)) {
console.error(chalk.red("Amount is required. Use --amount or run with --interactive."));
process.exit(1);
}
if (!category) {
console.error(chalk.red("Category is required. Use --category or run with --interactive."));
process.exit(1);
}
if (!date) {
date = new Date().toISOString().slice(0, 10);
}
else if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
console.error(chalk.red("Date must be in YYYY-MM-DD"));
process.exit(1);
}
const expense = {
id: nanoid(10),
amount,
currency: currency || "EUR",
category,
date,
note: note || undefined,
createdAt: new Date().toISOString()
};
const expenses = await loadExpenses();
expenses.push(expense);
await saveExpenses(expenses);
console.log(chalk.green("Saved expense:"), chalk.bold(expense.currency), expense.amount.toFixed(2), chalk.gray(`(${expense.category} on ${expense.date})`), expense.note ? `- ${expense.note}` : "");
console.log(chalk.gray(`Data file: ${getDataFilePath()}`));
});
program
.command("list")
.description("List expenses")
.option("-c, --category <category>", "Filter by category")
.option("--since <YYYY-MM-DD>", "Start date inclusive")
.option("--until <YYYY-MM-DD>", "End date inclusive")
.option("-l, --limit <n>", "Limit number", (v) => parseInt(v, 10))
.option("--json", "Output raw JSON", false)
.action(async (opts) => {
let expenses = await loadExpenses();
if (opts.category) {
const categoryLower = String(opts.category).toLowerCase();
expenses = expenses.filter((e) => e.category.toLowerCase() === categoryLower);
}
const validDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s);
if (opts.since) {
if (!validDate(opts.since)) {
console.error(chalk.red("since must be YYYY-MM-DD"));
process.exit(1);
}
expenses = expenses.filter((e) => e.date >= opts.since);
}
if (opts.until) {
if (!validDate(opts.until)) {
console.error(chalk.red("until must be YYYY-MM-DD"));
process.exit(1);
}
expenses = expenses.filter((e) => e.date <= opts.until);
}
expenses.sort((a, b) => a.date.localeCompare(b.date) || a.createdAt.localeCompare(b.createdAt));
if (opts.limit && Number.isFinite(opts.limit)) {
expenses = expenses.slice(-opts.limit);
}
if (opts.json) {
console.log(JSON.stringify(expenses, null, 2));
return;
}
if (expenses.length === 0) {
console.log(chalk.yellow("No expenses found."));
return;
}
const rows = expenses.map((e) => {
return `${chalk.gray(e.date)} ${chalk.bold(e.currency)} ${e.amount
.toFixed(2)
.padStart(8)} ${chalk.cyan(e.category.padEnd(14))} ${e.note ? e.note : ""}`;
});
console.log(rows.join("\n"));
const total = expenses.reduce((sum, e) => sum + e.amount, 0);
console.log(chalk.gray(`\nTotal: ${total.toFixed(2)} (mixed currencies may be summed as-is)`));
});
program
.command("path")
.description("Show the data file path")
.action(() => {
console.log(getDataFilePath());
});
program.parseAsync(process.argv);