From b35655d60371d36ec723b3b330de7c98509ccbb2 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Tue, 6 Oct 2026 09:12:43 -0500 Subject: [PATCH 1/3] Parse two-digit CSV years in the current century and reject years before 1900 --- src/libs/CSVDateUtils.ts | 68 ++++++++++++++++++++++----------- tests/unit/CSVDateUtils.test.ts | 17 +++++++++ 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/src/libs/CSVDateUtils.ts b/src/libs/CSVDateUtils.ts index 96f082dd00c2..4dcd491059c4 100644 --- a/src/libs/CSVDateUtils.ts +++ b/src/libs/CSVDateUtils.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; -import {addDays, format, isValid, parse} from 'date-fns'; +import {addDays, format, getYear, isValid, parse} from 'date-fns'; // Common date formats to try when parsing CSV dates // Order matters - more specific/common formats first @@ -18,8 +18,42 @@ const CSV_DATE_FORMATS = [ 'd MMM yyyy', // European with month name: 2 Nov 2025 'dd MMM yyyy', // European with month name: 02 Nov 2025 'yyyyMMdd', // Compact: 20251102 + // Two-digit years are mapped to the century closest to today. The `yyyy` formats above also match them as years 1-99, + // so these are only reached because those results are rejected by the minimum year check. + 'MM/dd/yy', // US short year: 11/02/25 or 1/2/25 + 'dd/MM/yy', // European short year: 02/11/25 or 2/1/25 + 'MM-dd-yy', // US short year with dashes: 11-02-25 + 'dd-MM-yy', // European short year with dashes: 02-11-25 + 'MMM d, yy', // Month name short year: Nov 2, 25 + 'd MMM yy', // European with month name short year: 2 Nov 25 ]; +// Earlier years are never real transaction dates. They come from short years like `26` being read as AD 26, which the backend drops as malformed. +const MIN_CSV_DATE_YEAR = 1900; + +/** + * Formats a parsed date as yyyy-MM-dd, or returns null when it is invalid or has an implausible year + */ +function formatParsedDate(date: Date): string | null { + if (!isValid(date) || getYear(date) < MIN_CSV_DATE_YEAR) { + return null; + } + return format(date, CONST.DATE.FNS_FORMAT_STRING); +} + +/** + * Tries each of the known CSV date formats in order and returns the first plausible match + */ +function parseWithKnownFormats(input: string): string | null { + for (const dateFormat of CSV_DATE_FORMATS) { + const formattedDate = formatParsedDate(parse(input, dateFormat, new Date())); + if (formattedDate) { + return formattedDate; + } + } + return null; +} + /** * Parses a date string from various formats and returns it in yyyy-MM-dd format */ @@ -32,11 +66,9 @@ function parseCSVDate(input: string): string | null { // Try parsing with common date formats using date-fns first. These are parsed in the local time zone, while `new Date()` // reads a bare yyyy-MM-dd string as UTC midnight, which formats back to the previous day for anyone west of UTC. - for (const dateFormat of CSV_DATE_FORMATS) { - const parsedDate = parse(trimmedInput, dateFormat, new Date()); - if (isValid(parsedDate)) { - return format(parsedDate, CONST.DATE.FNS_FORMAT_STRING); - } + const formattedDate = parseWithKnownFormats(trimmedInput); + if (formattedDate) { + return formattedDate; } // Convert 5-digit Excel serials before new Date() treats them as years. We subtract 2 because Excel counts from 1900-01-01 and treats 1900 as a leap year. @@ -44,17 +76,17 @@ function parseCSVDate(input: string): string | null { const inputInt = parseInt(trimmedInput, 10); if (inputInt > 0) { const excelEpoch = new Date(1900, 0, 1); // January 1, 1900 - const parsedDate = addDays(excelEpoch, inputInt - 2); - if (isValid(parsedDate)) { - return format(parsedDate, CONST.DATE.FNS_FORMAT_STRING); + const formattedSerialDate = formatParsedDate(addDays(excelEpoch, inputInt - 2)); + if (formattedSerialDate) { + return formattedSerialDate; } } } // Fall back to native Date parsing (handles ISO date-times and other formats not listed above) - let date = new Date(trimmedInput); - if (isValid(date) && !Number.isNaN(date.getTime())) { - return format(date, CONST.DATE.FNS_FORMAT_STRING); + const formattedNativeDate = formatParsedDate(new Date(trimmedInput)); + if (formattedNativeDate) { + return formattedNativeDate; } // If the date didn't parse, try taking just the first 10 characters @@ -62,17 +94,7 @@ function parseCSVDate(input: string): string | null { const shortInput = trimmedInput.substring(0, 10); // Formats run before native parsing here for the same time zone reason as above - for (const dateFormat of CSV_DATE_FORMATS) { - const parsedDate = parse(shortInput, dateFormat, new Date()); - if (isValid(parsedDate)) { - return format(parsedDate, CONST.DATE.FNS_FORMAT_STRING); - } - } - - date = new Date(shortInput); - if (isValid(date) && !Number.isNaN(date.getTime())) { - return format(date, CONST.DATE.FNS_FORMAT_STRING); - } + return parseWithKnownFormats(shortInput) ?? formatParsedDate(new Date(shortInput)); } return null; diff --git a/tests/unit/CSVDateUtils.test.ts b/tests/unit/CSVDateUtils.test.ts index 7b8d74857443..dc1e44a63610 100644 --- a/tests/unit/CSVDateUtils.test.ts +++ b/tests/unit/CSVDateUtils.test.ts @@ -27,6 +27,23 @@ describe('CSVDateUtils', () => { expect(parseCSVDate('2025')).toBe('2025-01-01'); }); + it('parses two-digit years into the current century instead of years 1-99', () => { + expect(parseCSVDate('9/22/26')).toBe('2026-09-22'); + expect(parseCSVDate('09/22/26')).toBe('2026-09-22'); + expect(parseCSVDate('22/09/26')).toBe('2026-09-22'); + expect(parseCSVDate('1/2/25')).toBe('2025-01-02'); + expect(parseCSVDate('09-22-26')).toBe('2026-09-22'); + expect(parseCSVDate('22-09-26')).toBe('2026-09-22'); + expect(parseCSVDate('Sep 22, 26')).toBe('2026-09-22'); + expect(parseCSVDate('22 Sep 26')).toBe('2026-09-22'); + }); + + it('rejects dates with implausibly old years', () => { + expect(parseCSVDate('0026-09-22')).toBeNull(); + expect(parseCSVDate('1899-12-31')).toBeNull(); + expect(parseCSVDate('1900-01-01')).toBe('1900-01-01'); + }); + it('returns null for invalid input', () => { expect(parseCSVDate('not a date')).toBeNull(); expect(parseCSVDate('')).toBeNull(); From 24a785fc3abe06e94e48836997589178fce62f8a Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Wed, 7 Oct 2026 08:13:54 -0500 Subject: [PATCH 2/3] Add Given/When/Then comments to CSV two-digit year tests --- tests/unit/CSVDateUtils.test.ts | 39 +++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/tests/unit/CSVDateUtils.test.ts b/tests/unit/CSVDateUtils.test.ts index dc1e44a63610..1da782da8979 100644 --- a/tests/unit/CSVDateUtils.test.ts +++ b/tests/unit/CSVDateUtils.test.ts @@ -28,20 +28,37 @@ describe('CSVDateUtils', () => { }); it('parses two-digit years into the current century instead of years 1-99', () => { - expect(parseCSVDate('9/22/26')).toBe('2026-09-22'); - expect(parseCSVDate('09/22/26')).toBe('2026-09-22'); - expect(parseCSVDate('22/09/26')).toBe('2026-09-22'); - expect(parseCSVDate('1/2/25')).toBe('2025-01-02'); - expect(parseCSVDate('09-22-26')).toBe('2026-09-22'); - expect(parseCSVDate('22-09-26')).toBe('2026-09-22'); - expect(parseCSVDate('Sep 22, 26')).toBe('2026-09-22'); - expect(parseCSVDate('22 Sep 26')).toBe('2026-09-22'); + // Given CSV dates with two-digit years in every supported separator and month-name layout, since bank exports + // commonly shorten years and the `yyyy` formats would otherwise read `26` as AD 26, which the backend drops + const twoDigitYearDates = ['9/22/26', '09/22/26', '22/09/26', '09-22-26', '22-09-26', 'Sep 22, 26', '22 Sep 26']; + + // When each value is parsed + const parsedDates = twoDigitYearDates.map((date) => parseCSVDate(date)); + const parsedShortDate = parseCSVDate('1/2/25'); + + // Then every value resolves to the same date in the current century, including unpadded day and month values + for (const parsedDate of parsedDates) { + expect(parsedDate).toBe('2026-09-22'); + } + expect(parsedShortDate).toBe('2025-01-02'); }); it('rejects dates with implausibly old years', () => { - expect(parseCSVDate('0026-09-22')).toBeNull(); - expect(parseCSVDate('1899-12-31')).toBeNull(); - expect(parseCSVDate('1900-01-01')).toBe('1900-01-01'); + // Given dates just before, well before, and exactly at the minimum year of 1900, because years that old are never + // real transaction dates and only come from misread short years + const ancientDate = '0026-09-22'; + const dayBeforeMinimumYear = '1899-12-31'; + const firstDayOfMinimumYear = '1900-01-01'; + + // When each value is parsed + const parsedAncientDate = parseCSVDate(ancientDate); + const parsedDayBeforeMinimumYear = parseCSVDate(dayBeforeMinimumYear); + const parsedFirstDayOfMinimumYear = parseCSVDate(firstDayOfMinimumYear); + + // Then dates before 1900 are rejected so they are never sent to the backend, while 1900 itself is still accepted + expect(parsedAncientDate).toBeNull(); + expect(parsedDayBeforeMinimumYear).toBeNull(); + expect(parsedFirstDayOfMinimumYear).toBe('1900-01-01'); }); it('returns null for invalid input', () => { From 38621bf72d9d9989bcb3e8d52c434341bed8d5ab Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Wed, 7 Oct 2026 08:29:35 -0500 Subject: [PATCH 3/3] Fix spellcheck in CSV date test comment --- tests/unit/CSVDateUtils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/CSVDateUtils.test.ts b/tests/unit/CSVDateUtils.test.ts index 1da782da8979..f44ac9e45c5b 100644 --- a/tests/unit/CSVDateUtils.test.ts +++ b/tests/unit/CSVDateUtils.test.ts @@ -36,7 +36,7 @@ describe('CSVDateUtils', () => { const parsedDates = twoDigitYearDates.map((date) => parseCSVDate(date)); const parsedShortDate = parseCSVDate('1/2/25'); - // Then every value resolves to the same date in the current century, including unpadded day and month values + // Then every value resolves to the same date in the current century, including day and month values without leading zeros for (const parsedDate of parsedDates) { expect(parsedDate).toBe('2026-09-22'); }