Showing posts with label tips and tricks. Show all posts
Showing posts with label tips and tricks. Show all posts

Saturday, March 22, 2025

Fix build error at "using Microsoft.IdentityModel.Clients.ActiveDirectory" in D365FO update 10.0.43

If you used Azure Active Directory Authentication Library (ADAL) in your customization, and get a compilation error at "using Microsoft.IdentityModel.Clients.ActiveDirectory" statement in 10.0.43 update -- this is because ADAL is deprecated and MS have removed the reference from the AOT. 

You will need to migrate you code to Microsoft Authentication Library (MSAL).

In order to do that, you will need to add the Microsoft.Identity.Client.dll library to your bin-folder (can be found in other subfolders under K:\AosService\PackagesLocalDirectory), and add a corresponding reference to a VS project. The reference will then be created as an XML-file. Both the DLL and the XML files must be added to source control via "Add Items to Folder...", like this:

K:\AosService\PackagesLocalDirectory\<package name>\bin\Microsoft.Identity.Client.dll

K:\AosService\PackagesLocalDirectory\<package name>\<model name>\AxReference\Microsoft.Identity.Client.xml

Then, you will need to change the code.

Before:

using Microsoft.IdentityModel.Clients.ActiveDirectory;

...

            ClientCredential clientCrendential = new ClientCredential(clientId, clientSecret);

            AuthenticationContext authContext = new AuthenticationContext(authority);

            AuthenticationResult authResult = authContext.AcquireToken(resource, clientCrendential);

            accessToken = authResult.AccessToken;

...

After:

using Microsoft.Identity.@Client;

...

            ConfidentialClientApplicationBuilder clientApplicationbuilder = ConfidentialClientApplicationBuilder::Create(clientId);

            IConfidentialClientApplication app = clientApplicationbuilder

                .WithClientSecret(clientSecret)

                .WithAuthority(authority)

                .Build();

            System.Collections.Generic.List<System.String> scopes = new System.Collections.Generic.List<System.String>();

            scopes.Add(resource + '/.default');

            System.Collections.IEnumerable enumerable = scopes;

            AcquireTokenForClientParameterBuilder parameterBuilder = app.AcquireTokenForClient(enumerable);

            var task = parameterBuilder.ExecuteAsync();

            task.Wait();

            AuthenticationResult authResult = task.Result;

            accessToken = authResult.AccessToken;

...


Wednesday, December 7, 2022

D365FO: Bypassing "next" in CoC

DISCLAIMER: Use this trick at your own risk. Do not do this, unless you absolutely have to. 

Some time ago I had to figure out why one of the custom services was slow. It took like 30+ seconds to return a dozen of trade agreements for a customer.

It turned out the SysExtension framework tried to collect data about classes through reflection, and it did that multiple times in the same session. 

The issue may be reproduced with this runnable class. Here, we instantiate instances of 4 different classes. The class constructors use SysExtension framework for figuring out which class type to build.


If running the class the first time, the output would look something like this:

SalesLineType::construct: 13610 ms
SalesSummaryFields::construct: 1968 ms
SalesQuantity::construct: 2078 ms
TradeModuleType::newFromModule: 3406 ms

After System administration -> Setup -> Refresh elements, the time would drop to:

SalesLineType::construct: 1735 ms
SalesSummaryFields::construct: 1781 ms
SalesQuantity::construct: 1641 ms
TradeModuleType::newFromModule: 1656 ms

But still, almost 2 seconds for every single class instance is too much. 

The following change can fix this issue by skipping metadata collection, if it is already done:


This code does the following: if the reflection is already collected (reflectionDataEnumerator.moveNext() returns "true"), we throw an exception of a type, that will most likely never be thrown from the "next populateFromMetadata();" itself. Then, we catch the exception, but that also means the "next" statement is skipped and the reflection cache data is not built from scratch again. 

After this change, the runnable class output looks like this:

