Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pm-stack/aggregator/scripts/produce-charge-candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@
// label (card brand + mask). No balances, no debt, no account
// numbers, no account-owner names ever enter the file.
//
// Admission (#3161, the charges contract's first rule): a
// candidate exists only where money demonstrably moved.
// Declined/failed alerts and amounts merely due are excluded by
// isNotChargeSubject below, and a candidate's amount is the
// amount PAID: explicit paid/charged labels outrank a bare total,
// and a bare total counts only as a standalone label, never as a
// substring of "Subtotal".
//
// Usage: bun run scripts/produce-charge-candidates.ts
// [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--out <path>] [--skip-gmail]
// Defaults: start = first of the current month, end = today,
Expand Down
18 changes: 18 additions & 0 deletions pm-stack/aggregator/src/charges-collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
// Everything here is pure except the state the collector itself
// passes in/out; file IO lives in collect-fleet-feed.ts, following
// the same split as charges-match.ts (decisions here, state there).
//
// The charges contract (#3161) as it applies here: identity is the
// shared rule stated in charges-match.ts — sameCharge below is its
// hand-fed-vs-auto form — and delivery means surfaced keys are
// persisted only for charges in a feed that was successfully
// published, so an unmatched charge counts as shown only once it
// was actually delivered.

import {
DEDUPE_WINDOW_DAYS,
Expand Down Expand Up @@ -119,6 +126,17 @@ function containsTokenSequence(tokens: string[], sequence: string[]): boolean {
return false;
}

/**
* The shared identity rule (#3161), hand-fed-vs-auto form: the two
* rows are the same charge only when amounts are equal, dates fall
* inside the dedupe window, and the merchant identity holds at
* token boundaries — token-sequence containment in either
* direction over the alias-expanded label token sequences (which
* preserves the documented METAPAY / "Meta PAYG spill" merge while
* excluding substring lookalikes). An empty normalized label never
* matches, and conflicting known card masks veto the merge;
* agreement or absence of a payment method does not block it.
*/
function sameCharge(handFed: ChargeRow, auto: ChargeRow): boolean {
if (handFed.amountUsd !== auto.amountUsd) return false;
if (daysBetween(handFed.at, auto.at) > DEDUPE_WINDOW_DAYS) return false;
Expand Down
25 changes: 25 additions & 0 deletions pm-stack/aggregator/src/charges-match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,31 @@
//
// No balances or debt data pass through here: candidates are charge
// evidence only (date, merchant, amount, payment-method label).
//
// The charges contract (#3161 — a charge is evidence), stated once:
// (1) Admission requires payment evidence: a candidate exists only
// where money demonstrably moved — declined/failed alerts and
// amounts merely due are not charges, and the candidate's amount is
// the amount paid (the producer's rule, produce-charge-candidates).
// (2) Identity is one rule, applied in two forms. Cross-source
// dedupe (matchCharges below) pairs observations from different
// sources only, with equal amounts, dates inside the 3-day window
// (DEDUPE_WINDOW_DAYS), and equal normalized merchants; when
// normalization removes every token, the raw descriptors must
// also agree exactly, so unrelated numeric merchant IDs cannot
// collapse into one charge. The hand-fed merge (charges-collect's
// sameCharge) compares merchant identity at token boundaries
// instead, and adds the payment-method veto: conflicting known
// card masks never merge there (agreement or absence does not
// block it). Classification follows the boundary rule: service
// names, aliases and SaaS markers match at token boundaries
// only, against the hub's whole known catalog — SaaS services
// plus the quota pools and their aliases.
// (3) Delivery: surfaced means delivered — surfaced keys are
// persisted only for charges in a feed that was successfully
// published, and consumers read the charges sections on their own
// validity, never gated on an unrelated section (the collector's
// and the live source's side of the contract).

import type { ChargeRow } from "./model.ts";

Expand Down