feat: late-attribution prompt for bank payments that crossed month boundary

This commit is contained in:
null 2026-06-04 00:06:16 -05:00
parent 278521a612
commit da4642dbd0
9 changed files with 237 additions and 25 deletions

View File

@ -10,6 +10,12 @@
- **Migration version sync assertion**`_runMigrationVersions` module-level variable is now populated by `runMigrations()` before its loop runs. `reconcileLegacyMigrations()` — which runs after `runMigrations()` on legacy-DB upgrade paths — compares its own version array against the stored list and throws a descriptive error if any version appears in one array but not the other. Catches drift between the two migration arrays at startup rather than silently misconfiguring a legacy schema.
- **Late-attribution coverage extended to single-bill sync and BillModal**`syncBillPaymentsFromSimplefin` (called by the Sync button in BillModal's Bank Matching Rules section) now runs the same late-attribution detection as the full sync. When a single-bill sync finds a payment that crossed a month boundary, it returns `late_attributions` in its response. The BillModal sync handler dispatches a `tracker:late-attributions` DOM event; TrackerPage listens and appends those to the existing queue, so the attribution dialog appears regardless of whether the sync came from the tracker header button or from inside a BillModal. Late attributions from both sources are processed in the same queue.
- **`BillMerchantRules` preview shows error state instead of silently failing** — The debounced preview API call previously swallowed all errors with `.catch(() => {})`, causing the preview badge to simply disappear on network or server failure. Added `previewError` state: on failure a red "Error" chip appears in the input, and typing again clears it.
- **Late-attribution prompt for bank-synced payments that just missed month end** — When `applyMerchantRules` auto-matches a transaction and the payment's `posted_date` falls within 5 days into a new month while the bill's `due_day` was in the prior month, the payment is flagged as a late-attribution candidate. After "Sync Bank" completes on the TrackerPage, a dialog appears for each candidate: "AT&T payment of $332.97 posted June 1 — should it count for May?" The user can accept (moves `paid_date` to the last day of the prior month so the tracker shows it as paid that month) or dismiss (keeps the original date). Multiple candidates are queued and shown one at a time. The date-only reclassification goes through a new `PATCH /api/payments/:id/attribute-to-month` endpoint that is specifically allowed for `provider_sync` payments (the existing PUT endpoint rejects transaction-linked payments). Amount and bank link are never changed.
- **Encryption key fully app-managed — no env var required**`TOKEN_ENCRYPTION_KEY` environment variable support removed entirely. The auto-generated DB key (`_auto_encryption_key` in the settings table) is now the primary mechanism, not a fallback. The `[security]` warning that fired on every startup when no env var was set is gone. On first startup a 48-byte cryptographically random key is generated and persisted to the database; subsequent restarts reuse it. All existing encrypted data (SMTP password, OIDC secret, SimpleFIN tokens, push notification tokens) continues to decrypt correctly.
- **TrackerPage crash fixed — `activeTotalExpected` temporal dead zone** — The `cashflow` block added in an earlier change referenced `activeTotalExpected` and `activePaidTowardDue` before their `const` declarations. JavaScript's temporal dead zone caused `Cannot access 'activeTotalExpected' before initialization` on every tracker load. Fixed by moving the four `active*` declarations above the cashflow block that depends on them.

View File

@ -351,6 +351,7 @@ export const api = {
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
allFinancialAccounts: () => get('/data-sources/accounts/all'),
syncAllSources: () => post('/data-sources/sync-all', {}),
attributePaymentToMonth: (id, paid_date) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }),
// Admin — bank sync feature flag
bankSyncConfig: () => get('/admin/bank-sync-config'),

View File

