feat: merchant rules, auto-match on sync, duplicate API fix
- Removed duplicate unmatchTransaction API entry in api.js - Unmonitored accounts: no chevron, click-to-expand disabled, tx panel hidden - matched_bill_name included via LEFT JOIN bills in accounts query - BillPickerDialog resets search/selection on open - Link to bill: marks historical txs matched, stores merchant rule, applyMerchantRules catches other unmatched txs from same merchant - Track (new subscription): creates bill with is_subscription=1, stores merchant rule for ongoing tracking - SimpleFIN sync: applyMerchantRules runs after tx insert, auto-matches by merchant rule with payment_source='auto_match' - Auto-match payments have transaction_id set, treated same as manual matches - New services/billMerchantRuleService.js for rule storage and matching - Migration for bill_merchant_rules table
This commit is contained in:
parent
eeb26ccab1
commit
6b30ee4eb7
|
|
@ -182,7 +182,7 @@ export const api = {
|
||||||
// Subscriptions
|
// Subscriptions
|
||||||
subscriptions: () => get('/subscriptions'),
|
subscriptions: () => get('/subscriptions'),
|
||||||
confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
|
confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
|
||||||
unmatchTransaction: (transactionId) => post(`/matches/${transactionId}/unmatch`),
|
matchRecommendationToBill: (transactionIds, billId, merchant) => post('/subscriptions/recommendations/match-bill', { transaction_ids: transactionIds, bill_id: billId, merchant }),
|
||||||
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
|
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
|
||||||
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
||||||
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
|
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
|
||||||
|
|
|
||||||
|
|
@ -164,13 +164,13 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border/40 first:border-t-0">
|
<div className="border-t border-border/40 first:border-t-0">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/20 cursor-pointer"
|
className={cn('flex items-center gap-3 px-4 py-2.5', account.monitored && 'hover:bg-muted/20 cursor-pointer')}
|
||||||
onClick={onToggleExpand}
|
onClick={account.monitored ? onToggleExpand : undefined}
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground shrink-0" onClick={e => e.stopPropagation()}>
|
<span className="text-muted-foreground shrink-0 w-3.5" onClick={e => e.stopPropagation()}>
|
||||||
{expanded
|
{account.monitored && (expanded
|
||||||
? <ChevronDown className="h-3.5 w-3.5" />
|
? <ChevronDown className="h-3.5 w-3.5" />
|
||||||
: <ChevronRight className="h-3.5 w-3.5" />}
|
: <ChevronRight className="h-3.5 w-3.5" />)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Monitored toggle */}
|
{/* Monitored toggle */}
|
||||||
|
|
@ -214,7 +214,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && account.monitored && (
|
||||||
<div className="border-t border-border/30 bg-muted/10">
|
<div className="border-t border-border/30 bg-muted/10">
|
||||||
{account.transactions.length === 0 ? (
|
{account.transactions.length === 0 ? (
|
||||||
<p className="px-6 py-3 text-xs text-muted-foreground italic">No transactions synced for this account.</p>
|
<p className="px-6 py-3 text-xs text-muted-foreground italic">No transactions synced for this account.</p>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
Link2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pause,
|
Pause,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -18,6 +19,10 @@ import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
|
|
||||||
const TYPE_LABELS = {
|
const TYPE_LABELS = {
|
||||||
|
|
@ -112,7 +117,74 @@ function SubscriptionRow({ item, onEdit, onToggle }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, busy }) {
|
function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, busy }) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return (bills || []).filter(b => !q || b.name.toLowerCase().includes(q));
|
||||||
|
}, [bills, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Link to existing bill</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
<span className="font-medium text-foreground">{recommendation?.name}</span>
|
||||||
|
{recommendation && (
|
||||||
|
<span className="ml-2 text-xs">{recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · {fmt(recommendation.expected_amount)}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The matching transactions will be marked as paid under the selected bill.
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Search bills…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 divide-y divide-border/40">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground text-center">No bills found.</p>
|
||||||
|
) : filtered.map(bill => (
|
||||||
|
<button
|
||||||
|
key={bill.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(bill.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/40',
|
||||||
|
selectedId === bill.id && 'bg-primary/10 text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{bill.name}</span>
|
||||||
|
<span className="shrink-0 ml-3 text-xs text-muted-foreground tabular-nums">
|
||||||
|
${(bill.expected_amount ?? 0).toFixed(2)}/mo
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={() => onConfirm(selectedId)} disabled={!selectedId || busy}>
|
||||||
|
{busy ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />Linking…</> : 'Link to bill'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
|
@ -139,7 +211,7 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, b
|
||||||
<p className="tracker-number text-xs font-semibold text-emerald-600 dark:text-emerald-300">
|
<p className="tracker-number text-xs font-semibold text-emerald-600 dark:text-emerald-300">
|
||||||
{fmt(recommendation.monthly_equivalent)} / mo
|
{fmt(recommendation.monthly_equivalent)} / mo
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex gap-2 sm:justify-end">
|
<div className="mt-3 flex flex-wrap gap-2 sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -151,6 +223,17 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, b
|
||||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||||
Decline
|
Decline
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onMatch(recommendation)}
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5" />
|
||||||
|
Link to bill
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -172,10 +255,12 @@ export default function SubscriptionsPage() {
|
||||||
const [data, setData] = useState({ summary: {}, subscriptions: [] });
|
const [data, setData] = useState({ summary: {}, subscriptions: [] });
|
||||||
const [recommendations, setRecommendations] = useState([]);
|
const [recommendations, setRecommendations] = useState([]);
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [bills, setBills] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [recommendationsLoading, setRecommendationsLoading] = useState(true);
|
const [recommendationsLoading, setRecommendationsLoading] = useState(true);
|
||||||
const [busyId, setBusyId] = useState(null);
|
const [busyId, setBusyId] = useState(null);
|
||||||
const [modal, setModal] = useState(null);
|
const [modal, setModal] = useState(null);
|
||||||
|
const [matchTarget, setMatchTarget] = useState(null); // recommendation being linked
|
||||||
|
|
||||||
const subscriptionCategoryId = useMemo(() => {
|
const subscriptionCategoryId = useMemo(() => {
|
||||||
const match = categories.find(category => /subscrip/i.test(category.name));
|
const match = categories.find(category => /subscrip/i.test(category.name));
|
||||||
|
|
@ -213,6 +298,7 @@ export default function SubscriptionsPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
loadRecommendations();
|
loadRecommendations();
|
||||||
|
api.bills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
|
||||||
}, [load, loadRecommendations]);
|
}, [load, loadRecommendations]);
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
|
|
@ -258,6 +344,21 @@ export default function SubscriptionsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function matchRecommendationToBill(billId) {
|
||||||
|
if (!matchTarget || !billId) return;
|
||||||
|
setBusyId(`match-${matchTarget.id}`);
|
||||||
|
try {
|
||||||
|
const result = await api.matchRecommendationToBill(matchTarget.transaction_ids, billId, matchTarget.merchant);
|
||||||
|
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 !== matchTarget.id));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Could not link recommendation to bill.');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openManualSubscription() {
|
function openManualSubscription() {
|
||||||
setModal({
|
setModal({
|
||||||
bill: null,
|
bill: null,
|
||||||
|
|
@ -369,9 +470,10 @@ export default function SubscriptionsPage() {
|
||||||
key={recommendation.id}
|
key={recommendation.id}
|
||||||
recommendation={recommendation}
|
recommendation={recommendation}
|
||||||
categoryId={subscriptionCategoryId}
|
categoryId={subscriptionCategoryId}
|
||||||
busy={busyId === `rec-${recommendation.id}` || busyId === `dec-${recommendation.id}`}
|
busy={busyId === `rec-${recommendation.id}` || busyId === `dec-${recommendation.id}` || busyId === `match-${recommendation.id}`}
|
||||||
onAccept={acceptRecommendation}
|
onAccept={acceptRecommendation}
|
||||||
onDecline={declineRecommendation}
|
onDecline={declineRecommendation}
|
||||||
|
onMatch={rec => setMatchTarget(rec)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -392,6 +494,15 @@ export default function SubscriptionsPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<BillPickerDialog
|
||||||
|
open={!!matchTarget}
|
||||||
|
onClose={() => setMatchTarget(null)}
|
||||||
|
recommendation={matchTarget}
|
||||||
|
bills={bills}
|
||||||
|
onConfirm={matchRecommendationToBill}
|
||||||
|
busy={!!busyId?.startsWith('match-')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1366,6 +1366,27 @@ function reconcileLegacyMigrations() {
|
||||||
ON declined_subscription_hints(user_id);
|
ON declined_subscription_hints(user_id);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.67',
|
||||||
|
description: 'bill_merchant_rules: persistent merchant→bill auto-match rules',
|
||||||
|
check: function() {
|
||||||
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_merchant_rules'").get();
|
||||||
|
},
|
||||||
|
run: function() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS bill_merchant_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
||||||
|
merchant TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(user_id, bill_id, merchant)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user
|
||||||
|
ON bill_merchant_rules(user_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -2167,6 +2188,25 @@ function runMigrations() {
|
||||||
ON declined_subscription_hints(user_id);
|
ON declined_subscription_hints(user_id);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.67',
|
||||||
|
description: 'bill_merchant_rules: persistent merchant→bill auto-match rules',
|
||||||
|
dependsOn: ['v0.66'],
|
||||||
|
run: function() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS bill_merchant_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
||||||
|
merchant TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(user_id, bill_id, merchant)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user
|
||||||
|
ON bill_merchant_rules(user_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.33.5",
|
"version": "0.33.6",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const {
|
||||||
getSubscriptionSummary,
|
getSubscriptionSummary,
|
||||||
getSubscriptions,
|
getSubscriptions,
|
||||||
} = require('../services/subscriptionService');
|
} = require('../services/subscriptionService');
|
||||||
|
const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService');
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
@ -41,6 +42,46 @@ router.post('/recommendations/decline', (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/subscriptions/recommendations/match-bill
|
||||||
|
// Link an existing bill to all transactions in a recommendation (no new bill created).
|
||||||
|
router.post('/recommendations/match-bill', (req, res) => {
|
||||||
|
const billId = parseInt(req.body?.bill_id, 10);
|
||||||
|
const rawIds = Array.isArray(req.body?.transaction_ids) ? req.body.transaction_ids : [];
|
||||||
|
const txIds = rawIds.map(id => parseInt(id, 10)).filter(n => Number.isInteger(n) && n > 0).slice(0, 50);
|
||||||
|
|
||||||
|
if (!Number.isInteger(billId) || billId < 1) {
|
||||||
|
return res.status(400).json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id'));
|
||||||
|
}
|
||||||
|
if (txIds.length === 0) {
|
||||||
|
return res.status(400).json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR', 'transaction_ids'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
||||||
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
|
const update = db.prepare(`
|
||||||
|
UPDATE transactions
|
||||||
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND user_id = ? AND ignored = 0 AND match_status != 'matched'
|
||||||
|
`);
|
||||||
|
let matchedCount = 0;
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const id of txIds) {
|
||||||
|
matchedCount += update.run(billId, id, req.user.id).changes;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Store merchant rule for ongoing auto-matching on future syncs
|
||||||
|
const merchant = typeof req.body?.merchant === 'string' ? req.body.merchant.trim() : '';
|
||||||
|
if (merchant) addMerchantRule(db, req.user.id, billId, merchant);
|
||||||
|
|
||||||
|
// Apply rules immediately to catch any unmatched transactions beyond the explicit list
|
||||||
|
const { matched: autoMatched } = applyMerchantRules(db, req.user.id);
|
||||||
|
|
||||||
|
res.json({ ok: true, matched_count: matchedCount + autoMatched, bill_name: bill.name });
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/recommendations/create', (req, res) => {
|
router.post('/recommendations/create', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
ensureUserDefaultCategories(req.user.id);
|
ensureUserDefaultCategories(req.user.id);
|
||||||
|
|
@ -55,6 +96,8 @@ router.post('/recommendations/create', (req, res) => {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const created = createSubscriptionFromRecommendation(db, req.user.id, req.body || {});
|
const created = createSubscriptionFromRecommendation(db, req.user.id, req.body || {});
|
||||||
|
// Store merchant rule so future SimpleFIN transactions auto-match this bill
|
||||||
|
if (req.body?.merchant) addMerchantRule(db, req.user.id, created.id, req.body.merchant);
|
||||||
res.status(201).json(created);
|
res.status(201).json(created);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(err.status || 400).json(standardizeError(err.message || 'Could not create subscription', err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', err.field || null));
|
res.status(err.status || 400).json(standardizeError(err.message || 'Could not create subscription', err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', err.field || null));
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const {
|
||||||
} = require('./simplefinService');
|
} = require('./simplefinService');
|
||||||
const { getBankSyncConfig } = require('./bankSyncConfigService');
|
const { getBankSyncConfig } = require('./bankSyncConfigService');
|
||||||
const { decorateDataSource } = require('./transactionService');
|
const { decorateDataSource } = require('./transactionService');
|
||||||
|
const { applyMerchantRules } = require('./billMerchantRuleService');
|
||||||
|
|
||||||
function sinceEpoch() {
|
function sinceEpoch() {
|
||||||
const { sync_days } = getBankSyncConfig();
|
const { sync_days } = getBankSyncConfig();
|
||||||
|
|
@ -118,7 +119,10 @@ async function runSync(db, userId, dataSource) {
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
`).run(partialError, dataSource.id, userId);
|
`).run(partialError, dataSource.id, userId);
|
||||||
|
|
||||||
return { accountsUpserted, transactionsNew, transactionsSkip, errlist: raw._errlistSummary || null };
|
// Apply any stored merchant→bill rules to newly synced transactions
|
||||||
|
const { matched: autoMatched } = applyMerchantRules(db, userId);
|
||||||
|
|
||||||
|
return { accountsUpserted, transactionsNew, transactionsSkip, autoMatched, errlist: raw._errlistSummary || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { normalizeMerchant } = require('./subscriptionService');
|
||||||
|
|
||||||
|
// Persist a merchant→bill rule so future synced transactions auto-match.
|
||||||
|
function addMerchantRule(db, userId, billId, merchant) {
|
||||||
|
const normalized = normalizeMerchant(merchant);
|
||||||
|
if (!normalized || normalized.length < 3) return;
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
|
||||||
|
`).run(userId, billId, normalized);
|
||||||
|
} catch {
|
||||||
|
// Table may not exist yet on legacy DBs — safe to skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan all unmatched negative transactions for this user, apply any stored
|
||||||
|
// merchant rules, create payments, and mark the transactions matched.
|
||||||
|
// Returns { matched: number }.
|
||||||
|
function applyMerchantRules(db, userId) {
|
||||||
|
let rules;
|
||||||
|
try {
|
||||||
|
rules = db.prepare(`
|
||||||
|
SELECT bmr.bill_id, bmr.merchant
|
||||||
|
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 = ?
|
||||||
|
`).all(userId);
|
||||||
|
} catch {
|
||||||
|
return { matched: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.length === 0) return { matched: 0 };
|
||||||
|
|
||||||
|
const txRows = db.prepare(`
|
||||||
|
SELECT id, amount, payee, description, memo, posted_date, transacted_at
|
||||||
|
FROM transactions
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND match_status = 'unmatched'
|
||||||
|
AND ignored = 0
|
||||||
|
AND amount < 0
|
||||||
|
`).all(userId);
|
||||||
|
|
||||||
|
if (txRows.length === 0) return { matched: 0 };
|
||||||
|
|
||||||
|
const insertPayment = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
|
||||||
|
VALUES (?, ?, ?, 'auto_match', ?)
|
||||||
|
`);
|
||||||
|
const updateTx = db.prepare(`
|
||||||
|
UPDATE transactions
|
||||||
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND user_id = ? AND match_status = 'unmatched'
|
||||||
|
`);
|
||||||
|
|
||||||
|
let matched = 0;
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const tx of txRows) {
|
||||||
|
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
|
||||||
|
if (!txMerchant) continue;
|
||||||
|
|
||||||
|
const rule = rules.find(r =>
|
||||||
|
txMerchant.includes(r.merchant) || r.merchant.includes(txMerchant)
|
||||||
|
);
|
||||||
|
if (!rule) 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;
|
||||||
|
insertPayment.run(rule.bill_id, amount, paidDate, tx.id);
|
||||||
|
updateTx.run(rule.bill_id, tx.id, userId);
|
||||||
|
matched++;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { matched };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { addMerchantRule, applyMerchantRules };
|
||||||
|
|
@ -427,4 +427,5 @@ module.exports = {
|
||||||
lookupCatalog,
|
lookupCatalog,
|
||||||
loadCatalog,
|
loadCatalog,
|
||||||
monthlyEquivalent,
|
monthlyEquivalent,
|
||||||
|
normalizeMerchant,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue