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.
D365 F&O has two fundamentally different posting mechanisms, and choosing the wrong one for your scenario is the first place developers go wrong.
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"
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));
}
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 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.
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 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)
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();
}
}
}
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.
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;
}
}
}
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.
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));
}
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.

Like
Report
*This post is locked for comments