Friday, July 3, 2026

The D365 F&O Posting Framework — A Deep Dive for X++ Developers

Every D365 F&O developer has written posting code. Most of them have also spent hours debugging silent failures — journals that appear to post but leave the Posted flag as No, sales invoices that silently skip lines, or voucher transactions that hit the wrong ledger account because a dimension defaulting step was skipped.

The root cause in almost every case is the same: the developer called the wrong method, skipped an initialisation step, or bolted custom logic onto a posting process without understanding where in the execution pipeline it belongs.

This article maps the D365 F&O posting framework from the inside out — the architecture layers, the key classes, verified X++ patterns for journal and sales invoice posting, and the right extension points for each scenario.


The two posting scenarios we will focus on : -

D365 F&O has two fundamentally different posting mechanisms, and choosing the wrong one for your scenario is the first place developers go wrong.
ScenarioEntry ClassUsed For
Journal postingLedgerJournalCheckPostGeneral journals, vendor payment journals, customer payment journals, fixed asset journals — any transaction that lives in LedgerJournalTable / LedgerJournalTrans
Document posting (FormLetter)SalesFormLetter / PurchFormLetterSales order invoices, packing slips, confirmations, purchase order invoices, product receipts — transactions that generate subledger journals

The journal pipeline creates ledger entries directly. The FormLetter pipeline first creates subledger journal entries via the SourceDocument framework, which are then posted to the general ledger through the SubledgerJournalizer. Understanding this distinction determines where you place your extensions.


Architecture — what happens between "Post" and "Voucher posted"

User / Code trigger
Button click on form,runOperation()call in X++, or batch job execution
Check & Validate
LedgerJournalCheckPost::newLedgerJournalTable()validates lines, checks mandatory fields, verifies period status, checks posting restrictions
Voucher generation
LedgerVoucher/LedgerVoucherObject/LedgerVoucherTransObject— assembles balanced voucher entries in temporary storage
Subledger journal
SubledgerJournalizerwrites toSubledgerJournalAccountEntry— used in FormLetter pipeline only, transfers to GL via batch or synchronous transfer
General Ledger commit
Writes toGeneralJournalEntry/GeneralJournalAccountEntry.LedgerJournalTable.Postedset toYes

The critical insight: custom logic inserted at the wrong layer causes data inconsistency. Adding GL entries after the SubledgerJournalizer step but before the GL commit means your entries bypass subledger reconciliation. Adding them before validation means they can be rolled back silently if the journal fails checks.


Scenario 1 — Journal posting with LedgerJournalCheckPost

The correct pattern for triggering posting from X++

The single most misused method in journal posting is calling post() or run() directly. The correct entry point is runOperation(), which orchestrates both validation and posting in one call.


public static void postJournalById(LedgerJournalId _journalNum)
{
    LedgerJournalTable      ledgerJournalTable;
    LedgerJournalCheckPost  ledgerJournalCheckPost;

    ledgerJournalTable = LedgerJournalTable::find(_journalNum);

    if (!ledgerJournalTable)
    {
        throw error(strFmt("Journal %1 not found.", _journalNum));
    }

    if (ledgerJournalTable.Posted == NoYes::Yes)
    {
        info(strFmt("Journal %1 is already posted.", _journalNum));
        return;
    }

    // NoYes::Yes = post (not just validate)
    ledgerJournalCheckPost = LedgerJournalCheckPost::newLedgerJournalTable(
        ledgerJournalTable,
        NoYes::Yes);

    ledgerJournalCheckPost.runOperation();

    // Reread to confirm posted status — the buffer passed in is now stale
    ledgerJournalTable.reread();

    if (ledgerJournalTable.Posted == NoYes::Yes)
    {
        info(strFmt("Journal %1 posted successfully.", _journalNum));
    }
    else
    {
        warning(strFmt("Journal %1 may not have posted. Review the infolog.", _journalNum));
    }
}


Why reread() after runOperation()

