web
You’re offline. This is a read only version of the page.
close
Skip to main content

Announcements

No record found.

News and Announcements icon
Community site session details

Community site session details

Session Id :

Importing Excel Dates in D365 F&O through X++ without the Apostrophe Trick

vishalsahijwani Profile Picture vishalsahijwani 392

 We often get a requirement to create excel upload custom functionality in x++ . In this post we will see how to handle Excel OLE Automation date serial numbers in X++ so your users never have to format a cell before uploading again.

If you have ever built an Excel import in D365 F&O and handed it to a business user, you have almost certainly received this complaint:

"The dates are not importing correctly. They are showing as random numbers."

The usual workaround developers give is: "Format the date column as Text in Excel and add an apostrophe before each date value."

That is a terrible user experience. No end user should have to manipulate cell formats before uploading a file. It also breaks down immediately when someone opens a saved template and Excel re-formats the cells automatically.

In this article I am going to explain exactly why this happens — what Excel is actually storing in a date cell and why your X++ code reads it as a 5-digit number and show you the clean, code-side solution that handles both cases automatically with no user action required.

Why date cells in Excel come through as 5-digit numbers

When you type a date into an Excel cell — say 03/06/2026 — Excel does not store that as a date string. It stores it as a floating-point number called an OLE Automation Date (also called a serial date or OADate).

The OLE Automation date system counts the number of days since a fixed epoch. Microsoft Excel uses 1 January 1900 as day 1. This means:
Date in Excel cell → What Excel actually stores internally 01/01/1900 → 1 01/01/2000 → 36526 03/06/2026 → 46145 31/12/2026 → 46388 The value is a floating-point number. The integer part = days since 01/01/1900 The decimal part = time of day (0.5 = noon, 0.75 = 18:00, etc.) When you read a date cell via EPPlus in X++ using .value: range.get_Item(i, 8).value You get back: "46145" ← a string of the integer OLE date number Not: "03/06/2026"

This is the root cause. The X++ library reads the raw underlying value of the cell, not its formatted display string. A cell that shows 03/06/2026 on screen returns "46145" when you call .value on it in code.

