← 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 };