feat: late-attribution prompt for bank payments that crossed month boundary
This commit is contained in:
parent
278521a612
commit
da4642dbd0
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -350,7 +350,8 @@ export const api = {
|
||||||
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
|
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
|
||||||
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
|
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
|
||||||
allFinancialAccounts: () => get('/data-sources/accounts/all'),
|
allFinancialAccounts: () => get('/data-sources/accounts/all'),
|
||||||
syncAllSources: () => post('/data-sources/sync-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
|
// Admin — bank sync feature flag
|
||||||
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
||||||
|
|
|
||||||
|
|
@ -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 (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;
|
if (count === null) return null;
|
||||||
return (
|
return (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
|
|
@ -84,6 +85,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
const [previewCount, setPreviewCount] = useState(null);
|
const [previewCount, setPreviewCount] = useState(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [previewError, setPreviewError] = useState(false);
|
||||||
const [conflicts, setConflicts] = useState([]);
|
const [conflicts, setConflicts] = useState([]);
|
||||||
const [retroFeedback, setRetroFeedback] = useState(null);
|
const [retroFeedback, setRetroFeedback] = useState(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
@ -116,13 +118,16 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
|
setPreviewError(false);
|
||||||
api.previewMerchantRule(billId, debouncedInput)
|
api.previewMerchantRule(billId, debouncedInput)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPreviewCount(data.match_count);
|
setPreviewCount(data.match_count);
|
||||||
setConflicts(data.conflicts || []);
|
setConflicts(data.conflicts || []);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {
|
||||||
|
if (!cancelled) setPreviewError(true);
|
||||||
|
})
|
||||||
.finally(() => { if (!cancelled) setPreviewLoading(false); });
|
.finally(() => { if (!cancelled) setPreviewLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [debouncedInput, billId]);
|
}, [debouncedInput, billId]);
|
||||||
|
|
@ -234,7 +239,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
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)}
|
onFocus={() => setShowSuggestions(true)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') { e.preventDefault(); handleAdd(); }
|
if (e.key === 'Enter') { e.preventDefault(); handleAdd(); }
|
||||||
|
|
@ -245,7 +250,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
|
||||||
disabled={adding}
|
disabled={adding}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1035,6 +1035,12 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
} else {
|
} else {
|
||||||
toast.info('No new matching transactions found.');
|
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) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Sync failed.');
|
toast.error(err.message || 'Sync failed.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
|
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
|
||||||
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
|
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
|
||||||
|
|
@ -29,6 +32,41 @@ import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket';
|
||||||
|
|
||||||
|
|
||||||
// ── Main page ──────────────────────────────────────────────────────────────
|
// ── 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() {
|
export default function TrackerPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -61,8 +99,10 @@ export default function TrackerPage() {
|
||||||
}, [setSearchParams]);
|
}, [setSearchParams]);
|
||||||
|
|
||||||
// Edit Bill modal: { bill, categories } when open, null when closed
|
// Edit Bill modal: { bill, categories } when open, null when closed
|
||||||
const [bankSyncStatus, setBankSyncStatus] = useState(null);
|
const [bankSyncStatus, setBankSyncStatus] = useState(null);
|
||||||
const [bankSyncing, setBankSyncing] = useState(false);
|
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 [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true');
|
||||||
const [editBillData, setEditBillData] = useState(null);
|
const [editBillData, setEditBillData] = useState(null);
|
||||||
// Edit Starting Amounts modal: true when open, false when closed
|
// Edit Starting Amounts modal: true when open, false when closed
|
||||||
|
|
@ -89,6 +129,18 @@ export default function TrackerPage() {
|
||||||
.catch(() => setBankSyncStatus(null));
|
.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) {
|
function navigate(delta) {
|
||||||
let nm = month + delta;
|
let nm = month + delta;
|
||||||
let ny = year;
|
let ny = year;
|
||||||
|
|
@ -104,6 +156,8 @@ export default function TrackerPage() {
|
||||||
const matched = result.auto_matched ?? 0;
|
const matched = result.auto_matched ?? 0;
|
||||||
const newTx = result.transactions_new ?? 0;
|
const newTx = result.transactions_new ?? 0;
|
||||||
const billNames = result.matched_bills ?? [];
|
const billNames = result.matched_bills ?? [];
|
||||||
|
const attributions = result.late_attributions ?? [];
|
||||||
|
|
||||||
if (matched > 0 && billNames.length > 0) {
|
if (matched > 0 && billNames.length > 0) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`Synced — ${billNames.join(', ')} ✓` +
|
`Synced — ${billNames.join(', ')} ✓` +
|
||||||
|
|
@ -117,6 +171,10 @@ export default function TrackerPage() {
|
||||||
} else {
|
} else {
|
||||||
toast.success('Synced — no new transactions');
|
toast.success('Synced — no new transactions');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surface late-attribution prompts (payments that just crossed a month boundary)
|
||||||
|
if (attributions.length > 0) setLateAttributions(attributions);
|
||||||
|
|
||||||
refetch();
|
refetch();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Bank sync failed');
|
toast.error(err.message || 'Bank sync failed');
|
||||||
|
|
@ -567,6 +625,31 @@ export default function TrackerPage() {
|
||||||
onSave={() => { setEditStartingOpen(false); refetch(); }}
|
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" */}
|
{/* PaymentLedgerDialog opened via Overdue Command Center "Pay Now" */}
|
||||||
{commandCenterPayRow && (
|
{commandCenterPayRow && (
|
||||||
<PaymentLedgerDialog
|
<PaymentLedgerDialog
|
||||||
|
|
|
||||||
|
|
@ -245,11 +245,12 @@ router.post('/sync-all', async (req, res) => {
|
||||||
return res.status(404).json(standardizeError('No SimpleFIN connections found', 'NOT_FOUND'));
|
return res.status(404).json(standardizeError('No SimpleFIN connections found', 'NOT_FOUND'));
|
||||||
}
|
}
|
||||||
|
|
||||||
let accountsUpserted = 0;
|
let accountsUpserted = 0;
|
||||||
let transactionsNew = 0;
|
let transactionsNew = 0;
|
||||||
let transactionsSkip = 0;
|
let transactionsSkip = 0;
|
||||||
let autoMatched = 0;
|
let autoMatched = 0;
|
||||||
const matchedBillSet = new Set(); // dedupe across multiple sources
|
const matchedBillSet = new Set();
|
||||||
|
const lateAttrAll = [];
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
|
|
@ -260,17 +261,19 @@ router.post('/sync-all', async (req, res) => {
|
||||||
transactionsSkip += result.transactionsSkip ?? 0;
|
transactionsSkip += result.transactionsSkip ?? 0;
|
||||||
autoMatched += result.autoMatched ?? 0;
|
autoMatched += result.autoMatched ?? 0;
|
||||||
for (const name of result.matched_bills ?? []) matchedBillSet.add(name);
|
for (const name of result.matched_bills ?? []) matchedBillSet.add(name);
|
||||||
|
for (const attr of result.late_attributions ?? []) lateAttrAll.push(attr);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(sanitizeErrorMessage(err?.message || 'Sync failed'));
|
errors.push(sanitizeErrorMessage(err?.message || 'Sync failed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
accounts_upserted: accountsUpserted,
|
accounts_upserted: accountsUpserted,
|
||||||
transactions_new: transactionsNew,
|
transactions_new: transactionsNew,
|
||||||
transactions_skip: transactionsSkip,
|
transactions_skip: transactionsSkip,
|
||||||
auto_matched: autoMatched,
|
auto_matched: autoMatched,
|
||||||
matched_bills: [...matchedBillSet],
|
matched_bills: [...matchedBillSet],
|
||||||
|
late_attributions: lateAttrAll,
|
||||||
errors,
|
errors,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -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));
|
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;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,9 @@ async function runSync(db, userId, dataSource, { days } = {}) {
|
||||||
`).run(partialError, dataSource.id, userId);
|
`).run(partialError, dataSource.id, userId);
|
||||||
|
|
||||||
// Apply any stored merchant→bill rules to newly synced transactions
|
// 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 ───────────────────────────────────────────────────────────────
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,22 @@ function addMerchantRule(db, userId, billId, merchant) {
|
||||||
// merchant rules, create payments, and mark the transactions matched.
|
// merchant rules, create payments, and mark the transactions matched.
|
||||||
// Returns { matched: number }.
|
// Returns { matched: number }.
|
||||||
function applyMerchantRules(db, userId) {
|
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;
|
let rules;
|
||||||
try {
|
try {
|
||||||
rules = db.prepare(`
|
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
|
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
|
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 = ?
|
WHERE bmr.user_id = ?
|
||||||
|
|
@ -68,7 +80,8 @@ function applyMerchantRules(db, userId) {
|
||||||
`);
|
`);
|
||||||
|
|
||||||
let matched = 0;
|
let matched = 0;
|
||||||
const matchedBills = new Map(); // bill_id → bill_name for the summary
|
const matchedBills = new Map(); // bill_id → bill_name for the summary
|
||||||
|
const lateAttributions = []; // payments that crossed a month boundary
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
|
|
@ -94,15 +107,33 @@ function applyMerchantRules(db, userId) {
|
||||||
updateTx.run(rule.bill_id, tx.id, userId);
|
updateTx.run(rule.bill_id, tx.id, userId);
|
||||||
matched++;
|
matched++;
|
||||||
matchedBills.set(rule.bill_id, rule.bill_name || `Bill #${rule.bill_id}`);
|
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) {
|
} catch (err) {
|
||||||
console.error('[applyMerchantRules] Transaction failed, no payments recorded:', err.message);
|
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
|
// 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 };
|
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 getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL');
|
||||||
const insertPayment = db.prepare(`
|
const insertPayment = db.prepare(`
|
||||||
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta)
|
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')
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
||||||
WHERE id = ? AND user_id = ? AND match_status = 'unmatched'
|
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;
|
let added = 0;
|
||||||
|
const lateAttributions = [];
|
||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const tx of txRows) {
|
for (const tx of txRows) {
|
||||||
|
|
@ -189,15 +223,30 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
|
||||||
if (balCalc) updateBalance.run(balCalc.new_balance, billId);
|
if (balCalc) updateBalance.run(balCalc.new_balance, billId);
|
||||||
updateTx.run(billId, tx.id, userId);
|
updateTx.run(billId, tx.id, userId);
|
||||||
added++;
|
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) {
|
} catch (err) {
|
||||||
console.error('[syncBillPaymentsFromSimplefin] Transaction failed, no payments recorded:', err.message);
|
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 };
|
module.exports = { addMerchantRule, applyMerchantRules, syncBillPaymentsFromSimplefin };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue