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);