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,135 @@
1
+ import type {
2
+ Transaction,
3
+ Invoice,
4
+ Expense,
5
+ Balance,
6
+ Subscription,
7
+ } from '../../src/adapters/types.js';
8
+
9
+ /**
10
+ * Deterministic seed-based value generator.
11
+ * Returns the same sequence every time for the same seed.
12
+ */
13
+ function seededValues(count: number, base: number, step: number): number[] {
14
+ return Array.from({ length: count }, (_, i) => base + i * step);
15
+ }
16
+
17
+ export function mockTransactions(
18
+ count: number = 10,
19
+ provider: 'stripe' | 'xero' = 'stripe'
20
+ ): Transaction[] {
21
+ const types: Transaction['type'][] = ['income', 'expense', 'transfer'];
22
+ const categories = ['Revenue', 'Refunds', 'Fees'];
23
+ // Deterministic amounts: 1500, 2200, 2900, 3600, 4300, 5000, 5700, 6400, 7100, 7800
24
+ const amounts = seededValues(count, 1500, 700);
25
+
26
+ return Array.from({ length: count }, (_, i) => ({
27
+ id: `txn_${provider}_${i}`,
28
+ date: new Date(2026, 2, 1 + i).toISOString(), // March 2026
29
+ amount: amounts[i],
30
+ currency: 'usd',
31
+ description: `${provider} transaction ${i}`,
32
+ category: categories[i % 3],
33
+ type: types[i % 3],
34
+ provider,
35
+ }));
36
+ }
37
+
38
+ export function mockInvoices(
39
+ count: number = 5,
40
+ provider: 'stripe' | 'xero' = 'stripe'
41
+ ): Invoice[] {
42
+ const statuses: Invoice['status'][] = ['paid', 'pending', 'overdue', 'paid', 'pending'];
43
+ // Deterministic amounts: 5000, 10000, 15000, 20000, 25000
44
+ const amounts = seededValues(count, 5000, 5000);
45
+
46
+ return Array.from({ length: count }, (_, i) => ({
47
+ id: `inv_${provider}_${i}`,
48
+ number: `INV-${String(i + 1).padStart(3, '0')}`,
49
+ customer_name: `Customer ${i + 1}`,
50
+ customer_email: `customer${i + 1}@example.com`,
51
+ amount: amounts[i],
52
+ currency: 'usd',
53
+ status: statuses[i % statuses.length],
54
+ due_date: new Date(2026, 3, 10 + i).toISOString(),
55
+ issued_date: new Date(2026, 2, 10 + i).toISOString(),
56
+ paid_date:
57
+ statuses[i % statuses.length] === 'paid'
58
+ ? new Date(2026, 3, 5 + i).toISOString()
59
+ : undefined,
60
+ provider,
61
+ }));
62
+ }
63
+
64
+ export function mockExpenses(
65
+ count: number = 8,
66
+ provider: 'stripe' | 'xero' = 'stripe'
67
+ ): Expense[] {
68
+ const categories = [
69
+ 'Processing Fees',
70
+ 'Refunds',
71
+ 'Office',
72
+ 'Software',
73
+ 'Travel',
74
+ 'Marketing',
75
+ 'Hosting',
76
+ 'Payroll',
77
+ ];
78
+ // Deterministic amounts: 750, 1500, 2250, 3000, 3750, 4500, 5250, 6000
79
+ const amounts = seededValues(count, 750, 750);
80
+
81
+ return Array.from({ length: count }, (_, i) => ({
82
+ id: `exp_${provider}_${i}`,
83
+ date: new Date(2026, 2, 1 + i * 3).toISOString(),
84
+ amount: amounts[i],
85
+ currency: 'usd',
86
+ description: `${categories[i % categories.length]} expense`,
87
+ category: categories[i % categories.length],
88
+ provider,
89
+ }));
90
+ }
91
+
92
+ export function mockBalances(provider: 'stripe' | 'xero' = 'stripe'): Balance[] {
93
+ return [
94
+ { currency: 'usd', available: 500000, pending: 25000, provider },
95
+ { currency: 'eur', available: 150000, pending: 0, provider },
96
+ ];
97
+ }
98
+
99
+ export function mockSubscriptions(): Subscription[] {
100
+ return [
101
+ {
102
+ id: 'sub_1',
103
+ customer_name: 'Enterprise Client',
104
+ amount: 9900,
105
+ currency: 'usd',
106
+ interval: 'month',
107
+ status: 'active',
108
+ current_period_start: '2026-03-01T00:00:00Z',
109
+ current_period_end: '2026-04-01T00:00:00Z',
110
+ provider: 'stripe',
111
+ },
112
+ {
113
+ id: 'sub_2',
114
+ customer_name: 'Startup Co',
115
+ amount: 2900,
116
+ currency: 'usd',
117
+ interval: 'month',
118
+ status: 'active',
119
+ current_period_start: '2026-03-15T00:00:00Z',
120
+ current_period_end: '2026-04-15T00:00:00Z',
121
+ provider: 'stripe',
122
+ },
123
+ {
124
+ id: 'sub_3',
125
+ customer_name: 'Agency Ltd',
126
+ amount: 14900,
127
+ currency: 'usd',
128
+ interval: 'month',
129
+ status: 'past_due',
130
+ current_period_start: '2026-02-01T00:00:00Z',
131
+ current_period_end: '2026-03-01T00:00:00Z',
132
+ provider: 'stripe',
133
+ },
134
+ ];
135
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { logAudit, getAuditLog, cleanup, resetAuditDb } from '../../src/lib/audit.js';
6
+
7
+ const TEST_DB_PATH = path.join(os.homedir(), '.financeops', 'audit-test.db');
8
+
9
+ afterEach(() => {
10
+ resetAuditDb();
11
+ if (fs.existsSync(TEST_DB_PATH)) {
12
+ fs.unlinkSync(TEST_DB_PATH);
13
+ }
14
+ });
15
+
16
+ describe('audit trail', () => {
17
+ it('logs an audit entry', () => {
18
+ logAudit(
19
+ {
20
+ tool: 'list_transactions',
21
+ provider: 'stripe',
22
+ input_summary: '{ limit: 10 }',
23
+ success: true,
24
+ },
25
+ TEST_DB_PATH
26
+ );
27
+
28
+ const log = getAuditLog(10, TEST_DB_PATH);
29
+ expect(log).toHaveLength(1);
30
+ expect(log[0].tool).toBe('list_transactions');
31
+ expect(log[0].provider).toBe('stripe');
32
+ });
33
+
34
+ it('logs failed entries with error message', () => {
35
+ logAudit(
36
+ {
37
+ tool: 'get_balances',
38
+ provider: 'xero',
39
+ success: false,
40
+ error: 'Token expired',
41
+ },
42
+ TEST_DB_PATH
43
+ );
44
+
45
+ const log = getAuditLog(10, TEST_DB_PATH);
46
+ expect(log[0].error).toBe('Token expired');
47
+ });
48
+
49
+ it('returns most recent entries first', () => {
50
+ logAudit({ tool: 'tool_a', success: true }, TEST_DB_PATH);
51
+ logAudit({ tool: 'tool_b', success: true }, TEST_DB_PATH);
52
+ logAudit({ tool: 'tool_c', success: true }, TEST_DB_PATH);
53
+
54
+ const log = getAuditLog(10, TEST_DB_PATH);
55
+ expect(log[0].tool).toBe('tool_c');
56
+ expect(log[2].tool).toBe('tool_a');
57
+ });
58
+
59
+ it('respects the limit parameter', () => {
60
+ for (let i = 0; i < 10; i++) {
61
+ logAudit({ tool: `tool_${i}`, success: true }, TEST_DB_PATH);
62
+ }
63
+
64
+ const log = getAuditLog(5, TEST_DB_PATH);
65
+ expect(log).toHaveLength(5);
66
+ });
67
+
68
+ it('cleanup removes entries older than maxAgeDays', () => {
69
+ // We can't easily insert old entries in SQLite with 'now' default,
70
+ // so we verify cleanup runs without error and doesn't remove fresh entries.
71
+ logAudit({ tool: 'fresh_entry', success: true }, TEST_DB_PATH);
72
+
73
+ cleanup(90, TEST_DB_PATH);
74
+
75
+ const log = getAuditLog(10, TEST_DB_PATH);
76
+ expect(log).toHaveLength(1); // Fresh entry survives
77
+ expect(log[0].tool).toBe('fresh_entry');
78
+ });
79
+
80
+ it('handles multiple providers in the same session', () => {
81
+ logAudit({ tool: 'list_transactions', provider: 'stripe', success: true }, TEST_DB_PATH);
82
+ logAudit({ tool: 'list_transactions', provider: 'xero', success: true }, TEST_DB_PATH);
83
+
84
+ const log = getAuditLog(10, TEST_DB_PATH);
85
+ expect(log).toHaveLength(2);
86
+ expect(log.map((e) => e.provider)).toContain('stripe');
87
+ expect(log.map((e) => e.provider)).toContain('xero');
88
+ });
89
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ // ─── Mock Stripe SDK ──────────────────────────────────────────────────────────
4
+ vi.mock('stripe', () => {
5
+ const mockStripe = 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
+ return { default: mockStripe };
12
+ });
13
+
14
+ describe('providers — caching', () => {
15
+ beforeEach(async () => {
16
+ process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
17
+ process.env.XERO_TENANT_ID = 'tenant_test';
18
+ process.env.XERO_CLIENT_ID = 'client_id_test';
19
+ process.env.XERO_CLIENT_SECRET = 'client_secret_test';
20
+ process.env.XERO_REFRESH_TOKEN = 'refresh_token_test';
21
+
22
+ const { clearProviderCache } = await import('../../src/lib/providers.js');
23
+ clearProviderCache();
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ it('getProvider returns same instance on second call (cache hit)', async () => {
31
+ const { getProvider, clearProviderCache } = await import('../../src/lib/providers.js');
32
+ clearProviderCache();
33
+
34
+ const p1 = getProvider('stripe');
35
+ const p2 = getProvider('stripe');
36
+
37
+ expect(p1).toBe(p2); // Strict reference equality
38
+ });
39
+
40
+ it('getProvider creates fresh instance after clearProviderCache', async () => {
41
+ const { getProvider, clearProviderCache } = await import('../../src/lib/providers.js');
42
+ clearProviderCache();
43
+
44
+ const p1 = getProvider('stripe');
45
+ clearProviderCache();
46
+ const p2 = getProvider('stripe');
47
+
48
+ // After cache clear, new instance is created
49
+ expect(p1).not.toBe(p2);
50
+ });
51
+
52
+ it('getProvider returns different instances for stripe vs xero', async () => {
53
+ const { getProvider, clearProviderCache } = await import('../../src/lib/providers.js');
54
+ clearProviderCache();
55
+
56
+ const stripe = getProvider('stripe');
57
+ const xero = getProvider('xero');
58
+
59
+ expect(stripe).not.toBe(xero);
60
+ });
61
+
62
+ it('getProvider stripe instance has getTransactions method', async () => {
63
+ const { getProvider } = await import('../../src/lib/providers.js');
64
+ const provider = getProvider('stripe');
65
+
66
+ expect(typeof provider.getTransactions).toBe('function');
67
+ });
68
+
69
+ it('getProvider stripe instance has getBalances method', async () => {
70
+ const { getProvider } = await import('../../src/lib/providers.js');
71
+ const provider = getProvider('stripe');
72
+
73
+ expect(typeof provider.getBalances).toBe('function');
74
+ });
75
+
76
+ it('getProvider stripe instance has getInvoices method', async () => {
77
+ const { getProvider } = await import('../../src/lib/providers.js');
78
+ const provider = getProvider('stripe');
79
+
80
+ expect(typeof provider.getInvoices).toBe('function');
81
+ });
82
+
83
+ it('getProvider stripe instance has getExpenses method', async () => {
84
+ const { getProvider } = await import('../../src/lib/providers.js');
85
+ const provider = getProvider('stripe');
86
+
87
+ expect(typeof provider.getExpenses).toBe('function');
88
+ });
89
+
90
+ it('getProvider stripe instance has getSubscriptions method', async () => {
91
+ const { getProvider } = await import('../../src/lib/providers.js');
92
+ const provider = getProvider('stripe');
93
+
94
+ expect(typeof provider.getSubscriptions).toBe('function');
95
+ });
96
+
97
+ it('getProviders returns array of providers for multiple names', async () => {
98
+ const { getProviders, clearProviderCache } = await import('../../src/lib/providers.js');
99
+ clearProviderCache();
100
+
101
+ const providers = getProviders(['stripe', 'xero']);
102
+
103
+ expect(providers).toHaveLength(2);
104
+ });
105
+
106
+ it('getProviders returns cached instances', async () => {
107
+ const { getProvider, getProviders, clearProviderCache } = await import('../../src/lib/providers.js');
108
+ clearProviderCache();
109
+
110
+ const stripe = getProvider('stripe');
111
+ const providers = getProviders(['stripe']);
112
+
113
+ // The instance returned by getProviders should be the same cached one
114
+ expect(providers[0]).toBe(stripe);
115
+ });
116
+
117
+ it('clearProviderCache does not throw when cache is empty', async () => {
118
+ const { clearProviderCache } = await import('../../src/lib/providers.js');
119
+ clearProviderCache(); // First clear
120
+ expect(() => clearProviderCache()).not.toThrow(); // Second clear on empty cache
121
+ });
122
+
123
+ it('xero provider instance has getTransactions method', async () => {
124
+ const { getProvider } = await import('../../src/lib/providers.js');
125
+ const provider = getProvider('xero');
126
+
127
+ expect(typeof provider.getTransactions).toBe('function');
128
+ });
129
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { forecastCashFlow } from '../../src/analysis/cashflow.js';
3
+ import type { Transaction } from '../../src/types.js';
4
+
5
+ // ─── Mock Stripe SDK (not needed for pure analysis tests, but avoids import errors) ──
6
+ vi.mock('stripe', () => ({
7
+ default: vi.fn().mockImplementation(() => ({
8
+ balanceTransactions: { list: vi.fn() },
9
+ balance: { retrieve: vi.fn() },
10
+ invoices: { list: vi.fn() },
11
+ subscriptions: { list: vi.fn() },
12
+ })),
13
+ }));
14
+
15
+ afterEach(() => {
16
+ delete process.env.PRO_LICENSE;
17
+ });
18
+
19
+ describe('cash_flow_forecast — requirePro gate', () => {
20
+ it('throws ProRequiredError when not pro', async () => {
21
+ delete process.env.PRO_LICENSE;
22
+ const { requirePro, ProRequiredError } = await import('../../src/premium/gate.js');
23
+ expect(() => requirePro('cash_flow_forecast')).toThrow(ProRequiredError);
24
+ });
25
+
26
+ it('does not throw when PRO_LICENSE is set', async () => {
27
+ process.env.PRO_LICENSE = 'pro-key';
28
+ const { requirePro } = await import('../../src/premium/gate.js');
29
+ expect(() => requirePro('cash_flow_forecast')).not.toThrow();
30
+ });
31
+ });
32
+
33
+ describe('cash_flow_forecast — 3 scenarios', () => {
34
+ const historicalTransactions: Transaction[] = [
35
+ { id: 'jan-inc', date: '2026-01-15', amount: 10000, currency: 'usd', description: 'Jan income', type: 'income', provider: 'stripe' },
36
+ { id: 'jan-exp', date: '2026-01-20', amount: 5000, currency: 'usd', description: 'Jan expense', type: 'expense', provider: 'stripe' },
37
+ { id: 'feb-inc', date: '2026-02-15', amount: 12000, currency: 'usd', description: 'Feb income', type: 'income', provider: 'stripe' },
38
+ { id: 'feb-exp', date: '2026-02-20', amount: 6000, currency: 'usd', description: 'Feb expense', type: 'expense', provider: 'stripe' },
39
+ { id: 'mar-inc', date: '2026-03-15', amount: 14000, currency: 'usd', description: 'Mar income', type: 'income', provider: 'stripe' },
40
+ { id: 'mar-exp', date: '2026-03-20', amount: 7000, currency: 'usd', description: 'Mar expense', type: 'expense', provider: 'stripe' },
41
+ ];
42
+
43
+ it('returns conservative, realistic, and optimistic scenarios', () => {
44
+ const forecast = forecastCashFlow(historicalTransactions, 3);
45
+
46
+ expect(forecast.scenarios.conservative).toBeDefined();
47
+ expect(forecast.scenarios.realistic).toBeDefined();
48
+ expect(forecast.scenarios.optimistic).toBeDefined();
49
+ });
50
+
51
+ it('each scenario has correct month count (3)', () => {
52
+ const forecast = forecastCashFlow(historicalTransactions, 3);
53
+
54
+ expect(forecast.scenarios.conservative).toHaveLength(3);
55
+ expect(forecast.scenarios.realistic).toHaveLength(3);
56
+ expect(forecast.scenarios.optimistic).toHaveLength(3);
57
+ });
58
+
59
+ it('each scenario has correct month count (6)', () => {
60
+ const forecast = forecastCashFlow(historicalTransactions, 6);
61
+
62
+ expect(forecast.scenarios.conservative).toHaveLength(6);
63
+ expect(forecast.scenarios.realistic).toHaveLength(6);
64
+ expect(forecast.scenarios.optimistic).toHaveLength(6);
65
+ });
66
+
67
+ it('each scenario has correct month count (12)', () => {
68
+ const forecast = forecastCashFlow(historicalTransactions, 12);
69
+
70
+ expect(forecast.scenarios.conservative).toHaveLength(12);
71
+ expect(forecast.scenarios.realistic).toHaveLength(12);
72
+ expect(forecast.scenarios.optimistic).toHaveLength(12);
73
+ });
74
+
75
+ it('optimistic income > realistic income each month', () => {
76
+ const forecast = forecastCashFlow(historicalTransactions, 3);
77
+
78
+ for (let i = 0; i < 3; i++) {
79
+ expect(forecast.scenarios.optimistic[i].income).toBeGreaterThan(
80
+ forecast.scenarios.realistic[i].income
81
+ );
82
+ }
83
+ });
84
+
85
+ it('conservative income < realistic income each month', () => {
86
+ const forecast = forecastCashFlow(historicalTransactions, 3);
87
+
88
+ for (let i = 0; i < 3; i++) {
89
+ expect(forecast.scenarios.conservative[i].income).toBeLessThan(
90
+ forecast.scenarios.realistic[i].income
91
+ );
92
+ }
93
+ });
94
+
95
+ it('month labels are in YYYY-MM format', () => {
96
+ const forecast = forecastCashFlow(historicalTransactions, 3);
97
+ const months = forecast.scenarios.realistic.map((m) => m.month);
98
+
99
+ for (const month of months) {
100
+ expect(month).toMatch(/^\d{4}-\d{2}$/);
101
+ }
102
+ });
103
+
104
+ it('cumulative_net is running total of net values', () => {
105
+ const forecast = forecastCashFlow(historicalTransactions, 3);
106
+ const realistic = forecast.scenarios.realistic;
107
+
108
+ let running = 0;
109
+ for (const month of realistic) {
110
+ running += month.net;
111
+ expect(month.cumulative_net).toBeCloseTo(running, 1);
112
+ }
113
+ });
114
+
115
+ it('historical_monthly_income is > 0 for non-empty input', () => {
116
+ const forecast = forecastCashFlow(historicalTransactions, 3);
117
+ expect(forecast.historical_monthly_income).toBeGreaterThan(0);
118
+ });
119
+
120
+ it('historical_monthly_expenses is > 0 for non-empty input', () => {
121
+ const forecast = forecastCashFlow(historicalTransactions, 3);
122
+ expect(forecast.historical_monthly_expenses).toBeGreaterThan(0);
123
+ });
124
+
125
+ it('empty historical data returns 0 averages', () => {
126
+ const forecast = forecastCashFlow([], 3);
127
+
128
+ expect(forecast.historical_monthly_income).toBe(0);
129
+ expect(forecast.historical_monthly_expenses).toBe(0);
130
+ });
131
+
132
+ it('each month has income, expenses, net, cumulative_net fields', () => {
133
+ const forecast = forecastCashFlow(historicalTransactions, 3);
134
+
135
+ for (const month of forecast.scenarios.realistic) {
136
+ expect(month).toHaveProperty('month');
137
+ expect(month).toHaveProperty('income');
138
+ expect(month).toHaveProperty('expenses');
139
+ expect(month).toHaveProperty('net');
140
+ expect(month).toHaveProperty('cumulative_net');
141
+ }
142
+ });
143
+
144
+ it('period_months matches the requested months_ahead', () => {
145
+ const forecast6 = forecastCashFlow(historicalTransactions, 6);
146
+ expect(forecast6.period_months).toBe(6);
147
+
148
+ const forecast12 = forecastCashFlow(historicalTransactions, 12);
149
+ expect(forecast12.period_months).toBe(12);
150
+ });
151
+
152
+ it('generated_at is a valid ISO timestamp', () => {
153
+ const forecast = forecastCashFlow(historicalTransactions, 3);
154
+ const d = new Date(forecast.generated_at);
155
+ expect(d.toString()).not.toBe('Invalid Date');
156
+ });
157
+ });