feat: SimpleFIN payment backfill button on subscription bills (v0.33.7.2)

This commit is contained in:
null 2026-05-29 04:19:20 -05:00
parent da6a93804b
commit 32f1568515
9 changed files with 203 additions and 17 deletions

View File

@ -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

View File

@ -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`),

View File

@ -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
/>
<p className="text-[10px] text-muted-foreground/70">0-30 days before renewal.</p>
</div>
{!isNew && (sourceBill?.has_merchant_rule || ['simplefin_recommendation', 'catalog_match'].includes(sourceBill?.subscription_source)) ? (
<div className="col-span-2 border-t border-border/40 pt-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-medium text-muted-foreground">SimpleFIN payment history</p>
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
Scan unmatched bank transactions and backfill any missing payments.
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 gap-1.5"
disabled={syncingPayments}
onClick={async () => {
setSyncingPayments(true);
try {
const result = await api.syncBillSimplefinPayments(sourceBill.id);
if (result.added > 0) {
toast.success(`Synced ${result.added} payment${result.added !== 1 ? 's' : ''} from SimpleFIN.`);
} else {
toast.info('No new payments found in SimpleFIN history.');
}
} catch (err) {
toast.error(err.message || 'Sync failed.');
} finally {
setSyncingPayments(false);
}
}}
>
{syncingPayments
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing</>
: <><RefreshCw className="h-3.5 w-3.5" />Sync payments</>}
</Button>
</div>
</div>
) : null}
</div>
)}
</div>

View File

@ -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',

View File

@ -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": {

View File

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

View File

@ -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);
}
})();

View File

@ -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 };

View File

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