financeops-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +24 -0
  3. package/.github/assets/banner.svg +104 -0
  4. package/.github/pull_request_template.md +25 -0
  5. package/CODE_OF_CONDUCT.md +40 -0
  6. package/CONTRIBUTING.md +71 -0
  7. package/LICENSE +21 -0
  8. package/README.md +390 -0
  9. package/SECURITY.md +30 -0
  10. package/dist/adapters/stripe.d.ts +15 -0
  11. package/dist/adapters/stripe.js +175 -0
  12. package/dist/adapters/types.d.ts +30 -0
  13. package/dist/adapters/types.js +2 -0
  14. package/dist/adapters/xero.d.ts +19 -0
  15. package/dist/adapters/xero.js +261 -0
  16. package/dist/analysis/anomaly.d.ts +12 -0
  17. package/dist/analysis/anomaly.js +103 -0
  18. package/dist/analysis/cashflow.d.ts +10 -0
  19. package/dist/analysis/cashflow.js +134 -0
  20. package/dist/analysis/pnl.d.ts +8 -0
  21. package/dist/analysis/pnl.js +56 -0
  22. package/dist/analysis/reconciliation.d.ts +14 -0
  23. package/dist/analysis/reconciliation.js +98 -0
  24. package/dist/analysis/tax.d.ts +11 -0
  25. package/dist/analysis/tax.js +81 -0
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.js +565 -0
  28. package/dist/lib/audit.d.ts +17 -0
  29. package/dist/lib/audit.js +70 -0
  30. package/dist/lib/providers.d.ts +6 -0
  31. package/dist/lib/providers.js +25 -0
  32. package/dist/premium/gate.d.ts +12 -0
  33. package/dist/premium/gate.js +22 -0
  34. package/dist/types.d.ts +138 -0
  35. package/dist/types.js +7 -0
  36. package/mcpize.yaml +10 -0
  37. package/package.json +35 -0
  38. package/src/adapters/stripe.ts +190 -0
  39. package/src/adapters/types.ts +34 -0
  40. package/src/adapters/xero.ts +317 -0
  41. package/src/analysis/anomaly.ts +119 -0
  42. package/src/analysis/cashflow.ts +158 -0
  43. package/src/analysis/pnl.ts +80 -0
  44. package/src/analysis/reconciliation.ts +117 -0
  45. package/src/analysis/tax.ts +98 -0
  46. package/src/index.ts +649 -0
  47. package/src/lib/audit.ts +92 -0
  48. package/src/lib/providers.ts +29 -0
  49. package/src/premium/gate.ts +24 -0
  50. package/src/types.ts +153 -0
  51. package/tests/adapters/stripe.test.ts +150 -0
  52. package/tests/adapters/xero.test.ts +188 -0
  53. package/tests/analysis/anomaly.test.ts +137 -0
  54. package/tests/analysis/cashflow.test.ts +112 -0
  55. package/tests/analysis/pnl.test.ts +95 -0
  56. package/tests/analysis/reconciliation.test.ts +121 -0
  57. package/tests/analysis/tax.test.ts +163 -0
  58. package/tests/helpers/mock-data.ts +135 -0
  59. package/tests/lib/audit.test.ts +89 -0
  60. package/tests/lib/providers.test.ts +129 -0
  61. package/tests/premium/cash_flow_forecast.test.ts +157 -0
  62. package/tests/premium/detect_anomalies.test.ts +189 -0
  63. package/tests/premium/gate.test.ts +59 -0
  64. package/tests/premium/generate_pnl.test.ts +155 -0
  65. package/tests/premium/multi_currency_report.test.ts +141 -0
  66. package/tests/premium/reconcile.test.ts +174 -0
  67. package/tests/premium/tax_summary.test.ts +166 -0
  68. package/tests/tools/expense_tracker.test.ts +181 -0
  69. package/tests/tools/financial_health.test.ts +196 -0
  70. package/tests/tools/get_balances.test.ts +160 -0
  71. package/tests/tools/list_invoices.test.ts +191 -0
  72. package/tests/tools/list_transactions.test.ts +210 -0
  73. package/tests/tools/revenue_summary.test.ts +188 -0
  74. package/tsconfig.json +20 -0
  75. package/vitest.config.ts +9 -0
