← back to AbramsEgo
lib/spend-reviews/resolver.js
98 lines
'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 };
}
/**
* True if this merchant should be auto-skipped (Steve's own businesses or a pure
* infra/SaaS vendor — see config.EXCLUDE_MERCHANTS). Matches the normalized AND
* display merchant as case-insensitive substrings so both "Designerwallcoverings"
* and a raw "DESIGNER WALLCOVERINGS LLC" descriptor are caught.
*/
function isExcluded(item) {
const norm = (item.norm_merchant || store.normalizeMerchant(item.merchant) || '').toLowerCase();
const disp = (item.display_merchant || item.merchant || '').toLowerCase();
return (cfg.EXCLUDE_MERCHANTS || []).some((x) => x && (norm.includes(x) || disp.includes(x)));
}
module.exports = { resolveItem, resolveGoogle, resolveAmazon, cachePlaceId, mapsSearchUrl, writeReviewUrl, isExcluded };