import { describe, expect, it } from 'vitest'; import { calculateBudgetProgress, getPeriodRange, type Budget } from '../src/domain/budgets'; import { calculateNextDueDate, getDueRecurring, instantiateRecurring, type RecurringTransaction } from '../src/domain/recurring'; import { buildTransactionCurrency, convertCurrency, getEffectiveRate, validateRateCoverage, type ExchangeRate } from '../src/domain/currency'; import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit, type CreditCard } from '../src/domain/creditCards'; import type { Transaction } from '../src/domain/types'; const tx = (over: Partial): Transaction => ({ id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '', ...over, }); describe('budgets', () => { const budget: Budget = { id: 'b1', name: '餐饮', amount: '1000', period: 'monthly', categoryAccount: 'Expenses:Food', startDate: '2026-01-01' }; const transactions = [ tx({ id: 't1', date: '2026-07-05', postings: [{ account: 'Assets:Alipay', amount: '-100', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }] }), tx({ id: 't2', date: '2026-07-10', postings: [{ account: 'Assets:Alipay', amount: '-200', currency: 'CNY' }, { account: 'Expenses:Food', amount: '200', currency: 'CNY' }] }), tx({ id: 't3', date: '2026-07-15', postings: [{ account: 'Assets:Alipay', amount: '-50', currency: 'CNY' }, { account: 'Expenses:Transport', amount: '50', currency: 'CNY' }] }), ]; it('计算预算进度', () => { const progress = calculateBudgetProgress(budget, transactions, '2026-07-15'); expect(progress.spent).toBe('300'); // 100+200(Food) expect(progress.percentage).toBe(30); // 300/1000 expect(progress.overBudget).toBe(false); }); it('多入账与退款与总预算测试', () => { const testTxs = [ // 1. 多分录交易:买了食物 100,交通 50,支付宝付了 150 tx({ id: 't_multi', date: '2026-07-06', postings: [ { account: 'Assets:Alipay', amount: '-150', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }, { account: 'Expenses:Transport', amount: '50', currency: 'CNY' } ] }), // 2. 退款交易:食物退了 10 tx({ id: 't_refund', date: '2026-07-07', postings: [ { account: 'Assets:Alipay', amount: '10', currency: 'CNY' }, { account: 'Expenses:Food', amount: '-10', currency: 'CNY' } ] }), // 3. 转账交易:微信转支付宝 50(不应计入预算) tx({ id: 't_transfer', date: '2026-07-08', postings: [ { account: 'Assets:WeChat', amount: '-50', currency: 'CNY' }, { account: 'Assets:Alipay', amount: '50', currency: 'CNY' } ] }), // 4. 工资收入:5000(不应计入预算) tx({ id: 't_salary', date: '2026-07-09', postings: [ { account: 'Assets:Alipay', amount: '5000', currency: 'CNY' }, { account: 'Income:Salary', amount: '-5000', currency: 'CNY' } ] }) ]; // 食物预算进度应该只计算食物分录:100 - 10 = 90 const foodProgress = calculateBudgetProgress(budget, testTxs, '2026-07-15'); expect(foodProgress.spent).toBe('90'); // 总预算进度应该计算所有 Expenses:* 分录:(100 + 50) - 10 = 140 const totalBudget: Budget = { id: 'b_total', name: '总预算', amount: '1000', period: 'monthly', startDate: '2026-01-01' }; const totalProgress = calculateBudgetProgress(totalBudget, testTxs, '2026-07-15'); expect(totalProgress.spent).toBe('140'); }); it('超支检测', () => { const smallBudget = { ...budget, amount: '200' }; const progress = calculateBudgetProgress(smallBudget, transactions, '2026-07-15'); expect(progress.overBudget).toBe(true); }); it('getPeriodRange monthly', () => { const range = getPeriodRange('monthly', '2026-07-15'); expect(range.start).toBe('2026-07-01'); expect(range.end).toBe('2026-07-31'); }); it('getPeriodRange yearly', () => { const range = getPeriodRange('yearly', '2026-07-15'); expect(range.start).toBe('2026-01-01'); expect(range.end).toBe('2026-12-31'); }); }); describe('recurring', () => { const r: RecurringTransaction = { id: 'r1', name: '房租', frequency: 'monthly', interval: 1, nextDueDate: '2026-07-01', enabled: true, draft: { date: '2026-07-01', narration: '房租', postings: [{ account: 'Assets:Bank', amount: '-3000', currency: 'CNY' }, { account: 'Expenses:Rent', amount: '3000', currency: 'CNY' }] }, }; it('getDueRecurring 检测到期', () => { expect(getDueRecurring([r], '2026-07-01')).toHaveLength(1); expect(getDueRecurring([r], '2026-06-30')).toHaveLength(0); }); it('calculateNextDueDate monthly', () => { expect(calculateNextDueDate('monthly', 1, '2026-07-01')).toBe('2026-08-01'); expect(calculateNextDueDate('monthly', 1, '2026-07-15')).toBe('2026-08-15'); expect(calculateNextDueDate('monthly', 1, '2026-01-31')).toBe('2026-02-28'); expect(calculateNextDueDate('monthly', 2, '2026-01-31')).toBe('2026-03-31'); }); it('calculateNextDueDate weekly', () => { expect(calculateNextDueDate('weekly', 2, '2026-07-01')).toBe('2026-07-15'); }); it('calculateNextDueDate yearly', () => { expect(calculateNextDueDate('yearly', 1, '2026-07-15')).toBe('2027-07-15'); expect(calculateNextDueDate('yearly', 1, '2024-02-29')).toBe('2025-02-28'); expect(calculateNextDueDate('yearly', 4, '2024-02-29')).toBe('2028-02-29'); }); it('instantiateRecurring', () => { const draft = instantiateRecurring(r); expect(draft.date).toBe('2026-07-01'); }); }); describe('currency', () => { const rates: ExchangeRate[] = [{ from: 'USD', to: 'CNY', rate: '7.2', date: '2026-07-13' }]; it('convertCurrency', () => { expect(convertCurrency('100', 'USD', 'CNY', rates)).toBe('720.00'); }); it('同币种不转换', () => { expect(convertCurrency('100', 'CNY', 'CNY', rates)).toBe('100'); }); it('getEffectiveRate 手动覆盖优先', () => { const overrides = [{ from: 'USD', to: 'CNY', rate: '7.0', enabled: true }]; const rate = getEffectiveRate('USD', 'CNY', rates, overrides); expect(rate?.rate).toBe('7.0'); }); it('buildTransactionCurrency 同币种', () => { const tc = buildTransactionCurrency('100', 'CNY', 'CNY', rates); expect(tc.exchangeRate).toBe('1'); }); it('buildTransactionCurrency 跨币种', () => { const tc = buildTransactionCurrency('100', 'USD', 'CNY', rates); expect(tc.exchangeRate).toBe('7.2'); }); it('validateRateCoverage', () => { const missing = validateRateCoverage('CNY', ['CNY', 'USD', 'EUR'], rates); expect(missing).toEqual(['EUR']); }); }); describe('creditCards', () => { const card: CreditCard = { id: 'c1', name: '招行信用卡', lastFour: '1234', bankName: 'CMB', billingDay: 5, paymentDay: 25, creditLimit: '10000', currency: 'CNY', linkedAccount: 'Liabilities:CreditCard:CMB' }; const transactions = [ tx({ id: 't1', date: '2026-07-10', postings: [{ account: 'Liabilities:CreditCard:CMB', amount: '-100', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }] }), tx({ id: 't2', date: '2026-07-15', postings: [{ account: 'Liabilities:CreditCard:CMB', amount: '-200', currency: 'CNY' }, { account: 'Expenses:Food', amount: '200', currency: 'CNY' }] }), ]; it('calculateBillingPeriod', () => { const period = calculateBillingPeriod(card, new Date('2026-07-20')); expect(period.periodStart).toBe('2026-06-05'); expect(period.periodEnd).toBe('2026-07-05'); expect(period.dueDate).toBe('2026-07-25'); }); it('calculateStatementAmount', () => { // 7月10日、15日 不在 6/5~7/5 周期内 → 0 const period = calculateBillingPeriod(card, new Date('2026-07-20')); expect(calculateStatementAmount(transactions, card, period)).toBe('0'); // 用 8/20 周期(7/5~8/5)→ 包含 7/10、7/15 const period2 = calculateBillingPeriod(card, new Date('2026-08-20')); expect(calculateStatementAmount(transactions, card, period2)).toBe('300'); }); it('calculateAvailableCredit', () => { expect(calculateAvailableCredit(card, '-500')).toBe('9500'); }); });