← 中文资源

简体中文版本

构建持久化记账命令行工具与报表

按日期与分类记录支出、持久化到可 diff 的 CSV,并输出月度与分类汇总的命令行工具。

阅读英文原文

项目目标

本项目构建一个记账工具,记录带日期、分类与金额的支出,存为 CSV 文件,并输出月度与分类汇总。金额以整数分为单位避免浮点误差,日期以 ISO 天存储,规避时区导致的跨日错分。

核心特性

  • 新增支出并校验金额、分类与日期
  • 按月份或分类筛选列表
  • 分类与月度汇总及总计
  • CSV 持久化,便于编辑与在 git 中 diff

架构与分层

参数解析把 argv 转成命令对象;存储以表头 CSV 原子读写;报表模块分组并求和的纯函数;格式化器打印对齐表格。金额在边界处解析为整数分,之后所有求和都是精确的。

实施阶段

六个阶段为:参数解析、金额模块、CSV 存储、报表分组、CLI 表面、测试与加固。入账后所有求和都是整数运算。

测试与验收

用 vitest 运行。金额测试覆盖解析、取整、负值与格式化往返;存储测试把记录往返于临时 CSV 并断言表头只写一次;报表测试对样例账本断言精确总计,含无支出月份。

延伸方向

预算、周期条目与银行 CSV 导入都消费同一记录结构。分组是记录上的纯函数,因此新报表是新函数而非存储改动。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

export function parseAmount(input) {
  const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(String(input).trim());
  if (!match) throw new Error('invalid amount: ' + input);
  const [, sign, whole, frac = ''] = match;
  const cents = Number(whole) * 100 + Number((frac + '00').slice(0, 2));
  return sign === '-' ? -cents : cents;
}

export function formatAmount(cents) {
  const sign = cents < 0 ? '-' : '';
  const abs = Math.abs(cents);
  return sign + Math.floor(abs / 100) + '.' + String(abs % 100).padStart(2, '0');
}
mkdir expense-cli && cd expense-cli
npm init -y
npm install -D vitest@^2.1.0
{
  "name": "expense-cli",
  "type": "module",
  "scripts": {
    "expense": "node src/cli.js",
    "test": "vitest run"
  }
}
export function parseArgs(argv) {
  const [command, ...rest] = argv;
  const options = {};
  const positionals = [];
  for (let i = 0; i < rest.length; i++) {
    const token = rest[i];
    if (token.startsWith('--')) {
      const [key, inline] = token.slice(2).split('=');
      options[key] = inline ?? rest[++i];
    } else positionals.push(token);
  }
  return { command, options, positionals };
}

export const COMMANDS = new Set(['add', 'list', 'report']);
export function parseAmount(input) {
  const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(String(input).trim());
  if (!match) throw new Error('invalid amount: ' + input);
  const [, sign, whole, frac = ''] = match;
  const cents = Number(whole) * 100 + Number((frac + '00').slice(0, 2));
  return sign === '-' ? -cents : cents;
}

export function sum(amounts) {
  return amounts.reduce((total, cents) => total + cents, 0);
}
import { readFile, writeFile, rename } from 'node:fs/promises';

const HEADER = ['date', 'category', 'amountCents', 'note'];

export function toCsv(records) {
  const rows = [HEADER.join(',')];
  for (const r of records) rows.push([r.date, r.category, r.amountCents, escapeCsv(r.note ?? '')].join(','));
  return rows.join('\n') + '\n';
}

export function fromCsv(text) {
  return text.trim().split('\n').slice(1).filter(Boolean).map((line) => {
    const [date, category, amountCents, ...note] = parseCsvLine(line);
    return { date, category, amountCents: Number(amountCents), note: note.join(',') };
  });
}

function escapeCsv(value) {
  return /[",\n]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value;
}

function parseCsvLine(line) {
  return line.match(/("([^"]|"")*"|[^,]*)(,|$)/g).slice(0, -1).map((f) =>
    f.replace(/,$/, '').replace(/^"|"$/g, '').replace(/""/g, '"'));
}
export function byCategory(records) {
  const groups = new Map();
  for (const r of records) {
    const bucket = groups.get(r.category) ?? { category: r.category, total: 0, count: 0 };
    bucket.total += r.amountCents;
    bucket.count += 1;
    groups.set(r.category, bucket);
  }
  return [...groups.values()].sort((a, b) => b.total - a.total);
}

export function byMonth(records) {
  const groups = new Map();
  for (const r of records) {
    const month = r.date.slice(0, 7);
    groups.set(month, (groups.get(month) ?? 0) + r.amountCents);
  }
  return [...groups.entries()].map(([month, total]) => ({ month, total }))
    .sort((a, b) => a.month.localeCompare(b.month));
}
import { parseArgs, COMMANDS } from './parse.js';
import { parseAmount, formatAmount } from './money.js';
import { fromCsv, toCsv } from './store.js';
import { byCategory } from './report.js';

const { command, options } = parseArgs(process.argv.slice(2));
if (!COMMANDS.has(command)) {
  console.error('usage: expense <add|list|report> [options]');
  process.exit(1);
}

if (command === 'add') {
  const cents = parseAmount(options.amount ?? '');
  console.log('recorded ' + formatAmount(cents) + ' in ' + options.category);
} else if (command === 'report') {
  for (const g of byCategory([])) console.log(g.category.padEnd(12) + formatAmount(g.total));
}
import { byCategory, byMonth } from '../src/report.js';

const ledger = [
  { date: '2026-01-02', category: 'food', amountCents: 1250 },
  { date: '2026-01-15', category: 'travel', amountCents: 8000 },
  { date: '2026-02-03', category: 'food', amountCents: 900 },
];

test('category totals add up', () => {
  expect(byCategory(ledger)).toEqual([
    { category: 'travel', total: 8000, count: 1 },
    { category: 'food', total: 2150, count: 2 },
  ]);
});

test('month totals split by month', () => {
  expect(byMonth(ledger)).toEqual([
    { month: '2026-01', total: 9250 },
    { month: '2026-02', total: 900 },
  ]);
});