feat: historical import batch selection UI, backend endpoint, DB migration v0.83

This commit is contained in:
null 2026-06-04 02:36:36 -05:00
parent d5a0b65532
commit 5689fc95c2
5 changed files with 115 additions and 34 deletions

View File

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

View File

@ -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 (
<span className="inline-flex items-center gap-1.5 rounded-full border border-primary/25 bg-primary/8 px-2.5 py-1 text-xs font-medium text-primary">
<Tag className="h-3 w-3 shrink-0" />
<span className="max-w-[12rem] truncate" title={rule.merchant}>{rule.merchant}</span>
<button
type="button"
onClick={() => onDelete(rule)}
disabled={deleting === rule.id}
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:opacity-100 disabled:opacity-30"
aria-label={`Remove rule "${rule.merchant}"`}
>
{deleting === rule.id
? <Loader2 className="h-3 w-3 animate-spin" />
: <X className="h-3 w-3" />}
</button>
</span>
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<div className="flex items-center gap-1.5 min-w-0">
<Tag className="h-3 w-3 shrink-0 text-primary" />
<span className="text-xs font-medium text-foreground truncate max-w-[10rem]" title={rule.merchant}>
{rule.merchant}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Auto-attribute late payment toggle */}
<button
type="button"
onClick={() => onToggleAutoAttr(rule, !rule.auto_attribute_late)}
disabled={togglingAutoAttr === rule.id}
title={rule.auto_attribute_late
? 'Auto-fixing month crossing is ON — payments in the first 5 days are automatically moved to the prior month'
: 'Turn on to automatically move payments that post in the first 5 days of a month to the prior month (e.g. AT&T posts June 1, belongs to May)'}
className={cn(
'flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold transition-colors',
rule.auto_attribute_late
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20'
: 'border-border/50 bg-background text-muted-foreground hover:bg-muted/40',
)}
>
{togglingAutoAttr === rule.id
? <Loader2 className="h-2.5 w-2.5 animate-spin" />
: <CalendarDays className="h-2.5 w-2.5" />}
{rule.auto_attribute_late ? 'Auto-fix on' : 'Auto-fix'}
</button>
<button
type="button"
onClick={() => onDelete(rule)}
disabled={deleting === rule.id}
className="rounded-full p-0.5 text-muted-foreground/60 transition-opacity hover:text-destructive disabled:opacity-30"
aria-label={`Remove rule "${rule.merchant}"`}
>
{deleting === rule.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <X className="h-3 w-3" />}
</button>
</div>
</div>
);
}
@ -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 && (
<div className="flex flex-wrap gap-2">
<div className="space-y-1.5">
{rules.map(rule => (
<RuleChip
key={rule.id}
rule={rule}
billId={billId}
onDelete={handleDelete}
onToggleAutoAttr={handleToggleAutoAttr}
deleting={deleting}
togglingAutoAttr={togglingAutoAttr}
/>
))}
</div>

View File

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

View File

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

View File

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