csv import: map a currency column, or foreign rows land as AUD
ci / lint-test (push) Successful in 41s
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.
This commit is contained in:
@@ -169,6 +169,39 @@ describe("applyMapping with account and row-id columns", () => {
|
|||||||
expect(out[0].transaction_type).toBe("credit");
|
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", () => {
|
it("leaves both undefined when the columns are not mapped", () => {
|
||||||
const out = applyMapping(
|
const out = applyMapping(
|
||||||
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830"]],
|
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830"]],
|
||||||
|
|||||||
@@ -300,12 +300,14 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
|||||||
<ColSelect label="Merchant Column (optional)" value={mapping.merchantCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} />
|
<ColSelect label="Merchant Column (optional)" value={mapping.merchantCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} />
|
||||||
<ColSelect label="Category Column (optional)" value={mapping.categoryCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} />
|
<ColSelect label="Category Column (optional)" value={mapping.categoryCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} />
|
||||||
<ColSelect label="Account Column (optional)" value={mapping.accountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, accountCol: v || undefined }))} options={columnLabels} />
|
<ColSelect label="Account Column (optional)" value={mapping.accountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, accountCol: v || undefined }))} options={columnLabels} />
|
||||||
|
<ColSelect label="Currency Column (optional)" value={mapping.currencyCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, currencyCol: v || undefined }))} options={columnLabels} />
|
||||||
<ColSelect label="Row ID Column (optional)" value={mapping.sourceRefCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, sourceRefCol: v || undefined }))} options={columnLabels} />
|
<ColSelect label="Row ID Column (optional)" value={mapping.sourceRefCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, sourceRefCol: v || undefined }))} options={columnLabels} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-zinc-500 leading-relaxed">
|
<p className="text-[11px] text-zinc-500 leading-relaxed">
|
||||||
Map <b>Account</b> when the file covers more than one account: rows already
|
Map <b>Account</b> when the file covers more than one account: rows already
|
||||||
covered by that account's statements are then left out. Map <b>Row ID</b> to
|
covered by that account's statements are then left out. Map <b>Row ID</b> to
|
||||||
make re-importing the same file do nothing.
|
make re-importing the same file do nothing. Map <b>Currency</b> for a
|
||||||
|
multi-currency file, or foreign rows are stored as if they were AUD.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer text-zinc-400">
|
<label className="flex items-center gap-2 text-sm cursor-pointer text-zinc-400">
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ export interface ColumnMapping {
|
|||||||
* measured against the same statement coverage.
|
* measured against the same statement coverage.
|
||||||
*/
|
*/
|
||||||
accountCol?: string;
|
accountCol?: string;
|
||||||
|
/**
|
||||||
|
* Column holding the ISO currency code, when the file is multi-currency.
|
||||||
|
*
|
||||||
|
* Without it a foreign row is stored as if it were AUD — a USD 10,782 salary
|
||||||
|
* lands as A$10,782, understating it by about a third. Mapping it records the
|
||||||
|
* native figure with `foreign_currency_code` set and `amount_aud` left NULL,
|
||||||
|
* which is the same shape order ingestion uses and what `AMOUNT_UNCONVERTED`
|
||||||
|
* looks for.
|
||||||
|
*/
|
||||||
|
currencyCol?: string;
|
||||||
/**
|
/**
|
||||||
* Column holding the provider's own row id, when it has one.
|
* Column holding the provider's own row id, when it has one.
|
||||||
*
|
*
|
||||||
@@ -44,6 +54,9 @@ export interface ParsedTransaction {
|
|||||||
account?: string;
|
account?: string;
|
||||||
/** Provider row id, when a column was mapped. */
|
/** Provider row id, when a column was mapped. */
|
||||||
source_ref?: string;
|
source_ref?: string;
|
||||||
|
/** Set only for a non-AUD row: the native amount and its code. */
|
||||||
|
foreign_currency_amount?: number;
|
||||||
|
foreign_currency_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCSVRows(text: string): string[][] {
|
export function parseCSVRows(text: string): string[][] {
|
||||||
@@ -130,6 +143,7 @@ export function applyMapping(
|
|||||||
const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
|
const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
|
||||||
const accountIdx = mapping.accountCol ? idx(mapping.accountCol) : -1;
|
const accountIdx = mapping.accountCol ? idx(mapping.accountCol) : -1;
|
||||||
const sourceRefIdx = mapping.sourceRefCol ? idx(mapping.sourceRefCol) : -1;
|
const sourceRefIdx = mapping.sourceRefCol ? idx(mapping.sourceRefCol) : -1;
|
||||||
|
const currencyIdx = mapping.currencyCol ? idx(mapping.currencyCol) : -1;
|
||||||
|
|
||||||
const results: ParsedTransaction[] = [];
|
const results: ParsedTransaction[] = [];
|
||||||
for (const row of dataRows) {
|
for (const row of dataRows) {
|
||||||
@@ -162,6 +176,15 @@ export function applyMapping(
|
|||||||
if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
|
if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
|
||||||
if (accountIdx >= 0 && row[accountIdx]) tx.account = row[accountIdx].trim();
|
if (accountIdx >= 0 && row[accountIdx]) tx.account = row[accountIdx].trim();
|
||||||
if (sourceRefIdx >= 0 && row[sourceRefIdx]) tx.source_ref = row[sourceRefIdx].trim();
|
if (sourceRefIdx >= 0 && row[sourceRefIdx]) tx.source_ref = row[sourceRefIdx].trim();
|
||||||
|
if (currencyIdx >= 0) {
|
||||||
|
const code = (row[currencyIdx] ?? "").trim().toUpperCase();
|
||||||
|
// AUD needs no marking; anything else is native and unconverted. No rate
|
||||||
|
// is invented — the statement supplies the AUD figure when it arrives.
|
||||||
|
if (/^[A-Z]{3}$/.test(code) && code !== "AUD") {
|
||||||
|
tx.foreign_currency_code = code;
|
||||||
|
tx.foreign_currency_amount = amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
results.push(tx);
|
results.push(tx);
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
Reference in New Issue
Block a user