@@ -139,7 +211,7 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, b
+
: }
Decline
+
);
}
diff --git a/db/database.js b/db/database.js
index cf072f1..e86301c 100644
--- a/db/database.js
+++ b/db/database.js
@@ -1366,6 +1366,27 @@ function reconcileLegacyMigrations() {
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);
`);
}
+ },
+ {
+ 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);
+ `);
+ }
}
];
diff --git a/package.json b/package.json
index 3fa31ab..6fa374f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bill-tracker",
- "version": "0.33.5",
+ "version": "0.33.6",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
diff --git a/routes/subscriptions.js b/routes/subscriptions.js
index 47e6fd6..455fb76 100644
--- a/routes/subscriptions.js
+++ b/routes/subscriptions.js
@@ -10,6 +10,7 @@ const {
getSubscriptionSummary,
getSubscriptions,
} = require('../services/subscriptionService');
+const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService');
router.get('/', (req, res) => {
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) => {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
@@ -55,6 +96,8 @@ router.post('/recommendations/create', (req, res) => {
}
try {
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);
} 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));
diff --git a/services/bankSyncService.js b/services/bankSyncService.js
index 33aa6eb..11424c0 100644
--- a/services/bankSyncService.js
+++ b/services/bankSyncService.js
@@ -10,6 +10,7 @@ const {
} = require('./simplefinService');
const { getBankSyncConfig } = require('./bankSyncConfigService');
const { decorateDataSource } = require('./transactionService');
+const { applyMerchantRules } = require('./billMerchantRuleService');
function sinceEpoch() {
const { sync_days } = getBankSyncConfig();
@@ -118,7 +119,10 @@ async function runSync(db, userId, dataSource) {
WHERE id = ? AND user_id = ?
`).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 ───────────────────────────────────────────────────────────────
diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js
new file mode 100644
index 0000000..a44486d
--- /dev/null
+++ b/services/billMerchantRuleService.js
@@ -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 };
diff --git a/services/subscriptionService.js b/services/subscriptionService.js
index 0bcc2d2..acb786e 100644
--- a/services/subscriptionService.js
+++ b/services/subscriptionService.js
@@ -427,4 +427,5 @@ module.exports = {
lookupCatalog,
loadCatalog,
monthlyEquivalent,
+ normalizeMerchant,
};