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, + }); + } } } }