feat: existing bill matching in recommendations, feedback tracking, broad-merchant rejection, annual price detection
This commit is contained in:
parent
422d8550bb
commit
a1e6a308cf
|
|
@ -229,12 +229,23 @@ export const api = {
|
|||
// Subscriptions
|
||||
subscriptions: () => get('/subscriptions'),
|
||||
confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
|
||||
matchRecommendationToBill: (transactionIds, billId, merchant) => post('/subscriptions/recommendations/match-bill', { transaction_ids: transactionIds, bill_id: billId, merchant }),
|
||||
matchRecommendationToBill: (transactionIds, billId, merchant, catalogId, confidence) => post('/subscriptions/recommendations/match-bill', {
|
||||
transaction_ids: transactionIds,
|
||||
bill_id: billId,
|
||||
merchant,
|
||||
catalog_id: catalogId,
|
||||
confidence,
|
||||
}),
|
||||
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
|
||||
subscriptionTransactionMatches: (params = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`),
|
||||
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
||||
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
|
||||
declineRecommendation: (declineKey) => post('/subscriptions/recommendations/decline', { decline_key: declineKey }),
|
||||
declineRecommendation: (recommendation) => post('/subscriptions/recommendations/decline', {
|
||||
decline_key: recommendation?.decline_key || recommendation,
|
||||
catalog_id: recommendation?.catalog_match?.id,
|
||||
merchant: recommendation?.merchant,
|
||||
confidence: recommendation?.confidence,
|
||||
}),
|
||||
subscriptionCatalog: () => get('/subscriptions/catalog'),
|
||||
updateSubscriptionCatalogLink:(id, catalogId) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }),
|
||||
addCatalogDescriptor: (catalogId, d) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }),
|
||||
|
|
|
|||
|
|
@ -285,12 +285,13 @@ function TxResultRow({ tx, onTrack }) {
|
|||
);
|
||||
}
|
||||
|
||||
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onDetails, busy }) {
|
||||
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }) {
|
||||
const identity = recommendation.evidence?.identity;
|
||||
const amount = recommendation.evidence?.amount;
|
||||
const cadence = recommendation.evidence?.cadence;
|
||||
const amountRange = recommendation.evidence?.amount_range;
|
||||
const ambiguity = recommendation.evidence?.ambiguity;
|
||||
const existingBill = recommendation.existing_bill_match;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
||||
|
|
@ -314,6 +315,11 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
{recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{existingBill && (
|
||||
<p className="mt-1 truncate text-xs font-semibold text-primary">
|
||||
Link to existing bill: {existingBill.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="tracker-number text-base font-semibold text-foreground">{fmt(recommendation.expected_amount)}</p>
|
||||
|
|
@ -356,6 +362,11 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
Review
|
||||
</Badge>
|
||||
)}
|
||||
{existingBill && (
|
||||
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
|
||||
Existing bill
|
||||
</Badge>
|
||||
)}
|
||||
{amountRange && amountRange.min !== amountRange.max && (
|
||||
<span className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
Range {fmt(amountRange.min)}-{fmt(amountRange.max)}
|
||||
|
|
@ -394,23 +405,24 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant={existingBill ? 'default' : 'outline'}
|
||||
className="gap-1.5"
|
||||
disabled={busy}
|
||||
onClick={() => onMatch(recommendation)}
|
||||
onClick={() => existingBill ? onQuickLink(recommendation, existingBill.id) : onMatch(recommendation)}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
Link to bill
|
||||
{existingBill ? 'Link existing' : 'Link to bill'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={existingBill ? 'outline' : 'default'}
|
||||
className="gap-2"
|
||||
disabled={busy}
|
||||
onClick={() => onAccept({ ...recommendation, category_id: categoryId })}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
Track
|
||||
{existingBill ? 'Track new' : 'Track'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -433,7 +445,7 @@ function EvidenceItem({ label, value, tone = 'default' }) {
|
|||
);
|
||||
}
|
||||
|
||||
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, busy }) {
|
||||
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, onQuickLink, busy }) {
|
||||
if (!recommendation) return null;
|
||||
|
||||
const identity = recommendation.evidence?.identity;
|
||||
|
|
@ -441,6 +453,7 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
const cadence = recommendation.evidence?.cadence;
|
||||
const amountRange = recommendation.evidence?.amount_range;
|
||||
const ambiguity = recommendation.evidence?.ambiguity;
|
||||
const existingBill = recommendation.existing_bill_match;
|
||||
const transactions = recommendation.transactions || [];
|
||||
const handleAccept = async () => {
|
||||
await onAccept({ ...recommendation, category_id: categoryId });
|
||||
|
|
@ -454,6 +467,11 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
onMatch(recommendation);
|
||||
onClose();
|
||||
};
|
||||
const handleQuickLink = async () => {
|
||||
if (!existingBill) return;
|
||||
await onQuickLink(recommendation, existingBill.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
|
||||
|
|
@ -470,6 +488,11 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
Review
|
||||
</Badge>
|
||||
)}
|
||||
{existingBill && (
|
||||
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
|
||||
Existing bill
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · last seen {fmtDate(recommendation.last_seen_date)}
|
||||
|
|
@ -518,6 +541,22 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
</div>
|
||||
)}
|
||||
|
||||
{existingBill && (
|
||||
<div className="rounded-lg border border-primary/25 bg-primary/10 px-3 py-2">
|
||||
<p className="text-sm font-semibold text-primary">Recommended action: link existing bill</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{existingBill.name} · {existingBill.expected_amount ? fmt(existingBill.expected_amount) : 'No amount'} · due day {existingBill.due_day || 'not set'}
|
||||
</p>
|
||||
{existingBill.reasons?.length > 0 && (
|
||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
{existingBill.reasons.map(reason => (
|
||||
<p key={reason}>{reason}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recommendation.reasons?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recommendation.reasons.map(reason => (
|
||||
|
|
@ -558,13 +597,13 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
Decline
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleMatch} disabled={busy} className="gap-1.5">
|
||||
<Button variant={existingBill ? 'default' : 'outline'} onClick={existingBill ? handleQuickLink : handleMatch} disabled={busy} className="gap-1.5">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
Link to bill
|
||||
{existingBill ? 'Link existing' : 'Link to bill'}
|
||||
</Button>
|
||||
<Button onClick={handleAccept} disabled={busy} className="gap-2">
|
||||
<Button variant={existingBill ? 'outline' : 'default'} onClick={handleAccept} disabled={busy} className="gap-2">
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
Track
|
||||
{existingBill ? 'Track new' : 'Track'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -689,7 +728,7 @@ export default function SubscriptionsPage() {
|
|||
if (!recommendation.decline_key) return;
|
||||
setBusyId(`dec-${recommendation.id}`);
|
||||
try {
|
||||
await api.declineRecommendation(recommendation.decline_key);
|
||||
await api.declineRecommendation(recommendation);
|
||||
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Could not dismiss recommendation.');
|
||||
|
|
@ -698,14 +737,20 @@ export default function SubscriptionsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function matchRecommendationToBill(billId) {
|
||||
if (!matchTarget || !billId) return;
|
||||
setBusyId(`match-${matchTarget.id}`);
|
||||
async function linkRecommendationToBill(recommendation, billId) {
|
||||
if (!recommendation || !billId) return;
|
||||
setBusyId(`match-${recommendation.id}`);
|
||||
try {
|
||||
const result = await api.matchRecommendationToBill(matchTarget.transaction_ids, billId, matchTarget.merchant);
|
||||
const result = await api.matchRecommendationToBill(
|
||||
recommendation.transaction_ids,
|
||||
billId,
|
||||
recommendation.merchant,
|
||||
recommendation.catalog_match?.id,
|
||||
recommendation.confidence,
|
||||
);
|
||||
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
|
||||
setMatchTarget(null);
|
||||
setRecommendations(prev => prev.filter(r => r.id !== matchTarget.id));
|
||||
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Could not link recommendation to bill.');
|
||||
} finally {
|
||||
|
|
@ -713,6 +758,10 @@ export default function SubscriptionsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function matchRecommendationToBill(billId) {
|
||||
await linkRecommendationToBill(matchTarget, billId);
|
||||
}
|
||||
|
||||
function openManualSubscription() {
|
||||
setModal({
|
||||
bill: null,
|
||||
|
|
@ -992,6 +1041,7 @@ export default function SubscriptionsPage() {
|
|||
onAccept={acceptRecommendation}
|
||||
onDecline={declineRecommendation}
|
||||
onMatch={rec => setMatchTarget(rec)}
|
||||
onQuickLink={linkRecommendationToBill}
|
||||
onDetails={setDetailsTarget}
|
||||
/>
|
||||
))
|
||||
|
|
@ -1098,6 +1148,7 @@ export default function SubscriptionsPage() {
|
|||
onAccept={acceptRecommendation}
|
||||
onDecline={declineRecommendation}
|
||||
onMatch={rec => setMatchTarget(rec)}
|
||||
onQuickLink={linkRecommendationToBill}
|
||||
busy={detailsTarget ? (
|
||||
busyId === `rec-${detailsTarget.id}`
|
||||
|| busyId === `dec-${detailsTarget.id}`
|
||||
|
|
|
|||
|
|
@ -3244,6 +3244,33 @@ function runMigrations() {
|
|||
console.log(`[v0.96] catalog_id added to bills; ${backfilled}/${subBills.length} subscriptions backfilled`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.97',
|
||||
description: 'subscription recommendation feedback: per-user learning signals',
|
||||
run() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS subscription_recommendation_feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
catalog_id INTEGER REFERENCES subscription_catalog(id) ON DELETE SET NULL,
|
||||
bill_id INTEGER REFERENCES bills(id) ON DELETE SET NULL,
|
||||
merchant TEXT,
|
||||
action TEXT NOT NULL,
|
||||
confidence INTEGER,
|
||||
descriptor TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_srf_user_catalog
|
||||
ON subscription_recommendation_feedback(user_id, catalog_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_srf_user_merchant
|
||||
ON subscription_recommendation_feedback(user_id, merchant);
|
||||
CREATE INDEX IF NOT EXISTS idx_srf_user_action
|
||||
ON subscription_recommendation_feedback(user_id, action);
|
||||
`);
|
||||
console.log('[v0.97] subscription recommendation feedback table ensured');
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// ── users: notification columns ───────────────────────────────────────────
|
||||
|
|
@ -3588,6 +3615,12 @@ function getDbPath() {
|
|||
|
||||
// Rollback SQL definitions
|
||||
const ROLLBACK_SQL_MAP = {
|
||||
'v0.97': {
|
||||
description: 'subscription recommendation feedback: per-user learning signals',
|
||||
sql: [
|
||||
'DROP TABLE IF EXISTS subscription_recommendation_feedback',
|
||||
]
|
||||
},
|
||||
'v0.96': {
|
||||
description: 'bills: catalog_id FK; user_catalog_descriptors',
|
||||
sql: [
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const {
|
|||
getSubscriptionSummary,
|
||||
getSubscriptions,
|
||||
monthlyEquivalent,
|
||||
recordSubscriptionFeedback,
|
||||
searchSubscriptionTransactions,
|
||||
} = require('../services/subscriptionService');
|
||||
const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService');
|
||||
|
|
@ -47,7 +48,15 @@ router.post('/recommendations/decline', (req, res) => {
|
|||
return res.status(400).json(standardizeError('decline_key is required', 'VALIDATION_ERROR', 'decline_key'));
|
||||
}
|
||||
try {
|
||||
declineRecommendation(getDb(), req.user.id, decline_key);
|
||||
const db = getDb();
|
||||
declineRecommendation(db, req.user.id, decline_key);
|
||||
recordSubscriptionFeedback(db, req.user.id, {
|
||||
action: 'decline',
|
||||
catalog_id: req.body?.catalog_id,
|
||||
merchant: req.body?.merchant,
|
||||
confidence: req.body?.confidence,
|
||||
metadata: { decline_key },
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json(standardizeError(err.message || 'Failed to decline recommendation', 'DECLINE_ERROR'));
|
||||
|
|
@ -69,8 +78,12 @@ router.post('/recommendations/match-bill', (req, res) => {
|
|||
}
|
||||
|
||||
const db = getDb();
|
||||
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
||||
const bill = db.prepare('SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
const catalogId = parseInt(req.body?.catalog_id, 10);
|
||||
const catalogEntry = Number.isInteger(catalogId) && catalogId > 0
|
||||
? db.prepare('SELECT id FROM subscription_catalog WHERE id = ?').get(catalogId)
|
||||
: null;
|
||||
|
||||
const placeholders = txIds.map(() => '?').join(',');
|
||||
const txRows = db.prepare(`
|
||||
|
|
@ -101,6 +114,20 @@ router.post('/recommendations/match-bill', (req, res) => {
|
|||
// Store merchant rule for ongoing auto-matching on future syncs
|
||||
const merchant = typeof req.body?.merchant === 'string' ? req.body.merchant.trim() : '';
|
||||
if (merchant) addMerchantRule(db, req.user.id, billId, merchant);
|
||||
if (catalogEntry && !bill.catalog_id) {
|
||||
try {
|
||||
db.prepare('UPDATE bills SET catalog_id = ?, updated_at = datetime(\'now\') WHERE id = ? AND user_id = ?')
|
||||
.run(catalogEntry.id, billId, req.user.id);
|
||||
} catch { /* pre-v0.96 */ }
|
||||
}
|
||||
recordSubscriptionFeedback(db, req.user.id, {
|
||||
action: 'link_existing_bill',
|
||||
catalog_id: catalogEntry?.id,
|
||||
bill_id: billId,
|
||||
merchant,
|
||||
confidence: req.body?.confidence,
|
||||
metadata: { transaction_ids: txIds },
|
||||
});
|
||||
|
||||
// Apply rules immediately to catch any unmatched transactions beyond the explicit list
|
||||
const { matched: autoMatched } = applyMerchantRules(db, req.user.id);
|
||||
|
|
@ -124,6 +151,14 @@ router.post('/recommendations/create', (req, res) => {
|
|||
const created = createSubscriptionFromRecommendation(db, req.user.id, req.body || {});
|
||||
// Store merchant rule so future SimpleFIN transactions auto-match this bill
|
||||
if (req.body?.merchant) addMerchantRule(db, req.user.id, created.id, req.body.merchant);
|
||||
recordSubscriptionFeedback(db, req.user.id, {
|
||||
action: 'accept_track_new',
|
||||
catalog_id: req.body?.catalog_match?.id,
|
||||
bill_id: created.id,
|
||||
merchant: req.body?.merchant,
|
||||
confidence: req.body?.confidence,
|
||||
metadata: { transaction_ids: req.body?.transaction_ids || [] },
|
||||
});
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
res.status(err.status || 400).json(standardizeError(err.message || 'Could not create subscription', err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', err.field || null));
|
||||
|
|
@ -201,7 +236,7 @@ router.put('/:id/catalog-link', (req, res) => {
|
|||
}
|
||||
|
||||
const bill = db.prepare(
|
||||
'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
|
||||
'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
|
||||
).get(billId, req.user.id);
|
||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
|
||||
|
|
@ -210,6 +245,12 @@ router.put('/:id/catalog-link', (req, res) => {
|
|||
if (rawCatalogId === null || rawCatalogId === undefined) {
|
||||
db.prepare("UPDATE bills SET catalog_id = NULL, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
||||
.run(billId, req.user.id);
|
||||
recordSubscriptionFeedback(db, req.user.id, {
|
||||
action: 'catalog_unlink',
|
||||
catalog_id: bill.catalog_id,
|
||||
bill_id: billId,
|
||||
merchant: bill.name,
|
||||
});
|
||||
return res.json({ ok: true, catalog_id: null });
|
||||
}
|
||||
|
||||
|
|
@ -222,6 +263,13 @@ router.put('/:id/catalog-link', (req, res) => {
|
|||
|
||||
db.prepare("UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
||||
.run(catalogId, billId, req.user.id);
|
||||
recordSubscriptionFeedback(db, req.user.id, {
|
||||
action: 'catalog_relink',
|
||||
catalog_id: catalogId,
|
||||
bill_id: billId,
|
||||
merchant: bill.name,
|
||||
metadata: { previous_catalog_id: bill.catalog_id || null },
|
||||
});
|
||||
res.json({ ok: true, catalog_id: catalogId });
|
||||
});
|
||||
|
||||
|
|
@ -256,6 +304,12 @@ router.post('/catalog/:catalogId/descriptors', (req, res) => {
|
|||
const result = db.prepare(
|
||||
'INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, ?)'
|
||||
).run(req.user.id, catalogId, descriptor);
|
||||
recordSubscriptionFeedback(db, req.user.id, {
|
||||
action: 'add_descriptor',
|
||||
catalog_id: catalogId,
|
||||
merchant: descriptor,
|
||||
descriptor,
|
||||
});
|
||||
|
||||
res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId });
|
||||
} catch (err) {
|
||||
|
|
@ -272,12 +326,23 @@ router.delete('/catalog/descriptors/:id', (req, res) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const existing = db.prepare(
|
||||
'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?'
|
||||
).get(descriptorId, req.user.id);
|
||||
const result = db.prepare(
|
||||
'DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?'
|
||||
).run(descriptorId, req.user.id);
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json(standardizeError('Descriptor not found', 'NOT_FOUND'));
|
||||
}
|
||||
if (existing) {
|
||||
recordSubscriptionFeedback(db, req.user.id, {
|
||||
action: 'delete_descriptor',
|
||||
catalog_id: existing.catalog_id,
|
||||
merchant: existing.descriptor,
|
||||
descriptor: existing.descriptor,
|
||||
});
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json(standardizeError(err.message || 'Failed to delete descriptor', 'DESCRIPTOR_DELETE_ERROR'));
|
||||
|
|
|
|||
|
|
@ -390,15 +390,20 @@ function ambiguityEvidence({ merchant, catalogEntry, identityInfo, amountInfo, c
|
|||
const descriptorContainsShortTransaction = identityInfo?.containment === 'descriptor_contains_transaction'
|
||||
&& compactMerchant.length > 0
|
||||
&& compactMerchant.length <= 4;
|
||||
const haystack = catalogTextForAmbiguity(merchant, catalogEntry, identityInfo);
|
||||
const tokens = new Set(haystack.split(/\s+/).filter(Boolean));
|
||||
const hit = [...AMBIGUOUS_CATALOG_TERMS.entries()].find(([term]) => tokens.has(term));
|
||||
const descriptorTokens = new Set(normalizeMerchant(identityInfo?.descriptor).split(/\s+/).filter(Boolean));
|
||||
const genericBroadBankDescriptor = identityType === 'bank_descriptor'
|
||||
&& !!hit
|
||||
&& ['bill', 'billing', 'payment', 'payments', 'store', 'mktplace', 'marketplace'].some(term => descriptorTokens.has(term));
|
||||
const weakIdentity = !['user_descriptor', 'bank_descriptor'].includes(identityType)
|
||||
|| descriptorContainsShortTransaction;
|
||||
|| descriptorContainsShortTransaction
|
||||
|| genericBroadBankDescriptor;
|
||||
if (!weakIdentity) {
|
||||
return { ambiguous: false, penalty: 0, label: null, reasons: [] };
|
||||
}
|
||||
|
||||
const haystack = catalogTextForAmbiguity(merchant, catalogEntry, identityInfo);
|
||||
const tokens = new Set(haystack.split(/\s+/).filter(Boolean));
|
||||
const hit = [...AMBIGUOUS_CATALOG_TERMS.entries()].find(([term]) => tokens.has(term));
|
||||
const compactDescriptor = String(identityInfo?.descriptor || '').replace(/\s+/g, '');
|
||||
const shortDescriptor = compactDescriptor.length > 0 && compactDescriptor.length <= 4;
|
||||
|
||||
|
|
@ -410,6 +415,7 @@ function ambiguityEvidence({ merchant, catalogEntry, identityInfo, amountInfo, c
|
|||
const penalty = recurringStrong ? 6 : 16;
|
||||
const reasons = [];
|
||||
if (hit) reasons.push(hit[1]);
|
||||
if (genericBroadBankDescriptor) reasons.push('The bank descriptor is a generic billing label for a broad merchant.');
|
||||
if (shortDescriptor) reasons.push('The matched service text is very short, so false positives are more likely.');
|
||||
if (!cadenceInfo?.recurring) reasons.push('Only one bank transaction supports this recommendation.');
|
||||
if (amountInfo?.match === 'unusual') reasons.push('The amount is outside the catalog price range.');
|
||||
|
|
@ -422,11 +428,36 @@ function ambiguityEvidence({ merchant, catalogEntry, identityInfo, amountInfo, c
|
|||
};
|
||||
}
|
||||
|
||||
function scoreKnownServiceRecommendation({ match, merchant, catalogEntry, amountInfo, cadenceInfo }) {
|
||||
function feedbackEvidence({ feedback, merchant, catalogEntry }) {
|
||||
const catalogKey = catalogEntry?.id ? String(catalogEntry.id) : null;
|
||||
const merchantKey = normalizeMerchant(merchant);
|
||||
const rows = [
|
||||
...(catalogKey ? (feedback.byCatalog.get(catalogKey) || []) : []),
|
||||
...(merchantKey ? (feedback.byMerchant.get(merchantKey) || []) : []),
|
||||
];
|
||||
if (!rows.length) return { score: 0, label: null, positive_count: 0, negative_count: 0 };
|
||||
|
||||
let positive = 0;
|
||||
let negative = 0;
|
||||
for (const row of rows) {
|
||||
if (['accept_track_new', 'link_existing_bill', 'catalog_relink', 'add_descriptor'].includes(row.action)) positive++;
|
||||
if (['decline', 'catalog_unlink', 'delete_descriptor'].includes(row.action)) negative++;
|
||||
}
|
||||
|
||||
const score = Math.max(-18, Math.min(8, positive * 3 - negative * 8));
|
||||
const label = score > 0
|
||||
? 'Boosted by your past subscription matching choices'
|
||||
: score < 0
|
||||
? 'Reduced by your past subscription matching choices'
|
||||
: null;
|
||||
return { score, label, positive_count: positive, negative_count: negative };
|
||||
}
|
||||
|
||||
function scoreKnownServiceRecommendation({ match, merchant, catalogEntry, amountInfo, cadenceInfo, feedbackInfo = null }) {
|
||||
const identity = identityEvidence(match);
|
||||
const ambiguity = ambiguityEvidence({ merchant, catalogEntry, identityInfo: identity, amountInfo, cadenceInfo });
|
||||
const confidence = Math.min(99, Math.max(0,
|
||||
identity.score + amountInfo.score + cadenceInfo.score - ambiguity.penalty
|
||||
identity.score + amountInfo.score + cadenceInfo.score - ambiguity.penalty + (feedbackInfo?.score || 0)
|
||||
));
|
||||
return { confidence, identity, ambiguity };
|
||||
}
|
||||
|
|
@ -528,6 +559,159 @@ function recommendationAccountLabel(item) {
|
|||
return accountName || orgName || item.data_source_name || '';
|
||||
}
|
||||
|
||||
function loadRecommendationFeedback(db, userId) {
|
||||
const empty = { byCatalog: new Map(), byMerchant: new Map() };
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT catalog_id, merchant, action, confidence, descriptor, created_at
|
||||
FROM subscription_recommendation_feedback
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 500
|
||||
`).all(userId);
|
||||
for (const row of rows) {
|
||||
if (row.catalog_id) {
|
||||
const key = String(row.catalog_id);
|
||||
if (!empty.byCatalog.has(key)) empty.byCatalog.set(key, []);
|
||||
empty.byCatalog.get(key).push(row);
|
||||
}
|
||||
const merchant = normalizeMerchant(row.merchant);
|
||||
if (merchant) {
|
||||
if (!empty.byMerchant.has(merchant)) empty.byMerchant.set(merchant, []);
|
||||
empty.byMerchant.get(merchant).push(row);
|
||||
}
|
||||
}
|
||||
} catch { /* pre-v0.97 */ }
|
||||
return empty;
|
||||
}
|
||||
|
||||
function recordSubscriptionFeedback(db, userId, payload = {}) {
|
||||
const action = String(payload.action || '').trim();
|
||||
if (!action) return;
|
||||
try {
|
||||
const catalogId = Number(payload.catalog_id);
|
||||
const billId = Number(payload.bill_id);
|
||||
const metadata = payload.metadata === undefined ? null : JSON.stringify(payload.metadata);
|
||||
db.prepare(`
|
||||
INSERT INTO subscription_recommendation_feedback
|
||||
(user_id, catalog_id, bill_id, merchant, action, confidence, descriptor, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
Number.isInteger(catalogId) && catalogId > 0 ? catalogId : null,
|
||||
Number.isInteger(billId) && billId > 0 ? billId : null,
|
||||
payload.merchant ? normalizeMerchant(payload.merchant) : null,
|
||||
action,
|
||||
Number.isInteger(Number(payload.confidence)) ? Number(payload.confidence) : null,
|
||||
payload.descriptor ? String(payload.descriptor).slice(0, 100) : null,
|
||||
metadata,
|
||||
);
|
||||
} catch { /* pre-v0.97 */ }
|
||||
}
|
||||
|
||||
function existingBillsForRecommendation(db, userId) {
|
||||
return db.prepare(`
|
||||
SELECT b.id, b.name, b.expected_amount, b.due_day, b.cycle_type, b.billing_cycle,
|
||||
b.is_subscription, b.active, b.catalog_id, c.name AS category_name,
|
||||
CASE WHEN EXISTS(
|
||||
SELECT 1 FROM bill_merchant_rules r
|
||||
WHERE r.bill_id = b.id AND r.user_id = b.user_id
|
||||
) THEN 1 ELSE 0 END AS has_merchant_rule
|
||||
FROM bills b
|
||||
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
|
||||
WHERE b.user_id = ?
|
||||
AND b.deleted_at IS NULL
|
||||
`).all(userId);
|
||||
}
|
||||
|
||||
function dayDelta(a, b) {
|
||||
const left = Math.min(Math.max(Number(a) || 1, 1), 31);
|
||||
const right = Math.min(Math.max(Number(b) || 1, 1), 31);
|
||||
return Math.abs(left - right);
|
||||
}
|
||||
|
||||
function existingBillMatch(existingBills, { merchant, catalogEntry, averageAmount, lastDate }) {
|
||||
const merchantKey = normalizeCatalogName(merchant);
|
||||
const catalogKey = normalizeCatalogName(catalogEntry?.name);
|
||||
const dueDay = Number(String(lastDate || '').slice(8, 10)) || 1;
|
||||
let best = null;
|
||||
|
||||
for (const bill of existingBills) {
|
||||
const billKey = normalizeCatalogName(bill.name);
|
||||
if (!billKey) continue;
|
||||
const reasons = [];
|
||||
let score = 0;
|
||||
|
||||
if (catalogEntry?.id && Number(bill.catalog_id) === Number(catalogEntry.id)) {
|
||||
score += 90;
|
||||
reasons.push('Bill is already linked to this catalog service');
|
||||
} else if (catalogKey && billKey === catalogKey) {
|
||||
score += 72;
|
||||
reasons.push('Bill name exactly matches the known service');
|
||||
} else if (merchantKey && billKey === merchantKey) {
|
||||
score += 66;
|
||||
reasons.push('Bill name exactly matches the bank merchant');
|
||||
} else if (catalogKey && (billKey.includes(catalogKey) || catalogKey.includes(billKey))) {
|
||||
score += 48;
|
||||
reasons.push('Bill name closely matches the known service');
|
||||
} else if (merchantKey && merchantKey.length >= 4 && (billKey.includes(merchantKey) || merchantKey.includes(billKey))) {
|
||||
score += 42;
|
||||
reasons.push('Bill name closely matches the bank merchant');
|
||||
}
|
||||
|
||||
if (score === 0) continue;
|
||||
|
||||
const expected = Number(bill.expected_amount || 0);
|
||||
const amountDelta = expected ? Math.abs(expected - averageAmount) : null;
|
||||
if (amountDelta !== null) {
|
||||
const pct = expected ? amountDelta / expected : 1;
|
||||
if (amountDelta <= 1 || pct <= 0.08) {
|
||||
score += 20;
|
||||
reasons.push('Bill amount matches this charge');
|
||||
} else if (amountDelta <= 5 || pct <= 0.20) {
|
||||
score += 8;
|
||||
reasons.push('Bill amount is near this charge');
|
||||
}
|
||||
}
|
||||
|
||||
const dueDelta = dayDelta(bill.due_day, dueDay);
|
||||
if (dueDelta === 0) {
|
||||
score += 12;
|
||||
reasons.push('Bill due day matches the latest charge date');
|
||||
} else if (dueDelta <= 2) {
|
||||
score += 9;
|
||||
reasons.push('Bill due day is close to the latest charge date');
|
||||
} else if (dueDelta <= 5) {
|
||||
score += 5;
|
||||
reasons.push('Bill due day is near the latest charge date');
|
||||
}
|
||||
|
||||
if (bill.is_subscription) score += 5;
|
||||
if (bill.has_merchant_rule) score += 3;
|
||||
|
||||
if (score >= 65 && (!best || score > best.score)) {
|
||||
best = {
|
||||
id: bill.id,
|
||||
name: bill.name,
|
||||
expected_amount: expected || null,
|
||||
due_day: bill.due_day || null,
|
||||
active: !!bill.active,
|
||||
is_subscription: !!bill.is_subscription,
|
||||
catalog_id: bill.catalog_id || null,
|
||||
category_name: bill.category_name || null,
|
||||
has_merchant_rule: !!bill.has_merchant_rule,
|
||||
score,
|
||||
strong: score >= 90 || (amountDelta !== null && amountDelta <= 1 && dueDelta <= 2),
|
||||
amount_delta: amountDelta === null ? null : Math.round(amountDelta * 100) / 100,
|
||||
due_day_delta: dueDelta,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── Decline store ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getDeclinedKeys(db, userId) {
|
||||
|
|
@ -552,7 +736,8 @@ function declineRecommendation(db, userId, declineKey) {
|
|||
function getSubscriptionRecommendations(db, userId) {
|
||||
const catalog = loadCatalog(db, userId);
|
||||
const catalogTypeMap = buildCatalogTypeMap(catalog);
|
||||
const existingNames = existingBillNames(db, userId);
|
||||
const existingBills = existingBillsForRecommendation(db, userId);
|
||||
const feedback = loadRecommendationFeedback(db, userId);
|
||||
const declined = getDeclinedKeys(db, userId);
|
||||
|
||||
const rows = db.prepare(`
|
||||
|
|
@ -605,7 +790,6 @@ function getSubscriptionRecommendations(db, userId) {
|
|||
const declineKey = catalogEntry ? `catalog:${catalogEntry.id}` : `merchant:${merchant}`;
|
||||
|
||||
if (declined.has(declineKey)) continue;
|
||||
if (existingNames.some(n => n.includes(merchant) || merchant.includes(n))) continue;
|
||||
|
||||
const sorted = group.items
|
||||
.filter(item => item.tx_date)
|
||||
|
|
@ -629,15 +813,19 @@ function getSubscriptionRecommendations(db, userId) {
|
|||
amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
|
||||
}
|
||||
const cadenceInfo = cadenceEvidence(sorted, cycleType, 30, maxDelta, averageAmount);
|
||||
const feedbackInfo = feedbackEvidence({ feedback, merchant, catalogEntry });
|
||||
const scored = scoreKnownServiceRecommendation({
|
||||
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo,
|
||||
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo, feedbackInfo,
|
||||
});
|
||||
if (scored.confidence < 90) continue;
|
||||
const billMatch = existingBillMatch(existingBills, {
|
||||
merchant, catalogEntry, averageAmount, lastDate: last.tx_date,
|
||||
});
|
||||
recommendations.push(buildRecommendation({
|
||||
merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
|
||||
cycleType, avgGap: 30, confidence: scored.confidence, tier: 'known_service',
|
||||
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
|
||||
ambiguityInfo: scored.ambiguity,
|
||||
ambiguityInfo: scored.ambiguity, existingBillMatch: billMatch, feedbackInfo,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
|
@ -664,16 +852,20 @@ function getSubscriptionRecommendations(db, userId) {
|
|||
|
||||
const amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
|
||||
const cadenceInfo = cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount);
|
||||
const feedbackInfo = feedbackEvidence({ feedback, merchant, catalogEntry });
|
||||
const scored = scoreKnownServiceRecommendation({
|
||||
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo,
|
||||
match: catalogIdentityMatch, merchant, catalogEntry, amountInfo, cadenceInfo, feedbackInfo,
|
||||
});
|
||||
if (scored.confidence < 90) continue;
|
||||
const billMatch = existingBillMatch(existingBills, {
|
||||
merchant, catalogEntry, averageAmount, lastDate: last.tx_date,
|
||||
});
|
||||
|
||||
recommendations.push(buildRecommendation({
|
||||
merchant, catalogEntry, sorted, averageAmount, maxDelta, last,
|
||||
cycleType, avgGap, confidence: scored.confidence, tier: 'confirmed',
|
||||
declineKey, catalogTypeMap, identityInfo: scored.identity, amountInfo, cadenceInfo,
|
||||
ambiguityInfo: scored.ambiguity,
|
||||
ambiguityInfo: scored.ambiguity, existingBillMatch: billMatch, feedbackInfo,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -681,7 +873,12 @@ function getSubscriptionRecommendations(db, userId) {
|
|||
// known service, keep only the highest-confidence one.
|
||||
const seen = new Map();
|
||||
const deduped = [];
|
||||
for (const rec of recommendations.sort((a, b) => b.confidence - a.confidence || b.occurrence_count - a.occurrence_count)) {
|
||||
for (const rec of recommendations.sort((a, b) => (
|
||||
Number(!!b.existing_bill_match?.strong) - Number(!!a.existing_bill_match?.strong)
|
||||
|| Number(!!b.existing_bill_match) - Number(!!a.existing_bill_match)
|
||||
|| b.confidence - a.confidence
|
||||
|| b.occurrence_count - a.occurrence_count
|
||||
))) {
|
||||
const key = rec.catalog_match ? `catalog:${rec.catalog_match.id}` : `merchant:${rec.merchant}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, true);
|
||||
|
|
@ -691,7 +888,7 @@ function getSubscriptionRecommendations(db, userId) {
|
|||
return deduped.slice(0, 20);
|
||||
}
|
||||
|
||||
function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, identityInfo = null, amountInfo = null, cadenceInfo = null, ambiguityInfo = null }) {
|
||||
function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, identityInfo = null, amountInfo = null, cadenceInfo = null, ambiguityInfo = null, existingBillMatch = null, feedbackInfo = null }) {
|
||||
const name = catalogEntry ? catalogEntry.name : titleCase(merchant);
|
||||
const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap);
|
||||
const accounts = Array.from(new Set(sorted
|
||||
|
|
@ -704,8 +901,11 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
|
|||
if (amountInfo?.label) reasons.push(amountInfo.label);
|
||||
if (cadenceInfo?.recurring && cadenceInfo.label) reasons.push(cadenceInfo.label);
|
||||
if (ambiguityInfo?.ambiguous && ambiguityInfo.label) reasons.push(ambiguityInfo.label);
|
||||
if (feedbackInfo?.label) reasons.push(feedbackInfo.label);
|
||||
if (existingBillMatch) reasons.push(`Best action: link to existing bill "${existingBillMatch.name}"`);
|
||||
reasons.push(`${last.currency || 'USD'} ${averageAmount.toFixed(2)} average`);
|
||||
|
||||
const recommendedAction = existingBillMatch ? 'link_existing_bill' : 'track_new';
|
||||
return {
|
||||
id: Buffer.from(`${merchant}:${Math.round(averageAmount)}:${last.tx_date}`).toString('base64url'),
|
||||
name,
|
||||
|
|
@ -719,6 +919,9 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
|
|||
occurrence_count: sorted.length,
|
||||
confidence,
|
||||
tier,
|
||||
recommended_action: recommendedAction,
|
||||
action_priority: existingBillMatch?.strong ? 'link_strong' : existingBillMatch ? 'link_possible' : 'track_new',
|
||||
existing_bill_match: existingBillMatch,
|
||||
catalog_match: catalogMatchPayload(catalogEntry),
|
||||
evidence: {
|
||||
identity: identityInfo,
|
||||
|
|
@ -744,6 +947,12 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
|
|||
reasons: ambiguityInfo.reasons || [],
|
||||
penalty: ambiguityInfo.penalty || 0,
|
||||
} : { ambiguous: false, label: null, reasons: [], penalty: 0 },
|
||||
feedback: feedbackInfo ? {
|
||||
score: feedbackInfo.score || 0,
|
||||
label: feedbackInfo.label,
|
||||
positive_count: feedbackInfo.positive_count || 0,
|
||||
negative_count: feedbackInfo.negative_count || 0,
|
||||
} : { score: 0, label: null, positive_count: 0, negative_count: 0 },
|
||||
},
|
||||
transaction_ids: sorted.map(item => item.id),
|
||||
transactions: sorted.map(item => ({
|
||||
|
|
@ -902,5 +1111,6 @@ module.exports = {
|
|||
loadCatalog,
|
||||
monthlyEquivalent,
|
||||
normalizeMerchant,
|
||||
recordSubscriptionFeedback,
|
||||
searchSubscriptionTransactions,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ process.env.DB_PATH = dbPath;
|
|||
const { getDb, closeDb } = require('../db/database');
|
||||
const {
|
||||
getSubscriptionRecommendations,
|
||||
recordSubscriptionFeedback,
|
||||
searchSubscriptionTransactions,
|
||||
} = require('../services/subscriptionService');
|
||||
|
||||
|
|
@ -48,6 +49,22 @@ function createAccount(db, userId, monitored = true) {
|
|||
`).run(userId, sourceId, `acct-${sourceId}`, monitored ? 1 : 0).lastInsertRowid;
|
||||
}
|
||||
|
||||
function createBill(db, userId, overrides = {}) {
|
||||
return db.prepare(`
|
||||
INSERT INTO bills
|
||||
(user_id, name, due_day, expected_amount, is_subscription, cycle_type, billing_cycle)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
overrides.name || 'Netflix',
|
||||
overrides.due_day || 8,
|
||||
overrides.expected_amount ?? 15.99,
|
||||
overrides.is_subscription ?? 1,
|
||||
overrides.cycle_type || 'monthly',
|
||||
overrides.billing_cycle || 'monthly',
|
||||
).lastInsertRowid;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
|
|
@ -73,6 +90,60 @@ test('known catalog services appear as high-confidence subscription recommendati
|
|||
assert.equal(netflix.evidence.amount.match, 'monthly_plausible');
|
||||
});
|
||||
|
||||
test('existing tracked bills are recommended for linking instead of tracking again', () => {
|
||||
const db = getDb();
|
||||
const userId = createUser(db, 'existing-bill');
|
||||
const accountId = createAccount(db, userId, true);
|
||||
const billId = createBill(db, userId, {
|
||||
name: 'Netflix',
|
||||
due_day: 12,
|
||||
expected_amount: 15.99,
|
||||
is_subscription: 1,
|
||||
});
|
||||
createTransaction(db, userId, {
|
||||
account_id: accountId,
|
||||
description: 'NETFLIX.COM',
|
||||
payee: 'NETFLIX.COM',
|
||||
amount: -1599,
|
||||
posted_date: '2026-04-12',
|
||||
});
|
||||
|
||||
const recommendations = getSubscriptionRecommendations(db, userId);
|
||||
const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix');
|
||||
|
||||
assert.ok(netflix, 'Netflix should still be recommended when an existing bill is present');
|
||||
assert.equal(netflix.recommended_action, 'link_existing_bill');
|
||||
assert.equal(netflix.existing_bill_match.id, billId);
|
||||
assert.equal(netflix.existing_bill_match.strong, true);
|
||||
});
|
||||
|
||||
test('subscription recommendation feedback boosts future matching for the user', () => {
|
||||
const db = getDb();
|
||||
const userId = createUser(db, 'feedback-boost');
|
||||
const accountId = createAccount(db, userId, true);
|
||||
createTransaction(db, userId, {
|
||||
account_id: accountId,
|
||||
description: 'NETFLIX.COM',
|
||||
payee: 'NETFLIX.COM',
|
||||
amount: -1599,
|
||||
posted_date: '2026-04-12',
|
||||
});
|
||||
|
||||
const before = getSubscriptionRecommendations(db, userId).find(item => item.catalog_match?.name === 'Netflix');
|
||||
assert.ok(before, 'Netflix should be recommended before feedback');
|
||||
recordSubscriptionFeedback(db, userId, {
|
||||
action: 'accept_track_new',
|
||||
catalog_id: before.catalog_match.id,
|
||||
merchant: before.merchant,
|
||||
confidence: before.confidence,
|
||||
});
|
||||
|
||||
const after = getSubscriptionRecommendations(db, userId).find(item => item.catalog_match?.name === 'Netflix');
|
||||
assert.ok(after, 'Netflix should still be recommended after feedback');
|
||||
assert.equal(after.confidence > before.confidence, true);
|
||||
assert.equal(after.evidence.feedback.positive_count > 0, true);
|
||||
});
|
||||
|
||||
test('weak one-off known service names stay below the recommendation threshold', () => {
|
||||
const db = getDb();
|
||||
const userId = createUser(db, 'weak-known');
|
||||
|
|
@ -88,6 +159,83 @@ test('weak one-off known service names stay below the recommendation threshold',
|
|||
assert.equal(recommendations.some(item => item.catalog_match?.name === 'Max'), false);
|
||||
});
|
||||
|
||||
test('broad Apple, Amazon, and Google one-off purchases with odd amounts are not recommended', () => {
|
||||
const db = getDb();
|
||||
const userId = createUser(db, 'broad-one-offs');
|
||||
const accountId = createAccount(db, userId, true);
|
||||
createTransaction(db, userId, {
|
||||
account_id: accountId,
|
||||
description: 'APPLE.COM/BILL',
|
||||
payee: 'APPLE.COM/BILL',
|
||||
amount: -19999,
|
||||
posted_date: '2026-04-02',
|
||||
});
|
||||
createTransaction(db, userId, {
|
||||
account_id: accountId,
|
||||
description: 'AMAZON MKTPLACE PMTS',
|
||||
payee: 'AMAZON MKTPLACE PMTS',
|
||||
amount: -8700,
|
||||
posted_date: '2026-04-03',
|
||||
});
|
||||
createTransaction(db, userId, {
|
||||
account_id: accountId,
|
||||
description: 'GOOGLE *STORE',
|
||||
payee: 'GOOGLE *STORE',
|
||||
amount: -64999,
|
||||
posted_date: '2026-04-04',
|
||||
});
|
||||
|
||||
const recommendations = getSubscriptionRecommendations(db, userId);
|
||||
const names = recommendations.map(item => item.catalog_match?.name || item.name).join(' ');
|
||||
assert.doesNotMatch(names, /Apple|Amazon|Google/);
|
||||
});
|
||||
|
||||
test('annual known-service charges can be recommended when catalog annual pricing matches', () => {
|
||||
const db = getDb();
|
||||
const userId = createUser(db, 'annual-known');
|
||||
const accountId = createAccount(db, userId, true);
|
||||
db.prepare("UPDATE subscription_catalog SET starting_annual_usd = 191.88 WHERE name = 'Netflix'").run();
|
||||
createTransaction(db, userId, {
|
||||
account_id: accountId,
|
||||
description: 'NETFLIX.COM',
|
||||
payee: 'NETFLIX.COM',
|
||||
amount: -19188,
|
||||
posted_date: '2026-04-12',
|
||||
});
|
||||
|
||||
const recommendations = getSubscriptionRecommendations(db, userId);
|
||||
const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix');
|
||||
|
||||
assert.ok(netflix, 'Annual Netflix charge should be recommended from exact descriptor and annual price');
|
||||
assert.equal(netflix.cycle_type, 'annual');
|
||||
assert.equal(netflix.evidence.amount.match, 'annual_close');
|
||||
});
|
||||
|
||||
test('exact user descriptors produce high-confidence one-off recommendations', () => {
|
||||
const db = getDb();
|
||||
const userId = createUser(db, 'custom-descriptor');
|
||||
const accountId = createAccount(db, userId, true);
|
||||
const netflixCatalog = db.prepare("SELECT id FROM subscription_catalog WHERE name = 'Netflix'").get();
|
||||
db.prepare(`
|
||||
INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor)
|
||||
VALUES (?, ?, 'NFX CUSTOM BILL')
|
||||
`).run(userId, netflixCatalog.id);
|
||||
createTransaction(db, userId, {
|
||||
account_id: accountId,
|
||||
description: 'NFX CUSTOM BILL',
|
||||
payee: 'NFX CUSTOM BILL',
|
||||
amount: -1599,
|
||||
posted_date: '2026-04-12',
|
||||
});
|
||||
|
||||
const recommendations = getSubscriptionRecommendations(db, userId);
|
||||
const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix');
|
||||
|
||||
assert.ok(netflix, 'Custom descriptor should produce a Netflix recommendation');
|
||||
assert.equal(netflix.confidence >= 90, true);
|
||||
assert.equal(netflix.evidence.identity.type, 'user_descriptor');
|
||||
});
|
||||
|
||||
test('ambiguous recurring known service names are flagged for review', () => {
|
||||
const db = getDb();
|
||||
const userId = createUser(db, 'ambiguous-known');
|
||||
|
|
|
|||
Loading…
Reference in New Issue