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
55 changes: 31 additions & 24 deletions pm-stack/aggregator/src/charges-collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,6 @@ export function parseChargeCandidates(raw: unknown): ChargeCandidate[] {
return out;
}

function squash(normalized: string): string {
return normalized.replace(/\s+/g, "");
}

function daysBetween(a: string, b: string): number {
const ms = Math.abs(Date.parse(a) - Date.parse(b));
return Math.floor(ms / 86_400_000);
Expand All @@ -67,41 +63,52 @@ function daysBetween(a: string, b: string): number {
/**
* Does a hand-fed row describe the same charge as an auto-matched
* row? Same amount, dates within the matcher's dedupe window, and
* merchant/label token strings that contain one another once
* squashed ("Meta PAYG spill" vs "METAPAY" both squash around
* METAPAY). Conservative: a hand-fed row is only dropped when an
* auto row plausibly IS that charge.
* merchant/label token sequences that contain one another at whole-
* token boundaries ("Meta PAYG spill" still matches the "Meta" alias
* of a METAPAY charge). Conservative: a hand-fed row is only dropped
* when an auto row plausibly IS that charge.
*/
/**
* Every squashed spelling a label answers to: its own, plus the
* Every token spelling a label answers to: its own, plus the
* canonical provider name and merchant aliases of its alias group
* when the label names one of them. Canonicalizing a matched label
* (the charge catalog's "Meta Model API" for a METAPAY charge) must
* not break deduplication against hand-fed rows written under the
* merchant spelling ("Meta PAYG spill" squashes around METAPAY, an
* alias of the canonical name) -- without the group expansion the
* same charge lands in the feed twice (Codex review, PR #3168).
* not break deduplication against hand-fed rows written under one of
* its merchant aliases ("Meta PAYG spill" contains the whole-token
* alias "Meta") -- without group expansion the same charge lands in
* the feed twice (Codex review, PR #3168).
*/
function labelSquashes(label: string): string[] {
const base = squash(normalizeMerchantName(label));
if (!base) return [];
const out = new Set<string>([base]);
function labelTokenSequences(label: string): string[][] {
const normalized = normalizeMerchantName(label);
if (!normalized) return [];
const base = normalized.split(" ");
const out = new Map<string, string[]>([[normalized, base]]);
for (const [canonical, aliases] of Object.entries(PROVIDER_ALIASES)) {
const group = [canonical, ...aliases].map((n) => squash(normalizeMerchantName(n)));
if (group.includes(base)) {
for (const g of group) out.add(g);
const group = [canonical, ...aliases]
.map((name) => normalizeMerchantName(name))
.filter(Boolean);
if (group.includes(normalized)) {
for (const name of group) out.set(name, name.split(" "));
}
}
return [...out];
return [...out.values()];
}

function containsTokenSequence(tokens: string[], sequence: string[]): boolean {
if (!sequence.length || sequence.length > tokens.length) return false;
for (let start = 0; start <= tokens.length - sequence.length; start++) {
if (sequence.every((token, offset) => tokens[start + offset] === token)) return true;
}
return false;
}

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;
const aSet = labelSquashes(handFed.label);
const bSet = labelSquashes(auto.label);
const aSet = labelTokenSequences(handFed.label);
const bSet = labelTokenSequences(auto.label);
if (!aSet.length || !bSet.length) return false;
return aSet.some((a) => bSet.some((b) => a.includes(b) || b.includes(a)));
return aSet.some((a) => bSet.some((b) => containsTokenSequence(a, b) || containsTokenSequence(b, a)));
}

/**
Expand Down
26 changes: 21 additions & 5 deletions pm-stack/aggregator/test/charges-collect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,31 @@ describe("parseChargeCandidates", () => {

describe("mergeChargeRows canonical-label dedupe", () => {
// Codex finding on PR #3168: the charge catalog canonicalizes a
// METAPAY auto-match to "Meta Model API", which no longer
// containment-matches the hand-fed "Meta PAYG spill" row for the
// same charge, so the charge would appear twice in the feed.
// METAPAY auto-match to "Meta Model API". Alias expansion must keep
// the whole-token "Meta" spelling so it still matches the hand-fed
// "Meta PAYG spill" row for the same charge.
test("a canonicalized auto row still dedupes the merchant-spelled hand-fed row", () => {
const auto = [{ at: "2026-10-01", label: "Meta Model API", amountUsd: 20 }];
const handFed = [{ at: "2026-10-01", label: "Meta PAYG spill", amountUsd: 20 }];
expect(mergeChargeRows(auto, handFed)).toEqual(auto);
});

for (const [handFedLabel, autoLabel] of [["Meta", "METAMUCIL"], ["AI", "RAILWAY"]]) {
test(`a short label does not match inside unrelated merchant token ${autoLabel}`, () => {
const auto = [{ at: "2026-10-01", label: autoLabel, amountUsd: 20 }];
const handFed = [{ at: "2026-10-01", label: handFedLabel, amountUsd: 20 }];
expect(mergeChargeRows(auto, handFed).map((row) => row.label).sort()).toEqual(
[autoLabel, handFedLabel].sort(),
);
});
}

test("Meta PAYG spill still dedupes against the METAPAY merchant alias", () => {
const auto = [{ at: "2026-10-01", label: "METAPAY", amountUsd: 20 }];
const handFed = [{ at: "2026-10-01", label: "Meta PAYG spill", amountUsd: 20 }];
expect(mergeChargeRows(auto, handFed)).toEqual(auto);
});

test("a different amount under the same provider still keeps both rows", () => {
const auto = [{ at: "2026-10-01", label: "Meta Model API", amountUsd: 20 }];
const handFed = [{ at: "2026-10-01", label: "Meta PAYG spill", amountUsd: 25 }];
Expand All @@ -77,8 +93,8 @@ describe("mergeChargeRows", () => {
},
];
// 2026-10-25 vs 2026-10-28 is inside the 3-day window, same
// amount, and squashed "METAPAYGSPILL" contains "METAPAY" — so
// the hand-fed row dedupes against the auto row (date order out).
// amount, and the auto METAPAY alias group shares the whole-token
// "Meta" spelling with the hand-fed row (date order out).
expect(mergeChargeRows(auto, handFed)).toEqual([auto[1], auto[0]]);
});

Expand Down
Loading