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,137 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { detectAnomalies } from '../../src/analysis/anomaly.js';
3
+ import type { Transaction } from '../../src/types.js';
4
+
5
+ function makeTransaction(
6
+ id: string,
7
+ date: string,
8
+ amount: number,
9
+ description: string,
10
+ category: string = 'Revenue',
11
+ type: 'income' | 'expense' | 'transfer' = 'income'
12
+ ): Transaction {
13
+ return {
14
+ id,
15
+ date,
16
+ amount,
17
+ currency: 'usd',
18
+ description,
19
+ category,
20
+ type,
21
+ provider: 'stripe',
22
+ };
23
+ }
24
+
25
+ describe('detectAnomalies', () => {
26
+ describe('duplicate detection', () => {
27
+ it('detects duplicate: same amount, similar description, within 24 hours', () => {
28
+ const transactions = [
29
+ makeTransaction('t1', '2026-03-01T10:00:00Z', 5000, 'Invoice payment from Acme'),
30
+ makeTransaction('t2', '2026-03-01T14:00:00Z', 5000, 'Invoice payment from Acme'),
31
+ makeTransaction('t3', '2026-03-05T10:00:00Z', 3000, 'Different transaction'),
32
+ ];
33
+
34
+ const anomalies = detectAnomalies(transactions);
35
+ const duplicates = anomalies.filter((a) => a.type === 'duplicate');
36
+
37
+ expect(duplicates).toHaveLength(1);
38
+ expect(duplicates[0].severity).toBe('high');
39
+ });
40
+
41
+ it('does NOT flag as duplicate if descriptions are different', () => {
42
+ const transactions = [
43
+ makeTransaction('t1', '2026-03-01T10:00:00Z', 5000, 'Invoice payment Acme'),
44
+ makeTransaction('t2', '2026-03-01T12:00:00Z', 5000, 'Hosting fee AWS monthly bill'),
45
+ ];
46
+
47
+ const anomalies = detectAnomalies(transactions);
48
+ const duplicates = anomalies.filter((a) => a.type === 'duplicate');
49
+
50
+ expect(duplicates).toHaveLength(0);
51
+ });
52
+
53
+ it('does NOT flag as duplicate if more than 24 hours apart', () => {
54
+ const transactions = [
55
+ makeTransaction('t1', '2026-03-01T10:00:00Z', 5000, 'Invoice payment Acme Corp'),
56
+ makeTransaction('t2', '2026-03-03T10:00:00Z', 5000, 'Invoice payment Acme Corp'),
57
+ ];
58
+
59
+ const anomalies = detectAnomalies(transactions);
60
+ const duplicates = anomalies.filter((a) => a.type === 'duplicate');
61
+
62
+ expect(duplicates).toHaveLength(0);
63
+ });
64
+ });
65
+
66
+ describe('large variance detection', () => {
67
+ it('detects transaction significantly above category average', () => {
68
+ // Category average ≈ 3000, one transaction at 9000 = 3× average
69
+ const transactions = [
70
+ makeTransaction('t1', '2026-03-01', 2000, 'Fee A', 'Fees'),
71
+ makeTransaction('t2', '2026-03-02', 2500, 'Fee B', 'Fees'),
72
+ makeTransaction('t3', '2026-03-03', 3000, 'Fee C', 'Fees'),
73
+ makeTransaction('t4', '2026-03-04', 3000, 'Fee D', 'Fees'),
74
+ makeTransaction('t5', '2026-03-05', 9000, 'Fee E (spike!)', 'Fees'),
75
+ ];
76
+
77
+ const anomalies = detectAnomalies(transactions, 'medium'); // 2× threshold
78
+ const variances = anomalies.filter((a) => a.type === 'large_variance');
79
+
80
+ expect(variances.length).toBeGreaterThan(0);
81
+ expect(variances[0].transaction?.id).toBe('t5');
82
+ });
83
+
84
+ it('sensitivity level affects detection threshold', () => {
85
+ // At 2500 average, 5000 = 2× — detected on 'medium' (2×) but not 'low' (3×)
86
+ const transactions = [
87
+ makeTransaction('t1', '2026-03-01', 2000, 'Fee A', 'Fees'),
88
+ makeTransaction('t2', '2026-03-02', 2500, 'Fee B', 'Fees'),
89
+ makeTransaction('t3', '2026-03-03', 2500, 'Fee C', 'Fees'),
90
+ makeTransaction('t4', '2026-03-04', 2500, 'Fee D', 'Fees'),
91
+ makeTransaction('t5', '2026-03-05', 6000, 'Fee E', 'Fees'),
92
+ ];
93
+
94
+ const anomaliesMedium = detectAnomalies(transactions, 'medium');
95
+ const anomaliesLow = detectAnomalies(transactions, 'low');
96
+
97
+ const variancesMedium = anomaliesMedium.filter((a) => a.type === 'large_variance');
98
+ const variancesLow = anomaliesLow.filter((a) => a.type === 'large_variance');
99
+
100
+ // Medium threshold (2×) should catch more than low threshold (3×)
101
+ expect(variancesMedium.length).toBeGreaterThanOrEqual(variancesLow.length);
102
+ });
103
+
104
+ it('skips categories with fewer than 3 transactions', () => {
105
+ const transactions = [
106
+ makeTransaction('t1', '2026-03-01', 100, 'Item A', 'Rare'),
107
+ makeTransaction('t2', '2026-03-02', 10000, 'Item B (huge!)', 'Rare'),
108
+ ];
109
+
110
+ const anomalies = detectAnomalies(transactions, 'low');
111
+ const variances = anomalies.filter((a) => a.type === 'large_variance');
112
+
113
+ expect(variances).toHaveLength(0);
114
+ });
115
+ });
116
+
117
+ it('returns empty array for empty input', () => {
118
+ const anomalies = detectAnomalies([]);
119
+ expect(anomalies).toHaveLength(0);
120
+ });
121
+
122
+ it('each anomaly has required fields', () => {
123
+ const transactions = [
124
+ makeTransaction('t1', '2026-03-01T10:00:00Z', 5000, 'Invoice payment Acme Corp'),
125
+ makeTransaction('t2', '2026-03-01T12:00:00Z', 5000, 'Invoice payment Acme Corp'),
126
+ ];
127
+
128
+ const anomalies = detectAnomalies(transactions);
129
+
130
+ for (const anomaly of anomalies) {
131
+ expect(anomaly.id).toBeDefined();
132
+ expect(anomaly.type).toBeDefined();
133
+ expect(anomaly.severity).toBeDefined();
134
+ expect(anomaly.description).toBeDefined();
135
+ }
136
+ });
137
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { forecastCashFlow } from '../../src/analysis/cashflow.js';
3
+ import type { Transaction } from '../../src/types.js';
4
+
5
+ function makeTransaction(
6
+ id: string,
7
+ date: string,
8
+ amount: number,
9
+ type: 'income' | 'expense' | 'transfer'
10
+ ): Transaction {
11
+ return {
12
+ id,
13
+ date,
14
+ amount,
15
+ currency: 'usd',
16
+ description: `${type} ${id}`,
17
+ type,
18
+ provider: 'stripe',
19
+ };
20
+ }
21
+
22
+ describe('forecastCashFlow', () => {
23
+ // Build 3 months of deterministic historical data
24
+ // Jan 2026: income 10000 cents ($100), expense 5000 cents ($50)
25
+ // Feb 2026: income 12000 cents ($120), expense 6000 cents ($60)
26
+ // Mar 2026: income 14000 cents ($140), expense 7000 cents ($70)
27
+ const historicalTransactions: Transaction[] = [
28
+ makeTransaction('jan-inc', '2026-01-15', 10000, 'income'),
29
+ makeTransaction('jan-exp', '2026-01-20', 5000, 'expense'),
30
+ makeTransaction('feb-inc', '2026-02-15', 12000, 'income'),
31
+ makeTransaction('feb-exp', '2026-02-20', 6000, 'expense'),
32
+ makeTransaction('mar-inc', '2026-03-15', 14000, 'income'),
33
+ makeTransaction('mar-exp', '2026-03-20', 7000, 'expense'),
34
+ ];
35
+
36
+ it('returns 3 scenarios: conservative, realistic, optimistic', () => {
37
+ const forecast = forecastCashFlow(historicalTransactions, 3);
38
+
39
+ expect(forecast.scenarios.conservative).toBeDefined();
40
+ expect(forecast.scenarios.realistic).toBeDefined();
41
+ expect(forecast.scenarios.optimistic).toBeDefined();
42
+ });
43
+
44
+ it('returns the correct number of months in each scenario', () => {
45
+ const forecast = forecastCashFlow(historicalTransactions, 3);
46
+
47
+ expect(forecast.scenarios.realistic).toHaveLength(3);
48
+ expect(forecast.scenarios.conservative).toHaveLength(3);
49
+ expect(forecast.scenarios.optimistic).toHaveLength(3);
50
+ });
51
+
52
+ it('optimistic income is higher than realistic', () => {
53
+ const forecast = forecastCashFlow(historicalTransactions, 3);
54
+
55
+ for (let i = 0; i < 3; i++) {
56
+ expect(forecast.scenarios.optimistic[i].income).toBeGreaterThan(
57
+ forecast.scenarios.realistic[i].income
58
+ );
59
+ }
60
+ });
61
+
62
+ it('conservative income is lower than realistic', () => {
63
+ const forecast = forecastCashFlow(historicalTransactions, 3);
64
+
65
+ for (let i = 0; i < 3; i++) {
66
+ expect(forecast.scenarios.conservative[i].income).toBeLessThan(
67
+ forecast.scenarios.realistic[i].income
68
+ );
69
+ }
70
+ });
71
+
72
+ it('month labels are in YYYY-MM format starting from next month', () => {
73
+ const forecast = forecastCashFlow(historicalTransactions, 3);
74
+
75
+ const months = forecast.scenarios.realistic.map((m) => m.month);
76
+ expect(months[0]).toBe('2026-04');
77
+ expect(months[1]).toBe('2026-05');
78
+ expect(months[2]).toBe('2026-06');
79
+ });
80
+
81
+ it('cumulative_net is running total of net', () => {
82
+ const forecast = forecastCashFlow(historicalTransactions, 3);
83
+ const realistic = forecast.scenarios.realistic;
84
+
85
+ let running = 0;
86
+ for (const month of realistic) {
87
+ running += month.net;
88
+ expect(month.cumulative_net).toBeCloseTo(running, 1);
89
+ }
90
+ });
91
+
92
+ it('historical averages are in major currency units (not cents)', () => {
93
+ const forecast = forecastCashFlow(historicalTransactions, 3);
94
+
95
+ // Weighted avg income ≈ 120 (weighted towards most recent 140)
96
+ expect(forecast.historical_monthly_income).toBeGreaterThan(10);
97
+ expect(forecast.historical_monthly_income).toBeLessThan(200);
98
+ });
99
+
100
+ it('returns empty scenarios with 0 averages when no historical data', () => {
101
+ const forecast = forecastCashFlow([], 3);
102
+
103
+ expect(forecast.historical_monthly_income).toBe(0);
104
+ expect(forecast.historical_monthly_expenses).toBe(0);
105
+ });
106
+
107
+ it('supports different monthsAhead values', () => {
108
+ const forecast6 = forecastCashFlow(historicalTransactions, 6);
109
+ expect(forecast6.scenarios.realistic).toHaveLength(6);
110
+ expect(forecast6.period_months).toBe(6);
111
+ });
112
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generatePnL } from '../../src/analysis/pnl.js';
3
+ import { mockTransactions, mockExpenses } from '../helpers/mock-data.js';
4
+
5
+ describe('generatePnL', () => {
6
+ // Use a wide date range so all mock transactions are included
7
+ const dateFrom = '2026-01-01';
8
+ const dateTo = '2026-12-31';
9
+
10
+ it('calculates revenue from income transactions only', () => {
11
+ const transactions = mockTransactions(10, 'stripe');
12
+ const incomeTransactions = transactions.filter((t) => t.type === 'income');
13
+ const expectedRevenue = incomeTransactions.reduce((s, t) => s + t.amount, 0) / 100;
14
+
15
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
16
+
17
+ expect(pnl.revenue).toBe(expectedRevenue);
18
+ });
19
+
20
+ it('calculates COGS from expense transactions', () => {
21
+ const transactions = mockTransactions(10, 'stripe');
22
+ const expenseTransactions = transactions.filter((t) => t.type === 'expense');
23
+ const expectedCogs = expenseTransactions.reduce((s, t) => s + t.amount, 0) / 100;
24
+
25
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
26
+
27
+ expect(pnl.cogs).toBe(expectedCogs);
28
+ });
29
+
30
+ it('calculates operating expenses from Expense records', () => {
31
+ const expenses = mockExpenses(4, 'stripe');
32
+ const expectedOpExp = expenses.reduce((s, e) => s + e.amount, 0) / 100;
33
+
34
+ const pnl = generatePnL([], expenses, dateFrom, dateTo);
35
+
36
+ expect(pnl.operating_expenses).toBe(expectedOpExp);
37
+ });
38
+
39
+ it('calculates gross profit = revenue - cogs', () => {
40
+ const transactions = mockTransactions(6, 'stripe');
41
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
42
+
43
+ expect(pnl.gross_profit).toBeCloseTo(pnl.revenue - pnl.cogs, 5);
44
+ });
45
+
46
+ it('calculates net income = gross profit - operating expenses', () => {
47
+ const transactions = mockTransactions(10, 'stripe');
48
+ const expenses = mockExpenses(4, 'stripe');
49
+ const pnl = generatePnL(transactions, expenses, dateFrom, dateTo);
50
+
51
+ expect(pnl.net_income).toBeCloseTo(pnl.gross_profit - pnl.operating_expenses, 5);
52
+ });
53
+
54
+ it('calculates gross margin percentage', () => {
55
+ const transactions = mockTransactions(10, 'stripe');
56
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
57
+
58
+ const expectedMargin = pnl.revenue > 0 ? (pnl.gross_profit / pnl.revenue) * 100 : 0;
59
+ expect(pnl.gross_margin_pct).toBeCloseTo(expectedMargin, 1);
60
+ });
61
+
62
+ it('returns currency from transactions (not hardcoded)', () => {
63
+ const transactions = mockTransactions(5, 'stripe');
64
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
65
+
66
+ expect(pnl.currency).toBe('usd');
67
+ });
68
+
69
+ it('returns usd as default currency when no transactions', () => {
70
+ const pnl = generatePnL([], [], dateFrom, dateTo);
71
+ expect(pnl.currency).toBe('usd');
72
+ });
73
+
74
+ it('groups expenses by category in by_category', () => {
75
+ const expenses = mockExpenses(4, 'stripe');
76
+ const pnl = generatePnL([], expenses, dateFrom, dateTo);
77
+
78
+ // mockExpenses(4) uses categories at indices 0..3: Processing Fees, Refunds, Office, Software
79
+ expect(Object.keys(pnl.by_category)).toHaveLength(4);
80
+ expect(pnl.by_category['Processing Fees']).toBeDefined();
81
+ });
82
+
83
+ it('includes period metadata', () => {
84
+ const pnl = generatePnL([], [], dateFrom, dateTo);
85
+ expect(pnl.date_from).toBe(dateFrom);
86
+ expect(pnl.date_to).toBe(dateTo);
87
+ expect(pnl.period).toContain(dateFrom);
88
+ });
89
+
90
+ it('revenue is 0 when there are no income transactions', () => {
91
+ const transactions = mockTransactions(3, 'stripe').filter((t) => t.type !== 'income');
92
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
93
+ expect(pnl.revenue).toBe(0);
94
+ });
95
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { reconcile } from '../../src/analysis/reconciliation.js';
3
+ import type { Transaction } from '../../src/types.js';
4
+
5
+ function makeTransaction(
6
+ id: string,
7
+ date: string,
8
+ amount: number,
9
+ description: string,
10
+ provider: 'stripe' | 'xero'
11
+ ): Transaction {
12
+ return {
13
+ id,
14
+ date,
15
+ amount,
16
+ currency: 'usd',
17
+ description,
18
+ type: 'income',
19
+ provider,
20
+ };
21
+ }
22
+
23
+ describe('reconcile', () => {
24
+ it('matches transactions with same amount, close date, and similar description', () => {
25
+ const stripe = [makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment from Acme Corp', 'stripe')];
26
+ const xero = [makeTransaction('x1', '2026-03-01T14:00:00Z', 5000, 'Payment Acme Corp', 'xero')];
27
+
28
+ const result = reconcile(stripe, xero);
29
+
30
+ expect(result.matched).toHaveLength(1);
31
+ expect(result.matched[0].stripe.id).toBe('s1');
32
+ expect(result.matched[0].xero.id).toBe('x1');
33
+ expect(result.unmatched_stripe).toHaveLength(0);
34
+ expect(result.unmatched_xero).toHaveLength(0);
35
+ });
36
+
37
+ it('HARD GATE: does NOT match transactions with same amount but completely unrelated descriptions', () => {
38
+ // Critical Fix 4: same amount within 2 days but different descriptions = no match
39
+ const stripe = [makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'AWS hosting fee', 'stripe')];
40
+ const xero = [makeTransaction('x1', '2026-03-02T10:00:00Z', 5000, 'Employee lunch reimbursement', 'xero')];
41
+
42
+ const result = reconcile(stripe, xero);
43
+
44
+ expect(result.matched).toHaveLength(0);
45
+ expect(result.unmatched_stripe).toHaveLength(1);
46
+ expect(result.unmatched_xero).toHaveLength(1);
47
+ });
48
+
49
+ it('does NOT match transactions with different amounts', () => {
50
+ const stripe = [makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment Acme', 'stripe')];
51
+ const xero = [makeTransaction('x1', '2026-03-01T10:00:00Z', 5100, 'Payment Acme', 'xero')];
52
+
53
+ const result = reconcile(stripe, xero);
54
+
55
+ expect(result.matched).toHaveLength(0);
56
+ });
57
+
58
+ it('does NOT match transactions outside 2-day date tolerance', () => {
59
+ const stripe = [makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment Acme Corp', 'stripe')];
60
+ const xero = [makeTransaction('x1', '2026-03-05T10:00:00Z', 5000, 'Payment Acme Corp', 'xero')];
61
+
62
+ const result = reconcile(stripe, xero);
63
+
64
+ expect(result.matched).toHaveLength(0);
65
+ expect(result.unmatched_stripe).toHaveLength(1);
66
+ });
67
+
68
+ it('returns correct summary counts', () => {
69
+ const stripe = [
70
+ makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment Acme Corp', 'stripe'),
71
+ makeTransaction('s2', '2026-03-02T10:00:00Z', 9900, 'Subscription renewal', 'stripe'),
72
+ ];
73
+ const xero = [
74
+ makeTransaction('x1', '2026-03-01T14:00:00Z', 5000, 'Payment Acme Corp', 'xero'),
75
+ makeTransaction('x2', '2026-03-10T10:00:00Z', 9900, 'Completely different thing', 'xero'),
76
+ ];
77
+
78
+ const result = reconcile(stripe, xero);
79
+
80
+ expect(result.summary.total_stripe).toBe(2);
81
+ expect(result.summary.total_xero).toBe(2);
82
+ expect(result.summary.matched_count).toBe(1);
83
+ expect(result.summary.unmatched_stripe_count).toBe(1);
84
+ expect(result.summary.unmatched_xero_count).toBe(1);
85
+ });
86
+
87
+ it('confidence score is between 0 and 1', () => {
88
+ const stripe = [makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment from Acme', 'stripe')];
89
+ const xero = [makeTransaction('x1', '2026-03-01T10:00:00Z', 5000, 'Payment from Acme', 'xero')];
90
+
91
+ const result = reconcile(stripe, xero);
92
+
93
+ expect(result.matched[0].confidence).toBeGreaterThanOrEqual(0);
94
+ expect(result.matched[0].confidence).toBeLessThanOrEqual(1);
95
+ });
96
+
97
+ it('handles empty inputs gracefully', () => {
98
+ const result = reconcile([], []);
99
+
100
+ expect(result.matched).toHaveLength(0);
101
+ expect(result.unmatched_stripe).toHaveLength(0);
102
+ expect(result.unmatched_xero).toHaveLength(0);
103
+ expect(result.summary.matched_count).toBe(0);
104
+ });
105
+
106
+ it('does not double-match the same xero transaction', () => {
107
+ const stripe = [
108
+ makeTransaction('s1', '2026-03-01T10:00:00Z', 5000, 'Payment Acme Corp', 'stripe'),
109
+ makeTransaction('s2', '2026-03-01T11:00:00Z', 5000, 'Payment Acme Corp', 'stripe'),
110
+ ];
111
+ const xero = [
112
+ makeTransaction('x1', '2026-03-01T12:00:00Z', 5000, 'Payment Acme Corp', 'xero'),
113
+ ];
114
+
115
+ const result = reconcile(stripe, xero);
116
+
117
+ // Only one match possible (xero transaction used only once)
118
+ expect(result.matched).toHaveLength(1);
119
+ expect(result.unmatched_stripe).toHaveLength(1);
120
+ });
121
+ });
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { calculateTax } from '../../src/analysis/tax.js';
3
+ import type { Transaction } from '../../src/types.js';
4
+
5
+ function makeIncome(id: string, amount: number, metadata?: Record<string, string>): Transaction {
6
+ return {
7
+ id,
8
+ date: '2026-03-01T00:00:00Z',
9
+ amount,
10
+ currency: 'usd',
11
+ description: `Sale ${id}`,
12
+ type: 'income',
13
+ provider: 'stripe',
14
+ metadata,
15
+ };
16
+ }
17
+
18
+ function makeExpense(id: string, amount: number): Transaction {
19
+ return {
20
+ id,
21
+ date: '2026-03-01T00:00:00Z',
22
+ amount,
23
+ currency: 'usd',
24
+ description: `Expense ${id}`,
25
+ type: 'expense',
26
+ provider: 'stripe',
27
+ };
28
+ }
29
+
30
+ describe('calculateTax — edge cases', () => {
31
+ it('zero revenue returns all zeros', () => {
32
+ const result = calculateTax([], 'quarter');
33
+
34
+ expect(result.total_taxable_amount).toBe(0);
35
+ expect(result.total_tax_collected).toBe(0);
36
+ expect(result.total_tax_owed).toBe(0);
37
+ expect(result.by_rate).toHaveLength(0);
38
+ });
39
+
40
+ it('only expense transactions return zeros', () => {
41
+ const transactions = [makeExpense('e1', 50000), makeExpense('e2', 30000)];
42
+ const result = calculateTax(transactions, 'quarter');
43
+
44
+ expect(result.total_taxable_amount).toBe(0);
45
+ expect(result.total_tax_collected).toBe(0);
46
+ });
47
+
48
+ it('transfer transactions do not count as taxable', () => {
49
+ const transactions: Transaction[] = [
50
+ { id: 't1', date: '2026-03-01', amount: 100000, currency: 'usd', description: 'Internal transfer', type: 'transfer', provider: 'stripe' },
51
+ ];
52
+
53
+ const result = calculateTax(transactions, 'quarter');
54
+ expect(result.total_taxable_amount).toBe(0);
55
+ });
56
+
57
+ it('multiple jurisdictions: NL, DE, GB produce different tax amounts', () => {
58
+ const transactions = [makeIncome('t1', 12000)];
59
+
60
+ const nlResult = calculateTax(transactions, 'quarter', 'NL');
61
+ const deResult = calculateTax(transactions, 'quarter', 'DE');
62
+ const gbResult = calculateTax(transactions, 'quarter', 'GB');
63
+
64
+ // All should be different due to different standard rates
65
+ expect(nlResult.total_tax_collected).not.toEqual(deResult.total_tax_collected);
66
+ // GB and DEFAULT are both 20%, so they should be equal
67
+ expect(gbResult.total_tax_collected).toBeCloseTo(20.00, 1);
68
+ });
69
+
70
+ it('US jurisdiction uses 8% standard rate', () => {
71
+ const transactions = [makeIncome('t1', 10800)];
72
+ const result = calculateTax(transactions, 'quarter', 'US');
73
+
74
+ expect(result.jurisdiction).toBe('US');
75
+ // 10800 / 1.08 = 10000 taxable, 800 tax = $8
76
+ expect(result.total_tax_collected).toBeCloseTo(8.00, 1);
77
+ });
78
+
79
+ it('EU jurisdiction uses 21% standard rate', () => {
80
+ const transactions = [makeIncome('t1', 12100)];
81
+ const result = calculateTax(transactions, 'quarter', 'EU');
82
+
83
+ expect(result.jurisdiction).toBe('EU');
84
+ expect(result.total_tax_collected).toBeCloseTo(21.00, 1);
85
+ });
86
+
87
+ it('tax calculation is cumulative across multiple transactions', () => {
88
+ const transactions = [
89
+ makeIncome('t1', 12000),
90
+ makeIncome('t2', 12000),
91
+ makeIncome('t3', 12000),
92
+ ];
93
+
94
+ const result = calculateTax(transactions, 'quarter');
95
+ // Each transaction: $120 inclusive at 20% → $100 taxable, $20 tax
96
+ // Total: 3 × $20 = $60 tax
97
+ expect(result.total_tax_collected).toBeCloseTo(60.00, 1);
98
+ });
99
+
100
+ it('jurisdiction key is case-insensitive (nl = NL)', () => {
101
+ const transactions = [makeIncome('t1', 12100)];
102
+
103
+ const upperResult = calculateTax(transactions, 'quarter', 'NL');
104
+ const lowerResult = calculateTax(transactions, 'quarter', 'nl');
105
+
106
+ expect(upperResult.jurisdiction).toBe('NL');
107
+ expect(lowerResult.jurisdiction).toBe('NL');
108
+ expect(upperResult.total_tax_collected).toBeCloseTo(lowerResult.total_tax_collected, 2);
109
+ });
110
+
111
+ it('mixed income and expense: only income is taxable', () => {
112
+ const transactions = [
113
+ makeIncome('t1', 12000),
114
+ makeExpense('e1', 5000),
115
+ makeIncome('t2', 12000),
116
+ ];
117
+
118
+ const result = calculateTax(transactions, 'quarter');
119
+ // Only 2 income transactions at $120 each → $40 total tax (2 × $20)
120
+ expect(result.total_tax_collected).toBeCloseTo(40.00, 1);
121
+ });
122
+
123
+ it('tax metadata: custom rate overrides standard rate', () => {
124
+ const transactions = [
125
+ makeIncome('t1', 10000, { tax_rate: '0.05' }), // 5% rate
126
+ ];
127
+
128
+ const result = calculateTax(transactions, 'quarter');
129
+ const rate5Entry = result.by_rate.find((r) => r.rate === 0.05);
130
+ expect(rate5Entry).toBeDefined();
131
+ });
132
+
133
+ it('filing_period for quarter matches current year', () => {
134
+ const result = calculateTax([], 'quarter');
135
+ const currentYear = new Date().getFullYear().toString();
136
+ expect(result.filing_period).toContain(currentYear);
137
+ });
138
+
139
+ it('filing_period for year equals current year string', () => {
140
+ const result = calculateTax([], 'year');
141
+ const currentYear = new Date().getFullYear().toString();
142
+ expect(result.filing_period).toBe(currentYear);
143
+ });
144
+
145
+ it('by_rate entries have rate_label: Exempt for rate 0', () => {
146
+ const transactions = [makeIncome('t1', 10000, { tax_rate: '0' })];
147
+ const result = calculateTax(transactions, 'quarter');
148
+
149
+ const exemptEntry = result.by_rate.find((r) => r.rate === 0);
150
+ expect(exemptEntry).toBeDefined();
151
+ expect(exemptEntry!.rate_label).toBe('Exempt');
152
+ });
153
+
154
+ it('by_rate entries have formatted rate_label for non-zero rates', () => {
155
+ const transactions = [makeIncome('t1', 12000)];
156
+ const result = calculateTax(transactions, 'quarter'); // Default 20%
157
+
158
+ const vatEntry = result.by_rate.find((r) => r.rate === 0.2);
159
+ expect(vatEntry).toBeDefined();
160
+ expect(vatEntry!.rate_label).toContain('VAT');
161
+ expect(vatEntry!.rate_label).toContain('20');
162
+ });
163
+ });