feat: historical payment import dialog for bank merchant rules

This commit is contained in:
null 2026-06-04 02:05:15 -05:00
parent d04f03b6b1
commit d2d3045afe
5 changed files with 453 additions and 8 deletions

View File

@ -184,6 +184,8 @@ export const api = {
previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`), previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`),
addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }), addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }),
deleteMerchantRule: (id, ruleId) => del(`/bills/${id}/merchant-rules/${ruleId}`), 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}`), billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data), saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`), billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`),

View File

@ -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 (
<span className={cn('text-[10px] font-medium', meta.className)}>
{Icon && <Icon className="inline h-3 w-3 mr-0.5 -mt-px" />}
{label}
</span>
);
}
// 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 (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Past payments found</DialogTitle>
<DialogDescription>
{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?`}
</DialogDescription>
</DialogHeader>
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{!loading && importable.length > 0 && (
<div className="space-y-2.5">
{/* Populate all */}
<button
type="button"
onClick={() => doImport(importable.map(c => c.id))}
disabled={importing}
className="w-full rounded-xl border border-primary/30 bg-primary/5 p-3.5 text-left transition-colors hover:bg-primary/10 disabled:opacity-50"
>
<p className="text-sm font-semibold">Import all {importable.length} payments</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Treat your bank as the source of truth import every matching transaction as a payment.
</p>
</button>
{/* Pick one by one */}
<button
type="button"
onClick={() => setStep('pick')}
disabled={importing}
className="w-full rounded-xl border border-border/60 bg-muted/20 p-3.5 text-left transition-colors hover:bg-muted/40 disabled:opacity-50"
>
<p className="text-sm font-semibold">Choose which ones</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Review the list and select exactly which transactions to import.
</p>
</button>
{/* Skip */}
<button
type="button"
onClick={onClose}
disabled={importing}
className="w-full rounded-xl border border-border/60 bg-background p-3.5 text-left transition-colors hover:bg-muted/20 disabled:opacity-50"
>
<p className="text-sm font-semibold">Skip future only</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Only import new transactions going forward. Past history stays as-is.
</p>
</button>
</div>
)}
{!loading && importable.length === 0 && alreadyDone.length > 0 && (
<p className="text-sm text-muted-foreground">
All matching transactions are already linked or have payments. Nothing to import.
</p>
)}
{importing && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Importing
</div>
)}
{(!loading || importing) && (
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={importing}>Cancel</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
// Pick step
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Choose transactions to import</DialogTitle>
<DialogDescription>
Select which past transactions to import as payments for {billName}.
</DialogDescription>
</DialogHeader>
{/* Select all toggle */}
<div className="flex items-center justify-between border-b border-border/50 pb-2">
<button
type="button"
onClick={() => toggleAll(!allSelected)}
className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{allSelected
? <CheckCircle2 className="h-4 w-4 text-primary" />
: <Circle className="h-4 w-4" />}
{allSelected ? 'Deselect all' : 'Select all'}
</button>
<span className="text-xs text-muted-foreground">{selected.size} selected</span>
</div>
{/* Transaction list */}
<div className="max-h-64 overflow-y-auto space-y-1">
{importable.map(c => (
<button
key={c.id}
type="button"
onClick={() => toggle(c.id)}
className={cn(
'w-full flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors',
selected.has(c.id)
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:bg-muted/30',
)}
>
{selected.has(c.id)
? <CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />
: <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{c.payee}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<CalendarDays className="h-3 w-3" />{fmtDate(c.paid_date)}
</span>
<StatusChip candidate={c} />
</div>
</div>
<span className="shrink-0 font-mono text-sm font-semibold tabular-nums">
{fmt(c.amount)}
</span>
</button>
))}
{/* Already-done items (dimmed, informational) */}
{alreadyDone.length > 0 && (
<div className="pt-2 border-t border-border/40">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/60 px-1 mb-1">
Already handled
</p>
{alreadyDone.map(c => (
<div key={c.id} className="flex items-center gap-3 rounded-lg border border-border/30 bg-muted/10 px-3 py-2 opacity-50">
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{c.payee}</p>
<StatusChip candidate={c} />
</div>
<span className="shrink-0 font-mono text-sm tabular-nums">{fmt(c.amount)}</span>
</div>
))}
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={() => setStep('choice')} disabled={importing}>Back</Button>
<Button
onClick={() => doImport([...selected])}
disabled={importing || selected.size === 0}
>
{importing
? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />Importing</>
: `Import ${selected.size} payment${selected.size === 1 ? '' : 's'}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -9,6 +9,7 @@ import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
// Debounce helper // Debounce helper
function useDebounce(value, delay) { 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 [rules, setRules] = useState([]);
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -87,7 +88,8 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState(false); const [previewError, setPreviewError] = useState(false);
const [conflicts, setConflicts] = useState([]); const [conflicts, setConflicts] = useState([]);
const [retroFeedback, setRetroFeedback] = useState(null); const [retroFeedback, setRetroFeedback] = useState(null);
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
const inputRef = useRef(null); const inputRef = useRef(null);
const debouncedInput = useDebounce(input.trim(), 380); const debouncedInput = useDebounce(input.trim(), 380);
@ -148,12 +150,9 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
setPreviewCount(null); setPreviewCount(null);
setConflicts([]); setConflicts([]);
setShowSuggestions(false); setShowSuggestions(false);
if (result.retroactive_matches > 0) { toast.success('Rule added');
setRetroFeedback(result.retroactive_matches); // Open the historical import dialog lets user decide how to handle past transactions
toast.success(`Rule added — ${result.retroactive_matches} past payment${result.retroactive_matches === 1 ? '' : 's'} imported`); setShowHistoricalDialog(true);
} else {
toast.success('Rule added — will match future transactions automatically');
}
onRulesChanged?.(); onRulesChanged?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to add rule'); 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. No rules yet. Type a merchant name or pick a recent transaction above to automatically import future payments for this bill.
</p> </p>
)} )}
{/* Historical import dialog — fires after a rule is added */}
<BillHistoricalImportDialog
billId={billId}
billName={billName || 'this bill'}
open={showHistoricalDialog}
onClose={() => setShowHistoricalDialog(false)}
onImported={() => {
setShowHistoricalDialog(false);
onRulesChanged?.();
}}
/>
</div> </div>
); );
} }

View File

@ -1056,6 +1056,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
<div className="px-3 py-3"> <div className="px-3 py-3">
<BillMerchantRules <BillMerchantRules
billId={sourceBill?.id} billId={sourceBill?.id}
billName={sourceBill?.name}
onRulesChanged={() => { onRulesChanged={() => {
setLocalHasRules(true); setLocalHasRules(true);
loadLinkedTransactions?.(); loadLinkedTransactions?.();

View File

@ -1025,6 +1025,164 @@ router.post('/:id/merchant-rules', (req, res) => {
// ── DELETE /api/bills/:id/merchant-rules/:ruleId ────────────────────────────── // ── 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) => { router.delete('/:id/merchant-rules/:ruleId', (req, res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);