The standard workaround prefixing cells with an apostrophe ( ' ) forces Excel to store the cell content as a text string rather than a number. When X++ reads a text cell, it returns the formatted string. This works, but it is fragile and puts the burden on the user.

The code-side solution is to detect when the value is an OLE serial number and convert it back to a real date in X++.


The detection and conversion logic is explained below : -

The key insight is that an OLE Automation date serial number for any reasonable business date (year 2000 onwards) will always be a 5-digit integer. A real date string formatted as text (01/06/2026 or 2026-06-03) is never 5 characters long.

This gives us a reliable discriminator:
Read cell value as string │ ▼ Is strLen(value) == 5? │ ├── YES → It is an OLE serial number │ str2int() converts "46145" to integer 46145 │ any2date(46145) converts OLE integer to X++ date │ → date value ready to use │ └── NO → It is already a formatted date string Use str2Date() or pass through directly → date value ready to use
Here is the full implementation from the import class, with detailed explanations of every step:


/// <summary>
/// Converts an Excel cell value to a formatted date string.
/// Handles both cases:
///   1. OLE Automation serial number (e.g. "46145") — Excel stores dates this way
///   2. Already a formatted date string (e.g. "03/06/2026")
///
/// Why strLen == 5:
///   OLE dates from year 1900 onwards produce 5-digit integers.
///   Any real date formatted as text (dd/mm/yyyy, yyyy-mm-dd, etc.)
///   is always longer than 5 characters.
///   This makes strLen == 5 a reliable discriminator.
///
/// Why any2date(str2int(...)):
///   str2int() parses the string to an X++ integer (e.g. 46145)
///   any2date() interprets that integer as an OLE Automation date
///   and converts it to an X++ date.
///   This is the same conversion Excel performs internally when
///   displaying the number as a date.
/// </summary>
private str getDateFromString(str _dateStringField)
{
    if (strLen(_dateStringField) == 5)
    {
        // OLE serial number detected
        // Convert: "46145" → int 46145 → X++ date
        date convertedDate = any2date(str2int(_dateStringField));

        // Return as formatted string for further processing
        // Note: the - 2 adjustment is explained below in the pitfalls section
        return strFmt("%1", DateTimeUtil::date(convertedDate) - 2);
    }
    else
    {
        // Already a formatted date string — return as-is
        return _dateStringField;
    }
}

/// 
/// Same logic — returns an X++ date type directly instead of a string.
/// Use this when the target field is a date type, not a string type.
/// 
private date getDate(str _dateStringField)
{
    date convertedDate;

    if (strLen(_dateStringField) == 5)
    {
        convertedDate = any2date(str2int(_dateStringField));
    }
    // If not a 5-char string, convertedDate remains dateNull()
    // Caller should handle the dateNull() case

    return convertedDate;
}
✅ Two methods for two use cases

The class has two variants of the same logic: getDateFromString() returns a str for fields that are stored as strings in the staging table, and getDate() returns an X++ date type for fields that map directly to a date column. Always use the one that matches the type of the target table field to avoid unnecessary type conversions.


How any2date() works with OLE serial numbers

The any2date() function in X++ interprets an integer as an OLE Automation date — the same epoch that Excel uses. This is not a coincidence: Microsoft standardised on this format across Office, COM automation, and the .NET DateTime.FromOADate() method for exactly this reason.
OLE Serial Numberany2date() resultCalendar date
4492701/01/20231 January 2023
4529201/01/20241 January 2024
4565801/01/20251 January 2025
4602201/01/20261 January 2026
4614503/06/20263 June 2026
4638831/12/202631 December 2026
You can verify any of these in Excel by typing the number into a cell and formatting it as a Date.

Handling datetime columns — the System.DateTime.FromOADate approach

The code also handles a more complex scenario — when the Excel column contains a datetime value with a time component (for example, a posting date that includes the time of day). In this case, the OLE value is a floating-point number, not a pure integer. The run() method handles this using System.DateTime::FromOADate() through CLR interop:

// For datetime columns where the OLE value has a decimal component
// (time component stored as fraction of a day)
// Example: 46145.375 = 03/06/2026 09:00:00

real pDateval;
utcdatetime pDatetimeval;

// Read the cell value as a real number (handles the decimal time portion)
pDateval = any2real(range.get_Item(i, 2).value);

// Convert OLE date (real/double) to .NET DateTime using CLR interop
// System.DateTime::FromOADate() is the exact inverse of
// the OLE Automation date encoding
System.DateTime postingDateTime = System.DateTime::FromOADate(pDateval);

// Convert .NET DateTime to D365 FO UTC datetime
pDatetimeval = Global::clrSystemDateTime2UtcDateTime(postingDateTime);

// Extract just the date component if needed
invoiceIntegration.PostingDate = DateTimeUtil::date(pDatetimeval);

✅ When to use which approach

Use any2date(str2int(value)) for pure date columns where no time component is expected — it is simpler and has no CLR interop overhead. Use System.DateTime::FromOADate(any2real(value)) for datetime columns where the time component matters, such as posting dates that carry both a date and time of day.


The complete import class with all patterns in context

Below is the full, cleaned-up import class showing how both date handling approaches are used together in a real Excel import scenario. The class reads a vendor accessorial invoice spreadsheet and stages the data into a custom interface table.

using System.IO;
using OfficeOpenXml;
using OfficeOpenXml.ExcelPackage;
using OfficeOpenXml.ExcelRange;
class ImportAccessorialInvoice
{
    private ItemId findItem(str _SiteBucket, str _InvoiceType)
    {
        return CustomItemMapping::findItemByParm(MappingType::Invoice, _SiteBucket, _InvoiceType);
    }
    private str getDateFromString(str _dateStringField)
    {
        if(strLen(_dateStringField) == 5)
        {
            date convertedDate = any2date(str2int(_dateStringField));
            return strFmt("%1",  DateTimeUtil::date(convertedDate) - 2);
        }
        else
            return _dateStringField;
    }
    private date getDate(str _dateStringField)
    {
        date convertedDate;
        if(strLen(_dateStringField) == 5)
        {
            convertedDate = any2date(str2int(_dateStringField));
        }

        return convertedDate;
    }
    void run()
    {
        real                                                        pDateval;
        utcdatetime                                                 pDatetimeval;
        System.IO.Stream                                            stream;
        ExcelSpreadsheetName                                        sheeet;
        FileUploadBuild                                             fileUpload;
        DialogGroup                                                 dlgUploadGroup;
        FileUploadBuild                                             fileUploadBuild;
        FormBuildControl                                            formBuildControl;
        COMVariantType                                              type;
        POInvoiceInterface                                          invoiceIntegration , invoiceIntegrationdel;
        Dialog                                                      dialog =    new Dialog("@ImportPOInvoice");
        dlgUploadGroup          = dialog.addGroup('@SYS54759');
        formBuildControl        = dialog.formBuildDesign().control(dlgUploadGroup.name());
        fileUploadBuild         = formBuildControl.addControlEx(classstr(FileUpload), "@InvoiceUpload");
        fileUploadBuild.style(FileUploadStyle::MinimalWithFilename);
        fileUploadBuild.fileTypesAccepted('.xlsx');
        str COMVariant2Str(COMVariant _cv)
        {
            switch (_cv.variantType())
            {
                case COMVariantType::VT_BSTR:
                    return _cv.bStr();
                case COMVariantType::VT_EMPTY:
                    return '';
                default:
                    throw error(strfmt('@SYS26908', _cv.variantType()));
            }
        }
        if (dialog.run() && dialog.closedOk())
        {
            FileUpload fileUploadControl     = dialog.formRun().control(dialog.formRun().controlId('Upload'));
            FileUploadTemporaryStorageResult fileUploadResult = fileUploadControl.getFileUploadResult();
            if (fileUploadResult != null && fileUploadResult.getUploadStatus())
            {
                stream = fileUploadResult.openResult();
                using (ExcelPackage Package = new ExcelPackage(stream))
                {
                    int                         rowCount, i,columncount,j;
                    Package.Load(stream);
                    ExcelWorksheet   worksheet   = package.get_Workbook().get_Worksheets().get_Item(1);
                    OfficeOpenXml.ExcelRange    range       = worksheet.Cells;
                    rowCount           = (worksheet.Dimension.End.Row) - (worksheet.Dimension.Start.Row)  + 1;
                    columncount      = (worksheet.Dimension.End.Column);
                    ttsbegin;
                    delete_from invoiceIntegrationdel where invoiceIntegrationdel.HasValidationErrors==NoYes::Yes;
                    for (i = 2; i<= rowCount; i++)
                    {
                        invoiceIntegration.clear();
                        POInvoiceIntegrationInterface        pOInvoiceIntegrationCounter;

                        select maxof(RowNum) from pOInvoiceIntegrationCounter
                            index hint RowNumIdx;
                        invoiceIntegration.RowNum      = pOInvoiceIntegrationCounter.RowNum + 1;
                        invoiceIntegration.Invoice = range.get_Item(i, 7).value;
                        invoiceIntegration.LocationID = range.get_Item(i, 9).value;
                        invoiceIntegration.CarrierName = range.get_Item(i, 3).value;
                        invoiceIntegration.CarrierProNumber = range.get_Item(i, 4).value;
                        invoiceIntegration.InvoiceDate = this.getDateFromString(range.get_Item(i, 8).value);
                        invoiceIntegration.LoadNumberFile = range.get_Item(i, 6).value;
                        invoiceIntegration.AccessorialsLessFuelSurcharge = range.get_Item(i, 10).value;
                        invoiceIntegration.ACCode  =   range.get_Item(i, 13).value;
                        invoiceIntegration.ACDescription   = range.get_Item(i, 14).value;
                        invoiceIntegration.Department      = range.get_Item(i, 15).value;
                        invoiceIntegration.CostCenter      = range.get_Item(i, 16).value;
                        invoiceIntegration.Purpose         = range.get_Item(i, 17).value;
                        invoiceIntegration.VendAccount     = range.get_Item(i, 1).value;

                        pDateval = any2real(range.get_Item(i, 2).value);
                        System.DateTime postingDateTime = System.DateTime::FromOADate(pDateval);
                        pDatetimeval = Global::clrSystemDateTime2UtcDateTime(postingDateTime);
                        invoiceIntegration.PostingDate = DateTimeUtil::date(pDatetimeval);

                        invoiceIntegration.AccessoriaApprovalNumber = range.get_Item(i, 5).value;
                        invoiceIntegration.AccessoriaApprovalNumber = invoiceIntegration.AccessoriaApprovalNumber ? invoiceIntegration.AccessoriaApprovalNumber : PurchParameters::find().CustomDefaultApprovalNumber;
                        invoiceIntegration.ItemId                   = this.findItem(range.get_Item(i, 11).value, range.get_Item(i, 12).value);
                        invoiceIntegration.PurchPrice               = range.get_Item(i, 10).value;

                        // Generate Row Number
                        invoiceIntegration.validateAccessorialInvoice();
                        if(invoiceIntegration.PurchPrice)
                        {
                            invoiceIntegration.insert();
                        }

                    }
                    ttscommit;
                    info("@ImportCompleted");
                }
            }
            else
            {
                error("@ImportError");
            }
            this.openStagingInterfaceForm();
        }
    }
    public static void main (Args args)
    {
        ImportPOInvoice importInvoice = new ImportPOInvoice();
        importInvoice.run();
    }
    private void openStagingInterfaceForm()
    {
        Args            args = new Args();
        MenuFunction    menuFunction;
        menuFunction = new MenuFunction(
                        menuItemDisplayStr(InterfaceForm1),
                        MenuItemType::Display);
        menuFunction.run(args);
    }
}


The - 2 adjustment — what it is and when you need it



You may have noticed this line in getDateFromString():

return strFmt("%1", DateTimeUtil::date(convertedDate) - 2);


The - 2 is a date correction that accounts for two known quirks in the OLE Automation date system:

1. The 1900 leap year bug: Excel incorrectly treats 1900 as a leap year and
includes February 29, 1900 in its day count — a date that never existed.
This adds 1 extra day to all Excel OLE dates from March 1900 onwards.

2. Epoch difference: The X++ any2date() function and the Excel OLE system may use a slightly
different epoch start in certain version combinations, adding another 1-day offset.


The strLen == 5 boundary — what about years before 2000?

A question worth addressing: what if someone imports a date from before year 2000 where the OLE serial number is a
4-digit number?

DateOLE serialstrLenHandled by
01/01/1995347005✅ getDateFromString() — 5-char check catches it
01/01/1999361615✅ getDateFromString() — correctly detected
01/01/2000365265✅ getDateFromString() — correctly detected
31/12/2099730505✅ getDateFromString() — correctly detected
01/01/190011❌ Falls through to else — treated as date string
01/01/1970255695✅ Correctly detected


For all practical business date scenarios — any date from 1995 onwards — the OLE serial number is always 5 digits. Dates before
approximately 1927 produce 4-digit serials, but these are outside any realistic AP invoice date range. The strLen == 5 check is
safe for all real-world financial document dates.

Why this pattern is better than asking users to format cells


The apostrophe approach — Problems in practice users generally forget. Every time someone gets a fresh copy of the template
or opens it in a different version of Excel, the cell format may reset.When cells are formatted as Text, Excel shows a green warning
triangle on every cell. Users call this a bug.Pasting dates from another system into a text-formatted cell in Excel pastes the raw text
with the apostrophe visible — producing values like '03/06/2026 in the import.When the template is filled by an automated
system or another X++ export, it will never add apostrophes — breaking the entire import pipeline.

The code-side approach — Why it is correct users upload the file exactly as Excel saved it — no cell formatting required.Works
whether the user typed the date, pasted it, or it was generated by another system.Works for both date and datetime columns using
two different but equally clean conversion paths.The logic is in the import class where it belongs — not in a user instruction
document that no one reads.



Conclusion :-


The reason Excel date cells come through as 5-digit numbers in X++ EPPlus imports is well-understood: Excel stores dates as
OLE Automation serial numbers, and EPPlus reads the raw underlying value rather than the formatted display string. The fix is
entirely on the code side.

For pure date columns, any2date(str2int(cellValue)) converts the serial number back to an X++ date in two function calls. For
datetime columns where the time component matters, System.DateTime::FromOADate(any2real(cellValue)) followed by
Global::clrSystemDateTime2UtcDateTime() handles the full precision conversion through CLR interop.

The strLen == 5 check is the discriminator that makes both approaches safe — it detects OLE serials reliably
for all dates from 1995 to 2099 without any false positives against real date strings. Test the - 2 epoch adjustment in your own
environment before going to production, and your users will never need to touch a cell format again.


That's all for now. Please let us know your questions or feedback in comments section !!!!

This was originally posted here.

Comments

*This post is locked for comments