From 60e4ce9ffcbf29f25ffe1871dd00e6c684d61d75 Mon Sep 17 00:00:00 2001 From: ExcelDsigN-tech Date: Sat, 26 Sep 2026 19:44:38 +0100 Subject: [PATCH] feat(categories): add confidence-scored transaction categorization (#963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the first-match rule list with weighted signals so a payment scores against every matching category and callers get a confidence value to threshold on. Exposes it as a non-persisting suggestion endpoint (POST /categories/payments/:paymentId/suggest) so callers can preview/override before assigning. Heuristic weighted-rule scorer, not a trained model — no labeled categorization data exists yet to train one. #961 (notification preferences) and #962 (onboarding checklist wizard) were already fully implemented; #964 (merchant dashboard with real-time metrics) already has a working push pipeline (30s interval broadcast over the analytics.updates WS channel, consumed by frontend/lib/analytics/realtime.ts on both dashboard pages), so no further scaffolding is added for those. Co-Authored-By: Claude Sonnet 5 --- .../src/controllers/CategoriesController.ts | 5 ++ backend/src/routes/categories.ts | 1 + .../src/services/__tests__/categories.test.ts | 39 ++++-------- backend/src/services/categories.ts | 62 +++++++++++++++---- 4 files changed, 69 insertions(+), 38 deletions(-) diff --git a/backend/src/controllers/CategoriesController.ts b/backend/src/controllers/CategoriesController.ts index dbfe6c83..f4f7a319 100644 --- a/backend/src/controllers/CategoriesController.ts +++ b/backend/src/controllers/CategoriesController.ts @@ -72,6 +72,11 @@ export class CategoriesController { res.status(201).json(result); }; + suggestCategories = async (req: Request, res: Response): Promise => { + const suggestions = this.service.suggestCategories(req.body ?? {}); + res.json({ suggestions }); + }; + removeAssignment = async (req: Request, res: Response): Promise => { await this.service.removeAssignment(req.params.paymentId, req.params.categoryId); res.status(204).end(); diff --git a/backend/src/routes/categories.ts b/backend/src/routes/categories.ts index dbfbde35..2916aec4 100644 --- a/backend/src/routes/categories.ts +++ b/backend/src/routes/categories.ts @@ -24,6 +24,7 @@ categoriesRouter.delete('/:id', asyncHandler(categoriesController.deleteCategory categoriesRouter.post('/payments/:paymentId/assign', asyncHandler(categoriesController.assignCategory)); categoriesRouter.post('/payments/:paymentId/auto-assign', asyncHandler(categoriesController.autoAssignCategory)); +categoriesRouter.post('/payments/:paymentId/suggest', asyncHandler(categoriesController.suggestCategories)); categoriesRouter.delete('/payments/:paymentId/assign/:categoryId', asyncHandler(categoriesController.removeAssignment)); categoriesRouter.get('/payments/:paymentId/categories', asyncHandler(categoriesController.getPaymentCategories)); diff --git a/backend/src/services/__tests__/categories.test.ts b/backend/src/services/__tests__/categories.test.ts index d8c59fab..50c858ec 100644 --- a/backend/src/services/__tests__/categories.test.ts +++ b/backend/src/services/__tests__/categories.test.ts @@ -1,36 +1,23 @@ import { describe, it, expect } from 'vitest'; -import { inferCategory } from '../../services/categories.js'; +import { scoreCategories, inferCategory } from '../categories.js'; -describe('inferCategory', () => { - it('returns refund for type=refund', () => { - expect(inferCategory({ type: 'refund' })).toBe('refund'); +describe('scoreCategories', () => { + it('ranks refund highest for a refund payment', () => { + const scores = scoreCategories({ type: 'refund' }); + expect(scores[0]).toEqual({ category: 'refund', confidence: 0.95 }); }); - it('returns milestone for type=milestone_payment', () => { - expect(inferCategory({ type: 'milestone_payment' })).toBe('milestone'); + it('stacks signals for the same category, capped at 1', () => { + const scores = scoreCategories({ type: 'full_payment', network: 'stellar' }); + // strong stellar-escrow signal (0.8) + weak generic full_payment signal (0.3), capped at 1 + expect(scores[0]).toEqual({ category: 'escrow', confidence: 1 }); }); - it('returns escrow for stellar full_payment', () => { - expect(inferCategory({ type: 'full_payment', network: 'stellar' })).toBe('escrow'); + it('falls back to other at full confidence when nothing matches', () => { + expect(scoreCategories({ type: 'unknown_type' })).toEqual([{ category: 'other', confidence: 1 }]); }); - it('returns subscription when metadata.subscriptionId is set', () => { - expect(inferCategory({ metadata: { subscriptionId: 'sub-1' } })).toBe('subscription'); - }); - - it('returns invoice when metadata.invoiceId is set', () => { - expect(inferCategory({ metadata: { invoiceId: 'inv-1' } })).toBe('invoice'); - }); - - it('returns donation when metadata.isDonation=true', () => { - expect(inferCategory({ metadata: { isDonation: true } })).toBe('donation'); - }); - - it('returns other when no rule matches', () => { - expect(inferCategory({})).toBe('other'); - }); - - it('refund rule takes priority over subscription metadata', () => { - expect(inferCategory({ type: 'refund', metadata: { subscriptionId: 's1' } })).toBe('refund'); + it('inferCategory returns the top-scored category', () => { + expect(inferCategory({ metadata: { invoiceId: 'inv_1' } })).toBe('invoice'); }); }); diff --git a/backend/src/services/categories.ts b/backend/src/services/categories.ts index 5a0a779e..01116bb1 100644 --- a/backend/src/services/categories.ts +++ b/backend/src/services/categories.ts @@ -9,23 +9,53 @@ import type { PaymentCategoryType as PrismaCategoryType } from '@prisma/client'; export type CategoryType = 'subscription' | 'invoice' | 'donation' | 'refund' | 'escrow' | 'milestone' | 'other'; -const AUTO_RULES: Array<{ - match: (p: { type?: string; network?: string; metadata?: Record }) => boolean; +export interface CategoryScore { category: CategoryType; + confidence: number; +} + +type PaymentSignal = { type?: string; network?: string; metadata?: Record }; + +// Issue #963: weighted signals replace the old first-match rule list, so a +// payment can score against several categories at once and callers get a +// confidence they can threshold on. +// ponytail: heuristic weighted-rule scorer, not a trained model — upgrade to +// a real classifier once labeled categorization corrections accumulate. +const CATEGORY_SIGNALS: Array<{ + match: (p: PaymentSignal) => boolean; + category: CategoryType; + weight: number; }> = [ - { match: (p) => p.type === 'refund', category: 'refund' }, - { match: (p) => p.type === 'milestone_payment', category: 'milestone' }, - { match: (p) => p.type === 'full_payment' && p.network === 'stellar', category: 'escrow' }, - { match: (p) => typeof (p.metadata as Record | undefined)?.subscriptionId === 'string', category: 'subscription' }, - { match: (p) => typeof (p.metadata as Record | undefined)?.invoiceId === 'string', category: 'invoice' }, - { match: (p) => (p.metadata as Record | undefined)?.isDonation === true, category: 'donation' }, + { match: (p) => p.type === 'refund', category: 'refund', weight: 0.95 }, + { match: (p) => p.type === 'milestone_payment', category: 'milestone', weight: 0.95 }, + { match: (p) => p.type === 'full_payment' && p.network === 'stellar', category: 'escrow', weight: 0.8 }, + { match: (p) => p.type === 'full_payment', category: 'escrow', weight: 0.3 }, + { match: (p) => typeof (p.metadata as Record | undefined)?.subscriptionId === 'string', category: 'subscription', weight: 0.9 }, + { match: (p) => typeof (p.metadata as Record | undefined)?.invoiceId === 'string', category: 'invoice', weight: 0.9 }, + { match: (p) => (p.metadata as Record | undefined)?.isDonation === true, category: 'donation', weight: 0.95 }, ]; -export function inferCategory(payment: { type?: string; network?: string; metadata?: Record }): CategoryType { - for (const rule of AUTO_RULES) { - if (rule.match(payment)) return rule.category; +/** + * Scores every category against the payment's signals and returns them + * ranked highest-confidence first. Multiple signals for the same category + * stack (capped at 1); no signals firing means 'other' at full confidence. + */ +export function scoreCategories(payment: PaymentSignal): CategoryScore[] { + const scores = new Map(); + for (const signal of CATEGORY_SIGNALS) { + if (!signal.match(payment)) continue; + scores.set(signal.category, Math.min(1, (scores.get(signal.category) ?? 0) + signal.weight)); } - return 'other'; + + if (scores.size === 0) return [{ category: 'other', confidence: 1 }]; + + return [...scores.entries()] + .map(([category, confidence]) => ({ category, confidence })) + .sort((a, b) => b.confidence - a.confidence); +} + +export function inferCategory(payment: PaymentSignal): CategoryType { + return scoreCategories(payment)[0].category; } export class CategoriesService { @@ -67,6 +97,14 @@ export class CategoriesService { return this.repo.findAssignmentsForPayment(paymentId); } + /** + * Ranks candidate categories for a payment with confidence scores, without + * persisting anything. Lets callers preview/override before assigning. + */ + suggestCategories(payment: PaymentSignal): CategoryScore[] { + return scoreCategories(payment); + } + /** * Auto-assign a category to a payment based on rules. * Creates default category for tenant if needed.