import { describe, it, expect } from "vitest"; import { applyMapping, inFileDuplicates, last4, splitByCoverage, type ColumnMapping, type ParsedTransaction, } from "@/lib/csv-parser"; /** * The statement-coverage rule, which replaced a whole Frollo-specific importer. * * The first aggregator import wrote 422 duplicate rows out of 550 — $1,023,824 * of movement counted twice — because "these accounts issue no statements" was * asserted rather than queried. Amount-matching could not fix it either: the two * sources decompose the same event differently, bundling a transfer fee where * the statement itemises it. * * A statement's end date answers the question that actually has an answer: up * to what date is this account complete? On the real 2026-08-13 export it takes * 2,607 rows down to 171, with no account allowlist and no card exclusions. */ const tx = (o: Partial & { date: string }): ParsedTransaction => ({ description: "SOMETHING", amount: 10, transaction_type: "debit", ...o, }); describe("last4", () => { it("reads the same account written four different ways", () => { // Every one of these forms appears in the real data. expect(last4("xxxxxxxxxxxx2176")).toBe("2176"); expect(last4("235242176")).toBe("2176"); expect(last4("xxx-xxx xx4878")).toBe("4878"); expect(last4("4085-56264")).toBe("6264"); }); it("returns empty for an unusable identifier rather than guessing", () => { expect(last4("N/A")).toBe(""); expect(last4("12")).toBe(""); expect(last4("")).toBe(""); }); }); describe("splitByCoverage", () => { const coverage = [{ last4: "2176", coveredTo: "2026-07-26" }]; it("excludes a row on or before the account's newest statement", () => { const { keep, covered } = splitByCoverage( [tx({ date: "2026-07-25", account: "xxxxxxxxxxxx2176" })], coverage ); expect(covered).toHaveLength(1); expect(keep).toHaveLength(0); }); it("treats the watermark date itself as covered", () => { const { covered } = splitByCoverage( [tx({ date: "2026-07-26", account: "xxxxxxxxxxxx2176" })], coverage ); expect(covered).toHaveLength(1); }); it("keeps a row after the watermark — the whole point of the feed", () => { // The 2026-08-12 salary: the statement has not arrived, so this row is the // only record of it and must survive. const { keep } = splitByCoverage( [tx({ date: "2026-08-12", account: "xxxxxxxxxxxx2176", amount: 10782 })], coverage ); expect(keep).toHaveLength(1); }); it("keeps everything for an account with no statements", () => { const { keep, covered } = splitByCoverage( [tx({ date: "2020-01-01", account: "xxx-xxx xx4878" })], coverage ); expect(keep).toHaveLength(1); expect(covered).toHaveLength(0); }); it("keeps everything when no account column was mapped", () => { // A single-account bank export has no account column, and a row must never // be dropped on a guess about which account it belongs to. const { keep, covered } = splitByCoverage([tx({ date: "2020-01-01" })], coverage); expect(keep).toHaveLength(1); expect(covered).toHaveLength(0); }); it("applies each account's own watermark, not a global one", () => { // The real export spans watermarks from 2026-03-31 to 2026-07-26. A single // date would either import duplicates or discard real rows. const { keep, covered } = splitByCoverage( [ tx({ date: "2026-05-01", account: "xxxx0887" }), // covered to 2026-03-31 → keep tx({ date: "2026-05-01", account: "xxxx2176" }), // covered to 2026-07-26 → drop ], [ { last4: "0887", coveredTo: "2026-03-31" }, { last4: "2176", coveredTo: "2026-07-26" }, ] ); expect(keep).toHaveLength(1); expect(keep[0].account).toBe("xxxx0887"); expect(covered).toHaveLength(1); }); }); describe("inFileDuplicates", () => { it("counts twins that a row id cannot catch", () => { // A CDR re-consent re-exports an account's whole history under fresh ids // while the originals survive, so source_ref sees two distinct rows. 385 of // these were in one 2,563-row export and doubled every salary payment. const rows = [ tx({ date: "2026-07-07", amount: 10782, description: "HDR", account: "4830" }), tx({ date: "2026-07-07", amount: 10782, description: "HDR", account: "4830" }), ]; expect(inFileDuplicates(rows)).toBe(1); }); it("does not flag the same amount on different days", () => { expect( inFileDuplicates([ tx({ date: "2026-07-07", amount: 5, description: "COFFEE" }), tx({ date: "2026-07-08", amount: 5, description: "COFFEE" }), ]) ).toBe(0); }); it("does not flag the same amount on different accounts", () => { // Both legs of an internal transfer: same day, same amount, different // accounts, and both are real. expect( inFileDuplicates([ tx({ date: "2026-05-18", amount: 3076.04, description: "Transfer", account: "6264" }), tx({ date: "2026-05-18", amount: 3076.04, description: "Transfer", account: "9940" }), ]) ).toBe(0); }); }); describe("applyMapping with account and row-id columns", () => { const labels = ["transaction_id", "description", "amount", "transaction_date", "account_number"]; const mapping: ColumnMapping = { dateCol: "transaction_date", descriptionCol: "description", amountMode: "single", amountCol: "amount", accountCol: "account_number", sourceRefCol: "transaction_id", }; it("carries the account and row id through", () => { const out = applyMapping( [["1326131276", "HDR Global Services", "10782.00", "2026-08-12", "xxxxxxxxxxxx4830"]], labels, mapping, "YYYY-MM-DD" ); expect(out).toHaveLength(1); expect(out[0].account).toBe("xxxxxxxxxxxx4830"); expect(out[0].source_ref).toBe("1326131276"); // A positive single-column amount is money in. expect(out[0].transaction_type).toBe("credit"); }); it("marks a non-AUD row as native and unconverted", () => { // The 2026-08-12 salary is USD. Stored without this it reads as A$10,782 — // about a third under — and sits in a column of AUD figures looking normal. const out = applyMapping( [["1", "HDR Global Services", "10782.00", "2026-08-12", "xxxx4830", "USD"]], [...labels, "currency"], { ...mapping, currencyCol: "currency" }, "YYYY-MM-DD" ); expect(out[0].foreign_currency_code).toBe("USD"); expect(out[0].foreign_currency_amount).toBe(10782); }); it("leaves an AUD row unmarked", () => { const out = applyMapping( [["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830", "AUD"]], [...labels, "currency"], { ...mapping, currencyCol: "currency" }, "YYYY-MM-DD" ); expect(out[0].foreign_currency_code).toBeUndefined(); }); it("ignores a currency cell that is not a code", () => { const out = applyMapping( [["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830", ""]], [...labels, "currency"], { ...mapping, currencyCol: "currency" }, "YYYY-MM-DD" ); expect(out[0].foreign_currency_code).toBeUndefined(); }); it("leaves both undefined when the columns are not mapped", () => { const out = applyMapping( [["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830"]], labels, { dateCol: "transaction_date", descriptionCol: "description", amountMode: "single", amountCol: "amount" }, "YYYY-MM-DD" ); expect(out[0].account).toBeUndefined(); expect(out[0].source_ref).toBeUndefined(); expect(out[0].transaction_type).toBe("debit"); }); });