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.
- package/README.md +175 -0
- package/dist/bin/optimal.d.ts +2 -0
- package/dist/bin/optimal.js +995 -0
- package/dist/lib/budget/projections.d.ts +115 -0
- package/dist/lib/budget/projections.js +384 -0
- package/dist/lib/budget/scenarios.d.ts +93 -0
- package/dist/lib/budget/scenarios.js +214 -0
- package/dist/lib/cms/publish-blog.d.ts +62 -0
- package/dist/lib/cms/publish-blog.js +74 -0
- package/dist/lib/cms/strapi-client.d.ts +123 -0
- package/dist/lib/cms/strapi-client.js +213 -0
- package/dist/lib/config.d.ts +55 -0
- package/dist/lib/config.js +206 -0
- package/dist/lib/infra/deploy.d.ts +29 -0
- package/dist/lib/infra/deploy.js +58 -0
- package/dist/lib/infra/migrate.d.ts +34 -0
- package/dist/lib/infra/migrate.js +103 -0
- package/dist/lib/kanban.d.ts +46 -0
- package/dist/lib/kanban.js +118 -0
- package/dist/lib/newsletter/distribute.d.ts +52 -0
- package/dist/lib/newsletter/distribute.js +193 -0
- package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
- package/dist/lib/newsletter/generate-insurance.js +36 -0
- package/dist/lib/newsletter/generate.d.ts +104 -0
- package/dist/lib/newsletter/generate.js +571 -0
- package/dist/lib/returnpro/anomalies.d.ts +64 -0
- package/dist/lib/returnpro/anomalies.js +166 -0
- package/dist/lib/returnpro/audit.d.ts +32 -0
- package/dist/lib/returnpro/audit.js +147 -0
- package/dist/lib/returnpro/diagnose.d.ts +52 -0
- package/dist/lib/returnpro/diagnose.js +281 -0
- package/dist/lib/returnpro/kpis.d.ts +32 -0
- package/dist/lib/returnpro/kpis.js +192 -0
- package/dist/lib/returnpro/templates.d.ts +48 -0
- package/dist/lib/returnpro/templates.js +229 -0
- package/dist/lib/returnpro/upload-income.d.ts +25 -0
- package/dist/lib/returnpro/upload-income.js +235 -0
- package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
- package/dist/lib/returnpro/upload-netsuite.js +566 -0
- package/dist/lib/returnpro/upload-r1.d.ts +48 -0
- package/dist/lib/returnpro/upload-r1.js +398 -0
- package/dist/lib/social/post-generator.d.ts +83 -0
- package/dist/lib/social/post-generator.js +333 -0
- package/dist/lib/social/publish.d.ts +66 -0
- package/dist/lib/social/publish.js +226 -0
- package/dist/lib/social/scraper.d.ts +67 -0
- package/dist/lib/social/scraper.js +361 -0
- package/dist/lib/supabase.d.ts +4 -0
- package/dist/lib/supabase.js +20 -0
- package/dist/lib/transactions/delete-batch.d.ts +60 -0
- package/dist/lib/transactions/delete-batch.js +203 -0
- package/dist/lib/transactions/ingest.d.ts +43 -0
- package/dist/lib/transactions/ingest.js +555 -0
- package/dist/lib/transactions/stamp.d.ts +51 -0
- package/dist/lib/transactions/stamp.js +524 -0
- 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
|
+
}
|