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,92 @@
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const DB_PATH = path.join(os.homedir(), '.financeops', 'audit.db');
7
+
8
+ let db: Database.Database | null = null;
9
+
10
+ function getDb(dbPath?: string): Database.Database {
11
+ if (db) return db;
12
+
13
+ const targetPath = dbPath ?? DB_PATH;
14
+ const dir = path.dirname(targetPath);
15
+
16
+ if (!fs.existsSync(dir)) {
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ }
19
+
20
+ db = new Database(targetPath);
21
+
22
+ db.exec(`
23
+ CREATE TABLE IF NOT EXISTS audit_log (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ tool TEXT NOT NULL,
26
+ provider TEXT,
27
+ timestamp TEXT DEFAULT (datetime('now')),
28
+ input_summary TEXT,
29
+ success INTEGER DEFAULT 1,
30
+ error TEXT
31
+ )
32
+ `);
33
+
34
+ return db;
35
+ }
36
+
37
+ export interface AuditEntry {
38
+ tool: string;
39
+ provider?: string;
40
+ input_summary?: string;
41
+ success?: boolean;
42
+ error?: string;
43
+ }
44
+
45
+ export function logAudit(entry: AuditEntry, dbPath?: string): void {
46
+ const database = getDb(dbPath);
47
+
48
+ const stmt = database.prepare(`
49
+ INSERT INTO audit_log (tool, provider, input_summary, success, error)
50
+ VALUES (@tool, @provider, @input_summary, @success, @error)
51
+ `);
52
+
53
+ stmt.run({
54
+ tool: entry.tool,
55
+ provider: entry.provider ?? null,
56
+ input_summary: entry.input_summary ?? null,
57
+ success: entry.success !== false ? 1 : 0,
58
+ error: entry.error ?? null,
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Delete entries older than maxAgeDays.
64
+ * Called on server start (Important Fix I9: only call from main, not from singleton).
65
+ */
66
+ export function cleanup(maxAgeDays: number = 90, dbPath?: string): void {
67
+ const database = getDb(dbPath);
68
+
69
+ database.prepare(`
70
+ DELETE FROM audit_log
71
+ WHERE timestamp < datetime('now', '-' || ? || ' days')
72
+ `).run(maxAgeDays);
73
+ }
74
+
75
+ export function getAuditLog(limit: number = 100, dbPath?: string): AuditEntry[] {
76
+ const database = getDb(dbPath);
77
+
78
+ return database.prepare(`
79
+ SELECT tool, provider, timestamp, input_summary, success, error
80
+ FROM audit_log
81
+ ORDER BY id DESC
82
+ LIMIT ?
83
+ `).all(limit) as AuditEntry[];
84
+ }
85
+
86
+ /** Reset the singleton — for testing only. */
87
+ export function resetAuditDb(): void {
88
+ if (db) {
89
+ db.close();
90
+ db = null;
91
+ }
92
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Critical Fix 1: Cached provider factory.
3
+ * Adapters are created once and reused across calls to avoid:
4
+ * - Repeated credential validation
5
+ * - Multiple Stripe client instances
6
+ * - Multiple Xero token file reads
7
+ */
8
+ import { StripeAdapter } from '../adapters/stripe.js';
9
+ import { XeroAdapter } from '../adapters/xero.js';
10
+ import type { FinanceProvider } from '../adapters/types.js';
11
+
12
+ const cache = new Map<string, FinanceProvider>();
13
+
14
+ export function getProvider(name: 'stripe' | 'xero'): FinanceProvider {
15
+ if (cache.has(name)) return cache.get(name)!;
16
+
17
+ const adapter: FinanceProvider = name === 'stripe' ? new StripeAdapter() : new XeroAdapter();
18
+ cache.set(name, adapter);
19
+ return adapter;
20
+ }
21
+
22
+ export function getProviders(names: ('stripe' | 'xero')[]): FinanceProvider[] {
23
+ return names.map((n) => getProvider(n));
24
+ }
25
+
26
+ /** Clear the cache — useful for testing. */
27
+ export function clearProviderCache(): void {
28
+ cache.clear();
29
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Premium gate — reads PRO_LICENSE dynamically from environment.
3
+ * CRITICAL: Do NOT cache the env variable at module load time.
4
+ * Must re-read process.env.PRO_LICENSE on every call to support
5
+ * runtime license changes (e.g., in tests or when env is updated).
6
+ */
7
+
8
+ export class ProRequiredError extends Error {
9
+ constructor(tool: string) {
10
+ super(`Tool "${tool}" requires a PRO_LICENSE. Set the PRO_LICENSE environment variable.`);
11
+ this.name = 'ProRequiredError';
12
+ }
13
+ }
14
+
15
+ export function isPro(): boolean {
16
+ // Dynamic read — NOT cached
17
+ return Boolean(process.env.PRO_LICENSE);
18
+ }
19
+
20
+ export function requirePro(tool: string): void {
21
+ if (!isPro()) {
22
+ throw new ProRequiredError(tool);
23
+ }
24
+ }
package/src/types.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Core domain types for FinanceOps MCP.
3
+ * All monetary amounts are in the smallest currency unit (cents).
4
+ * Example: 15000 = €150.00
5
+ */
6
+
7
+ export interface Transaction {
8
+ id: string;
9
+ date: string; // ISO 8601
10
+ amount: number; // Smallest currency unit (cents). 15000 = €150.00
11
+ currency: string; // ISO 4217 lowercase, e.g. "usd"
12
+ description: string;
13
+ category?: string;
14
+ type: 'income' | 'expense' | 'transfer';
15
+ provider: 'stripe' | 'xero';
16
+ metadata?: Record<string, string>;
17
+ }
18
+
19
+ export interface Invoice {
20
+ id: string;
21
+ number: string;
22
+ customer_name: string;
23
+ customer_email?: string;
24
+ amount: number; // Smallest currency unit (cents)
25
+ currency: string;
26
+ status: 'paid' | 'pending' | 'overdue' | 'draft' | 'voided';
27
+ due_date: string; // ISO 8601
28
+ issued_date: string; // ISO 8601
29
+ paid_date?: string; // ISO 8601
30
+ provider: 'stripe' | 'xero';
31
+
32
+ // Read-only computed fields from Xero (do NOT write these back)
33
+ readonly amount_due?: number;
34
+ readonly amount_paid?: number;
35
+ readonly amount_credited?: number;
36
+ }
37
+
38
+ export interface Balance {
39
+ currency: string;
40
+ available: number; // Smallest currency unit (cents)
41
+ pending: number; // Smallest currency unit (cents)
42
+ provider: 'stripe' | 'xero';
43
+ }
44
+
45
+ export interface Expense {
46
+ id: string;
47
+ date: string; // ISO 8601
48
+ amount: number; // Smallest currency unit (cents)
49
+ currency: string;
50
+ description: string;
51
+ category: string;
52
+ provider: 'stripe' | 'xero';
53
+ }
54
+
55
+ export interface Subscription {
56
+ id: string;
57
+ customer_name: string;
58
+ amount: number; // Smallest currency unit (cents)
59
+ currency: string;
60
+ interval: 'month' | 'year';
61
+ status: 'active' | 'canceled' | 'past_due' | 'trialing';
62
+ current_period_start: string; // ISO 8601
63
+ current_period_end: string; // ISO 8601
64
+ provider: 'stripe' | 'xero';
65
+ }
66
+
67
+ export interface PaginatedResult<T> {
68
+ data: T[];
69
+ has_more: boolean;
70
+ next_cursor?: string;
71
+ }
72
+
73
+ export interface PnLReport {
74
+ period: string;
75
+ date_from: string;
76
+ date_to: string;
77
+ currency: string;
78
+ revenue: number; // In major currency units (e.g. 150.00)
79
+ cogs: number;
80
+ gross_profit: number;
81
+ gross_margin_pct: number;
82
+ operating_expenses: number;
83
+ net_income: number;
84
+ by_category: Record<string, number>;
85
+ }
86
+
87
+ export interface CashFlowForecast {
88
+ period_months: number;
89
+ generated_at: string;
90
+ historical_monthly_income: number; // Average over 3-month lookback (major units)
91
+ historical_monthly_expenses: number; // Average over 3-month lookback (major units)
92
+ trend_pct: number; // Monthly growth rate as decimal
93
+ scenarios: {
94
+ conservative: MonthlyForecast[];
95
+ realistic: MonthlyForecast[];
96
+ optimistic: MonthlyForecast[];
97
+ };
98
+ }
99
+
100
+ export interface MonthlyForecast {
101
+ month: string; // YYYY-MM
102
+ income: number;
103
+ expenses: number;
104
+ net: number;
105
+ cumulative_net: number;
106
+ }
107
+
108
+ export interface ReconciliationResult {
109
+ matched: MatchedPair[];
110
+ unmatched_stripe: Transaction[];
111
+ unmatched_xero: Transaction[];
112
+ summary: {
113
+ total_stripe: number;
114
+ total_xero: number;
115
+ matched_count: number;
116
+ unmatched_stripe_count: number;
117
+ unmatched_xero_count: number;
118
+ };
119
+ }
120
+
121
+ export interface MatchedPair {
122
+ stripe: Transaction;
123
+ xero: Transaction;
124
+ confidence: number; // 0-1
125
+ }
126
+
127
+ export interface Anomaly {
128
+ id: string;
129
+ type: 'duplicate' | 'large_variance' | 'missed_recurring' | 'unusual_timing';
130
+ severity: 'low' | 'medium' | 'high';
131
+ transaction?: Transaction;
132
+ description: string;
133
+ amount?: number;
134
+ date?: string;
135
+ }
136
+
137
+ export interface TaxSummary {
138
+ provider: 'stripe' | 'xero';
139
+ period: 'quarter' | 'year';
140
+ jurisdiction?: string;
141
+ total_taxable_amount: number; // Major units
142
+ total_tax_collected: number; // Major units
143
+ total_tax_owed: number; // Major units
144
+ by_rate: TaxByRate[];
145
+ filing_period: string;
146
+ }
147
+
148
+ export interface TaxByRate {
149
+ rate: number; // e.g. 0.2 for 20%
150
+ rate_label: string; // e.g. "20% VAT"
151
+ taxable_amount: number;
152
+ tax_amount: number;
153
+ }
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock the stripe module before importing the adapter
4
+ vi.mock('stripe', () => {
5
+ const mockStripe = vi.fn().mockImplementation(() => ({
6
+ balanceTransactions: {
7
+ list: vi.fn().mockResolvedValue({
8
+ data: [
9
+ {
10
+ id: 'txn_001',
11
+ created: 1743465600, // 2026-04-01T00:00:00Z
12
+ amount: 15000, // 150.00 USD in cents
13
+ currency: 'usd',
14
+ description: 'Payment from customer',
15
+ type: 'charge',
16
+ metadata: {},
17
+ },
18
+ {
19
+ id: 'txn_002',
20
+ created: 1743552000, // 2026-04-02T00:00:00Z
21
+ amount: -500, // -5.00 fee
22
+ currency: 'usd',
23
+ description: 'Stripe fee',
24
+ type: 'stripe_fee',
25
+ metadata: {},
26
+ },
27
+ ],
28
+ has_more: false,
29
+ }),
30
+ },
31
+ balance: {
32
+ retrieve: vi.fn().mockResolvedValue({
33
+ available: [
34
+ { currency: 'usd', amount: 500000 },
35
+ { currency: 'eur', amount: 150000 },
36
+ ],
37
+ pending: [
38
+ { currency: 'usd', amount: 25000 },
39
+ ],
40
+ }),
41
+ },
42
+ invoices: {
43
+ list: vi.fn().mockResolvedValue({
44
+ data: [
45
+ {
46
+ id: 'in_001',
47
+ number: 'INV-001',
48
+ customer_name: 'Test Customer',
49
+ customer_email: 'test@example.com',
50
+ amount_due: 9900,
51
+ currency: 'usd',
52
+ status: 'paid',
53
+ due_date: 1748736000,
54
+ created: 1746057600,
55
+ status_transitions: { paid_at: 1748044800 },
56
+ },
57
+ ],
58
+ has_more: false,
59
+ }),
60
+ },
61
+ subscriptions: {
62
+ list: vi.fn().mockResolvedValue({
63
+ data: [
64
+ {
65
+ id: 'sub_001',
66
+ customer: 'cus_xxx',
67
+ currency: 'usd',
68
+ status: 'active',
69
+ current_period_start: 1743465600,
70
+ current_period_end: 1746057600,
71
+ items: {
72
+ data: [
73
+ {
74
+ price: {
75
+ unit_amount: 4900,
76
+ recurring: { interval: 'month' },
77
+ },
78
+ },
79
+ ],
80
+ },
81
+ },
82
+ ],
83
+ }),
84
+ },
85
+ }));
86
+
87
+ return { default: mockStripe };
88
+ });
89
+
90
+ describe('StripeAdapter', () => {
91
+ let adapter: import('../../src/adapters/stripe.js').StripeAdapter;
92
+
93
+ beforeEach(async () => {
94
+ process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
95
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
96
+ adapter = new StripeAdapter();
97
+ });
98
+
99
+ it('throws if STRIPE_SECRET_KEY is missing', async () => {
100
+ delete process.env.STRIPE_SECRET_KEY;
101
+ const { StripeAdapter } = await import('../../src/adapters/stripe.js');
102
+ expect(() => new StripeAdapter()).toThrow('STRIPE_SECRET_KEY');
103
+ process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
104
+ });
105
+
106
+ it('getTransactions returns amounts in cents', async () => {
107
+ const result = await adapter.getTransactions({});
108
+ expect(result.data).toHaveLength(2);
109
+
110
+ const first = result.data[0];
111
+ expect(first.amount).toBe(15000); // 150.00 USD as cents
112
+ expect(first.currency).toBe('usd');
113
+ expect(first.provider).toBe('stripe');
114
+ });
115
+
116
+ it('getTransactions maps charge type to income', async () => {
117
+ const result = await adapter.getTransactions({});
118
+ expect(result.data[0].type).toBe('income');
119
+ });
120
+
121
+ it('getTransactions maps stripe_fee type to expense', async () => {
122
+ const result = await adapter.getTransactions({});
123
+ expect(result.data[1].type).toBe('expense');
124
+ });
125
+
126
+ it('getTransactions includes pagination cursor when has_more is true', async () => {
127
+ // The mock returns has_more: false
128
+ const result = await adapter.getTransactions({});
129
+ expect(result.has_more).toBe(false);
130
+ expect(result.next_cursor).toBeUndefined();
131
+ });
132
+
133
+ it('getBalances returns amounts in cents', async () => {
134
+ const balances = await adapter.getBalances();
135
+ expect(balances.length).toBeGreaterThan(0);
136
+
137
+ const usdBalance = balances.find((b) => b.currency === 'usd');
138
+ expect(usdBalance).toBeDefined();
139
+ expect(usdBalance!.available).toBe(500000); // $5000.00 in cents
140
+ expect(usdBalance!.pending).toBe(25000); // $250.00 in cents
141
+ expect(usdBalance!.provider).toBe('stripe');
142
+ });
143
+
144
+ it('getInvoices returns invoice data with amounts in cents', async () => {
145
+ const result = await adapter.getInvoices({});
146
+ expect(result.data).toHaveLength(1);
147
+ expect(result.data[0].amount).toBe(9900); // $99.00 in cents
148
+ expect(result.data[0].status).toBe('paid');
149
+ });
150
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ const TOKEN_PATH = path.join(os.homedir(), '.financeops', 'xero-tokens.json');
7
+
8
+ function writeTokens(tokens: object) {
9
+ const dir = path.dirname(TOKEN_PATH);
10
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
11
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens));
12
+ }
13
+
14
+ function removeTokens() {
15
+ if (fs.existsSync(TOKEN_PATH)) fs.unlinkSync(TOKEN_PATH);
16
+ }
17
+
18
+ describe('XeroAdapter', () => {
19
+ beforeEach(() => {
20
+ process.env.XERO_TENANT_ID = 'tenant_test';
21
+ process.env.XERO_CLIENT_ID = 'client_id_test';
22
+ process.env.XERO_CLIENT_SECRET = 'client_secret_test';
23
+ process.env.XERO_REFRESH_TOKEN = 'refresh_token_test';
24
+ delete process.env.XERO_ACCESS_TOKEN;
25
+ });
26
+
27
+ afterEach(() => {
28
+ removeTokens();
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ it('throws if XERO_TENANT_ID is missing', async () => {
33
+ delete process.env.XERO_TENANT_ID;
34
+ const { XeroAdapter } = await import('../../src/adapters/xero.js');
35
+ expect(() => new XeroAdapter()).toThrow('XERO_TENANT_ID');
36
+ process.env.XERO_TENANT_ID = 'tenant_test';
37
+ });
38
+
39
+ it('validates date format — rejects invalid dates', async () => {
40
+ // Write valid tokens so adapter doesn't fail on token load
41
+ writeTokens({
42
+ access_token: 'tok',
43
+ refresh_token: 'ref',
44
+ expires_at: Date.now() + 60 * 60 * 1000,
45
+ });
46
+
47
+ const { XeroAdapter } = await import('../../src/adapters/xero.js');
48
+ const adapter = new XeroAdapter();
49
+
50
+ await expect(adapter.getTransactions({ date_from: '01-01-2026' })).rejects.toThrow(
51
+ 'Invalid date format'
52
+ );
53
+ await expect(adapter.getTransactions({ date_from: "2026-01-01' OR '1'='1" })).rejects.toThrow(
54
+ 'Invalid date format'
55
+ );
56
+ });
57
+
58
+ it('accepts valid YYYY-MM-DD dates', async () => {
59
+ writeTokens({
60
+ access_token: 'tok',
61
+ refresh_token: 'ref',
62
+ expires_at: Date.now() + 60 * 60 * 1000,
63
+ });
64
+
65
+ // Mock fetch
66
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
67
+ ok: true,
68
+ json: vi.fn().mockResolvedValue({
69
+ BankTransactions: [
70
+ {
71
+ BankTransactionID: 'bt_001',
72
+ Date: '2026-03-15',
73
+ Total: 150.0,
74
+ CurrencyCode: 'USD',
75
+ Type: 'RECEIVE',
76
+ Reference: 'Payment received',
77
+ },
78
+ ],
79
+ }),
80
+ }));
81
+
82
+ const { XeroAdapter } = await import('../../src/adapters/xero.js');
83
+ const adapter = new XeroAdapter();
84
+ const result = await adapter.getTransactions({ date_from: '2026-01-01', date_to: '2026-03-31' });
85
+
86
+ expect(result.data).toHaveLength(1);
87
+ expect(result.data[0].amount).toBe(15000); // 150.00 * 100 = cents
88
+ });
89
+
90
+ it('converts Xero Total to cents correctly', async () => {
91
+ writeTokens({
92
+ access_token: 'tok',
93
+ refresh_token: 'ref',
94
+ expires_at: Date.now() + 60 * 60 * 1000,
95
+ });
96
+
97
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
98
+ ok: true,
99
+ json: vi.fn().mockResolvedValue({
100
+ BankTransactions: [
101
+ {
102
+ BankTransactionID: 'bt_002',
103
+ Date: '2026-03-01',
104
+ Total: 99.99,
105
+ CurrencyCode: 'USD',
106
+ Type: 'RECEIVE',
107
+ Reference: 'Test',
108
+ },
109
+ ],
110
+ }),
111
+ }));
112
+
113
+ const { XeroAdapter } = await import('../../src/adapters/xero.js');
114
+ const adapter = new XeroAdapter();
115
+ const result = await adapter.getTransactions({});
116
+
117
+ // Math.round(99.99 * 100) = 9999
118
+ expect(result.data[0].amount).toBe(9999);
119
+ });
120
+
121
+ it('refreshes token when expired', async () => {
122
+ // Write expired tokens
123
+ writeTokens({
124
+ access_token: 'old_token',
125
+ refresh_token: 'valid_refresh',
126
+ expires_at: Date.now() - 1000, // Already expired
127
+ });
128
+
129
+ const fetchMock = vi.fn()
130
+ // First call: token refresh endpoint
131
+ .mockResolvedValueOnce({
132
+ ok: true,
133
+ json: vi.fn().mockResolvedValue({
134
+ access_token: 'new_access_token',
135
+ refresh_token: 'new_refresh_token',
136
+ expires_in: 1800,
137
+ }),
138
+ })
139
+ // Second call: actual API request
140
+ .mockResolvedValueOnce({
141
+ ok: true,
142
+ json: vi.fn().mockResolvedValue({ BankTransactions: [] }),
143
+ });
144
+
145
+ vi.stubGlobal('fetch', fetchMock);
146
+
147
+ const { XeroAdapter } = await import('../../src/adapters/xero.js');
148
+ const adapter = new XeroAdapter();
149
+ await adapter.getTransactions({});
150
+
151
+ // Verify token refresh was called first
152
+ const firstCall = fetchMock.mock.calls[0][0] as string;
153
+ expect(firstCall).toContain('identity.xero.com');
154
+ });
155
+
156
+ it('getInvoices returns paginated results with page-based cursor', async () => {
157
+ writeTokens({
158
+ access_token: 'tok',
159
+ refresh_token: 'ref',
160
+ expires_at: Date.now() + 60 * 60 * 1000,
161
+ });
162
+
163
+ // Return exactly 100 invoices to trigger has_more: true
164
+ const mockInvoices = Array.from({ length: 100 }, (_, i) => ({
165
+ InvoiceID: `inv_${i}`,
166
+ InvoiceNumber: `INV-${i}`,
167
+ Contact: { Name: `Customer ${i}`, EmailAddress: `c${i}@test.com` },
168
+ Total: 100,
169
+ CurrencyCode: 'USD',
170
+ Status: 'AUTHORISED',
171
+ DueDate: '2026-05-01',
172
+ Date: '2026-03-01',
173
+ }));
174
+
175
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
176
+ ok: true,
177
+ json: vi.fn().mockResolvedValue({ Invoices: mockInvoices }),
178
+ }));
179
+
180
+ const { XeroAdapter } = await import('../../src/adapters/xero.js');
181
+ const adapter = new XeroAdapter();
182
+ const result = await adapter.getInvoices({});
183
+
184
+ expect(result.has_more).toBe(true);
185
+ expect(result.next_cursor).toBe('2'); // Next page number
186
+ expect(result.data).toHaveLength(100);
187
+ });
188
+ });