From 6b30ee4eb7c92b8a2edf8682d0aa5c62bbae788a Mon Sep 17 00:00:00 2001 From: null Date: Fri, 29 May 2026 03:38:48 -0500 Subject: [PATCH] feat: merchant rules, auto-match on sync, duplicate API fix - Removed duplicate unmatchTransaction API entry in api.js - Unmonitored accounts: no chevron, click-to-expand disabled, tx panel hidden - matched_bill_name included via LEFT JOIN bills in accounts query - BillPickerDialog resets search/selection on open - Link to bill: marks historical txs matched, stores merchant rule, applyMerchantRules catches other unmatched txs from same merchant - Track (new subscription): creates bill with is_subscription=1, stores merchant rule for ongoing tracking - SimpleFIN sync: applyMerchantRules runs after tx insert, auto-matches by merchant rule with payment_source='auto_match' - Auto-match payments have transaction_id set, treated same as manual matches - New services/billMerchantRuleService.js for rule storage and matching - Migration for bill_merchant_rules table --- client/api.js | 2 +- client/components/data/BankSyncSection.jsx | 12 +-- client/pages/SubscriptionsPage.jsx | 117 ++++++++++++++++++++- db/database.js | 40 +++++++ package.json | 2 +- routes/subscriptions.js | 43 ++++++++ services/bankSyncService.js | 6 +- services/billMerchantRuleService.js | 84 +++++++++++++++ services/subscriptionService.js | 1 + 9 files changed, 295 insertions(+), 12 deletions(-) create mode 100644 services/billMerchantRuleService.js diff --git a/client/api.js b/client/api.js index 95ab5c9..994915b 100644 --- a/client/api.js +++ b/client/api.js @@ -182,7 +182,7 @@ export const api = { // Subscriptions subscriptions: () => get('/subscriptions'), confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }), - unmatchTransaction: (transactionId) => post(`/matches/${transactionId}/unmatch`), + matchRecommendationToBill: (transactionIds, billId, merchant) => post('/subscriptions/recommendations/match-bill', { transaction_ids: transactionIds, bill_id: billId, merchant }), subscriptionRecommendations: () => get('/subscriptions/recommendations'), updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data), createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data), diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index e55afe7..e624796 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -164,13 +164,13 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit return (
- e.stopPropagation()}> - {expanded + e.stopPropagation()}> + {account.monitored && (expanded ? - : } + : )} {/* Monitored toggle */} @@ -214,7 +214,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
- {expanded && ( + {expanded && account.monitored && (
{account.transactions.length === 0 ? (

No transactions synced for this account.

diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index 690cf89..274c712 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -5,6 +5,7 @@ import { CalendarDays, CheckCircle2, Cloud, + Link2, Loader2, Pause, Plus, @@ -18,6 +19,10 @@ import { cn, fmt, fmtDate } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { + Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, +} from '@/components/ui/dialog'; import BillModal from '@/components/BillModal'; const TYPE_LABELS = { @@ -112,7 +117,74 @@ function SubscriptionRow({ item, onEdit, onToggle }) { ); } -function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, busy }) { +function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, busy }) { + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); + + useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return (bills || []).filter(b => !q || b.name.toLowerCase().includes(q)); + }, [bills, search]); + + return ( + { if (!v) onClose(); }}> + + + Link to existing bill +

+ {recommendation?.name} + {recommendation && ( + {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · {fmt(recommendation.expected_amount)} + )} +

+

+ The matching transactions will be marked as paid under the selected bill. +

+
+ + setSearch(e.target.value)} + className="text-sm" + /> + +
+ {filtered.length === 0 ? ( +

No bills found.

+ ) : filtered.map(bill => ( + + ))} +
+ + + + + +
+
+ ); +} + +function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) { return (
@@ -139,7 +211,7 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, b

{fmt(recommendation.monthly_equivalent)} / mo

-
+
+
); } diff --git a/db/database.js b/db/database.js index cf072f1..e86301c 100644 --- a/db/database.js +++ b/db/database.js @@ -1366,6 +1366,27 @@ function reconcileLegacyMigrations() { ON declined_subscription_hints(user_id); `); } + }, + { + version: 'v0.67', + description: 'bill_merchant_rules: persistent merchant→bill auto-match rules', + check: function() { + return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_merchant_rules'").get(); + }, + run: function() { + db.exec(` + CREATE TABLE IF NOT EXISTS bill_merchant_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, + merchant TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, bill_id, merchant) + ); + CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user + ON bill_merchant_rules(user_id); + `); + } } ]; @@ -2167,6 +2188,25 @@ function runMigrations() { ON declined_subscription_hints(user_id); `); } + }, + { + version: 'v0.67', + description: 'bill_merchant_rules: persistent merchant→bill auto-match rules', + dependsOn: ['v0.66'], + run: function() { + db.exec(` + CREATE TABLE IF NOT EXISTS bill_merchant_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, + merchant TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, bill_id, merchant) + ); + CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user + ON bill_merchant_rules(user_id); + `); + } } ]; diff --git a/package.json b/package.json index 3fa31ab..6fa374f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.33.5", + "version": "0.33.6", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/subscriptions.js b/routes/subscriptions.js index 47e6fd6..455fb76 100644 --- a/routes/subscriptions.js +++ b/routes/subscriptions.js @@ -10,6 +10,7 @@ const { getSubscriptionSummary, getSubscriptions, } = require('../services/subscriptionService'); +const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService'); router.get('/', (req, res) => { const db = getDb(); @@ -41,6 +42,46 @@ router.post('/recommendations/decline', (req, res) => { } }); +// POST /api/subscriptions/recommendations/match-bill +// Link an existing bill to all transactions in a recommendation (no new bill created). +router.post('/recommendations/match-bill', (req, res) => { + const billId = parseInt(req.body?.bill_id, 10); + const rawIds = Array.isArray(req.body?.transaction_ids) ? req.body.transaction_ids : []; + const txIds = rawIds.map(id => parseInt(id, 10)).filter(n => Number.isInteger(n) && n > 0).slice(0, 50); + + if (!Number.isInteger(billId) || billId < 1) { + return res.status(400).json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id')); + } + if (txIds.length === 0) { + return res.status(400).json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR', 'transaction_ids')); + } + + 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); + if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); + + const update = db.prepare(` + UPDATE transactions + SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ? AND ignored = 0 AND match_status != 'matched' + `); + let matchedCount = 0; + db.transaction(() => { + for (const id of txIds) { + matchedCount += update.run(billId, id, req.user.id).changes; + } + })(); + + // 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); + + // Apply rules immediately to catch any unmatched transactions beyond the explicit list + const { matched: autoMatched } = applyMerchantRules(db, req.user.id); + + res.json({ ok: true, matched_count: matchedCount + autoMatched, bill_name: bill.name }); +}); + router.post('/recommendations/create', (req, res) => { const db = getDb(); ensureUserDefaultCategories(req.user.id); @@ -55,6 +96,8 @@ router.post('/recommendations/create', (req, res) => { } try { 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); 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)); diff --git a/services/bankSyncService.js b/services/bankSyncService.js index 33aa6eb..11424c0 100644 --- a/services/bankSyncService.js +++ b/services/bankSyncService.js @@ -10,6 +10,7 @@ const { } = require('./simplefinService'); const { getBankSyncConfig } = require('./bankSyncConfigService'); const { decorateDataSource } = require('./transactionService'); +const { applyMerchantRules } = require('./billMerchantRuleService'); function sinceEpoch() { const { sync_days } = getBankSyncConfig(); @@ -118,7 +119,10 @@ async function runSync(db, userId, dataSource) { WHERE id = ? AND user_id = ? `).run(partialError, dataSource.id, userId); - return { accountsUpserted, transactionsNew, transactionsSkip, errlist: raw._errlistSummary || null }; + // Apply any stored merchant→bill rules to newly synced transactions + const { matched: autoMatched } = applyMerchantRules(db, userId); + + return { accountsUpserted, transactionsNew, transactionsSkip, autoMatched, errlist: raw._errlistSummary || null }; } // ─── Public API ─────────────────────────────────────────────────────────────── diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js new file mode 100644 index 0000000..a44486d --- /dev/null +++ b/services/billMerchantRuleService.js @@ -0,0 +1,84 @@ +'use strict'; + +const { normalizeMerchant } = require('./subscriptionService'); + +// Persist a merchant→bill rule so future synced transactions auto-match. +function addMerchantRule(db, userId, billId, merchant) { + const normalized = normalizeMerchant(merchant); + if (!normalized || normalized.length < 3) return; + try { + db.prepare(` + INSERT INTO bill_merchant_rules (user_id, bill_id, merchant) + VALUES (?, ?, ?) + ON CONFLICT(user_id, bill_id, merchant) DO NOTHING + `).run(userId, billId, normalized); + } catch { + // Table may not exist yet on legacy DBs — safe to skip + } +} + +// Scan all unmatched negative transactions for this user, apply any stored +// merchant rules, create payments, and mark the transactions matched. +// Returns { matched: number }. +function applyMerchantRules(db, userId) { + let rules; + try { + rules = db.prepare(` + SELECT bmr.bill_id, bmr.merchant + FROM bill_merchant_rules bmr + JOIN bills b ON b.id = bmr.bill_id AND b.user_id = bmr.user_id AND b.deleted_at IS NULL + WHERE bmr.user_id = ? + `).all(userId); + } catch { + return { matched: 0 }; + } + + if (rules.length === 0) return { matched: 0 }; + + const txRows = db.prepare(` + SELECT id, amount, payee, description, memo, posted_date, transacted_at + FROM transactions + WHERE user_id = ? + AND match_status = 'unmatched' + AND ignored = 0 + AND amount < 0 + `).all(userId); + + if (txRows.length === 0) return { matched: 0 }; + + const insertPayment = db.prepare(` + INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id) + VALUES (?, ?, ?, 'auto_match', ?) + `); + const updateTx = db.prepare(` + UPDATE transactions + SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') + WHERE id = ? AND user_id = ? AND match_status = 'unmatched' + `); + + let matched = 0; + + db.transaction(() => { + for (const tx of txRows) { + const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); + if (!txMerchant) continue; + + const rule = rules.find(r => + txMerchant.includes(r.merchant) || r.merchant.includes(txMerchant) + ); + if (!rule) continue; + + const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); + if (!paidDate) continue; + + const amount = Math.round(Math.abs(tx.amount)) / 100; + insertPayment.run(rule.bill_id, amount, paidDate, tx.id); + updateTx.run(rule.bill_id, tx.id, userId); + matched++; + } + })(); + + return { matched }; +} + +module.exports = { addMerchantRule, applyMerchantRules }; diff --git a/services/subscriptionService.js b/services/subscriptionService.js index 0bcc2d2..acb786e 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -427,4 +427,5 @@ module.exports = { lookupCatalog, loadCatalog, monthlyEquivalent, + normalizeMerchant, };