finances/migration/review_preview_mixed.html
Dane Sabo 26fb19ca9a Migration runbook + rebuild tooling; 10 PNC/income/Don't Know rules
- migration/README.md: cold-start rebuild runbook (reconciliation gate,
  classification rules, transfer pairing, investment policy, execution order)
- migration/build_rebuild_dataset.py: consolidated 3-QFX builder with PNC-
  owned transfers, counterpart pairing & drop, per-account reconciliation
- migration/rebuild_clusters.{json,md}: clustering proposal for the rebuild
- migration/rebuild_review.html: read-only browser review for the 1017-txn
  rebuild plan (transfers under PNC, category fixes baked in)
- migration/{pnc_review,review_preview_mixed}.html: earlier UI previews
- merchant_map.json: add 10 settled deterministic rules (Duquesne Light,
  Pitt Salary, Interest Payment, IRS, Pitt Tuition, Daily Cash Adjustment,
  ATM Surcharge/Yardi/Venmo/Zelle->Don't Know) so the skill stops flagging
  pre-classified PNC lines as UNMATCHED
2026-05-25 18:54:50 -04:00

379 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Firefly import review</title>
<style>
:root { --bg:#0f1115; --panel:#181b22; --line:#2a2f3a; --fg:#e7e9ee;
--muted:#9aa3b2; --accent:#4f9cf9; --ok:#3fb950; --no:#f85149;
--warn:#d29922; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;
background:var(--bg); color:var(--fg); }
header { position:sticky; top:0; z-index:5; background:var(--panel);
border-bottom:1px solid var(--line); padding:12px 18px;
display:flex; gap:18px; align-items:center; flex-wrap:wrap; }
header h1 { font-size:15px; margin:0; font-weight:600; }
.counts { display:flex; gap:14px; color:var(--muted); font-size:13px; }
.counts b { color:var(--fg); }
.spacer { flex:1; }
button { font:inherit; cursor:pointer; border:1px solid var(--line);
background:#222732; color:var(--fg); border-radius:6px;
padding:7px 12px; }
button:hover { border-color:var(--accent); }
button.primary { background:var(--accent); border-color:var(--accent);
color:#06203f; font-weight:600; }
section { margin:22px auto; max-width:1200px; padding:0 18px; }
h2 { font-size:15px; border-bottom:1px solid var(--line);
padding-bottom:6px; }
h2 .sub { color:var(--muted); font-weight:400; font-size:12px;
margin-left:8px; }
table { width:100%; border-collapse:collapse; }
th,td { text-align:left; padding:7px 8px; border-bottom:1px solid var(--line);
vertical-align:top; font-size:13px; }
th { color:var(--muted); font-weight:600; }
td.amt { text-align:right; font-variant-numeric:tabular-nums;
white-space:nowrap; }
.desc { color:var(--fg); }
.desc small { color:var(--muted); display:block; }
input[type=text], select { font:inherit; background:#11141a; color:var(--fg);
border:1px solid var(--line); border-radius:5px; padding:5px 6px;
width:100%; }
.toggle { display:inline-flex; border:1px solid var(--line);
border-radius:6px; overflow:hidden; }
.toggle button { border:0; border-radius:0; padding:5px 10px;
background:#11141a; }
.toggle button.on-ok { background:var(--ok); color:#04210b;
font-weight:600; }
.toggle button.on-no { background:var(--no); color:#2a0606;
font-weight:600; }
.pill { font-size:11px; padding:1px 7px; border-radius:999px;
border:1px solid var(--line); color:var(--muted); }
.pill.review { color:var(--warn); border-color:var(--warn); }
.pill.unmatched { color:var(--no); border-color:var(--no); }
.pill.create { color:var(--ok); border-color:var(--ok); }
.pill.transfer { color:#fff; background:#8e5cf7; border-color:#8e5cf7; }
.pill.dup { color:var(--muted); }
tr.is-transfer td:first-child { box-shadow: inset 3px 0 0 #8e5cf7; }
.amt .dir { font-weight:700; margin-right:2px; }
.amt .io { font-size:9px; letter-spacing:.05em; opacity:.65;
margin-left:4px; vertical-align:1px; }
.amt.in { color:#3fb950; }
.amt.out { color:#f0883e; }
.amt.xfer{ color:#a983f7; }
tr.grp td { background:#1b2030; font-weight:700; color:#9fb3c8;
padding:7px 9px; border-top:2px solid #2a3140;
letter-spacing:.02em; }
tr.grp .sub { color:var(--muted); font-weight:400; }
.sugg { color:var(--muted); font-size:12px; margin-top:3px; }
.sugg a { color:var(--accent); cursor:pointer; text-decoration:underline; }
.readonly td { color:var(--muted); }
.empty { color:var(--muted); padding:10px 0; }
footer { max-width:1200px; margin:30px auto 60px; padding:0 18px;
color:var(--muted); font-size:12px; }
</style>
</head>
<body>
<header>
<h1>Firefly import review</h1>
<div class="counts" id="counts"></div>
<div class="spacer"></div>
<button id="approveAuto">Approve all auto</button>
<button id="export" class="primary">Export decisions</button>
</header>
<section>
<h2>Needs Clarity <span class="sub">UNMATCHED + REVIEW &mdash; set the
canonical account, category, budget, then approve</span></h2>
<table><thead><tr>
<th>Date</th><th>Amount</th><th>Description</th><th>Account</th>
<th>Category</th><th>Budget</th><th>Comment</th><th>Decision</th>
</tr></thead><tbody id="tb-clarity"></tbody></table>
<div class="empty" id="empty-clarity" hidden>Nothing needs clarification.</div>
</section>
<section>
<h2>Auto-Proposed <span class="sub">matched a rule &mdash; approved by
default, deny or edit if wrong</span></h2>
<table><thead><tr>
<th>Date</th><th>Amount</th><th>Description</th><th>Account</th>
<th>Category</th><th>Budget</th><th>Comment</th><th>Decision</th>
</tr></thead><tbody id="tb-auto"></tbody></table>
<div class="empty" id="empty-auto" hidden>No auto-proposed rows.</div>
</section>
<section>
<h2>All Transactions <span class="sub">complete picture, read-only
(includes skipped duplicates)</span></h2>
<table><thead><tr>
<th>Date</th><th>Amount</th><th>Type</th><th>Description</th>
<th>Bucket</th><th>Target</th>
</tr></thead><tbody id="tb-all"></tbody></table>
</section>
<footer>
Decisions are exported as <code>decisions.json</code> (keyed by
external_id). Re-run
<code>firefly_import.py &lt;normalized&gt; --decisions decisions.json --post</code>
to post only approved rows.
</footer>
<script>
const PLAN = {"meta": {"generated": "2026-05-17", "config": ".firefly.json", "record_count": 13, "note": "Transfers all owned by PNC leg (PNC date/FITID); card-side counterparts dropped", "categories": ["Adjustment", "Amazon", "Auto: Fees", "Auto: Fuel", "Auto: Insurance", "Auto: Parking", "Clothes", "Coffee", "Education", "Entertainment", "Groceries", "Home", "Medical", "Other", "Personal Care", "Pets", "Recreation: Firearms", "Recreation: Racing", "Rent", "Restaurants", "Subscriptions", "Taxes", "Tools", "Travel", "Utilities: Electric", "Wages", "WRX (Aimee)", "Z (Mizumi)"], "accounts": ["PNC Checking", "Apple Credit Card", "Costco Visa Card", "Schwab Stocks", "Schwab Savings", "Cash", "Illiquid Assets", "Don't Know", "Sheetz", "Amazon", "Costco", "Costco Gas", "Duquesne Light", "Local Cafe", "Tazza D'Oro"], "rendered": "2026-05-17T15:09:50"}, "rows": [{"external_id": "pnc:f1", "bucket": "TRANSFER", "date": "2026-06-02", "amount": "1650.72", "type": "transfer", "description": "APPLECARD GSBANK PAYMENT ACH WEB-RECUR", "asset_account": "PNC Checking", "destination_account": "Apple Credit Card", "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": false}, {"external_id": "pnc:f2", "bucket": "TRANSFER", "date": "2026-06-15", "amount": "116.09", "type": "transfer", "description": "CITI AUTOPAY PAYMENT ACH WEB-REC", "asset_account": "PNC Checking", "destination_account": "Costco Visa Card", "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": false}, {"external_id": "pnc:f3", "bucket": "TRANSFER", "date": "2026-06-03", "amount": "3000.00", "type": "transfer", "description": "ONLINE TRANSFER TO SCHWAB MONEYLINK", "asset_account": "PNC Checking", "destination_account": "Schwab Stocks", "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": false}, {"external_id": "pnc:e1", "bucket": "CREATE", "date": "2026-06-09", "amount": "52.18", "type": "withdrawal", "description": "DUQUESNE LIGHT PAYMENT", "asset_account": "PNC Checking", "destination_account": null, "proposed_account": "Duquesne Light", "proposed_account_id": 701, "proposed_category": "Utilities: Electric", "proposed_budget": null, "suggestions": [], "review": false, "budget_suggestions": ["Needs"]}, {"external_id": "pnc:r1", "bucket": "REVIEW", "date": "2026-06-08", "amount": "300.00", "type": "withdrawal", "description": "CHECK 145 xxxxx9921", "asset_account": "PNC Checking", "destination_account": null, "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": true, "category_suggestions": [], "budget_suggestions": []}, {"external_id": "pnc:u1", "bucket": "UNMATCHED", "date": "2026-06-04", "amount": "128.44", "type": "withdrawal", "description": "SQ *BRDEJ VENTURES 8773319635 CA", "asset_account": "PNC Checking", "destination_account": null, "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": ["Don't Know"], "review": false, "category_suggestions": [], "budget_suggestions": []}, {"external_id": "pnc:inc1", "bucket": "CREATE", "date": "2026-06-05", "amount": "2778.70", "type": "deposit", "description": "UNIV PITTSBURGH PAYROLL ACH CREDIT", "asset_account": "PNC Checking", "destination_account": null, "proposed_account": "Pitt Salary", "proposed_account_id": 505, "proposed_category": "Wages", "proposed_budget": null, "suggestions": [], "review": false, "budget_suggestions": []}, {"external_id": "apple_blue:320260601AX01", "bucket": "SKIP-dup", "date": "2026-06-01", "amount": "44.10", "type": "withdrawal", "description": "SHEETZ 0481 PETRO BEDFORD PA (re-download)", "asset_account": "Apple Credit Card", "destination_account": null, "proposed_account": "Sheetz", "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": false}, {"external_id": "ap:1", "bucket": "CREATE", "date": "2026-06-01", "amount": "44.10", "type": "withdrawal", "description": "SHEETZ 0481 PETRO BEDFORD PA", "asset_account": "Apple Credit Card", "destination_account": null, "proposed_account": "Sheetz", "proposed_account_id": 566, "proposed_category": "Auto: Fuel", "proposed_budget": null, "suggestions": [], "review": false, "budget_suggestions": ["Needs"]}, {"external_id": "ap:2", "bucket": "CREATE", "date": "2026-06-03", "amount": "58.99", "type": "withdrawal", "description": "AMAZON MKTPL*Z9", "asset_account": "Apple Credit Card", "destination_account": null, "proposed_account": "Amazon", "proposed_account_id": 548, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": true, "category_suggestions": ["Amazon"], "budget_suggestions": []}, {"external_id": "ap:4", "bucket": "UNMATCHED", "date": "2026-06-07", "amount": "14.25", "type": "withdrawal", "description": "TST* THE COMMONPLACE CAFE", "asset_account": "Apple Credit Card", "destination_account": null, "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": ["Local Cafe", "Tazza D'Oro"], "review": false, "category_suggestions": ["Coffee", "Restaurants"], "budget_suggestions": ["Wants"]}, {"external_id": "co:1", "bucket": "CREATE", "date": "2026-06-06", "amount": "187.44", "type": "withdrawal", "description": "COSTCO WHSE #1234 PITTSBURGH", "asset_account": "Costco Visa Card", "destination_account": null, "proposed_account": "Costco", "proposed_account_id": 512, "proposed_category": "Groceries", "proposed_budget": null, "suggestions": [], "review": false, "budget_suggestions": ["Needs"]}, {"external_id": "co:3", "bucket": "REVIEW", "date": "2026-06-10", "amount": "73.20", "type": "withdrawal", "description": "COSTCO GAS #0332", "asset_account": "Costco Visa Card", "destination_account": null, "proposed_account": "Costco Gas", "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": true, "category_suggestions": ["Auto: Fuel"], "budget_suggestions": ["Needs"]}]};
const BUDGETS = ["", "Needs", "Wants", "Savings"];
const rows = PLAN.rows || [];
// Per-row decision state, keyed by external_id (the stable id).
const state = {};
for (const r of rows) {
const isAuto = r.bucket === "CREATE" || r.bucket === "TRANSFER";
state[r.external_id] = {
approved: isAuto, // auto rows default-approved; clarity not
account: r.proposed_account || "",
account_id: (r.proposed_account_id != null) ? r.proposed_account_id : null,
category: r.proposed_category || "",
budget: r.proposed_budget || "",
comment: ""
};
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
// Amount cell with explicit direction: deposit = green +in, withdrawal =
// red -out, transfer = purple between-accounts.
function amtCell(r) {
const t = r.type;
const sign = t === "deposit" ? "+" : t === "transfer" ? "⇄" : "";
const word = t === "deposit" ? "in" : t === "transfer" ? "xfer" : "out";
return `<td class="amt ${word}"><span class="dir">${sign}</span>` +
`$${esc(r.amount)} <span class="io">${word.toUpperCase()}</span></td>`;
}
function budgetOptions(sel) {
return BUDGETS.map(b =>
`<option value="${esc(b)}"${b===sel?" selected":""}>${esc(b||"—")}</option>`
).join("");
}
function decisionCell(id) {
const s = state[id];
return `<div class="toggle" data-id="${esc(id)}">
<button class="d-ok${s.approved?" on-ok":""}">Approve</button>
<button class="d-no${!s.approved?" on-no":""}">Deny</button></div>`;
}
function editableRow(r) {
const id = r.external_id, s = state[id];
const pill = r.bucket === "UNMATCHED"
? '<span class="pill unmatched">UNMATCHED</span>'
: r.bucket === "TRANSFER"
? '<span class="pill transfer">TRANSFER</span>'
: (r.review ? '<span class="pill review">REVIEW</span>'
: '<span class="pill create">CREATE</span>');
const canon = r.bucket === "TRANSFER"
? `Transfer &rarr; <b>${esc(r.destination_account || "?")}</b>`
: (r.proposed_account
? `Canonical account &rarr; <b>${esc(r.proposed_account)}</b>` +
(r.proposed_account_id != null
? ` <span style="color:#888">#${esc(r.proposed_account_id)}</span>`
: ` <span style="color:#888">(new)</span>`)
: `Canonical account &rarr; <b style="color:#c0392b">unset</b> ` +
`<span style="color:#888">&mdash; set it below</span>`);
let sugg = "";
if (r.suggestions && r.suggestions.length) {
sugg = `<div class="sugg">did you mean: ` +
r.suggestions.map(x =>
`<a data-id="${esc(id)}" data-acct="${esc(x)}">${esc(x)}</a>`
).join(", ") + `</div>`;
}
let catsugg = "";
if (r.category_suggestions && r.category_suggestions.length) {
catsugg = `<div class="sugg catsugg">try: ` +
r.category_suggestions.map(x =>
`<a data-id="${esc(id)}" data-cat="${esc(x)}">${esc(x)}</a>`
).join(", ") + `</div>`;
}
let budsugg = "";
if (r.budget_suggestions && r.budget_suggestions.length) {
budsugg = `<div class="sugg budsugg">` +
r.budget_suggestions.map(x =>
`<a data-id="${esc(id)}" data-bud="${esc(x)}">${esc(x)}</a>`
).join(", ") + `</div>`;
}
return `<tr data-id="${esc(id)}" class="${r.bucket==="TRANSFER"?"is-transfer":""}">
<td>${esc(r.date)}</td>
${amtCell(r)}
<td class="desc">${esc(r.description)}
<small>${esc(r.asset_account)} ${pill}</small>
<div class="canon" style="margin-top:3px;font-size:12px">${canon}</div>${sugg}</td>
<td><input type="text" class="f-account" list="acctlist"
value="${esc(s.account)}" placeholder="pick / type account"></td>
<td><input type="text" class="f-category" list="catlist"
value="${esc(s.category)}" placeholder="pick existing category">${catsugg}</td>
<td><select class="f-budget">${budgetOptions(s.budget)}</select>${budsugg}</td>
<td><input type="text" class="f-comment" value="${esc(s.comment)}"
placeholder="note"></td>
<td>${decisionCell(id)}</td></tr>`;
}
function readonlyRow(r) {
let tgt = "";
if (r.bucket === "TRANSFER") tgt = "→ " + esc(r.destination_account || "?");
else if (r.proposed_account) tgt = "→ " + esc(r.proposed_account) +
(r.proposed_account_id != null ? " #" + esc(r.proposed_account_id)
: " (new)");
else if (r.bucket === "SKIP-dup") tgt = "dup";
const cls = {"SKIP-dup":"dup","UNMATCHED":"unmatched","TRANSFER":"transfer",
"REVIEW":"review","CREATE":"create"}[r.bucket] || "";
return `<tr class="readonly${r.bucket==="TRANSFER"?" is-transfer":""}">
<td>${esc(r.date)}</td>${amtCell(r)}
<td>${esc(r.type)}</td><td>${esc(r.description)}</td>
<td><span class="pill ${cls}">${esc(r.bucket)}</span></td>
<td>${tgt}</td></tr>`;
}
// Group a section's rows by the statement they came from (PNC / Apple /
// Costco ...) with a subheader per account, so a multi-statement rebuild is
// reviewable account-by-account instead of one undifferentiated list.
function groupByAccount(list, rowFn, cols) {
const order = [], groups = {};
for (const r of list) {
const a = r.asset_account || "(unknown)";
if (!(a in groups)) { groups[a] = []; order.push(a); }
groups[a].push(r);
}
return order.map(a =>
`<tr class="grp"><td colspan="${cols}">${esc(a)} ` +
`<span class="sub">${groups[a].length}</span></td></tr>` +
groups[a].map(rowFn).join("")).join("");
}
function render() {
const clarity = rows.filter(r =>
r.bucket === "UNMATCHED" || r.bucket === "REVIEW");
const auto = rows.filter(r =>
r.bucket === "CREATE" || r.bucket === "TRANSFER");
const tbC = document.getElementById("tb-clarity");
const tbA = document.getElementById("tb-auto");
const tbAll = document.getElementById("tb-all");
tbC.innerHTML = groupByAccount(clarity, editableRow, 8);
tbA.innerHTML = groupByAccount(auto, editableRow, 8);
tbAll.innerHTML = groupByAccount(rows, readonlyRow, 6);
document.getElementById("empty-clarity").hidden = clarity.length > 0;
document.getElementById("empty-auto").hidden = auto.length > 0;
const nApproved = Object.values(state).filter(s => s.approved).length;
document.getElementById("counts").innerHTML =
`<span><b>${rows.length}</b> rows</span>` +
`<span><b>${clarity.length}</b> need clarity</span>` +
`<span><b>${auto.length}</b> auto</span>` +
`<span><b>${rows.filter(r=>r.bucket==="SKIP-dup").length}</b> dup</span>` +
`<span><b>${nApproved}</b> approved</span>`;
}
// --- event wiring (delegated, so re-render keeps working) ------------------
document.body.addEventListener("input", e => {
const tr = e.target.closest("tr[data-id]");
if (!tr) return;
const id = tr.dataset.id, s = state[id];
if (e.target.classList.contains("f-account")) {
s.account = e.target.value; s.account_id = null; // typed name => re-resolve
} else if (e.target.classList.contains("f-category")) {
s.category = e.target.value;
} else if (e.target.classList.contains("f-budget")) {
s.budget = e.target.value;
} else if (e.target.classList.contains("f-comment")) {
s.comment = e.target.value;
}
});
document.body.addEventListener("click", e => {
// suggestion chip -> fill account
if (e.target.matches(".sugg a") && e.target.dataset.acct != null) {
const id = e.target.dataset.id;
state[id].account = e.target.dataset.acct;
state[id].account_id = null;
render();
return;
}
if (e.target.matches(".catsugg a") && e.target.dataset.cat != null) {
const id = e.target.dataset.id;
state[id].category = e.target.dataset.cat;
render();
return;
}
if (e.target.matches(".budsugg a") && e.target.dataset.bud != null) {
const id = e.target.dataset.id;
state[id].budget = e.target.dataset.bud;
render();
return;
}
const tog = e.target.closest(".toggle");
if (tog) {
const id = tog.dataset.id;
if (e.target.classList.contains("d-ok")) state[id].approved = true;
else if (e.target.classList.contains("d-no")) state[id].approved = false;
render();
}
});
document.getElementById("approveAuto").addEventListener("click", () => {
for (const r of rows) {
if (r.bucket === "CREATE" || r.bucket === "TRANSFER")
state[r.external_id].approved = true;
}
render();
});
document.getElementById("export").addEventListener("click", () => {
// Only emit rows the user can act on (clarity + auto). SKIP-dup rows are
// read-only context and are never posted, so they stay out of decisions.
const out = {};
for (const r of rows) {
if (r.bucket === "SKIP-dup") continue;
const s = state[r.external_id];
out[r.external_id] = {
approved: !!s.approved,
account: s.account || null,
account_id: (s.account_id != null) ? s.account_id : null,
category: s.category || null,
budget: s.budget || null,
comment: s.comment || ""
};
}
const blob = new Blob([JSON.stringify(out, null, 2)],
{ type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "decisions.json";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
// Constrain category/account inputs to what already exists in Firefly, so
// the user clicks/filters real values instead of free-typing near-duplicates
// ("Restaurants" vs "Restaurant" vs "Dining"). New values are still possible
// but it's a deliberate type-through, not the path of least resistance.
function buildList(id, items) {
const dl = document.createElement("datalist");
dl.id = id;
for (const v of (items || [])) {
const o = document.createElement("option");
o.value = v;
dl.appendChild(o);
}
document.body.appendChild(dl);
}
buildList("catlist", (PLAN.meta && PLAN.meta.categories) || []);
buildList("acctlist", (PLAN.meta && PLAN.meta.accounts) || []);
render();
</script>
</body>
</html>