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,210 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { mockTransactions } from '../helpers/mock-data.js';
3
+
4
+ // ─── Mock the Stripe SDK ───────────────────────────────────────────────────────
5
+ vi.mock('stripe', () => {
6
+ const mockStripe = vi.fn().mockImplementation(() => ({
7
+ balanceTransactions: {
8
+ list: vi.fn().mockResolvedValue({
9
+ data: [
10
+ {
11
+ id: 'txn_001',
12
+ created: 1743465600,
13
+ amount: 10000,
14
+ currency: 'usd',
15
+ description: 'Payment from customer A',
16
+ type: 'charge',
17
+ metadata: {},
18
+ },
19
+ {
20
+ id: 'txn_002',
21
+ created: 1743552000,
22
+ amount: 5000,
23
+ currency: 'usd',
24
+ description: 'Payment from customer B',
25
+ type: 'charge',
26
+ metadata: {},
27
+ },
28
+ {
29
+ id: 'txn_003',
30
+ created: 1743638400,
31
+ amount: -500,
32
+ currency: 'usd',
33
+ description: 'Stripe fee',
34
+ type: 'stripe_fee',
35
+ metadata: {},
36
+ },
37
+ ],
38
+ has_more: false,
39
+ }),
40
+ },
41
+ balance: {
42
+ retrieve: vi.fn().mockResolvedValue({
43
+ available: [{ currency: 'usd', amount: 500000 }],
44
+ pending: [{ currency: 'usd', amount: 25000 }],
45
+ }),
46
+ },
47
+ invoices: {
48
+ list: vi.fn().mockResolvedValue({ data: [], has_more: false }),
49
+ },
50
+ subscriptions: {
51
+ list: vi.fn().mockResolvedValue({ data: [] }),
52
+ },
53
+ }));
54
+ return { default: mockStripe };
55
+ });
56
+
57
+ describe('list_transactions tool handler', () => {
58
+ beforeEach(() => {
59
+ process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
60
+ });
61
+
62
+ afterEach(() => {
63
+ vi.clearAllMocks();
64
+ });
65
+
66
+ it('getTransactions returns correct structure from Stripe adapter', async () => {
67
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
68
+ const adapter = new StripeAdapter();
69
+ const result = await adapter.getTransactions({});
70
+
71
+ expect(result).toHaveProperty('data');
72
+ expect(result).toHaveProperty('has_more');
73
+ expect(Array.isArray(result.data)).toBe(true);
74
+ });
75
+
76
+ it('returns transactions with required fields', async () => {
77
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
78
+ const adapter = new StripeAdapter();
79
+ const result = await adapter.getTransactions({});
80
+
81
+ for (const tx of result.data) {
82
+ expect(tx).toHaveProperty('id');
83
+ expect(tx).toHaveProperty('date');
84
+ expect(tx).toHaveProperty('amount');
85
+ expect(tx).toHaveProperty('currency');
86
+ expect(tx).toHaveProperty('description');
87
+ expect(tx).toHaveProperty('type');
88
+ expect(tx).toHaveProperty('provider');
89
+ }
90
+ });
91
+
92
+ it('all transactions have provider set to stripe', async () => {
93
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
94
+ const adapter = new StripeAdapter();
95
+ const result = await adapter.getTransactions({});
96
+
97
+ for (const tx of result.data) {
98
+ expect(tx.provider).toBe('stripe');
99
+ }
100
+ });
101
+
102
+ it('pagination: has_more false means no next cursor', async () => {
103
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
104
+ const adapter = new StripeAdapter();
105
+ const result = await adapter.getTransactions({ limit: 10 });
106
+
107
+ expect(result.has_more).toBe(false);
108
+ expect(result.next_cursor).toBeUndefined();
109
+ });
110
+
111
+ it('income type transactions have positive amounts', async () => {
112
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
113
+ const adapter = new StripeAdapter();
114
+ const result = await adapter.getTransactions({});
115
+
116
+ const incomeTransactions = result.data.filter((t) => t.type === 'income');
117
+ for (const tx of incomeTransactions) {
118
+ expect(tx.amount).toBeGreaterThan(0);
119
+ }
120
+ });
121
+
122
+ it('amounts are in cents (integer values)', async () => {
123
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
124
+ const adapter = new StripeAdapter();
125
+ const result = await adapter.getTransactions({});
126
+
127
+ for (const tx of result.data) {
128
+ expect(Number.isInteger(tx.amount)).toBe(true);
129
+ }
130
+ });
131
+
132
+ it('date field is a valid ISO string', async () => {
133
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
134
+ const adapter = new StripeAdapter();
135
+ const result = await adapter.getTransactions({});
136
+
137
+ for (const tx of result.data) {
138
+ const d = new Date(tx.date);
139
+ expect(d.toString()).not.toBe('Invalid Date');
140
+ }
141
+ });
142
+
143
+ it('audit is logged after successful list_transactions', async () => {
144
+ const fs = await import('fs');
145
+ const os = await import('os');
146
+ const path = await import('path');
147
+ const TEST_DB_PATH = path.join(os.homedir(), '.financeops', 'audit-test-list-tx.db');
148
+
149
+ const { logAudit, getAuditLog, resetAuditDb } = await import('../../src/lib/audit.js');
150
+
151
+ resetAuditDb();
152
+ logAudit({ tool: 'list_transactions', provider: 'stripe', input_summary: '{ limit: 10 }', success: true }, TEST_DB_PATH);
153
+
154
+ const log = getAuditLog(10, TEST_DB_PATH);
155
+ expect(log).toHaveLength(1);
156
+ expect(log[0].tool).toBe('list_transactions');
157
+ expect(log[0].provider).toBe('stripe');
158
+ expect(log[0].success).toBe(1);
159
+
160
+ resetAuditDb();
161
+ if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
162
+ });
163
+
164
+ it('audit logs failed transactions with error message', async () => {
165
+ const fs = await import('fs');
166
+ const os = await import('os');
167
+ const path = await import('path');
168
+ const TEST_DB_PATH = path.join(os.homedir(), '.financeops', 'audit-test-list-tx-err.db');
169
+
170
+ const { logAudit, getAuditLog, resetAuditDb } = await import('../../src/lib/audit.js');
171
+
172
+ resetAuditDb();
173
+ logAudit({ tool: 'list_transactions', provider: 'stripe', success: false, error: 'Token expired' }, TEST_DB_PATH);
174
+
175
+ const log = getAuditLog(10, TEST_DB_PATH);
176
+ expect(log[0].success).toBe(0);
177
+ expect(log[0].error).toBe('Token expired');
178
+
179
+ resetAuditDb();
180
+ if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
181
+ });
182
+
183
+ it('mock data helper produces deterministic transactions', () => {
184
+ const t1 = mockTransactions(5, 'stripe');
185
+ const t2 = mockTransactions(5, 'stripe');
186
+
187
+ expect(t1.map((t) => t.amount)).toEqual(t2.map((t) => t.amount));
188
+ expect(t1.map((t) => t.id)).toEqual(t2.map((t) => t.id));
189
+ });
190
+
191
+ it('mock transactions have all required fields', () => {
192
+ const transactions = mockTransactions(10, 'stripe');
193
+
194
+ for (const tx of transactions) {
195
+ expect(tx.id).toBeDefined();
196
+ expect(tx.date).toBeDefined();
197
+ expect(tx.amount).toBeGreaterThan(0);
198
+ expect(tx.currency).toBe('usd');
199
+ expect(['income', 'expense', 'transfer']).toContain(tx.type);
200
+ expect(tx.provider).toBe('stripe');
201
+ }
202
+ });
203
+
204
+ it('transactions from xero have provider set to xero', () => {
205
+ const transactions = mockTransactions(5, 'xero');
206
+ for (const tx of transactions) {
207
+ expect(tx.provider).toBe('xero');
208
+ }
209
+ });
210
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import type { Transaction } from '../../src/types.js';
3
+
4
+ // ─── Mock Stripe SDK ──────────────────────────────────────────────────────────
5
+ vi.mock('stripe', () => {
6
+ const transactions = [
7
+ { id: 'txn_1', created: 1743465600, amount: 10000, currency: 'usd', description: 'Revenue A', type: 'charge', metadata: {} },
8
+ { id: 'txn_2', created: 1743552000, amount: 20000, currency: 'usd', description: 'Revenue B', type: 'charge', metadata: {} },
9
+ { id: 'txn_3', created: 1743638400, amount: 15000, currency: 'usd', description: 'Revenue C', type: 'charge', metadata: {} },
10
+ { id: 'txn_4', created: 1743724800, amount: 5000, currency: 'usd', description: 'Revenue D', type: 'charge', metadata: {} },
11
+ { id: 'txn_5', created: 1743811200, amount: -500, currency: 'usd', description: 'Stripe fee', type: 'stripe_fee', metadata: {} },
12
+ { id: 'txn_6', created: 1743897600, amount: -1000, currency: 'usd', description: 'Refund', type: 'payment_refund', metadata: {} },
13
+ ];
14
+
15
+ const mockStripe = vi.fn().mockImplementation(() => ({
16
+ balanceTransactions: {
17
+ list: vi.fn().mockResolvedValue({ data: transactions, has_more: false }),
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('revenue_summary — provider layer', () => {
36
+ beforeEach(() => {
37
+ process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.clearAllMocks();
42
+ });
43
+
44
+ it('income transactions have type income', async () => {
45
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
46
+ const adapter = new StripeAdapter();
47
+ const result = await adapter.getTransactions({});
48
+
49
+ const incomeTransactions = result.data.filter((t) => t.type === 'income');
50
+ expect(incomeTransactions.length).toBeGreaterThan(0);
51
+ });
52
+
53
+ it('total revenue = sum of income transaction amounts 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 incomeTransactions = result.data.filter((t) => t.type === 'income');
59
+ const totalCents = incomeTransactions.reduce((s, t) => s + t.amount, 0);
60
+ // mock: 10000 + 20000 + 15000 + 5000 = 50000 cents = $500
61
+ expect(totalCents).toBe(50000);
62
+ expect(totalCents / 100).toBe(500);
63
+ });
64
+
65
+ it('revenue calculation excludes expense type transactions', async () => {
66
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
67
+ const adapter = new StripeAdapter();
68
+ const result = await adapter.getTransactions({});
69
+
70
+ const expenseTransactions = result.data.filter((t) => t.type === 'expense');
71
+ const incomeTransactions = result.data.filter((t) => t.type === 'income');
72
+
73
+ expect(expenseTransactions.length).toBeGreaterThan(0);
74
+ expect(incomeTransactions.length).toBeGreaterThan(0);
75
+
76
+ const incomeIds = new Set(incomeTransactions.map((t) => t.id));
77
+ for (const exp of expenseTransactions) {
78
+ expect(incomeIds.has(exp.id)).toBe(false);
79
+ }
80
+ });
81
+
82
+ it('average transaction = total / count', async () => {
83
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
84
+ const adapter = new StripeAdapter();
85
+ const result = await adapter.getTransactions({});
86
+
87
+ const incomeTransactions = result.data.filter((t) => t.type === 'income');
88
+ const totalCents = incomeTransactions.reduce((s, t) => s + t.amount, 0);
89
+ const expectedAvg = totalCents / incomeTransactions.length / 100;
90
+
91
+ expect(expectedAvg).toBeCloseTo(125.00, 2); // 50000 / 4 / 100 = 125
92
+ });
93
+ });
94
+
95
+ describe('revenue_summary — period grouping logic', () => {
96
+ it('groups transactions by month correctly', () => {
97
+ const transactions: Transaction[] = [
98
+ { id: 't1', date: '2026-01-10T00:00:00Z', amount: 10000, currency: 'usd', description: 'Jan sale', type: 'income', provider: 'stripe' },
99
+ { id: 't2', date: '2026-01-25T00:00:00Z', amount: 20000, currency: 'usd', description: 'Jan sale 2', type: 'income', provider: 'stripe' },
100
+ { id: 't3', date: '2026-02-15T00:00:00Z', amount: 15000, currency: 'usd', description: 'Feb sale', type: 'income', provider: 'stripe' },
101
+ ];
102
+
103
+ const byMonth: Record<string, number> = {};
104
+ for (const t of transactions) {
105
+ const month = t.date.substring(0, 7);
106
+ byMonth[month] = (byMonth[month] ?? 0) + t.amount;
107
+ }
108
+
109
+ expect(byMonth['2026-01']).toBe(30000);
110
+ expect(byMonth['2026-02']).toBe(15000);
111
+ });
112
+
113
+ it('monthly growth rate is positive when revenue increasing', () => {
114
+ const monthlyRevenues = [10000, 12000, 14400];
115
+ const growthRates = [];
116
+
117
+ for (let i = 1; i < monthlyRevenues.length; i++) {
118
+ const rate = (monthlyRevenues[i] - monthlyRevenues[i - 1]) / monthlyRevenues[i - 1];
119
+ growthRates.push(rate);
120
+ }
121
+
122
+ for (const rate of growthRates) {
123
+ expect(rate).toBeGreaterThan(0);
124
+ }
125
+ expect(growthRates[0]).toBeCloseTo(0.2, 2);
126
+ });
127
+
128
+ it('monthly growth rate is negative when revenue declining', () => {
129
+ const monthlyRevenues = [14000, 12000, 10000];
130
+ const growthRate = (monthlyRevenues[1] - monthlyRevenues[0]) / monthlyRevenues[0];
131
+ expect(growthRate).toBeLessThan(0);
132
+ });
133
+
134
+ it('growth rate is zero when revenue flat', () => {
135
+ const monthlyRevenues = [10000, 10000, 10000];
136
+ const growthRate = (monthlyRevenues[1] - monthlyRevenues[0]) / monthlyRevenues[0];
137
+ expect(growthRate).toBe(0);
138
+ });
139
+
140
+ it('top customers are sorted by revenue descending', () => {
141
+ const transactions: Transaction[] = [
142
+ { id: 't1', date: '2026-01-10T00:00:00Z', amount: 5000, currency: 'usd', description: 'Customer B', type: 'income', provider: 'stripe' },
143
+ { id: 't2', date: '2026-01-15T00:00:00Z', amount: 50000, currency: 'usd', description: 'Customer A', type: 'income', provider: 'stripe' },
144
+ { id: 't3', date: '2026-01-20T00:00:00Z', amount: 10000, currency: 'usd', description: 'Customer C', type: 'income', provider: 'stripe' },
145
+ ];
146
+
147
+ const byCustomer: Record<string, number> = {};
148
+ for (const t of transactions) {
149
+ byCustomer[t.description] = (byCustomer[t.description] ?? 0) + t.amount;
150
+ }
151
+
152
+ const topCustomers = Object.entries(byCustomer)
153
+ .sort(([, a], [, b]) => b - a)
154
+ .slice(0, 3)
155
+ .map(([name, amount]) => ({ name, amount: amount / 100 }));
156
+
157
+ expect(topCustomers[0].name).toBe('Customer A');
158
+ expect(topCustomers[0].amount).toBe(500);
159
+ expect(topCustomers[1].name).toBe('Customer C');
160
+ expect(topCustomers[2].name).toBe('Customer B');
161
+ });
162
+
163
+ it('returns 0 revenue for empty transaction list', () => {
164
+ const transactions: Transaction[] = [];
165
+ const incomeTransactions = transactions.filter((t) => t.type === 'income');
166
+ const totalRevenue = incomeTransactions.reduce((s, t) => s + t.amount, 0) / 100;
167
+
168
+ expect(totalRevenue).toBe(0);
169
+ });
170
+
171
+ it('average transaction is 0 when no income', () => {
172
+ const incomeTransactions: Transaction[] = [];
173
+ const avg = incomeTransactions.length > 0
174
+ ? incomeTransactions.reduce((s, t) => s + t.amount, 0) / incomeTransactions.length / 100
175
+ : 0;
176
+
177
+ expect(avg).toBe(0);
178
+ });
179
+
180
+ it('transaction count matches income transactions only', async () => {
181
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
182
+ const adapter = new StripeAdapter();
183
+ const result = await adapter.getTransactions({});
184
+
185
+ const incomeCount = result.data.filter((t) => t.type === 'income').length;
186
+ expect(incomeCount).toBe(4); // From mock: 4 charge transactions
187
+ });
188
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "lib": ["ES2022"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist", "tests"]
20
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ testTimeout: 30000,
6
+ globals: true,
7
+ environment: 'node',
8
+ },
9
+ });