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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +24 -0
- package/.github/assets/banner.svg +104 -0
- package/.github/pull_request_template.md +25 -0
- package/CODE_OF_CONDUCT.md +40 -0
- package/CONTRIBUTING.md +71 -0
- package/LICENSE +21 -0
- package/README.md +390 -0
- package/SECURITY.md +30 -0
- package/dist/adapters/stripe.d.ts +15 -0
- package/dist/adapters/stripe.js +175 -0
- package/dist/adapters/types.d.ts +30 -0
- package/dist/adapters/types.js +2 -0
- package/dist/adapters/xero.d.ts +19 -0
- package/dist/adapters/xero.js +261 -0
- package/dist/analysis/anomaly.d.ts +12 -0
- package/dist/analysis/anomaly.js +103 -0
- package/dist/analysis/cashflow.d.ts +10 -0
- package/dist/analysis/cashflow.js +134 -0
- package/dist/analysis/pnl.d.ts +8 -0
- package/dist/analysis/pnl.js +56 -0
- package/dist/analysis/reconciliation.d.ts +14 -0
- package/dist/analysis/reconciliation.js +98 -0
- package/dist/analysis/tax.d.ts +11 -0
- package/dist/analysis/tax.js +81 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +565 -0
- package/dist/lib/audit.d.ts +17 -0
- package/dist/lib/audit.js +70 -0
- package/dist/lib/providers.d.ts +6 -0
- package/dist/lib/providers.js +25 -0
- package/dist/premium/gate.d.ts +12 -0
- package/dist/premium/gate.js +22 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.js +7 -0
- package/mcpize.yaml +10 -0
- package/package.json +35 -0
- package/src/adapters/stripe.ts +190 -0
- package/src/adapters/types.ts +34 -0
- package/src/adapters/xero.ts +317 -0
- package/src/analysis/anomaly.ts +119 -0
- package/src/analysis/cashflow.ts +158 -0
- package/src/analysis/pnl.ts +80 -0
- package/src/analysis/reconciliation.ts +117 -0
- package/src/analysis/tax.ts +98 -0
- package/src/index.ts +649 -0
- package/src/lib/audit.ts +92 -0
- package/src/lib/providers.ts +29 -0
- package/src/premium/gate.ts +24 -0
- package/src/types.ts +153 -0
- package/tests/adapters/stripe.test.ts +150 -0
- package/tests/adapters/xero.test.ts +188 -0
- package/tests/analysis/anomaly.test.ts +137 -0
- package/tests/analysis/cashflow.test.ts +112 -0
- package/tests/analysis/pnl.test.ts +95 -0
- package/tests/analysis/reconciliation.test.ts +121 -0
- package/tests/analysis/tax.test.ts +163 -0
- package/tests/helpers/mock-data.ts +135 -0
- package/tests/lib/audit.test.ts +89 -0
- package/tests/lib/providers.test.ts +129 -0
- package/tests/premium/cash_flow_forecast.test.ts +157 -0
- package/tests/premium/detect_anomalies.test.ts +189 -0
- package/tests/premium/gate.test.ts +59 -0
- package/tests/premium/generate_pnl.test.ts +155 -0
- package/tests/premium/multi_currency_report.test.ts +141 -0
- package/tests/premium/reconcile.test.ts +174 -0
- package/tests/premium/tax_summary.test.ts +166 -0
- package/tests/tools/expense_tracker.test.ts +181 -0
- package/tests/tools/financial_health.test.ts +196 -0
- package/tests/tools/get_balances.test.ts +160 -0
- package/tests/tools/list_invoices.test.ts +191 -0
- package/tests/tools/list_transactions.test.ts +210 -0
- package/tests/tools/revenue_summary.test.ts +188 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mockBalances, mockTransactions, mockSubscriptions } from '../helpers/mock-data.js';
|
|
3
|
+
|
|
4
|
+
// ─── Mock Stripe SDK ──────────────────────────────────────────────────────────
|
|
5
|
+
vi.mock('stripe', () => {
|
|
6
|
+
const mockStripe = vi.fn().mockImplementation(() => ({
|
|
7
|
+
balance: {
|
|
8
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
9
|
+
available: [{ currency: 'usd', amount: 500000 }],
|
|
10
|
+
pending: [{ currency: 'usd', amount: 25000 }],
|
|
11
|
+
}),
|
|
12
|
+
},
|
|
13
|
+
balanceTransactions: {
|
|
14
|
+
list: vi.fn().mockResolvedValue({
|
|
15
|
+
data: [
|
|
16
|
+
{ id: 'txn_1', created: 1743465600, amount: 20000, currency: 'usd', description: 'Revenue', type: 'charge', metadata: {} },
|
|
17
|
+
{ id: 'txn_2', created: 1743552000, amount: 20000, currency: 'usd', description: 'Revenue', type: 'charge', metadata: {} },
|
|
18
|
+
{ id: 'txn_3', created: 1743638400, amount: -3000, currency: 'usd', description: 'Stripe fee', type: 'stripe_fee', metadata: {} },
|
|
19
|
+
],
|
|
20
|
+
has_more: false,
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
invoices: {
|
|
24
|
+
list: vi.fn().mockResolvedValue({ data: [], has_more: false }),
|
|
25
|
+
},
|
|
26
|
+
subscriptions: {
|
|
27
|
+
list: vi.fn().mockResolvedValue({
|
|
28
|
+
data: [
|
|
29
|
+
{
|
|
30
|
+
id: 'sub_1',
|
|
31
|
+
customer: 'cus_aaa',
|
|
32
|
+
currency: 'usd',
|
|
33
|
+
status: 'active',
|
|
34
|
+
current_period_start: 1743465600,
|
|
35
|
+
current_period_end: 1746057600,
|
|
36
|
+
items: { data: [{ price: { unit_amount: 9900, recurring: { interval: 'month' } } }] },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'sub_2',
|
|
40
|
+
customer: 'cus_bbb',
|
|
41
|
+
currency: 'usd',
|
|
42
|
+
status: 'active',
|
|
43
|
+
current_period_start: 1743465600,
|
|
44
|
+
current_period_end: 1746057600,
|
|
45
|
+
items: { data: [{ price: { unit_amount: 4900, recurring: { interval: 'month' } } }] },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'sub_3',
|
|
49
|
+
customer: 'cus_ccc',
|
|
50
|
+
currency: 'usd',
|
|
51
|
+
status: 'canceled',
|
|
52
|
+
current_period_start: 1740787200,
|
|
53
|
+
current_period_end: 1743465600,
|
|
54
|
+
items: { data: [{ price: { unit_amount: 2900, recurring: { interval: 'month' } } }] },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
return { default: mockStripe };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('financial_health — MRR calculation', () => {
|
|
64
|
+
it('MRR is sum of active subscriptions monthly amount', () => {
|
|
65
|
+
const subscriptions = mockSubscriptions();
|
|
66
|
+
const activeSubs = subscriptions.filter((s) => s.status === 'active');
|
|
67
|
+
|
|
68
|
+
const mrr = activeSubs.reduce((sum, s) => {
|
|
69
|
+
const monthly = s.interval === 'year' ? s.amount / 12 : s.amount;
|
|
70
|
+
return sum + monthly;
|
|
71
|
+
}, 0);
|
|
72
|
+
|
|
73
|
+
// mockSubscriptions: sub_1=9900 + sub_2=2900 = 12800 cents = $128 MRR
|
|
74
|
+
expect(mrr).toBe(12800);
|
|
75
|
+
expect(mrr / 100).toBe(128);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('MRR excludes past_due and canceled subscriptions', () => {
|
|
79
|
+
const subscriptions = mockSubscriptions();
|
|
80
|
+
const activeSubs = subscriptions.filter((s) => s.status === 'active');
|
|
81
|
+
const allSubs = subscriptions;
|
|
82
|
+
|
|
83
|
+
expect(activeSubs.length).toBeLessThan(allSubs.length);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('annual subscriptions are normalized to monthly for MRR', () => {
|
|
87
|
+
const subscriptions = [
|
|
88
|
+
{ id: 'sub_annual', customer_name: 'Big Co', amount: 120000, currency: 'usd', interval: 'year' as const, status: 'active' as const, current_period_start: '2026-01-01', current_period_end: '2027-01-01', provider: 'stripe' as const },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const mrr = subscriptions.reduce((sum, s) => {
|
|
92
|
+
const monthly = s.interval === 'year' ? s.amount / 12 : s.amount;
|
|
93
|
+
return sum + monthly;
|
|
94
|
+
}, 0);
|
|
95
|
+
|
|
96
|
+
expect(mrr).toBe(10000); // $100/month
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('subscriptions data fetched from Stripe adapter', async () => {
|
|
100
|
+
process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
|
|
101
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
102
|
+
const adapter = new StripeAdapter();
|
|
103
|
+
const subs = await adapter.getSubscriptions!();
|
|
104
|
+
|
|
105
|
+
expect(Array.isArray(subs)).toBe(true);
|
|
106
|
+
expect(subs.length).toBeGreaterThan(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('financial_health — churn rate', () => {
|
|
111
|
+
it('churn rate = canceled / total * 100', () => {
|
|
112
|
+
const subscriptions = mockSubscriptions();
|
|
113
|
+
const totalSubs = subscriptions.length;
|
|
114
|
+
const canceledSubs = subscriptions.filter((s) => s.status === 'canceled').length;
|
|
115
|
+
const churnRate = totalSubs > 0 ? (canceledSubs / totalSubs) * 100 : 0;
|
|
116
|
+
|
|
117
|
+
expect(churnRate).toBe(0); // No canceled in mock
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('churn rate is 100% when all subscriptions are canceled', () => {
|
|
121
|
+
const subscriptions = [
|
|
122
|
+
{ id: 'sub_1', customer_name: 'A', amount: 9900, currency: 'usd', interval: 'month' as const, status: 'canceled' as const, current_period_start: '2026-01-01', current_period_end: '2026-02-01', provider: 'stripe' as const },
|
|
123
|
+
{ id: 'sub_2', customer_name: 'B', amount: 4900, currency: 'usd', interval: 'month' as const, status: 'canceled' as const, current_period_start: '2026-01-01', current_period_end: '2026-02-01', provider: 'stripe' as const },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const totalSubs = subscriptions.length;
|
|
127
|
+
const canceledSubs = subscriptions.filter((s) => s.status === 'canceled').length;
|
|
128
|
+
const churnRate = totalSubs > 0 ? (canceledSubs / totalSubs) * 100 : 0;
|
|
129
|
+
|
|
130
|
+
expect(churnRate).toBe(100);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('churn rate is 0 when no subscriptions', () => {
|
|
134
|
+
const totalSubs = 0;
|
|
135
|
+
const churnRate = totalSubs > 0 ? 0 : 0;
|
|
136
|
+
expect(churnRate).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('partial churn rate: 1 of 3 canceled = 33.33%', () => {
|
|
140
|
+
const subscriptions = [
|
|
141
|
+
{ status: 'active' as const },
|
|
142
|
+
{ status: 'active' as const },
|
|
143
|
+
{ status: 'canceled' as const },
|
|
144
|
+
];
|
|
145
|
+
const total = subscriptions.length;
|
|
146
|
+
const canceled = subscriptions.filter((s) => s.status === 'canceled').length;
|
|
147
|
+
const churnRate = (canceled / total) * 100;
|
|
148
|
+
|
|
149
|
+
expect(churnRate).toBeCloseTo(33.33, 1);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('financial_health — burn rate and runway', () => {
|
|
154
|
+
it('runway = total_balance / burn_rate (in months)', () => {
|
|
155
|
+
const totalAvailableCents = 500000; // $5000
|
|
156
|
+
const monthlyExpensesCents = 50000; // $500/month
|
|
157
|
+
|
|
158
|
+
const burnRate = monthlyExpensesCents / 100; // $500
|
|
159
|
+
const totalBalance = totalAvailableCents / 100; // $5000
|
|
160
|
+
const runwayMonths = totalBalance / burnRate; // 10 months
|
|
161
|
+
|
|
162
|
+
expect(runwayMonths).toBe(10);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('runway is null when burn rate is zero', () => {
|
|
166
|
+
const burnRate = 0;
|
|
167
|
+
const totalBalance = 5000;
|
|
168
|
+
const runway = burnRate > 0 ? totalBalance / burnRate : null;
|
|
169
|
+
|
|
170
|
+
expect(runway).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('total balance = sum of available across all currencies', () => {
|
|
174
|
+
const balances = mockBalances('stripe');
|
|
175
|
+
const totalAvailable = balances.reduce((sum, b) => sum + b.available, 0);
|
|
176
|
+
|
|
177
|
+
expect(totalAvailable).toBe(650000); // 500000 USD + 150000 EUR
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('monthly income > 0 from mock transactions', async () => {
|
|
181
|
+
process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
|
|
182
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
183
|
+
const adapter = new StripeAdapter();
|
|
184
|
+
const txResult = await adapter.getTransactions({});
|
|
185
|
+
|
|
186
|
+
const monthlyIncome = txResult.data
|
|
187
|
+
.filter((t) => t.type === 'income')
|
|
188
|
+
.reduce((sum, t) => sum + t.amount, 0);
|
|
189
|
+
|
|
190
|
+
expect(monthlyIncome / 100).toBeGreaterThan(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
afterEach(() => {
|
|
194
|
+
vi.clearAllMocks();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mockBalances } from '../helpers/mock-data.js';
|
|
3
|
+
|
|
4
|
+
// ─── Mock Stripe SDK ──────────────────────────────────────────────────────────
|
|
5
|
+
vi.mock('stripe', () => {
|
|
6
|
+
const mockStripe = vi.fn().mockImplementation(() => ({
|
|
7
|
+
balance: {
|
|
8
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
9
|
+
available: [
|
|
10
|
+
{ currency: 'usd', amount: 1000000 },
|
|
11
|
+
{ currency: 'eur', amount: 250000 },
|
|
12
|
+
{ currency: 'gbp', amount: 75000 },
|
|
13
|
+
],
|
|
14
|
+
pending: [
|
|
15
|
+
{ currency: 'usd', amount: 50000 },
|
|
16
|
+
{ currency: 'eur', amount: 10000 },
|
|
17
|
+
],
|
|
18
|
+
}),
|
|
19
|
+
},
|
|
20
|
+
balanceTransactions: {
|
|
21
|
+
list: vi.fn().mockResolvedValue({ data: [], has_more: false }),
|
|
22
|
+
},
|
|
23
|
+
invoices: {
|
|
24
|
+
list: vi.fn().mockResolvedValue({ data: [], has_more: false }),
|
|
25
|
+
},
|
|
26
|
+
subscriptions: {
|
|
27
|
+
list: vi.fn().mockResolvedValue({ data: [] }),
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
return { default: mockStripe };
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('get_balances tool — Stripe', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns an array of balance objects', async () => {
|
|
43
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
44
|
+
const adapter = new StripeAdapter();
|
|
45
|
+
const balances = await adapter.getBalances();
|
|
46
|
+
|
|
47
|
+
expect(Array.isArray(balances)).toBe(true);
|
|
48
|
+
expect(balances.length).toBeGreaterThan(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('each balance has required fields', async () => {
|
|
52
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
53
|
+
const adapter = new StripeAdapter();
|
|
54
|
+
const balances = await adapter.getBalances();
|
|
55
|
+
|
|
56
|
+
for (const b of balances) {
|
|
57
|
+
expect(b).toHaveProperty('currency');
|
|
58
|
+
expect(b).toHaveProperty('available');
|
|
59
|
+
expect(b).toHaveProperty('pending');
|
|
60
|
+
expect(b).toHaveProperty('provider');
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('all balances have provider set to stripe', async () => {
|
|
65
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
66
|
+
const adapter = new StripeAdapter();
|
|
67
|
+
const balances = await adapter.getBalances();
|
|
68
|
+
|
|
69
|
+
for (const b of balances) {
|
|
70
|
+
expect(b.provider).toBe('stripe');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('USD balance available is correct in cents', async () => {
|
|
75
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
76
|
+
const adapter = new StripeAdapter();
|
|
77
|
+
const balances = await adapter.getBalances();
|
|
78
|
+
|
|
79
|
+
const usd = balances.find((b) => b.currency === 'usd');
|
|
80
|
+
expect(usd).toBeDefined();
|
|
81
|
+
expect(usd!.available).toBe(1000000);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('EUR balance is returned', async () => {
|
|
85
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
86
|
+
const adapter = new StripeAdapter();
|
|
87
|
+
const balances = await adapter.getBalances();
|
|
88
|
+
|
|
89
|
+
const eur = balances.find((b) => b.currency === 'eur');
|
|
90
|
+
expect(eur).toBeDefined();
|
|
91
|
+
expect(eur!.available).toBe(250000);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('GBP balance is returned', async () => {
|
|
95
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
96
|
+
const adapter = new StripeAdapter();
|
|
97
|
+
const balances = await adapter.getBalances();
|
|
98
|
+
|
|
99
|
+
const gbp = balances.find((b) => b.currency === 'gbp');
|
|
100
|
+
expect(gbp).toBeDefined();
|
|
101
|
+
expect(gbp!.available).toBe(75000);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('pending amount defaults to 0 for currencies with no pending', async () => {
|
|
105
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
106
|
+
const adapter = new StripeAdapter();
|
|
107
|
+
const balances = await adapter.getBalances();
|
|
108
|
+
|
|
109
|
+
const gbp = balances.find((b) => b.currency === 'gbp');
|
|
110
|
+
// GBP has no pending in mock — should default to 0
|
|
111
|
+
expect(gbp!.pending).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('available and pending are integers (cents)', async () => {
|
|
115
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
116
|
+
const adapter = new StripeAdapter();
|
|
117
|
+
const balances = await adapter.getBalances();
|
|
118
|
+
|
|
119
|
+
for (const b of balances) {
|
|
120
|
+
expect(Number.isInteger(b.available)).toBe(true);
|
|
121
|
+
expect(Number.isInteger(b.pending)).toBe(true);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('get_balances — mock data helper', () => {
|
|
127
|
+
it('mockBalances returns stripe provider by default', () => {
|
|
128
|
+
const balances = mockBalances('stripe');
|
|
129
|
+
for (const b of balances) {
|
|
130
|
+
expect(b.provider).toBe('stripe');
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('mockBalances returns xero provider when specified', () => {
|
|
135
|
+
const balances = mockBalances('xero');
|
|
136
|
+
for (const b of balances) {
|
|
137
|
+
expect(b.provider).toBe('xero');
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('mockBalances returns usd and eur', () => {
|
|
142
|
+
const balances = mockBalances('stripe');
|
|
143
|
+
const currencies = balances.map((b) => b.currency);
|
|
144
|
+
expect(currencies).toContain('usd');
|
|
145
|
+
expect(currencies).toContain('eur');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('multi-provider balance combination is additive', () => {
|
|
149
|
+
const stripeBalances = mockBalances('stripe');
|
|
150
|
+
const xeroBalances = mockBalances('xero');
|
|
151
|
+
const allBalances = [...stripeBalances, ...xeroBalances];
|
|
152
|
+
|
|
153
|
+
const stripeCount = allBalances.filter((b) => b.provider === 'stripe').length;
|
|
154
|
+
const xeroCount = allBalances.filter((b) => b.provider === 'xero').length;
|
|
155
|
+
|
|
156
|
+
expect(stripeCount).toBeGreaterThan(0);
|
|
157
|
+
expect(xeroCount).toBeGreaterThan(0);
|
|
158
|
+
expect(allBalances.length).toBe(stripeCount + xeroCount);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mockInvoices } from '../helpers/mock-data.js';
|
|
3
|
+
|
|
4
|
+
// ─── Mock Stripe SDK ──────────────────────────────────────────────────────────
|
|
5
|
+
vi.mock('stripe', () => {
|
|
6
|
+
const mockStripe = vi.fn().mockImplementation(() => ({
|
|
7
|
+
invoices: {
|
|
8
|
+
list: vi.fn().mockResolvedValue({
|
|
9
|
+
data: [
|
|
10
|
+
{
|
|
11
|
+
id: 'in_001',
|
|
12
|
+
number: 'INV-001',
|
|
13
|
+
customer_name: 'Customer A',
|
|
14
|
+
customer_email: 'a@example.com',
|
|
15
|
+
amount_due: 9900,
|
|
16
|
+
currency: 'usd',
|
|
17
|
+
status: 'paid',
|
|
18
|
+
due_date: 1748736000,
|
|
19
|
+
created: 1746057600,
|
|
20
|
+
status_transitions: { paid_at: 1748044800 },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'in_002',
|
|
24
|
+
number: 'INV-002',
|
|
25
|
+
customer_name: 'Customer B',
|
|
26
|
+
customer_email: 'b@example.com',
|
|
27
|
+
amount_due: 4900,
|
|
28
|
+
currency: 'usd',
|
|
29
|
+
status: 'open',
|
|
30
|
+
due_date: 1748822400,
|
|
31
|
+
created: 1746144000,
|
|
32
|
+
status_transitions: { paid_at: null },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'in_003',
|
|
36
|
+
number: 'INV-003',
|
|
37
|
+
customer_name: 'Customer C',
|
|
38
|
+
customer_email: 'c@example.com',
|
|
39
|
+
amount_due: 19900,
|
|
40
|
+
currency: 'usd',
|
|
41
|
+
status: 'paid',
|
|
42
|
+
due_date: 1743465600,
|
|
43
|
+
created: 1740787200,
|
|
44
|
+
status_transitions: { paid_at: 1743465600 },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
has_more: false,
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
balance: {
|
|
51
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
52
|
+
available: [{ currency: 'usd', amount: 500000 }],
|
|
53
|
+
pending: [],
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
balanceTransactions: {
|
|
57
|
+
list: vi.fn().mockResolvedValue({ data: [], has_more: false }),
|
|
58
|
+
},
|
|
59
|
+
subscriptions: {
|
|
60
|
+
list: vi.fn().mockResolvedValue({ data: [] }),
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
return { default: mockStripe };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('list_invoices tool — Stripe provider', () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
vi.clearAllMocks();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns paginated result with data array', async () => {
|
|
76
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
77
|
+
const adapter = new StripeAdapter();
|
|
78
|
+
const result = await adapter.getInvoices({});
|
|
79
|
+
|
|
80
|
+
expect(result).toHaveProperty('data');
|
|
81
|
+
expect(result).toHaveProperty('has_more');
|
|
82
|
+
expect(Array.isArray(result.data)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('each invoice has required fields', async () => {
|
|
86
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
87
|
+
const adapter = new StripeAdapter();
|
|
88
|
+
const result = await adapter.getInvoices({});
|
|
89
|
+
|
|
90
|
+
for (const inv of result.data) {
|
|
91
|
+
expect(inv).toHaveProperty('id');
|
|
92
|
+
expect(inv).toHaveProperty('number');
|
|
93
|
+
expect(inv).toHaveProperty('customer_name');
|
|
94
|
+
expect(inv).toHaveProperty('amount');
|
|
95
|
+
expect(inv).toHaveProperty('currency');
|
|
96
|
+
expect(inv).toHaveProperty('status');
|
|
97
|
+
expect(inv).toHaveProperty('provider');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('amounts are in cents', async () => {
|
|
102
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
103
|
+
const adapter = new StripeAdapter();
|
|
104
|
+
const result = await adapter.getInvoices({});
|
|
105
|
+
|
|
106
|
+
for (const inv of result.data) {
|
|
107
|
+
expect(Number.isInteger(inv.amount)).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('all invoices have provider set to stripe', async () => {
|
|
112
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
113
|
+
const adapter = new StripeAdapter();
|
|
114
|
+
const result = await adapter.getInvoices({});
|
|
115
|
+
|
|
116
|
+
for (const inv of result.data) {
|
|
117
|
+
expect(inv.provider).toBe('stripe');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('paid invoices have paid_date set', async () => {
|
|
122
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
123
|
+
const adapter = new StripeAdapter();
|
|
124
|
+
const result = await adapter.getInvoices({});
|
|
125
|
+
|
|
126
|
+
const paidInvoices = result.data.filter((inv) => inv.status === 'paid');
|
|
127
|
+
expect(paidInvoices.length).toBeGreaterThan(0);
|
|
128
|
+
for (const inv of paidInvoices) {
|
|
129
|
+
expect(inv.paid_date).toBeDefined();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('unpaid invoices do not have paid_date', async () => {
|
|
134
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
135
|
+
const adapter = new StripeAdapter();
|
|
136
|
+
const result = await adapter.getInvoices({});
|
|
137
|
+
|
|
138
|
+
const unpaidInvoices = result.data.filter((inv) => inv.status !== 'paid');
|
|
139
|
+
for (const inv of unpaidInvoices) {
|
|
140
|
+
expect(inv.paid_date).toBeUndefined();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('has_more: false results in no next_cursor', async () => {
|
|
145
|
+
const { StripeAdapter } = await import('../../src/adapters/stripe.js');
|
|
146
|
+
const adapter = new StripeAdapter();
|
|
147
|
+
const result = await adapter.getInvoices({});
|
|
148
|
+
|
|
149
|
+
expect(result.has_more).toBe(false);
|
|
150
|
+
expect(result.next_cursor).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('list_invoices — mock data helper', () => {
|
|
155
|
+
it('mockInvoices returns correct count', () => {
|
|
156
|
+
const invoices = mockInvoices(5, 'stripe');
|
|
157
|
+
expect(invoices).toHaveLength(5);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('mockInvoices uses deterministic amounts', () => {
|
|
161
|
+
const inv1 = mockInvoices(5, 'stripe');
|
|
162
|
+
const inv2 = mockInvoices(5, 'stripe');
|
|
163
|
+
expect(inv1.map((i) => i.amount)).toEqual(inv2.map((i) => i.amount));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('mockInvoices has mix of paid and pending statuses', () => {
|
|
167
|
+
const invoices = mockInvoices(5, 'stripe');
|
|
168
|
+
const statuses = invoices.map((i) => i.status);
|
|
169
|
+
expect(statuses).toContain('paid');
|
|
170
|
+
expect(statuses).toContain('pending');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('invoice numbers are sequential', () => {
|
|
174
|
+
const invoices = mockInvoices(3, 'stripe');
|
|
175
|
+
expect(invoices[0].number).toBe('INV-001');
|
|
176
|
+
expect(invoices[1].number).toBe('INV-002');
|
|
177
|
+
expect(invoices[2].number).toBe('INV-003');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('mockInvoices xero provider sets provider field', () => {
|
|
181
|
+
const invoices = mockInvoices(3, 'xero');
|
|
182
|
+
for (const inv of invoices) {
|
|
183
|
+
expect(inv.provider).toBe('xero');
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('100 invoices from mock helper have correct length', () => {
|
|
188
|
+
const invoices = mockInvoices(100, 'xero');
|
|
189
|
+
expect(invoices).toHaveLength(100);
|
|
190
|
+
});
|
|
191
|
+
});
|