diff --git a/client/api.js b/client/api.js
index 0e82be5..12e759a 100644
--- a/client/api.js
+++ b/client/api.js
@@ -184,7 +184,8 @@ export const api = {
previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`),
addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }),
deleteMerchantRule: (id, ruleId) => del(`/bills/${id}/merchant-rules/${ruleId}`),
- merchantRuleCandidates: (id) => get(`/bills/${id}/merchant-rules/candidates`),
+ merchantRuleCandidates: (id) => get(`/bills/${id}/merchant-rules/candidates`),
+ toggleRuleAutoAttribute: (id, ruleId, on) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }),
importHistoricalPayments: (id, ids) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }),
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
diff --git a/client/components/BillMerchantRules.jsx b/client/components/BillMerchantRules.jsx
index aa78919..112ec8a 100644
--- a/client/components/BillMerchantRules.jsx
+++ b/client/components/BillMerchantRules.jsx
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import {
- AlertTriangle, Building2, CheckCircle2, Loader2, Plus, Tag, Trash2, X,
+ AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
@@ -21,23 +21,47 @@ function useDebounce(value, delay) {
return debounced;
}
-function RuleChip({ rule, onDelete, deleting }) {
+function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }) {
return (
-
-
- {rule.merchant}
-
-
+
+
+
+
+ {rule.merchant}
+
+
+
+ {/* Auto-attribute late payment toggle */}
+
+
+
+
);
}
@@ -88,8 +112,9 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState(false);
const [conflicts, setConflicts] = useState([]);
- const [retroFeedback, setRetroFeedback] = useState(null);
+ const [retroFeedback, setRetroFeedback] = useState(null);
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
+ const [togglingAutoAttr, setTogglingAutoAttr] = useState(null);
const inputRef = useRef(null);
const debouncedInput = useDebounce(input.trim(), 380);
@@ -175,6 +200,21 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
}
}
+ async function handleToggleAutoAttr(rule, enabled) {
+ setTogglingAutoAttr(rule.id);
+ try {
+ await api.toggleRuleAutoAttribute(billId, rule.id, enabled);
+ setRules(prev => prev.map(r => r.id === rule.id ? { ...r, auto_attribute_late: enabled ? 1 : 0 } : r));
+ toast.success(enabled
+ ? `Auto-fix on — ${rule.merchant} payments will automatically count for the prior month`
+ : 'Auto-fix off');
+ } catch (err) {
+ toast.error(err.message || 'Failed to update rule');
+ } finally {
+ setTogglingAutoAttr(null);
+ }
+ }
+
function pickSuggestion(s) {
setInput(s.label);
setShowSuggestions(false);
@@ -201,13 +241,16 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
{/* Existing rules */}
{rules.length > 0 && (
-
+
{rules.map(rule => (
))}
diff --git a/db/database.js b/db/database.js
index fa6361a..60e485e 100644
--- a/db/database.js
+++ b/db/database.js
@@ -2705,6 +2705,18 @@ function runMigrations() {
).run();
console.log(`[v0.82] Normalised ${result.changes} auto_match payment(s) to provider_sync`);
}
+ },
+ {
+ version: 'v0.83',
+ description: 'bill_merchant_rules: auto_attribute_late flag for bills that always post after month end',
+ dependsOn: ['v0.82'],
+ run: function() {
+ const cols = db.prepare('PRAGMA table_info(bill_merchant_rules)').all().map(c => c.name);
+ if (!cols.includes('auto_attribute_late')) {
+ db.exec('ALTER TABLE bill_merchant_rules ADD COLUMN auto_attribute_late INTEGER NOT NULL DEFAULT 0');
+ console.log('[v0.83] bill_merchant_rules.auto_attribute_late added');
+ }
+ }
}
];
diff --git a/routes/bills.js b/routes/bills.js
index 0d063bd..fd27a01 100644
--- a/routes/bills.js
+++ b/routes/bills.js
@@ -939,7 +939,7 @@ router.get('/:id/merchant-rules', (req, res) => {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const rules = db.prepare(`
- SELECT id, merchant, created_at FROM bill_merchant_rules
+ SELECT id, merchant, auto_attribute_late, created_at FROM bill_merchant_rules
WHERE user_id = ? AND bill_id = ?
ORDER BY created_at ASC
`).all(req.user.id, billId);
@@ -1183,6 +1183,26 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => {
res.json({ imported, late_attributions: lateAttributions });
});
+// PATCH /api/bills/:id/merchant-rules/:ruleId/auto-attribute
+// Toggle the auto_attribute_late flag for a single merchant rule.
+router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req, res) => {
+ const db = getDb();
+ const billId = parseInt(req.params.id, 10);
+ const ruleId = parseInt(req.params.ruleId, 10);
+ if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
+ return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
+ if (!requireBill(db, billId, req.user.id))
+ return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
+
+ const enabled = req.body?.enabled ? 1 : 0;
+ const changes = db.prepare(
+ "UPDATE bill_merchant_rules SET auto_attribute_late = ? WHERE id = ? AND user_id = ? AND bill_id = ?"
+ ).run(enabled, ruleId, req.user.id, billId).changes;
+
+ if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND'));
+ res.json({ id: ruleId, auto_attribute_late: enabled === 1 });
+});
+
router.delete('/:id/merchant-rules/:ruleId', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js
index 8b544fc..2d1235d 100644
--- a/services/billMerchantRuleService.js
+++ b/services/billMerchantRuleService.js
@@ -49,7 +49,7 @@ function applyMerchantRules(db, userId) {
let rules;
try {
rules = db.prepare(`
- SELECT bmr.bill_id, bmr.merchant, b.name AS bill_name, b.due_day
+ SELECT bmr.bill_id, bmr.merchant, bmr.auto_attribute_late, b.name AS bill_name, b.due_day
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 = ?
@@ -123,18 +123,23 @@ function applyMerchantRules(db, userId) {
// Check if this payment just missed the previous month's window
const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day);
if (suggestedDate) {
- // Fetch the payment id just inserted
- const inserted = db.prepare(
- 'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'
- ).get(tx.id, rule.bill_id);
- if (inserted) {
- lateAttributions.push({
- payment_id: inserted.id,
- bill_name: rule.bill_name || `Bill #${rule.bill_id}`,
- original_date: paidDate,
- suggested_date: suggestedDate,
- amount,
- });
+ if (rule.auto_attribute_late) {
+ // Auto-apply without prompting — this bill always posts after month end
+ db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL")
+ .run(suggestedDate, tx.id, rule.bill_id);
+ } else {
+ const inserted = db.prepare(
+ 'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'
+ ).get(tx.id, rule.bill_id);
+ if (inserted) {
+ lateAttributions.push({
+ payment_id: inserted.id,
+ bill_name: rule.bill_name || `Bill #${rule.bill_id}`,
+ original_date: paidDate,
+ suggested_date: suggestedDate,
+ amount,
+ });
+ }
}
}
}