// ╔═══════════════════════════════════════════════════════════════════╗
// nasama-accounting-v2.reconcile.jsx
// Bank Reconciliation — match a bank statement (CSV / Excel / PDF)
// against the recorded ledger, surface unrecorded & outstanding items,
// pinpoint the unexplained gap, and post the missing entries.
//
// Reuses core.jsx globals: parseDelimitedRows, parseImportDate, toCents,
// fmtAED, accountBalance, uid, todayStr, toast, C, hasPermission.
// Excel via global XLSX (loaded in HTML); PDF via global pdfjsLib (loaded in HTML).
// ╚═══════════════════════════════════════════════════════════════════╝
const RECON_PDF_WORKER = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
// ── Column detection ───────────────────────────────────────────────
// Map a statement's header names onto the roles we need. Best-effort; the
// user can override every choice in the UI.
function reconDetectColumns(headers) {
const find = (re) => headers.find(h => re.test(String(h || ""))) || "";
const date = find(/value\s*date/i) || find(/(txn|trans|transaction|posting|book)\s*date/i) || find(/\bdate\b/i);
const desc = find(/description|narration|details|particular|remark|transaction(?!\s*date)/i) || find(/\bdetails?\b/i);
const debit = find(/debit|withdraw|paid\s*out|with\s*draw|\bdr\b|money\s*out/i);
const credit = find(/credit|deposit|paid\s*in|\bcr\b|money\s*in/i);
const amount = (!debit && !credit) ? (find(/^amount$/i) || find(/amount|value(?!\s*date)/i)) : "";
const balance = find(/running\s*balance|closing\s*balance|balance/i);
return { date, desc, debit, credit, amount, balance };
}
// Parse a money cell → number (handles "1,234.56", "(123.45)", trailing "Dr").
function reconParseAmount(v) {
if (v == null) return null;
let s = String(v).trim();
if (!s) return null;
let neg = false;
if (/^\(.*\)$/.test(s)) { neg = true; s = s.slice(1, -1); }
if (/\b(dr|debit)\b/i.test(s)) neg = true;
if (/\b(cr|credit)\b/i.test(s)) neg = false;
s = s.replace(/[^0-9.\-]/g, "");
if (s === "" || s === "-" || s === ".") return null;
const n = parseFloat(s);
if (!isFinite(n)) return null;
return neg ? -Math.abs(n) : n;
}
// Find the header row in a raw matrix (banks often prepend title/address rows).
function reconFindHeaderRow(matrix) {
for (let i = 0; i < Math.min(matrix.length, 20); i++) {
const cells = (matrix[i] || []).map(c => String(c || "").toLowerCase());
const hasDate = cells.some(c => /date/.test(c));
const hasMoney = cells.some(c => /amount|debit|credit|balance|withdraw|deposit/.test(c));
if (hasDate && hasMoney) return i;
}
return 0;
}
// Raw matrix (array of arrays) → { headers, rows[] } keyed by header.
function reconMatrixToRows(matrix) {
if (!matrix || !matrix.length) return { headers: [], rows: [] };
const hi = reconFindHeaderRow(matrix);
const headers = (matrix[hi] || []).map((h, i) => String(h || "").trim() || ("Column " + (i + 1)));
const rows = matrix.slice(hi + 1)
.filter(r => (r || []).some(c => String(c == null ? "" : c).trim() !== ""))
.map(r => { const o = {}; headers.forEach((h, i) => { o[h] = String((r || [])[i] == null ? "" : (r || [])[i]).trim(); }); return o; });
return { headers, rows };
}
// PDF text lines → row objects with synthetic Date/Description/Amount/Balance.
function reconPdfLinesToRows(textLines) {
const dateRe = /(\d{2}\/\d{2}\/\d{4}|\d{1,2}\s+[A-Za-z]{3}\s+\d{4}|\d{4}-\d{2}-\d{2})/;
const numRe = /-?\(?[\d,]+\.\d{2}\)?/g;
const out = [];
(textLines || []).forEach(t => {
const dm = t.match(dateRe);
if (!dm) return;
const nums = t.match(numRe);
if (!nums || !nums.length) return;
let amount = nums[0], balance = "";
if (nums.length >= 2) { balance = nums[nums.length - 1]; amount = nums[nums.length - 2]; }
const desc = t.replace(dateRe, "").replace(numRe, "").replace(/\s+/g, " ").trim();
out.push({ Date: dm[1], Description: desc, Amount: amount, Balance: balance });
});
return out;
}
async function reconReadPdf(arrayBuffer) {
const lib = window.pdfjsLib;
if (!lib) throw new Error("PDF reader didn't load — use CSV or Excel, or check your connection.");
try { if (lib.GlobalWorkerOptions && !lib.GlobalWorkerOptions.workerSrc) lib.GlobalWorkerOptions.workerSrc = RECON_PDF_WORKER; } catch (e) {}
const pdf = await lib.getDocument({ data: arrayBuffer }).promise;
const textLines = [];
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const content = await page.getTextContent();
const byY = new Map();
content.items.forEach(it => { const y = Math.round(it.transform[5]); const a = byY.get(y) || []; a.push(it); byY.set(y, a); });
[...byY.keys()].sort((a, b) => b - a).forEach(y => {
const line = byY.get(y).sort((a, b) => a.transform[4] - b.transform[4]).map(i => i.str).join(" ").replace(/\s+/g, " ").trim();
if (line) textLines.push(line);
});
}
const rows = reconPdfLinesToRows(textLines);
if (!rows.length) throw new Error("Couldn't read transactions from this PDF. Try the bank's CSV or Excel export.");
return { headers: ["Date", "Description", "Amount", "Balance"], rows };
}
// Normalize any parsed date to YYYY-MM-DD so the period window + sorting are
// consistent regardless of source format (parseImportDate handles dd/mm/yyyy and
// "d Mon yyyy"; Date.parse mops up the rest, e.g. Excel's m/d/yyyy).
function reconNormDate(raw) {
const p = parseImportDate(String(raw || "").trim());
if (/^\d{4}-\d{2}-\d{2}$/.test(p)) return p;
const t = Date.parse(p || raw || "");
if (!isNaN(t)) { const d = new Date(t); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }
return p || "";
}
// Row objects + column map → normalized statement lines.
// amountC is signed cents: + = money in (credit), − = money out (debit).
function reconRowsToLines(rows, map) {
return (rows || []).map((row, i) => {
const date = reconNormDate(map.date ? row[map.date] : "");
let amt = null;
if (map.amount) {
amt = reconParseAmount(row[map.amount]);
} else {
const d = map.debit ? reconParseAmount(row[map.debit]) : null;
const c = map.credit ? reconParseAmount(row[map.credit]) : null;
if (c != null && Math.abs(c) > 0) amt = Math.abs(c);
else if (d != null && Math.abs(d) > 0) amt = -Math.abs(d);
else amt = 0;
}
const balRaw = map.balance ? reconParseAmount(row[map.balance]) : null;
return {
id: "stmt-" + i,
date,
desc: map.desc ? String(row[map.desc] || "").trim() : "",
amountC: amt == null ? 0 : Math.round(amt * 100),
balanceC: balRaw == null ? null : Math.round(balRaw * 100),
};
}).filter(l => l.date || l.amountC);
}
// Recorded bank txns for one account → { id, date, ref, desc, amountC (signed) }.
function reconBookBankTxns(txns, bankAcctId) {
return (txns || []).filter(t => !t.isVoid).map(t => {
const amt = (t.lines || []).filter(l => l.accountId === bankAcctId).reduce((s, l) => s + (l.debit || 0) - (l.credit || 0), 0);
if (!amt) return null;
return { id: t.id, date: t.date || "", ref: t.ref || "", desc: t.description || "", amountC: amt };
}).filter(Boolean);
}
// Bounded subset-sum: the smallest subset (size 2..maxSize) of `cands` whose
// magnitudes add up to |targetC| within tolC. Returns the chosen ids, or null.
// Powers split / combined matching (one entry ↔ several lines that sum to it).
function reconSubsetSum(targetC, cands, tolC, maxSize) {
const T = Math.abs(targetC);
const items = cands.map(x => ({ id: x.id, m: Math.abs(x.c) })).filter(x => x.m > 0 && x.m <= T + tolC).sort((a, b) => b.m - a.m);
const cap = Math.min(items.length, 14); // bound the search space
for (let size = 2; size <= Math.min(maxSize, cap); size++) {
let found = null;
const idx = [];
const dfs = (start, sum) => {
if (found) return;
if (idx.length === size) { if (Math.abs(sum - T) <= tolC) found = idx.slice(); return; }
for (let i = start; i < cap && !found; i++) {
if (sum + items[i].m > T + tolC) continue; // this part overshoots; smaller ones later may still fit
idx.push(i); dfs(i + 1, sum + items[i].m); idx.pop();
}
};
dfs(0, 0);
if (found) return found.map(i => items[i].id);
}
return null;
}
// Three-pass match. (1) exact 1:1 — same signed amount, nearest date within window.
// (2) near 1:1 — amounts within amountTolC (rounding/fee/typo). (3) group/split —
// one entry ↔ a combination of lines on the other side that sum to it (e.g. a
// receipt banked as two cheques, or a fee + its VAT).
function reconMatch(lines, bookTxns, toleranceDays, amountTolC) {
const usedBook = new Set(), usedLine = new Set();
const dayMs = 86400000;
const toTime = d => { const t = Date.parse(d); return isNaN(t) ? null : t; };
const dDiff = (a, b) => { const lt = toTime(a), btt = toTime(b); return (lt != null && btt != null) ? Math.abs(lt - btt) / dayMs : 0; };
const inWin = (a, b) => dDiff(a, b) <= toleranceDays;
const matched = [], near = [], groups = [];
const sorted = [...lines].sort((a, b) => String(a.date).localeCompare(String(b.date)));
// Pass 1 — exact 1:1 (nearest date wins)
sorted.forEach(line => {
let best = null, bestDiff = Infinity;
for (const bt of bookTxns) {
if (usedBook.has(bt.id) || bt.amountC !== line.amountC) continue;
const diff = dDiff(line.date, bt.date);
if (diff <= toleranceDays && diff < bestDiff) { best = bt; bestDiff = diff; }
}
if (best) { usedBook.add(best.id); usedLine.add(line.id); matched.push({ line, book: best }); }
});
// Pass 2 — near 1:1 (amount within tolerance)
if (amountTolC > 0) sorted.forEach(line => {
if (usedLine.has(line.id)) return;
let best = null, bestScore = Infinity;
for (const bt of bookTxns) {
if (usedBook.has(bt.id) || Math.sign(bt.amountC) !== Math.sign(line.amountC)) continue;
const ad = Math.abs(bt.amountC - line.amountC);
if (ad === 0 || ad > amountTolC) continue;
const dd = dDiff(line.date, bt.date);
if (dd > toleranceDays) continue;
const score = ad * 1000 + dd; // closest amount first, then closest date
if (score < bestScore) { best = bt; bestScore = score; }
}
if (best) { usedBook.add(best.id); usedLine.add(line.id); near.push({ line, book: best, diffC: line.amountC - best.amountC }); }
});
// Pass 3 — group / split: one entry ↔ several lines summing to it (within window, same sign)
bookTxns.forEach(bt => { // 3a: one book item ↔ several statement lines
if (usedBook.has(bt.id)) return;
const cands = sorted.filter(l => !usedLine.has(l.id) && Math.sign(l.amountC) === Math.sign(bt.amountC) && inWin(l.date, bt.date));
const pick = reconSubsetSum(bt.amountC, cands.map(l => ({ id: l.id, c: l.amountC })), amountTolC, 4);
if (pick) {
const parts = pick.map(id => cands.find(l => l.id === id));
parts.forEach(p => usedLine.add(p.id)); usedBook.add(bt.id);
groups.push({ book: [bt], lines: parts, diffC: parts.reduce((s, p) => s + p.amountC, 0) - bt.amountC });
}
});
sorted.forEach(line => { // 3b: one statement line ↔ several book items
if (usedLine.has(line.id)) return;
const cands = bookTxns.filter(b => !usedBook.has(b.id) && Math.sign(b.amountC) === Math.sign(line.amountC) && inWin(b.date, line.date));
const pick = reconSubsetSum(line.amountC, cands.map(b => ({ id: b.id, c: b.amountC })), amountTolC, 4);
if (pick) {
const parts = pick.map(id => cands.find(b => b.id === id));
parts.forEach(p => usedBook.add(p.id)); usedLine.add(line.id);
groups.push({ book: parts, lines: [line], diffC: line.amountC - parts.reduce((s, p) => s + p.amountC, 0) });
}
});
const statementOnly = sorted.filter(l => !usedLine.has(l.id));
const bookOnly = bookTxns.filter(bt => !usedBook.has(bt.id));
return { matched, near, groups, statementOnly, bookOnly };
}
const reconFmtSigned = (c) => (c > 0 ? "+" : c < 0 ? "−" : "") + fmtAED(Math.abs(c)).replace("AED ", "");
// ═══════════════════════════════════════════════════════════════════
// BankReconcileModal
// ═══════════════════════════════════════════════════════════════════
function BankReconcileModal({ accounts, txns, ledger, journal, persistTxn, onClose }) {
const bankAccts = (accounts || []).filter(a => a.isBank || a.isCash || a.code === "1001" || a.code === "1002");
const [step, setStep] = React.useState("upload"); // upload | review
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState("");
const [fileName, setFileName] = React.useState("");
const [parsed, setParsed] = React.useState(null); // { headers, rows, kind }
const [colMap, setColMap] = React.useState({ date: "", desc: "", debit: "", credit: "", amount: "", balance: "" });
const [bankCode, setBankCode] = React.useState(() => (bankAccts.find(a => a.code === "1002") || bankAccts[0] || {}).code || "1002");
const [stmtBalStr, setStmtBalStr] = React.useState("");
const [tolerance, setTolerance] = React.useState(7); // days — bank value dates routinely lag booking by a few days
const [amountTol, setAmountTol] = React.useState(1); // AED — pair near-amounts (rounding/fees/typos)
const [showMap, setShowMap] = React.useState(false);
const [showMatched, setShowMatched] = React.useState(false);
const [addAcct, setAddAcct] = React.useState({}); // lineId -> offset account id (quick pre-pick)
const [addLine, setAddLine] = React.useState(null); // statement line being added via the detail window
const acct = bankAccts.find(a => a.code === bankCode) || bankAccts[0];
const lines = React.useMemo(() => parsed ? reconRowsToLines(parsed.rows, colMap) : [], [parsed, colMap]);
const period = React.useMemo(() => {
const ds = lines.map(l => l.date).filter(Boolean).sort();
return { from: ds[0] || "", to: ds[ds.length - 1] || "" };
}, [lines]);
const bookTxns = React.useMemo(() => acct ? reconBookBankTxns(txns, acct.id) : [], [txns, acct]);
const amountTolC = Math.round((parseFloat(amountTol) || 0) * 100);
const result = React.useMemo(() => reconMatch(lines, bookTxns, tolerance, amountTolC), [lines, bookTxns, tolerance, amountTolC]);
// Outstanding = book txns not on the statement, scoped to the statement's own
// date window (older items already cleared in prior periods are not "outstanding").
const outstanding = React.useMemo(() => result.bookOnly.filter(b =>
(!period.from || b.date >= period.from) && (!period.to || b.date <= period.to)
), [result.bookOnly, period]);
// Auto-fill the statement closing balance from the latest line that carries a balance.
React.useEffect(() => {
if (!lines.length) return;
const withBal = lines.filter(l => l.balanceC != null && l.date);
if (!withBal.length) return;
const last = withBal.reduce((a, b) => (String(b.date).localeCompare(String(a.date)) >= 0 ? b : a));
setStmtBalStr(((last.balanceC || 0) / 100).toFixed(2));
}, [lines]);
const bookBalC = acct ? accountBalance(acct, ledger) : 0;
const sumStmtOnly = result.statementOnly.reduce((s, l) => s + l.amountC, 0);
const sumOutstanding = outstanding.reduce((s, b) => s + b.amountC, 0);
const sumNearDiff = result.near.reduce((s, n) => s + n.diffC, 0); // Σ(statement − book) over near pairs
const sumGroupDiff = result.groups.reduce((s, g) => s + g.diffC, 0); // Σ(statement − book) over grouped sets
const expectedC = bookBalC + sumStmtOnly - sumOutstanding + sumNearDiff + sumGroupDiff;
const breakdown = [
["Book balance (per system)", bookBalC, false],
["Add: unrecorded on statement", sumStmtOnly, true],
["Less: outstanding in books", -sumOutstanding, true],
...(result.near.length ? [["Near-match differences (stmt − book)", sumNearDiff, true]] : []),
...(result.groups.length ? [["Grouped-match differences (stmt − book)", sumGroupDiff, true]] : []),
["Expected statement balance", expectedC, false],
];
// Control totals — the aggregate that matters most: do total money-in and
// money-out on the statement equal the books over the same period? This is the
// reliable check; line-matching is only for locating the specific differences.
const stmtIn = lines.reduce((s, l) => s + (l.amountC > 0 ? l.amountC : 0), 0);
const stmtOut = lines.reduce((s, l) => s + (l.amountC < 0 ? -l.amountC : 0), 0);
const bookInPeriod = bookTxns.filter(b => (!period.from || b.date >= period.from) && (!period.to || b.date <= period.to));
const bookIn = bookInPeriod.reduce((s, b) => s + (b.amountC > 0 ? b.amountC : 0), 0);
const bookOut = bookInPeriod.reduce((s, b) => s + (b.amountC < 0 ? -b.amountC : 0), 0);
const inDiff = stmtIn - bookIn, outDiff = stmtOut - bookOut;
const totalsMatch = Math.abs(inDiff) < 1 && Math.abs(outDiff) < 1;
// Amounts that already matched — flag a likely duplicate when the same value also sits in Outstanding.
const matchedAmounts = new Set([...result.matched, ...result.near].map(m => m.book.amountC));
const hasStmtBal = stmtBalStr.trim() !== "" && isFinite(parseFloat(stmtBalStr));
const actualC = hasStmtBal ? toCents(stmtBalStr) : null;
const diffC = actualC == null ? null : actualC - bookBalC;
const unexplainedC = actualC == null ? null : actualC - expectedC;
const reconciled = unexplainedC != null && Math.abs(unexplainedC) < 1;
const onFile = async (file) => {
if (!file) return;
setBusy(true); setError(""); setParsed(null);
setFileName(file.name);
const name = file.name.toLowerCase();
try {
let headers, rows;
if (name.endsWith(".pdf")) {
const r = await reconReadPdf(await file.arrayBuffer());
headers = r.headers; rows = r.rows;
setColMap({ date: "Date", desc: "Description", amount: "Amount", balance: "Balance", debit: "", credit: "" });
} else if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
if (typeof XLSX === "undefined") throw new Error("Excel reader not loaded — check your connection.");
const wb = XLSX.read(await file.arrayBuffer(), { type: "array" });
const matrix = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, defval: "", raw: false });
({ headers, rows } = reconMatrixToRows(matrix));
setColMap(reconDetectColumns(headers));
} else {
const text = await file.text();
const delim = (text.split(/\r?\n/, 1)[0] || "").includes("\t") ? "\t" : ",";
const matrix = parseDelimitedRows(text, delim);
({ headers, rows } = reconMatrixToRows(matrix));
setColMap(reconDetectColumns(headers));
}
if (!rows.length) throw new Error("No data rows found in this file.");
setParsed({ headers, rows, kind: name.split(".").pop() });
setShowMap(false);
setStep("review");
} catch (e) { setError(e.message || String(e)); }
setBusy(false);
};
// ── styles ──
const G = "#1D4ED8";
const card = { background: "#fff", border: "1px solid #E5E7EB", borderRadius: 12, padding: "14px 16px" };
const lbl = { fontSize: 11, fontWeight: 700, letterSpacing: "0.04em", textTransform: "uppercase", color: "#6B7280" };
const sel = { border: "1.5px solid #D0D5DD", borderRadius: 8, padding: "7px 10px", fontSize: 13, background: "#fff", outline: "none", width: "100%", boxSizing: "border-box" };
const th = { ...C.th, fontSize: 10.5, padding: "6px 8px" };
const td = { ...C.td, fontSize: 12, padding: "6px 8px" };
const money = (c, color) => {reconFmtSigned(c)};
const summaryCard = (title, value, sub, accent) => (
{title}
{value}
{sub &&
{sub}
}
);
return (
e.stopPropagation()}>
🔄 Bank Reconciliation
{fileName && · {fileName}}
{step === "upload" && (
Upload a bank statement and we'll match it against your recorded transactions, then show exactly what's unrecorded, outstanding, and any unexplained difference.
{error &&
{error}
}
Tip: CSV or Excel exports reconcile most accurately. PDF reading is best-effort — review the parsed rows after upload.
)}
{step === "review" && (
{/* Control totals — money in / out, statement vs books (the reliable aggregate check) */}
Total money in and out over the statement period. If both differences are 0, every dirham is accounted for — regardless of which bucket individual lines land in below.
{breakdown.map(([l, v, signed], i) => {
const last = i === breakdown.length - 1;
return (
{l}{signed ? reconFmtSigned(v) : fmtAED(v)}
);
})}
{actualC != null && (
Actual statement balance{fmtAED(actualC)}
)}
{actualC != null && !reconciled && (
{reconFmtSigned(unexplainedC)} still unexplained — likely an amount that differs from the books, a date outside the ±{tolerance}-day window, or a missing line. Adjust the column mapping or tolerance, or add the entries below.
)}
{reconciled && (
✓ Fully reconciled — every difference is explained by the items below.
Leave off for VAT-free items (salaries, bank transfers, government fees…).
}
{err &&
{err}
}
);
}
function ReconSection({ title, count, accent, hint, children }) {
return (
{title}{count}
{hint &&
{hint}
}
{children}
);
}
// ═══════════════════════════════════════════════════════════════════
// Duplicate finder — scan bank/cash transactions for same-amount,
// near-date copies (catches import-vs-manual double entries, incl. small
// typos). Sets that mix an imported bank line with a manual entry are the
// classic duplicate and are highlighted + listed first.
// ═══════════════════════════════════════════════════════════════════
function reconFindDuplicates(txns, accounts, windowDays, amountTolC) {
const liquid = new Set((accounts || []).filter(a => a.isBank || a.isCash || a.code === "1001" || a.code === "1002").map(a => a.id));
const dayMs = 86400000;
const toTime = d => { const t = Date.parse(d); return isNaN(t) ? null : t; };
const items = (txns || []).filter(t => !t.isVoid).map(t => {
const amt = (t.lines || []).filter(l => liquid.has(l.accountId)).reduce((s, l) => s + (l.debit || 0) - (l.credit || 0), 0);
if (!amt) return null;
return { id: t.id, date: t.date || "", ref: t.ref || "", desc: t.description || "", txnType: t.txnType || "", amountC: amt, imported: /bank-import/.test(t.tags || "") || t.txnType === "BK" };
}).filter(Boolean);
const n = items.length;
const parent = items.map((_, i) => i);
const find = i => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
const order = items.map((_, i) => i).sort((a, b) => Math.abs(items[a].amountC) - Math.abs(items[b].amountC));
for (let a = 0; a < n; a++) {
const A = items[order[a]];
for (let b = a + 1; b < n; b++) {
const B = items[order[b]];
if (Math.abs(B.amountC) - Math.abs(A.amountC) > amountTolC) break; // sorted asc → no further candidates
if (Math.sign(A.amountC) !== Math.sign(B.amountC) || Math.abs(A.amountC - B.amountC) > amountTolC) continue;
const x = toTime(A.date), y = toTime(B.date);
if (x == null || y == null || Math.abs(x - y) / dayMs > windowDays) continue;
parent[find(order[a])] = find(order[b]);
}
}
const groups = new Map();
items.forEach((it, i) => { const r = find(i); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(it); });
const sets = [...groups.values()].filter(g => g.length >= 2).map(g => {
g.sort((a, b) => String(a.date).localeCompare(String(b.date)));
return { items: g, mixed: g.some(x => x.imported) && g.some(x => !x.imported), amountC: g[0].amountC, extraC: (g.length - 1) * Math.abs(g[0].amountC) };
});
sets.sort((a, b) => (b.mixed ? 1 : 0) - (a.mixed ? 1 : 0) || Math.abs(b.amountC) - Math.abs(a.amountC));
return sets;
}
function DuplicatesModal({ txns, accounts, persistTxn, onClose }) {
const [windowDays, setWindowDays] = React.useState(2);
const [amountTol, setAmountTol] = React.useState(1);
const [onlyMixed, setOnlyMixed] = React.useState(true);
const [busy, setBusy] = React.useState("");
const amountTolC = Math.round((parseFloat(amountTol) || 0) * 100);
const allSets = React.useMemo(() => reconFindDuplicates(txns, accounts, windowDays, amountTolC), [txns, accounts, windowDays, amountTolC]);
const sets = onlyMixed ? allSets.filter(s => s.mixed) : allSets;
const totalExtra = sets.reduce((s, g) => s + g.extraC, 0);
const voidTxn = async (id) => {
const t = (txns || []).find(x => x.id === id);
if (!t) return;
if (!window.confirm(`Void this transaction?\n\n${t.ref} — ${t.description}\n\nIt stays on record marked Void and no longer affects balances. (Reversible.)`)) return;
setBusy(id);
try { await persistTxn({ ...t, isVoid: true }); toast("Transaction voided", "success"); }
catch (e) { toast("Void failed: " + e.message, "error"); }
setBusy("");
};
const card = { background: "#fff", border: "1px solid #E5E7EB", borderRadius: 12, padding: "12px 14px" };
const sel = { border: "1.5px solid #D0D5DD", borderRadius: 8, padding: "5px 8px", fontSize: 12.5, background: "#fff", outline: "none" };
return (
e.stopPropagation()}>
⚠ Possible Duplicates
Bank/cash transactions with the same amount (within tolerance) and close dates — likely the same money entered twice (e.g. imported from the statement and keyed manually).
Keep the date window small (±1–2 days) so genuinely recurring charges on different dates aren't flagged. Void the extra copy — usually the manual one (especially if it has a typo), keeping the imported bank line.