Skip to content

[$175] CSV import parses two-digit-year dates as year 2–25 AD #102517

Closed

Description

@MelvinBot

Problem

When you import transactions from a CSV or spreadsheet, dates with a two-digit year now get the wrong year. Some also swap the month and day. The app shows no error, and the wrong date is saved on the expense.

CSV value Before Now
01/20/24 2024-01-20 0024-01-20
11/2/25 2025-11-02 0025-11-02
11-02-25 2025-11-02 0011-02-25
Nov 2, 25 2025-11-02 0025-11-02
2 Nov 25 2025-11-02 0025-11-02

m/d/yy is Excel's default short-date format on US locales, so many bank and card exports use it.

Cause

Read XLSX date cells as dates and parse Excel serial numbers correctly changed parseCSVDate to run the date-fns format loop before new Date() (src/libs/CSVDateUtils.ts:33-40).

The yyyy token in date-fns matches one to four digits, not exactly four. So 01/20/24 matches MM/dd/yyyy as year 24. 11-02-25 matches the first entry, yyyy-MM-dd, as year 11, month 02, day 25. Before that PR, these strings went to new Date() first, which maps two-digit years to 19xx or 20xx.

The full analysis, including a suggested fix, is in this comment.

Steps to reproduce

  1. Go to Account > Wallet > Import transactions.
  2. Upload a CSV whose date column uses a two-digit year, for example 01/20/24.
  3. Finish the column mapping and import.
  4. Open an imported expense.

Expected: The date is 2024-01-20.
Actual: The date is 0024-01-20.

Suggested fix

Handle bare yyyy-MM-dd strings explicitly by building the date in local time. Keep the original order for everything else: new Date() first, then the format loop. Add a unit test such as expect(parseCSVDate('01/20/24')).toBe('2024-01-20').

This is a regression from PR 100612, which reached production on 2026-09-24. Its 7-day regression period is still running, so the author of that PR is assigned.