@@ -0,0 +1,174 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { reconcile } from '../../src/analysis/reconciliation.js';
3
+ import type { Transaction } from '../../src/types.js';
4
+
5
+ vi.mock('stripe', () => ({
6
+ default: vi.fn().mockImplementation(() => ({
7
+ balanceTransactions: { list: vi.fn() },
8
+ balance: { retrieve: vi.fn() },
9
+ invoices: { list: vi.fn() },
10
+ subscriptions: { list: vi.fn() },
11
+ })),
12
+ }));
13
+
14
+ afterEach(() => {
15
+ delete process.env.PRO_LICENSE;
16
+ });
17
+
18
+ function makeTransaction(
19
+ id: string,
20
+ date: string,
21
+ amount: number,
22
+ description: string,
23
+ provider: 'stripe' | 'xero'
24
+ ): Transaction {
25
+ return {
26
+ id,
27
+ date,
28
+ amount,
29
+ currency: 'usd',
30
+ description,
31
+ type: 'income',
32
+ provider,
33
+ };
34
+ }
35
+
36
+ describe('reconcile — requirePro gate', () => {
37
+ it('throws ProRequiredError without license', async () => {
38
+ delete process.env.PRO_LICENSE;
39
+ const { requirePro, ProRequiredError } = await import('../../src/premium/gate.js');
40
+ expect(() => requirePro('reconcile')).toThrow(ProRequiredError);
41
+ });
42
+
43
+ it('does not throw with PRO_LICENSE set', async () => {
44
+ process.env.PRO_LICENSE = 'license-key';
45
+ const { requirePro } = await import('../../src/premium/gate.js');
46
+ expect(() => requirePro('reconcile')).not.toThrow();
47
+ });
48
+
49
+ it('HARD GATE — error message contains tool name', async () => {
50
+ delete process.env.PRO_LICENSE;
51
+ const { requirePro, ProRequiredError } = await import('../../src/premium/gate.js');
52
+ try {
53
+ requirePro('reconcile');
54
+ expect.fail('should have thrown');
55
+ } catch (err) {
56
+ expect(err).toBeInstanceOf(ProRequiredError);
57
+ expect((err as Error).message).toContain('reconcile');
58
+ }
59
+ });
60
+ });
61
+
62
+ describe('reconcile — Stripe↔Xero matching', () => {
63
+ it('matches transactions with same amount, close date, similar description', () => {
64
+ const stripe = [makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment from Acme Corp', 'stripe')];
65
+ const xero = [makeTransaction('x1', '2026-03-01T14:00:00Z', 5000, 'Payment Acme Corp', 'xero')];
66
+
67
+ const result = reconcile(stripe, xero);
68
+
69
+ expect(result.matched).toHaveLength(1);
70
+ expect(result.matched[0].stripe.id).toBe('s1');
71
+ expect(result.matched[0].xero.id).toBe('x1');
72
+ });
73
+
74
+ it('HARD GATE — does not match same amount but different descriptions', () => {
75
+ const stripe = [makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'AWS hosting fee', 'stripe')];
76
+ const xero = [makeTransaction('x1', '2026-03-02T10:00:00Z', 5000, 'Employee lunch reimbursement', 'xero')];
77
+
78
+ const result = reconcile(stripe, xero);
79
+
80
+ expect(result.matched).toHaveLength(0);
81
+ expect(result.unmatched_stripe).toHaveLength(1);
82
+ expect(result.unmatched_xero).toHaveLength(1);
83
+ });
84
+
85
+ it('does not match transactions with different amounts', () => {
86
+ const stripe = [makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment Acme', 'stripe')];
87
+ const xero = [makeTransaction('x1', '2026-03-01T10:00:00Z', 5001, 'Payment Acme', 'xero')];
88
+
89
+ const result = reconcile(stripe, xero);
90
+
91
+ expect(result.matched).toHaveLength(0);
92
+ });
93
+
94
+ it('does not match transactions outside 2-day tolerance', () => {
95
+ const stripe = [makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment Acme Corp', 'stripe')];
96
+ const xero = [makeTransaction('x1', '2026-03-04T10:00:00Z', 5000, 'Payment Acme Corp', 'xero')];
97
+
98
+ const result = reconcile(stripe, xero);
99
+
100
+ expect(result.matched).toHaveLength(0);
101
+ expect(result.unmatched_stripe).toHaveLength(1);
102
+ expect(result.unmatched_xero).toHaveLength(1);
103
+ });
104
+
105
+ it('confidence score is between 0 and 1 for matched pairs', () => {
106
+ const stripe = [makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment from Acme', 'stripe')];
107
+ const xero = [makeTransaction('x1', '2026-03-01T10:00:00Z', 5000, 'Payment from Acme', 'xero')];
108
+
109
+ const result = reconcile(stripe, xero);
110
+
111
+ expect(result.matched[0].confidence).toBeGreaterThanOrEqual(0);
112
+ expect(result.matched[0].confidence).toBeLessThanOrEqual(1);
113
+ });
114
+
115
+ it('summary counts are correct', () => {
116
+ const stripe = [
117
+ makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment Acme Corp', 'stripe'),
118
+ makeTransaction('s2', '2026-03-02T10:00:00Z', 9900, 'Subscription renewal', 'stripe'),
119
+ ];
120
+ const xero = [
121
+ makeTransaction('x1', '2026-03-01T14:00:00Z', 5000, 'Payment Acme Corp', 'xero'),
122
+ makeTransaction('x2', '2026-03-10T10:00:00Z', 9900, 'Completely different description', 'xero'),
123
+ ];
124
+
125
+ const result = reconcile(stripe, xero);
126
+
127
+ expect(result.summary.total_stripe).toBe(2);
128
+ expect(result.summary.total_xero).toBe(2);
129
+ expect(result.summary.matched_count).toBe(1);
130
+ expect(result.summary.unmatched_stripe_count).toBe(1);
131
+ expect(result.summary.unmatched_xero_count).toBe(1);
132
+ });
133
+
134
+ it('empty inputs produce empty results', () => {
135
+ const result = reconcile([], []);
136
+
137
+ expect(result.matched).toHaveLength(0);
138
+ expect(result.unmatched_stripe).toHaveLength(0);
139
+ expect(result.unmatched_xero).toHaveLength(0);
140
+ expect(result.summary.matched_count).toBe(0);
141
+ });
142
+
143
+ it('does not double-match the same xero transaction', () => {
144
+ const stripe = [
145
+ makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment Acme Corp', 'stripe'),
146
+ makeTransaction('s2', '2026-03-01T11:00:00Z', 5000, 'Payment Acme Corp', 'stripe'),
147
+ ];
148
+ const xero = [
149
+ makeTransaction('x1', '2026-03-01T12:00:00Z', 5000, 'Payment Acme Corp', 'xero'),
150
+ ];
151
+
152
+ const result = reconcile(stripe, xero);
153
+
154
+ expect(result.matched).toHaveLength(1);
155
+ expect(result.unmatched_stripe).toHaveLength(1);
156
+ });
157
+
158
+ it('multiple matches are handled correctly', () => {
159
+ const stripe = [
160
+ makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment Acme Corp', 'stripe'),
161
+ makeTransaction('s2', '2026-03-05T10:00:00Z', 9900, 'Subscription BigCo', 'stripe'),
162
+ ];
163
+ const xero = [
164
+ makeTransaction('x1', '2026-03-01T14:00:00Z', 5000, 'Payment Acme Corp', 'xero'),
165
+ makeTransaction('x2', '2026-03-05T12:00:00Z', 9900, 'Subscription BigCo', 'xero'),
166
+ ];
167
+
168
+ const result = reconcile(stripe, xero);
169
+
170
+ expect(result.matched).toHaveLength(2);
171
+ expect(result.unmatched_stripe).toHaveLength(0);
172
+ expect(result.unmatched_xero).toHaveLength(0);
173
+ });
174
+ });
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { calculateTax } from '../../src/analysis/tax.js';
3
+ import type { Transaction } from '../../src/types.js';
4
+
5
+ vi.mock('stripe', () => ({
6
+ default: vi.fn().mockImplementation(() => ({
7
+ balanceTransactions: { list: vi.fn() },
8
+ balance: { retrieve: vi.fn() },
9
+ invoices: { list: vi.fn() },
10
+ subscriptions: { list: vi.fn() },
11
+ })),
12
+ }));
13
+
14
+ afterEach(() => {
15
+ delete process.env.PRO_LICENSE;
16
+ });
17
+
18
+ function makeIncomeTransaction(id: string, amount: number, metadata?: Record<string, string>): Transaction {
19
+ return {
20
+ id,
21
+ date: '2026-03-01T00:00:00Z',
22
+ amount,
23
+ currency: 'usd',
24
+ description: `Sale ${id}`,
25
+ type: 'income',
26
+ provider: 'stripe',
27
+ metadata,
28
+ };
29
+ }
30
+
31
+ describe('tax_summary — requirePro gate', () => {
32
+ it('throws ProRequiredError without license', async () => {
33
+ delete process.env.PRO_LICENSE;
34
+ const { requirePro, ProRequiredError } = await import('../../src/premium/gate.js');
35
+ expect(() => requirePro('tax_summary')).toThrow(ProRequiredError);
36
+ });
37
+
38
+ it('does not throw with PRO_LICENSE set', async () => {
39
+ process.env.PRO_LICENSE = 'license-key';
40
+ const { requirePro } = await import('../../src/premium/gate.js');
41
+ expect(() => requirePro('tax_summary')).not.toThrow();
42
+ });
43
+ });
44
+
45
+ describe('tax_summary — tax calculation', () => {
46
+ it('returns TaxSummary structure with required fields', () => {
47
+ const transactions = [makeIncomeTransaction('t1', 10000)];
48
+ const result = calculateTax(transactions, 'quarter');
49
+
50
+ expect(result).toHaveProperty('provider');
51
+ expect(result).toHaveProperty('period');
52
+ expect(result).toHaveProperty('total_taxable_amount');
53
+ expect(result).toHaveProperty('total_tax_collected');
54
+ expect(result).toHaveProperty('total_tax_owed');
55
+ expect(result).toHaveProperty('by_rate');
56
+ expect(result).toHaveProperty('filing_period');
57
+ });
58
+
59
+ it('total_taxable_amount > 0 when there are income transactions', () => {
60
+ const transactions = [
61
+ makeIncomeTransaction('t1', 10000),
62
+ makeIncomeTransaction('t2', 20000),
63
+ ];
64
+
65
+ const result = calculateTax(transactions, 'quarter');
66
+ expect(result.total_taxable_amount).toBeGreaterThan(0);
67
+ });
68
+
69
+ it('expense transactions are excluded from tax calculation', () => {
70
+ const transactions: Transaction[] = [
71
+ { id: 't1', date: '2026-03-01', amount: 50000, currency: 'usd', description: 'Expense', type: 'expense', provider: 'stripe' },
72
+ ];
73
+
74
+ const result = calculateTax(transactions, 'quarter');
75
+ expect(result.total_taxable_amount).toBe(0);
76
+ expect(result.total_tax_collected).toBe(0);
77
+ expect(result.by_rate).toHaveLength(0);
78
+ });
79
+
80
+ it('empty transaction list returns zeros', () => {
81
+ const result = calculateTax([], 'quarter');
82
+
83
+ expect(result.total_taxable_amount).toBe(0);
84
+ expect(result.total_tax_collected).toBe(0);
85
+ expect(result.total_tax_owed).toBe(0);
86
+ expect(result.by_rate).toHaveLength(0);
87
+ });
88
+
89
+ it('jurisdiction NL uses 21% standard rate', () => {
90
+ // Single transaction of 12100 cents = $121.00
91
+ // Tax-exclusive: 121 / 1.21 = 100.00 taxable, 21.00 tax
92
+ const transactions = [makeIncomeTransaction('t1', 12100)];
93
+ const result = calculateTax(transactions, 'quarter', 'NL');
94
+
95
+ expect(result.jurisdiction).toBe('NL');
96
+ expect(result.total_tax_collected).toBeCloseTo(21.00, 1);
97
+ expect(result.total_taxable_amount).toBeCloseTo(100.00, 1);
98
+ });
99
+
100
+ it('jurisdiction GB uses 20% standard rate', () => {
101
+ // 12000 cents = $120.00 inclusive
102
+ // Tax-exclusive: 120 / 1.20 = 100.00 taxable, 20.00 tax
103
+ const transactions = [makeIncomeTransaction('t1', 12000)];
104
+ const result = calculateTax(transactions, 'quarter', 'GB');
105
+
106
+ expect(result.jurisdiction).toBe('GB');
107
+ expect(result.total_tax_collected).toBeCloseTo(20.00, 1);
108
+ });
109
+
110
+ it('jurisdiction DE uses 19% standard rate', () => {
111
+ const transactions = [makeIncomeTransaction('t1', 11900)];
112
+ const result = calculateTax(transactions, 'quarter', 'DE');
113
+
114
+ expect(result.jurisdiction).toBe('DE');
115
+ expect(result.total_tax_collected).toBeCloseTo(19.00, 1);
116
+ expect(result.total_taxable_amount).toBeCloseTo(100.00, 1);
117
+ });
118
+
119
+ it('DEFAULT jurisdiction uses 20% when jurisdiction not specified', () => {
120
+ const transactions = [makeIncomeTransaction('t1', 12000)];
121
+ const result = calculateTax(transactions, 'quarter');
122
+
123
+ // Default rate = 20%
124
+ expect(result.total_tax_collected).toBeCloseTo(20.00, 1);
125
+ expect(result.jurisdiction).toBeUndefined();
126
+ });
127
+
128
+ it('by_rate contains entries for each rate used', () => {
129
+ const transactions = [makeIncomeTransaction('t1', 10000)];
130
+ const result = calculateTax(transactions, 'quarter');
131
+
132
+ expect(Array.isArray(result.by_rate)).toBe(true);
133
+ for (const entry of result.by_rate) {
134
+ expect(entry).toHaveProperty('rate');
135
+ expect(entry).toHaveProperty('rate_label');
136
+ expect(entry).toHaveProperty('taxable_amount');
137
+ expect(entry).toHaveProperty('tax_amount');
138
+ }
139
+ });
140
+
141
+ it('filing_period for quarter includes Q prefix', () => {
142
+ const result = calculateTax([], 'quarter');
143
+ expect(result.filing_period).toMatch(/^Q\d \d{4}$/);
144
+ });
145
+
146
+ it('filing_period for year is just the year', () => {
147
+ const result = calculateTax([], 'year');
148
+ expect(result.filing_period).toMatch(/^\d{4}$/);
149
+ });
150
+
151
+ it('tax metadata override: transaction with tax_rate=0 is treated as exempt', () => {
152
+ const transactions = [makeIncomeTransaction('t1', 10000, { tax_rate: '0' })];
153
+ const result = calculateTax(transactions, 'quarter');
154
+
155
+ const exemptEntry = result.by_rate.find((r) => r.rate === 0);
156
+ expect(exemptEntry).toBeDefined();
157
+ expect(exemptEntry!.rate_label).toBe('Exempt');
158
+ });
159
+
160
+ it('total_tax_owed equals total_tax_collected in v1', () => {
161
+ const transactions = [makeIncomeTransaction('t1', 12000), makeIncomeTransaction('t2', 24000)];
162
+ const result = calculateTax(transactions, 'year');
163
+
164
+ expect(result.total_tax_owed).toBeCloseTo(result.total_tax_collected, 5);
165
+ });
166
+ });
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { mockExpenses } from '../helpers/mock-data.js';
3
+ import type { Expense } from '../../src/types.js';
4
+
5
+ // ─── Mock Stripe SDK ──────────────────────────────────────────────────────────
6
+ vi.mock('stripe', () => {
7
+ const mockStripe = vi.fn().mockImplementation(() => ({
8
+ balanceTransactions: {
9
+ list: vi.fn().mockResolvedValue({
10
+ data: [
11
+ { id: 'txn_1', created: 1743465600, amount: -500, currency: 'usd', description: 'Stripe fee', type: 'stripe_fee', metadata: {} },
12
+ { id: 'txn_2', created: 1743552000, amount: -1500, currency: 'usd', description: 'Refund to Bob', type: 'payment_refund', metadata: {} },
13
+ { id: 'txn_3', created: 1743638400, amount: -500, currency: 'usd', description: 'Stripe fee 2', type: 'stripe_fee', metadata: {} },
14
+ { id: 'txn_4', created: 1743724800, amount: -2000, currency: 'usd', description: 'Payout refund', type: 'payment_refund', metadata: {} },
15
+ ],
16
+ has_more: false,
17
+ }),
18
+ },
19
+ balance: {
20
+ retrieve: vi.fn().mockResolvedValue({
21
+ available: [{ currency: 'usd', amount: 500000 }],
22
+ pending: [],
23
+ }),
24
+ },
25
+ invoices: {
26
+ list: vi.fn().mockResolvedValue({ data: [], has_more: false }),
27
+ },
28
+ subscriptions: {
29
+ list: vi.fn().mockResolvedValue({ data: [] }),
30
+ },
31
+ }));
32
+ return { default: mockStripe };
33
+ });
34
+
35
+ describe('expense_tracker tool — Stripe adapter', () => {
36
+ beforeEach(() => {
37
+ process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.clearAllMocks();
42
+ });
43
+
44
+ it('expense transactions have type expense', async () => {
45
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
46
+ const adapter = new StripeAdapter();
47
+ const result = await adapter.getTransactions({});
48
+
49
+ const expenses = result.data.filter((t) => t.type === 'expense');
50
+ expect(expenses.length).toBeGreaterThan(0);
51
+ });
52
+
53
+ it('expense amounts are in cents', async () => {
54
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
55
+ const adapter = new StripeAdapter();
56
+ const result = await adapter.getTransactions({});
57
+
58
+ const expenses = result.data.filter((t) => t.type === 'expense');
59
+ for (const exp of expenses) {
60
+ expect(Number.isInteger(exp.amount)).toBe(true);
61
+ }
62
+ });
63
+ });
64
+
65
+ describe('expense_tracker — category grouping logic', () => {
66
+ it('groups expenses by category correctly', () => {
67
+ const expenses: Expense[] = [
68
+ { id: 'e1', date: '2026-03-01', amount: 5000, currency: 'usd', description: 'AWS bill', category: 'Hosting', provider: 'stripe' },
69
+ { id: 'e2', date: '2026-03-02', amount: 3000, currency: 'usd', description: 'Vercel', category: 'Hosting', provider: 'stripe' },
70
+ { id: 'e3', date: '2026-03-03', amount: 10000, currency: 'usd', description: 'Salary', category: 'Payroll', provider: 'stripe' },
71
+ ];
72
+
73
+ const byCategory: Record<string, number> = {};
74
+ for (const e of expenses) {
75
+ const cat = e.category ?? 'Uncategorized';
76
+ byCategory[cat] = (byCategory[cat] ?? 0) + e.amount;
77
+ }
78
+
79
+ expect(byCategory['Hosting']).toBe(8000);
80
+ expect(byCategory['Payroll']).toBe(10000);
81
+ });
82
+
83
+ it('total expenses equals sum of all categories', () => {
84
+ const expenses = mockExpenses(4, 'stripe');
85
+ const total = expenses.reduce((s, e) => s + e.amount, 0);
86
+
87
+ const byCategory: Record<string, number> = {};
88
+ for (const e of expenses) {
89
+ const cat = e.category ?? 'Uncategorized';
90
+ byCategory[cat] = (byCategory[cat] ?? 0) + e.amount;
91
+ }
92
+
93
+ const categorySum = Object.values(byCategory).reduce((s, v) => s + v, 0);
94
+ expect(categorySum).toBe(total);
95
+ });
96
+
97
+ it('total is converted to major units correctly', () => {
98
+ const expenses: Expense[] = [
99
+ { id: 'e1', date: '2026-03-01', amount: 50000, currency: 'usd', description: 'Office rent', category: 'Office', provider: 'stripe' },
100
+ ];
101
+
102
+ const total = expenses.reduce((s, e) => s + e.amount, 0);
103
+ expect(total / 100).toBe(500.00);
104
+ });
105
+
106
+ it('category values are converted to major units', () => {
107
+ const expenses: Expense[] = [
108
+ { id: 'e1', date: '2026-03-01', amount: 7500, currency: 'usd', description: 'AWS', category: 'Hosting', provider: 'stripe' },
109
+ { id: 'e2', date: '2026-03-02', amount: 2500, currency: 'usd', description: 'GCP', category: 'Hosting', provider: 'stripe' },
110
+ ];
111
+
112
+ const byCategory: Record<string, number> = {};
113
+ for (const e of expenses) {
114
+ const cat = e.category ?? 'Uncategorized';
115
+ byCategory[cat] = (byCategory[cat] ?? 0) + e.amount;
116
+ }
117
+
118
+ const byCategoryMajor = Object.fromEntries(
119
+ Object.entries(byCategory).map(([k, v]) => [k, v / 100])
120
+ );
121
+
122
+ expect(byCategoryMajor['Hosting']).toBe(100.00);
123
+ });
124
+
125
+ it('expenses without category are grouped as Uncategorized', () => {
126
+ const expenses: Expense[] = [
127
+ { id: 'e1', date: '2026-03-01', amount: 1000, currency: 'usd', description: 'Misc', category: '', provider: 'stripe' },
128
+ ];
129
+
130
+ const byCategory: Record<string, number> = {};
131
+ for (const e of expenses) {
132
+ const cat = e.category || 'Uncategorized';
133
+ byCategory[cat] = (byCategory[cat] ?? 0) + e.amount;
134
+ }
135
+
136
+ expect(byCategory['Uncategorized']).toBe(1000);
137
+ });
138
+
139
+ it('empty expenses list returns zero total', () => {
140
+ const expenses: Expense[] = [];
141
+ const total = expenses.reduce((s, e) => s + e.amount, 0);
142
+ expect(total / 100).toBe(0);
143
+ });
144
+
145
+ it('mockExpenses returns correct count', () => {
146
+ const expenses = mockExpenses(8, 'stripe');
147
+ expect(expenses).toHaveLength(8);
148
+ });
149
+
150
+ it('mockExpenses has multiple categories', () => {
151
+ const expenses = mockExpenses(8, 'stripe');
152
+ const categories = [...new Set(expenses.map((e) => e.category))];
153
+ expect(categories.length).toBeGreaterThan(1);
154
+ });
155
+
156
+ it('category filter: only expenses matching category are included', () => {
157
+ const expenses = mockExpenses(8, 'stripe');
158
+ const targetCategory = 'Hosting';
159
+
160
+ const filtered = expenses.filter((e) => e.category === targetCategory);
161
+
162
+ for (const exp of filtered) {
163
+ expect(exp.category).toBe(targetCategory);
164
+ }
165
+ });
166
+
167
+ it('expense count is tracked correctly', () => {
168
+ const expenses = mockExpenses(6, 'stripe');
169
+ expect(expenses.length).toBe(6);
170
+
171
+ const count = expenses.reduce((s) => s + 1, 0);
172
+ expect(count).toBe(6);
173
+ });
174
+
175
+ it('provider is set on all mock expenses', () => {
176
+ const expenses = mockExpenses(4, 'xero');
177
+ for (const e of expenses) {
178
+ expect(e.provider).toBe('xero');
179
+ }
180
+ });
181
+ });