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,189 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { detectAnomalies } from '../../src/analysis/anomaly.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
+ category: string = 'Revenue',
24
+ type: 'income' | 'expense' | 'transfer' = 'income'
25
+ ): Transaction {
26
+ return { id, date, amount, currency: 'usd', description, category, type, provider: 'stripe' };
27
+ }
28
+
29
+ describe('detect_anomalies — requirePro gate', () => {
30
+ it('throws ProRequiredError without license', async () => {
31
+ delete process.env.PRO_LICENSE;
32
+ const { requirePro, ProRequiredError } = await import('../../src/premium/gate.js');
33
+ expect(() => requirePro('detect_anomalies')).toThrow(ProRequiredError);
34
+ });
35
+
36
+ it('does not throw with PRO_LICENSE set', async () => {
37
+ process.env.PRO_LICENSE = 'license-key';
38
+ const { requirePro } = await import('../../src/premium/gate.js');
39
+ expect(() => requirePro('detect_anomalies')).not.toThrow();
40
+ });
41
+ });
42
+
43
+ describe('detect_anomalies — duplicate detection', () => {
44
+ it('detects duplicate: same amount, similar description, within 24 hours', () => {
45
+ const transactions = [
46
+ makeTransaction('t1', '2026-03-01T10:00:00Z', 5000, 'Invoice payment from Acme'),
47
+ makeTransaction('t2', '2026-03-01T14:00:00Z', 5000, 'Invoice payment from Acme'),
48
+ ];
49
+
50
+ const anomalies = detectAnomalies(transactions);
51
+ const duplicates = anomalies.filter((a) => a.type === 'duplicate');
52
+
53
+ expect(duplicates).toHaveLength(1);
54
+ expect(duplicates[0].severity).toBe('high');
55
+ });
56
+
57
+ it('does NOT flag as duplicate if descriptions differ significantly', () => {
58
+ const transactions = [
59
+ makeTransaction('t1', '2026-03-01T10:00:00Z', 5000, 'AWS hosting bill monthly'),
60
+ makeTransaction('t2', '2026-03-01T12:00:00Z', 5000, 'Payroll run employee A'),
61
+ ];
62
+
63
+ const anomalies = detectAnomalies(transactions);
64
+ const duplicates = anomalies.filter((a) => a.type === 'duplicate');
65
+
66
+ expect(duplicates).toHaveLength(0);
67
+ });
68
+
69
+ it('does NOT flag as duplicate if more than 24 hours apart', () => {
70
+ const transactions = [
71
+ makeTransaction('t1', '2026-03-01T10:00:00Z', 5000, 'Invoice payment Acme Corp'),
72
+ makeTransaction('t2', '2026-03-03T10:00:00Z', 5000, 'Invoice payment Acme Corp'),
73
+ ];
74
+
75
+ const anomalies = detectAnomalies(transactions);
76
+ const duplicates = anomalies.filter((a) => a.type === 'duplicate');
77
+
78
+ expect(duplicates).toHaveLength(0);
79
+ });
80
+
81
+ it('duplicate anomaly references the transaction', () => {
82
+ const transactions = [
83
+ makeTransaction('t1', '2026-03-01T10:00:00Z', 5000, 'Invoice payment Acme'),
84
+ makeTransaction('t2', '2026-03-01T12:00:00Z', 5000, 'Invoice payment Acme'),
85
+ ];
86
+
87
+ const anomalies = detectAnomalies(transactions);
88
+ const duplicates = anomalies.filter((a) => a.type === 'duplicate');
89
+
90
+ expect(duplicates[0].transaction).toBeDefined();
91
+ });
92
+ });
93
+
94
+ describe('detect_anomalies — variance detection', () => {
95
+ it('detects a spike that is significantly above category average', () => {
96
+ const transactions = [
97
+ makeTransaction('t1', '2026-03-01', 2000, 'Fee A', 'Fees'),
98
+ makeTransaction('t2', '2026-03-02', 2500, 'Fee B', 'Fees'),
99
+ makeTransaction('t3', '2026-03-03', 3000, 'Fee C', 'Fees'),
100
+ makeTransaction('t4', '2026-03-04', 3000, 'Fee D', 'Fees'),
101
+ makeTransaction('t5', '2026-03-05', 9000, 'Fee E (spike!)', 'Fees'),
102
+ ];
103
+
104
+ const anomalies = detectAnomalies(transactions, 'medium');
105
+ const variances = anomalies.filter((a) => a.type === 'large_variance');
106
+
107
+ expect(variances.length).toBeGreaterThan(0);
108
+ expect(variances[0].transaction?.id).toBe('t5');
109
+ });
110
+
111
+ it('medium sensitivity catches more anomalies than low sensitivity', () => {
112
+ const transactions = [
113
+ makeTransaction('t1', '2026-03-01', 2000, 'Fee A', 'Fees'),
114
+ makeTransaction('t2', '2026-03-02', 2500, 'Fee B', 'Fees'),
115
+ makeTransaction('t3', '2026-03-03', 2500, 'Fee C', 'Fees'),
116
+ makeTransaction('t4', '2026-03-04', 2500, 'Fee D', 'Fees'),
117
+ makeTransaction('t5', '2026-03-05', 6000, 'Fee E', 'Fees'),
118
+ ];
119
+
120
+ const anomaliesMedium = detectAnomalies(transactions, 'medium');
121
+ const anomaliesLow = detectAnomalies(transactions, 'low');
122
+
123
+ const variancesMedium = anomaliesMedium.filter((a) => a.type === 'large_variance');
124
+ const variancesLow = anomaliesLow.filter((a) => a.type === 'large_variance');
125
+
126
+ expect(variancesMedium.length).toBeGreaterThanOrEqual(variancesLow.length);
127
+ });
128
+
129
+ it('skips categories with fewer than 3 transactions', () => {
130
+ const transactions = [
131
+ makeTransaction('t1', '2026-03-01', 100, 'Item A', 'Rare'),
132
+ makeTransaction('t2', '2026-03-02', 10000, 'Item B (huge!)', 'Rare'),
133
+ ];
134
+
135
+ const anomalies = detectAnomalies(transactions, 'low');
136
+ const variances = anomalies.filter((a) => a.type === 'large_variance');
137
+
138
+ expect(variances).toHaveLength(0);
139
+ });
140
+ });
141
+
142
+ describe('detect_anomalies — general', () => {
143
+ it('returns empty array for empty input', () => {
144
+ const anomalies = detectAnomalies([]);
145
+ expect(anomalies).toHaveLength(0);
146
+ });
147
+
148
+ it('each anomaly has required fields: id, type, severity, description', () => {
149
+ const transactions = [
150
+ makeTransaction('t1', '2026-03-01T10:00:00Z', 5000, 'Invoice payment Acme Corp'),
151
+ makeTransaction('t2', '2026-03-01T12:00:00Z', 5000, 'Invoice payment Acme Corp'),
152
+ ];
153
+
154
+ const anomalies = detectAnomalies(transactions);
155
+
156
+ for (const anomaly of anomalies) {
157
+ expect(anomaly.id).toBeDefined();
158
+ expect(anomaly.type).toBeDefined();
159
+ expect(anomaly.severity).toBeDefined();
160
+ expect(anomaly.description).toBeDefined();
161
+ }
162
+ });
163
+
164
+ it('anomaly severity is one of: low, medium, high', () => {
165
+ const transactions = [
166
+ makeTransaction('t1', '2026-03-01T10:00:00Z', 5000, 'Invoice payment Acme Corp'),
167
+ makeTransaction('t2', '2026-03-01T12:00:00Z', 5000, 'Invoice payment Acme Corp'),
168
+ ];
169
+
170
+ const anomalies = detectAnomalies(transactions);
171
+
172
+ for (const anomaly of anomalies) {
173
+ expect(['low', 'medium', 'high']).toContain(anomaly.severity);
174
+ }
175
+ });
176
+
177
+ it('no anomalies when all transactions are unique and consistent', () => {
178
+ const transactions = [
179
+ makeTransaction('t1', '2026-03-01', 1000, 'Sale 1', 'Revenue'),
180
+ makeTransaction('t2', '2026-03-02', 1100, 'Sale 2', 'Revenue'),
181
+ makeTransaction('t3', '2026-03-03', 1050, 'Sale 3', 'Revenue'),
182
+ ];
183
+
184
+ const anomalies = detectAnomalies(transactions, 'low');
185
+ // With low sensitivity and consistent amounts, should detect few/no variances
186
+ const variances = anomalies.filter((a) => a.type === 'large_variance');
187
+ expect(variances).toHaveLength(0);
188
+ });
189
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { isPro, requirePro, ProRequiredError } from '../../src/premium/gate.js';
3
+
4
+ afterEach(() => {
5
+ delete process.env.PRO_LICENSE;
6
+ });
7
+
8
+ describe('premium gate', () => {
9
+ it('isPro returns false when PRO_LICENSE is not set', () => {
10
+ delete process.env.PRO_LICENSE;
11
+ expect(isPro()).toBe(false);
12
+ });
13
+
14
+ it('isPro returns true when PRO_LICENSE is set', () => {
15
+ process.env.PRO_LICENSE = 'valid-license-key';
16
+ expect(isPro()).toBe(true);
17
+ });
18
+
19
+ it('requirePro throws ProRequiredError when not pro', () => {
20
+ delete process.env.PRO_LICENSE;
21
+ expect(() => requirePro('generate_pnl')).toThrow(ProRequiredError);
22
+ expect(() => requirePro('generate_pnl')).toThrow('generate_pnl');
23
+ });
24
+
25
+ it('requirePro does not throw when PRO_LICENSE is set', () => {
26
+ process.env.PRO_LICENSE = 'any-value';
27
+ expect(() => requirePro('generate_pnl')).not.toThrow();
28
+ });
29
+
30
+ it('reads PRO_LICENSE dynamically — responds to runtime changes', () => {
31
+ delete process.env.PRO_LICENSE;
32
+ expect(isPro()).toBe(false);
33
+
34
+ // Simulate runtime license activation
35
+ process.env.PRO_LICENSE = 'activated';
36
+ expect(isPro()).toBe(true);
37
+
38
+ // Simulate runtime license deactivation
39
+ delete process.env.PRO_LICENSE;
40
+ expect(isPro()).toBe(false);
41
+ });
42
+
43
+ it('error message includes the tool name', () => {
44
+ delete process.env.PRO_LICENSE;
45
+
46
+ try {
47
+ requirePro('cash_flow_forecast');
48
+ expect.fail('should have thrown');
49
+ } catch (err) {
50
+ expect(err).toBeInstanceOf(ProRequiredError);
51
+ expect((err as Error).message).toContain('cash_flow_forecast');
52
+ }
53
+ });
54
+
55
+ it('PRO_LICENSE with empty string is treated as not set', () => {
56
+ process.env.PRO_LICENSE = '';
57
+ expect(isPro()).toBe(false);
58
+ });
59
+ });
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { generatePnL } from '../../src/analysis/pnl.js';
3
+ import { mockTransactions, mockExpenses } from '../helpers/mock-data.js';
4
+ import type { Transaction, Expense } from '../../src/types.js';
5
+
6
+ // ─── Mock Stripe SDK ──────────────────────────────────────────────────────────
7
+ vi.mock('stripe', () => {
8
+ const mockStripe = vi.fn().mockImplementation(() => ({
9
+ balanceTransactions: {
10
+ list: vi.fn().mockResolvedValue({
11
+ data: [
12
+ { id: 'txn_1', created: 1743465600, amount: 50000, currency: 'usd', description: 'Revenue', type: 'charge', metadata: {} },
13
+ { id: 'txn_2', created: 1743552000, amount: -5000, currency: 'usd', description: 'Fee', type: 'stripe_fee', metadata: {} },
14
+ ],
15
+ has_more: false,
16
+ }),
17
+ },
18
+ balance: { retrieve: vi.fn() },
19
+ invoices: { list: vi.fn() },
20
+ subscriptions: { list: vi.fn() },
21
+ }));
22
+ return { default: mockStripe };
23
+ });
24
+
25
+ describe('generate_pnl — requirePro gate', () => {
26
+ afterEach(() => {
27
+ delete process.env.PRO_LICENSE;
28
+ });
29
+
30
+ it('throws ProRequiredError when PRO_LICENSE is not set', async () => {
31
+ delete process.env.PRO_LICENSE;
32
+ const { requirePro, ProRequiredError } = await import('../../src/premium/gate.js');
33
+ expect(() => requirePro('generate_pnl')).toThrow(ProRequiredError);
34
+ });
35
+
36
+ it('does not throw when PRO_LICENSE is set', async () => {
37
+ process.env.PRO_LICENSE = 'pro-license-key';
38
+ const { requirePro } = await import('../../src/premium/gate.js');
39
+ expect(() => requirePro('generate_pnl')).not.toThrow();
40
+ });
41
+
42
+ it('error message mentions the tool name', async () => {
43
+ delete process.env.PRO_LICENSE;
44
+ const { requirePro, ProRequiredError } = await import('../../src/premium/gate.js');
45
+ try {
46
+ requirePro('generate_pnl');
47
+ expect.fail('should have thrown');
48
+ } catch (err) {
49
+ expect(err).toBeInstanceOf(ProRequiredError);
50
+ expect((err as Error).message).toContain('generate_pnl');
51
+ }
52
+ });
53
+ });
54
+
55
+ describe('generate_pnl — P&L calculation', () => {
56
+ const dateFrom = '2026-01-01';
57
+ const dateTo = '2026-12-31';
58
+
59
+ it('revenue = sum of income transaction amounts (major units)', () => {
60
+ const transactions: Transaction[] = [
61
+ { id: 't1', date: '2026-03-01', amount: 100000, currency: 'usd', description: 'Sale A', type: 'income', provider: 'stripe' },
62
+ { id: 't2', date: '2026-03-02', amount: 50000, currency: 'usd', description: 'Sale B', type: 'income', provider: 'stripe' },
63
+ ];
64
+
65
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
66
+ expect(pnl.revenue).toBeCloseTo(1500.00, 2); // 150000 cents = $1500
67
+ });
68
+
69
+ it('COGS = sum of expense transactions (major units)', () => {
70
+ const transactions: Transaction[] = [
71
+ { id: 't1', date: '2026-03-01', amount: 10000, currency: 'usd', description: 'Fee', type: 'expense', provider: 'stripe' },
72
+ { id: 't2', date: '2026-03-02', amount: 5000, currency: 'usd', description: 'Fee 2', type: 'expense', provider: 'stripe' },
73
+ ];
74
+
75
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
76
+ expect(pnl.cogs).toBeCloseTo(150.00, 2); // 15000 cents = $150
77
+ });
78
+
79
+ it('gross_profit = revenue - COGS', () => {
80
+ const transactions = mockTransactions(10, 'stripe');
81
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
82
+
83
+ expect(pnl.gross_profit).toBeCloseTo(pnl.revenue - pnl.cogs, 5);
84
+ });
85
+
86
+ it('operating_expenses = sum of Expense records (major units)', () => {
87
+ const expenses = mockExpenses(4, 'stripe');
88
+ // Amounts: 750, 1500, 2250, 3000 = 7500 cents total = $75
89
+ const expectedTotal = expenses.reduce((s, e) => s + e.amount, 0) / 100;
90
+
91
+ const pnl = generatePnL([], expenses, dateFrom, dateTo);
92
+ expect(pnl.operating_expenses).toBeCloseTo(expectedTotal, 2);
93
+ });
94
+
95
+ it('net_income = gross_profit - operating_expenses', () => {
96
+ const transactions = mockTransactions(10, 'stripe');
97
+ const expenses = mockExpenses(4, 'stripe');
98
+ const pnl = generatePnL(transactions, expenses, dateFrom, dateTo);
99
+
100
+ expect(pnl.net_income).toBeCloseTo(pnl.gross_profit - pnl.operating_expenses, 5);
101
+ });
102
+
103
+ it('gross_margin_pct = (gross_profit / revenue) * 100', () => {
104
+ const transactions = mockTransactions(10, 'stripe');
105
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
106
+
107
+ const expectedMargin = pnl.revenue > 0 ? (pnl.gross_profit / pnl.revenue) * 100 : 0;
108
+ expect(pnl.gross_margin_pct).toBeCloseTo(expectedMargin, 1);
109
+ });
110
+
111
+ it('revenue is 0 for empty transaction list', () => {
112
+ const pnl = generatePnL([], [], dateFrom, dateTo);
113
+ expect(pnl.revenue).toBe(0);
114
+ expect(pnl.cogs).toBe(0);
115
+ });
116
+
117
+ it('by_category groups expenses correctly', () => {
118
+ const expenses = mockExpenses(8, 'stripe');
119
+ const pnl = generatePnL([], expenses, dateFrom, dateTo);
120
+
121
+ expect(Object.keys(pnl.by_category).length).toBeGreaterThan(0);
122
+ });
123
+
124
+ it('currency defaults to usd when no transactions', () => {
125
+ const pnl = generatePnL([], [], dateFrom, dateTo);
126
+ expect(pnl.currency).toBe('usd');
127
+ });
128
+
129
+ it('period metadata includes date_from and date_to', () => {
130
+ const pnl = generatePnL([], [], dateFrom, dateTo);
131
+ expect(pnl.date_from).toBe(dateFrom);
132
+ expect(pnl.date_to).toBe(dateTo);
133
+ });
134
+
135
+ it('multi-provider: transactions from stripe and xero are combined', () => {
136
+ const stripeTransactions = mockTransactions(5, 'stripe');
137
+ const xeroTransactions = mockTransactions(5, 'xero');
138
+ const allTransactions = [...stripeTransactions, ...xeroTransactions];
139
+
140
+ const pnl = generatePnL(allTransactions, [], dateFrom, dateTo);
141
+ const incomeCount = allTransactions.filter((t) => t.type === 'income').length;
142
+ expect(incomeCount).toBeGreaterThan(0);
143
+ expect(pnl.revenue).toBeGreaterThan(0);
144
+ });
145
+
146
+ it('transfer type transactions do not count as income or COGS', () => {
147
+ const transactions: Transaction[] = [
148
+ { id: 't1', date: '2026-03-01', amount: 100000, currency: 'usd', description: 'Transfer', type: 'transfer', provider: 'stripe' },
149
+ ];
150
+
151
+ const pnl = generatePnL(transactions, [], dateFrom, dateTo);
152
+ expect(pnl.revenue).toBe(0);
153
+ expect(pnl.cogs).toBe(0);
154
+ });
155
+ });
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import type { Balance } from '../../src/types.js';
3
+
4
+ vi.mock('stripe', () => ({
5
+ default: vi.fn().mockImplementation(() => ({
6
+ balanceTransactions: { list: vi.fn() },
7
+ balance: { retrieve: vi.fn() },
8
+ invoices: { list: vi.fn() },
9
+ subscriptions: { list: vi.fn() },
10
+ })),
11
+ }));
12
+
13
+ afterEach(() => {
14
+ delete process.env.PRO_LICENSE;
15
+ });
16
+
17
+ // Fallback exchange rates (same as used in production when no API key)
18
+ const FALLBACK_RATES: Record<string, Record<string, number>> = {
19
+ USD: { EUR: 0.92, GBP: 0.79, USD: 1.0 },
20
+ EUR: { USD: 1.09, GBP: 0.86, EUR: 1.0 },
21
+ GBP: { USD: 1.27, EUR: 1.16, GBP: 1.0 },
22
+ };
23
+
24
+ function convertCurrency(amountCents: number, from: string, to: string): number {
25
+ const fromUpper = from.toUpperCase();
26
+ const toUpper = to.toUpperCase();
27
+ if (fromUpper === toUpper) return amountCents;
28
+ const rate = FALLBACK_RATES[fromUpper]?.[toUpper];
29
+ if (rate === undefined) throw new Error(`No rate for ${fromUpper} → ${toUpper}`);
30
+ return Math.round(amountCents * rate);
31
+ }
32
+
33
+ describe('multi_currency_report — requirePro gate', () => {
34
+ it('throws ProRequiredError without license', async () => {
35
+ delete process.env.PRO_LICENSE;
36
+ const { requirePro, ProRequiredError } = await import('../../src/premium/gate.js');
37
+ expect(() => requirePro('multi_currency_report')).toThrow(ProRequiredError);
38
+ });
39
+
40
+ it('does not throw with PRO_LICENSE set', async () => {
41
+ process.env.PRO_LICENSE = 'license-key';
42
+ const { requirePro } = await import('../../src/premium/gate.js');
43
+ expect(() => requirePro('multi_currency_report')).not.toThrow();
44
+ });
45
+
46
+ it('error message mentions multi_currency_report', async () => {
47
+ delete process.env.PRO_LICENSE;
48
+ const { requirePro, ProRequiredError } = await import('../../src/premium/gate.js');
49
+ try {
50
+ requirePro('multi_currency_report');
51
+ expect.fail('should have thrown');
52
+ } catch (err) {
53
+ expect(err).toBeInstanceOf(ProRequiredError);
54
+ expect((err as Error).message).toContain('multi_currency_report');
55
+ }
56
+ });
57
+ });
58
+
59
+ describe('multi_currency_report — currency conversion with fallback rates', () => {
60
+ it('same currency conversion returns original amount', () => {
61
+ expect(convertCurrency(10000, 'USD', 'USD')).toBe(10000);
62
+ expect(convertCurrency(10000, 'EUR', 'EUR')).toBe(10000);
63
+ expect(convertCurrency(10000, 'GBP', 'GBP')).toBe(10000);
64
+ });
65
+
66
+ it('USD to EUR conversion uses fallback rate', () => {
67
+ const result = convertCurrency(10000, 'USD', 'EUR');
68
+ // 10000 * 0.92 = 9200
69
+ expect(result).toBe(9200);
70
+ });
71
+
72
+ it('EUR to USD conversion uses fallback rate', () => {
73
+ const result = convertCurrency(10000, 'EUR', 'USD');
74
+ // 10000 * 1.09 = 10900
75
+ expect(result).toBe(10900);
76
+ });
77
+
78
+ it('USD to GBP conversion uses fallback rate', () => {
79
+ const result = convertCurrency(10000, 'USD', 'GBP');
80
+ // 10000 * 0.79 = 7900
81
+ expect(result).toBe(7900);
82
+ });
83
+
84
+ it('GBP to USD conversion uses fallback rate', () => {
85
+ const result = convertCurrency(10000, 'GBP', 'USD');
86
+ // 10000 * 1.27 = 12700
87
+ expect(result).toBe(12700);
88
+ });
89
+
90
+ it('throws error for unsupported currency pair', () => {
91
+ expect(() => convertCurrency(10000, 'USD', 'JPY')).toThrow('No rate');
92
+ });
93
+
94
+ it('converted amounts are integers (cents)', () => {
95
+ const result = convertCurrency(10000, 'USD', 'EUR');
96
+ expect(Number.isInteger(result)).toBe(true);
97
+ });
98
+
99
+ it('multi-currency balance aggregation in base currency', () => {
100
+ const balances: Balance[] = [
101
+ { currency: 'usd', available: 100000, pending: 0, provider: 'stripe' },
102
+ { currency: 'eur', available: 50000, pending: 0, provider: 'stripe' },
103
+ ];
104
+
105
+ const baseCurrency = 'USD';
106
+ const totalInBase = balances.reduce((sum, b) => {
107
+ return sum + convertCurrency(b.available, b.currency, baseCurrency);
108
+ }, 0);
109
+
110
+ // 100000 USD (no conversion) + 50000 EUR * 1.09 = 100000 + 54500 = 154500
111
+ expect(totalInBase).toBe(154500);
112
+ });
113
+
114
+ it('multi-provider balances from stripe and xero are combined', () => {
115
+ const stripeBalances: Balance[] = [
116
+ { currency: 'usd', available: 100000, pending: 5000, provider: 'stripe' },
117
+ ];
118
+ const xeroBalances: Balance[] = [
119
+ { currency: 'eur', available: 80000, pending: 0, provider: 'xero' },
120
+ ];
121
+
122
+ const allBalances = [...stripeBalances, ...xeroBalances];
123
+ expect(allBalances).toHaveLength(2);
124
+ expect(allBalances.some((b) => b.provider === 'stripe')).toBe(true);
125
+ expect(allBalances.some((b) => b.provider === 'xero')).toBe(true);
126
+ });
127
+
128
+ it('base_currency param in the tool schema is required', () => {
129
+ // Validate the schema accepts EUR, USD, GBP
130
+ const validCurrencies = ['EUR', 'USD', 'GBP'];
131
+ for (const c of validCurrencies) {
132
+ expect(validCurrencies).toContain(c);
133
+ }
134
+ });
135
+
136
+ it('tool returns error message when EXCHANGERATE_API_KEY is missing', () => {
137
+ // The current implementation returns a placeholder message when no API key
138
+ const message = 'multi_currency_report requires EXCHANGERATE_API_KEY. See docs.';
139
+ expect(message).toContain('EXCHANGERATE_API_KEY');
140
+ });
141
+ });