optimal-cli 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 (56) hide show
  1. package/README.md +175 -0
  2. package/dist/bin/optimal.d.ts +2 -0
  3. package/dist/bin/optimal.js +995 -0
  4. package/dist/lib/budget/projections.d.ts +115 -0
  5. package/dist/lib/budget/projections.js +384 -0
  6. package/dist/lib/budget/scenarios.d.ts +93 -0
  7. package/dist/lib/budget/scenarios.js +214 -0
  8. package/dist/lib/cms/publish-blog.d.ts +62 -0
  9. package/dist/lib/cms/publish-blog.js +74 -0
  10. package/dist/lib/cms/strapi-client.d.ts +123 -0
  11. package/dist/lib/cms/strapi-client.js +213 -0
  12. package/dist/lib/config.d.ts +55 -0
  13. package/dist/lib/config.js +206 -0
  14. package/dist/lib/infra/deploy.d.ts +29 -0
  15. package/dist/lib/infra/deploy.js +58 -0
  16. package/dist/lib/infra/migrate.d.ts +34 -0
  17. package/dist/lib/infra/migrate.js +103 -0
  18. package/dist/lib/kanban.d.ts +46 -0
  19. package/dist/lib/kanban.js +118 -0
  20. package/dist/lib/newsletter/distribute.d.ts +52 -0
  21. package/dist/lib/newsletter/distribute.js +193 -0
  22. package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
  23. package/dist/lib/newsletter/generate-insurance.js +36 -0
  24. package/dist/lib/newsletter/generate.d.ts +104 -0
  25. package/dist/lib/newsletter/generate.js +571 -0
  26. package/dist/lib/returnpro/anomalies.d.ts +64 -0
  27. package/dist/lib/returnpro/anomalies.js +166 -0
  28. package/dist/lib/returnpro/audit.d.ts +32 -0
  29. package/dist/lib/returnpro/audit.js +147 -0
  30. package/dist/lib/returnpro/diagnose.d.ts +52 -0
  31. package/dist/lib/returnpro/diagnose.js +281 -0
  32. package/dist/lib/returnpro/kpis.d.ts +32 -0
  33. package/dist/lib/returnpro/kpis.js +192 -0
  34. package/dist/lib/returnpro/templates.d.ts +48 -0
  35. package/dist/lib/returnpro/templates.js +229 -0
  36. package/dist/lib/returnpro/upload-income.d.ts +25 -0
  37. package/dist/lib/returnpro/upload-income.js +235 -0
  38. package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
  39. package/dist/lib/returnpro/upload-netsuite.js +566 -0
  40. package/dist/lib/returnpro/upload-r1.d.ts +48 -0
  41. package/dist/lib/returnpro/upload-r1.js +398 -0
  42. package/dist/lib/social/post-generator.d.ts +83 -0
  43. package/dist/lib/social/post-generator.js +333 -0
  44. package/dist/lib/social/publish.d.ts +66 -0
  45. package/dist/lib/social/publish.js +226 -0
  46. package/dist/lib/social/scraper.d.ts +67 -0
  47. package/dist/lib/social/scraper.js +361 -0
  48. package/dist/lib/supabase.d.ts +4 -0
  49. package/dist/lib/supabase.js +20 -0
  50. package/dist/lib/transactions/delete-batch.d.ts +60 -0
  51. package/dist/lib/transactions/delete-batch.js +203 -0
  52. package/dist/lib/transactions/ingest.d.ts +43 -0
  53. package/dist/lib/transactions/ingest.js +555 -0
  54. package/dist/lib/transactions/stamp.d.ts +51 -0
  55. package/dist/lib/transactions/stamp.js +524 -0
  56. package/package.json +50 -0
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Transaction Stamp Engine — Auto-Categorization by Rules
3
+ *
4
+ * Ported from OptimalOS:
5
+ * - /home/optimal/optimalos/lib/stamp-engine/matcher.ts
6
+ * - /home/optimal/optimalos/lib/stamp-engine/patterns.ts
7
+ * - /home/optimal/optimalos/lib/stamp-engine/description-hash.ts
8
+ * - /home/optimal/optimalos/lib/stamp-engine/db/
9
+ * - /home/optimal/optimalos/app/api/stamp/route.ts
10
+ *
11
+ * 4-stage matching algorithm:
12
+ * 1. PATTERN — transfers, P2P, credit card payments (100% confidence)
13
+ * 2. LEARNED — user-confirmed patterns (80-99% confidence)
14
+ * 3. EXACT — provider name found in description (100% confidence)
15
+ * 4. FUZZY — token overlap matching (60-95% confidence)
16
+ * Fallback: CATEGORY_INFER from institution category (50% confidence)
17
+ *
18
+ * Queries unclassified transactions for a given user, loads matching
19
+ * rules from providers / learned_patterns / user_provider_overrides /
20
+ * stamp_categories, then updates `category_id` on matched rows.
21
+ */
22
+ import { getSupabase } from '../supabase.js';
23
+ // =============================================================================
24
+ // DESCRIPTION HASH — normalize descriptions for pattern learning
25
+ // =============================================================================
26
+ function createDescriptionHash(description) {
27
+ let hash = description.toUpperCase();
28
+ // Remove dates
29
+ hash = hash.replace(/\d{1,2}\/\d{1,2}(\/\d{2,4})?/g, '');
30
+ hash = hash.replace(/\d{1,2}-\d{1,2}(-\d{2,4})?/g, '');
31
+ // Remove reference numbers (8+ alphanumeric chars)
32
+ hash = hash.replace(/[A-Z0-9]{8,}/g, '');
33
+ // Remove order/transaction numbers with # prefix
34
+ hash = hash.replace(/#[A-Z0-9]+/gi, '');
35
+ // Remove amounts ($XX.XX)
36
+ hash = hash.replace(/\$?\d+\.\d{2}/g, '');
37
+ // Remove phone numbers
38
+ hash = hash.replace(/\d{3}[-.]?\d{3}[-.]?\d{4}/g, '');
39
+ // Remove standalone long numbers (store numbers, etc)
40
+ hash = hash.replace(/\b\d{4,}\b/g, '');
41
+ // Remove variable suffixes
42
+ hash = hash.replace(/\bAPPLE PAY ENDING IN \d+/gi, '');
43
+ hash = hash.replace(/\bPENDING\b/gi, '');
44
+ hash = hash.replace(/\bPPD ID:\s*\d+/gi, '');
45
+ hash = hash.replace(/\bWEB ID:\s*\w+/gi, '');
46
+ hash = hash.replace(/\btransaction#:\s*\d+/gi, '');
47
+ // Remove trailing state abbreviations
48
+ hash = hash.replace(/\s+[A-Z]{2}$/g, '');
49
+ // Normalize whitespace
50
+ hash = hash.replace(/\s+/g, ' ').trim();
51
+ // Remove trailing punctuation
52
+ hash = hash.replace(/[,.\-*]+$/, '').trim();
53
+ return hash;
54
+ }
55
+ /**
56
+ * Extract potential merchant tokens from a description.
57
+ */
58
+ function extractMerchantTokens(description) {
59
+ const upper = description.toUpperCase();
60
+ const parts = upper.split(/[\s*#]+/);
61
+ const US_STATES = new Set([
62
+ 'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
63
+ 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
64
+ 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
65
+ 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
66
+ 'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY',
67
+ 'DC', 'PR', 'VI', 'GU',
68
+ ]);
69
+ return parts
70
+ .filter((p) => {
71
+ if (p.length < 2)
72
+ return false;
73
+ if (/^\d+$/.test(p))
74
+ return false;
75
+ if (/^[A-Z]{2}$/.test(p) && US_STATES.has(p))
76
+ return false;
77
+ return true;
78
+ })
79
+ .slice(0, 6);
80
+ }
81
+ /**
82
+ * Normalize a provider name for matching.
83
+ */
84
+ function normalizeProviderName(provider) {
85
+ return provider
86
+ .toUpperCase()
87
+ .replace(/[''`]/g, "'")
88
+ .replace(/\s+/g, ' ')
89
+ .trim();
90
+ }
91
+ /**
92
+ * Generate name variants for matching (spaces, asterisks, hyphens, apostrophes).
93
+ */
94
+ function generateProviderVariants(provider) {
95
+ const normalized = normalizeProviderName(provider);
96
+ const variants = new Set([normalized]);
97
+ if (normalized.includes(' ')) {
98
+ variants.add(normalized.replace(/\s+/g, ''));
99
+ variants.add(normalized.replace(/\s+/g, '*'));
100
+ variants.add(normalized.replace(/\s+/g, '-'));
101
+ }
102
+ if (normalized.includes("'")) {
103
+ variants.add(normalized.replace(/'/g, ''));
104
+ }
105
+ return Array.from(variants);
106
+ }
107
+ const TRANSFER_PATTERNS = [
108
+ // Zelle
109
+ { pattern: /Zelle payment from\s+(.+?)(?:\s+[A-Z0-9]{8,})?$/i, category: 'P2P', provider: 'extract_name' },
110
+ { pattern: /Zelle payment to\s+(.+?)(?:\s+[A-Z0-9]{8,})?$/i, category: 'P2P', provider: 'extract_name' },
111
+ { pattern: /ZELLE\s+(?:PAYMENT|TRANSFER)\s+(?:FROM|TO)\s+(.+?)(?:\s+[A-Z0-9]{8,})?$/i, category: 'P2P', provider: 'extract_name' },
112
+ // Internal transfers
113
+ { pattern: /Online Transfer (?:from|to) CHK/i, category: 'TRANSFER', provider: 'INTERNAL TRANSFER' },
114
+ { pattern: /Online Transfer (?:from|to) SAV/i, category: 'TRANSFER', provider: 'INTERNAL TRANSFER' },
115
+ { pattern: /ACCT_XFER/i, category: 'TRANSFER', provider: 'INTERNAL TRANSFER' },
116
+ { pattern: /Online Banking Transfer/i, category: 'TRANSFER', provider: 'INTERNAL TRANSFER' },
117
+ // Credit card payments
118
+ { pattern: /CHASE CREDIT CRD AUTOPAY/i, category: 'CREDIT CARD', provider: 'CHASE CC PAYMENT' },
119
+ { pattern: /AMEX EPAYMENT/i, category: 'CREDIT CARD', provider: 'AMEX PAYMENT' },
120
+ { pattern: /AMERICAN EXPRESS ACH PMT/i, category: 'CREDIT CARD', provider: 'AMEX PAYMENT' },
121
+ { pattern: /CITI CARD/i, category: 'CREDIT CARD', provider: 'CITI PAYMENT' },
122
+ { pattern: /DISCOVER\s+E-?PAYMENT/i, category: 'CREDIT CARD', provider: 'DISCOVER PAYMENT' },
123
+ { pattern: /AUTOMATIC PAYMENT - THANK/i, category: 'CREDIT CARD', provider: 'CC PAYMENT' },
124
+ { pattern: /MOBILE PAYMENT - THANK/i, category: 'CREDIT CARD', provider: 'CC PAYMENT' },
125
+ { pattern: /Payment to Chase card ending in/i, category: 'CREDIT CARD', provider: 'CHASE CC PAYMENT' },
126
+ // Loan / Mortgage
127
+ { pattern: /LOAN_PMT/i, category: 'FINANCIAL', provider: 'LOAN PAYMENT' },
128
+ { pattern: /MORTGAGE/i, category: 'FINANCIAL', provider: 'MORTGAGE' },
129
+ // Payroll
130
+ { pattern: /PAYROLL/i, category: 'PAYROLL', provider: 'PAYROLL' },
131
+ { pattern: /DIRECT DEP/i, category: 'PAYROLL', provider: 'DIRECT DEPOSIT' },
132
+ { pattern: /SALARY/i, category: 'PAYROLL', provider: 'SALARY' },
133
+ // ATM
134
+ { pattern: /ATM WITHDRAWAL/i, category: 'ATM', provider: 'ATM' },
135
+ { pattern: /ATM\s+\d+/i, category: 'ATM', provider: 'ATM' },
136
+ // Fees
137
+ { pattern: /INTEREST CHARGE/i, category: 'FEES', provider: 'INTEREST CHARGE' },
138
+ { pattern: /PURCHASE INTEREST/i, category: 'FEES', provider: 'INTEREST CHARGE' },
139
+ { pattern: /PLAN FEE/i, category: 'FEES', provider: 'PLAN FEE' },
140
+ { pattern: /FEE_TRANSACTION/i, category: 'FEES', provider: 'BANK FEE' },
141
+ { pattern: /STATEMENT CREDIT/i, category: 'REFUND', provider: 'STATEMENT CREDIT' },
142
+ { pattern: /AUTOMATIC STATEMENT CREDIT/i, category: 'REFUND', provider: 'STATEMENT CREDIT' },
143
+ // P2P platforms
144
+ { pattern: /VENMO\s+(?:PAYMENT|CASHOUT)/i, category: 'P2P', provider: 'VENMO' },
145
+ { pattern: /CASH APP/i, category: 'P2P', provider: 'CASH APP' },
146
+ { pattern: /APPLE CASH/i, category: 'P2P', provider: 'APPLE CASH' },
147
+ ];
148
+ function detectTransferPattern(description) {
149
+ for (const { pattern, category, provider } of TRANSFER_PATTERNS) {
150
+ const match = description.match(pattern);
151
+ if (match) {
152
+ let resolvedProvider = provider;
153
+ if (provider === 'extract_name' && match[1]) {
154
+ resolvedProvider = match[1].trim().toUpperCase().replace(/\s+[A-Z0-9]{8,}$/, '');
155
+ }
156
+ return {
157
+ provider: resolvedProvider,
158
+ category,
159
+ confidence: 1.0,
160
+ matchType: 'PATTERN',
161
+ matchedPattern: pattern.source,
162
+ };
163
+ }
164
+ }
165
+ return null;
166
+ }
167
+ // =============================================================================
168
+ // INSTITUTION CATEGORY MAPS (fallback inference)
169
+ // =============================================================================
170
+ const CATEGORY_MAPS = {
171
+ chase_credit: {
172
+ 'Food & Drink': 'DINING', 'Shopping': 'RETAIL', 'Groceries': 'GROCERIES',
173
+ 'Gas': 'TRANSPORTATION', 'Travel': 'TRAVEL', 'Entertainment': 'ENTERTAINMENT',
174
+ 'Automotive': 'TRANSPORTATION', 'Health & Wellness': 'HEALTH', 'Home': 'RETAIL',
175
+ 'Bills & Utilities': 'UTILITIES', 'Personal': 'RETAIL',
176
+ 'Fees & Adjustments': 'FINANCIAL', 'Professional Services': 'SERVICE',
177
+ },
178
+ amex: {
179
+ 'Restaurant-Bar & Cafe': 'DINING', 'Restaurant-Restaurant': 'DINING',
180
+ 'RESTAURANT': 'DINING', 'Merchandise & Supplies-Internet Purchase': 'RETAIL',
181
+ 'Transportation-Taxis & Coach': 'TRANSPORTATION',
182
+ 'Merchandise & Supplies-Groceries': 'GROCERIES',
183
+ 'Entertainment-Theatrical Events': 'ENTERTAINMENT',
184
+ 'FAST FOOD RESTAURANT': 'FAST FOOD', 'FAST FOOD': 'FAST FOOD',
185
+ 'MERCHANDISE': 'RETAIL',
186
+ },
187
+ discover: {
188
+ 'Restaurants': 'DINING', 'Merchandise': 'RETAIL', 'Services': 'SERVICE',
189
+ 'Supermarkets': 'GROCERIES', 'Gas Stations': 'TRANSPORTATION',
190
+ 'Travel/ Entertainment': 'ENTERTAINMENT', 'Payments and Credits': 'CREDIT CARD',
191
+ 'Interest': 'FEES', 'Awards and Rebate Credits': 'REFUND',
192
+ },
193
+ };
194
+ function inferCategoryFromSource(originalCategory, institution) {
195
+ if (!originalCategory)
196
+ return null;
197
+ const normalized = originalCategory.trim();
198
+ const mapping = CATEGORY_MAPS[institution.toLowerCase()];
199
+ if (!mapping)
200
+ return null;
201
+ if (normalized in mapping)
202
+ return mapping[normalized];
203
+ const lower = normalized.toLowerCase();
204
+ for (const [key, value] of Object.entries(mapping)) {
205
+ if (key.toLowerCase() === lower)
206
+ return value;
207
+ }
208
+ return null;
209
+ }
210
+ // =============================================================================
211
+ // STAMP MATCHER CLASS
212
+ // =============================================================================
213
+ const FUZZY_THRESHOLD = 0.6;
214
+ const AUTO_CONFIRM_THRESHOLD = 0.9;
215
+ class StampMatcher {
216
+ providers = new Map();
217
+ learnedPatterns = new Map();
218
+ userOverrides = new Map(); // provider -> category
219
+ loadProviders(providers) {
220
+ this.providers.clear();
221
+ for (const p of providers) {
222
+ this.providers.set(p.name, p);
223
+ for (const alias of p.aliases || []) {
224
+ if (!this.providers.has(alias))
225
+ this.providers.set(alias, p);
226
+ }
227
+ }
228
+ }
229
+ loadLearnedPatterns(patterns) {
230
+ this.learnedPatterns.clear();
231
+ for (const p of patterns)
232
+ this.learnedPatterns.set(p.descriptionHash, p);
233
+ }
234
+ loadUserOverrides(overrides) {
235
+ this.userOverrides.clear();
236
+ for (const o of overrides)
237
+ this.userOverrides.set(o.providerName, o.category);
238
+ }
239
+ getCategoryForProvider(providerName) {
240
+ const user = this.userOverrides.get(providerName);
241
+ if (user)
242
+ return user;
243
+ return this.providers.get(providerName)?.category ?? null;
244
+ }
245
+ getProviderCount() {
246
+ return new Set(Array.from(this.providers.values()).map((p) => p.name)).size;
247
+ }
248
+ getLearnedPatternCount() {
249
+ return this.learnedPatterns.size;
250
+ }
251
+ // ---- MAIN MATCHING PIPELINE ----
252
+ match(description, originalCategory, institution) {
253
+ // Stage 1: Pattern
254
+ const patternResult = detectTransferPattern(description);
255
+ if (patternResult)
256
+ return patternResult;
257
+ // Stage 2: Learned
258
+ const learnedResult = this.matchLearned(description);
259
+ if (learnedResult)
260
+ return learnedResult;
261
+ // Stage 3: Exact
262
+ const exactResult = this.matchExact(description);
263
+ if (exactResult)
264
+ return exactResult;
265
+ // Stage 4: Fuzzy
266
+ const fuzzyResult = this.matchFuzzy(description);
267
+ if (fuzzyResult)
268
+ return fuzzyResult;
269
+ // Fallback: Category inference
270
+ if (originalCategory && institution) {
271
+ const inferred = inferCategoryFromSource(originalCategory, institution);
272
+ if (inferred) {
273
+ return { provider: null, category: inferred, confidence: 0.5, matchType: 'CATEGORY_INFER' };
274
+ }
275
+ }
276
+ return { provider: null, category: null, confidence: 0, matchType: 'NONE' };
277
+ }
278
+ matchLearned(description) {
279
+ const hash = createDescriptionHash(description);
280
+ const learned = this.learnedPatterns.get(hash);
281
+ if (!learned)
282
+ return null;
283
+ const confidence = Math.min(0.99, 0.8 + learned.weight * 0.05);
284
+ return {
285
+ provider: learned.providerName,
286
+ category: learned.category,
287
+ confidence,
288
+ matchType: 'LEARNED',
289
+ };
290
+ }
291
+ matchExact(description) {
292
+ const upper = description.toUpperCase();
293
+ const sorted = Array.from(this.providers.values())
294
+ .filter((p, i, arr) => arr.findIndex((x) => x.name === p.name) === i)
295
+ .sort((a, b) => b.name.length - a.name.length);
296
+ for (const provider of sorted) {
297
+ for (const variant of generateProviderVariants(provider.name)) {
298
+ if (upper.includes(variant)) {
299
+ return {
300
+ provider: provider.name,
301
+ category: this.getCategoryForProvider(provider.name),
302
+ confidence: 1.0,
303
+ matchType: 'EXACT',
304
+ };
305
+ }
306
+ }
307
+ }
308
+ return null;
309
+ }
310
+ matchFuzzy(description) {
311
+ const descTokens = new Set(extractMerchantTokens(description));
312
+ if (descTokens.size === 0)
313
+ return null;
314
+ let bestMatch = null;
315
+ let bestScore = 0;
316
+ const unique = Array.from(new Map(Array.from(this.providers.values()).map((p) => [p.name, p])).values());
317
+ for (const provider of unique) {
318
+ const provTokens = new Set(provider.name.split(/\s+/));
319
+ if (provTokens.size === 0)
320
+ continue;
321
+ const intersection = new Set([...descTokens].filter((t) => provTokens.has(t)));
322
+ const tokenScore = intersection.size / provTokens.size;
323
+ const substringBonus = description.toUpperCase().includes(provider.name) ? 0.3 : 0;
324
+ const score = Math.min(1.0, tokenScore + substringBonus);
325
+ if (score > bestScore && score >= FUZZY_THRESHOLD) {
326
+ bestScore = score;
327
+ bestMatch = provider;
328
+ }
329
+ }
330
+ if (bestMatch) {
331
+ const confidence = Math.min(0.95, 0.6 + bestScore * 0.35);
332
+ return {
333
+ provider: bestMatch.name,
334
+ category: this.getCategoryForProvider(bestMatch.name),
335
+ confidence,
336
+ matchType: 'FUZZY',
337
+ };
338
+ }
339
+ return null;
340
+ }
341
+ }
342
+ // =============================================================================
343
+ // DATABASE QUERIES
344
+ // =============================================================================
345
+ async function fetchProviders() {
346
+ const supabase = getSupabase('optimal');
347
+ const { data, error } = await supabase
348
+ .from('providers')
349
+ .select('*')
350
+ .order('usage_count', { ascending: false });
351
+ if (error) {
352
+ console.error('Error fetching providers:', error.message);
353
+ return [];
354
+ }
355
+ return (data || []).map((row) => ({
356
+ id: row.id,
357
+ name: row.name,
358
+ category: row.category,
359
+ aliases: (row.aliases ?? []),
360
+ source: row.source,
361
+ usageCount: row.usage_count,
362
+ }));
363
+ }
364
+ async function fetchLearnedPatterns(userId) {
365
+ const supabase = getSupabase('optimal');
366
+ let query = supabase
367
+ .from('learned_patterns')
368
+ .select('*')
369
+ .order('weight', { ascending: false });
370
+ if (userId) {
371
+ query = query.or(`scope.eq.global,user_id.eq.${userId}`);
372
+ }
373
+ else {
374
+ query = query.eq('scope', 'global');
375
+ }
376
+ const { data, error } = await query;
377
+ if (error) {
378
+ console.error('Error fetching learned patterns:', error.message);
379
+ return [];
380
+ }
381
+ return (data || []).map((row) => ({
382
+ descriptionHash: row.description_hash,
383
+ providerName: row.provider_name,
384
+ category: row.category,
385
+ weight: row.weight,
386
+ }));
387
+ }
388
+ async function fetchUserOverrides(userId) {
389
+ const supabase = getSupabase('optimal');
390
+ const { data, error } = await supabase
391
+ .from('user_provider_overrides')
392
+ .select('*')
393
+ .eq('user_id', userId);
394
+ if (error) {
395
+ console.error('Error fetching user overrides:', error.message);
396
+ return [];
397
+ }
398
+ return (data || []).map((row) => ({
399
+ providerName: row.provider_name,
400
+ category: row.category,
401
+ }));
402
+ }
403
+ async function initializeMatcher(userId) {
404
+ const matcher = new StampMatcher();
405
+ const [providers, patterns, overrides] = await Promise.all([
406
+ fetchProviders(),
407
+ fetchLearnedPatterns(userId),
408
+ fetchUserOverrides(userId),
409
+ ]);
410
+ matcher.loadProviders(providers);
411
+ matcher.loadLearnedPatterns(patterns);
412
+ if (overrides.length > 0)
413
+ matcher.loadUserOverrides(overrides);
414
+ return matcher;
415
+ }
416
+ // =============================================================================
417
+ // MAIN STAMP FUNCTION
418
+ // =============================================================================
419
+ /**
420
+ * Stamp (auto-categorize) unclassified transactions for a user.
421
+ *
422
+ * 1. Fetch unclassified transactions (provider IS NULL or category_id IS NULL)
423
+ * 2. Load matching rules from providers, learned_patterns, user_provider_overrides
424
+ * 3. Run 4-stage matching on each transaction
425
+ * 4. Update matched transactions with provider + category_id
426
+ *
427
+ * @param userId Supabase user UUID
428
+ * @param options dryRun=true to preview without writing
429
+ * @returns counts of stamped, unmatched, and total
430
+ */
431
+ export async function stampTransactions(userId, options) {
432
+ const supabase = getSupabase('optimal');
433
+ const dryRun = options?.dryRun ?? false;
434
+ // 1. Initialize matcher with DB data
435
+ const matcher = await initializeMatcher(userId);
436
+ console.log(`Matcher loaded: ${matcher.getProviderCount()} providers, ${matcher.getLearnedPatternCount()} learned patterns`);
437
+ // 2. Fetch unclassified transactions
438
+ const { data: txns, error: txnError } = await supabase
439
+ .from('transactions')
440
+ .select('id, description, amount, type, date, category_id, provider')
441
+ .eq('user_id', userId)
442
+ .or('provider.is.null,category_id.is.null')
443
+ .order('date', { ascending: false });
444
+ if (txnError) {
445
+ throw new Error(`Failed to fetch transactions: ${txnError.message}`);
446
+ }
447
+ if (!txns || txns.length === 0) {
448
+ return { stamped: 0, unmatched: 0, total: 0, byMatchType: emptyMatchTypeCounts(), dryRun };
449
+ }
450
+ // 3. Fetch stamp_categories for mapping category name -> id
451
+ const { data: stampCategories } = await supabase
452
+ .from('stamp_categories')
453
+ .select('id, name');
454
+ const categoryNameToId = new Map();
455
+ if (stampCategories) {
456
+ for (const sc of stampCategories) {
457
+ categoryNameToId.set(sc.name.toUpperCase(), sc.id);
458
+ }
459
+ }
460
+ // Also fetch user categories for mapping
461
+ const { data: userCategories } = await supabase
462
+ .from('categories')
463
+ .select('id, name')
464
+ .eq('user_id', userId);
465
+ const userCategoryNameToId = new Map();
466
+ if (userCategories) {
467
+ for (const uc of userCategories) {
468
+ userCategoryNameToId.set(uc.name.toUpperCase(), uc.id);
469
+ }
470
+ }
471
+ // 4. Match each transaction
472
+ const byMatchType = emptyMatchTypeCounts();
473
+ let stampedCount = 0;
474
+ let unmatchedCount = 0;
475
+ for (const txn of txns) {
476
+ const result = matcher.match(txn.description, null, // originalCategory not stored on existing rows
477
+ undefined);
478
+ byMatchType[result.matchType]++;
479
+ if (result.matchType === 'NONE') {
480
+ unmatchedCount++;
481
+ continue;
482
+ }
483
+ stampedCount++;
484
+ if (!dryRun && result.category) {
485
+ // Find category_id — try stamp_categories first, then user categories
486
+ let categoryId = null;
487
+ const upperCat = result.category.toUpperCase();
488
+ categoryId = categoryNameToId.get(upperCat) ?? null;
489
+ if (categoryId === null) {
490
+ categoryId = userCategoryNameToId.get(upperCat) ?? null;
491
+ }
492
+ const updatePayload = {
493
+ provider: result.provider,
494
+ provider_method: result.matchType,
495
+ provider_confidence: result.confidence,
496
+ provider_inferred_at: new Date().toISOString(),
497
+ };
498
+ if (categoryId !== null) {
499
+ updatePayload.category_id = categoryId;
500
+ }
501
+ await supabase
502
+ .from('transactions')
503
+ .update(updatePayload)
504
+ .eq('id', txn.id);
505
+ }
506
+ }
507
+ return {
508
+ stamped: stampedCount,
509
+ unmatched: unmatchedCount,
510
+ total: txns.length,
511
+ byMatchType,
512
+ dryRun,
513
+ };
514
+ }
515
+ function emptyMatchTypeCounts() {
516
+ return {
517
+ PATTERN: 0,
518
+ LEARNED: 0,
519
+ EXACT: 0,
520
+ FUZZY: 0,
521
+ CATEGORY_INFER: 0,
522
+ NONE: 0,
523
+ };
524
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "optimal-cli",
3
+ "version": "0.1.0",
4
+ "description": "Optimal CLI — unified command-line interface for agent config sync, financial analytics, content management, and infrastructure",
5
+ "type": "module",
6
+ "main": "./dist/bin/optimal.js",
7
+ "bin": {
8
+ "optimal": "./dist/bin/optimal.js"
9
+ },
10
+ "files": [
11
+ "dist/**/*",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "dev": "tsx watch bin/optimal.ts",
18
+ "lint": "tsc --noEmit",
19
+ "prepublishOnly": "pnpm build"
20
+ },
21
+ "keywords": [
22
+ "cli",
23
+ "optimal",
24
+ "openclaw",
25
+ "supabase",
26
+ "agent-config",
27
+ "automation"
28
+ ],
29
+ "author": "Carlos Lenis <clenis@optimaltech.ai>",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/clenisa/optimal-cli.git"
34
+ },
35
+ "engines": {
36
+ "node": ">=20.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.0.0",
40
+ "tsx": "^4.0.0",
41
+ "typescript": "^5.7.0"
42
+ },
43
+ "dependencies": {
44
+ "@supabase/supabase-js": "^2.49.0",
45
+ "commander": "^13.0.0",
46
+ "dotenv": "^16.4.0",
47
+ "exceljs": "^4.4.0",
48
+ "playwright": "^1.58.2"
49
+ }
50
+ }