SalesLineType::construct: 1657 ms
SalesSummaryFields::construct: 218 ms
SalesQuantity::construct: 125 ms
TradeModuleType::newFromModule: 157 ms

It is important, that this "skip" is only done when there is no transaction scope around, otherwise it would abort the transaction, therefore the check for ttsLevel. So, this trick will basically improve performance of certain "read" scenarios, but in order to improve performance for "write" scenarios, MS has to provide a fix.

Wednesday, March 27, 2019

Common mistake, when creating Purchase Orders from X++

I would like to warn my fellow Axapta/AX/D365FO bloggers, that blind copy-pasting from each other seems innocent, but it fact may result in spreading bugs.

E.g., the following piece of code is very popular in "How to create Purchase Order from X++" posts.

numberSeq = NumberSeq::newGetNumFromCode(purchParameters::numRefPurchaseOrderId().NumberSequence,true);

Purchtable.PurchId = numberSeq.num();

The problem is numRefPurchaseOrderId returns the number sequence for purchase order confirmation number. For purchase order number, numRefPurchId should have been used. Using the wrong number sequence may result in duplicate key issues.

Thursday, December 6, 2018

Controlling grid line colors

Can see some folks have found the DisplayOptionInitialize event, still they are in doubt on how to control the displayOption settings based on the selected line.


Here is the solution:


X++:
 [FormDataSourceEventHandler(formDataSourceStr(Form1, SalesTable), FormDataSourceEventType::DisplayOptionInitialize)]
    public static void SalesTable_OnDisplayOptionInitialize(FormDataSource sender, FormDataSourceEventArgs e)
    {
        FormDataSourceDisplayOptionInitializeEventArgs args = e as FormDataSourceDisplayOptionInitializeEventArgs;
        RecId currentRecId = args.record().RecId;
        int rgbValue = 255 - (20 * (currentRecId mod 2));
        args.displayOption().backColor(WinAPI::RGB2int(rgbValue, rgbValue, rgbValue));
    }



Tuesday, November 6, 2018

Create LedgerDimension from main account and financial dimensions

The following code will (hopefully) keep generating valid LedgerDimension values, even after account structures or advanced rule structures are modified.

In the example below, there is an account structure with MainAccount-BusinessUnit-Department segments and an advanced rule with Project segment.

class TestLedgerDim
{        
    public static DimensionDynamicAccount generateLedgerDimension(
        MainAccountNum _mainAccountId,
        str _departmentId,
        str _businessUnit,
        str _projectId)
    {
        DimensionAttributeValueSetStorage dimensionAttributeValueSetStorage 
            = new DimensionAttributeValueSetStorage();

        void addDimensionAttributeValue(
            DimensionAttribute _dimensionAttribute, 
            str _dimValueStr)
        {
            DimensionAttributeValue dimensionAttributeValue;

            if (_dimValueStr != '')
            {
                dimensionAttributeValue = 
                    DimensionAttributeValue::findByDimensionAttributeAndValueNoError(
                        _dimensionAttribute,
                        _dimValueStr);
            }

            if (dimensionAttributeValue.RecId != 0)
            {
                dimensionAttributeValueSetStorage.addItem(dimensionAttributeValue);
            }
        }

        DimensionAttribute dimensionAttribute;

        while select dimensionAttribute
            where dimensionAttribute.ViewName == tableStr(DimAttributeOMDepartment)
               || dimensionAttribute.ViewName == tableStr(DimAttributeOMBusinessUnit)
               || dimensionAttribute.ViewName == tableStr(DimAttributeProjTable)
        {
            switch (dimensionAttribute.ViewName)
            {
                case tableStr(DimAttributeOMDepartment):
                    addDimensionAttributeValue(dimensionAttribute, _departmentId);
                    break;

                case tableStr(DimAttributeOMBusinessUnit):
                    addDimensionAttributeValue(dimensionAttribute, _businessUnit);
                    break;

                case tableStr(DimAttributeProjTable):
                    addDimensionAttributeValue(dimensionAttribute, _projectId);
                    break;
            }            
        }

        RecId defaultDimensionRecId = dimensionAttributeValueSetStorage.save();

        return LedgerDimensionFacade::serviceCreateLedgerDimension(
            LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountRecId(
                MainAccount::findByMainAccountId(_mainAccountId).RecId),
            defaultDimensionRecId);
    }

