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 ( + { if (!v) onClose(); }}> + + + Past payments found + + {loading + ? 'Searching your bank history…' + : importable.length === 0 + ? `No past transactions found matching ${billName}.` + : `Found ${importable.length} past transaction${importable.length === 1 ? '' : 's'} matching ${billName}. What would you like to do?`} + + + + {loading && ( +
+ +
+ )} + + {!loading && importable.length > 0 && ( +
+ {/* Populate all */} + + + {/* Pick one by one */} + + + {/* Skip */} + +
+ )} + + {!loading && importable.length === 0 && alreadyDone.length > 0 && ( +

+ All matching transactions are already linked or have payments. Nothing to import. +

+ )} + + {importing && ( +
+ + Importing… +
+ )} + + {(!loading || importing) && ( + + + + )} +
+
+ ); + } + + // ── Pick step ──────────────────────────────────────────────────────────────── + return ( + { if (!v) onClose(); }}> + + + Choose transactions to import + + Select which past transactions to import as payments for {billName}. + + + + {/* Select all toggle */} +
+ + {selected.size} selected +
+ + {/* Transaction list */} +
+ {importable.map(c => ( + + ))} + + {/* Already-done items (dimmed, informational) */} + {alreadyDone.length > 0 && ( +
+

+ Already handled +

+ {alreadyDone.map(c => ( +
+ +
+

{c.payee}

+ +
+ {fmt(c.amount)} +
+ ))} +
+ )} +
+ + + + + +
+
+ ); +} 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);