← back to Nationalrealestate

src/ingest/parcels/price_guard.ts

96 lines

// Shared sale-price sanity guard for every parcel-deed ingester.
//
// Why this exists: county sale feeds occasionally carry a corrupt sale amount —
// most commonly a NEGATIVE price. King County WA's EXTR_RPSale.csv, for example,
// contains rows whose `saleprice` is a small negative value (e.g. -400, -300,
// -200) — a source-side data-entry / adjustment-code artifact, NOT a real sale.
// A recorded sale price can never be negative. Before this util, king_wa.ts
// parsed price with a bare `num()` that only rejected 0 (`n !== 0`), so those
// negatives passed straight into parcel.last_sale_price (72 such rows were found
// in the local mirror, all King County). arcgis_sales.anyPrice already required
// `> 0` and nyc_acris required `>= MIN_AMT`, so this centralizes ONE tested rule
// so every price-emit point rejects the same defect.
//
// IMPORTANT — what this does NOT do: it does not treat low-but-positive prices
// as bad. Nominal-consideration deeds ($1 / $10 quitclaims, gift/family
// transfers) are legitimate and abundant in county records (10k+ rows under $100
// in the mirror), and there is no defensible universal upper bound either — real
// Manhattan tower sales legitimately exceed $1B (245 Park Ave @ $1.77B). So the
// only value this guard rejects is a negative (or non-finite) price; a 0 is
// dropped to null to match the existing `num()`/`anyPrice()` "0 carries no
// signal" semantics.
//
// Contract: input is a raw string/number/nullish sale amount. Output is a finite
// POSITIVE number, or null. It never throws and never mutates its input.

/**
 * Parse and sanity-check a raw sale amount.
 *
 * Returns the amount as a finite number > 0, or null when the input is missing,
 * non-numeric, zero, or negative. Strips currency formatting ($, commas) so
 * "$1,250,000.00" parses; a bare number passes through unchanged.
 *
 * @param raw  a sale price as string, number, null, or undefined
 */
export function sanitizeSalePrice(raw: string | number | null | undefined): number | null {
  if (raw == null) return null;
  // Strip currency formatting from strings ("$1,250,000.00", " 000001060000. ");
  // keep digits, one decimal point, and a leading minus so negatives are still
  // detectable (and then rejected below).
  const cleaned = typeof raw === 'number' ? raw : Number(String(raw).replace(/[^\d.-]/g, ''));
  const n = Number(cleaned);
  if (!Number.isFinite(n)) return null;
  if (n <= 0) return null; // reject negative AND zero (0 carries no sale signal)
  return n;
}

// --- self-test (run: `tsx src/ingest/parcels/price_guard.ts`) ----------------
// No test framework is installed in this repo, so the util ships its own
// deterministic self-test that exits non-zero on any failure. Runs only when
// executed directly, never on import.
const isMain = (() => {
  try {
    return typeof process !== 'undefined'
      && process.argv[1] != null
      && import.meta.url === new URL(`file://${process.argv[1]}`).href;
  } catch { return false; }
})();

if (isMain) {
  const cases: Array<[string | number | null | undefined, number | null, string]> = [
    // [input, expected, label]
    [250000, 250000, 'ordinary positive number'],
    ['250000', 250000, 'positive numeric string'],
    ['$1,250,000.00', 1250000, 'currency-formatted string'],
    [' 000001060000. ', 1060000, 'zero-padded arcgis-style string w/ trailing dot'],
    [1, 1, '$1 nominal consideration kept (real deed class)'],
    [10, 10, '$10 quitclaim kept'],
    [1769900601.25, 1769900601.25, 'legit >$1B tower sale kept (no upper cap)'],
    [-400, null, 'negative rejected (the King County defect)'],
    ['-400', null, 'negative string rejected'],
    [-1, null, 'small negative rejected'],
    [0, null, '0 dropped to null'],
    ['0', null, '"0" dropped to null'],
    ['', null, 'empty string -> null'],
    ['abc', null, 'non-numeric -> null'],
    [null, null, 'null -> null'],
    [undefined, null, 'undefined -> null'],
    [NaN, null, 'NaN -> null'],
    [Infinity, null, 'Infinity -> null'],
  ];
  let failed = 0;
  for (const [input, expected, label] of cases) {
    const got = sanitizeSalePrice(input);
    const ok = got === expected;
    if (!ok) {
      failed++;
      console.error(`FAIL: ${label}\n  input=${JSON.stringify(input)} expected=${JSON.stringify(expected)} got=${JSON.stringify(got)}`);
    }
  }
  if (failed) {
    console.error(`\n${failed}/${cases.length} sanitizeSalePrice cases FAILED`);
    process.exit(1);
  }
  console.log(`sanitizeSalePrice: all ${cases.length} cases passed`);
}