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