Account statements from Slack, a web tool or one API call
Support teams ask for customer account statements all day, and the workflow behind them failed in silence. I rebuilt it as one tested engine with three ways in: plain-English Slack requests, a web tool and an API. Each returns a branded PDF and CSV.
- Request to PDF and CSV on a 68-transaction account
- 5 sRequest to PDF and CSV on a 68-transaction account
- Automated tests passing, none failing
- 109Automated tests passing, none failing
- Channels sharing one core: Slack, web tool, API
- 3Channels sharing one core: Slack, web tool, API
- From 1,500 worst-case rows: none overflowing, 1.6 MB
- 151 pagesFrom 1,500 worst-case rows: none overflowing, 1.6 MB
- Source on GitHub
- Try the live tool
It sits behind a passcode. Ask me and I'll send one.
Read this first
This is an independent demo built on synthetic data. It isn't an official Zepz or WorldRemit product, and it isn't affiliated with or endorsed by either company. Every customer, account and transaction shown is made up.
Watch it work
The problem it replaced
The original was an n8n workflow of 22 nodes. A Slack mention started it, an LLM pulled out the dates, SQL found the customer and their transactions, and code built the statement, the PDF and a CSV. It worked until it didn't, and when it didn't, nobody found out.
- No error branches. If any node crashed, the person who asked got nothing.
- SQL built by pasting user input and dates into query text.
- AI output cleaned with regular expressions and used as-is.
- Slack was the only way in, so no web tool or other system could use it.
- Statement pages held a fixed number of rows, a layout bug waiting to happen.
- No tests, with the logic spread across no-code nodes.
Three ways in, one engine
The business logic lives in a core that knows nothing about Slack, HTTP or Trigger.dev. Each channel is a thin adapter, so a fourth channel means one more adapter and no change to the core.
| Channel | Who uses it | How it works |
|---|---|---|
| Slack | Support and ops staff | Mention the bot with "statement for jane@example.com since March". It replies in the thread with a PDF and a CSV. |
| Web tool | Anyone with the passcode | Four steps: find the account, confirm it, pick a period, preview and download. The preview is the real PDF. |
| API | Other systems and notebooks | POST a user ID and optional dates with a bearer token. Get the PDF and CSV back as base64 JSON. |
The core has four steps: look up the account, resolve the period, build the statement (data, HTML and CSV), and render the PDF. Splitting build from render lets the web tool show results quickly and pay for the slow PDF step only when it's needed.
One problem, three stacks
I've built this system in three different stacks. The point isn't that one is best. The problem and the rules stay the same while the tools change.
Low-code
Retool for the interface and n8n for the workflow, with SQL and JavaScript doing the work. Quick to build and easy for non-engineers to adjust.
Hybrid
The same front end and workflow glue, with Trigger.dev taking the long-running steps that need retries and run history.
Full-code
A Next.js web tool and API on Vercel, Slack on Trigger.dev, and TypeScript over one tested core. This is the version with 109 tests.
How a Slack request works
- Slack sends a signed webhook. It's verified and de-duplicated, and the bot replies that it's on it.
- The email or user ID is pulled out locally and replaced with a placeholder.
- Only the remaining words, such as "statement for <identifier> since March", go to the model, which returns dates as schema-validated JSON.
- The account is found with bound SQL parameters and its transactions are fetched for the period.
- A headless Chrome service renders the statement HTML to a PDF.
- The PDF and CSV are uploaded in the thread, and the "on it" message is deleted.
Every request ends in a reply. It's delivered, rejected with a plain sentence, or, if the task itself crashes, answered with "Something went wrong" and a run reference the person can quote.
Keeping customer data away from the AI
The model only needs to know "since March". It never needs to know who the customer is. Emails and IDs are masked locally before any text leaves, and masked again at the network boundary as a second safeguard.
- SQL uses bound parameters, and every input is validated against a strict schema.
- Slack requests are signed and replay-protected, with workspace, channel and user allow-lists.
- The web tool uses passcode sessions. The cookie is a keyed hash and never contains the passcode.
- API tokens are compared in constant time, and several can be live at once with optional expiry dates.
- Unexpected errors return only a reference ID. Details stay in server logs.
- CSV cells that start with =, +, - or @ are neutralised so a spreadsheet won't run them.
One honest limit: masking covers emails and account IDs. If someone types a name or phone number into a Slack request, that text reaches the model as written.
Decisions worth explaining
| Decision | Why |
|---|---|
| Default to the full history | A request like "statement for jane@example.com" should just work. Asking for dates in the common case is friction. |
| Ask only when dates are unreadable | Silently widening an unreadable request would disclose more customer data than was asked for. |
| PDF and CSV every time | Finance users read the PDF and analyse the CSV. |
| The preview is the PDF | If the preview is the generated file, it can never disagree with the download, and downloading is instant. |
| Web tool on Vercel, Slack on Trigger.dev | Every click in the web tool is interactive, so a queue would be felt. Slack needs durable runs and retries more than it needs low latency. |
| Fail closed | A malformed passcode list makes login return errors instead of running unprotected. |
The footer that fell off the page
Real statements were losing the last line of the footer. The template split pages by a fixed row count, but rows aren't equal height. A long transaction ID or a wrapped amount can take three lines, so a page of tall rows overflowed.
I weighed three options: estimate the heights, let the browser paginate, or measure in the page. I chose to measure. A short script in the statement measures each row in the PDF browser, fills each page to its real height, then writes "Page X of Y". The fixed split stays as a fallback if the script doesn't run.
| Worst-case rows | Fixed rows per page | Measured rows |
|---|---|---|
| 60 | 5 pages, all overflowing | 7 pages, none overflowing |
| 1,500 | Not run | 151 pages, none overflowing, 1.6 MB |
Results
| Measure | Value |
|---|---|
| Input channels | 3 live (Slack, web tool, API), plus a webhook gateway that is built and tested but not hosted |
| Build window | 38 commits over two days, about 12 hours of commits |
| Automated tests | 109 passing, 0 failing |
| Time to a real statement | About 5 seconds end to end, on a 68-transaction account with the real renderer |
| Largest stress test | 1,500 transactions, 151 pages, 1.6 MB, no overflowing pages |
| Output | PDF and CSV (17 columns) for every successful request |
Limits and what's next
- Past about 1,500 transactions, a statement keeps the oldest days first and stops at a complete day. The tool says so and offers to continue.
- Web access is a shared passcode. That suits a demo, not real staff access, which needs per-user sign-in and an audit log.
- The only rate limiting is a short delay after a wrong passcode.
Next: host the webhook gateway and add a scheduled monthly-statement channel, per-user sign-in with an audit log, alerting on failure references, and a "continue" button in Slack for shortened statements.