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,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
+ }