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,317 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import type { FinanceProvider, TransactionQuery, InvoiceQuery, ExpenseQuery } from './types.js';
|
|
5
|
+
import type { Transaction, Invoice, Balance, Expense, PaginatedResult } from '../types.js';
|
|
6
|
+
|
|
7
|
+
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
8
|
+
|
|
9
|
+
function validateDate(date: string | undefined): string | undefined {
|
|
10
|
+
if (!date) return undefined;
|
|
11
|
+
if (!DATE_REGEX.test(date)) {
|
|
12
|
+
throw new Error(`Invalid date format: "${date}". Expected YYYY-MM-DD.`);
|
|
13
|
+
}
|
|
14
|
+
return date;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface XeroTokenStore {
|
|
18
|
+
access_token: string;
|
|
19
|
+
refresh_token: string;
|
|
20
|
+
expires_at: number; // Unix timestamp in ms
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TOKEN_PATH = path.join(os.homedir(), '.financeops', 'xero-tokens.json');
|
|
24
|
+
|
|
25
|
+
export class XeroAdapter implements FinanceProvider {
|
|
26
|
+
private tokens: XeroTokenStore | null = null;
|
|
27
|
+
private tenantId: string;
|
|
28
|
+
|
|
29
|
+
constructor() {
|
|
30
|
+
const tenantId = process.env.XERO_TENANT_ID;
|
|
31
|
+
if (!tenantId) {
|
|
32
|
+
throw new Error('XERO_TENANT_ID environment variable is required');
|
|
33
|
+
}
|
|
34
|
+
this.tenantId = tenantId;
|
|
35
|
+
this.loadTokens();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private loadTokens(): void {
|
|
39
|
+
if (fs.existsSync(TOKEN_PATH)) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(TOKEN_PATH, 'utf-8');
|
|
42
|
+
this.tokens = JSON.parse(raw) as XeroTokenStore;
|
|
43
|
+
return;
|
|
44
|
+
} catch {
|
|
45
|
+
// Fall through to env
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Bootstrap from environment
|
|
50
|
+
const accessToken = process.env.XERO_ACCESS_TOKEN;
|
|
51
|
+
const refreshToken = process.env.XERO_REFRESH_TOKEN;
|
|
52
|
+
if (refreshToken) {
|
|
53
|
+
this.tokens = {
|
|
54
|
+
access_token: accessToken ?? '',
|
|
55
|
+
refresh_token: refreshToken,
|
|
56
|
+
expires_at: accessToken ? Date.now() + 30 * 60 * 1000 : 0,
|
|
57
|
+
};
|
|
58
|
+
this.saveTokens();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private saveTokens(): void {
|
|
63
|
+
if (!this.tokens) return;
|
|
64
|
+
const dir = path.dirname(TOKEN_PATH);
|
|
65
|
+
if (!fs.existsSync(dir)) {
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
fs.writeFileSync(TOKEN_PATH, JSON.stringify(this.tokens, null, 2), 'utf-8');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async refreshAccessToken(): Promise<void> {
|
|
72
|
+
if (!this.tokens?.refresh_token) {
|
|
73
|
+
throw new Error('No Xero refresh token available. Set XERO_REFRESH_TOKEN environment variable.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const clientId = process.env.XERO_CLIENT_ID;
|
|
77
|
+
const clientSecret = process.env.XERO_CLIENT_SECRET;
|
|
78
|
+
if (!clientId || !clientSecret) {
|
|
79
|
+
throw new Error('XERO_CLIENT_ID and XERO_CLIENT_SECRET are required for token refresh');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const body = new URLSearchParams({
|
|
83
|
+
grant_type: 'refresh_token',
|
|
84
|
+
refresh_token: this.tokens.refresh_token,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
88
|
+
const response = await fetch('https://identity.xero.com/connect/token', {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Basic ${credentials}`,
|
|
92
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
93
|
+
},
|
|
94
|
+
body: body.toString(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
const text = await response.text();
|
|
99
|
+
throw new Error(`Xero token refresh failed (${response.status}): ${text}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const data = (await response.json()) as {
|
|
103
|
+
access_token: string;
|
|
104
|
+
refresh_token: string;
|
|
105
|
+
expires_in: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
this.tokens = {
|
|
109
|
+
access_token: data.access_token,
|
|
110
|
+
refresh_token: data.refresh_token,
|
|
111
|
+
expires_at: Date.now() + data.expires_in * 1000,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
this.saveTokens();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async ensureFreshToken(): Promise<string> {
|
|
118
|
+
if (!this.tokens) {
|
|
119
|
+
throw new Error('No Xero tokens available. Provide XERO_REFRESH_TOKEN environment variable.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Refresh if within 5 minutes of expiry
|
|
123
|
+
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
124
|
+
if (!this.tokens.access_token || Date.now() >= this.tokens.expires_at - REFRESH_BUFFER_MS) {
|
|
125
|
+
await this.refreshAccessToken();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return this.tokens!.access_token;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async request<T>(path: string): Promise<T> {
|
|
132
|
+
const token = await this.ensureFreshToken();
|
|
133
|
+
|
|
134
|
+
const response = await fetch(`https://api.xero.com/api.xro/2.0${path}`, {
|
|
135
|
+
headers: {
|
|
136
|
+
Authorization: `Bearer ${token}`,
|
|
137
|
+
'Xero-Tenant-Id': this.tenantId,
|
|
138
|
+
Accept: 'application/json',
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const text = await response.text();
|
|
144
|
+
throw new Error(`Xero API error (${response.status}) for ${path}: ${text}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return response.json() as Promise<T>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async getTransactions(opts: TransactionQuery): Promise<PaginatedResult<Transaction>> {
|
|
151
|
+
validateDate(opts.date_from);
|
|
152
|
+
validateDate(opts.date_to);
|
|
153
|
+
|
|
154
|
+
const page = opts.cursor ? parseInt(opts.cursor, 10) : 1;
|
|
155
|
+
if (isNaN(page) || page < 1) {
|
|
156
|
+
throw new Error(`Invalid cursor: "${opts.cursor}". Expected a page number.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let queryPath = `/BankTransactions?order=Date DESC&page=${page}`;
|
|
160
|
+
|
|
161
|
+
if (opts.date_from) queryPath += `&fromDate=${opts.date_from}`;
|
|
162
|
+
if (opts.date_to) queryPath += `&toDate=${opts.date_to}`;
|
|
163
|
+
|
|
164
|
+
const data = await this.request<{ BankTransactions: any[] }>(queryPath);
|
|
165
|
+
const items = data.BankTransactions ?? [];
|
|
166
|
+
|
|
167
|
+
const transactions: Transaction[] = items.map((bt: any) => ({
|
|
168
|
+
id: bt.BankTransactionID,
|
|
169
|
+
date: bt.Date ?? '',
|
|
170
|
+
amount: Math.round((bt.Total ?? 0) * 100), // Convert to cents
|
|
171
|
+
currency: (bt.CurrencyCode ?? 'USD').toLowerCase(),
|
|
172
|
+
description: bt.Reference ?? bt.BankTransactionID,
|
|
173
|
+
category: bt.LineItems?.[0]?.AccountCode ?? undefined,
|
|
174
|
+
type: bt.Type === 'SPEND' ? 'expense' : 'income',
|
|
175
|
+
provider: 'xero' as const,
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
const XERO_PAGE_SIZE = 100;
|
|
179
|
+
const hasMore = items.length === XERO_PAGE_SIZE;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
data: transactions,
|
|
183
|
+
has_more: hasMore,
|
|
184
|
+
next_cursor: hasMore ? String(page + 1) : undefined,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async getBalances(): Promise<Balance[]> {
|
|
189
|
+
const data = await this.request<{ Accounts: any[] }>('/Accounts?where=Type=="BANK"');
|
|
190
|
+
const accounts = data.Accounts ?? [];
|
|
191
|
+
|
|
192
|
+
return accounts.map((acc: any) => ({
|
|
193
|
+
currency: (acc.CurrencyCode ?? 'USD').toLowerCase(),
|
|
194
|
+
available: Math.round((acc.Balance ?? 0) * 100),
|
|
195
|
+
pending: 0,
|
|
196
|
+
provider: 'xero' as const,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async getInvoices(opts: InvoiceQuery): Promise<PaginatedResult<Invoice>> {
|
|
201
|
+
validateDate(opts.date_from);
|
|
202
|
+
validateDate(opts.date_to);
|
|
203
|
+
|
|
204
|
+
const page = opts.cursor ? parseInt(opts.cursor, 10) : 1;
|
|
205
|
+
if (isNaN(page) || page < 1) {
|
|
206
|
+
throw new Error(`Invalid cursor: "${opts.cursor}". Expected a page number.`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let queryPath = `/Invoices?order=Date DESC&where=Type=="ACCREC"&page=${page}`;
|
|
210
|
+
|
|
211
|
+
if (opts.status) {
|
|
212
|
+
const statusMap: Record<string, string> = {
|
|
213
|
+
paid: 'PAID',
|
|
214
|
+
pending: 'AUTHORISED',
|
|
215
|
+
overdue: 'AUTHORISED',
|
|
216
|
+
};
|
|
217
|
+
queryPath += ` AND Status=="${statusMap[opts.status] ?? 'AUTHORISED'}"`;
|
|
218
|
+
}
|
|
219
|
+
if (opts.date_from) {
|
|
220
|
+
queryPath += ` AND Date>="${opts.date_from}"`;
|
|
221
|
+
}
|
|
222
|
+
if (opts.date_to) {
|
|
223
|
+
queryPath += ` AND Date<="${opts.date_to}"`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const data = await this.request<{ Invoices: any[] }>(queryPath);
|
|
227
|
+
|
|
228
|
+
let invoices: Invoice[] = (data.Invoices ?? []).map((inv: any) => this.mapXeroInvoice(inv));
|
|
229
|
+
|
|
230
|
+
// Post-filter overdue
|
|
231
|
+
if (opts.status === 'overdue') {
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
invoices = invoices.filter(
|
|
234
|
+
(inv) => inv.status !== 'paid' && inv.due_date && new Date(inv.due_date).getTime() < now
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const XERO_PAGE_SIZE = 100;
|
|
239
|
+
const hasMore = (data.Invoices ?? []).length === XERO_PAGE_SIZE;
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
data: invoices,
|
|
243
|
+
has_more: hasMore,
|
|
244
|
+
next_cursor: hasMore ? String(page + 1) : undefined,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async getExpenses(opts: ExpenseQuery): Promise<PaginatedResult<Expense>> {
|
|
249
|
+
validateDate(opts.date_from);
|
|
250
|
+
validateDate(opts.date_to);
|
|
251
|
+
|
|
252
|
+
const page = opts.cursor ? parseInt(opts.cursor, 10) : 1;
|
|
253
|
+
if (isNaN(page) || page < 1) {
|
|
254
|
+
throw new Error(`Invalid cursor: "${opts.cursor}". Expected a page number.`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let queryPath = `/BankTransactions?order=Date DESC&where=Type=="SPEND"&page=${page}`;
|
|
258
|
+
if (opts.date_from) queryPath += `&fromDate=${opts.date_from}`;
|
|
259
|
+
if (opts.date_to) queryPath += `&toDate=${opts.date_to}`;
|
|
260
|
+
|
|
261
|
+
const data = await this.request<{ BankTransactions: any[] }>(queryPath);
|
|
262
|
+
const items = data.BankTransactions ?? [];
|
|
263
|
+
|
|
264
|
+
const expenses: Expense[] = items.map((bt: any) => ({
|
|
265
|
+
id: bt.BankTransactionID,
|
|
266
|
+
date: bt.Date ?? '',
|
|
267
|
+
amount: Math.round((bt.Total ?? 0) * 100),
|
|
268
|
+
currency: (bt.CurrencyCode ?? 'USD').toLowerCase(),
|
|
269
|
+
description: bt.Reference ?? bt.BankTransactionID,
|
|
270
|
+
category: bt.LineItems?.[0]?.AccountCode ?? 'Uncategorized',
|
|
271
|
+
provider: 'xero' as const,
|
|
272
|
+
}));
|
|
273
|
+
|
|
274
|
+
const XERO_PAGE_SIZE = 100;
|
|
275
|
+
const hasMore = items.length === XERO_PAGE_SIZE;
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
data: expenses,
|
|
279
|
+
has_more: hasMore,
|
|
280
|
+
next_cursor: hasMore ? String(page + 1) : undefined,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private mapXeroInvoice(inv: any): Invoice {
|
|
285
|
+
return {
|
|
286
|
+
id: inv.InvoiceID,
|
|
287
|
+
number: inv.InvoiceNumber ?? '',
|
|
288
|
+
customer_name: inv.Contact?.Name ?? '',
|
|
289
|
+
customer_email: inv.Contact?.EmailAddress,
|
|
290
|
+
amount: Math.round((inv.Total ?? 0) * 100), // Convert to cents
|
|
291
|
+
currency: (inv.CurrencyCode ?? 'USD').toLowerCase(),
|
|
292
|
+
status: this.mapXeroInvoiceStatus(inv.Status, inv.DueDate),
|
|
293
|
+
due_date: inv.DueDate ?? '',
|
|
294
|
+
issued_date: inv.Date ?? '',
|
|
295
|
+
paid_date: inv.FullyPaidOnDate ?? undefined,
|
|
296
|
+
provider: 'xero' as const,
|
|
297
|
+
// Read-only computed fields from Xero
|
|
298
|
+
amount_due: inv.AmountDue != null ? Math.round(inv.AmountDue * 100) : undefined,
|
|
299
|
+
amount_paid: inv.AmountPaid != null ? Math.round(inv.AmountPaid * 100) : undefined,
|
|
300
|
+
amount_credited: inv.AmountCredited != null ? Math.round(inv.AmountCredited * 100) : undefined,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private mapXeroInvoiceStatus(
|
|
305
|
+
status: string,
|
|
306
|
+
dueDate: string | undefined
|
|
307
|
+
): 'paid' | 'pending' | 'overdue' | 'draft' | 'voided' {
|
|
308
|
+
if (status === 'PAID') return 'paid';
|
|
309
|
+
if (status === 'DRAFT') return 'draft';
|
|
310
|
+
if (status === 'VOIDED' || status === 'DELETED') return 'voided';
|
|
311
|
+
if (status === 'AUTHORISED' || status === 'SUBMITTED') {
|
|
312
|
+
if (dueDate && new Date(dueDate).getTime() < Date.now()) return 'overdue';
|
|
313
|
+
return 'pending';
|
|
314
|
+
}
|
|
315
|
+
return 'pending';
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Transaction, Anomaly } from '../types.js';
|
|
2
|
+
|
|
3
|
+
type SensitivityLevel = 'low' | 'medium' | 'high';
|
|
4
|
+
|
|
5
|
+
const VARIANCE_THRESHOLDS: Record<SensitivityLevel, number> = {
|
|
6
|
+
low: 3.0,
|
|
7
|
+
medium: 2.0,
|
|
8
|
+
high: 1.5,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detect anomalies in a set of transactions.
|
|
13
|
+
*
|
|
14
|
+
* Rule-based detection:
|
|
15
|
+
* 1. Duplicate detection: same amount + similar description within 24 hours
|
|
16
|
+
* 2. Large variance: transaction > N×category average (N depends on sensitivity)
|
|
17
|
+
*/
|
|
18
|
+
export function detectAnomalies(
|
|
19
|
+
transactions: Transaction[],
|
|
20
|
+
sensitivity: SensitivityLevel = 'medium'
|
|
21
|
+
): Anomaly[] {
|
|
22
|
+
const anomalies: Anomaly[] = [];
|
|
23
|
+
const threshold = VARIANCE_THRESHOLDS[sensitivity];
|
|
24
|
+
|
|
25
|
+
// --- 1. Duplicate detection ---
|
|
26
|
+
// Same amount + same provider + description similarity within 24 hours
|
|
27
|
+
for (let i = 0; i < transactions.length; i++) {
|
|
28
|
+
for (let j = i + 1; j < transactions.length; j++) {
|
|
29
|
+
const a = transactions[i];
|
|
30
|
+
const b = transactions[j];
|
|
31
|
+
|
|
32
|
+
if (a.amount !== b.amount) continue;
|
|
33
|
+
if (a.type !== b.type) continue;
|
|
34
|
+
|
|
35
|
+
const timeDiff =
|
|
36
|
+
Math.abs(new Date(a.date).getTime() - new Date(b.date).getTime()) /
|
|
37
|
+
(1000 * 60 * 60);
|
|
38
|
+
|
|
39
|
+
if (timeDiff > 24) continue;
|
|
40
|
+
|
|
41
|
+
// Description similarity: check if they share significant words
|
|
42
|
+
const descSimilar = descriptionsSimilar(a.description, b.description);
|
|
43
|
+
if (!descSimilar) continue;
|
|
44
|
+
|
|
45
|
+
anomalies.push({
|
|
46
|
+
id: `dup_${a.id}_${b.id}`,
|
|
47
|
+
type: 'duplicate',
|
|
48
|
+
severity: 'high',
|
|
49
|
+
transaction: a,
|
|
50
|
+
description: `Potential duplicate: "${a.description}" (${formatAmount(a.amount, a.currency)}) appears twice within 24 hours`,
|
|
51
|
+
amount: a.amount,
|
|
52
|
+
date: a.date,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Only flag once per pair
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- 2. Large variance detection ---
|
|
61
|
+
// Group transactions by category
|
|
62
|
+
const byCategory: Record<string, Transaction[]> = {};
|
|
63
|
+
for (const t of transactions) {
|
|
64
|
+
const cat = t.category ?? 'Uncategorized';
|
|
65
|
+
if (!byCategory[cat]) byCategory[cat] = [];
|
|
66
|
+
byCategory[cat].push(t);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const [category, txns] of Object.entries(byCategory)) {
|
|
70
|
+
if (txns.length < 3) continue; // Need at least 3 to compute meaningful average
|
|
71
|
+
|
|
72
|
+
const amounts = txns.map((t) => t.amount);
|
|
73
|
+
const avg = amounts.reduce((a, b) => a + b, 0) / amounts.length;
|
|
74
|
+
|
|
75
|
+
for (const t of txns) {
|
|
76
|
+
if (avg === 0) continue;
|
|
77
|
+
const ratio = t.amount / avg;
|
|
78
|
+
|
|
79
|
+
if (ratio > threshold) {
|
|
80
|
+
anomalies.push({
|
|
81
|
+
id: `var_${t.id}`,
|
|
82
|
+
type: 'large_variance',
|
|
83
|
+
severity: ratio > threshold * 1.5 ? 'high' : 'medium',
|
|
84
|
+
transaction: t,
|
|
85
|
+
description: `Large variance in "${category}": ${formatAmount(t.amount, t.currency)} is ${ratio.toFixed(1)}× the category average of ${formatAmount(Math.round(avg), t.currency)}`,
|
|
86
|
+
amount: t.amount,
|
|
87
|
+
date: t.date,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Deduplicate anomalies by id
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
return anomalies.filter((a) => {
|
|
96
|
+
if (seen.has(a.id)) return false;
|
|
97
|
+
seen.add(a.id);
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function descriptionsSimilar(a: string, b: string): boolean {
|
|
103
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
|
|
104
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
|
|
105
|
+
|
|
106
|
+
if (wordsA.size === 0 || wordsB.size === 0) return true; // Both empty = similar
|
|
107
|
+
|
|
108
|
+
let commonCount = 0;
|
|
109
|
+
for (const word of wordsA) {
|
|
110
|
+
if (wordsB.has(word)) commonCount++;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const jaccardSimilarity = commonCount / (wordsA.size + wordsB.size - commonCount);
|
|
114
|
+
return jaccardSimilarity >= 0.3;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatAmount(cents: number, currency: string): string {
|
|
118
|
+
return `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
|
119
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Transaction, CashFlowForecast, MonthlyForecast } from '../types.js';
|
|
2
|
+
|
|
3
|
+
interface MonthlyData {
|
|
4
|
+
income: number;
|
|
5
|
+
expenses: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function getMonthKey(date: string): string {
|
|
9
|
+
return date.substring(0, 7); // YYYY-MM
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function addMonths(yearMonth: string, n: number): string {
|
|
13
|
+
const [year, month] = yearMonth.split('-').map(Number);
|
|
14
|
+
const d = new Date(year, month - 1 + n, 1);
|
|
15
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate a cash flow forecast using weighted moving average.
|
|
20
|
+
* 3-month lookback, 3 scenarios (conservative / realistic / optimistic ±20%).
|
|
21
|
+
* Input amounts are in cents; output is in major currency units.
|
|
22
|
+
*
|
|
23
|
+
* Spec: minimum 3 months historical data required (returns empty scenarios otherwise).
|
|
24
|
+
*/
|
|
25
|
+
export function forecastCashFlow(
|
|
26
|
+
transactions: Transaction[],
|
|
27
|
+
monthsAhead: number = 3
|
|
28
|
+
): CashFlowForecast {
|
|
29
|
+
// Build monthly buckets from historical data
|
|
30
|
+
const monthly: Record<string, MonthlyData> = {};
|
|
31
|
+
|
|
32
|
+
for (const t of transactions) {
|
|
33
|
+
const key = getMonthKey(t.date);
|
|
34
|
+
if (!monthly[key]) monthly[key] = { income: 0, expenses: 0 };
|
|
35
|
+
|
|
36
|
+
if (t.type === 'income') {
|
|
37
|
+
monthly[key].income += t.amount / 100;
|
|
38
|
+
} else if (t.type === 'expense') {
|
|
39
|
+
monthly[key].expenses += t.amount / 100;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sortedMonths = Object.keys(monthly).sort();
|
|
44
|
+
|
|
45
|
+
// Use last 3 months as lookback window
|
|
46
|
+
const LOOKBACK = 3;
|
|
47
|
+
const lookbackMonths = sortedMonths.slice(-LOOKBACK);
|
|
48
|
+
|
|
49
|
+
let avgIncome = 0;
|
|
50
|
+
let avgExpenses = 0;
|
|
51
|
+
let trendPct = 0;
|
|
52
|
+
|
|
53
|
+
if (lookbackMonths.length >= LOOKBACK) {
|
|
54
|
+
const windowData = lookbackMonths.map((m) => monthly[m]);
|
|
55
|
+
|
|
56
|
+
// Weighted moving average: most recent gets higher weight
|
|
57
|
+
// Weights: [1, 2, 3] for 3 months (oldest to newest)
|
|
58
|
+
const weights = [1, 2, 3];
|
|
59
|
+
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
|
60
|
+
|
|
61
|
+
avgIncome =
|
|
62
|
+
windowData.reduce((sum, d, i) => sum + d.income * weights[i], 0) / totalWeight;
|
|
63
|
+
avgExpenses =
|
|
64
|
+
windowData.reduce((sum, d, i) => sum + d.expenses * weights[i], 0) / totalWeight;
|
|
65
|
+
|
|
66
|
+
// Linear trend: compare first month to last month in window
|
|
67
|
+
if (windowData.length >= 2 && windowData[0].income > 0) {
|
|
68
|
+
const firstNet = windowData[0].income - windowData[0].expenses;
|
|
69
|
+
const lastNet = windowData[windowData.length - 1].income - windowData[windowData.length - 1].expenses;
|
|
70
|
+
trendPct = firstNet !== 0 ? (lastNet - firstNet) / Math.abs(firstNet) / (windowData.length - 1) : 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const startMonth = sortedMonths.length > 0
|
|
75
|
+
? addMonths(sortedMonths[sortedMonths.length - 1], 1)
|
|
76
|
+
: getMonthKey(new Date().toISOString());
|
|
77
|
+
|
|
78
|
+
const VARIANCE = 0.2;
|
|
79
|
+
|
|
80
|
+
// Conservative: -20% income
|
|
81
|
+
function buildConservative(): MonthlyForecast[] {
|
|
82
|
+
const forecasts: MonthlyForecast[] = [];
|
|
83
|
+
let cumulative = 0;
|
|
84
|
+
for (let i = 0; i < monthsAhead; i++) {
|
|
85
|
+
const month = addMonths(startMonth, i);
|
|
86
|
+
const trendMultiplier = Math.pow(1 + trendPct, i);
|
|
87
|
+
const income = avgIncome * trendMultiplier * (1 - VARIANCE);
|
|
88
|
+
const expenses = avgExpenses * trendMultiplier;
|
|
89
|
+
const net = income - expenses;
|
|
90
|
+
cumulative += net;
|
|
91
|
+
forecasts.push({
|
|
92
|
+
month,
|
|
93
|
+
income: Math.round(income * 100) / 100,
|
|
94
|
+
expenses: Math.round(expenses * 100) / 100,
|
|
95
|
+
net: Math.round(net * 100) / 100,
|
|
96
|
+
cumulative_net: Math.round(cumulative * 100) / 100,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return forecasts;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Realistic: base forecast
|
|
103
|
+
function buildRealistic(): MonthlyForecast[] {
|
|
104
|
+
const forecasts: MonthlyForecast[] = [];
|
|
105
|
+
let cumulative = 0;
|
|
106
|
+
for (let i = 0; i < monthsAhead; i++) {
|
|
107
|
+
const month = addMonths(startMonth, i);
|
|
108
|
+
const trendMultiplier = Math.pow(1 + trendPct, i);
|
|
109
|
+
const income = avgIncome * trendMultiplier;
|
|
110
|
+
const expenses = avgExpenses * trendMultiplier;
|
|
111
|
+
const net = income - expenses;
|
|
112
|
+
cumulative += net;
|
|
113
|
+
forecasts.push({
|
|
114
|
+
month,
|
|
115
|
+
income: Math.round(income * 100) / 100,
|
|
116
|
+
expenses: Math.round(expenses * 100) / 100,
|
|
117
|
+
net: Math.round(net * 100) / 100,
|
|
118
|
+
cumulative_net: Math.round(cumulative * 100) / 100,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return forecasts;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Optimistic: +20% income
|
|
125
|
+
function buildOptimistic(): MonthlyForecast[] {
|
|
126
|
+
const forecasts: MonthlyForecast[] = [];
|
|
127
|
+
let cumulative = 0;
|
|
128
|
+
for (let i = 0; i < monthsAhead; i++) {
|
|
129
|
+
const month = addMonths(startMonth, i);
|
|
130
|
+
const trendMultiplier = Math.pow(1 + trendPct, i);
|
|
131
|
+
const income = avgIncome * trendMultiplier * (1 + VARIANCE);
|
|
132
|
+
const expenses = avgExpenses * trendMultiplier;
|
|
133
|
+
const net = income - expenses;
|
|
134
|
+
cumulative += net;
|
|
135
|
+
forecasts.push({
|
|
136
|
+
month,
|
|
137
|
+
income: Math.round(income * 100) / 100,
|
|
138
|
+
expenses: Math.round(expenses * 100) / 100,
|
|
139
|
+
net: Math.round(net * 100) / 100,
|
|
140
|
+
cumulative_net: Math.round(cumulative * 100) / 100,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return forecasts;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
period_months: monthsAhead,
|
|
148
|
+
generated_at: new Date().toISOString(),
|
|
149
|
+
historical_monthly_income: Math.round(avgIncome * 100) / 100,
|
|
150
|
+
historical_monthly_expenses: Math.round(avgExpenses * 100) / 100,
|
|
151
|
+
trend_pct: Math.round(trendPct * 10000) / 10000,
|
|
152
|
+
scenarios: {
|
|
153
|
+
conservative: buildConservative(),
|
|
154
|
+
realistic: buildRealistic(),
|
|
155
|
+
optimistic: buildOptimistic(),
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Transaction, Expense, PnLReport } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a Profit & Loss report from transactions and expenses.
|
|
5
|
+
* Input amounts are in cents (smallest currency unit).
|
|
6
|
+
* Output amounts are in major currency units (e.g. 150.00).
|
|
7
|
+
*/
|
|
8
|
+
export function generatePnL(
|
|
9
|
+
transactions: Transaction[],
|
|
10
|
+
expenses: Expense[],
|
|
11
|
+
dateFrom: string,
|
|
12
|
+
dateTo: string
|
|
13
|
+
): PnLReport {
|
|
14
|
+
// Determine currency from data (Important Fix I7)
|
|
15
|
+
const currency = transactions.length > 0 ? transactions[0].currency : 'usd';
|
|
16
|
+
|
|
17
|
+
// Revenue: income transactions in the period
|
|
18
|
+
const incomeTransactions = transactions.filter(
|
|
19
|
+
(t) =>
|
|
20
|
+
t.type === 'income' &&
|
|
21
|
+
t.date >= dateFrom &&
|
|
22
|
+
t.date <= dateTo
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// Expense transactions in the period
|
|
26
|
+
const expenseTransactions = transactions.filter(
|
|
27
|
+
(t) =>
|
|
28
|
+
t.type === 'expense' &&
|
|
29
|
+
t.date >= dateFrom &&
|
|
30
|
+
t.date <= dateTo
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Direct expenses from expense records
|
|
34
|
+
const periodExpenses = expenses.filter(
|
|
35
|
+
(e) => e.date >= dateFrom && e.date <= dateTo
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Revenue in cents then convert to major units
|
|
39
|
+
const revenueCents = incomeTransactions.reduce((sum, t) => sum + t.amount, 0);
|
|
40
|
+
const revenue = revenueCents / 100;
|
|
41
|
+
|
|
42
|
+
// COGS: expense transactions (fees, refunds, direct costs)
|
|
43
|
+
const cogsCents = expenseTransactions.reduce((sum, t) => sum + t.amount, 0);
|
|
44
|
+
const cogs = cogsCents / 100;
|
|
45
|
+
|
|
46
|
+
// Operating expenses from explicit expense records
|
|
47
|
+
const opExpCents = periodExpenses.reduce((sum, e) => sum + e.amount, 0);
|
|
48
|
+
const operatingExpenses = opExpCents / 100;
|
|
49
|
+
|
|
50
|
+
const grossProfit = revenue - cogs;
|
|
51
|
+
const grossMarginPct = revenue > 0 ? (grossProfit / revenue) * 100 : 0;
|
|
52
|
+
const netIncome = grossProfit - operatingExpenses;
|
|
53
|
+
|
|
54
|
+
// Expenses by category
|
|
55
|
+
const byCategory: Record<string, number> = {};
|
|
56
|
+
for (const e of periodExpenses) {
|
|
57
|
+
const cat = e.category ?? 'Uncategorized';
|
|
58
|
+
byCategory[cat] = (byCategory[cat] ?? 0) + e.amount / 100;
|
|
59
|
+
}
|
|
60
|
+
for (const t of expenseTransactions) {
|
|
61
|
+
const cat = t.category ?? 'Other';
|
|
62
|
+
byCategory[cat] = (byCategory[cat] ?? 0) + t.amount / 100;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const period = `${dateFrom} to ${dateTo}`;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
period,
|
|
69
|
+
date_from: dateFrom,
|
|
70
|
+
date_to: dateTo,
|
|
71
|
+
currency,
|
|
72
|
+
revenue,
|
|
73
|
+
cogs,
|
|
74
|
+
gross_profit: grossProfit,
|
|
75
|
+
gross_margin_pct: Math.round(grossMarginPct * 100) / 100,
|
|
76
|
+
operating_expenses: operatingExpenses,
|
|
77
|
+
net_income: netIncome,
|
|
78
|
+
by_category: byCategory,
|
|
79
|
+
};
|
|
80
|
+
}
|