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.
|
||||
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue