项目目标
本项目构建一个记账工具,记录带日期、分类与金额的支出,存为 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 },
]);
});