ci / lint-test (push) Successful in 41s
Deleting the Frollo importer dropped its currency handling and nothing replaced it: ColumnMapping had no currency column, so applyMapping could never produce foreign_currency_code and batchInsertCSVTransactions' support for it was unreachable from the UI. The USD 10,782 salary imported as A$10,782 — about a third under, sitting in a column of AUD figures looking entirely normal, which is the same defect the owner spotted in the first place. An optional Currency column now sets foreign_currency_code and foreign_currency_amount when the cell is a three-letter code other than AUD, and leaves amount_aud NULL rather than inventing a rate. That is the shape order ingestion already uses and what AMOUNT_UNCONVERTED looks for, so the row renders as its native figure with "no AUD rate" and the statement supplies the real number when it arrives. Caught by reading the imported row rather than the import summary: the summary said 171 inserted and was right about everything it reported.
217 lines
7.6 KiB
TypeScript
217 lines
7.6 KiB
TypeScript
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<ParsedTransaction> & { 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");
|
|
});
|
|
});
|