Upwork Automation - Do Not Edit

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Sep 29, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Sep 29, 2026
  3. melvin-bot commented on Sep 29, 2026

    @melvin-bot

    Current assignee @rojiphil is eligible for the External assigner, not assigning anyone new.

  4. changed the title [-]CSV import parses two-digit-year dates as year 2–25 AD[/-] [+][$175] CSV import parses two-digit-year dates as year 2–25 AD[/+] on Sep 29, 2026
  5. melvin-bot commented on Sep 29, 2026

    @melvin-bot
  6. dominictb commented on Sep 29, 2026

    @dominictb
    Contributor

    Assigned @rojiphil since he authored #100612 which is from #100317

  7. melvin-bot commented on Oct 2, 2026

    @melvin-bot

    @rojiphil Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

  8. melvin-bot commented on Oct 3, 2026

    @melvin-bot

    @rojiphil Whoops! This issue is 2 days overdue. Let's get this updated quick!

  9. rojiphil commented on Oct 4, 2026

    @rojiphil
    Contributor

    @MelvinBot Implement to create a draft PR with the suggested fix as mentioned here

  10. MelvinBot commented on Oct 4, 2026

    @MelvinBot
    ContributorAuthor

    🤖 Draft PR created: Fix CSV import parsing two-digit-year dates as year 2-25 AD
    Please mark it as "Ready for review" when it is ready for review.

    It restores the original parsing order, new Date() first and then the date-fns formats, so 01/20/24 parses as 2024-01-20 again. Bare yyyy-MM-dd values are now built in local time, which keeps the time zone fix from the earlier PR.

    What changed and how it was checked
    • src/libs/CSVDateUtils.ts: adds parseISODateOnly. It accepts months 01–12 and days 01–31, the same ranges new Date() accepts. So 2026-02-30 still rolls over to 2026-03-02, and 2026-13-01 and 2026-01-00 still return null. It also runs on the first 10 characters of longer values.
    • tests/unit/CSVDateUtils.test.ts: adds tests for every two-digit-year example in this issue, ISO rollover and rejection, and the 10-character fallback.
    • 117 tests across the 7 related suites pass, including CSVDateUtils, ImportTransactions, and the CompanyCards CSV import tests. ESLint, typecheck, spellcheck, and oxfmt pass too.
    • Not covered: the Jest suite runs with TZ=utc, so no automated test checks a zone west of UTC. I also didn't run an end-to-end import, because the web automation can't open the native file picker.
    • Known gap: 20/01/24 (day first, two-digit year) still parses as year 24. This already happened before the earlier PR, so this PR doesn't change it.
  11. yuvrajangadsingh commented on Oct 7, 2026

    @yuvrajangadsingh
    Contributor

    Proposal

    Re-state problem

    After PR #100612, CSV import turns two-digit-year dates into the wrong year:

    • 01/20/24 becomes 0024-01-20.
    • 11-02-25 becomes 0011-02-25.
    • Nov 2, 25 becomes 0025-11-02.

    There is no error, and the wrong date is saved on the expense.

    Root cause

    #100612 put the date-fns format loop ahead of native new Date() in parseCSVDate:

    function parseCSVDate(input: string): string | null {
    if (!input || typeof input !== 'string') {
    return null;
    }
    const trimmedInput = input.trim();
    // Try native Date parsing first (handles ISO and some other formats)
    let date = new Date(trimmedInput);
    if (isValid(date) && !Number.isNaN(date.getTime())) {
    return format(date, CONST.DATE.FNS_FORMAT_STRING);
    }
    // Try parsing with common date formats using date-fns
    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);
    }
    }

    In this checkout, the new Date call sits at the top of the function. The issue links the pre-fix lines at 41d52ca and says the two blocks were swapped there, so I could not confirm the swap from this checkout alone.

    What I did confirm is the shape of the code. The format list starts with yyyy-MM-dd, MM/dd/yyyy, and dd/MM/yyyy:

    const CSV_DATE_FORMATS = [
    'yyyy-MM-dd', // ISO format: 2025-11-02
    'MM/dd/yyyy', // US format: 11/02/2025
    'dd/MM/yyyy', // European format: 02/11/2025
    'M/d/yyyy', // US short: 1/2/2025
    'd/M/yyyy', // European short: 2/1/2025
    'MM-dd-yyyy', // US with dashes: 11-02-2025
    'dd-MM-yyyy', // European with dashes: 02-11-2025
    'yyyy/MM/dd', // Alternative ISO: 2025/11/02
    'MMM d, yyyy', // Month name: Nov 2, 2025
    'MMMM d, yyyy', // Full month: November 2, 2025
    'd MMM yyyy', // European with month name: 2 Nov 2025
    'dd MMM yyyy', // European with month name: 02 Nov 2025
    'yyyyMMdd', // Compact: 20251102
    ];

    In date-fns, yyyy accepts one to four digits and does not require four. Therefore:

    • 01/20/24 matches MM/dd/yyyy as year 24.
    • 11-02-25 matches yyyy-MM-dd as year 11, month 02, and day 25.

    Whenever the loop runs before new Date, these strings never reach the native parser, which maps two-digit years to 19xx or 20xx.

    Changes to make

    This is a bug, so I would keep the fix small and limited to src/libs/CSVDateUtils.ts.

    1. Handle a bare yyyy-MM-dd string on its own, before anything else.

      • Match /^(\d{4})-(\d{2})-(\d{2})$/ and build the date with new Date(y, m - 1, d) in local time.
      • Return it through format only if isValid passes and the month and day round-trip, so 2025-02-31 is not silently rolled over.
      • This avoids the UTC-midnight off-by-one that new Date('2025-11-02') can produce. That is why yyyy-MM-dd no longer needs to be the first format.
    2. Restore the original order for everything else: new Date first, then the CSV_DATE_FORMATS loop. Apply the same order in the shortInput fallback. This brings back the two-digit-year behavior from before Read XLSX date cells as dates and parse Excel serial numbers correctly #100612.

    3. Leave the Excel serial branch as it is. Serials are plain digits, so new Date('45000') is either invalid or a far-off year. Keep the /^\d+$/ check ahead of the generic native parse if Read XLSX date cells as dates and parse Excel serial numbers correctly #100612 relies on it for serials such as 45000, because new Date with 45000 can parse as year 45000 in V8. Check that case in the test.

    Tests, in tests/unit/CSVDateUtilsTest.ts (add the file if it does not exist):

    • expect(parseCSVDate('01/20/24')).toBe('2024-01-20')
    • 11/2/25 returns 2025-11-02.
    • Nov 2, 25 returns 2025-11-02.
    • 2 Nov 25 returns 2025-11-02.
    • 11-02-25 returns 2025-11-02.
    • 2025-11-02 returns 2025-11-02, with no timezone shift.
    • An Excel serial such as 45000 still returns its serial date.

    Alternatives

    • Require exactly four digits by adding a regex guard to the four-digit formats and adding yy variants. This is more code, and it changes which ambiguous strings win.
    • Swap yyyy for y. This does not help because y is just as permissive.

    I would not take either option. Handling ISO strings explicitly and restoring the old order is the smallest change and puts back the behavior users had before the regression.

  12. melvin-bot commented on Oct 7, 2026

    @melvin-bot

    @rojiphil Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

  13. melvin-bot commented on Oct 9, 2026

    @melvin-bot

    @rojiphil Eep! 4 days overdue now. Issues have feelings too...

  14. rojiphil commented on Oct 12, 2026

    @rojiphil
    Contributor

    Closing this issue as the issue is already addressed in another PR at #103201

    102517-resolved.mp4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions