From d2d3045afe15cba699122a408209213e1b2cefe3 Mon Sep 17 00:00:00 2001
From: null
Date: Thu, 4 Jun 2026 02:05:15 -0500
Subject: [PATCH] feat: historical payment import dialog for bank merchant
rules
---
client/api.js | 2 +
.../components/BillHistoricalImportDialog.jsx | 273 ++++++++++++++++++
client/components/BillMerchantRules.jsx | 27 +-
client/components/BillModal.jsx | 1 +
routes/bills.js | 158 ++++++++++
5 files changed, 453 insertions(+), 8 deletions(-)
create mode 100644 client/components/BillHistoricalImportDialog.jsx
diff --git a/client/api.js b/client/api.js
index b323496..0e82be5 100644
--- a/client/api.js
+++ b/client/api.js
@@ -184,6 +184,8 @@ export const api = {
previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`),
addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }),
deleteMerchantRule: (id, ruleId) => del(`/bills/${id}/merchant-rules/${ruleId}`),
+ merchantRuleCandidates: (id) => get(`/bills/${id}/merchant-rules/candidates`),
+ importHistoricalPayments: (id, ids) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }),
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`),
diff --git a/client/components/BillHistoricalImportDialog.jsx b/client/components/BillHistoricalImportDialog.jsx
new file mode 100644
index 0000000..13fbc12
--- /dev/null
+++ b/client/components/BillHistoricalImportDialog.jsx
@@ -0,0 +1,273 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { toast } from 'sonner';
+import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react';
+import { api } from '@/api';
+import { cn, fmt, fmtDate } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
+} from '@/components/ui/dialog';
+
+const STATUS_META = {
+ unmatched: { label: 'Unmatched', className: 'text-muted-foreground', icon: null },
+ matched_this_bill:{ label: 'Already linked', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 },
+ matched_other_bill:{ label: null, className: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle },
+ payment_exists: { label: 'Payment exists', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 },
+};
+
+function StatusChip({ candidate }) {
+ const meta = STATUS_META[candidate.status] ?? STATUS_META.unmatched;
+ const Icon = meta.icon;
+ const label = candidate.status === 'matched_other_bill'
+ ? `Matched to ${candidate.matched_bill_name || 'another bill'}`
+ : meta.label;
+ if (!label) return null;
+ return (
+
+ {Icon && }
+ {label}
+
+ );
+}
+
+// ── Main dialog ───────────────────────────────────────────────────────────────
+
+export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }) {
+ const [step, setStep] = useState('choice'); // 'choice' | 'pick'
+ const [candidates, setCandidates] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selected, setSelected] = useState(new Set());
+ const [importing, setImporting] = useState(false);
+
+ // Load candidates whenever the dialog opens
+ useEffect(() => {
+ if (!open || !billId) return;
+ setStep('choice');
+ setSelected(new Set());
+ setLoading(true);
+ api.merchantRuleCandidates(billId)
+ .then(data => {
+ // Pre-select importable candidates (not already a payment for this bill)
+ const importable = (data.candidates || []).filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill');
+ setCandidates(data.candidates || []);
+ setSelected(new Set(importable.map(c => c.id)));
+ })
+ .catch(() => setCandidates([]))
+ .finally(() => setLoading(false));
+ }, [open, billId]);
+
+ const importable = candidates.filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill');
+ const alreadyDone = candidates.filter(c => c.status === 'payment_exists' || c.status === 'matched_this_bill');
+
+ async function doImport(ids) {
+ if (ids.length === 0) { onClose(); return; }
+ setImporting(true);
+ try {
+ const result = await api.importHistoricalPayments(billId, ids);
+ toast.success(`${result.imported} payment${result.imported === 1 ? '' : 's'} imported for ${billName}`);
+ onImported?.(result);
+ onClose();
+ } catch (err) {
+ toast.error(err.message || 'Import failed');
+ } finally {
+ setImporting(false);
+ }
+ }
+
+ function toggleAll(checked) {
+ setSelected(checked ? new Set(importable.map(c => c.id)) : new Set());
+ }
+
+ function toggle(id) {
+ setSelected(prev => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+ }
+
+ const allSelected = importable.length > 0 && importable.every(c => selected.has(c.id));
+
+ // ── Choice step ──────────────────────────────────────────────────────────────
+ if (step === 'choice') {
+ return (
+
+ );
+ }
+
+ // ── Pick step ────────────────────────────────────────────────────────────────
+ return (
+
+ );
+}
diff --git a/client/components/BillMerchantRules.jsx b/client/components/BillMerchantRules.jsx
index e299196..aa78919 100644
--- a/client/components/BillMerchantRules.jsx
+++ b/client/components/BillMerchantRules.jsx
@@ -9,6 +9,7 @@ import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
+import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
// Debounce helper
function useDebounce(value, delay) {
@@ -75,7 +76,7 @@ function PreviewBadge({ count, loading, error }) {
);
}
-export default function BillMerchantRules({ billId, onRulesChanged }) {
+export default function BillMerchantRules({ billId, billName, onRulesChanged }) {
const [rules, setRules] = useState([]);
const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(true);
@@ -87,7 +88,8 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState(false);
const [conflicts, setConflicts] = useState([]);
- const [retroFeedback, setRetroFeedback] = useState(null);
+ const [retroFeedback, setRetroFeedback] = useState(null);
+ const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
const inputRef = useRef(null);
const debouncedInput = useDebounce(input.trim(), 380);
@@ -148,12 +150,9 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
setPreviewCount(null);
setConflicts([]);
setShowSuggestions(false);
- if (result.retroactive_matches > 0) {
- setRetroFeedback(result.retroactive_matches);
- toast.success(`Rule added — ${result.retroactive_matches} past payment${result.retroactive_matches === 1 ? '' : 's'} imported`);
- } else {
- toast.success('Rule added — will match future transactions automatically');
- }
+ toast.success('Rule added');
+ // Open the historical import dialog — lets user decide how to handle past transactions
+ setShowHistoricalDialog(true);
onRulesChanged?.();
} catch (err) {
toast.error(err.message || 'Failed to add rule');
@@ -298,6 +297,18 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
No rules yet. Type a merchant name or pick a recent transaction above to automatically import future payments for this bill.
)}
+
+ {/* Historical import dialog — fires after a rule is added */}
+ setShowHistoricalDialog(false)}
+ onImported={() => {
+ setShowHistoricalDialog(false);
+ onRulesChanged?.();
+ }}
+ />
);
}
diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx
index 90b9878..53e684b 100644
--- a/client/components/BillModal.jsx
+++ b/client/components/BillModal.jsx
@@ -1056,6 +1056,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
{
setLocalHasRules(true);
loadLinkedTransactions?.();
diff --git a/routes/bills.js b/routes/bills.js
index 481adf5..c0a48e5 100644
--- a/routes/bills.js
+++ b/routes/bills.js
@@ -1025,6 +1025,164 @@ router.post('/:id/merchant-rules', (req, res) => {
// ── DELETE /api/bills/:id/merchant-rules/:ruleId ──────────────────────────────
+// ── GET /api/bills/:id/merchant-rules/candidates ─────────────────────────────
+// All transactions matching this bill's merchant rules — any match_status.
+// Each item includes the current status so the user knows what will happen.
+
+router.get('/:id/merchant-rules/candidates', (req, res) => {
+ const db = getDb();
+ const billId = parseInt(req.params.id, 10);
+ if (!Number.isInteger(billId) || billId < 1)
+ return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
+ const bill = requireBill(db, billId, req.user.id);
+ if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
+
+ const rules = db.prepare(
+ 'SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?'
+ ).all(req.user.id, billId).map(r => r.merchant);
+
+ if (rules.length === 0) return res.json({ candidates: [] });
+
+ // Fetch all negative transactions for this user — any match status
+ let txRows;
+ try {
+ txRows = db.prepare(`
+ SELECT t.id, t.amount, t.payee, t.description, t.memo,
+ t.posted_date, t.transacted_at, t.match_status,
+ t.matched_bill_id,
+ b.name AS matched_bill_name
+ FROM transactions t
+ LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
+ WHERE t.user_id = ? AND t.amount < 0 AND t.ignored = 0
+ ORDER BY COALESCE(t.posted_date, substr(t.transacted_at,1,10)) DESC
+ LIMIT 500
+ `).all(req.user.id);
+ } catch {
+ return res.json({ candidates: [] });
+ }
+
+ // Existing payments for this bill keyed by transaction_id
+ const existingPayments = new Set(
+ db.prepare('SELECT transaction_id FROM payments WHERE bill_id = ? AND transaction_id IS NOT NULL AND deleted_at IS NULL')
+ .all(billId).map(r => r.transaction_id)
+ );
+
+ const candidates = [];
+ for (const tx of txRows) {
+ const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
+ if (!txMerchant) continue;
+ const matches = rules.some(r => txMerchant.includes(r) || r.includes(txMerchant));
+ if (!matches) continue;
+
+ const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
+ if (!paidDate) continue;
+
+ let status;
+ if (existingPayments.has(tx.id)) {
+ status = 'payment_exists';
+ } else if (tx.match_status === 'matched' && tx.matched_bill_id === billId) {
+ status = 'matched_this_bill';
+ } else if (tx.match_status === 'matched' && tx.matched_bill_id !== billId) {
+ status = 'matched_other_bill';
+ } else {
+ status = 'unmatched';
+ }
+
+ candidates.push({
+ id: tx.id,
+ payee: tx.payee || tx.description || '(no description)',
+ amount: Math.round(Math.abs(tx.amount)) / 100,
+ paid_date: paidDate,
+ status,
+ matched_bill_name: tx.matched_bill_name || null,
+ });
+ }
+
+ res.json({ candidates });
+});
+
+// ── POST /api/bills/:id/merchant-rules/import-historical ──────────────────────
+// Import a specific list of transaction IDs as payments for this bill.
+
+router.post('/:id/merchant-rules/import-historical', (req, res) => {
+ const db = getDb();
+ const billId = parseInt(req.params.id, 10);
+ if (!Number.isInteger(billId) || billId < 1)
+ return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
+ const bill = requireBill(db, billId, req.user.id);
+ if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
+
+ const ids = req.body?.transaction_ids;
+ if (!Array.isArray(ids) || ids.length === 0)
+ return res.status(400).json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR'));
+
+ const validIds = ids.filter(id => Number.isInteger(id) && id > 0);
+ if (validIds.length === 0)
+ return res.status(400).json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR'));
+
+ const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL');
+ const getTx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ? AND amount < 0');
+ const insertPayment = db.prepare(`
+ INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta)
+ VALUES (?, ?, ?, 'provider_sync', ?, ?)
+ `);
+ const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
+ const updateTx = db.prepare(`
+ UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') WHERE id = ?
+ `);
+
+ let imported = 0;
+ const lateAttributions = [];
+
+ try {
+ db.transaction(() => {
+ for (const txId of validIds) {
+ const tx = getTx.get(txId, req.user.id);
+ if (!tx) 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;
+ const billRow = getBill.get(billId);
+ const balCalc = billRow ? computeBalanceDelta(billRow, amount) : null;
+
+ const result = insertPayment.run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null);
+ if (result.changes > 0) {
+ if (balCalc) updateBalance.run(balCalc.new_balance, billId);
+ updateTx.run(billId, txId);
+ imported++;
+
+ // Check for late attribution
+ const { normalizeMerchant: nm, ..._ } = { normalizeMerchant };
+ const rules2 = db.prepare('SELECT due_day FROM bills WHERE id = ?').get(billId);
+ if (rules2?.due_day) {
+ const { lateAttributionCandidate } = require('../services/billMerchantRuleService');
+ // inline check
+ const paid = new Date(paidDate + 'T00:00:00');
+ const dom = paid.getDate();
+ if (dom <= 5) {
+ const prevEnd = new Date(paid.getFullYear(), paid.getMonth(), 0);
+ if (rules2.due_day <= prevEnd.getDate()) {
+ const suggested = prevEnd.toISOString().slice(0, 10);
+ const inserted = db.prepare('SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(txId, billId);
+ if (inserted) {
+ lateAttributions.push({ payment_id: inserted.id, bill_name: bill.name, original_date: paidDate, suggested_date: suggested, amount });
+ }
+ }
+ }
+ }
+ }
+ }
+ })();
+ } catch (err) {
+ console.error('[import-historical] Transaction failed:', err.message);
+ return res.status(500).json(standardizeError('Import failed', 'DB_ERROR'));
+ }
+
+ res.json({ imported, late_attributions: lateAttributions });
+});
+
router.delete('/:id/merchant-rules/:ruleId', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);