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.
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:
{{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:
{{Message.MessageType}} == "DailySalesSummary"
Sales report, {{Promoted.DailySalesSummary.ReportDate}}, {{Promoted.DailySalesSummary.Revenue}}
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.
{{Message.MessageType}} == "SalesReportFailed"
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 |
|---|---|---|
| DailySalesSummary | XML | the canonical summary the aggregate port publishes (Step 6) |
| SalesReportFailed | XML | the 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.
| Promotion | Path |
|---|---|
| ReportDate | /Summary/ReportDate |
| Revenue | /Summary/Revenue |
| OrderCount | /Summary/OrderCount |
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.
| Setting | Value |
|---|---|
| Name | SalesDb |
| Adapter | SQL Caller |
| Definition | SQL Server Connection |
| Connection String (Database Config) | the sales database’s connection string, credentials included |
| Setting | Value |
|---|---|
| Name | O365Reports |
| Adapter | O365 Mail Sender |
| Definition | Microsoft Graph |
| Tenant Id / Client Id / Client Secret (Graph AuthConfig) | an app registration with Mail.Send granted |
| Setting | Value |
|---|---|
| Name | O365Ops |
| Adapter | O365 Mail Sender |
| Definition | Microsoft Graph |
| Tenant Id / Client Id / Client Secret (Graph AuthConfig) | a second app registration with Mail.Send granted, for the operations mailbox |
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.
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;
Under the application’s Maps, create the map the aggregate port’s inbound flow (Step 6) will select:
| Map setting | Value |
|---|---|
| Name | ResultToDailySummary |
| Source message type | SqlCallerResult (JSON), the SQL Caller’s result; this query ends in FOR JSON (Step 3), so the result is JSON |
| Target message type | DailySalesSummary, from Step 1 |
Because the procedure’s SELECT ends in FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, the single row comes back as one JSON object:
{ "ReportDate": "2026-06-04", "Revenue": 48230.00, "OrderCount": 312 }and becomes the <Summary> document whose paths the Step 1 promotions read:
<Summary> <ReportDate>2026-06-04</ReportDate> <Revenue>48230.00</Revenue> <OrderCount>312</OrderCount> </Summary>
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:
<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>
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.
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.)
| Setting | Value |
|---|---|
| Adapter | SQL Caller |
| Way | Two |
| Auth Config | SalesDb, from Step 2 |
| Command Type | StoredProcedure |
| Command Text | dbo.DailySalesSummary, from Step 3 |
The procedure takes no parameters, so Input Parameters stays empty.
In the port’s inbound flow, select ResultToDailySummary, from Step 4, so the result row is published as DailySalesSummary.
Create an O365 Mail send port ReportMail subscribing on {{Message.MessageType}} == "DailySalesSummary":
| Setting | Value |
|---|---|
| Authentication | O365Reports, from Step 2 |
| From | reports@acme.example |
| To | management@acme.example |
| Content Type | HTML |
Subject and Body are templated against the promotions from Step 1:
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:
<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>
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 a second O365 Mail port OpsAlert, different recipients and a different sending mailbox than the report itself, subscribing on {{Message.MessageType}} == "SalesReportFailed":
| Setting | Value |
|---|---|
| Authentication | O365Ops, from Step 2 |
| From | noreply@acme.example |
| To | ops@acme.example |
| Subject | Operations alert, {{Message.MessageType}} |
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.
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.
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 the query once, rename the procedure, and confirm operations gets SalesReportFailed at tick time; replaying the run should simply re-send the morning report.