diff --git a/client/components/BillMerchantRules.jsx b/client/components/BillMerchantRules.jsx
index 112ec8a..b2e29c4 100644
--- a/client/components/BillMerchantRules.jsx
+++ b/client/components/BillMerchantRules.jsx
@@ -115,6 +115,8 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
const [retroFeedback, setRetroFeedback] = useState(null);
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
const [togglingAutoAttr, setTogglingAutoAttr] = useState(null);
+ const [liveResults, setLiveResults] = useState([]);
+ const [liveSearching, setLiveSearching] = useState(false);
const inputRef = useRef(null);
const debouncedInput = useDebounce(input.trim(), 380);
@@ -158,6 +160,31 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
return () => { cancelled = true; };
}, [debouncedInput, billId]);
+ // Live transaction search when user types something
+ useEffect(() => {
+ if (!debouncedInput || debouncedInput.length < 2) {
+ setLiveResults([]);
+ return;
+ }
+ let cancelled = false;
+ setLiveSearching(true);
+ api.subscriptionTransactionMatches({ q: debouncedInput, limit: 20 })
+ .then(data => {
+ if (cancelled) return;
+ const rows = Array.isArray(data) ? data : (data?.transactions ?? []);
+ setLiveResults(rows.filter(tx => tx.match_status !== 'matched').map(tx => ({
+ id: tx.id,
+ label: tx.payee || tx.description || tx.memo || '',
+ normalized: tx.merchant || (tx.payee || tx.description || tx.memo || '').toLowerCase(),
+ amount: tx.amount,
+ date: tx.posted_date || '',
+ })).filter(s => s.label));
+ })
+ .catch(() => { if (!cancelled) setLiveResults([]); })
+ .finally(() => { if (!cancelled) setLiveSearching(false); });
+ return () => { cancelled = true; };
+ }, [debouncedInput]);
+
// Popover handles its own outside-click dismissal — no manual handler needed
async function handleAdd(merchantText) {
@@ -221,11 +248,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
inputRef.current?.focus();
}
- // Filter suggestions: not already a rule, and not already matched to something
- const filteredSuggestions = suggestions.filter(s =>
- !rules.some(r => r.merchant === s.normalized) &&
- (input.length < 2 || s.label.toLowerCase().includes(input.toLowerCase()))
- ).slice(0, 8);
+ // When typing: use live search results. When blank: use pre-loaded recent 30.
+ const filteredSuggestions = (input.trim().length >= 2 ? liveResults : suggestions.filter(s =>
+ !rules.some(r => r.merchant === s.normalized)
+ )).slice(0, 10);
if (loading) {
return (
@@ -299,10 +325,11 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
{/* Suggestions — inline block, no absolute positioning.
Avoids overflow-y-auto clipping AND Radix Dialog pointer-event capture. */}
- {showSuggestions && filteredSuggestions.length > 0 && (
+ {showSuggestions && (liveSearching || filteredSuggestions.length > 0) && (
-
- Recent unmatched transactions
+
+ {input.trim().length >= 2 ? 'Matching transactions' : 'Recent unmatched transactions'}
+ {liveSearching && }
{filteredSuggestions.map(s => {
@@ -322,6 +349,9 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
);
})}
+ {!liveSearching && input.trim().length >= 2 && filteredSuggestions.length === 0 && (
+
No transactions found for "{input.trim()}"
+ )}
)}
diff --git a/client/components/BillsTableInner.jsx b/client/components/BillsTableInner.jsx
index c71a95a..fa189c1 100644
--- a/client/components/BillsTableInner.jsx
+++ b/client/components/BillsTableInner.jsx
@@ -119,12 +119,12 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
S
)}
- {!!bill.has_merchant_rule && (
+ {(!!bill.has_merchant_rule || !!bill.has_linked_transactions) && (
- Bank
+ L
)}
{hasHistory && (
diff --git a/client/components/MobileBillRow.jsx b/client/components/MobileBillRow.jsx
index 623f2cb..59ba4df 100644
--- a/client/components/MobileBillRow.jsx
+++ b/client/components/MobileBillRow.jsx
@@ -116,6 +116,9 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
{bill.has_2fa && (
2FA
)}
+ {(bill.has_merchant_rule || bill.has_linked_transactions) && (
+ L
+ )}
{bill.is_subscription && (
S
)}
diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx
index 89b6b64..b4b939c 100644
--- a/client/components/tracker/MobileTrackerRow.jsx
+++ b/client/components/tracker/MobileTrackerRow.jsx
@@ -197,6 +197,14 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
AP
)}
+ {(row.has_merchant_rule || row.has_linked_transactions) && (
+
+ L
+
+ )}
{row.is_subscription && (
)}
+ {(row.has_merchant_rule || row.has_linked_transactions) && (
+
+ L
+
+ )}
{row.is_subscription && (
+
+
+ );
+}
+
// ─── SettingsPage ─────────────────────────────────────────────────────────────
export default function SettingsPage() {
@@ -323,6 +352,7 @@ export default function SettingsPage() {
{/* Billing Behavior */}
+
{
@@ -938,9 +941,12 @@ export default function SubscriptionsPage() {
async function acceptRecommendation(recommendation) {
setBusyId(`rec-${recommendation.id}`);
try {
- await api.createSubscriptionFromRecommendation(recommendation);
+ const created = await api.createSubscriptionFromRecommendation(recommendation);
toast.success(`${recommendation.name} is now tracked.`);
await refreshAll();
+ if (getLinkImportPref() && recommendation.merchant && created?.id) {
+ setImportDialog({ billId: created.id, billName: created.name || recommendation.name });
+ }
} catch (err) {
toast.error(err.message || 'Could not create subscription.');
} finally {
@@ -975,6 +981,9 @@ export default function SubscriptionsPage() {
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 !== recommendation.id));
+ if (getLinkImportPref() && recommendation.merchant) {
+ setImportDialog({ billId, billName: result.bill_name || recommendation.name });
+ }
} catch (err) {
toast.error(err.message || 'Could not link recommendation to bill.');
} finally {
@@ -1474,6 +1483,14 @@ export default function SubscriptionsPage() {
onConfirm={matchRecommendationToBill}
busy={!!busyId?.startsWith('match-')}
/>
+
+ setImportDialog(null)}
+ onImported={() => { setImportDialog(null); refreshAll(); }}
+ />
);
}
diff --git a/routes/bills.js b/routes/bills.js
index 77b0978..cfa5874 100644
--- a/routes/bills.js
+++ b/routes/bills.js
@@ -23,13 +23,17 @@ router.get('/', (req, res) => {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
const includeInactive = req.query.inactive === 'true';
- // LEFT JOIN on a pre-grouped subquery is one query instead of N+1 correlated EXISTS lookups.
+ // LEFT JOIN on pre-grouped subqueries — one query instead of N+1 correlated EXISTS lookups.
const bills = db.prepare(`
SELECT b.*, c.name AS category_name,
- CASE WHEN hr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_history_ranges
+ CASE WHEN hr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_history_ranges,
+ CASE WHEN mr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule,
+ CASE WHEN lt.matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
LEFT JOIN (SELECT DISTINCT bill_id FROM bill_history_ranges) hr ON hr.bill_id = b.id
+ LEFT JOIN (SELECT DISTINCT bill_id FROM bill_merchant_rules) mr ON mr.bill_id = b.id
+ LEFT JOIN (SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched') lt ON lt.matched_bill_id = b.id
WHERE b.user_id = ?
AND b.deleted_at IS NULL
${includeInactive ? '' : 'AND b.active = 1'}
@@ -333,7 +337,10 @@ router.get('/:id', (req, res) => {
) THEN 1 ELSE 0 END AS has_history_ranges,
CASE WHEN EXISTS(
SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id
- ) THEN 1 ELSE 0 END AS has_merchant_rule
+ ) THEN 1 ELSE 0 END AS has_merchant_rule,
+ CASE WHEN EXISTS(
+ SELECT 1 FROM transactions WHERE matched_bill_id = b.id AND match_status = 'matched' AND user_id = b.user_id
+ ) THEN 1 ELSE 0 END AS has_linked_transactions
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.id = ? AND b.user_id = ? AND b.deleted_at IS NULL
diff --git a/services/trackerService.js b/services/trackerService.js
index 048f7cd..b6e92b6 100644
--- a/services/trackerService.js
+++ b/services/trackerService.js
@@ -115,9 +115,13 @@ const FETCH_BILLS_ORDER = {
function fetchActiveBills(db, userId, orderKey = 'due_day') {
const orderBy = FETCH_BILLS_ORDER[orderKey] ?? FETCH_BILLS_ORDER.due_day;
return db.prepare(`
- SELECT b.*, c.name AS category_name
+ SELECT b.*, c.name AS category_name,
+ CASE WHEN mr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule,
+ CASE WHEN lt.matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
+ LEFT JOIN (SELECT DISTINCT bill_id FROM bill_merchant_rules) mr ON mr.bill_id = b.id
+ LEFT JOIN (SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched') lt ON lt.matched_bill_id = b.id
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
ORDER BY ${orderBy}
`).all(userId);