    /// <summary>
    /// Runs the class with the specified arguments.
    /// </summary>
    /// <param name = "_args">The specified arguments.</param>
    public static void main(Args _args)
    {   
        // Prints 110110-001-022-000002
        info(
            DimensionAttributeValueCombination::find(
                TestLedgerDim::generateLedgerDimension(
                    '110110', 
                    '022', 
                    '001', 
                    '000002')).DisplayValue);
    }

}

Tuesday, November 29, 2016

Monday, April 4, 2016

Error executing code: The field with ID '0' does not exist in table 'SysExtensionSerializerExtensionMap'.

If you ever get "Error executing code: The field with ID '0' does not exist in table 'SysExtensionSerializerExtensionMap'." error in CIL, it may happen it is the table extension framework + the standard buf2buf function to blame.


Somehow, if you pass a Map instead of a table to buf2buf function, the following line fails:

_to.(fieldId) = _from.(fieldId);


However, if you replace this line with:


fieldName = fieldId2name(_from.TableId, fieldId);
 _to.setFieldValue(fieldName, _from.getFieldValue(fieldName));


everything is fine both in X++ and in CIL.


P.S. I didn't dare to change the standard buf2buf method. Istead, I created another method and used it in the table extension framework logic, which failed because of the issue (\Data Dictionary\Maps\SysExtensionSerializerMap\Methods\copyExtensionTableData)


P.P.S. There are a couple of other SysExtensionSerializerMap methods that should be fixed, as they call buf2buf too.


Tuesday, October 27, 2015

When fields lists don't work as expected

Did you know that a field list in a single-record select-statement is not always beneficial in comparison to a select firstonly * from...  (or a static find-method)?

The thing is, when a where-clause matches a unique index (in AX 2009 – a primary index), the AOS may find the complete record in the cache and return it. And if this is the very first call, it will pull the whole record anyway, in order to be able to cache it. This may be proved by debugging.

So please use find-methods where possible, because they are easier to read and maintain. And remember: a while select with a nested find-method call may actually perform better than a join – thanks to caching. Therefore do measure performance to confirm that your select-statement is the most optimal one.

Tuesday, October 20, 2015

Table extension framework

There is a great article on how to use the table extension framework in AX 2012.

Keep in mind that if the parent table's primary key is not RecId-based, but the table has CreateRecIdIndex property set to Yes, then the relation on step #1 should be created based on "Single field AlternateKey based"; otherwise the new feld will potentially have wrong type, and it will not be possible to use it in the SysExtensionSerializerExtensionMap map.

And remember to add a DeleteAction to the parent table.

Friday, June 19, 2015

Looking for EDTs with broken table relations

The other day I found a couple of EDTs with broken table relations and wrote a script that found even more EDTs with the same problem.

Broken relations look like this:


