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
package/src/lib/audit.ts
ADDED
|
@@ -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
|
+
});
|