From 32f156851563f88d76c0fc65c73d4bdbde2489f8 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 29 May 2026 04:19:20 -0500 Subject: [PATCH] feat: SimpleFIN payment backfill button on subscription bills (v0.33.7.2) --- HISTORY.md | 17 +++++++ client/api.js | 1 + client/components/BillModal.jsx | 43 ++++++++++++++++- client/lib/version.js | 12 ++--- package.json | 2 +- routes/bills.js | 23 ++++++++- routes/subscriptions.js | 20 ++++++-- services/billMerchantRuleService.js | 72 ++++++++++++++++++++++++++++- services/subscriptionService.js | 30 ++++++++++-- 9 files changed, 203 insertions(+), 17 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index be84e97..ca83522 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,22 @@ # Bill Tracker β€” Changelog +## v0.33.7.2 + +### πŸš€ Features + +- **SimpleFIN payment backfill** β€” The bill edit/subscription modal now shows a "Sync payments" button for bills that have a merchant rule stored (`has_merchant_rule === 1`). This covers both "Track from recommendation" and "Link to bill" flows. Clicking it calls `POST /api/bills/:id/sync-simplefin-payments` which scans unmatched negative transactions matching the bill's merchant rules and auto-creates `payment_source = 'auto_match'` records with the transaction's date and amount. + +### πŸ› Bug Fixes + +- **Subscription -> bill flow now creates payments** β€” Both `POST /api/subscriptions/recommendations/match-bill` and the "Create subscription from recommendation" path now insert auto-match payment records alongside the transaction match update. + +### πŸ›  Internal + +- `GET /api/bills/:id` now returns `has_merchant_rule` boolean for conditional UI rendering. +- New helper `syncBillPaymentsFromSimplefin()` in `billMerchantRuleService.js` handles merchant extraction, rule fallback from notes, transaction scanning, and payment creation. + +--- + ## v0.28.01 ### πŸ† Major Features diff --git a/client/api.js b/client/api.js index 994915b..2e6c7b0 100644 --- a/client/api.js +++ b/client/api.js @@ -169,6 +169,7 @@ 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`), 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/BillModal.jsx b/client/components/BillModal.jsx index a2e2425..b8bf87f 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { ChevronDown, Copy, Link2, Link2Off, Pencil, Plus, Trash2 } from 'lucide-react'; +import { ChevronDown, Copy, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -85,6 +85,7 @@ function paymentSourceLabel(source) { file_import: 'File import', provider_sync: 'Sync', transaction_match: 'Transaction', + auto_match: 'SimpleFIN', }; return labels[source] || source || 'Manual'; } @@ -95,6 +96,7 @@ function paymentSourceTone(source) { file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400', provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400', transaction_match: 'border-primary/25 bg-primary/10 text-primary', + auto_match: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400', }; return tones[source] || tones.manual; } @@ -137,6 +139,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa const [minimumPayment, setMinimumPayment] = useState(sourceBill?.minimum_payment == null ? '' : String(sourceBill.minimum_payment)); const [snowballInclude, setSnowballInclude] = useState(!!sourceBill?.snowball_include); const [snowballExempt, setSnowballExempt] = useState(!!sourceBill?.snowball_exempt); + const [syncingPayments, setSyncingPayments] = useState(false); const [showDebtSection, setShowDebtSection] = useState( () => isDebtCat(categories, sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE) || !!sourceBill?.snowball_include @@ -705,6 +708,44 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa />

0-30 days before renewal.