static void findBrokenRelationsInEDTs(Args _args)
{
    #AOT
    #TreeNodeSysNodeType
 
    TreeNodeIterator iterator;
    TreeNode         edtTreeNode;
    TreeNode         relationsNode;
    TreeNode         relationNode;
    ;
 
    iterator = TreeNode::findNode(#ExtendedDataTypesPath).AOTiterator();
    if (iterator == null)
    {
        throw error("Cannot create tree node iterator");
    }
 
    edtTreeNode = iterator.next();
 
    while (edtTreeNode != null)
    {
        relationsNode = edtTreeNode.AOTfindChild('Relations');
 
        if (relationsNode.AOTchildNodeCount() == 0
         || edtTreeNode.AOTname() like "DEL_*")
        {
            edtTreeNode = iterator.next();
            continue;
        }
 
        relationNode = relationsNode.AOTfirstChild();
 
        while (relationNode != null)
        {
            if (relationNode.sysNodeType() == #NT_DBTYPENORMALREFERENCE
             && (relationNode.AOTgetProperty('Table') == ''
              || relationNode.AOTgetProperty('RelatedField') == ''))
            {
                error(edtTreeNode.AOTname());
                break;
            }
 
            relationNode = relationNode.AOTnextSibling();
        }
 
        edtTreeNode = iterator.next();
    }
}

Output:


Tuesday, June 2, 2015

Which query range should I select?!

Customer asked me to figure out which "Customer account" should be selected in a query. There were three of them on the Customer invoice journal table:


Apparently, there were a couple of fields that used the default label, and I found them in the following way.

Selected the first "Customer account" range:


... right-clicked in the field value and selected Record Info:


... in the dialog, clicked on the Script button:


... pasted the clipboard to Notepad:


The extended field ID was 65540.

Then, I selected another range:


... and repeated the process. The second field ID was 81625. Finally, I selected the third Customer account range and found that it was based on the field ID 81626.

So, the three field IDs for CustInvoiceJour table were found. But what were the field names?

static void printFieldNames(Args _args)
{
    print fieldId2Name(tableNum(CustInvoiceJour), fieldExt2Id(65540));
    print fieldId2Name(tableNum(CustInvoiceJour), fieldExt2Id(81625));
    print fieldId2Name(tableNum(CustInvoiceJour), fieldExt2Id(81626));
    pause;
}

And the output was:

Somebody did not follow the Best Practices for Table Fields:






Tuesday, March 10, 2015

AX 2009: Extra BP check for report designs

Sometimes new report designs are created by duplicating original ones. AX adds postfixes to cloned controls automatically to keep names unique, but should there be any references to report controls in the code (say, in executeSection methods), those must be fixed manually.

If this is not done, there will be no compilation errors, but the code in the new design will point to report controls located under the original design, which does not make sense at run time.

I have customized SysBPCheckReportDesign class to check if the code must be fixed in the new design. The customization is based on the standard cross-reference functionality. There was one issue with that: some report control names were cut in the xRefTmpReferences table, so xRefName EDT had to be extended. xRefTmpReferences table is not customized, but it is included to the project, as it should be re-compiled after the project is imported and data dictionary is synchronized.

You can find the XPO here.

And this is what the imported project and the new BP errors look like:




Thursday, January 22, 2015

Off-by-one error and UtcDateTime

It looks like some developers think that DateTimeUtil::newDateTime(01\01\2006, 86400) will result in 01-01-2006 11:59:59 PM.

In fact, it will be 02-01-2006 12:00:00 AM.

Be careful when defining ranges for UtcDateTime fields.

Friday, May 9, 2014

AX Content: Create your own PDF guides from TechNet content

TechNet has added a great feature that you can use to create custom PDF guides from topics.

Enjoy.

P. S. The first thing I have done after reading the article by AX Support was export Best Practices for Microsoft Dynamics AX Development [AX 2012] and create a reading plan. Reading them online was unbearable.

 

Wednesday, December 18, 2013

Table inheritance and collection objects


Be aware that there is an issue with storing child table records in collection objects like List.

If you have a table hierarchy and add a child table record to a List and then try to get it back, information from parent tables is lost, along with InstanceRelationType field value.

The following job reproes the issue:

static void tableInheritanceAndListBug(Args _args)
{
    CompanyInfo     companyInfo;
    List            companyInfoList;
    ListEnumerator  companyInfoEnumerator;
 
    companyInfoList = new List(Types::Record);
 
    select firstOnly companyInfo;
 
    info(strFmt(
        "Orig: RecId: %1, Name: %2, InstanceRelationType: %3",
        companyInfo.RecId,
        companyInfo.Name,
        companyInfo.InstanceRelationType));
 
    companyInfoList.addEnd(companyInfo);
 
    companyInfoEnumerator = companyInfoList.getEnumerator();
    if (companyInfoEnumerator.moveNext())
    {
        companyInfo = companyInfoEnumerator.current();
        info(strFmt(
            "List: RecId: %1, Name: %2, InstanceRelationType: %3",
            companyInfo.RecId,
            companyInfo.Name,
            companyInfo.InstanceRelationType));
    }
}

Output:
Orig: RecId: 5637151316, Name: Contoso Entertainment Systems - E&G Division, InstanceRelationType: 41
List: RecId: 5637151316, Name: , InstanceRelationType: 0


The workaround is to use buf2con function to convert the table buffer to a container, save the container in the list and finally use con2buf when fetching the value with enumerator.

static void tableInheritanceAndListBugWorkaround(Args _args)
{
    CompanyInfo     companyInfo;
    List            companyInfoList;
    ListEnumerator  companyInfoEnumerator;
 
    companyInfoList = new List(Types::Container);
 
    select firstOnly companyInfo;
 
    info(strFmt(
        "Orig: RecId: %1, Name: %2, InstanceRelationType: %3",
        companyInfo.RecId,
        companyInfo.Name,
        companyInfo.InstanceRelationType));
 
    companyInfoList.addEnd(buf2Con(companyInfo));
 
    companyInfoEnumerator = companyInfoList.getEnumerator();
    if (companyInfoEnumerator.moveNext())
    {
        companyInfo = con2Buf(companyInfoEnumerator.current());
        info(strFmt(
            "List: RecId: %1, Name: %2, InstanceRelationType: %3",
            companyInfo.RecId,
            companyInfo.Name,
            companyInfo.InstanceRelationType));
    }
}

Output:
Orig: RecId: 5637151316, Name: Contoso Entertainment Systems - E&G Division, InstanceRelationType: 41
List: RecId: 5637151316, Name: Contoso Entertainment Systems - E&G Division, InstanceRelationType: 41


UPDATE: the bugs is reported to MS Support

Wednesday, October 23, 2013

Debugging update conflicts with X++

Let's say, you need to figure out why an update conflict happens, but there is no way to find that with cross-references (e.g., if doUpdate() method is called somewhere).

Normally, you could use SQL Server Management Studio, while debugging the main logic. Simply set transaction isolation level to "read uncommitted" and periodically check RecVersion field value in the "problematic" record .

However, if there is no access to the SQL Server management Studio, try the following approach:

1. Open another AX client and development workspace.
2. Create a class and add main-method to it.
3. Paste the following code to the main method:
 
public static server void main(Args _args)
 {
     Connection      connection;
     Statement       statement;
     str             query;
     Resultset       resultSet;
 
     connection = new Connection();
     statement = connection.createStatement();
 
     query  = "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;";
 
     query += @"SELECT RecVersion FROM PurchLine                "; 
     query += @"    WHERE PurchLine.InventTransId = N'11199055' ";
     query += @"      AND PurchLine.dataAreaId    = N'CEU'      ";
 
     new SqlStatementExecutePermission(query).assert();
 
     resultSet = statement.executeQuery(query);
 
     while (resultSet.next())
     {
         info(resultSet.getString(1));
     }
 
     CodeAccessPermission::revertAssert();
 }

4. Adjust the query string as needed.
5. Go through the main logic in the debugger and periodically run this class in the second workspace, so you will know if the RecVersion value is still the same.

Wednesday, September 18, 2013

Why reading whitepapers may be useful

If you don't know how to find a list of values behind a DefaultDimension in AX 2012, Google will most likely get you to the following solution:

static void DEV_Dimension(Args _args)
{
    CustTable                         custTable = CustTable::find("1101");
    DimensionAttributeValueSetStorage dimStorage;
    Counter i;

    dimStorage = DimensionAttributeValueSetStorage::find(custTable.DefaultDimension);

    for (i=1 ; i<= dimStorage.elements() ; i++)
    {
        info(strFmt("%1 = %2", DimensionAttribute::find(dimStorage.getAttributeByIndex(i)).Name,       
                               dimStorage.getDisplayValueByIndex(i)));
    }
}


The solution above is copy-pasted all over the AX segment of the Internet.

However, there is a better way to do that:

static void ShowDimensionInOneSelect(Args _args)
{
    DefaultDimensionView defaultDimensionView;
    CustTable                       custTable;

    while select defaultDimensionView
        exists join custTable
            where custTable.AccountNum == "1101"
               && custTable.DefaultDimension == defaultDimensionView.DefaultDimension
    {
        info(strFmt("%1 = %2", defaultDimensionView.Name,
                               defaultDimensionView.DisplayValue));
    }
}

This and some other useful techniques are well described in Implementing the Account and Financial Dimensions Framework white paper.

Tuesday, January 8, 2013

Marking from code: part 2

In this post, I provided a code sample that manipulated InventMarking form from code to complete an ad-hoc task.

Today I had to implement marking from X++ code again, but this time it should have been run on the server side on a daily basis, so I did have to use
\Data Dictionary\Tables\TmpInventTransMark\Methods\updateTmpMark
method instead of the form.

As code samples I found in the Internet were outdated, I decided to post here code that did compile on my box (DAX 2012 CU3):

static void testXppMarking(Args _args)
{
    InventTrans issueInventTrans;
    TmpInventTransMark tmpInventTransMask;
    Map mapMarkNow;
    container con;
    real qty;
    Map mapTmp;
    MapEnumerator mapEnumerator;
 
    InventTransOriginId issueInventTransOriginId = 
        InventTransOrigin::findByInventTransId('Issue lot ID').RecId;
 
    InventTransOriginId receiptInventTransOriginId = 
        InventTransOrigin::findByInventTransId('Receipt lot ID').RecId;    
 
    InventQty qtyToMark = 11;
 
    ttsBegin;
 
    issueInventTrans = InventTrans::findByInventTransOrigin(
        issueInventTransOriginId);
 
    [con, qty] = TmpInventTransMark::packTmpMark(
        InventTransOrigin::find(issueInventTransOriginId),
        issueInventTrans.inventDim(), 
        issueInventTrans.Qty);
 
    mapTmp = Map::create(con);
    mapEnumerator = mapTmp.getEnumerator();
    while (mapEnumerator.moveNext())
    {
        tmpInventTransMask = mapEnumerator.currentValue();
 
        if (tmpInventTransMask.InventTransOrigin == receiptInventTransOriginId)
        {
            tmpInventTransMask.QtyMarkNow = qtyToMark;
            tmpInventTransMask.QtyRemain -= tmpInventTransMask.QtyMarkNow;
            mapMarkNow = new Map(Types::Int64, Types::Record);
            mapMarkNow.insert(tmpInventTransMask.RecId, tmpInventTransMask);
 
            TmpInventTransMark::updateTmpMark(
                issueInventTransOriginId, 
                issueInventTrans.inventDim(), 
                -qtyToMark,
                mapMarkNow.pack());
 
            break;
        }
    }
 
    ttsCommit;
}

Sunday, November 4, 2012

Computed columns in Views by example

There is a bug in the standard AX, in the query named AxdPurchaseRequisition. The problem is here:


PurchLineAllVersions datasource is based on a View object, so PurchLineAllVersions.TableId contains table ID of the view itself, and not that of the underlying tables. As a result, no DocuRefTrans records will be found with this quiry.

The issue may be fixed by using computed columns in views - the new feature in AX 2012.

As you may already know, the PurchLineAllVersions view is based on the quiry PurchLineAllVersions, which, in turn, is a union of 2 other views: PurchLineArchivedVersions and PurchLineNotArchivedVersions.

First, add a method to the 2 views.

PurchLineNotArchivedVersions:

public static server str purchLineTableId()
{
    return SysComputedColumn::returnLiteral(tableNum(PurchLine));
} 

PurchLineArchivedVersions:

public static server str purchLineTableId()
{
    return SysComputedColumn::returnLiteral(tableNum(PurchLineHistory));
}

Add a column to the 2 views: right-click on the Fields, then "New -> Int Computed Column". Set the new fields' properties like that:


Then, add new PurchLineTableId fields to the PurchLineAllVersions query datasources and the PurchLineAllVersions view.

Finally, change the faulty link in the AxdPurchaseRequisition quiry, so that it will connect PurchLineAllVersions.PurchLineTableId and DocuRefTrans.RefTableId.

By the way, there is another, better example of how computed columns may be used: take a look at the InventValue* views, which are used by the Inventory Value report.

Thursday, November 1, 2012

Exporting to Excel with Microsoft.Dynamics.AX.Fim.Spreadsheets.* classes

While looking for a way to export to Excel in batch, I investigated what they do in financial statements (LedgerBalanceSheetDimPrintExcelEngine class).

Pros:
  • It is possible to use these .NET components in batch on the server side
  • Execution is way faster than that of the SysExcel* classes

Cons:
  • It looks like it is not possible to create more columns than there are letters in the English alphabet
  • Less flexible comparing to standard SysExcel classes, so manual formatting will likely be needed in the end. 
static void AnotherWayToExportToExcel(Args _args)
{
    #define.ReadWritePermission('RW')
    #define.FileName('c:\myFile.xlsx')
    #define.ExcelColumnWidth(15)
    #define.ExcelCellFontSize("Microsoft.Dynamics.AX.Fim.Spreadsheets.CellFontSize")
    #define.Size9("Size9")    
 
    CustTable custTable;
 
    Microsoft.Dynamics.AX.Fim.Spreadsheets.Spreadsheet spreadsheet;
    Microsoft.Dynamics.AX.Fim.Spreadsheets.CellProperties cellProperties;    
    Microsoft.Dynamics.AX.Fim.Spreadsheets.ColumnProperties columnProperties;    
 
    void addColumn(str _name)
    {
        columnProperties = new Microsoft.Dynamics.AX.Fim.Spreadsheets.ColumnProperties();
        columnProperties.set_Width(#ExcelColumnWidth);
        spreadSheet.InstantiateColumn(columnProperties);
 
        cellProperties = new Microsoft.Dynamics.AX.Fim.Spreadsheets.CellProperties();
        cellProperties.set_FontSize(CLRInterop::parseClrEnum(#ExcelCellFontSize, #Size9));
        cellProperties.set_Bold(true);
 
        spreadSheet.AddStringCellToWorkbook(_name, cellProperties);
    }    
 
    new FileIOPermission(#FileName, #ReadWritePermission).assert();
 
    spreadSheet = new Microsoft.Dynamics.AX.Fim.Spreadsheets.Spreadsheet();
 
    if (!spreadSheet.CreateSpreadsheet(#FileName))
    {
        throw error(strFmt("@SYS72245", #FileName));
    }
 
    addColumn("Customer name");
    addColumn("Balance");    
 
    while select custTable
    {
        spreadSheet.MoveToNextRowInWorkbook();        
 
        cellProperties = new Microsoft.Dynamics.AX.Fim.Spreadsheets.CellProperties();
        cellProperties.set_FontSize(CLRInterop::parseClrEnum(#ExcelCellFontSize, #Size9));        
 
        spreadSheet.AddStringCellToWorkbook(custTable.name(), cellProperties);            
        spreadSheet.AddNumberCellToWorkbook(real2double(custTable.openBalanceCur()), cellProperties);                    
    }
 
    spreadSheet.WriteFile();
    spreadSheet.Dispose();
 
    CodeAccessPermission::revertAssert();    
}

Output: