Skip to content

aggregator(charges-match): service and SaaS-marker matching is substring-only — METAMUCIL and METAL ROOFING classify as Meta charges #3048

Closed

Description

@dezren39

Summary

matchService and isSaasLooking in pm-stack/aggregator/src/charges-match.ts match on squashed substrings with no token boundary, so ordinary non-SaaS merchants whose names merely contain a marker or service name are classified as SaaS/AI charges — and, when a hub service carries that name, are relabelled as that service in the charges feed.

  • isSaasLooking (charges-match.ts:158-160): SAAS_MARKERS.some((marker) => flat.includes(marker)) over the space-stripped merchant, with markers as short as 3–4 chars (XAI, META, GROK, CLINE).
  • matchService (charges-match.ts:164-172): a service name/alias whose squashed form is ≥4 chars matches anywhere inside the squashed merchant (flat.includes(flatNorm)), so the service Meta matches any merchant containing the letters META.

Evidence (verified this run, main 0f84fe08; laptop probe ~/git/fleet/probe-2026-10-31-laneB-charges/probe.ts importing the shipped file — charges-match.ts md5 bec11791e5b91a32acf78529d4960087 identical in the sandbox and laptop clones)

  • normalizeMerchantName("METAMUCIL") → "METAMUCIL"; isSaasLooking → true; matchService(..., [{name:"Meta"}, ...]) → Meta. A full matchCharges run on a single Plaid METAMUCIL $12.50 charge produces one row labelled "Meta" — a pharmacy product reported as a Meta AI charge.
  • normalizeMerchantName("METAL ROOFING LLC") → "METAL ROOFING"; isSaasLooking → true; matchService → Meta. A roofing contractor is likewise classified as a Meta charge.
  • Control (existing behaviour that must keep working): OPENAI *CHATGPT SUB matches the OpenAI service via its ChatGPT alias, and CURSOR.COM normalizes to CURSOR (covered by test/charges-match.test.ts).

Why it matters

The charges strip is financial evidence (see #1057/#2130): a mislabelled row attributes one vendor's charge to a hub service, and a false SaaS-looking flag pushes household merchants (pharmacy, trades) into the unmatched-SaaS attention strip. The failure is silent — the row looks authoritative, carries the service's name, and rerun determinism makes the wrong answer stable.

Distinct from #2130 (same module): that issue is the dedupe key collapsing distinct charges; this one is the classification match itself. #2130's acceptance criteria do not touch matching boundaries.

Acceptance criteria

  • Service names/aliases and SaaS markers match only at token boundaries (a whole normalized token, or a whole multi-token sequence), never as a substring inside a longer token: METAMUCIL and METAL ROOFING match neither the Meta service nor the META SaaS marker.
  • Existing matches keep working and stay tested: CHATGPT→OpenAI alias, CURSOR.COM→Cursor, multi-word service names, and the token-set path in matchService.
  • New tests in pm-stack/aggregator/test/charges-match.test.ts cover the boundary cases above (prefix-containing merchants for at least META and one more short marker), and all existing charges-match tests pass.

Related: #1057 (feature), #2130 (dedupe defect in the same module), #1047 (epic).

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/triageQueue packet kind: verify, reproduce, and route this item
    source/creatorFiled by the new-issue-creator job
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    effort/xsTrivial: expected under 30 minutes of work
    app/aggregatorPackage/app: aggregator (pm-stack in this repo)
    on Oct 31, 2026
  2. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

  3. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Design-architect pass 27 (2026-10-31) — agent note: this issue is adopted as part of Phase 2 (Admission & classification) of design #3161 ("a charge is evidence"). Triage: do NOT re-file, re-scope or split it — record the phase structure instead. The design records the rules this phase lands under: admission requires payment evidence (declined/failed and merely-due amounts produce no candidate; the amount is the amount paid — explicit paid/charged labels outrank a bare total, and a bare total must be a standalone label, never a substring of "Subtotal"); classification matches names/aliases/markers at token boundaries only, against the hub's whole catalog (SaaS services plus quota pools and their aliases, from their existing extractors). Phase 2 siblings: #3120, #3121, #3048, #3119. Phase 2 is independent of Phase 1. Details on #3161 and in files/designs/code-charges-pipeline-contract.md (goal workspace).

  4. added
    work/assignedQueue worker lifecycle state
    and removed on Nov 1, 2026
  5. 51 remaining items

  6. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — holder: repo-worker-code (OpenChamber scheduled lane), at 2026-11-02T14:53:00Z. event: picked-up; task: 5620; queue: repo-health-code-pilot. Fresh metadata confirms #3048 OPEN and canonical PR #4306 OPEN, non-draft, authored/assigned to Drew. No PR contents/checks/reviews/branch/source inspected.

  7. added
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 2, 2026
  8. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — holder: repo-worker-code (OpenChamber scheduled lane), at 2026-11-02T14:54:00Z. event: parked; task: 5620; queue: repo-health-code-pilot. Canonical PR #4306 remains OPEN, non-draft, Drew-authored/assigned. Hard stop: this lane did not inspect or touch PR contents, checks, reviews, branch or source and did not create a competing PR. Resume after ownership release or PR disposition, then revalidate fresh.

  9. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 2, 2026
  10. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

  11. removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    work/assignedQueue worker lifecycle state
    on Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/aggregatorPackage/app: aggregator (pm-stack in this repo)app/pm-stackpm-stack (Pad/Grist) in developing-today/codeeffort/xsTrivial: expected under 30 minutes of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions