feat: bill bank matching rules with pattern preview, conflict detection, retroactive payment sync
- Merchant rules link bills to bank transaction patterns for auto-import - Live preview badge shows match count as user types merchant name - Inline conflict warning if another bill already owns that pattern - Retroactive sync on save — imports historical payments immediately - Green Bank chip on bill list items with active rules - New endpoints: GET/POST/DELETE merchant-rules + GET preview
This commit is contained in:
parent
690a86611a
commit
a0fe7880df
|
|
@ -16,6 +16,8 @@
|
|||
|
||||
### ✨ Features
|
||||
|
||||
- **Bill bank matching rules** — Bills can now be linked to bank transaction patterns so payments import automatically without manual matching. A new "Bank matching rules" section in the Bill Modal (Transactions tab) shows all existing patterns for a bill as removable chips and lets the user add new ones by typing a merchant name or picking from a dropdown of recent unmatched transactions. As the user types, a live preview badge shows how many existing unmatched transactions the pattern would match (debounced, updates as-you-type). If the pattern is already claimed by another bill a conflict warning appears inline with the other bill's name, prompting the user to be more specific. On save the rule is applied retroactively — `syncBillPaymentsFromSimplefin` runs immediately and a green feedback banner reports how many historical payments were imported (e.g. "3 existing payments imported from your transaction history"). Bills with at least one active rule show a green **Bank** chip in the bill list with a tooltip. Four new endpoints: `GET /api/bills/:id/merchant-rules` (list rules + suggestions), `GET /api/bills/:id/merchant-rules/preview?merchant=X` (match count + conflict check), `POST /api/bills/:id/merchant-rules` (add + retroactive apply), `DELETE /api/bills/:id/merchant-rules/:ruleId` (remove).
|
||||
|
||||
- **SimpleFIN bank budget tracking** — A new opt-in mode replaces manually-entered starting amounts with the live balance of a connected bank account. When enabled from the Bank Sync settings page (Data → Bank Sync → Bank Budget Tracking), the user selects which financial account to track and configures a pending payment window (0–7 days, default 3). Budget remaining is calculated as: `bank balance − pending payments − unpaid bills this month`. Bills already marked paid are not double-counted — the bank balance already reflects them. Payments made within the pending window appear in the tracker with an amber **Pending** badge, flagging that the bank may not have processed the debit yet. The CalendarPage Monthly Money Map switches to four live metrics (Balance / Pending / Unpaid Bills / After Bills) when bank mode is active. The TrackerPage starting-amounts card shows the account name and "live balance" hint; the manual-edit button is hidden since there is nothing to manually set. Implementation: three new `user_settings` keys (`bank_tracking_enabled`, `bank_tracking_account_id`, `bank_tracking_pending_days`), a new `GET /api/data-sources/accounts/all` endpoint for the account picker, `buildBankTrackingSummary()` in both `summary.js` and `trackerService.js`, and `pending_cleared` flag on tracker rows.
|
||||
|
||||
- **404 page** — Unknown routes previously silently redirected to `/` with no feedback. Replaced both catch-all routes (`path="*"` inside the auth layout and at the top level) with a dedicated `NotFoundPage`. The page is standalone (no sidebar), theme-aware, and works for authenticated and unauthenticated users alike. Design features: a glitch counter that cycles each digit of "404" through random numbers before snapping to value (staggered by 180 ms per digit), a CSS mesh gradient background with primary-color radial glows, a 48 px grid overlay faded with a radial mask, a gradient clip-text `404` that scales from `6rem` to `14rem` via `clamp()`, and smart CTAs — "Go back" only appears when browser history exists, and the home button adapts to auth state. The bad path is shown inline in a `<code>` tag so the user knows what they typed.
|
||||
|
|
|
|||
|
|
@ -178,7 +178,11 @@ export const api = {
|
|||
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
||||
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
||||
billTransactions: (id) => get(`/bills/${id}/transactions`),
|
||||
syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`),
|
||||
syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`),
|
||||
billMerchantRules: (id) => get(`/bills/${id}/merchant-rules`),
|
||||
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}`),
|
||||
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`),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertTriangle, Building2, CheckCircle2, Loader2, Plus, Tag, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
// Debounce helper
|
||||
function useDebounce(value, delay) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function RuleChip({ rule, onDelete, deleting }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-primary/25 bg-primary/8 px-2.5 py-1 text-xs font-medium text-primary">
|
||||
<Tag className="h-3 w-3 shrink-0" />
|
||||
<span className="max-w-[12rem] truncate" title={rule.merchant}>{rule.merchant}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(rule)}
|
||||
disabled={deleting === rule.id}
|
||||
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:opacity-100 disabled:opacity-30"
|
||||
aria-label={`Remove rule "${rule.merchant}"`}
|
||||
>
|
||||
{deleting === rule.id
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <X className="h-3 w-3" />}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictWarning({ conflicts }) {
|
||||
if (!conflicts?.length) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
This pattern is already used by{' '}
|
||||
{conflicts.map((c, i) => (
|
||||
<span key={c.id}>
|
||||
{i > 0 && ', '}
|
||||
<strong>{c.name}</strong>
|
||||
</span>
|
||||
))}.
|
||||
Transactions could match both bills — consider making your pattern more specific.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewBadge({ count, loading }) {
|
||||
if (loading) return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />;
|
||||
if (count === null) return null;
|
||||
return (
|
||||
<span className={cn(
|
||||
'rounded-full border px-2 py-0.5 text-[10px] font-semibold tabular-nums',
|
||||
count > 0
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||
: 'border-border/60 bg-muted/40 text-muted-foreground',
|
||||
)}>
|
||||
{count === 0 ? 'No matches' : `${count} match${count === 1 ? '' : 'es'}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BillMerchantRules({ billId, onRulesChanged }) {
|
||||
const [rules, setRules] = useState([]);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [previewCount, setPreviewCount] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [conflicts, setConflicts] = useState([]);
|
||||
const [retroFeedback, setRetroFeedback] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const debouncedInput = useDebounce(input.trim(), 380);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!billId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.billMerchantRules(billId);
|
||||
setRules(data.rules || []);
|
||||
setSuggestions(data.suggestions || []);
|
||||
} catch {
|
||||
// non-fatal
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [billId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Preview debounced input
|
||||
useEffect(() => {
|
||||
if (!debouncedInput || debouncedInput.length < 2) {
|
||||
setPreviewCount(null);
|
||||
setConflicts([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPreviewLoading(true);
|
||||
api.previewMerchantRule(billId, debouncedInput)
|
||||
.then(data => {
|
||||
if (cancelled) return;
|
||||
setPreviewCount(data.match_count);
|
||||
setConflicts(data.conflicts || []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!cancelled) setPreviewLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [debouncedInput, billId]);
|
||||
|
||||
// Close suggestion dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handler(e) {
|
||||
if (!dropdownRef.current?.contains(e.target) && !inputRef.current?.contains(e.target)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
async function handleAdd(merchantText) {
|
||||
const text = (merchantText || input).trim();
|
||||
if (!text) return;
|
||||
setAdding(true);
|
||||
setRetroFeedback(null);
|
||||
try {
|
||||
const result = await api.addMerchantRule(billId, text);
|
||||
setRules(prev => {
|
||||
if (prev.some(r => r.id === result.rule?.id)) return prev;
|
||||
return [...prev, result.rule].filter(Boolean);
|
||||
});
|
||||
setInput('');
|
||||
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');
|
||||
}
|
||||
onRulesChanged?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to add rule');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(rule) {
|
||||
setDeleting(rule.id);
|
||||
try {
|
||||
await api.deleteMerchantRule(billId, rule.id);
|
||||
setRules(prev => prev.filter(r => r.id !== rule.id));
|
||||
toast.success(`Rule "${rule.merchant}" removed`);
|
||||
onRulesChanged?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to remove rule');
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
}
|
||||
|
||||
function pickSuggestion(s) {
|
||||
setInput(s.label);
|
||||
setShowSuggestions(false);
|
||||
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);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading matching rules…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* Existing rules */}
|
||||
{rules.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rules.map(rule => (
|
||||
<RuleChip
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
onDelete={handleDelete}
|
||||
deleting={deleting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retroactive feedback */}
|
||||
{retroFeedback !== null && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-500/25 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||
{retroFeedback} existing payment{retroFeedback === 1 ? '' : 's'} imported from your transaction history.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add rule input */}
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => { setInput(e.target.value); setShowSuggestions(true); setRetroFeedback(null); }}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleAdd(); }
|
||||
if (e.key === 'Escape') setShowSuggestions(false);
|
||||
}}
|
||||
placeholder="Type merchant name or pick from recent transactions…"
|
||||
className="h-8 pr-20 text-xs"
|
||||
disabled={adding}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<PreviewBadge count={previewCount} loading={previewLoading} />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-3 text-xs"
|
||||
disabled={adding || input.trim().length < 2}
|
||||
onClick={() => handleAdd()}
|
||||
>
|
||||
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Conflict warning */}
|
||||
{conflicts.length > 0 && input.trim().length >= 2 && (
|
||||
<div className="mt-1.5">
|
||||
<ConflictWarning conflicts={conflicts} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute left-0 right-14 top-full z-50 mt-1 overflow-hidden rounded-lg border border-border/80 bg-card shadow-md"
|
||||
>
|
||||
<p className="border-b border-border/50 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Recent unmatched transactions
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredSuggestions.map(s => {
|
||||
const amountVal = Math.abs(Number(s.amount || 0)) / 100;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left text-xs hover:bg-muted/50 focus:bg-muted/50 focus:outline-none"
|
||||
onClick={() => pickSuggestion(s)}
|
||||
>
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{s.label}</span>
|
||||
<span className="shrink-0 font-mono text-muted-foreground tabular-nums">
|
||||
${amountVal.toFixed(2)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{rules.length === 0 && !input && (
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
No rules yet. Type a merchant name or pick a recent transaction above to automatically import future payments for this bill.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from '@/components/ui/select';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||||
import BillMerchantRules from '@/components/BillMerchantRules';
|
||||
import {
|
||||
BILLING_SCHEDULE_OPTIONS,
|
||||
billingCycleForSchedule,
|
||||
|
|
@ -1039,6 +1040,27 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Bank Matching Rules */}
|
||||
{!isNew && (
|
||||
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
|
||||
<div className="border-b border-border/50 px-3 py-2.5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bank matching rules</p>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground/70">
|
||||
Transactions whose description contains these patterns are automatically imported as payments.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 py-3">
|
||||
<BillMerchantRules
|
||||
billId={sourceBill?.id}
|
||||
onRulesChanged={() => {
|
||||
refetch?.();
|
||||
loadLinkedTransactions?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
|
|
|
|||
|
|
@ -119,6 +119,14 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
|
|||
S
|
||||
</span>
|
||||
)}
|
||||
{!!bill.has_merchant_rule && (
|
||||
<span
|
||||
className="shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400"
|
||||
title="Bank matching rule active — transactions import automatically"
|
||||
>
|
||||
Bank
|
||||
</span>
|
||||
)}
|
||||
{hasHistory && (
|
||||
<span
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
|
||||
|
|
|
|||
153
routes/bills.js
153
routes/bills.js
|
|
@ -13,7 +13,8 @@ const {
|
|||
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
|
||||
const { standardizeError } = require('../middleware/errorFormatter');
|
||||
const { validatePaymentInput } = require('../services/paymentValidation');
|
||||
const { syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService');
|
||||
const { addMerchantRule, syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService');
|
||||
const { normalizeMerchant } = require('../services/subscriptionService');
|
||||
const { decorateTransaction } = require('../services/transactionService');
|
||||
|
||||
// ── GET /api/bills ────────────────────────────────────────────────────────────
|
||||
|
|
@ -892,4 +893,154 @@ router.patch('/:id/balance', (req, res) => {
|
|||
res.json({ id: billId, current_balance: val });
|
||||
});
|
||||
|
||||
// ── Merchant rule helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function requireBill(db, billId, userId) {
|
||||
return db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, userId);
|
||||
}
|
||||
|
||||
// Count unmatched transactions that would match a normalized merchant string.
|
||||
function previewMatchCount(db, userId, normalized) {
|
||||
if (!normalized || normalized.length < 2) return 0;
|
||||
const txRows = db.prepare(`
|
||||
SELECT t.payee, t.description, t.memo
|
||||
FROM transactions t
|
||||
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
|
||||
WHERE t.user_id = ?
|
||||
AND t.match_status = 'unmatched'
|
||||
AND t.ignored = 0
|
||||
AND t.amount < 0
|
||||
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
|
||||
`).all(userId);
|
||||
return txRows.filter(tx => {
|
||||
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
|
||||
return txMerchant && (txMerchant.includes(normalized) || normalized.includes(txMerchant));
|
||||
}).length;
|
||||
}
|
||||
|
||||
// Find bills (other than this one) that already claim this merchant.
|
||||
function findConflicts(db, userId, billId, normalized) {
|
||||
return db.prepare(`
|
||||
SELECT b.id, b.name
|
||||
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 = ? AND bmr.merchant = ? AND bmr.bill_id != ?
|
||||
`).all(userId, normalized, billId);
|
||||
}
|
||||
|
||||
// ── GET /api/bills/:id/merchant-rules ────────────────────────────────────────
|
||||
|
||||
router.get('/:id/merchant-rules', (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'));
|
||||
if (!requireBill(db, billId, req.user.id))
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
|
||||
const rules = db.prepare(`
|
||||
SELECT id, merchant, created_at FROM bill_merchant_rules
|
||||
WHERE user_id = ? AND bill_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`).all(req.user.id, billId);
|
||||
|
||||
// Suggest recent unmatched transactions as quick-pick options
|
||||
const suggestions = db.prepare(`
|
||||
SELECT t.id, t.payee, t.description, t.memo, t.amount, t.posted_date, t.transacted_at
|
||||
FROM transactions t
|
||||
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
|
||||
WHERE t.user_id = ?
|
||||
AND t.match_status = 'unmatched'
|
||||
AND t.ignored = 0
|
||||
AND t.amount < 0
|
||||
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
|
||||
ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) DESC
|
||||
LIMIT 30
|
||||
`).all(req.user.id).map(tx => {
|
||||
const raw = tx.payee || tx.description || tx.memo || '';
|
||||
const normalized = normalizeMerchant(raw);
|
||||
return { id: tx.id, label: raw.trim(), normalized, amount: tx.amount,
|
||||
date: tx.posted_date || String(tx.transacted_at || '').slice(0, 10) };
|
||||
}).filter(s => s.normalized.length >= 2);
|
||||
|
||||
res.json({ rules, suggestions });
|
||||
});
|
||||
|
||||
// ── GET /api/bills/:id/merchant-rules/preview ─────────────────────────────────
|
||||
|
||||
router.get('/:id/merchant-rules/preview', (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'));
|
||||
if (!requireBill(db, billId, req.user.id))
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
|
||||
const raw = String(req.query.merchant || '').trim();
|
||||
const normalized = normalizeMerchant(raw);
|
||||
if (!normalized || normalized.length < 2)
|
||||
return res.json({ match_count: 0, conflicts: [], normalized: '' });
|
||||
|
||||
const match_count = previewMatchCount(db, req.user.id, normalized);
|
||||
const conflicts = findConflicts(db, req.user.id, billId, normalized);
|
||||
|
||||
res.json({ match_count, conflicts, normalized });
|
||||
});
|
||||
|
||||
// ── POST /api/bills/:id/merchant-rules ───────────────────────────────────────
|
||||
|
||||
router.post('/:id/merchant-rules', (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'));
|
||||
if (!requireBill(db, billId, req.user.id))
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
|
||||
const raw = String(req.body?.merchant || '').trim();
|
||||
const normalized = normalizeMerchant(raw);
|
||||
if (!normalized || normalized.length < 2)
|
||||
return res.status(400).json(standardizeError('merchant must be at least 2 characters after normalisation', 'VALIDATION_ERROR', 'merchant'));
|
||||
|
||||
const conflicts = findConflicts(db, req.user.id, billId, normalized);
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
|
||||
`).run(req.user.id, billId, normalized);
|
||||
} catch (err) {
|
||||
return res.status(500).json(standardizeError('Failed to save rule', 'DB_ERROR'));
|
||||
}
|
||||
|
||||
// Retroactively apply the new rule to existing unmatched transactions
|
||||
const { added } = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
|
||||
|
||||
const rule = db.prepare('SELECT id, merchant, created_at FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ? AND merchant = ?')
|
||||
.get(req.user.id, billId, normalized);
|
||||
|
||||
res.status(201).json({ rule, retroactive_matches: added, conflicts });
|
||||
});
|
||||
|
||||
// ── DELETE /api/bills/:id/merchant-rules/:ruleId ──────────────────────────────
|
||||
|
||||
router.delete('/:id/merchant-rules/:ruleId', (req, res) => {
|
||||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
const ruleId = parseInt(req.params.ruleId, 10);
|
||||
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
|
||||
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
|
||||
if (!requireBill(db, billId, req.user.id))
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
|
||||
const changes = db.prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?')
|
||||
.run(ruleId, req.user.id, billId).changes;
|
||||
|
||||
if (changes === 0)
|
||||
return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND'));
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
Loading…
Reference in New Issue