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
21 changes: 16 additions & 5 deletions pm-stack/aggregator/scripts/produce-charge-candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,20 @@ function isNotChargeSubject(subject: string): boolean {
);
}

/** Body amount for a receipt whose subject carried none. Explicit
* paid/charged labels win; a standalone "total" label is next — word-
* boundaried, so the "total" inside "Subtotal" can never match it
* (#3121: a Subtotal-first body recorded the subtotal as the charge,
* and the wrong amount then defeated cross-source dedupe); any $
* amount in the body is the last resort. */
export function amountFromBody(text: string): string | undefined {
return (
/(?:amount paid|amount charged|total charged|transaction amount)[:\s]*\$([\d,]+\.\d{2})/i.exec(text)?.[1] ??
/\btotal[:\s]*\$([\d,]+\.\d{2})/i.exec(text)?.[1] ??
AMOUNT_RE.exec(text)?.[1]
);
}

async function gmailCandidates(
start: string,
end: string,
Expand Down Expand Up @@ -204,10 +218,7 @@ async function gmailCandidates(
maxBuffer: 16 * 1024 * 1024,
});
const text = (JSON.parse(body) as { body_text?: string }).body_text ?? "";
amount ??=
/(?:amount paid|amount charged|total charged|transaction amount|total)[:\s]*\$([\d,]+\.\d{2})/i.exec(
text,
)?.[1] ?? AMOUNT_RE.exec(text)?.[1];
amount ??= amountFromBody(text);
merchant ??= cleanMerchant(/\bmerchant[:\s]+([A-Za-z0-9][A-Za-z0-9 .&'*+-]{1,40})/i.exec(text)?.[1]);
} catch {
/* unreadable body: counted as skipped below */
Expand Down Expand Up @@ -257,4 +268,4 @@ async function main(): Promise<void> {
);
}

await main();
if (import.meta.main) await main();
30 changes: 30 additions & 0 deletions pm-stack/aggregator/test/produce-charge-candidates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// #3121: the Gmail body-amount fallback must not read the "total"
// inside "Subtotal" as the charged total, and an explicit paid amount
// wins over an earlier bare total.
import { describe, expect, test } from "bun:test";
import { amountFromBody } from "../scripts/produce-charge-candidates.ts";

describe("amountFromBody", () => {
test("records the paid total, not the subtotal", () => {
const body = "Subtotal $20.00\nTax $1.60\nTotal $21.60\nAmount paid $21.60";
expect(amountFromBody(body)).toBe("21.60");
});

test("a later Amount paid beats an earlier bare Total", () => {
const body = "Total $21.60\nSubtotal $20.00\nAmount paid $21.60";
expect(amountFromBody(body)).toBe("21.60");
});

test("standalone Total is used when no paid label exists", () => {
const body = "Subtotal $20.00\nTax $1.60\nTotal $21.60";
expect(amountFromBody(body)).toBe("21.60");
});

test("total charged is an explicit paid/charged label", () => {
expect(amountFromBody("Subtotal $20.00\nTotal charged $21.60")).toBe("21.60");
});

test("no amount in the body yields undefined", () => {
expect(amountFromBody("Thank you for your order")).toBeUndefined();
});
});