Invoice line posting
A supplier invoice has been approved for payment at Acme, and it lists a dozen things Acme bought, one per line. The accounting system wants each of those lines recorded as its own entry in the books, all filed under the same invoice, and in the order they appear. So Acme works down the invoice one line at a time, posts that line, then moves to the next, until the whole invoice is in the books. The lines belong together: they share the invoice’s approval, its currency, its date, so they cannot be split apart and posted on their own. And if one line is rejected, a wrong account code, say, the work stops right there so a person can fix that line and the rest can carry on, with nothing posted twice.
So picture a clerk with a stack of invoice lines, posting them to the ledger from top to bottom and crossing each one off. Post a line, count down, post the next, until none are left. If a line will not post, the clerk stops on that line and raises a hand rather than skipping ahead or starting over. That is the whole job: every line recorded, in order, under one invoice, and a clean stopping point if something is wrong.
Here is how Art2link ESB builds that clerk. Everything runs on the bus, the shared message backbone that flows publish to and subscribe from, and the trick is that the invoice keeps re-publishing itself, one lap per line, until the lines run out. The lines cannot be torn off and posted separately, because they share the invoice’s approval, currency and date, so this is not debatching (splitting a batch into stand-alone records). It is the countdown loop, the same document going round and round with a counter ticking down.
The invoice arrives on an API Listener receive port (a receive port is the entry point that brings work onto the bus; an adapter is the connector for one kind of system, here an inbound web call) as ApprovedInvoice, a message type, which is just a name plus a format. On the way in, a small pipeline component (a bit of code that runs as the message enters and can set values for the flow) counts the lines and writes that count into variables, named slots that travel with this one invoice (a component is needed because the platform’s JSONPath engine, Newtonsoft.Json, has no length function, and components are the surface allowed to write variables). A two-way SQL Caller send port (a send port delivers a message out, here to the database that holds the ledger) then posts the line at the current index, re-publishes the same invoice under the same type with the counter one lower, and so matches itself again until the counter runs out:
output.Variables["iLines"] = count.ToString(); (CountLines component, Step 4)
A SQL Caller send port subscribes on the type and the counter, posts the line at the current index, decrements, and re-publishes the message under the same type, so it matches itself again until the counter runs out:
{{Message.MessageType}} == "ApprovedInvoice" && {{Variable.iLines}} > 0
Open Variables, one assignment per row (select the variable, then enter the expression):
| Variable | Expression |
|---|---|
| idx | {{Variable.Total}} - {{Variable.iLines}} |
| LineNumber | {{Message.Body}}.JPath("$.lines[{{Variable.idx}}].lineNumber") |
| GlCode | {{Message.Body}}.JPath("$.lines[{{Variable.idx}}].glCode") |
| Amount | {{Message.Body}}.JPath("$.lines[{{Variable.idx}}].amount") |
Close Variables, decrement the counter:
| Variable | Expression |
|---|---|
| iLines | {{Variable.iLines}} - 1 |
A null port subscribing on {{Variable.iLines}} == 0 absorbs the final pass so the finished invoice does not dead-letter for want of a subscriber. Ledger posting usually must run first-to-last, so the port computes a forward index in Open Variables, idx ← Total - iLines, and the bindings read $.lines[{{Variable.idx}}]: the arithmetic lives in the variable assignment, never inside the JPath, because the JSONPath engine accepts only a literal index. This is the forward variant the pattern article describes.
Why not debatch? A settlement record stands alone; an invoice line does not, it shares the invoice's approval, currency and posting date, and posting half an invoice's lines as independent messages leaves the ledger in a state no accountant will accept as "partially arrived". The loop keeps one flow instance, one invoice, one place to look.
When it fails. A rejected line, bad GL code, closed posting period, stops the loop exactly where it stands: the counter still reads the failing index, the exception type LinePostFailed re-publishes the invoice with that state, and the O365 Mail operations subscription tells finance which invoice and which line. Make the posting procedure idempotent on invoice number + line number, and replaying from tracking resumes the loop without double-posting the lines already in the ledger.
{{Message.MessageType}} == "LinePostFailed"
Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.
Under the application’s Message types, create the two types this flow routes on. A message type is a name plus a format, no schema required:
| Name | Format | Purpose |
|---|---|---|
| ApprovedInvoice | JSON | the approved supplier invoice, header plus lines, that circulates the loop |
| LinePostFailed | JSON | the exception type the failure path publishes under (Step 9) |
Promotions live on the message type, so add them while you are here. The looping port’s header parameter (Step 7) and the alert subject (Step 9) bind these values, and adapter parameters are plain strings: they bind {{…}} tokens but never evaluate a body path.
| Promotion | Path |
|---|---|
| InvoiceNumber | $.invoiceNumber |
A failed invoice re-publishes payload intact, but promotions are type-qualified, so the exception type needs its own:
| Promotion | Path |
|---|---|
| InvoiceNumber | $.invoiceNumber |
Three lines, the document the Step 10 test posts:
{
"invoiceNumber": "PI-88421",
"supplier": "NordicGear",
"currency": "EUR",
"postingDate": "2026-06-05",
"lines": [
{ "lineNumber": 1, "glCode": "5010", "description": "Trail Pack 45L x40", "amount": 2448.00 },
{ "lineNumber": 2, "glCode": "5010", "description": "Hex Stove x60", "amount": 1125.00 },
{ "lineNumber": 3, "glCode": "6420", "description": "Inbound freight", "amount": 180.00 }
]
}Two external systems, two credentials. Under the application’s Authentications, the dialog asks for a Name, the Adapter it pairs with, and a Definition, the credential shape that adapter offers, with the Application preset; the Definition decides the config section that follows.
| Setting | Value |
|---|---|
| Name | LedgerDbSql |
| Adapter | SQL Caller |
| Definition | SQL Server Connection |
| Connection String (Database Config) | the ledger database’s connection string, credentials included |
| Setting | Value |
|---|---|
| Name | O365OpsMail |
| Adapter | O365 Mail Sender |
| Definition | Microsoft Graph |
| Tenant Id / Client Id / Client Secret (Graph AuthConfig) | an app registration with Mail.Send granted |
Create the ledger and the posting procedure; the looping port (Step 7) calls it once per pass. The primary key on (invoice, line) makes posting idempotent; an unknown GL code THROWs, the per-line failure that stops the loop and raises LinePostFailed (Step 9). The final SELECT echoes the whole invoice JSON back and ends in FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, so the result is one JSON object carrying that string; the response map (Step 5) lifts it back out.
CREATE TABLE dbo.GlAccount ( GlCode VARCHAR(10) NOT NULL PRIMARY KEY ); CREATE TABLE dbo.Ledger ( InvoiceNumber VARCHAR(40) NOT NULL, LineNumber INT NOT NULL, GlCode VARCHAR(10) NOT NULL, Amount DECIMAL(18,2) NOT NULL, PostedAt DATETIME2 NOT NULL CONSTRAINT DF_Ledger DEFAULT SYSUTCDATETIME(), CONSTRAINT PK_Ledger PRIMARY KEY (InvoiceNumber, LineNumber) ); CREATE OR ALTER PROCEDURE dbo.PostInvoiceLine @InvoiceNumber VARCHAR(40), @LineNumber INT, @GlCode VARCHAR(10), @Amount DECIMAL(18,2), @InvoiceJson NVARCHAR(MAX) AS BEGIN SET NOCOUNT ON; IF NOT EXISTS (SELECT 1 FROM dbo.GlAccount WHERE GlCode = @GlCode) THROW 50010, 'Unknown GL code', 1; MERGE dbo.Ledger AS tgt USING (SELECT @InvoiceNumber AS InvoiceNumber, @LineNumber AS LineNumber) AS src ON tgt.InvoiceNumber = src.InvoiceNumber AND tgt.LineNumber = src.LineNumber WHEN NOT MATCHED THEN INSERT (InvoiceNumber, LineNumber, GlCode, Amount) VALUES (@InvoiceNumber, @LineNumber, @GlCode, @Amount); SELECT @InvoiceJson AS InvoiceJson FOR JSON PATH, WITHOUT_ARRAY_WRAPPER; END;
Under the application’s pipeline components, create CountLines; the receive port (Step 6) references it in its inbound pipeline. JPath cannot count an array (Newtonsoft.Json has no length function), and components are the write surface for Variables, so the component counts the lines once and writes both the countdown counter and the forward-indexing total:
using CC.Art2link.Pipelines.Domain.Models.PipelineComponents; using Newtonsoft.Json.Linq; public sealed class CountLinesConfig { } public sealed class CountLines : PipelineComponentBase<CountLinesConfig> { public override string Name => "CountLines"; protected override Task<PipelineComponentOutput> ExecuteAsync( PipelineComponentInput input, CountLinesConfig config, CancellationToken cancellationToken) { var count = (JObject.Parse(input.Body)["lines"] as JArray)?.Count ?? 0; return Task.FromResult(new PipelineComponentOutput { Success = true, Messages = [new PipelineMessage { Body = input.Body }], Variables = { ["iLines"] = count.ToString(), ["Total"] = count.ToString() } }); } }
Under the application’s Maps, create the map the looping port (Step 7) selects on its response side. The posting procedure’s final SELECT echoes the invoice JSON back as a column; this map lifts it out of the SqlCallerResult result so the original body republishes.
| Map setting | Value |
|---|---|
| Name | SqlCallerResultToApprovedInvoice |
| Source | SqlCallerResult (JSON), the SQL Caller’s result; the procedure ends in FOR JSON (Step 3), so the result is JSON |
| Target | ApprovedInvoice, from Step 1, set as the response-side fallback in Step 7 |
The procedure echoes the invoice back in one column and ends in FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, so the result is one JSON object whose InvoiceJson field carries the original invoice as a string:
{ "InvoiceJson": "{\"invoiceNumber\":\"PI-88421\", ... }" }Maps are authored in XSLT 3.0 and run on Saxon HE 12.9. JSON in, the original body out: parse-json(.) reads the result and ?InvoiceJson returns the echoed invoice string unchanged:
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:output method="text"/> <xsl:template match="/"> <xsl:value-of select="parse-json(.)?InvoiceJson"/> </xsl:template> </xsl:stylesheet>
Create a receive port InvoiceIntake on the API Listener, one-way, classifying as ApprovedInvoice, from Step 1. (The dropdowns can also create types and maps inline; building the dependencies first keeps each dialog a selection.)
In the port’s inbound pipeline, reference CountLines, from Step 4.
Create a send port PostLine on the SQL Caller, two-way, because the republish that drives the loop is the response: a two-way port publishes back what the SQL call returned. It subscribes on the type while the counter is still above zero:
{{Message.MessageType}} == "ApprovedInvoice" && {{Variable.iLines}} > 0
| Setting | Value |
|---|---|
| Adapter | SQL Caller |
| Way | Two |
| Auth Config | LedgerDbSql, from Step 2 |
| Command Type | StoredProcedure |
| Command Text | dbo.PostInvoiceLine |
In Open Variables the port computes the forward index first, then captures the line’s three values by that index. Both reads belong in the assignments: the JSONPath engine does no arithmetic, so idx must resolve to a literal first, and the parameters below bind the captured variables. Add one assignment per row (select the variable, then enter the expression):
| Variable | Expression |
|---|---|
| idx | {{Variable.Total}} - {{Variable.iLines}} |
| LineNumber | {{Message.Body}}.JPath("$.lines[{{Variable.idx}}].lineNumber") |
| GlCode | {{Message.Body}}.JPath("$.lines[{{Variable.idx}}].glCode") |
| Amount | {{Message.Body}}.JPath("$.lines[{{Variable.idx}}].amount") |
Add the Input Parameters (key/value), one row per parameter; the header’s invoice number binds the Promotion from Step 1:
| Parameter | Value |
|---|---|
| InvoiceNumber | {{Promoted.ApprovedInvoice.InvoiceNumber}} |
| LineNumber | {{Variable.LineNumber}} |
| GlCode | {{Variable.GlCode}} |
| Amount | {{Variable.Amount}} |
| InvoiceJson | {{Message.Body}}, the whole invoice; the procedure echoes it back so the response carries the original body |
In Close Variables, decrement the counter:
| Variable | Expression |
|---|---|
| iLines | {{Variable.iLines}} - 1 |
On the response side, select the map from Step 5 as a Universal assignment and set Publish Message Type Fallback to ApprovedInvoice: the republished message is the same invoice under the same type with the counter one lower, so it matches the subscription again until the counter reaches zero.
Create a null port subscribing on {{Message.MessageType}} == "ApprovedInvoice" && {{Variable.iLines}} == 0 so the finished invoice has a subscriber and does not dead-letter on its last lap.
On PostLine, set Exception Message Type, on the port’s General step, to LinePostFailed, from Step 1.
Create the OpsAlert O365 Mail port for finance subscribing on {{Message.MessageType}} == "LinePostFailed":
| Setting | Value |
|---|---|
| Authentication | O365OpsMail, from Step 2 |
| From | noreply@acme.example |
| To | ops@acme.example |
| Subject | Invoice {{Promoted.LinePostFailed.InvoiceNumber}} line post failed |
Everything you configure is live the moment you save it, there is nothing to deploy. Start the send ports first, then the receive ports.
Set Tracking to Enabled + Body on every port the flow touches, so you can walk each run step by step, with its message body, in tracking.
Post a test invoice with three lines, and watch tracking: three passes through PostLine, counter walking 3 → 0, three ledger rows, the null port absorbing the final pass.
Post an invoice with a bad GL code on line 2 and confirm the loop stops there, finance gets the alert, and replay after the fix resumes without double-posting line 1. Remember the constraint: one ApprovedInvoice loop in flight at a time.