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
19 changes: 19 additions & 0 deletions pm-stack/aggregator/src/charges-collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,29 @@ function daysBetween(a: string, b: string): number {
return Math.floor(ms / 86_400_000);
}

/**
* Extract a card mask (e.g. "7535" from "Visa •7535", "•7535", or
* "Visa ending 7535") when present on a payment-method string.
* Returns undefined when no card mask is present (e.g. "card",
* "Link", empty/missing).
*/
export function extractCardMask(method: string | undefined | null): string | undefined {
if (typeof method !== "string" || !method.trim()) return undefined;
const m = /(?:[•*×xX#]|ending\s*)?\s*(\d{3,4})\b/i.exec(method);
return m ? m[1] : undefined;
}

/**
* 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 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.
*
* When both rows carry known, conflicting card masks (e.g. Visa
* •7535 vs Visa •9545), they describe charges on different cards
* and are preserved as distinct evidence (#3123).
*/
/**
* Every token spelling a label answers to: its own, plus the
Expand Down Expand Up @@ -106,6 +122,9 @@ function containsTokenSequence(tokens: string[], sequence: string[]): boolean {
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 maskA = extractCardMask(handFed.method);
const maskB = extractCardMask(auto.method);
if (maskA && maskB && maskA !== maskB) return false;
const aSet = labelTokenSequences(handFed.label);
const bSet = labelTokenSequences(auto.label);
if (!aSet.length || !bSet.length) return false;
Expand Down
83 changes: 82 additions & 1 deletion pm-stack/aggregator/test/charges-collect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// and the packet's replay acceptance (one row per charge, rerun
// adds nothing and re-surfaces nothing).
import { describe, expect, test } from "bun:test";
import { filterNewUnmatched, mergeChargeRows, parseChargeCandidates, unmatchedKey } from "../src/charges-collect.ts";
import { filterNewUnmatched, mergeChargeRows, parseChargeCandidates, unmatchedKey, extractCardMask } from "../src/charges-collect.ts";
import { matchCharges, type ChargeCandidate } from "../src/charges-match.ts";
import type { ChargeRow } from "../src/model.ts";

Expand Down Expand Up @@ -122,6 +122,65 @@ describe("mergeChargeRows", () => {
expect(mergeChargeRows(auto, handFed)).toEqual([auto[1], auto[0]]);
});

test("a hand-fed row with a conflicting card mask is kept as distinct evidence (#3123)", () => {
const handFed: ChargeRow[] = [
{
at: "2026-10-25",
label: "Meta PAYG spill",
amountUsd: 20,
method: "Visa •9545",
source: "Gmail card alert (registry billing incidents)",
},
];
// auto[0] has Visa •7535, handFed has Visa •9545. Even with matching
// amount, dates within window, and matching merchant label, conflicting
// card masks identify distinct charges and must both be preserved.
const merged = mergeChargeRows(auto, handFed);
expect(merged).toContainEqual(handFed[0]);
expect(merged).toContainEqual(auto[0]);
});

test("same-charge merging still works when masks agree or either row lacks a method (#3123)", () => {
// Both masks agree: Visa •7535 vs Visa •7535 -> handFed dropped
const handFedAgree: ChargeRow[] = [
{
at: "2026-10-25",
label: "Meta PAYG spill",
amountUsd: 20,
method: "Visa •7535",
source: "Gmail card alert",
},
];
expect(mergeChargeRows(auto, handFedAgree)).not.toContainEqual(handFedAgree[0]);

// handFed lacks mask: method is "card" -> merges/drops handFed
const handFedNoMask: ChargeRow[] = [
{
at: "2026-10-25",
label: "Meta PAYG spill",
amountUsd: 20,
method: "card",
source: "Gmail card alert",
},
];
expect(mergeChargeRows(auto, handFedNoMask)).not.toContainEqual(handFedNoMask[0]);

// auto lacks mask -> merges/drops handFed
const autoNoMask: ChargeRow[] = [
{ at: "2026-10-28", label: "METAPAY*UC5DPA22Y4 US", amountUsd: 20, method: "card", source: "Plaid" },
];
const handFedWithMask: ChargeRow[] = [
{
at: "2026-10-25",
label: "Meta PAYG spill",
amountUsd: 20,
method: "Visa •7535",
source: "Gmail card alert",
},
];
expect(mergeChargeRows(autoNoMask, handFedWithMask)).not.toContainEqual(handFedWithMask[0]);
});

test("a hand-fed row outside the window or for another charge is kept", () => {
const handFed: ChargeRow[] = [
{
Expand Down Expand Up @@ -208,3 +267,25 @@ describe("period replay (packet acceptance)", () => {
expect(second.fresh).toEqual([]);
});
});

describe("extractCardMask", () => {
test("extracts last digits from various payment method formats", () => {
expect(extractCardMask("Visa •7535")).toBe("7535");
expect(extractCardMask("Visa •9545")).toBe("9545");
expect(extractCardMask("Visa • 7535")).toBe("7535");
expect(extractCardMask("•7535")).toBe("7535");
expect(extractCardMask("Visa ending 7535")).toBe("7535");
expect(extractCardMask("Mastercard •1234")).toBe("1234");
expect(extractCardMask("Amex •1002")).toBe("1002");
expect(extractCardMask("card •5678")).toBe("5678");
});

test("returns undefined when no card mask is present", () => {
expect(extractCardMask("card")).toBeUndefined();
expect(extractCardMask("Link")).toBeUndefined();
expect(extractCardMask("")).toBeUndefined();
expect(extractCardMask(" ")).toBeUndefined();
expect(extractCardMask(undefined)).toBeUndefined();
expect(extractCardMask(null)).toBeUndefined();
});
});