Skip to content
Art2link ESB HomeDocumentationBlogContact
Updated May 5, 2026
Tutorials/Getting started/Daily sales report

Daily sales report

Acme’s managers want to start each day knowing how the business did yesterday. Before they are at their desks, they want one short email in their inbox: yesterday’s total sales and how many orders came in. So every morning, very early, the system looks back over the previous day’s orders, adds up the takings and the order count, writes them into a tidy email, and sends it to the management group. The risk to design against is the quiet one: an email that simply never turns up, which nobody notices until the numbers are missed in a meeting. A run that fails has to tell the operations team straight away.

So the goal is simple. Once a day, early, total up yesterday’s orders, write the figures into a readable email, and send it to the managers. One report a morning, and if something breaks, an alert to operations rather than silence.

A sales summary in every manager’s inbox Early morning before the day starts Total yesterday’s orders revenue and order count Write the report a readable email Managers’ inbox one report each morning if it fails, alert operations

Here is how Art2link ESB builds that. The trigger is a clock. A Scheduler receive port (a receive port is the entry point that brings work onto the bus, the shared message backbone every flow publishes to and reads from) named MorningReportClock uses a Daily Window so its tick fires once each morning at 06:00 in the company's time zone, carrying an empty payload. A two-way SQL Caller send port (a send port is the exit point that hands a message to an outside system, and SQL Caller is the adapter, the connector to a specific kind of system, that talks to a database) subscribes to that tick and calls dbo.DailySalesSummary, which aggregates revenue and order count for the previous day into a single row. A subscription is just the rule that decides which messages a port picks up. The SQL Caller returns that row to the port as its standard SqlCallerResult result; a map (a map reshapes a message from one layout into another) in the port’s inbound flow reshapes it into the canonical DailySalesSummary document and publishes it to the bus. An O365 Mail send port subscribes to that document and sends the email:

EXPRESSION
{{Config.PortName}} == "MorningReportClock"

The mail port subscribes to the summary. Subject and body are templated against the message, so the email reads as a report, not a data dump, the body pulls its figures straight out of the result:

EXPRESSION
{{Message.MessageType}} == "DailySalesSummary"
TEMPLATE
Sales report, {{Promoted.DailySalesSummary.ReportDate}}, {{Promoted.DailySalesSummary.Revenue}}
Scheduler daily 06:00 BUS SQL Caller (two-way) yesterday's aggregates Sales DB DailySalesSummary O365 Mail report to managers

When it fails. A report that silently does not arrive is the failure mode to design against, nobody notices a missing email until the numbers are needed. Set one shared Exception Message Type on both send ports, SalesReportFailed, and subscribe a second O365 Mail port, pointed at operations, not management, to it. A broken query at 06:00 becomes an alert at 06:01 instead of an awkward question at 09:00. The failed run stays in tracking for replay, which simply re-sends the morning report.

EXPRESSION
{{Message.MessageType}} == "SalesReportFailed"
Let the ops alert use a different mail port. If the report and its own failure alert share one send port, a mail outage takes both down together. A separate port, ideally a separate recipient list, keeps the alarm on its own wire.

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

Before you start. Create the Application AcmeOrders with its namespace under Applications and select it, so every artifact below lands in it. The report reads the dbo.Orders table the order intake flow fills.
About SqlCallerResult. The SQL Caller hands the 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. This flow ends the SELECT in FOR JSON (Step 3), so the map’s source is JSON (Step 4). You do not have to create this type by hand; it is provided by the SQL Caller. 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
DailySalesSummaryXMLthe canonical summary the aggregate port publishes (Step 6)
SalesReportFailedXMLthe shared exception type both send ports publish failures under (Step 8)

Promotions live on the message type, so add them while you are here. The report mail’s subject and body (Step 7) bind these values, and mail fields are plain strings: they bind {{…}} tokens but never evaluate a body path.

Promotions on DailySalesSummary
PromotionPath
ReportDate/Summary/ReportDate
Revenue/Summary/Revenue
OrderCount/Summary/OrderCount

2
Step Two
Create the Authentications

Three external parties, three 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.

Sales database, for the SQL Caller
SettingValue
NameSalesDb
AdapterSQL Caller
DefinitionSQL Server Connection
Connection String (Database Config)the sales database’s connection string, credentials included
Report mailbox, for O365 Mail
SettingValue
NameO365Reports
AdapterO365 Mail Sender
DefinitionMicrosoft Graph
Tenant Id / Client Id / Client Secret (Graph AuthConfig)an app registration with Mail.Send granted
Alert mailbox, for O365 Mail
SettingValue
NameO365Ops
AdapterO365 Mail Sender
DefinitionMicrosoft Graph
Tenant Id / Client Id / Client Secret (Graph AuthConfig)a second app registration with Mail.Send granted, for the operations mailbox

3
Step Three
Create the database objects

Create the reporting procedure in the sales database; the aggregate port (Step 6) calls it. One aggregate row for the previous day. The SELECT ends in FOR JSON PATH, WITHOUT_ARRAY_WRAPPER so the single row comes back as one JSON object, not an array; that JSON is what the map in Step 4 consumes.

