The problem
Our UAE e-invoice integration (Peppol) originally relied on a scheduled batch job to pick up posted sales invoices, stage them into a custom outbound table, and submit them to our e-invoicing connector. That works, but it introduces a delay between "invoice posted" and "invoice staged for submission" — anywhere from a few minutes to an hour, depending on batch recurrence.
The ask this month: make staging happen the moment an invoice is posted, not on the next batch cycle. The batch job stays in place as the actual submission engine (with its retry logic, error logging, and FTA-reference polling) — but staging should happen in real time, right after posting.
That meant answering one question first: where, exactly, in D365 F&O's posting pipeline is it safe to hook in?
Why "just extend CustInvoiceTrans.insert()" was the wrong instinct
The obvious first idea is a table-level Chain of Command (COC) on CustInvoiceTrans.insert() — after all, that's the table the invoice lines land in. It turns out to be the wrong layer, for a few concrete reasons:
-
It fires per line, not per invoice. A 10-line invoice means 10 firings, and you'd need extra logic just to detect "is this the last line" — fragile, since line insert order isn't guaranteed.
-
It fires mid-transaction. Posting a sales invoice is one large transaction — header, lines, and tax calculation can all happen inside a single ttsbegin/ttscommit. Hooking too early risks reading TaxTrans before tax has actually been calculated.
-
It's too broad. CustInvoiceTrans.insert() fires for any insert into that table — corrections, reversals, any code path — not just the specific "invoice was posted" event we care about.
The right layer is a hook on the posting class itself — the method that owns "post this invoice" as a complete, committed unit of work.
Finding the real hook point: don't guess, trace it
Rather than pick a plausible-sounding method name (post()? run()? postInvoiceJournal()?), we traced the actual call stack for a real posting action using the X++ debugger:
-
Set a breakpoint on the menu item / button that triggers "Post invoice."
-
Step through with Step Into, watching the Call Stack window, until reaching classes that looked like real candidates (SalesFormLetter, SalesFormLetter_Invoice).
-
Confirmed — via class hierarchy inspection — that this environment posts sales invoices entirely through SalesFormLetter_Invoice, not the lower-level CustPostInvoice engine (a breakpoint there never even got hit — useful negative signal).
-
Landed on SalesFormLetter_Invoice.run() as the candidate wrapping the full posting operation.
Verifying the hook with a throwaway diagnostic
Before writing any real logic, we proved the hook point was trustworthy with a two-line diagnostic:
[ExtensionOf(classStr(SalesFormLetter_Invoice))]
final class SalesFormLetterInvoiceCBS_Extension
{
public void run()
{
next run();
CustInvoiceJour jour;
select firstonly jour
order by jour.RecId desc
where jour.SalesId == this.salesTable().SalesId;
info(strFmt("After run(): Invoice found = %1, InvoiceId = %2",
jour.RecId ? "Yes" : "No", jour.InvoiceId));
}
}
Two things had to hold true before trusting this hook:
-
It must fire only for genuinely completed postings — an early test where the posting dialog was cancelled midway still triggered the hook, which was an important red flag to catch before building on top of it. Once we confirmed a completed posting (an actual CIV-xxxxxx invoice number, not a cancelled operation), the hook behaved correctly.
-
The invoice must already be fully committed and queryable by the time control returns from next run(). The select above confirmed exactly that — CustInvoiceJour was immediately findable, meaning tax calculation and line posting had already completed.
A note on X++ syntax while debugging this: order by must come before where in a select statement — the reverse of what you'd expect coming from SQL. Small thing, but it's exactly the kind of error that eats ten minutes if you don't know to look for it.
The real hook: staging, not submission
With the hook point confirmed, the run() override does the actual staging — mirroring the same field-population logic already used by the manual "Generate Lines" button and the scheduled batch job, so there's one consistent source of truth for how a staged invoice line gets built:
public void run()
{
next run();
this.stageEInvoiceCBS();
}
private void stageEInvoiceCBS()
{
CustInvoiceJour jour;
// ... locate the just-posted invoice ...
ttsbegin;
try
{
// upsert EinvoiceOutboundTableCBS (header)
// upsert EinvoiceOutboundDtlTblCBS (lines), including TaxCode from TaxTrans
// flag ReadyForBatch = Yes
ttscommit;
}
catch
{
ttsabort;
// log and continue — never let staging failure block posting
}
}
The one boundary we were careful not to cross
It's tempting to go one step further and call the e-invoice submission API directly from this same hook — genuinely real-time, end to end. We deliberately didn't.
Calling an external HTTP API synchronously from inside a posting hook means invoice posting — a core financial transaction — now depends on a third party's uptime and latency. If the e-invoicing connector is slow or briefly unavailable, posting shouldn't be the thing that breaks.
Instead:
-
The hook does only local, fast, transactional work: stage the invoice, flag it ready.
-
The existing batch job — already built with retry logic, error logging, and FTA-reference polling — picks it up and handles submission on a short recurrence (a minute or two).
The net effect is "real-time" from a user's perspective, without coupling invoice posting reliability to an external API's availability.
Takeaways
-
When you need to hook a business process in D365 F&O, trace the actual call stack rather than guessing a method name — the X++ debugger and a few well-placed breakpoints will tell you definitively where a transaction actually starts and commits.
-
Verify a hook point before building on it — a quick info() diagnostic that checks whether committed data is actually visible is cheap insurance against building a feature on a false assumption.
-
Keep hooks narrow. A posting-time extension should do the minimum necessary (stage data, set a flag) and let existing, already-hardened batch infrastructure handle anything that talks to the outside world.
Have you run into a similar "which method actually owns this transaction" problem in your own F&O extensions? Curious to hear how others have approached tracing posting pipelines.
