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
3 changes: 2 additions & 1 deletion pm-stack/aggregator/scripts/collect-fleet-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import type {
SessionRecord,
} from "../src/model.ts";
import { matchCharges, type UnmatchedCharge } from "../src/charges-match.ts";
import { chargeServiceCatalog } from "../src/charges-catalog.ts";
import { filterNewUnmatched, mergeChargeRows, parseChargeCandidates } from "../src/charges-collect.ts";
import { extractQuotaPools } from "../src/quota-extract.ts";
import { computeChoices } from "../src/quota-guidance.ts";
Expand Down Expand Up @@ -434,7 +435,7 @@ async function main(): Promise<void> {
if (candidates.length) {
const auto = matchCharges(
candidates,
(saas ?? []).map((s) => ({ name: s.name })),
chargeServiceCatalog(saas ?? [], pools),
Comment thread
dezren39 marked this conversation as resolved.
Resolved
);
charges = mergeChargeRows(auto.rows, charges ?? []);
let surfaced = new Set<string>();
Expand Down
26 changes: 26 additions & 0 deletions pm-stack/aggregator/src/charges-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ChargeServiceRef } from "./charges-match.ts";
import type { QuotaPool, SaasService } from "./model.ts";

// Merchant names, not plan labels: generic plan words are unsafe aliases.
export const PROVIDER_ALIASES: Record<string, string[]> = {
Anthropic: ["Claude"],
OpenAI: ["ChatGPT", "Codex"],
"Meta Model API": ["Meta", "MetaPay"],
ClinePass: ["Cline Pass"],
"Open Design": ["OpenDesign"],
"Command Code": ["CommandCode"],
};

/** Build the match catalog from the same service and pool identities the hub exposes. */
export function chargeServiceCatalog(
services: readonly Pick<SaasService, "name">[],
pools: readonly Pick<QuotaPool, "provider">[],
): ChargeServiceRef[] {
const catalog = new Map<string, ChargeServiceRef>();
for (const { name } of services) catalog.set(name.toLowerCase(), { name });
for (const { provider } of pools) {
const key = provider.toLowerCase();
if (!catalog.has(key)) catalog.set(key, { name: provider, aliases: PROVIDER_ALIASES[provider] });
}
return [...catalog.values()];
}
32 changes: 28 additions & 4 deletions pm-stack/aggregator/src/charges-collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type UnmatchedCharge,
} from "./charges-match.ts";
import type { ChargeRow } from "./model.ts";
import { PROVIDER_ALIASES } from "./charges-catalog.ts";

/**
* Structural validation of the producer's candidates file. Accepts
Expand Down Expand Up @@ -71,13 +72,36 @@ function daysBetween(a: string, b: string): number {
* METAPAY). 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
* 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).
*/
function labelSquashes(label: string): string[] {
const base = squash(normalizeMerchantName(label));
if (!base) return [];
const out = new Set<string>([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);
}
}
return [...out];
}

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

/**
Expand Down
27 changes: 27 additions & 0 deletions pm-stack/aggregator/test/charges-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { chargeServiceCatalog } from "../src/charges-catalog.ts";
import { matchCharges } from "../src/charges-match.ts";
import { extractQuotaPools } from "../src/quota-extract.ts";
import { extractSaasServices } from "../src/saas-extract.ts";

describe("charge service catalog", () => {
test("matches quota providers and merchant aliases alongside SaaS services", () => {
const catalog = chargeServiceCatalog(extractSaasServices(""), extractQuotaPools(""));
const merchants = ["ANTHROPIC", "OPENAI", "CLAUDE", "CHATGPT", "VERCEL", "PADDLE MYSTERY"];
const result = matchCharges(merchants.map((merchant, i) => ({
source: "plaid" as const, at: "2026-10-01", merchant, amountUsd: i + 1,
})), catalog);
expect(result.rows.map((row) => row.label)).toEqual(expect.arrayContaining(["Anthropic", "OpenAI", "Vercel"]));
expect(result.rows.filter((row) => row.label === "Anthropic")).toHaveLength(2);
expect(result.rows.filter((row) => row.label === "OpenAI")).toHaveLength(2);
expect(result.unmatchedSaas.map((row) => row.merchant)).not.toContain("ANTHROPIC");
expect(result.unmatchedSaas.map((row) => row.merchant)).not.toContain("OPENAI");
expect(result.unmatchedSaas.map((row) => row.merchant)).toContain("PADDLE MYSTERY");
});

test("deduplicates providers and respects filtered pools", () => {
const catalog = chargeServiceCatalog([], extractQuotaPools("", new Date(), new Set(["claude-code"])));
expect(catalog.filter((entry) => entry.name === "OpenCode Go")).toHaveLength(1);
expect(catalog.some((entry) => entry.name === "Anthropic")).toBe(false);
});
});
18 changes: 18 additions & 0 deletions pm-stack/aggregator/test/charges-collect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ 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.
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);
});

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 }];
expect(mergeChargeRows(auto, handFed)).toHaveLength(2);
});
});

describe("mergeChargeRows", () => {
const auto: ChargeRow[] = [
{ at: "2026-10-28", label: "METAPAY*UC5DPA22Y4 US", amountUsd: 20, method: "Visa •7535", source: "Plaid" },
Expand Down