The reporting procedure
SQL
CREATE OR ALTER PROCEDURE dbo.DailySalesSummary
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @day DATE = CAST(DATEADD(DAY, -1, SYSUTCDATETIME()) AS DATE);

    SELECT
        CONVERT(CHAR(10), @day, 23)                       AS ReportDate,
        CAST(ISNULL(SUM(OrderTotal), 0) AS DECIMAL(18,2))  AS Revenue,
        COUNT(*)                                           AS OrderCount
    FROM dbo.Orders
    WHERE CAST(ReceivedAt AS DATE) = @day
    FOR JSON PATH, WITHOUT_ARRAY_WRAPPER;
END;

4
Step Four
Build the map
Create the map

Under the application’s Maps, create the map the aggregate port’s inbound flow (Step 6) will select:

Map settingValue
NameResultToDailySummary
Source message typeSqlCallerResult (JSON), the SQL Caller’s result; this query ends in FOR JSON (Step 3), so the result is JSON
Target message typeDailySalesSummary, from Step 1
The sample SqlCallerResult

Because the procedure’s SELECT ends in FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, the single row comes back as one JSON object:

JSON
{ "ReportDate": "2026-06-04", "Revenue": 48230.00, "OrderCount": 312 }

and becomes the <Summary> document whose paths the Step 1 promotions read:

XML
<Summary>
  <ReportDate>2026-06-04</ReportDate>
  <Revenue>48230.00</Revenue>
  <OrderCount>312</OrderCount>
</Summary>
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, XML out: parse-json(.) turns the result text into a map, and the ?key operator reads each field:

XSLT 3.0
<xsl:stylesheet version="3.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="xml" indent="yes"/>
  <xsl:template match="/">
    <xsl:variable name="r" select="parse-json(.)"/>
    <Summary>
      <ReportDate><xsl:value-of select="$r?ReportDate"/></ReportDate>
      <Revenue><xsl:value-of select="$r?Revenue"/></Revenue>
      <OrderCount><xsl:value-of select="$r?OrderCount"/></OrderCount>
    </Summary>
  </xsl:template>
</xsl:stylesheet>

5
Step Five
Create the clock

Create a receive port MorningReportClock on the Scheduler: Repeat Every 1, Repeat Unit Days, Time Zone yours, Daily Window on with Daily Start Window 06:00:00 AM. Empty Payload.


6
Step Six
Create the aggregate port
General

Create a send port SalesAggregate on the SQL Caller, subscribing on {{Config.PortName}} == "MorningReportClock", the clock from Step 5. Everything it references already exists, so each field is a pure selection. (The dropdowns can also create types and maps inline; building the dependencies first keeps each dialog a selection.)

SettingValue
AdapterSQL Caller
WayTwo
Auth ConfigSalesDb, from Step 2
Command TypeStoredProcedure
Command Textdbo.DailySalesSummary, from Step 3
Input Parameters

The procedure takes no parameters, so Input Parameters stays empty.

Select the map

In the port’s inbound flow, select ResultToDailySummary, from Step 4, so the result row is published as DailySalesSummary.


7
Step Seven
Create the report mail port
General

Create an O365 Mail send port ReportMail subscribing on {{Message.MessageType}} == "DailySalesSummary":

SettingValue
AuthenticationO365Reports, from Step 2
Fromreports@acme.example
Tomanagement@acme.example
Content TypeHTML
Template the Subject and Body

Subject and Body are templated against the promotions from Step 1:

TEMPLATE
Sales report, {{Promoted.DailySalesSummary.ReportDate}}, {{Promoted.DailySalesSummary.Revenue}}

The Body is plain HTML carrying the same tokens, a modest table of the summary fields reads better in a morning inbox than a payload dump:

TEMPLATE
<h3>Daily sales report, {{Promoted.DailySalesSummary.ReportDate}}</h3>
<table>
  <tr><td>Revenue (EUR)</td><td>{{Promoted.DailySalesSummary.Revenue}}</td></tr>
  <tr><td>Orders</td><td>{{Promoted.DailySalesSummary.OrderCount}}</td></tr>
</table>

8
Step Eight
Wire the failure path
Set the Exception Message Type

On both send ports, SalesAggregate and ReportMail, set Exception Message Type to SalesReportFailed, from Step 1, so a failed run re-publishes under that type, payload intact.

Create the OpsAlert send port

Create a second O365 Mail port OpsAlert, different recipients and a different sending mailbox than the report itself, subscribing on {{Message.MessageType}} == "SalesReportFailed":

SettingValue
AuthenticationO365Ops, from Step 2
Fromnoreply@acme.example
Toops@acme.example
SubjectOperations alert, {{Message.MessageType}}

9
Step Nine
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, SalesAggregate, ReportMail, and OpsAlert, then the receive port MorningReportClock.

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.

Trigger the tick

To test without waiting for the 06:00 window, temporarily set MorningReportClock to repeat every 1 Minutes, check the email arrives formatted, then restore the schedule. Verify the run in tracking.

Break it on purpose

Break the query once, rename the procedure, and confirm operations gets SalesReportFailed at tick time; replaying the run should simply re-send the morning report.

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.