The LedgerJournalTable buffer you pass into newLedgerJournalTable() is a snapshot. The Posted flag is written to the database during posting — your local buffer does not update automatically. Always call .reread() on the buffer before checking Posted.


Validate only — without posting

Pass NoYes::No as the second parameter to run validation without committing the post. This is useful in integration scenarios where you want to surface errors before triggering the actual post.


// Validate without posting
ledgerJournalCheckPost = LedgerJournalCheckPost::newLedgerJournalTable(
    ledgerJournalTable,
    NoYes::No);

ledgerJournalCheckPost.runOperation();

// Check infolog for errors — no vouchers were committed
if (infolog.num() > 0)
{
    // surface or log errors
}


Creating a general journal header and lines before posting

Creating the journal correctly is just as important as posting it. The most common mistake is populating LedgerJournalTrans fields manually and calling insert() without using initFromLedgerJournalName() on the header and initValue() on the lines. This skips defaulting logic and produces journals that post but have incorrect dimensions, due dates, or currency exchange rates.

public static void createAndPostGeneralJournal()
{
    LedgerJournalTable      ledgerJournalTable;
    LedgerJournalTrans      ledgerJournalTrans;
    LedgerJournalCheckPost  ledgerJournalCheckPost;
    LedgerJournalName       ledgerJournalName;
    NumberSeq               numberSeq;

    // 1. Find an active journal name of type Daily
    select firstonly ledgerJournalName
        where ledgerJournalName.JournalName == 'GenJrn';

    if (!ledgerJournalName)
    {
        throw error("Journal name 'GenJrn' not found.");
    }

    ttsBegin;

    // 2. Create the journal header
    ledgerJournalTable.clear();
    ledgerJournalTable.JournalName = ledgerJournalName.JournalName;
    ledgerJournalTable.initFromLedgerJournalName();   // ← critical: sets journal type, voucher series, posting layer
    ledgerJournalTable.Name        = "Auto-posted adjustment";
    ledgerJournalTable.insert();

    // 3. Create the debit line
    ledgerJournalTrans.clear();
    ledgerJournalTrans.initValue();                   // ← sets defaults: currency, exchange rate, company
    ledgerJournalTrans.JournalNum   = ledgerJournalTable.JournalNum;
    ledgerJournalTrans.TransDate    = today();
    ledgerJournalTrans.AccountType  = LedgerJournalACType::Ledger;

    // LedgerDimension must be a valid RecId from DimensionAttributeValueCombination
    // Use LedgerDimensionFacade or LedgerDefaultAccountHelper to build it properly
    ledgerJournalTrans.LedgerDimension = LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId('110180');

    ledgerJournalTrans.AmountCurDebit  = 1000.00;
    ledgerJournalTrans.CurrencyCode    = CompanyInfo::standardCurrency();
    ledgerJournalTrans.ExchRate        = Currency::exchRate(ledgerJournalTrans.CurrencyCode);
    ledgerJournalTrans.Txt             = "Test debit entry";

    // Voucher must be obtained from the number sequence on the journal name
    numberSeq = NumberSeq::newGetVoucherFromCode(
        LedgerJournalName::find(ledgerJournalTable.JournalName).VoucherSeries);
    ledgerJournalTrans.Voucher = numberSeq.voucher();

    ledgerJournalTrans.LineNum  = LedgerJournalTrans::lastLineNum(ledgerJournalTrans.JournalNum) + 1;
    ledgerJournalTrans.insert();

    // 4. Create the offsetting credit line
    ledgerJournalTrans.clear();
    ledgerJournalTrans.initValue();
    ledgerJournalTrans.JournalNum      = ledgerJournalTable.JournalNum;
    ledgerJournalTrans.TransDate       = today();
    ledgerJournalTrans.AccountType     = LedgerJournalACType::Ledger;
    ledgerJournalTrans.LedgerDimension = LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId('140270');
    ledgerJournalTrans.AmountCurCredit = 1000.00;
    ledgerJournalTrans.CurrencyCode    = CompanyInfo::standardCurrency();
    ledgerJournalTrans.ExchRate        = Currency::exchRate(ledgerJournalTrans.CurrencyCode);
    ledgerJournalTrans.Txt             = "Test credit entry";
    ledgerJournalTrans.Voucher         = numberSeq.voucher(); // same voucher — debit and credit must balance
    ledgerJournalTrans.LineNum         = LedgerJournalTrans::lastLineNum(ledgerJournalTrans.JournalNum) + 1;
    ledgerJournalTrans.insert();

    ttsCommit;

    // 5. Post
    ledgerJournalCheckPost = LedgerJournalCheckPost::newLedgerJournalTable(
        ledgerJournalTable,
        NoYes::Yes);

    ledgerJournalCheckPost.runOperation();

    ledgerJournalTable.reread();

    if (ledgerJournalTable.Posted == NoYes::Yes)
    {
        info(strFmt("Journal %1 created and posted successfully.", ledgerJournalTable.JournalNum));
    }
⚠️ Never set LedgerDimension by hardcoding an account string directly

LedgerJournalTrans.LedgerDimension is a RecId pointing to DimensionAttributeValueCombination — not a string. Assigning an account number string directly compiles but results in a zero RecId at runtime, causing the line to post to an unresolved account. Always use LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId() or LedgerDimensionFacade::serviceCreateLedgerDimension() to build the RecId properly.

⚠️ The debit and credit on the same voucher must balance to zero

The general ledger enforces that the sum of all AmountMST values under a single voucher equals zero. If your journal lines on the same voucher do not balance, the post will fail with "Voucher is not balanced." Use the same numberSeq.voucher() value for all lines that belong to the same balanced entry.


Scenario 2 — Document posting with SalesFormLetter


The posting class hierarchy

When a sales order is invoiced, the entry point is SalesFormLetter. This is a factory class — you construct it with SalesFormLetter::construct(DocumentStatus::Invoice), not by instantiating SalesFormLetter_Invoice directly. The same pattern applies across all document types.
Document StatusFormLetter ClassJournal Table Created
ConfirmationSalesFormLetter_ConfirmCustConfirmJour
Picking ListSalesFormLetter_PickingListWMSPickingRoute
Packing SlipSalesFormLetter_PackingSlipCustPackingSlipJour
InvoiceSalesFormLetter_InvoiceCustInvoiceJour

The below mentioned code helps us to post sales invoice through x++ : -


public static void postSalesInvoice(SalesId _salesId)
{
    SalesTable              salesTable;
    SalesFormLetter_Invoice salesFormLetter;

    salesTable = SalesTable::find(_salesId);

    if (!salesTable)
    {
        throw error(strFmt("Sales order %1 not found.", _salesId));
    }

    if (salesTable.SalesStatus == SalesStatus::Invoiced)
    {
        info(strFmt("Sales order %1 is already fully invoiced.", _salesId));
        return;
    }

    ttsBegin;

    // construct() returns the correct subclass based on DocumentStatus
    salesFormLetter = SalesFormLetter::construct(DocumentStatus::Invoice);

    salesFormLetter.update(
        salesTable,              // the sales order record
        SystemDateGet(),         // invoice date
        SalesUpdate::All,        // update all uninvoiced lines
        AccountOrder::None,      // account order (None = use default)
        false,                   // printFormLetter — false to suppress print dialog
        true                     // specQty — true means use actual qty from packing slip
    );

    ttsCommit;

    // Reread to confirm
    salesTable.reread();

    if (salesTable.SalesStatus == SalesStatus::Invoiced)
    {
        info(strFmt("Sales order %1 invoiced successfully.", _salesId));
    }
}
✅ SalesUpdate::All vs SalesUpdate::PackingSlip

SalesUpdate::All invoices all lines regardless of packing slip status. SalesUpdate::PackingSlip invoices only lines that have been packing-slipped. In most integration scenarios where posting is triggered from an external system, SalesUpdate::PackingSlip is safer — it follows the same business process the user would follow manually.

Posting a packing slip before invoicing


public static void postPackingSlip(SalesId _salesId)
{
    SalesTable              salesTable;
    SalesFormLetter         salesFormLetter;

    salesTable = SalesTable::find(_salesId);

    ttsBegin;

    salesFormLetter = SalesFormLetter::construct(DocumentStatus::PackingSlip);

    salesFormLetter.update(
        salesTable,
        SystemDateGet(),
        SalesUpdate::PickingList,  // only lines that are picked
        AccountOrder::None,
        false,
        false
    );

    ttsCommit;
}
 

Extending the posting pipeline with Chain of Command

This is where most real-world customisation work happens. The rules are:Always call next methodName() — skipping it breaks the standard posting logic entirely
Pre-logic before next runs inside the same transaction as the standard logic
Post-logic after next also runs in the same transaction — a throw here rolls back the whole post.
Use pre-logic for validation (can abort the post cleanly). Use post-logic for side effects (writing to custom tables after the post succeeds)



Extension 1 — Add a custom validation before journal posting

The validate() method on LedgerJournalCheckPost runs before any vouchers are committed. This is the correct place to add business-rule checks that should block posting.

[ExtensionOf(classStr(LedgerJournalCheckPost))]
final class LedgerJournalCheckPost_CustomValidation_Extension
{
    public boolean validate()
    {
        boolean ret;

        // Run standard validation first
        ret = next validate();

        // Only add our check if standard validation passed
        if (ret)
        {
            LedgerJournalTrans  ledgerJournalTrans;
            LedgerJournalTable  journalTable = this.parmLedgerJournalTable();

            // Example: block posting if any line exceeds a custom threshold
            while select ledgerJournalTrans
                where ledgerJournalTrans.JournalNum == journalTable.JournalNum
                   && ledgerJournalTrans.AmountCurDebit > 500000
            {
                ret = checkFailed(strFmt(
                    "Line %1 exceeds the maximum allowed single-line amount of 500,000. Journal %2 cannot be posted.",
                    ledgerJournalTrans.LineNum,
                    journalTable.JournalNum));
            }
        }

        return ret;
    }
}

Extension 2 — Write to a custom audit table after journal posting

The runOperation() method completes after the post is committed. Extending it post-next gives you a guaranteed hook that only runs on a successful post.


[ExtensionOf(classStr(LedgerJournalCheckPost))]
final class LedgerJournalCheckPost_PostingAudit_Extension
{
    public void runOperation()
    {
        LedgerJournalTable journalTable = this.parmLedgerJournalTable();
        LedgerJournalId    journalNum   = journalTable.JournalNum;

        // Run the standard post
        next runOperation();

        // After standard post completes — reread to check actual status
        journalTable.reread();

        if (journalTable.Posted == NoYes::Yes)
        {
            // Write to custom audit log
            CustomPostingAuditLog auditLog;

            auditLog.JournalNum     = journalNum;
            auditLog.PostedBy       = curUserId();
            auditLog.PostedDateTime = DateTimeUtil::utcNow();
            auditLog.PostedAmount   = this.totalAmountPosted(journalNum);
            auditLog.insert();
        }
    }

    private AmountMST totalAmountPosted(LedgerJournalId _journalNum)
    {
        GeneralJournalEntry         gje;
        GeneralJournalAccountEntry  gjae;
        AmountMST                   total;

        // Sum the absolute debit amounts from GL entries for this journal
        while select sum(AccountingCurrencyAmount) from gjae
            exists join gje
                where gje.RecId          == gjae.GeneralJournalEntry
                   && gje.JournalNumber  == _journalNum
                   && gjae.AccountingCurrencyAmount > 0
        {
            total = gjae.AccountingCurrencyAmount;
        }

        return total;
    }
}

Extension 3 — Extend sales invoice posting to populate a custom field

The SalesFormLetter_Invoice class has a createJournalHeader() method that runs when the CustInvoiceJour record is being created. This is the correct place to stamp custom fields onto the invoice journal header.



[ExtensionOf(classStr(SalesFormLetter_Invoice))]
final class SalesFormLetter_Invoice_CustomField_Extension
{
    protected void createJournalHeader(
        SalesParmUpdate     _salesParmUpdate,
        SalesTable          _salesTable,
        CustInvoiceJour     _custInvoiceJour)
    {
        // Call standard logic first — header record is populated by next
        next createJournalHeader(_salesParmUpdate, _salesTable, _custInvoiceJour);

        // Stamp custom field from SalesTable extension onto the invoice journal
        // Assumes SalesTable has a custom field CustomContractRef added via extension
        SalesTable salesTableLocal = SalesTable::find(_salesTable.SalesId);

        if (salesTableLocal.CustomContractRef)
        {
            _custInvoiceJour.selectForUpdate(true);
            _custInvoiceJour.CustomContractRef = salesTableLocal.CustomContractRef;
            _custInvoiceJour.doUpdate();
        }
    }
}
⚠️ Use doUpdate() not update() inside posting CoC extensions

update() on a table inside a posting CoC will trigger validateWrite() and modifiedField() again, which can cause recursion or secondary side effects mid-post. Use doUpdate() when you need to update a record that is already in-flight inside the posting pipeline.




Posting with error handling — the production pattern

In batch or integration contexts, a posting failure on one record must not stop processing of the remaining records. The correct pattern uses a try/catch per journal with infolog capture, so errors are logged and processing continues.


public static void postMultipleJournals(container _journalNums)
{
    LedgerJournalTable      ledgerJournalTable;
    LedgerJournalCheckPost  ledgerJournalCheckPost;
    int                     infologLine;
    int                     i;
    LedgerJournalId         journalNum;

    for (i = 1; i <= conLen(_journalNums); i++)
    {
        journalNum = conPeek(_journalNums, i);

        ledgerJournalTable = LedgerJournalTable::find(journalNum);

        if (!ledgerJournalTable || ledgerJournalTable.Posted == NoYes::Yes)
        {
            continue;
        }

        // Capture infolog line before each attempt
        infologLine = Global::infologLine();

        try
        {
            ledgerJournalCheckPost = LedgerJournalCheckPost::newLedgerJournalTable(
                ledgerJournalTable,
                NoYes::Yes);

            ledgerJournalCheckPost.runOperation();

            ledgerJournalTable.reread();

            if (ledgerJournalTable.Posted == NoYes::Yes)
            {
                info(strFmt("Journal %1 posted successfully.", journalNum));
            }
            else
            {
                // Post returned without exception but journal is not marked posted
                // Capture infolog messages for this journal
                str errorMessages = RetailTransactionServiceUtilities::getInfologMessages(infologLine);
                warning(strFmt("Journal %1 did not post. Messages: %2", journalNum, errorMessages));
            }
        }
        catch (Exception::Error)
        {
            // Capture the actual error from infolog
            str errorMessages = RetailTransactionServiceUtilities::getInfologMessages(infologLine);
            error(strFmt("Error posting journal %1: %2", journalNum, errorMessages));

            // Continue to next journal — exception is swallowed per record
        }
        catch (Exception::Deadlock)
        {
            // Retry on deadlock
            retry;
        }
    }
}
✅ Always capture infologLine before the try block

Calling Global::infologLine() before your try block records the current position in the infolog. If the post fails, you can pass this to RetailTransactionServiceUtilities::getInfologMessages(infologLine) to retrieve only the messages generated by this specific post attempt — not the entire infolog since the session started. This is the same enterprise exception handling pattern covered in the earlier article on this blog.




The LedgerVoucher API — when to use it directly

Occasionally you need to write GL entries without going through a journal or a FormLetter — for example, in a custom integration that posts financial adjustments programmatically. The LedgerVoucher / LedgerVoucherObject / LedgerVoucherTransObject API is the correct approach for this.
ClassResponsibility
LedgerVoucherTop-level container — manages one or more vouchers. Controls DetailSummary mode and the SysModule context.
LedgerVoucherObjectRepresents a single balanced voucher. Holds a voucher number, transaction date, and correction flag.
LedgerVoucherTransObjectRepresents a single GL line within a voucher — account, dimension, currency, amount.


public static void postDirectGLAdjustment(
    MainAccountNum  _debitAccount,
    MainAccountNum  _creditAccount,
    AmountCur       _amount,
    str             _description)
{
    LedgerVoucher           ledgerVoucher;
    LedgerVoucherObject     ledgerVoucherObject;
    LedgerVoucherTransObject ledgerVoucherTransObject;
    NumberSeq               numberSeq;
    Voucher                 voucher;
    LedgerDimensionAccount  debitDimension;
    LedgerDimensionAccount  creditDimension;

    // Build ledger dimension RecIds from main account numbers
    debitDimension  = LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId(_debitAccount);
    creditDimension = LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId(_creditAccount);

    // Get a voucher from the appropriate number sequence
    numberSeq = NumberSeq::newGetVoucherFromCode('Ledger_1');
    voucher   = numberSeq.voucher();

    ttsBegin;

    // 1. Create the top-level LedgerVoucher container
    ledgerVoucher = LedgerVoucher::newLedgerPost(
        DetailSummary::Detail,
        SysModule::Ledger,
        'Ledger_1');           // voucher series code

    // 2. Create a voucher object (one balanced entry)
    ledgerVoucherObject = LedgerVoucherObject::newVoucher(
        voucher,
        today(),
        SysModule::Ledger,
        LedgerTransType::None);

    ledgerVoucher.addVoucher(ledgerVoucherObject);

    // 3. Add the debit transaction line
    ledgerVoucherTransObject = LedgerVoucherTransObject::newCreateTrans(
        ledgerVoucherObject,
        LedgerPostingType::LedgerJournal,
        debitDimension,
        CompanyInfo::standardCurrency(),
        _amount,        // AmountCurDebit
        0,              // AmountCurCredit
        0,              // sourceTableId
        0);             // sourceRecId

    ledgerVoucherTransObject.parmTransTxt(_description);
    ledgerVoucher.addTrans(ledgerVoucherTransObject);

    // 4. Add the credit transaction line (negated amount)
    ledgerVoucherTransObject = LedgerVoucherTransObject::newCreateTrans(
        ledgerVoucherObject,
        LedgerPostingType::LedgerJournal,
        creditDimension,
        CompanyInfo::standardCurrency(),
        0,              // AmountCurDebit
        _amount,        // AmountCurCredit
        0,
        0);

    ledgerVoucherTransObject.parmTransTxt(_description);
    ledgerVoucher.addTrans(ledgerVoucherTransObject);

    // 5. End() commits all vouchers to the GL
    ledgerVoucher.end();

    ttsCommit;

    info(strFmt("Voucher %1 posted: %2 DR %3, CR %4 for amount %5",
        voucher, _debitAccount, _creditAccount, _amount));
}
⚠️ LedgerVoucher.end() must be called inside the same ttsBegin/ttsCommit

The LedgerVoucher API stages entries in memory. Calling end() flushes them to the database. If end() is called outside a transaction scope, the write behaviour is unpredictable. Always bracket the entire sequence — newLedgerPost through end() — inside a single ttsBegin / ttsCommit block.




Conclusion

The posting framework in D365 F&O is not complicated once you understand that it has two distinct pipelines — journal posting through LedgerJournalCheckPost, and document posting through SalesFormLetter — and that each pipeline has specific, correct entry points.

Most production bugs in posting customisations come from three places: skipping field initialisation when creating journal lines, checking Posted on a stale buffer instead of calling reread(), and placing custom logic outside the transaction scope of the post so it either runs when the post fails or gets rolled back when it should not.

Apply the patterns in this article and your posting code will be solid, upgrade-safe, and diagnosable when things go wrong — because with a properly structured try/catch and infolog capture, you will always know exactly what failed and why.


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

No comments:

Post a Comment

The D365 F&O Posting Framework — A Deep Dive for X++ Developers

Every D365 F&O developer has written posting code. Most of them have also spent hours debugging silent failures — journals that appear t...