Skip to content
Art2link ESB HomeDocumentationBlogContact
Updated May 5, 2026
Tutorials/File & batch patterns/Invoice line posting

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.

Post the lines one at a time, in order Approved invoice a dozen lines lines left to post Post one line, count down more lines? go again Accounting ledger one entry per line, in order Line rejected stop here for a person to fix

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:

C#
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:

EXPRESSION
{{Message.MessageType}} == "ApprovedInvoice" && {{Variable.iLines}} > 0

Open Variables, one assignment per row (select the variable, then enter the expression):

VariableExpression
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:

VariableExpression
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.

API Listener iLines ← lines.Count BUS iLines > 0 SQL Caller, PostLine post line, iLines − 1 re-publish as ApprovedInvoice ERP ledgerone row per pass iLines == 0 null port, done

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.

EXPRESSION
{{Message.MessageType}} == "LinePostFailed"
One invoice loop at a time. The counter is per flow instance, but every pass shares the ApprovedInvoice type on a shared bus, two invoices looping concurrently can cross subscriptions. Run one loop of this type at a time; the pattern article explains the constraint and what it will take to lift it.

Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.

Before you start. Every artifact below, ports, message types, variables, lives inside one Application. Under Applications, create it first, Name and Namespace AcmeOrders, both code-safe, no dots or special characters, and select it so the steps build into it. This flow also assigns several Variables, so define them on the Application now: iLines, Total, idx, LineNumber, GlCode, and Amount.
About SqlCallerResult. The two-way SQL send port hands its response map a result under the standard type name SqlCallerResult. Its format is whatever the procedure’s final SELECT emits, not a fixed envelope: a plain SELECT or FOR JSON returns JSON, FOR XML returns XML. The posting procedure (Step 3) echoes the invoice back with FOR JSON, so the result is JSON and the response map’s source is JSON (Step 5). You do not create this type by hand; the SQL Caller provides it. If you prefer it listed, add a message type SqlCallerResult with format JSON.
1
Step One
Create the message types
The two types

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:

NameFormatPurpose
ApprovedInvoiceJSONthe approved supplier invoice, header plus lines, that circulates the loop
LinePostFailedJSONthe 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 on ApprovedInvoice
PromotionPath
InvoiceNumber$.invoiceNumber
Promotion on LinePostFailed

A failed invoice re-publishes payload intact, but promotions are type-qualified, so the exception type needs its own:

PromotionPath
InvoiceNumber$.invoiceNumber
A sample ApprovedInvoice

Three lines, the document the Step 10 test posts:

JSON
{
  "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 }
  ]
}

2
Step Two
Create the Authentications

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.

Ledger database, for the SQL Caller
SettingValue
NameLedgerDbSql
AdapterSQL Caller
DefinitionSQL Server Connection
Connection String (Database Config)the ledger database’s connection string, credentials included
Finance mailbox, for O365 Mail
SettingValue
NameO365OpsMail
AdapterO365 Mail Sender
DefinitionMicrosoft Graph
Tenant Id / Client Id / Client Secret (Graph AuthConfig)an app registration with Mail.Send granted

3
Step Three
Create the database objects

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.

The ledger tables and posting procedure
SQL
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;

4
Step Four
Create the counting component

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:

C#
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() }
        });
    }
}

5
Step Five
Create the response map
Create the map

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 settingValue
NameSqlCallerResultToApprovedInvoice
SourceSqlCallerResult (JSON), the SQL Caller’s result; the procedure ends in FOR JSON (Step 3), so the result is JSON
TargetApprovedInvoice, from Step 1, set as the response-side fallback in Step 7
The result

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:

JSON
{ "InvoiceJson": "{\"invoiceNumber\":\"PI-88421\", ... }" }
The SQL Caller returns what the SELECT asks for. This query ends in FOR JSON, so the result is JSON, and the map’s source is JSON to match. A plain SELECT would also return JSON; FOR XML would return XML. If the map’s source format disagrees with what the SELECT emits, the map matches nothing and the message republishes empty.
Author the XSLT

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:

XSLT 3.0
<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>

6
Step Six
Create the receive port
General

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.)

Select the component

In the port’s inbound pipeline, reference CountLines, from Step 4.


7
Step Seven
Create the looping post port
General

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:

EXPRESSION
{{Message.MessageType}} == "ApprovedInvoice" && {{Variable.iLines}} > 0
SettingValue
AdapterSQL Caller
WayTwo
Auth ConfigLedgerDbSql, from Step 2
Command TypeStoredProcedure
Command Textdbo.PostInvoiceLine
Open Variables

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):

VariableExpression
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")
Input Parameters

Add the Input Parameters (key/value), one row per parameter; the header’s invoice number binds the Promotion from Step 1:

ParameterValue
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
Close Variables

In Close Variables, decrement the counter:

VariableExpression
iLines{{Variable.iLines}} - 1
Response side

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.


8
Step Eight
Absorb the final pass

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.


9
Step Nine
Wire the failure path
Set the Exception Message Type

On PostLine, set Exception Message Type, on the port’s General step, to LinePostFailed, from Step 1.

Create the OpsAlert send port

Create the OpsAlert O365 Mail port for finance subscribing on {{Message.MessageType}} == "LinePostFailed":

SettingValue
AuthenticationO365OpsMail, from Step 2
Fromnoreply@acme.example
Toops@acme.example
SubjectInvoice {{Promoted.LinePostFailed.InvoiceNumber}} line post failed

10
Step Ten
Start the ports and test
Start in dependency order

Everything you configure is live the moment you save it, there is nothing to deploy. Start the send ports first, then the receive ports.

Turn on tracking

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

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.

Break it on purpose

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.

Turn bodies off in production. Enabled + Body is the most expensive tracking level, extra processing and database space, and it records every payload including successful runs. Once the flow is proven, set the ports to Only on Error instead: failures still capture the full body for diagnosis, while healthy runs are not recorded.