// ╔═══════════════════════════════════════════════════════════════════╗ // 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) */}
Control totals · {period.from || "—"} → {period.to || "—"} {totalsMatch ? "✓ Totals match" : "Totals differ"}
{["", "Money In", "Money Out", "Net"].map((h, i) => )} {[["Bank statement", stmtIn, stmtOut], ["Your books", bookIn, bookOut]].map(([l, inC, outC]) => ( ))}
{h}
{l} {fmtAED(inC)} {fmtAED(outC)} {fmtAED(inC - outC)}
Difference {reconFmtSigned(inDiff)} {reconFmtSigned(outDiff)} {reconFmtSigned(inDiff - outDiff)}
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.
{/* Summary cards */}
{summaryCard("Book balance", fmtAED(bookBalC), `${acct ? acct.name : ""} · per system`, "#1D4ED8")}
Statement closing balance
AED setStmtBalStr(e.target.value)} placeholder="enter / auto" />
from file or type it in
{summaryCard("Difference", actualC == null ? "—" : reconFmtSigned(diffC), "statement − book", Math.abs(diffC || 0) < 1 ? "#059669" : "#D97706")}
Unexplained
{actualC == null ? "—" : reconciled ? "✓ Reconciled" : reconFmtSigned(unexplainedC)}
after the items below
{/* Reconciliation breakdown */}
{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.
)}
{/* Controls: mapping + tolerance */}
Date tolerance
Amount tolerance
{lines.length} statement lines{period.from ? ` · ${period.from} → ${period.to}` : ""}
{showMap && (
{[["date", "Date"], ["desc", "Description"], ["debit", "Debit / Out"], ["credit", "Credit / In"], ["amount", "Amount (signed)"], ["balance", "Balance"]].map(([k, l]) => (
))}
Use Debit/Credit for statements with separate columns, or Amount for a single signed column.
)} {/* Unrecorded on statement */} {result.statementOnly.length === 0 ?
Nothing — every statement line is recorded. 🎉
: {["Date", "Description", "Amount", "Post against", ""].map((h, i) => )} {result.statementOnly.map(l => ( ))}
{h}
{l.date || "—"} {l.desc || "—"} {money(l.amountC)}
}
{/* Near matches */} {result.near.length > 0 && ( {["Date", "Description", "Statement", "Book ref", "Book amount", "Stmt − Book"].map((h, i) => )} {result.near.map((n, i) => ( ))}
{h}
{n.line.date || "—"} {n.line.desc || "—"} {money(n.line.amountC)} {n.book.ref || "—"} {money(n.book.amountC)} {money(n.diffC, "#B45309")}
)} {/* Grouped (split / combined) matches */} {result.groups.length > 0 && (
{result.groups.map((g, i) => { const oneIsBook = g.book.length === 1; const single = oneIsBook ? g.book[0] : g.lines[0]; const parts = oneIsBook ? g.lines : g.book; const lbl = (it, isBook) => `${it.date || "—"} · ${isBook && it.ref ? it.ref + " — " : ""}${it.desc || ""}`.trim(); return (
{oneIsBook ? "📒 " : "🏦 "}{lbl(single, oneIsBook)} {money(single.amountC)}
{parts.map((p, j) => (
↳ {oneIsBook ? "🏦 " : "📒 "}{lbl(p, !oneIsBook)} {money(p.amountC)}
))}
{Math.abs(g.diffC) >= 1 &&
Net difference {reconFmtSigned(g.diffC)} — carried into the reconciliation
}
); })}
)} {/* Outstanding in books */} {outstanding.length === 0 ?
Nothing outstanding in this period.
: {["Date", "Ref", "Description", "Amount"].map((h, i) => )} {outstanding.map(b => ( ))}
{h}
{b.date || "—"} {b.ref || "—"} {b.desc || "—"} {matchedAmounts.has(b.amountC) && ⚠ possible duplicate} {money(b.amountC)}
}
{/* Matched */}
setShowMatched(s => !s)}> ✓ Matched · {result.matched.length} {showMatched ? "Hide" : "Show"}
{showMatched && ( {["Date", "Statement", "Book ref", "Amount"].map((h, i) => )} {result.matched.map((m, i) => ( ))}
{h}
{m.line.date || "—"} {m.line.desc || "—"} {m.book.ref || "—"} {money(m.line.amountC)}
)}
)}
{step === "review" && acct ? `Reconciling ${acct.name}` : "Statement → Ledger"}
{addLine && ( setAddLine(null)} onPosted={() => setAddLine(null)} /> )}
); } // ═══════════════════════════════════════════════════════════════════ // ReconAddTxnModal — full transaction entry for an unrecorded statement // line. Pre-filled from the line; lets you set date / description / // counterparty / category account and optionally split out VAT. // The bank amount (gross) is fixed — it's the real money movement. // • Money OUT (payment): DR category (net) / DR Input VAT / CR bank (gross) // • Money IN (receipt): DR bank (gross) / CR category (net) / CR Output VAT // ═══════════════════════════════════════════════════════════════════ function ReconAddTxnModal({ line, bankAcct, accounts, defaultOffsetId, journal, persistTxn, onClose, onPosted }) { const moneyIn = line.amountC > 0; const grossC = Math.abs(line.amountC); const [date, setDate] = React.useState(line.date || todayStr()); const [desc, setDesc] = React.useState(line.desc || ""); const [counterparty, setCounterparty] = React.useState(""); const [offsetId, setOffsetId] = React.useState(defaultOffsetId || ""); const [vatOn, setVatOn] = React.useState(false); const [vatRate, setVatRate] = React.useState(5); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(""); const rate = vatOn ? (parseFloat(vatRate) || 0) : 0; // Bank amount is VAT-inclusive (gross). Net = gross / (1 + rate); VAT = remainder (keeps it balanced to the cent). const netC = rate > 0 ? Math.round(grossC / (1 + rate / 100)) : grossC; const vatC = grossC - netC; const outputVATA = accounts.find(a => a.isOutputVAT); const inputVATA = accounts.find(a => a.isInputVAT); const vatAcct = moneyIn ? outputVATA : inputVATA; // Categories: everything except the bank account itself and the VAT accounts (VAT is auto-handled). const categoryAccts = (accounts || []).filter(a => a.id !== (bankAcct && bankAcct.id) && !a.isOutputVAT && !a.isInputVAT); const offset = (accounts || []).find(a => a.id === offsetId); const fmtC = (c) => "AED " + (c / 100).toLocaleString("en-AE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const post = async () => { setErr(""); if (!bankAcct) { setErr("No bank account selected."); return; } if (!offset) { setErr("Choose a category / account to post against."); return; } if (rate > 0 && !vatAcct) { setErr(`Missing ${moneyIn ? "Output" : "Input"} VAT account in the chart of accounts.`); return; } const memo = desc || (moneyIn ? "Bank receipt" : "Bank payment"); const lns = []; if (moneyIn) { lns.push({ id: uid(), accountId: bankAcct.id, debit: grossC, credit: 0, memo }); lns.push({ id: uid(), accountId: offset.id, debit: 0, credit: netC, memo }); if (vatC > 0) lns.push({ id: uid(), accountId: vatAcct.id, debit: 0, credit: vatC, memo: `Output VAT ${rate}%` }); } else { lns.push({ id: uid(), accountId: offset.id, debit: netC, credit: 0, memo }); if (vatC > 0) lns.push({ id: uid(), accountId: vatAcct.id, debit: vatC, credit: 0, memo: `Input VAT ${rate}%` }); lns.push({ id: uid(), accountId: bankAcct.id, debit: 0, credit: grossC, memo }); } setBusy(true); try { const txn = journal.post({ date: date || todayStr(), description: desc || (moneyIn ? "Bank receipt (reconciliation)" : "Bank payment (reconciliation)"), ref: "REC-" + Date.now().toString(36).toUpperCase(), counterparty, tags: "reconcile bank-import", txnType: "BK", lines: lns, commit: false, }); await persistTxn(txn); toast("Posted — statement line now recorded", "success"); onPosted && onPosted(txn); } catch (e) { setErr(e.message || String(e)); setBusy(false); } }; const lbl = { fontSize: 11, fontWeight: 700, letterSpacing: "0.04em", textTransform: "uppercase", color: "#6B7280" }; const sel = { border: "1.5px solid #D0D5DD", borderRadius: 8, padding: "8px 10px", fontSize: 13, background: "#fff", outline: "none", width: "100%", boxSizing: "border-box" }; const accent = moneyIn ? "#059669" : "#DC2626"; return (
e.stopPropagation()}>
Add transaction to books
{/* Direction + amount banner */}
{moneyIn ? "Money in — receipt" : "Money out — payment"}
{bankAcct ? `into / from ${bankAcct.name}` : ""}
{fmtC(grossC)}
setDate(e.target.value)} />
setCounterparty(e.target.value)} placeholder="who it's to / from" />
setDesc(e.target.value)} placeholder="what this is for" />
{/* VAT */}
{vatOn && (
{!vatAcct &&
⚠ No {moneyIn ? "Output" : "Input"} VAT account found — VAT can't be posted.
}
Net ({moneyIn ? "income" : "expense"}){fmtC(netC)}
{moneyIn ? "Output" : "Input"} VAT {rate}%{fmtC(vatC)}
Total (bank){fmtC(grossC)}
)} {!vatOn &&
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).

{sets.length} set{sets.length !== 1 ? "s" : ""}{totalExtra ? ` · ~${fmtAED(totalExtra)} likely extra` : ""}
{sets.length === 0 ?
No possible duplicates with these settings. 🎉
:
{sets.map((g, i) => (
{g.items.length}× {fmtAED(Math.abs(g.amountC))} {g.amountC < 0 ? "out" : "in"} {g.mixed && imported + manual}
{g.items.map(it => ( ))}
{it.date || "—"} {it.ref || "—"} {it.imported ? "imported" : "manual"} {it.desc || "—"} {fmtAED(it.amountC)}
))}
}
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.
); }