Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/src/controllers/CategoriesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ export class CategoriesController {
res.status(201).json(result);
};

suggestCategories = async (req: Request, res: Response): Promise<void> => {
const suggestions = this.service.suggestCategories(req.body ?? {});
res.json({ suggestions });
};

removeAssignment = async (req: Request, res: Response): Promise<void> => {
await this.service.removeAssignment(req.params.paymentId, req.params.categoryId);
res.status(204).end();
Expand Down
1 change: 1 addition & 0 deletions backend/src/routes/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
39 changes: 13 additions & 26 deletions backend/src/services/__tests__/categories.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
62 changes: 50 additions & 12 deletions backend/src/services/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }) => boolean;
export interface CategoryScore {
category: CategoryType;
confidence: number;
}

type PaymentSignal = { type?: string; network?: string; metadata?: Record<string, unknown> };

// 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<string, unknown> | undefined)?.subscriptionId === 'string', category: 'subscription' },
{ match: (p) => typeof (p.metadata as Record<string, unknown> | undefined)?.invoiceId === 'string', category: 'invoice' },
{ match: (p) => (p.metadata as Record<string, unknown> | 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<string, unknown> | undefined)?.subscriptionId === 'string', category: 'subscription', weight: 0.9 },
{ match: (p) => typeof (p.metadata as Record<string, unknown> | undefined)?.invoiceId === 'string', category: 'invoice', weight: 0.9 },
{ match: (p) => (p.metadata as Record<string, unknown> | undefined)?.isDonation === true, category: 'donation', weight: 0.95 },
];

export function inferCategory(payment: { type?: string; network?: string; metadata?: Record<string, unknown> }): 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<CategoryType, number>();
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 {
Expand Down Expand Up @@ -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.
Expand Down
Loading