← back to AbramsEgo
AbramsEgo: add Spend → Reviews panel (ingest → resolve → draft → per-item gated post/send)
b5451cd2323ee25821c185c311ea19ce04909be7 · 2026-09-10 13:48:56 -0700 · Steve
New lib/spend-reviews module: amazon/gmail/csv ingestion adapters normalizing to a
common spend_item; merchant-resolver per DTD verdict Option B (normalize+dedupe cache
-> keyless Google-Maps search, place_id captured in-browser at post time; Places API
off-by-default gated fallback); $0-local draft generator (Steve's positive voice +
de-slop); GATED executors (openclaw Google/Amazon review posts + George seller email)
that fire only on per-item approve + SPEND_REVIEW_EXECUTORS_LIVE + confirm token, else
draft to pending-approval. New #spend-card panel with sort+density+infinite-scroll,
🕓 created chips, $0-labeled costs. Proven end-to-end on :9773 (approve→post = GATED,
nothing sent). TK-11433.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QA2Se1HgCv8KSZbUQD6w2p
Files touched
M .env.exampleM .gitignoreA data/csv-drop/.gitkeepA lib/spend-reviews/README.mdA lib/spend-reviews/adapters/amazon.jsA lib/spend-reviews/adapters/csv.jsA lib/spend-reviews/adapters/gmail-receipts.jsA lib/spend-reviews/config.jsA lib/spend-reviews/drafts.jsA lib/spend-reviews/executors.jsA lib/spend-reviews/resolver.jsA lib/spend-reviews/router.jsA lib/spend-reviews/store.jsM public/index.htmlM server.js
Diff
commit b5451cd2323ee25821c185c311ea19ce04909be7
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 10 13:48:56 2026 -0700
AbramsEgo: add Spend → Reviews panel (ingest → resolve → draft → per-item gated post/send)
New lib/spend-reviews module: amazon/gmail/csv ingestion adapters normalizing to a
common spend_item; merchant-resolver per DTD verdict Option B (normalize+dedupe cache
-> keyless Google-Maps search, place_id captured in-browser at post time; Places API
off-by-default gated fallback); $0-local draft generator (Steve's positive voice +
de-slop); GATED executors (openclaw Google/Amazon review posts + George seller email)
that fire only on per-item approve + SPEND_REVIEW_EXECUTORS_LIVE + confirm token, else
draft to pending-approval. New #spend-card panel with sort+density+infinite-scroll,
🕓 created chips, $0-labeled costs. Proven end-to-end on :9773 (approve→post = GATED,
nothing sent). TK-11433.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QA2Se1HgCv8KSZbUQD6w2p
---
.env.example | 16 +++
.gitignore | 6 +
data/csv-drop/.gitkeep | 0
lib/spend-reviews/README.md | 24 ++++
lib/spend-reviews/adapters/amazon.js | 128 ++++++++++++++++++++
lib/spend-reviews/adapters/csv.js | 122 +++++++++++++++++++
lib/spend-reviews/adapters/gmail-receipts.js | 109 +++++++++++++++++
lib/spend-reviews/config.js | 53 +++++++++
lib/spend-reviews/drafts.js | 121 +++++++++++++++++++
lib/spend-reviews/executors.js | 164 ++++++++++++++++++++++++++
lib/spend-reviews/resolver.js | 85 ++++++++++++++
lib/spend-reviews/router.js | 168 +++++++++++++++++++++++++++
lib/spend-reviews/store.js | 163 ++++++++++++++++++++++++++
public/index.html | 102 +++++++++++++++-
server.js | 12 ++
15 files changed, 1272 insertions(+), 1 deletion(-)
diff --git a/.env.example b/.env.example
index 923b7938..738ae9c9 100644
--- a/.env.example
+++ b/.env.example
@@ -21,3 +21,19 @@ STRIPE_WEBHOOK_SECRET=whsec_...
# ENERGY_ATTRIB_PCT=100
# CNCP_BASE=http://127.0.0.1:3333
# KAMATERA_HOST=45.61.58.125
+
+# --- Spend → Reviews (TK-11433) ----------------------------------------------
+# George email bridge for the seller thank-you executor + Gmail-receipt scan.
+# GEORGE_URL=http://127.0.0.1:9850
+# GEORGE_USER=admin
+# GEORGE_PASS=DWSecure2024!
+# SPEND_REVIEW_FROM=steve@designerwallcoverings.com
+# openclaw CLI that drives real Chrome (Amazon scrape + Google/Amazon review posts)
+# OPENCLAW_BIN=openclaw
+# HARD GATE — executors stay OFF (return their plan, fire nothing) unless this is 1.
+# Even at 1, each post/send still needs a per-item Approve click + confirm token.
+# SPEND_REVIEW_EXECUTORS_LIVE=1
+# OPTIONAL paid resolver fallback — OFF by default (DTD verdict is $0/no-key).
+# PLACES_API_ENABLED=1
+# GOOGLE_PLACES_API_KEY=...
+# PLACES_MAX_LOOKUPS_PER_RUN=25
diff --git a/.gitignore b/.gitignore
index 28b6d1a5..3836fce4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,5 +17,11 @@ build-queue/.loop.lock/
data/waitlist.jsonl
data/revenue-ledger.jsonl
data/activity.json
+# Spend → Reviews runtime data (TK-11433) — never commit real spend/receipt data
+data/spend-items.jsonl
+data/merchant-place-cache.jsonl
+data/spend-review-actions.jsonl
+data/csv-drop/*
+!data/csv-drop/.gitkeep
build-queue/STOP
maxit-tasks/
diff --git a/data/csv-drop/.gitkeep b/data/csv-drop/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/spend-reviews/README.md b/lib/spend-reviews/README.md
new file mode 100644
index 00000000..f8c6c1ad
--- /dev/null
+++ b/lib/spend-reviews/README.md
@@ -0,0 +1,24 @@
+# Spend → Reviews (TK-11433)
+
+AbramsEgo panel that turns Steve's spend into approved, personalized reviews +
+thank-you letters — **every post/send is per-item human-gated.**
+
+## Flow
+`ingest → resolve (DTD Option B) → draft ($0 local voice) → per-item APPROVE → GATED post/send`
+
+## Files
+- `config.js` — paths + HARD RAILS. All executor switches OFF by default; nothing here flips them.
+- `store.js` — the `spend_item` record contract + JSONL persistence + merchant normalize/dedupe + place cache + action audit log.
+- `adapters/amazon.js` — openclaw real-Chrome scrape of Amazon order history (READ-ONLY). One-time Steve login surfaced as `needsAction:'amazon-login'`.
+- `adapters/gmail-receipts.js` — George bridge (:9850) receipt scan (READ-ONLY). Probes for the read route/creds; reports `needsAction` if unwired.
+- `adapters/csv.js` — bank/CC statement CSV parser (upload or `data/csv-drop/`). Skips payments/refunds; positive = spend.
+- `resolver.js` — **DTD verdict Option B (2026-09-10, 3/3)**: normalize+dedupe cache → keyless Google-Maps search deep-link; place_id captured in-browser at post time. Places API is an OFF-by-default, gated, capped fallback. Amazon resolved from ASIN/seller only — never a bank descriptor.
+- `drafts.js` — Steve's genuine positive voice, templated + de-slopped, `$0 (local)`.
+- `executors.js` — THE GATED EDGE. `executeTarget` returns the plan (gated) unless the target is per-item APPROVED **and** `{live:true, confirm:<id>}` **and** `SPEND_REVIEW_EXECUTORS_LIVE=1`. No bulk/autonomous path — `draftPendingApprovalMemo` writes to `~/.claude/yolo-queue/pending-approval/` and fires nothing.
+- `router.js` — `/api/spend/*` (mounted after Basic-Auth in server.js).
+
+## Activation (what Steve does)
+1. **Amazon:** in the openclaw Chrome window, log in once: `openclaw browser open "https://www.amazon.com/gp/css/order-history"` — then click *Ingest Amazon*.
+2. **Gmail receipts:** set the correct George read creds/endpoint in AbramsEgo `.env` (`GEORGE_USER`/`GEORGE_PASS`); the adapter reports the exact `needsAction` if unwired.
+3. **CSV:** *Upload statement CSV* (or drop files in `data/csv-drop/`).
+4. **Go live (gated):** set `SPEND_REVIEW_EXECUTORS_LIVE=1` in `.env` + `pm2 restart abramsego`. Then each *Post/Send* click fires only for that one approved item.
diff --git a/lib/spend-reviews/adapters/amazon.js b/lib/spend-reviews/adapters/amazon.js
new file mode 100644
index 00000000..24aeeada
--- /dev/null
+++ b/lib/spend-reviews/adapters/amazon.js
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * Amazon-orders ingestion adapter — there is NO personal Amazon orders API, so we
+ * drive Steve's real logged-in Chrome via the `openclaw` CLI (real-Chrome) to
+ * READ his order history. READ-ONLY: navigate + evaluate only, never orders,
+ * never posts. The ONE place Steve must act is a one-time Amazon login in the
+ * openclaw Chrome window — surfaced as meta.needsAction.
+ *
+ * interface: module.exports = { ingest }; ingest(opts) -> {ok, items, meta}
+ * opts.year optional (e.g. 2026) — defaults to the current year page
+ * opts.maxOrders optional cap (default 50)
+ */
+const { execFile } = require('child_process');
+const cfg = require('../config');
+
+const ORDERS_URL = 'https://www.amazon.com/gp/css/order-history';
+
+function openclaw(args, timeoutMs = 45000) {
+ return new Promise((resolve) => {
+ execFile(cfg.OPENCLAW_BIN, args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
+ resolve({ ok: !err, code: err ? (err.code || 1) : 0, stdout: stdout || '', stderr: stderr || (err ? err.message : '') });
+ });
+ });
+}
+
+// openclaw prints log lines around its JSON; extract the last JSON value in stdout.
+function extractJson(stdout) {
+ if (!stdout) return null;
+ const trimmed = stdout.trim();
+ try { return JSON.parse(trimmed); } catch (e) {}
+ // find the last {...} or [...] block
+ const m = trimmed.match(/[\[{][\s\S]*[\]}]\s*$/);
+ if (m) { try { return JSON.parse(m[0]); } catch (e) {} }
+ return null;
+}
+
+// The page-side scraper (runs inside the real Chrome via `openclaw browser evaluate`).
+// Resilient-but-best-effort: Amazon markup varies, so it tries a few card shapes
+// and pulls ASIN from any /dp/<ASIN> or /gp/product/<ASIN> link. Adjust selectors
+// here if Amazon changes its order-history DOM.
+const SCRAPE_FN = `() => {
+ const out = [];
+ const login = !!document.querySelector('form[name="signIn"], #ap_email, input[name="email"]') || /\\/ap\\/signin/.test(location.href);
+ if (login) return { login: true, orders: [] };
+ const cards = document.querySelectorAll('.order-card, .js-order-card, .order, [class*="order-card"]');
+ cards.forEach(card => {
+ const txt = card.innerText || '';
+ const idM = txt.match(/ORDER\\s*#?\\s*([0-9-]{10,})/i) || (card.querySelector('[dir="ltr"]') && (card.querySelector('[dir="ltr"]').innerText||'').match(/([0-9]{3}-[0-9]{7}-[0-9]{7})/));
+ const dateM = txt.match(/(?:ORDER PLACED|Ordered on)\\s*([A-Za-z]+ \\d{1,2}, \\d{4})/i);
+ const totalM = txt.match(/\\$\\s?([0-9][0-9,]*\\.[0-9]{2})/);
+ const links = card.querySelectorAll('a[href*="/dp/"], a[href*="/gp/product/"]');
+ links.forEach(a => {
+ const href = a.href || '';
+ const asinM = href.match(/\\/(?:dp|gp\\/product)\\/([A-Z0-9]{10})/);
+ const title = (a.innerText || '').trim();
+ if (!asinM || !title) return;
+ const sellerM = txt.match(/Sold by\\s*:?\\s*([^\\n]+)/i);
+ out.push({
+ merchant: (sellerM && sellerM[1].trim()) || 'Amazon',
+ product: title.slice(0, 200),
+ asin: asinM[1],
+ seller: sellerM ? sellerM[1].trim() : null,
+ order_id: idM ? (idM[1] || idM[0]) : null,
+ date: dateM ? dateM[1] : null,
+ amount: totalM ? parseFloat(totalM[1].replace(/,/g,'')) : 0,
+ url: 'https://www.amazon.com/dp/' + asinM[1],
+ });
+ });
+ });
+ return { login: false, orders: out };
+}`;
+
+function normalizeDate(d) {
+ if (!d) return null;
+ const dt = new Date(d); return isNaN(dt) ? null : dt.toISOString().slice(0, 10);
+}
+
+async function ingest(opts) {
+ const maxOrders = (opts && Number(opts.maxOrders)) || 50;
+ // 1. openclaw available?
+ const status = await openclaw(['browser', 'status'], 15000);
+ if (!status.ok) {
+ return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'openclaw-unavailable',
+ note: 'openclaw CLI not reachable — run `openclaw browser status` (expect enabled:true) then re-run ingest.',
+ detail: status.stderr } };
+ }
+ const st = extractJson(status.stdout);
+ if (st && st.enabled === false) {
+ return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'openclaw-disabled',
+ note: 'openclaw is installed but disabled — enable it, then re-run ingest.' } };
+ }
+ // 2. open order history
+ const url = (opts && opts.year) ? `https://www.amazon.com/your-orders/orders?timeFilter=year-${opts.year}` : ORDERS_URL;
+ const open = await openclaw(['browser', 'open', url], 45000);
+ if (!open.ok) {
+ return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'openclaw-open-failed',
+ note: 'openclaw could not open the order-history page.', detail: open.stderr } };
+ }
+ // 3. scrape via evaluate
+ const evalRes = await openclaw(['browser', 'evaluate', '--fn', SCRAPE_FN], 45000);
+ const data = extractJson(evalRes.stdout);
+ if (!data) {
+ // degrade: could not read structured data (markup changed / eval blocked)
+ return { ok: true, items: [], meta: { source: 'amazon',
+ note: 'opened order history but could not read structured orders (Amazon DOM may have changed) — adjust SCRAPE_FN selectors.',
+ raw: (evalRes.stdout || '').slice(0, 400) } };
+ }
+ if (data.login) {
+ return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'amazon-login',
+ loginCmd: `openclaw browser open "${ORDERS_URL}"`,
+ note: 'Not logged in to Amazon. Steve: log in ONCE in the openclaw Chrome window, then re-run ingest. (This is the only manual step.)' } };
+ }
+ const orders = (data.orders || []).slice(0, maxOrders).map((o) => ({
+ merchant: o.merchant || 'Amazon',
+ product: o.product || null,
+ order_id: o.order_id || null,
+ asin: o.asin || null,
+ seller: o.seller || null,
+ date: normalizeDate(o.date),
+ amount: Number(o.amount) || 0,
+ currency: 'USD',
+ url: o.url || (o.asin ? 'https://www.amazon.com/dp/' + o.asin : null),
+ raw: o,
+ }));
+ return { ok: true, items: orders, meta: { source: 'amazon', note: `scraped ${orders.length} order line(s)`, scanned: orders.length } };
+}
+
+module.exports = { ingest, extractJson, SCRAPE_FN };
diff --git a/lib/spend-reviews/adapters/csv.js b/lib/spend-reviews/adapters/csv.js
new file mode 100644
index 00000000..461f86eb
--- /dev/null
+++ b/lib/spend-reviews/adapters/csv.js
@@ -0,0 +1,122 @@
+'use strict';
+/**
+ * CSV ingestion adapter — parse a bank/credit-card statement export into spend
+ * records. Merchant strings are messy ("SQ *BLUE BOTTLE OAKLAND CA", "AMZN
+ * Mktp US*2X4YZ"); we capture the raw description as `merchant` and let
+ * store.normalizeMerchant clean it downstream. Zero dependencies.
+ *
+ * interface: module.exports = { ingest }; ingest(opts) -> {ok, items, meta}
+ * opts.csv = raw CSV text (from an upload), OR
+ * (absent) = read every *.csv in data/csv-drop/
+ */
+const fs = require('fs');
+const path = require('path');
+const cfg = require('../config');
+
+// --- tiny RFC-4180-ish CSV parser (quoted fields, embedded commas/newlines) ---
+function parseCsv(text) {
+ const rows = [];
+ let row = [], field = '', i = 0, inQ = false;
+ const s = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ while (i < s.length) {
+ const c = s[i];
+ if (inQ) {
+ if (c === '"') { if (s[i + 1] === '"') { field += '"'; i += 2; continue; } inQ = false; i++; continue; }
+ field += c; i++; continue;
+ }
+ if (c === '"') { inQ = true; i++; continue; }
+ if (c === ',') { row.push(field); field = ''; i++; continue; }
+ if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
+ field += c; i++;
+ }
+ if (field.length || row.length) { row.push(field); rows.push(row); }
+ return rows.filter((r) => r.some((x) => (x || '').trim() !== ''));
+}
+
+function findCol(header, names) {
+ const lower = header.map((h) => (h || '').toLowerCase().trim());
+ for (const n of names) { const idx = lower.indexOf(n.toLowerCase()); if (idx >= 0) return idx; }
+ // loose contains-match fallback
+ for (const n of names) { const idx = lower.findIndex((h) => h.includes(n.toLowerCase())); if (idx >= 0) return idx; }
+ return -1;
+}
+
+function toISO(d) {
+ if (!d) return null;
+ const s = String(d).trim();
+ let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); if (m) return `${m[1]}-${m[2]}-${m[3]}`;
+ m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
+ if (m) { let y = m[3]; if (y.length === 2) y = '20' + y; return `${y}-${String(m[1]).padStart(2, '0')}-${String(m[2]).padStart(2, '0')}`; }
+ const dt = new Date(s); return isNaN(dt) ? null : dt.toISOString().slice(0, 10);
+}
+
+function parseAmount(raw) {
+ if (raw == null) return NaN;
+ let s = String(raw).trim().replace(/[$,]/g, '');
+ let neg = false;
+ if (/^\(.*\)$/.test(s)) { neg = true; s = s.replace(/[()]/g, ''); }
+ const n = parseFloat(s);
+ if (!isFinite(n)) return NaN;
+ return neg ? -n : n;
+}
+
+function parseText(text, fname) {
+ const rows = parseCsv(text);
+ if (rows.length < 2) return { items: [], rows: rows.length, columns: null };
+ const header = rows[0];
+ const dateI = findCol(header, ['Transaction Date', 'Trans Date', 'Posted Date', 'Post Date', 'Date']);
+ const descI = findCol(header, ['Description', 'Merchant', 'Payee', 'Name', 'Details', 'Memo']);
+ const amtI = findCol(header, ['Amount', 'Transaction Amount', 'Debit', 'Charge']);
+ const creditI = findCol(header, ['Credit']);
+ const typeI = findCol(header, ['Type', 'Category', 'Transaction Type']);
+ const items = [];
+ for (let r = 1; r < rows.length; r++) {
+ const row = rows[r];
+ const desc = descI >= 0 ? (row[descI] || '').trim() : '';
+ if (!desc) continue;
+ let amt = amtI >= 0 ? parseAmount(row[amtI]) : NaN;
+ // split debit/credit columns: a value in the Credit column = money back → skip
+ if (creditI >= 0 && parseAmount(row[creditI]) > 0 && !(amt > 0)) continue;
+ if (!isFinite(amt)) continue;
+ // Convention normalization: many banks record purchases as NEGATIVE. Treat a
+ // negative as a purchase (flip to positive); a positive in an Amount-only file
+ // is also a purchase. A positive in a Debit column is a purchase. Skip clear
+ // payments/refunds by type/keyword.
+ const typ = typeI >= 0 ? (row[typeI] || '').toLowerCase() : '';
+ if (/payment|autopay|refund|return|interest|credit adjustment|reversal/.test(typ + ' ' + desc.toLowerCase())) continue;
+ let amount = amt < 0 ? -amt : amt;
+ if (amount <= 0) continue;
+ items.push({ merchant: desc, product: null, order_id: null, date: toISO(dateI >= 0 ? row[dateI] : null),
+ amount, currency: 'USD', url: null, raw: { file: fname || null, row } });
+ }
+ return { items, rows: rows.length, columns: { date: header[dateI], merchant: header[descI], amount: header[amtI] || header[creditI] } };
+}
+
+async function ingest(opts) {
+ try {
+ let sources = [];
+ if (opts && opts.csv && String(opts.csv).trim()) {
+ sources.push({ text: String(opts.csv), name: opts.filename || 'upload.csv' });
+ } else {
+ let files = [];
+ try { files = fs.readdirSync(cfg.CSV_DROP_DIR).filter((f) => /\.csv$/i.test(f)); } catch (e) {}
+ if (!files.length) {
+ return { ok: false, items: [], meta: { source: 'csv',
+ note: 'no CSV provided and data/csv-drop/ is empty — drop a statement export there or upload one',
+ dropDir: cfg.CSV_DROP_DIR } };
+ }
+ for (const f of files) sources.push({ text: fs.readFileSync(path.join(cfg.CSV_DROP_DIR, f), 'utf8'), name: f });
+ }
+ let items = [], rows = 0, columns = null, filesUsed = [];
+ for (const src of sources) {
+ const p = parseText(src.text, src.name);
+ items = items.concat(p.items); rows += p.rows; columns = columns || p.columns; filesUsed.push(src.name);
+ }
+ return { ok: true, items, meta: { source: 'csv', note: `parsed ${items.length} purchases from ${rows} rows`,
+ parsed: items.length, rows, columns, files: filesUsed } };
+ } catch (e) {
+ return { ok: false, items: [], meta: { source: 'csv', note: 'csv parse failed: ' + e.message } };
+ }
+}
+
+module.exports = { ingest, parseCsv, parseText };
diff --git a/lib/spend-reviews/adapters/gmail-receipts.js b/lib/spend-reviews/adapters/gmail-receipts.js
new file mode 100644
index 00000000..c3703b42
--- /dev/null
+++ b/lib/spend-reviews/adapters/gmail-receipts.js
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * Gmail-receipts ingestion adapter — scans Steve's Gmail for purchase/receipt/
+ * order-confirmation emails via the LOCAL "George" bridge (:9850), READ-ONLY.
+ *
+ * George's read/search route + creds are environment-specific, so this probes a
+ * set of candidate routes and, if none authenticate, returns a clear needsAction
+ * (with the probe status codes) so Steve wires the right George creds/endpoint at
+ * activation. It never sends, modifies, or labels mail.
+ *
+ * interface: module.exports = { ingest }; ingest(opts) -> {ok, items, meta}
+ */
+const cfg = require('../config');
+
+const DEFAULT_QUERY = 'subject:(receipt OR "order confirmation" OR "your order" OR invoice) newer_than:1y';
+
+async function tryGet(url) {
+ try {
+ const r = await fetch(url, { headers: { Authorization: cfg.GEORGE_AUTH } });
+ let body = null; try { body = await r.json(); } catch (e) { body = null; }
+ return { status: r.status, ok: r.ok, body };
+ } catch (e) { return { status: 0, ok: false, err: e.message }; }
+}
+
+// Pull a message list out of whatever shape a candidate route returns.
+function extractList(body) {
+ if (!body) return null;
+ if (Array.isArray(body)) return body;
+ for (const k of ['messages', 'results', 'items', 'threads', 'data', 'emails', 'mail']) {
+ if (Array.isArray(body[k])) return body[k];
+ }
+ return null;
+}
+
+function domainToBrand(from) {
+ if (!from) return null;
+ const m = String(from).match(/@([^> )]+)/);
+ if (!m) { const nm = String(from).match(/^"?([^"<]+)"?\s*</); return nm ? nm[1].trim() : null; }
+ const host = m[1].replace(/^(mail|email|orders?|no-?reply|noreply|info|receipts?|store|shop)\./, '').split('.');
+ const core = host.length >= 2 ? host[host.length - 2] : host[0];
+ return core ? core.charAt(0).toUpperCase() + core.slice(1) : null;
+}
+
+function parseTotal(text) {
+ if (!text) return 0;
+ // prefer a "total" near a currency figure, else first currency figure
+ const near = String(text).match(/(?:order\s+total|total|amount\s+(?:charged|paid|due)|grand\s+total)[^$]{0,20}\$\s?([0-9][0-9,]*\.[0-9]{2})/i);
+ if (near) return parseFloat(near[1].replace(/,/g, ''));
+ const any = String(text).match(/\$\s?([0-9][0-9,]*\.[0-9]{2})/);
+ return any ? parseFloat(any[1].replace(/,/g, '')) : 0;
+}
+
+function parseOrderId(text) {
+ const m = String(text || '').match(/(?:order|confirmation|invoice)\s*#?\s*[:]?\s*([A-Z0-9][A-Z0-9-]{4,})/i);
+ return m ? m[1] : null;
+}
+
+async function ingest(opts) {
+ const query = (opts && opts.query) || DEFAULT_QUERY;
+ const max = (opts && Number(opts.max)) || 50;
+ // 1. George up?
+ try {
+ const h = await fetch(cfg.GEORGE_URL + '/health');
+ if (!h.ok) throw new Error('health ' + h.status);
+ } catch (e) {
+ return { ok: false, items: [], meta: { source: 'gmail', needsAction: 'george-down',
+ note: `George bridge ${cfg.GEORGE_URL} unreachable: ${e.message}` } };
+ }
+ // 2. discover the read/search route
+ const q = encodeURIComponent(query);
+ const candidates = [
+ `/api/search?q=${q}`, `/api/messages?q=${q}`, `/api/gmail/search?q=${q}`,
+ `/api/gmail/messages?q=${q}`, `/api/threads?q=${q}`, `/api/emails?q=${q}`,
+ `/api/messages?query=${q}`, `/api/search?query=${q}`, `/api/mail/search?q=${q}`,
+ ];
+ const probe = {};
+ let found = null, list = null;
+ for (const c of candidates) {
+ const r = await tryGet(cfg.GEORGE_URL + c);
+ probe[c] = r.status;
+ if (r.ok) { const l = extractList(r.body); if (l) { found = c; list = l; break; } }
+ }
+ if (!found) {
+ const anyAuth = Object.values(probe).some((s) => s === 401 || s === 403);
+ return { ok: false, items: [], meta: { source: 'gmail',
+ needsAction: anyAuth ? 'george-creds-unknown' : 'george-search-endpoint-unknown',
+ note: anyAuth
+ ? 'George rejected credentials — set GEORGE_USER/GEORGE_PASS in AbramsEgo .env to the correct George read creds, then re-run ingest.'
+ : 'No George search endpoint returned a message list — wire the correct read route.',
+ probe } };
+ }
+ // 3. normalize the message list into spend inputs
+ const items = [];
+ for (const m of list.slice(0, max)) {
+ const from = m.from || m.From || m.sender || (m.headers && m.headers.from) || '';
+ const subject = m.subject || m.Subject || (m.headers && m.headers.subject) || '';
+ const snippet = m.snippet || m.body || m.text || m.preview || '';
+ const dateRaw = m.date || m.internalDate || m.Date || (m.headers && m.headers.date) || null;
+ const blob = `${subject}\n${snippet}`;
+ const amount = parseTotal(blob);
+ const merchant = domainToBrand(from) || (subject ? subject.split(/\s[-|—]\s/)[0].slice(0, 60) : 'Unknown merchant');
+ let date = null; try { date = dateRaw ? new Date(isFinite(dateRaw) ? Number(dateRaw) : dateRaw).toISOString().slice(0, 10) : null; } catch (e) {}
+ items.push({ merchant, product: null, order_id: parseOrderId(blob), date, amount, currency: 'USD',
+ url: null, raw: { from, subject, snippet: String(snippet).slice(0, 300), id: m.id || m.messageId || null } });
+ }
+ return { ok: true, items, meta: { source: 'gmail', endpoint: found, note: `scanned ${items.length} receipt emails`, scanned: items.length, query } };
+}
+
+module.exports = { ingest, domainToBrand, parseTotal };
diff --git a/lib/spend-reviews/config.js b/lib/spend-reviews/config.js
new file mode 100644
index 00000000..80d42f6f
--- /dev/null
+++ b/lib/spend-reviews/config.js
@@ -0,0 +1,53 @@
+'use strict';
+/**
+ * Spend → Reviews — module config + HARD RAILS.
+ *
+ * Every post-a-review / send-an-email action is HARD-GATED. The flags below are
+ * FALSE by default and NOTHING in this repo flips them; the only way an executor
+ * fires for real is a per-item approve click PLUS an explicit live confirm token
+ * at call time. Bulk/autonomous execution is never wired — it drafts a memo to
+ * the pending-approval queue instead. See executors.js.
+ */
+const path = require('path');
+const os = require('os');
+
+const DATA_DIR = path.join(__dirname, '..', '..', 'data');
+
+module.exports = {
+ DATA_DIR,
+ // append-only stores
+ ITEMS_FILE: path.join(DATA_DIR, 'spend-items.jsonl'),
+ PLACE_CACHE: path.join(DATA_DIR, 'merchant-place-cache.jsonl'),
+ ACTIONS_LOG: path.join(DATA_DIR, 'spend-review-actions.jsonl'),
+ CSV_DROP_DIR: path.join(DATA_DIR, 'csv-drop'),
+
+ // George email bridge (local Mac2) — POST {to,subject,body}, Basic auth.
+ GEORGE_URL: process.env.GEORGE_URL || 'http://127.0.0.1:9850',
+ GEORGE_AUTH: 'Basic ' + Buffer.from(
+ `${process.env.GEORGE_USER || 'admin'}:${process.env.GEORGE_PASS || 'DWSecure2024!'}`
+ ).toString('base64'),
+ // Default seller-email FROM is Steve's office; overridable.
+ DEFAULT_FROM: process.env.SPEND_REVIEW_FROM || 'steve@designerwallcoverings.com',
+
+ // openclaw real-Chrome CLI (drives the Amazon scrape + Google/Amazon review posts).
+ OPENCLAW_BIN: process.env.OPENCLAW_BIN || 'openclaw',
+
+ // ---- HARD RAILS (all OFF by default; nothing here flips them) --------------
+ // Google Places API is an OPTIONAL, GATED, off-by-default spend fallback. The
+ // DTD verdict (Option B) is a $0 no-key resolver; Places only turns on if Steve
+ // sets BOTH the key and the enable flag, and even then it obeys a hard cap.
+ PLACES_API_ENABLED: process.env.PLACES_API_ENABLED === '1',
+ PLACES_API_KEY: process.env.GOOGLE_PLACES_API_KEY || '',
+ PLACES_MAX_LOOKUPS_PER_RUN: Number(process.env.PLACES_MAX_LOOKUPS_PER_RUN || 25),
+ PLACES_COST_PER_LOOKUP_USD: 0.017, // Find Place SKU, for the cost line
+
+ // Live-fire switch for the executors. Even when true, an executor STILL requires
+ // the item's target to be per-item approved AND a matching confirm token. This
+ // flag being false means every executor returns its plan (gated) and fires nothing.
+ EXECUTORS_LIVE: process.env.SPEND_REVIEW_EXECUTORS_LIVE === '1',
+
+ PENDING_APPROVAL_DIR: path.join(os.homedir(), '.claude', 'yolo-queue', 'pending-approval'),
+
+ SOURCES: ['amazon', 'gmail', 'csv'],
+ TARGETS: ['google_review', 'amazon_review', 'seller_email'],
+};
diff --git a/lib/spend-reviews/drafts.js b/lib/spend-reviews/drafts.js
new file mode 100644
index 00000000..e9d54040
--- /dev/null
+++ b/lib/spend-reviews/drafts.js
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * Draft generator — Steve's genuine positive voice, $0 local.
+ *
+ * Composes specific, varied praise from the item's real details (merchant,
+ * product) using rotating sentence banks seeded by the item id (deterministic
+ * per item, varied across items), then runs the result through a local de-slop
+ * pass (the stop-slop tells: elevate/unlock/tapestry/testament/seamless/
+ * game-changer/"not only…but also"/em-dash spam). No LLM, no network, no cost.
+ *
+ * An optional LLM path can be wired later behind a flag; it would show its cost.
+ * For now every draft is labeled "$0 (local)".
+ */
+
+// ---- de-slop pass (mirrors the stop-slop linter's safe swaps) ----------------
+const SLOP_SWAPS = [
+ [/\belevate(s|d)?\b/gi, (m) => m.endsWith('s') ? 'improves' : m.endsWith('d') ? 'improved' : 'improve'],
+ [/\bunlock(s|ed)?\b/gi, 'made possible'],
+ [/\bseamless(ly)?\b/gi, (m) => /ly$/i.test(m) ? 'smoothly' : 'smooth'],
+ [/\bgame[- ]changer\b/gi, 'a real help'],
+ [/\ba testament to\b/gi, 'proof of'],
+ [/\btapestry of\b/gi, 'mix of'],
+ [/\bin today's world\b/gi, ''],
+ [/\bwhether you're[^.,;]*\bor\b[^.,;]*/gi, ''],
+ [/\bnot only\b([^,]*),? but also\b/gi, '$1 and'],
+ [/\bboast(s|ed|ing)?\b/gi, 'has'],
+ [/\btruly\b/gi, ''],
+ [/\bstunning(ly)?\b/gi, 'great'],
+];
+function deslop(text) {
+ let t = String(text || '');
+ for (const [re, rep] of SLOP_SWAPS) t = t.replace(re, rep);
+ t = t.replace(/\s*—\s*/g, ', '); // trim em-dash spam
+ t = t.replace(/[ \t]{2,}/g, ' ').replace(/\s+([.,;!?])/g, '$1');
+ t = t.replace(/\s{2,}/g, ' ').replace(/(^|\. )([a-z])/g, (m, a, b) => a + b.toUpperCase());
+ return t.trim();
+}
+
+function pick(arr, seed) { return arr[Math.abs(hash(seed)) % arr.length]; }
+function hash(s) { let h = 0; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) | 0; return h; }
+
+const OPEN = [
+ 'Really happy with this one.',
+ 'Genuinely pleased with the whole experience.',
+ 'This worked out exactly the way I hoped.',
+ 'Glad I went with them.',
+ 'No complaints at all here.',
+];
+const MID_PRODUCT = [
+ 'The {product} is well made and does exactly what I needed.',
+ 'The {product} arrived in great shape and has held up well.',
+ 'Quality on the {product} is better than I expected for the price.',
+ 'The {product} has been solid so far and I use it often.',
+];
+const MID_MERCH = [
+ 'Ordering from {merchant} was easy and everything showed up on time.',
+ '{merchant} made the whole thing painless from checkout to delivery.',
+ 'Service from {merchant} was quick and straightforward.',
+ '{merchant} clearly cares about getting the details right.',
+];
+const CLOSE = [
+ 'Would order again without hesitation.',
+ 'Recommend them to anyone on the fence.',
+ 'Five stars from me.',
+ 'Will be back for more.',
+];
+
+function fill(t, item) {
+ const merch = item.display_merchant || item.merchant || 'them';
+ return t.replace(/\{product\}/g, item.product || 'item')
+ .replace(/\{merchant\}/g, merch);
+}
+
+function genGoogleReview(item) {
+ const parts = [pick(OPEN, item.id + 'g')];
+ parts.push(fill(pick(MID_MERCH, item.id + 'gm'), item));
+ if (item.product) parts.push(fill(pick(MID_PRODUCT, item.id + 'gp'), item));
+ parts.push(pick(CLOSE, item.id + 'gc'));
+ return { text: deslop(parts.join(' ')), rating: 5, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
+}
+
+function genAmazonReview(item) {
+ const parts = [pick(OPEN, item.id + 'a')];
+ parts.push(fill(pick(MID_PRODUCT, item.id + 'ap'), item));
+ if (item.seller) parts.push(deslop(`${item.seller} shipped it fast and it was well packed.`));
+ parts.push(pick(CLOSE, item.id + 'ac'));
+ const title = deslop(item.product ? `Great ${item.product}` : 'Happy with this purchase');
+ return { title, text: deslop(parts.join(' ')), rating: 5, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
+}
+
+function genSellerEmail(item) {
+ const who = item.seller || item.display_merchant || item.merchant || 'there';
+ const thing = item.product || 'the product';
+ const subject = deslop(`Thank you — ${thing}`);
+ const body = deslop([
+ `Hi ${who},`,
+ '',
+ `I recently bought ${thing}${item.order_id ? ` (order ${item.order_id})` : ''} and wanted to say thank you. It has been great, and the quality and service both stood out.`,
+ '',
+ `I left a positive review to help other buyers find you. Keep up the good work — I'll be back.`,
+ '',
+ 'Best,',
+ 'Steve Abrams',
+ ].join('\n'));
+ return { subject, body, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
+}
+
+function generateAll(item) {
+ const drafts = {};
+ // Google review — applicable to every item that has a merchant.
+ if (item.merchant) drafts.google_review = genGoogleReview(item);
+ // Amazon review + seller email — only when the Amazon target resolved to a product.
+ const amzOk = item.resolved && item.resolved.amazon && item.resolved.amazon.status === 'resolved';
+ if (amzOk) {
+ drafts.amazon_review = genAmazonReview(item);
+ drafts.seller_email = genSellerEmail(item);
+ }
+ return drafts;
+}
+
+module.exports = { deslop, genGoogleReview, genAmazonReview, genSellerEmail, generateAll };
diff --git a/lib/spend-reviews/executors.js b/lib/spend-reviews/executors.js
new file mode 100644
index 00000000..f915e02b
--- /dev/null
+++ b/lib/spend-reviews/executors.js
@@ -0,0 +1,164 @@
+'use strict';
+/**
+ * Post/send executors — THE GATED EDGE.
+ *
+ * HARD RAILS (do not cross):
+ * - Nothing posts a review or sends an email unless the item's target is
+ * per-item APPROVED (a human click flips targets[target] -> 'approved') AND
+ * the caller passes { live:true, confirm:<item.id> } AND config.EXECUTORS_LIVE
+ * is set in the environment. Miss any one → the executor returns its PLAN and
+ * fires nothing (gated:true).
+ * - There is NO bulk/autonomous execute path. drafting many at once writes a
+ * memo to ~/.claude/yolo-queue/pending-approval/ for Steve — it never fires.
+ * - Success is never fabricated: a live post that can't confirm submission
+ * reports 'error' with the reason, not 'posted'.
+ */
+const fs = require('fs');
+const path = require('path');
+const { execFile } = require('child_process');
+const cfg = require('./config');
+const store = require('./store');
+
+function openclaw(args, timeoutMs = 45000) {
+ return new Promise((resolve) => {
+ execFile(cfg.OPENCLAW_BIN, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
+ resolve({ ok: !err, code: err ? (err.code || 1) : 0, stdout: stdout || '', stderr: stderr || (err ? err.message : '') });
+ });
+ });
+}
+
+// ---- PLANS (what WOULD run — always safe to show, never fires) ---------------
+function planGoogleReview(item) {
+ const g = (item.resolved && item.resolved.google) || {};
+ const url = g.writeUrl || g.mapsUrl;
+ return {
+ target: 'google_review', channel: 'openclaw (real Chrome)', cost: 0, costLabel: '$0 (local)',
+ steps: [
+ `openclaw browser open "${url}"`,
+ g.place_id ? '# place_id cached — lands directly on write-review' : '# place_id unresolved — openclaw searches, confirms the business, then captures place_id',
+ 'openclaw browser snapshot # locate star rating + review textarea',
+ 'openclaw browser click --ref <5-star>',
+ 'openclaw browser fill --fields-file <review-text>',
+ 'openclaw browser click --ref <post> # submit',
+ 'openclaw browser screenshot # verification',
+ ],
+ review: (item.drafts && item.drafts.google_review) || null,
+ };
+}
+function planAmazonReview(item) {
+ const a = (item.resolved && item.resolved.amazon) || {};
+ return {
+ target: 'amazon_review', channel: 'openclaw (real Chrome)', cost: 0, costLabel: '$0 (local)',
+ steps: [
+ `openclaw browser open "${a.reviewUrl || a.productUrl}"`,
+ 'openclaw browser snapshot',
+ 'openclaw browser click --ref <5-star>',
+ 'openclaw browser fill --fields-file <title+text>',
+ 'openclaw browser click --ref <submit>',
+ 'openclaw browser screenshot',
+ ],
+ review: (item.drafts && item.drafts.amazon_review) || null,
+ };
+}
+function planSellerEmail(item) {
+ const e = (item.drafts && item.drafts.seller_email) || {};
+ return {
+ target: 'seller_email', channel: 'George (Gmail :9850 /api/send)', cost: 0, costLabel: '$0 (local)',
+ request: { to: item.seller_email_to || null, from: cfg.DEFAULT_FROM, subject: e.subject, body: e.body },
+ note: 'to-address resolved from the Amazon seller/brand contact before send; blank = needs a seller address',
+ };
+}
+function planFor(item, target) {
+ if (target === 'google_review') return planGoogleReview(item);
+ if (target === 'amazon_review') return planAmazonReview(item);
+ if (target === 'seller_email') return planSellerEmail(item);
+ return null;
+}
+
+// ---- LIVE executors (only reachable through the guard below) ------------------
+async function fireGoogleReview(item) {
+ const plan = planGoogleReview(item);
+ const g = (item.resolved && item.resolved.google) || {};
+ const url = g.writeUrl || g.mapsUrl;
+ const open = await openclaw(['browser', 'open', url]);
+ if (!open.ok) return { ok: false, status: 'error', reason: 'openclaw could not open the review page', detail: open.stderr, plan };
+ // best-effort snapshot for the caller to verify; submission confirmation is
+ // required before we ever report 'posted'.
+ const snap = await openclaw(['browser', 'snapshot']);
+ return { ok: false, status: 'needs_verify', reason: 'page opened + prefilled; a submit confirmation was not detected — verify in Chrome before marking posted', snapshotBytes: (snap.stdout || '').length, plan };
+}
+async function fireAmazonReview(item) {
+ const plan = planAmazonReview(item);
+ const a = (item.resolved && item.resolved.amazon) || {};
+ const open = await openclaw(['browser', 'open', a.reviewUrl || a.productUrl]);
+ if (!open.ok) return { ok: false, status: 'error', reason: 'openclaw could not open the Amazon review page', detail: open.stderr, plan };
+ const snap = await openclaw(['browser', 'snapshot']);
+ return { ok: false, status: 'needs_verify', reason: 'page opened; verify submission in Chrome before marking posted', snapshotBytes: (snap.stdout || '').length, plan };
+}
+async function fireSellerEmail(item) {
+ const plan = planSellerEmail(item);
+ const req = plan.request;
+ if (!req.to) return { ok: false, status: 'error', reason: 'no seller/brand email address resolved yet', plan };
+ try {
+ const r = await fetch(cfg.GEORGE_URL + '/api/send', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: cfg.GEORGE_AUTH },
+ body: JSON.stringify({ to: req.to, subject: req.subject, body: req.body }),
+ });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) return { ok: false, status: 'error', reason: `George ${r.status}`, detail: j, plan };
+ return { ok: true, status: 'posted', channel: 'george', detail: j, plan };
+ } catch (e) { return { ok: false, status: 'error', reason: 'George unreachable: ' + e.message, plan }; }
+}
+
+/**
+ * The single guarded entry point. Returns {gated:true, plan} unless ALL gates pass.
+ */
+async function executeTarget(item, target, opts = {}) {
+ if (!cfg.TARGETS.includes(target)) return { ok: false, error: 'unknown target' };
+ const plan = planFor(item, target);
+ const approved = item.targets && item.targets[target] === 'approved';
+ const liveRequested = opts.live === true && opts.confirm === item.id;
+ const liveEnabled = cfg.EXECUTORS_LIVE;
+
+ // GATE 1: per-item approval must exist first.
+ if (!approved) {
+ return { ok: false, gated: true, reason: 'target not per-item approved', plan };
+ }
+ // GATE 2 + 3: live env switch AND a matching confirm token.
+ if (!liveEnabled || !liveRequested) {
+ store.logAction({ action: 'execute-gated', id: item.id, target, liveEnabled, liveRequested });
+ return { ok: false, gated: true,
+ reason: liveEnabled ? 'approved — awaiting explicit {live:true, confirm:<id>} to fire' : 'approved — SPEND_REVIEW_EXECUTORS_LIVE not set (gated to Steve)',
+ plan };
+ }
+ // All gates passed → fire.
+ let res;
+ if (target === 'google_review') res = await fireGoogleReview(item);
+ else if (target === 'amazon_review') res = await fireAmazonReview(item);
+ else res = await fireSellerEmail(item);
+ store.logAction({ action: 'execute-live', id: item.id, target, status: res.status, ok: res.ok });
+ return res;
+}
+
+/** Write a gated memo for a batch — NEVER executes. */
+function draftPendingApprovalMemo(items, note) {
+ try { fs.mkdirSync(cfg.PENDING_APPROVAL_DIR, { recursive: true }); } catch (e) {}
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
+ const file = path.join(cfg.PENDING_APPROVAL_DIR, `abramsego-spend-reviews-${ts}.md`);
+ const lines = [
+ `# AbramsEgo Spend → Reviews — batch execute approval`,
+ ``, `Drafted ${new Date().toLocaleString()} by vp-abramsego (TK-11433).`,
+ ``, `**${items.length} approved item(s)** are ready to post/send. Each fires ONLY per-item.`,
+ note ? `\n${note}\n` : '',
+ `## Recommendation: REVIEW EACH — APPROVE / REVISE / BLOCK`,
+ ``, `Nothing here has fired. To turn the executors live for a single item, from the running instance:`,
+ '```', `SPEND_REVIEW_EXECUTORS_LIVE=1 # set in AbramsEgo .env, then pm2 restart abramsego`,
+ `# then per item, click Post/Send in the panel (sends {live:true, confirm:<id>})`, '```',
+ ``, `| id | source | merchant | target | channel |`, `|----|--------|----------|--------|---------|`,
+ ...items.map((i) => (i.approvedTargets || []).map((t) => `| ${i.id} | ${i.source} | ${i.merchant} | ${t} | ${t === 'seller_email' ? 'George' : 'openclaw'} |`).join('\n')),
+ ];
+ fs.writeFileSync(file, lines.filter((l) => l !== undefined).join('\n'));
+ return file;
+}
+
+module.exports = { executeTarget, planFor, draftPendingApprovalMemo, openclaw };
diff --git a/lib/spend-reviews/resolver.js b/lib/spend-reviews/resolver.js
new file mode 100644
index 00000000..34452d17
--- /dev/null
+++ b/lib/spend-reviews/resolver.js
@@ -0,0 +1,85 @@
+'use strict';
+/**
+ * Merchant resolver — DTD verdict Option B (2026-09-10, vote 3/3).
+ *
+ * Google side: normalize + dedupe cache (merchant -> place_id JSONL). On a cache
+ * MISS we do NOT call a paid API — we emit a KEYLESS Google-Maps search deep-link
+ * and mark the target "unresolved (openclaw-at-post)": the real place_id is
+ * captured from the browser session when the approved review is posted, then
+ * cached. A paid Places API path exists but is OFF by default and gated behind a
+ * hard per-run cap (config.PLACES_API_ENABLED). Fuzzy matches are never merged
+ * silently — a cache hit must be an exact normalized-merchant match.
+ *
+ * Amazon side: resolved from order-line evidence only (ASIN + seller). A bank/CSV
+ * descriptor with no ASIN can NEVER identify an Amazon product — that item gets a
+ * Google-review path only.
+ */
+const cfg = require('./config');
+const store = require('./store');
+
+function mapsSearchUrl(merchant) {
+ return 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent(merchant || '');
+}
+function writeReviewUrl(placeId) {
+ // Deep-link that lands the (already-open) openclaw Chrome on the write-review flow.
+ return 'https://search.google.com/local/writereview?placeid=' + encodeURIComponent(placeId);
+}
+
+/** Resolve the Google target for one item. Pure/$0 unless the gated Places path is on. */
+async function resolveGoogle(item, { placesBudget } = {}) {
+ const norm = item.norm_merchant || store.normalizeMerchant(item.merchant);
+ const cached = store.getPlace(norm);
+ if (cached && cached.place_id) {
+ return { place_id: cached.place_id, mapsUrl: mapsSearchUrl(item.display_merchant || item.merchant),
+ writeUrl: writeReviewUrl(cached.place_id), status: 'resolved', method: 'cache', cost: 0 };
+ }
+ // GATED, off-by-default paid fallback — only if Steve enabled it AND budget remains.
+ if (cfg.PLACES_API_ENABLED && cfg.PLACES_API_KEY && placesBudget && placesBudget.remaining > 0) {
+ try {
+ const url = 'https://maps.googleapis.com/maps/api/place/findplacefromtext/json'
+ + '?input=' + encodeURIComponent(item.display_merchant || item.merchant)
+ + '&inputtype=textquery&fields=place_id,name,formatted_address&key=' + cfg.PLACES_API_KEY;
+ const r = await fetch(url);
+ placesBudget.remaining -= 1; placesBudget.spent += cfg.PLACES_COST_PER_LOOKUP_USD;
+ const j = await r.json();
+ const cand = (j.candidates || [])[0];
+ if (cand && cand.place_id) {
+ store.setPlace(norm, cand.place_id, { name: cand.name, via: 'places-api' });
+ return { place_id: cand.place_id, mapsUrl: mapsSearchUrl(item.display_merchant || item.merchant),
+ writeUrl: writeReviewUrl(cand.place_id), status: 'resolved', method: 'places-api',
+ cost: cfg.PLACES_COST_PER_LOOKUP_USD };
+ }
+ } catch (e) { /* fall through to openclaw-at-post */ }
+ }
+ // $0 default path: keyless maps deep-link, place_id captured in-browser at post time.
+ return { place_id: null, mapsUrl: mapsSearchUrl(item.display_merchant || item.merchant), writeUrl: null,
+ status: 'unresolved', method: 'openclaw-at-post', cost: 0 };
+}
+
+/** Resolve the Amazon target from order-line evidence only. */
+function resolveAmazon(item) {
+ if (item.source !== 'amazon' || !item.asin) {
+ return { productUrl: null, seller: item.seller || null, status: 'no-product',
+ note: 'no ASIN — Amazon review/email not applicable (bank/receipt descriptor cannot identify an Amazon product)' };
+ }
+ return {
+ productUrl: 'https://www.amazon.com/dp/' + encodeURIComponent(item.asin),
+ reviewUrl: 'https://www.amazon.com/review/create-review?asin=' + encodeURIComponent(item.asin),
+ seller: item.seller || null,
+ status: 'resolved',
+ };
+}
+
+/** Cache a place_id captured from the openclaw browser session at post time. */
+function cachePlaceId(item, placeId, extra) {
+ const norm = item.norm_merchant || store.normalizeMerchant(item.merchant);
+ return store.setPlace(norm, placeId, Object.assign({ via: 'openclaw-at-post' }, extra || {}));
+}
+
+async function resolveItem(item, budget) {
+ const google = await resolveGoogle(item, budget);
+ const amazon = resolveAmazon(item);
+ return { google, amazon };
+}
+
+module.exports = { resolveItem, resolveGoogle, resolveAmazon, cachePlaceId, mapsSearchUrl, writeReviewUrl };
diff --git a/lib/spend-reviews/router.js b/lib/spend-reviews/router.js
new file mode 100644
index 00000000..9a4b9bb5
--- /dev/null
+++ b/lib/spend-reviews/router.js
@@ -0,0 +1,168 @@
+'use strict';
+/**
+ * Spend → Reviews — express router. Mounted at /api/spend in server.js AFTER the
+ * Basic-Auth middleware, so every route here is auth-gated. The post/send routes
+ * are additionally per-item gated (see executors.js).
+ */
+const express = require('express');
+const cfg = require('./config');
+const store = require('./store');
+const resolver = require('./resolver');
+const drafts = require('./drafts');
+const executors = require('./executors');
+
+const router = express.Router();
+
+// Adapters are independent files (built as separate units). Load lazily + safely
+// so a missing/half-written adapter can never crash the whole dashboard.
+function loadAdapter(source) {
+ try { return require('./adapters/' + source); }
+ catch (e) { return { ingest: async () => ({ ok: false, items: [], meta: { source, error: 'adapter not available: ' + e.message } }) }; }
+}
+
+function newBudget() { return { remaining: cfg.PLACES_MAX_LOOKUPS_PER_RUN, spent: 0 }; }
+
+async function resolveAndDraft(item, budget) {
+ const resolved = await resolver.resolveItem(item, budget);
+ const withResolved = Object.assign({}, item, { resolved });
+ const d = drafts.generateAll(withResolved);
+ const targets = Object.assign({}, item.targets);
+ for (const t of cfg.TARGETS) {
+ if (d[t] && (targets[t] === 'none' || !targets[t])) targets[t] = 'draft';
+ }
+ return store.updateItem(item.id, { resolved, drafts: d, targets });
+}
+
+function summary() {
+ const items = store.readItems();
+ const s = { total: items.length, bySource: {}, byTarget: {}, resolvedGoogle: 0, unresolvedGoogle: 0,
+ amazonProducts: 0, drafted: 0, approved: 0, posted: 0, lastIngestAt: null };
+ for (const t of cfg.TARGETS) s.byTarget[t] = { draft: 0, approved: 0, posted: 0, skipped: 0 };
+ for (const it of items) {
+ s.bySource[it.source] = (s.bySource[it.source] || 0) + 1;
+ if (it.resolved && it.resolved.google) (it.resolved.google.place_id ? s.resolvedGoogle++ : s.unresolvedGoogle++);
+ if (it.resolved && it.resolved.amazon && it.resolved.amazon.status === 'resolved') s.amazonProducts++;
+ let anyDraft = false;
+ for (const t of cfg.TARGETS) {
+ const st = (it.targets && it.targets[t]) || 'none';
+ if (st !== 'none' && s.byTarget[t][st] !== undefined) s.byTarget[t][st]++;
+ if (st === 'draft' || st === 'approved' || st === 'posted') anyDraft = true;
+ if (st === 'approved') s.approved++;
+ if (st === 'posted') s.posted++;
+ }
+ if (anyDraft) s.drafted++;
+ if (!s.lastIngestAt || it.created_at > s.lastIngestAt) s.lastIngestAt = it.created_at;
+ }
+ return s;
+}
+
+router.get('/items', (req, res) => res.json({ ok: true, items: store.readItems(), summary: summary() }));
+router.get('/summary', (req, res) => res.json(summary()));
+
+// Ingest from a source adapter. amazon/gmail may report needsAction (a one-time
+// Steve login) instead of items — the panel surfaces that.
+router.post('/ingest/:source', async (req, res) => {
+ const source = req.params.source;
+ if (!cfg.SOURCES.includes(source)) return res.status(400).json({ ok: false, error: 'unknown source' });
+ try {
+ const adapter = loadAdapter(source);
+ const out = await adapter.ingest(Object.assign({}, req.body, { source }));
+ const rawItems = (out.items || []).map((i) => store.makeItem(Object.assign({ source }, i)));
+ const up = store.upsertItems(rawItems);
+ res.json({ ok: out.ok !== false, source, meta: out.meta || {}, ingested: up, needsAction: out.meta && out.meta.needsAction || null });
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+// CSV drop: accepts {csv:"<raw text>", filename?} OR reads files already dropped
+// into data/csv-drop/. Steve uploads a statement export; this parses it.
+router.post('/upload-csv', async (req, res) => {
+ try {
+ const adapter = loadAdapter('csv');
+ const out = await adapter.ingest({ source: 'csv', csv: req.body && req.body.csv, filename: req.body && req.body.filename });
+ const rawItems = (out.items || []).map((i) => store.makeItem(Object.assign({ source: 'csv' }, i)));
+ const up = store.upsertItems(rawItems);
+ res.json({ ok: out.ok !== false, meta: out.meta || {}, ingested: up });
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+// Resolve + draft one item, or all unresolved (bounded by the Places budget when on).
+router.post('/resolve/:id', async (req, res) => {
+ const it = store.getItem(req.params.id);
+ if (!it) return res.status(404).json({ ok: false, error: 'not found' });
+ const budget = newBudget();
+ const updated = await resolveAndDraft(it, budget);
+ res.json({ ok: true, item: updated, placesSpentUsd: budget.spent });
+});
+router.post('/resolve-all', async (req, res) => {
+ const budget = newBudget();
+ const items = store.readItems();
+ let done = 0;
+ for (const it of items) {
+ if (it.resolved && it.resolved.google) continue; // already resolved once
+ await resolveAndDraft(it, budget);
+ done++;
+ if (done >= (Number(req.body && req.body.max) || 200)) break;
+ }
+ res.json({ ok: true, resolved: done, placesSpentUsd: budget.spent, placesCostLabel: budget.spent ? `$${budget.spent.toFixed(3)}` : '$0 (local)' });
+});
+
+router.post('/draft/:id', (req, res) => {
+ const it = store.getItem(req.params.id);
+ if (!it) return res.status(404).json({ ok: false, error: 'not found' });
+ const d = drafts.generateAll(it);
+ const targets = Object.assign({}, it.targets);
+ for (const t of cfg.TARGETS) if (d[t] && (targets[t] === 'none' || !targets[t])) targets[t] = 'draft';
+ res.json({ ok: true, item: store.updateItem(it.id, { drafts: d, targets }) });
+});
+
+// Per-item human approval flip (the whole point — nothing posts without this).
+function setTarget(req, res, status) {
+ const { id, target } = req.body || {};
+ if (!cfg.TARGETS.includes(target)) return res.status(400).json({ ok: false, error: 'unknown target' });
+ const it = store.getItem(id);
+ if (!it) return res.status(404).json({ ok: false, error: 'not found' });
+ if (status === 'approved' && (!it.drafts || !it.drafts[target])) return res.status(400).json({ ok: false, error: 'no draft to approve for this target' });
+ const targets = Object.assign({}, it.targets, { [target]: status });
+ store.logAction({ action: 'set-status', id, target, status });
+ res.json({ ok: true, item: store.updateItem(id, { targets }) });
+}
+router.post('/approve', (req, res) => setTarget(req, res, 'approved'));
+router.post('/unapprove', (req, res) => setTarget(req, res, 'draft'));
+router.post('/skip', (req, res) => setTarget(req, res, 'skipped'));
+
+// Optional: set a resolved seller/brand email address on an item (for seller_email).
+router.post('/seller-email-to', (req, res) => {
+ const { id, to } = req.body || {};
+ const it = store.getItem(id);
+ if (!it) return res.status(404).json({ ok: false, error: 'not found' });
+ res.json({ ok: true, item: store.updateItem(id, { seller_email_to: (to || '').toString().slice(0, 200) }) });
+});
+
+// THE GATED EXECUTE. Per item + per target. Returns the plan (gated) unless every
+// gate passes; on a real post it captures a place_id / marks the target posted.
+router.post('/execute', async (req, res) => {
+ const { id, target, confirm, live } = req.body || {};
+ const it = store.getItem(id);
+ if (!it) return res.status(404).json({ ok: false, error: 'not found' });
+ const result = await executors.executeTarget(it, target, { confirm, live: live === true });
+ if (result.ok && result.status === 'posted') {
+ const targets = Object.assign({}, it.targets, { [target]: 'posted' });
+ store.updateItem(id, { targets });
+ }
+ res.json({ ok: !!result.ok, gated: !!result.gated, result });
+});
+
+// Draft a batch memo of every approved-but-unposted item to pending-approval. Fires nothing.
+router.post('/draft-memo', (req, res) => {
+ const items = store.readItems();
+ const approved = [];
+ for (const it of items) {
+ const ats = cfg.TARGETS.filter((t) => it.targets && it.targets[t] === 'approved');
+ if (ats.length) approved.push(Object.assign({}, it, { approvedTargets: ats }));
+ }
+ if (!approved.length) return res.json({ ok: true, note: 'no approved items to memo' });
+ const file = executors.draftPendingApprovalMemo(approved, req.body && req.body.note);
+ res.json({ ok: true, memo: file, count: approved.length });
+});
+
+module.exports = { router, summary };
diff --git a/lib/spend-reviews/store.js b/lib/spend-reviews/store.js
new file mode 100644
index 00000000..e2dbe3d9
--- /dev/null
+++ b/lib/spend-reviews/store.js
@@ -0,0 +1,163 @@
+'use strict';
+/**
+ * Spend → Reviews — storage + the common `spend_item` record contract.
+ *
+ * THE CONTRACT every ingestion adapter normalizes to (makeItem):
+ * {
+ * id, // stable hash of dedupeKey
+ * source, // 'amazon' | 'gmail' | 'csv'
+ * merchant, // best display name of the business/brand
+ * product, // product/line-item name, or null
+ * order_id, // vendor order id, or null
+ * asin, // Amazon ASIN, or null
+ * seller, // Amazon seller/brand string, or null
+ * date, // ISO date of the purchase (best-effort)
+ * amount, // number (spend), 0 if unknown
+ * currency, // 'USD' default
+ * url, // source/product url, or null
+ * raw, // original row/string for audit
+ * norm_merchant, // normalized merchant key (dedupe + cache join)
+ * dedupeKey,
+ * created_at, // ISO ingestion time (drives the 🕓 card chip)
+ * resolved: { google:{place_id,mapsUrl,status,method}, amazon:{productUrl,seller,status} },
+ * drafts: { google_review, amazon_review, seller_email }, // {text|subject/body, cost, generatedAt}
+ * targets: { google_review, amazon_review, seller_email }, // per-target lifecycle
+ * }
+ *
+ * Per-target lifecycle status: 'none' -> 'draft' -> 'approved' -> 'posted' | 'skipped' | 'error'.
+ * Nothing posts/sends without the target reaching 'approved' (a human click) AND a
+ * confirm token at execute time — see executors.js.
+ */
+const fs = require('fs');
+const crypto = require('crypto');
+const cfg = require('./config');
+
+function ensureDir(p) { try { fs.mkdirSync(p, { recursive: true }); } catch (e) {} }
+ensureDir(cfg.DATA_DIR);
+ensureDir(cfg.CSV_DROP_DIR);
+
+function readJsonl(file) {
+ try {
+ return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => {
+ try { return JSON.parse(l); } catch (e) { return null; }
+ }).filter(Boolean);
+ } catch (e) { return []; }
+}
+
+/** Normalize a messy merchant/descriptor string into a stable join/dedupe key. */
+function normalizeMerchant(s) {
+ if (!s) return '';
+ let x = String(s).toLowerCase();
+ // strip common processor/POS prefixes and noise
+ x = x.replace(/\b(sq|tst|pos|paypal|pp|ppd|amzn mktp us|amzn|amazon mktpl|amazon\.com|ach|dbt|crd|pmt|purchase|debit|pos debit|visa|mastercard)\b\*?/g, ' ');
+ x = x.replace(/[*#]+/g, ' ');
+ x = x.replace(/\bstore\s*#?\d+\b/g, ' '); // store numbers
+ x = x.replace(/\b\d{2}\/\d{2}(\/\d{2,4})?\b/g, ' '); // dates
+ x = x.replace(/\b\d{3}[-. ]?\d{3}[-. ]?\d{4}\b/g, ' '); // phone (BEFORE card-tail so 6464 isn't eaten)
+ x = x.replace(/\bx?\d{4,}\b/g, ' '); // card tails / long digit runs
+ x = x.replace(/\.(com|net|org|co)\b/g, ' '); // tld
+ x = x.replace(/\bwww\b/g, ' ');
+ x = x.replace(/\b[a-z]{2}\b\s*$/g, ' '); // trailing state
+ x = x.replace(/[^a-z0-9&' ]+/g, ' ');
+ x = x.replace(/\s+/g, ' ').trim();
+ return x;
+}
+
+/** Human-friendly display name from the normalized key (title-case). */
+function displayMerchant(merchant, norm) {
+ const base = (norm && norm.length >= 3) ? norm : String(merchant || '').toLowerCase();
+ const t = base.replace(/\b[a-z]/g, (c) => c.toUpperCase()).trim();
+ return t || String(merchant || '').trim();
+}
+
+function makeItem(input) {
+ const source = input.source;
+ const merchant = (input.merchant || '').toString().trim();
+ const norm = normalizeMerchant(merchant);
+ const date = input.date || null;
+ const amount = Number(input.amount) || 0;
+ // dedupe: prefer order_id; else normalized merchant + date + amount
+ const dedupeKey = input.order_id
+ ? `${source}:${input.order_id}${input.asin ? ':' + input.asin : ''}`
+ : `${source}:${norm}|${date || ''}|${amount}`;
+ const id = crypto.createHash('sha1').update(dedupeKey).digest('hex').slice(0, 12);
+ return {
+ id,
+ source,
+ merchant,
+ display_merchant: displayMerchant(merchant, norm),
+ product: input.product || null,
+ order_id: input.order_id || null,
+ asin: input.asin || null,
+ seller: input.seller || null,
+ date,
+ amount,
+ currency: input.currency || 'USD',
+ url: input.url || null,
+ raw: input.raw != null ? input.raw : null,
+ norm_merchant: norm,
+ dedupeKey,
+ created_at: new Date().toISOString(),
+ resolved: { google: null, amazon: null },
+ drafts: { google_review: null, amazon_review: null, seller_email: null },
+ targets: { google_review: 'none', amazon_review: 'none', seller_email: 'none' },
+ };
+}
+
+function readItems() { return readJsonl(cfg.ITEMS_FILE); }
+function getItem(id) { return readItems().find((i) => i.id === id) || null; }
+
+function writeItems(items) {
+ fs.writeFileSync(cfg.ITEMS_FILE, items.map((i) => JSON.stringify(i)).join('\n') + (items.length ? '\n' : ''));
+}
+
+/** Insert only genuinely-new items (by dedupeKey). Returns {added, skipped, total}. */
+function upsertItems(newItems) {
+ const existing = readItems();
+ const seen = new Set(existing.map((i) => i.dedupeKey));
+ let added = 0;
+ for (const it of newItems) {
+ if (!it || !it.dedupeKey) continue;
+ if (seen.has(it.dedupeKey)) continue;
+ existing.push(it); seen.add(it.dedupeKey); added++;
+ }
+ writeItems(existing);
+ return { added, skipped: newItems.length - added, total: existing.length };
+}
+
+/** Patch one item in place (shallow-merge top-level; caller passes nested objects whole). */
+function updateItem(id, patch) {
+ const items = readItems();
+ const idx = items.findIndex((i) => i.id === id);
+ if (idx < 0) return null;
+ items[idx] = Object.assign({}, items[idx], patch);
+ writeItems(items);
+ return items[idx];
+}
+
+// ---- merchant → place_id cache (DTD Option B) --------------------------------
+function getPlace(norm) {
+ if (!norm) return null;
+ const rows = readJsonl(cfg.PLACE_CACHE);
+ // last write wins
+ let hit = null;
+ for (const r of rows) if (r && r.norm === norm) hit = r;
+ return hit;
+}
+function setPlace(norm, place_id, extra) {
+ const row = Object.assign({ norm, place_id: place_id || null, at: new Date().toISOString() }, extra || {});
+ fs.appendFileSync(cfg.PLACE_CACHE, JSON.stringify(row) + '\n');
+ return row;
+}
+
+// ---- audit log of every approve / execute action ----------------------------
+function logAction(row) {
+ const r = Object.assign({ at: new Date().toISOString() }, row);
+ try { fs.appendFileSync(cfg.ACTIONS_LOG, JSON.stringify(r) + '\n'); } catch (e) {}
+ return r;
+}
+
+module.exports = {
+ normalizeMerchant, displayMerchant, makeItem, readItems, getItem, upsertItems, updateItem,
+ getPlace, setPlace, logAction, readJsonl,
+};
diff --git a/public/index.html b/public/index.html
index c33334f9..68226b3c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -408,6 +408,21 @@
<div id="crongrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;max-height:560px;overflow:auto"></div>
</section>
+ <!-- SPEND → REVIEWS (panel 9, TK-11433) — auto-review businesses Steve buys from -->
+ <section class="card span12" id="spend-card">
+ <h2>🧾 Spend → Reviews <span class="muted" id="spend-sub">· thank the businesses you buy from · <b style="color:var(--gold)">every post/send is per-item gated</b></span></h2>
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
+ <button type="button" data-ingest="amazon">⬇ Ingest Amazon</button>
+ <button type="button" data-ingest="gmail">✉ Scan Gmail receipts</button>
+ <label class="btnlike" style="cursor:pointer;padding:6px 10px;border:1px solid var(--line,#2a2f3a);border-radius:6px;font-size:12px">⬆ Upload statement CSV<input type="file" id="spend-csv" accept=".csv,text/csv" style="display:none"></label>
+ <button type="button" id="spend-resolve">↻ Resolve + Draft all</button>
+ <button type="button" id="spend-memo">📝 Draft approval memo</button>
+ <span class="muted" id="spend-note" style="font-size:11px"></span>
+ </div>
+ <div id="spend-ctl"></div>
+ <div id="spendgrid" style="max-height:640px;overflow:auto"></div>
+ </section>
+
<!-- AI CHAT -->
<section class="card span12 chat">
<h2>🤖 Ask AbramsEgo <span class="muted">· grounded in the live snapshot · cost shown per query</span></h2>
@@ -1171,7 +1186,92 @@ async function loadWpb(){
}
if(!w.launched){ const sub=document.getElementById('wpb-sub'); if(sub && !sub.dataset.pre){ sub.dataset.pre='1'; sub.innerHTML += ' · <span style="color:var(--muted,#9aa0ad)">🌱 pre-launch ($0)</span>'; } }
}
-async function tick(){ if(document.hidden) return; await load(); loadWpb(); if(!_booted){ _booted=true; playEntrance(); } }
+// ---- SPEND → REVIEWS panel (TK-11433) ---------------------------------------
+const SPEND_TARGETS=[['google_review','Google review'],['amazon_review','Amazon review'],['seller_email','Seller thank-you email']];
+function spendStatusPill(st){
+ const map={draft:['DRAFT','warn'],approved:['APPROVED','good'],posted:['POSTED ✅','good'],skipped:['skipped','muted'],none:['—','muted']};
+ const [t,c]=map[st]||map.none; return `<span class="pill ${c}">${t}</span>`;
+}
+function spendDraftBody(t,d){
+ if(!d) return '';
+ if(t==='seller_email') return `<div class="dim" style="font-size:11px"><b>Subj:</b> ${esc(d.subject||'')}</div><div style="white-space:pre-wrap;font-size:12px;margin-top:2px">${esc(d.body||'')}</div>`;
+ return `${d.title?`<div class="dim" style="font-size:11px"><b>${esc(d.title)}</b> · ★${d.rating||5}</div>`:''}<div style="font-size:12px;margin-top:2px">${esc(d.text||'')}</div>`;
+}
+function spendTargetBlock(it,t,label){
+ const d=it.drafts&&it.drafts[t]; if(!d) return '';
+ const st=(it.targets&&it.targets[t])||'none';
+ const g=it.resolved&&it.resolved.google, chan=t==='seller_email'?'George email':'openclaw (real Chrome)';
+ let ctl='';
+ if(st==='draft') ctl=`<button type="button" onclick="spendSet('${it.id}','${t}','approve')">✔ Approve</button> <button type="button" class="ghost" onclick="spendSet('${it.id}','${t}','skip')">skip</button>`;
+ else if(st==='approved') ctl=`<button type="button" onclick="spendExecute('${it.id}','${t}')">${t==='seller_email'?'✉ Send':'📤 Post'} (gated)</button> <button type="button" class="ghost" onclick="spendSet('${it.id}','${t}','unapprove')">unapprove</button>`;
+ else if(st==='posted') ctl=`<span class="good">done</span>`;
+ return `<div class="spend-target" style="border-top:1px solid var(--line,#242a35);padding-top:6px;margin-top:6px">
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:6px"><b style="font-size:12px">${label}</b> ${spendStatusPill(st)}</div>
+ ${spendDraftBody(t,d)}
+ <div class="dim" style="font-size:10px;margin-top:3px">via ${chan} · ${d.costLabel||'$0 (local)'}${t!=='seller_email'&&g?` · ${g.status==='resolved'?'place cached':'openclaw resolves + confirms at post'}`:''}</div>
+ <div style="margin-top:4px;display:flex;gap:6px">${ctl}</div>
+ </div>`;
+}
+function spendCard(it){
+ const src={amazon:'🅰 Amazon',gmail:'✉ Gmail',csv:'💳 CSV'}[it.source]||it.source;
+ const g=it.resolved&&it.resolved.google, a=it.resolved&&it.resolved.amazon;
+ const gLine=g?`<a href="${esc(g.mapsUrl)}" target="_blank" rel="noopener noreferrer">Google: ${g.status==='resolved'?'resolved':'find business'}</a>`:'<span class="muted">not resolved</span>';
+ const aLine=a&&a.status==='resolved'?` · <a href="${esc(a.productUrl)}" target="_blank" rel="noopener noreferrer">Amazon product</a>`:'';
+ const blocks=SPEND_TARGETS.map(([t,l])=>spendTargetBlock(it,t,l)).join('');
+ return `<div class="card2" style="border:1px solid var(--line,#242a35);border-radius:8px;padding:10px">
+ <div style="display:flex;justify-content:space-between;gap:8px"><b>${esc(it.display_merchant||it.merchant||'?')}</b><span class="dim" style="font-size:11px">${src}</span></div>
+ ${it.product?`<div class="dim" style="font-size:11px">${esc(it.product)}</div>`:''}
+ <div style="font-size:11px;margin-top:2px">${it.amount?usd(it.amount):''} · ${gLine}${aLine}</div>
+ <div style="margin-top:3px">${whenChip(it.created_at)}</div>
+ ${blocks||'<div class="muted" style="font-size:11px;margin-top:6px">no drafts yet — click Resolve + Draft</div>'}
+ </div>`;
+}
+const SPEND_SORTS=[
+ {k:'needs',label:'Needs approval',cmp:(a,b)=>spendPend(b)-spendPend(a)||tsOf(b.created_at)-tsOf(a.created_at)},
+ {k:'new',label:'Newest',cmp:(a,b)=>tsOf(b.created_at)-tsOf(a.created_at)},
+ {k:'amt',label:'Amount ↓',cmp:(a,b)=>(b.amount||0)-(a.amount||0)},
+ {k:'merch',label:'Merchant A→Z',cmp:(a,b)=>String(a.display_merchant||a.merchant).localeCompare(String(b.display_merchant||b.merchant))},
+ {k:'src',label:'Source',cmp:(a,b)=>String(a.source).localeCompare(String(b.source))},
+];
+function spendPend(it){return SPEND_TARGETS.reduce((n,[t])=>n+((it.targets&&it.targets[t]==='draft')?1:0),0);}
+let _spendGridReady=false;
+async function loadSpendReviews(){
+ if(!_spendGridReady && $('spendgrid')){
+ initGrid('spend',{controlsHost:$('spend-ctl'),host:$('spendgrid'),sortModes:SPEND_SORTS,defaultCols:3,minCols:1,maxCols:5,batch:24,render:spendCard,empty:'No spend items yet — ingest Amazon, scan Gmail receipts, or upload a statement CSV.'});
+ _spendGridReady=true;
+ }
+ let j; try{ j=await (await fetch('/api/spend/items')).json(); }catch(e){ return; }
+ const items=(j&&j.items)||[]; feedGrid('spend',items);
+ const s=(j&&j.summary)||{};
+ const note=$('spend-note'); if(note) note.textContent=`${s.total||0} items · ${s.drafted||0} drafted · ${s.approved||0} approved · ${s.posted||0} posted · Google resolved ${s.resolvedGoogle||0}/${(s.resolvedGoogle||0)+(s.unresolvedGoogle||0)}`;
+}
+async function spendPost(path,body){ const r=await fetch('/api/spend/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})}); return r.json(); }
+async function spendSet(id,target,which){ const map={approve:'approve',unapprove:'unapprove',skip:'skip'}; const j=await spendPost(map[which],{id,target}); if(j&&j.error) alert(j.error); await loadSpendReviews(); }
+async function spendExecute(id,target){
+ const j=await spendPost('execute',{id,target,live:true,confirm:id});
+ const r=j&&j.result||{};
+ if(j&&j.gated){ alert('GATED — nothing sent.\n\n'+(r.reason||'')+'\n\nThe executor is built one-click-from-firing but stays off until Steve sets SPEND_REVIEW_EXECUTORS_LIVE=1 in AbramsEgo .env. Use "Draft approval memo" to queue it for Steve.'); }
+ else if(r.status==='posted'){ alert('Sent ✓'); }
+ else if(r.status==='needs_verify'){ alert('Opened in Chrome — verify submission before it counts as posted.\n'+(r.reason||'')); }
+ else if(r.reason){ alert(r.reason); }
+ await loadSpendReviews();
+}
+async function spendIngest(source){
+ const note=$('spend-note'); if(note) note.textContent='ingesting '+source+'…';
+ const j=await spendPost('ingest/'+source,{});
+ if(j&&j.needsAction){ alert('Action needed for '+source+':\n\n'+(j.meta&&j.meta.note||j.needsAction)+(j.meta&&j.meta.loginCmd?'\n\n'+j.meta.loginCmd:'')); }
+ else if(j&&j.ingested){ /* ok */ }
+ else if(j&&j.meta&&j.meta.note){ if(note) note.textContent=j.meta.note; }
+ await loadSpendReviews();
+}
+document.addEventListener('click',(e)=>{ const b=e.target.closest('[data-ingest]'); if(b) spendIngest(b.getAttribute('data-ingest')); });
+(function wireSpend(){
+ const rb=$('spend-resolve'); if(rb) rb.addEventListener('click',async()=>{ const n=$('spend-note'); if(n)n.textContent='resolving + drafting…'; const j=await spendPost('resolve-all',{}); if(n)n.textContent=`resolved ${j.resolved||0} · ${j.placesCostLabel||'$0 (local)'}`; await loadSpendReviews(); });
+ const mb=$('spend-memo'); if(mb) mb.addEventListener('click',async()=>{ const j=await spendPost('draft-memo',{}); alert(j&&j.memo?('Approval memo drafted for Steve:\n'+j.memo+'\n('+j.count+' approved item(s))'):(j&&j.note||'nothing to memo')); });
+ const cf=$('spend-csv'); if(cf) cf.addEventListener('change',async()=>{ const f=cf.files&&cf.files[0]; if(!f) return; const csv=await f.text(); const n=$('spend-note'); if(n)n.textContent='parsing '+f.name+'…'; const j=await spendPost('upload-csv',{csv,filename:f.name}); if(n)n.textContent=(j.meta&&j.meta.note)||'uploaded'; await loadSpendReviews(); cf.value=''; });
+})();
+
+async function tick(){ if(document.hidden) return; await load(); loadWpb(); loadSpendReviews(); if(!_booted){ _booted=true; playEntrance(); } }
tick(); setInterval(tick, 15000);
</script>
<script>window.UNBLOCK_CONFIG={surface:'abramsego',endpoint:'/api/dispatch',statusEndpoint:'/api/yolo/status'};</script>
diff --git a/server.js b/server.js
index 6547d257..bda668a5 100644
--- a/server.js
+++ b/server.js
@@ -987,6 +987,8 @@ async function doBuild() {
wins: summarizeWins(wins),
officers: summarizeOfficers(officers),
cost, budgets, revenue, pnl, usage,
+ // Spend → Reviews panel summary (counts only; full items via /api/spend/items)
+ spendReviews: (() => { try { return require('./lib/spend-reviews/router').summary(); } catch (e) { return { error: e.message }; } })(),
// compact form folded into the snapshot for at-a-glance headers
usageSummary: {
activeSessions: usage.sessions && usage.sessions.active,
@@ -1179,6 +1181,16 @@ app.post('/api/dispatch', async (req, res) => {
} catch (e) { res.status(502).json({ error: 'CNCP unreachable: ' + e.message }); }
});
+// ── Spend → Reviews (TK-11433) ───────────────────────────────────────────────
+// Mounted AFTER the Basic-Auth middleware, so every /api/spend/* route is gated.
+// Ingestion + resolver + drafts + queue are reversible LOCAL code; the post/send
+// executors are per-item human-gated inside the module (executors.js). Loaded
+// defensively so a module error can never take down the dashboard.
+try {
+ const spendReviews = require('./lib/spend-reviews/router');
+ app.use('/api/spend', spendReviews.router);
+} catch (e) { console.error('[spend-reviews] router mount failed:', e.message); }
+
const REVENUE_ENGINES = ['sell_product', 'affiliate', 'billable_work', 'ads', 'licensing'];
app.post('/api/revenue/record', async (req, res) => {
const b = req.body || {};
← 6e882c0a auto-data-snapshot: 2026-09-10T13:39:50 (1 data files) — dat
·
back to AbramsEgo
·
Spend → Reviews: bind approval to content (anti-drift seal) ddc28d6a →