@ -59,8 +59,9 @@ function ConflictWarning({ conflicts }) {
);
}
function PreviewBadge({ count, loading }) {
function PreviewBadge({ count, loading, error }) {
if (loading) return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />;
if (error) return <span className="rounded-full border border-destructive/30 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold text-destructive">Error</span>;
if (count === null) return null;
return (
<span className={cn(
@ -84,6 +85,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [previewCount, setPreviewCount] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState(false);
const [conflicts, setConflicts] = useState([]);
const [retroFeedback, setRetroFeedback] = useState(null);
const inputRef = useRef(null);
@ -116,13 +118,16 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
}
let cancelled = false;
setPreviewLoading(true);
setPreviewError(false);
api.previewMerchantRule(billId, debouncedInput)
.then(data => {
if (cancelled) return;
setPreviewCount(data.match_count);
setConflicts(data.conflicts || []);
})
.catch(() => {})
.catch(() => {
if (!cancelled) setPreviewError(true);
})
.finally(() => { if (!cancelled) setPreviewLoading(false); });
return () => { cancelled = true; };
}, [debouncedInput, billId]);
@ -234,7 +239,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
<Input
ref={inputRef}
value={input}
onChange={e => { setInput(e.target.value); setShowSuggestions(true); setRetroFeedback(null); }}
onChange={e => { setInput(e.target.value); setShowSuggestions(true); setRetroFeedback(null); setPreviewError(false); }}
onFocus={() => setShowSuggestions(true)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); handleAdd(); }
@ -245,7 +250,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
disabled={adding}
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<PreviewBadge count={previewCount} loading={previewLoading} />
<PreviewBadge count={previewCount} loading={previewLoading} error={previewError} />
</div>
</div>
<Button

View File

@ -1035,6 +1035,12 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
} else {
toast.info('No new matching transactions found.');
}
// Surface late-attribution prompts to the tracker page
if (result.late_attributions?.length) {
window.dispatchEvent(new CustomEvent('tracker:late-attributions', {
detail: { attributions: result.late_attributions },
}));
}
} catch (err) {
toast.error(err.message || 'Sync failed.');
} finally {

View File

@ -14,6 +14,9 @@ import { Skeleton } from '@/components/ui/Skeleton';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
@ -29,6 +32,41 @@ import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket';
// Main page
function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }) {
if (!attr) return null;
const fmtDate = (d) => new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
const priorMonth = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
return (
<Dialog open onOpenChange={onDismiss}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Payment posted after month end</DialogTitle>
<DialogDescription>
A <strong>{attr.bill_name}</strong> payment of <strong>{fmt(attr.amount)}</strong> posted on{' '}
<strong>{fmtDate(attr.original_date)}</strong> after the previous month closed.
Should it count for {priorMonth}?
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">What this does</p>
<p className="text-sm">Moves the paid date to <strong>{fmtDate(attr.suggested_date)}</strong> so it appears in the prior month's tracker. Amount and bank link are unchanged.</p>
</div>
{remaining > 0 && (
<p className="text-xs text-muted-foreground">{remaining} more similar payment{remaining > 1 ? 's' : ''} to review after this.</p>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onDismiss} disabled={busy}>
Keep as {fmtDate(attr.original_date).replace(/,?\s*\d{4}/, '').trim()}
</Button>
<Button onClick={() => onAccept(attr)} disabled={busy}>
{busy ? 'Moving…' : `Count for ${priorMonth}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function TrackerPage() {
const [searchParams, setSearchParams] = useSearchParams();
const now = new Date();
@ -63,6 +101,8 @@ export default function TrackerPage() {
// Edit Bill modal: { bill, categories } when open, null when closed
const [bankSyncStatus, setBankSyncStatus] = useState(null);
const [bankSyncing, setBankSyncing] = useState(false);
const [lateAttributions, setLateAttributions] = useState([]); // pending month-attribution prompts
const [attrBusy, setAttrBusy] = useState(null); // payment_id being resolved
const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true');
const [editBillData, setEditBillData] = useState(null);
// Edit Starting Amounts modal: true when open, false when closed
@ -89,6 +129,18 @@ export default function TrackerPage() {
.catch(() => setBankSyncStatus(null));
}, []);
// Listen for late-attribution events fired by BillModal's single-bill sync
useEffect(() => {
function handler(e) {
const attrs = e.detail?.attributions;
if (Array.isArray(attrs) && attrs.length > 0) {
setLateAttributions(prev => [...prev, ...attrs]);
}
}
window.addEventListener('tracker:late-attributions', handler);
return () => window.removeEventListener('tracker:late-attributions', handler);
}, []);
function navigate(delta) {
let nm = month + delta;
let ny = year;
@ -104,6 +156,8 @@ export default function TrackerPage() {
const matched = result.auto_matched ?? 0;
const newTx = result.transactions_new ?? 0;
const billNames = result.matched_bills ?? [];
const attributions = result.late_attributions ?? [];
if (matched > 0 && billNames.length > 0) {
toast.success(
`Synced — ${billNames.join(', ')}` +
@ -117,6 +171,10 @@ export default function TrackerPage() {
} else {
toast.success('Synced — no new transactions');
}
// Surface late-attribution prompts (payments that just crossed a month boundary)
if (attributions.length > 0) setLateAttributions(attributions);
refetch();
} catch (err) {
toast.error(err.message || 'Bank sync failed');
@ -567,6 +625,31 @@ export default function TrackerPage() {
onSave={() => { setEditStartingOpen(false); refetch(); }}
/>
{/* Late-attribution dialog — fires after sync when a payment just crossed a month boundary */}
{lateAttributions.length > 0 && (
<LateAttributionDialog
attr={lateAttributions[0]}
remaining={lateAttributions.length - 1}
busy={attrBusy === lateAttributions[0]?.payment_id}
onAccept={async (attr) => {
setAttrBusy(attr.payment_id);
try {
await api.attributePaymentToMonth(attr.payment_id, attr.suggested_date);
const month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
toast.success(`${attr.bill_name} payment moved to ${month}`);
setLateAttributions(prev => prev.slice(1)); // dismiss only on success
refetch();
} catch (err) {
toast.error(err.message || 'Failed to reclassify payment — try again');
// keep the attribution in queue so user can retry
} finally {
setAttrBusy(null);
}
}}
onDismiss={() => setLateAttributions(prev => prev.slice(1))}
/>
)}
{/* PaymentLedgerDialog opened via Overdue Command Center "Pay Now" */}
{commandCenterPayRow && (
<PaymentLedgerDialog

View File

@ -249,7 +249,8 @@ router.post('/sync-all', async (req, res) => {
let transactionsNew = 0;
let transactionsSkip = 0;
let autoMatched = 0;
const matchedBillSet = new Set(); // dedupe across multiple sources
const matchedBillSet = new Set();
const lateAttrAll = [];
const errors = [];
for (const source of sources) {
@ -260,6 +261,7 @@ router.post('/sync-all', async (req, res) => {
transactionsSkip += result.transactionsSkip ?? 0;
autoMatched += result.autoMatched ?? 0;
for (const name of result.matched_bills ?? []) matchedBillSet.add(name);
for (const attr of result.late_attributions ?? []) lateAttrAll.push(attr);
} catch (err) {
errors.push(sanitizeErrorMessage(err?.message || 'Sync failed'));
}
@ -271,6 +273,7 @@ router.post('/sync-all', async (req, res) => {
transactions_skip: transactionsSkip,
auto_matched: autoMatched,
matched_bills: [...matchedBillSet],
late_attributions: lateAttrAll,
errors,
});
} catch (err) {

View File

@ -460,4 +460,63 @@ router.post('/:id/restore', (req, res) => {
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
});
// PATCH /api/payments/:id/attribute-to-month
// Changes only the paid_date of a provider_sync payment to move it into the
// correct billing period when it posted just after month end.
// Does not touch the amount or balance_delta.
router.patch('/:id/attribute-to-month', (req, res) => {
const db = getDb();
const paymentId = parseInt(req.params.id, 10);
if (!Number.isInteger(paymentId) || paymentId < 1) {
return res.status(400).json(standardizeError('Invalid payment id', 'VALIDATION_ERROR'));
}
const { paid_date } = req.body;
if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) {
return res.status(400).json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date'));
}
// Validate it is a real calendar date
const newDate = new Date(paid_date + 'T00:00:00');
if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) {
return res.status(400).json(standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date'));
}
try {
const payment = db.prepare(`
SELECT p.* FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL
`).get(paymentId, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND'));
// Only allow date-only reclassification for provider_sync payments
if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
return res.status(409).json(standardizeError(
'Only bank-synced payments can be reclassified to a different month',
'RECLASSIFY_ONLY_SYNC',
));
}
// Sanity check: new date must be in the month immediately before the original date
const orig = new Date(payment.paid_date + 'T00:00:00');
const origYM = orig.getFullYear() * 12 + orig.getMonth();
const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
if (newYM !== origYM - 1) {
return res.status(400).json(standardizeError(
'The new paid_date must be in the month immediately before the original payment date',
'VALIDATION_ERROR', 'paid_date',
));
}
db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?")
.run(paid_date, paymentId);
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(paymentId, req.user.id));
} catch (err) {
console.error('[payments] attribute-to-month error:', err.message);
res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR'));
}
});
module.exports = router;

View File

@ -130,9 +130,9 @@ async function runSync(db, userId, dataSource, { days } = {}) {
`).run(partialError, dataSource.id, userId);
// Apply any stored merchant→bill rules to newly synced transactions
const { matched: autoMatched, matched_bills: matchedBills } = applyMerchantRules(db, userId);
const { matched: autoMatched, matched_bills: matchedBills, late_attributions: lateAttributions } = applyMerchantRules(db, userId);
return { accountsUpserted, transactionsNew, transactionsSkip, autoMatched, matched_bills: matchedBills || [], errlist: raw._errlistSummary || null };
return { accountsUpserted, transactionsNew, transactionsSkip, autoMatched, matched_bills: matchedBills || [], late_attributions: lateAttributions || [], errlist: raw._errlistSummary || null };
}
// ─── Public API ───────────────────────────────────────────────────────────────

View File

@ -22,10 +22,22 @@ function addMerchantRule(db, userId, billId, merchant) {
// merchant rules, create payments, and mark the transactions matched.
// Returns { matched: number }.
function applyMerchantRules(db, userId) {
// Detects when a payment posted just after month end but the bill was due in the prior month.
// Grace window: up to LATE_ATTR_DAYS days into the new month.
const LATE_ATTR_DAYS = 5;
function lateAttributionCandidate(paidDateStr, dueDayOfMonth) {
const paid = new Date(paidDateStr + 'T00:00:00');
const dayOfMonth = paid.getDate();
if (dayOfMonth > LATE_ATTR_DAYS) return null;
const prevMonthLastDay = new Date(paid.getFullYear(), paid.getMonth(), 0);
if (dueDayOfMonth > prevMonthLastDay.getDate()) return null;
return prevMonthLastDay.toISOString().slice(0, 10); // suggested prior-month date
}
let rules;
try {
rules = db.prepare(`
SELECT bmr.bill_id, bmr.merchant, b.name AS bill_name
SELECT bmr.bill_id, bmr.merchant, 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 = ?
@ -69,6 +81,7 @@ function applyMerchantRules(db, userId) {
let matched = 0;
const matchedBills = new Map(); // bill_id → bill_name for the summary
const lateAttributions = []; // payments that crossed a month boundary
try {
db.transaction(() => {
@ -94,15 +107,33 @@ function applyMerchantRules(db, userId) {
updateTx.run(rule.bill_id, tx.id, userId);
matched++;
matchedBills.set(rule.bill_id, rule.bill_name || `Bill #${rule.bill_id}`);
// 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,
});
}
}
}
}
})();
} catch (err) {
console.error('[applyMerchantRules] Transaction failed, no payments recorded:', err.message);
return { matched: 0, matched_bills: [] };
return { matched: 0, matched_bills: [], late_attributions: [] };
}
return { matched, matched_bills: [...matchedBills.values()] };
return { matched, matched_bills: [...matchedBills.values()], late_attributions: lateAttributions };
}
// Sync all unmatched SimpleFIN transactions for a single bill using its stored
@ -158,6 +189,7 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
if (txRows.length === 0) return { added: 0 };
const billMeta = db.prepare('SELECT name, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL').get(billId);
const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL');
const insertPayment = db.prepare(`
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta)
@ -169,8 +201,10 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
WHERE id = ? AND user_id = ? AND match_status = 'unmatched'
`);
const getPaymentId = db.prepare('SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL');
let added = 0;
const lateAttributions = [];
try {
db.transaction(() => {
for (const tx of txRows) {
@ -189,15 +223,30 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
if (balCalc) updateBalance.run(balCalc.new_balance, billId);
updateTx.run(billId, tx.id, userId);
added++;
// Check for late attribution (payment just crossed month boundary)
const suggestedDate = billMeta ? lateAttributionCandidate(paidDate, billMeta.due_day) : null;
if (suggestedDate) {
const inserted = getPaymentId.get(tx.id, billId);
if (inserted) {
lateAttributions.push({
payment_id: inserted.id,
bill_name: billMeta.name || `Bill #${billId}`,
original_date: paidDate,
suggested_date: suggestedDate,
amount,
});
}
}
}
}
})();
} catch (err) {
console.error('[syncBillPaymentsFromSimplefin] Transaction failed, no payments recorded:', err.message);
return { added: 0 };
return { added: 0, late_attributions: [] };
}
return { added };
return { added, late_attributions: lateAttributions };
}
module.exports = { addMerchantRule, applyMerchantRules, syncBillPaymentsFromSimplefin };