+ {!isNew && (sourceBill?.has_merchant_rule || ['simplefin_recommendation', 'catalog_match'].includes(sourceBill?.subscription_source)) ? ( +
+
+
+

SimpleFIN payment history

+

+ Scan unmatched bank transactions and backfill any missing payments. +

+
+ +
+
+ ) : null} )} diff --git a/client/lib/version.js b/client/lib/version.js index a0d2ed8..9735c45 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -6,8 +6,13 @@ export const APP_NAME = 'BillTracker'; export const RELEASE_NOTES = { version: APP_VERSION, - date: '2026-05-15', + date: '2026-05-29', highlights: [ + { + icon: 'πŸ”„', + title: 'SimpleFIN payment backfill', + desc: 'Subscription bills with merchant rules now have a Sync Payments button in the edit modal. It scans unmatched SimpleFIN transactions and auto-creates payment records for missing history.', + }, { icon: 'πŸ›‘οΈ', title: 'Safer financial history', @@ -33,11 +38,6 @@ export const RELEASE_NOTES = { title: 'Ramsey Snowball mode', desc: 'Debt Snowball now defaults to smallest-balance-first, keeps custom drag ordering behind a toggle, skips mortgages by default, and adds an inline Ramsey readiness checklist.', }, - { - icon: 'πŸŽ›οΈ', - title: 'Cleaner tracker and interface polish', - desc: 'The Tracker remaining card now shows the active 1st or 15th balance, Roadmap columns breathe on desktop and mobile, and app surfaces have a calmer darker treatment.', - }, ], image: { src: '/img/doingmypart.jpg', diff --git a/package.json b/package.json index 71724cb..c1bfe89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.33.7.1", + "version": "0.33.7.2", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/bills.js b/routes/bills.js index 0d19aa3..b6a167c 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -13,6 +13,7 @@ const { const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { standardizeError } = require('../middleware/errorFormatter'); const { validatePaymentInput } = require('../services/paymentValidation'); +const { syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService'); const { decorateTransaction } = require('../services/transactionService'); // ── GET /api/bills ──────────────────────────────────────────────────────────── @@ -229,7 +230,10 @@ router.get('/:id', (req, res) => { SELECT b.*, c.name AS category_name, CASE WHEN EXISTS( SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id - ) THEN 1 ELSE 0 END AS has_history_ranges + ) THEN 1 ELSE 0 END AS has_history_ranges, + CASE WHEN EXISTS( + SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id + ) THEN 1 ELSE 0 END AS has_merchant_rule FROM bills b LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL WHERE b.id = ? AND b.user_id = ? AND b.deleted_at IS NULL @@ -373,6 +377,23 @@ router.post('/:id/restore', (req, res) => { res.json(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)); }); +// POST /api/bills/:id/sync-simplefin-payments +// Scan unmatched SimpleFIN transactions for this bill's merchant rules and +// backfill any missing payments. +router.post('/:id/sync-simplefin-payments', (req, res) => { + const billId = parseInt(req.params.id, 10); + if (!Number.isInteger(billId)) return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); + const db = getDb(); + const bill = db.prepare('SELECT id 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')); + try { + const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId); + res.json(result); + } catch (err) { + res.status(500).json(standardizeError(err.message || 'Sync failed', 'SYNC_ERROR')); + } +}); + // ── GET /api/bills/:id/payments?page=1&limit=20 ─────────────────────────────── router.get('/:id/payments', (req, res) => { const db = getDb(); diff --git a/routes/subscriptions.js b/routes/subscriptions.js index 455fb76..bfe2201 100644 --- a/routes/subscriptions.js +++ b/routes/subscriptions.js @@ -60,15 +60,29 @@ router.post('/recommendations/match-bill', (req, res) => { 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(` + const placeholders = txIds.map(() => '?').join(','); + const txRows = db.prepare(` + SELECT id, amount, posted_date, transacted_at + FROM transactions + WHERE user_id = ? AND id IN (${placeholders}) AND ignored = 0 AND match_status != 'matched' + `).all(req.user.id, ...txIds); + + const updateTx = 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' `); + const insertPayment = db.prepare(` + INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id) + VALUES (?, ?, ?, 'auto_match', ?) + `); let matchedCount = 0; db.transaction(() => { - for (const id of txIds) { - matchedCount += update.run(billId, id, req.user.id).changes; + for (const tx of txRows) { + const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); + const amount = Math.round(Math.abs(tx.amount)) / 100; + matchedCount += updateTx.run(billId, tx.id, req.user.id).changes; + if (paidDate) insertPayment.run(billId, amount, paidDate, tx.id); } })(); diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index a44486d..2d430f0 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -81,4 +81,74 @@ function applyMerchantRules(db, userId) { return { matched }; } -module.exports = { addMerchantRule, applyMerchantRules }; +// Sync all unmatched SimpleFIN transactions for a single bill using its stored +// merchant rules. If no rule exists yet but the bill has a detected merchant in +// its notes, the rule is created on the fly. +// Returns { added: number }. +function syncBillPaymentsFromSimplefin(db, userId, billId) { + // Load rules for this specific bill + let rules; + try { + rules = db.prepare(` + SELECT merchant FROM bill_merchant_rules + WHERE user_id = ? AND bill_id = ? + `).all(userId, billId).map(r => r.merchant); + } catch { + return { added: 0 }; + } + + // Fallback: extract merchant from notes "Detected from recurring merchant: X" + if (rules.length === 0) { + const bill = db.prepare('SELECT notes FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, userId); + const match = bill?.notes?.match(/Detected from recurring merchant:\s*(.+)/i); + if (match) { + const extracted = normalizeMerchant(match[1].trim()); + if (extracted && extracted.length >= 3) { + addMerchantRule(db, userId, billId, extracted); + rules = [extracted]; + } + } + if (rules.length === 0) return { added: 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 { added: 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 added = 0; + db.transaction(() => { + 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; + const amount = Math.round(Math.abs(tx.amount)) / 100; + insertPayment.run(billId, amount, paidDate, tx.id); + updateTx.run(billId, tx.id, userId); + added++; + } + })(); + + return { added }; +} + +module.exports = { addMerchantRule, applyMerchantRules, syncBillPaymentsFromSimplefin }; diff --git a/services/subscriptionService.js b/services/subscriptionService.js index acb786e..5d9d94a 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -151,7 +151,10 @@ function decorateSubscription(bill) { function getSubscriptions(db, userId) { return db.prepare(` - SELECT b.*, c.name AS category_name + SELECT b.*, c.name AS category_name, + CASE WHEN EXISTS( + SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id + ) THEN 1 ELSE 0 END AS has_merchant_rule FROM bills b LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL WHERE b.user_id = ? @@ -405,12 +408,31 @@ function createSubscriptionFromRecommendation(db, userId, payload = {}) { ? payload.transaction_ids.map(id => Number(id)).filter(Number.isInteger).slice(0, 50) : []; if (ids.length > 0) { - const update = db.prepare(` + const placeholders = ids.map(() => '?').join(','); + const txRows = db.prepare(` + SELECT id, amount, posted_date, transacted_at + FROM transactions + WHERE user_id = ? AND id IN (${placeholders}) AND ignored = 0 + `).all(userId, ...ids); + + const updateTx = db.prepare(` UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP - WHERE user_id = ? AND id = ? AND ignored = 0 + WHERE id = ? AND user_id = ? AND ignored = 0 AND match_status != 'matched' `); - for (const id of ids) update.run(created.id, userId, id); + const insertPayment = db.prepare(` + INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id) + VALUES (?, ?, ?, 'auto_match', ?) + `); + + db.transaction(() => { + for (const tx of txRows) { + const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); + const amount = Math.round(Math.abs(tx.amount)) / 100; + updateTx.run(created.id, tx.id, userId); + if (paidDate) insertPayment.run(created.id, amount, paidDate, tx.id); + } + })(); } return decorateSubscription(created);