White-label platform generating regulated investment factsheets for UK wealth managers. One engine serves 18 firms, and adding a client is configuration rather than a fork. Lead developer and top contributor on both repositories.
Key information
Started
Sep 2024
Status
Production
Role
Lead developer
Contribution
1,651 commits
Top holdings
Rough share of the work; read it as a sketch.
NestJS / TypeScript
40%
BullMQ / Redis
20%
D3 / node-canvas
20%
PostgreSQL / S3
20%
18
firms
91
pdf templates
76
brand fonts
19
excel tab modules
~50k
loc, pdf engine
Problem
Eighteen firms, one document, every month
Every wealth manager publishes a factsheet for each model portfolio, usually monthly. It is a regulated document. Performance figures, holdings, a risk indicator, the firm's own typography and colours, and a page of disclaimers that legal has signed off on. A wrong number in it is a compliance incident with a paper trail.
Done by hand it does not scale past a handful of portfolios. The usual arrangement is a designer rebuilding the same document every month from a spreadsheet someone emailed over, and slow here matters: the data is only current for a few days. Mabel Insights sells the alternative. A firm uploads its data and gets back finished factsheets in its own brand.
I joined in September 2024 and took over the platform that does the rendering. I am the top contributor on both repositories: 1,651 commits across branches on the backend and 1,118 on the frontend, which is about half the backend commits on the main line and just under 60% of the frontend.
Constraints
What the shape of the problem forced
Eighteen brands, and not as a colour swap. Different grids, different fonts (76 of them bundled), different table conventions, different disclaimer text, different ideas about what belongs on page one.
The output is a PDF. There is no browser doing layout, no web fonts, no flexbox. Every position resolves on the server before anything is drawn.
The data arrives as Excel, from people. It is late, it is hand-edited, and it is sometimes wrong in ways that only a human reading the right cell would catch.
Publishing is bursty. A firm pushes 40 portfolios at once and then nothing for a week, so the system has to survive the spike without holding capacity for it.
Small team. Whatever I build, someone else has to be able to onboard a client into it without reading my mind.
Decision 01
One engine, 91 templates
The white-labelling is a strategy pattern. A client is a configuration object and a template class. The configuration carries brand tokens, fonts, disclaimer text and which template each document type resolves to. The template class owns the layout for that brand. Nothing about a client lives in a branch.
There are 91 template generator classes across those 18 brands, keyed by an 85 member template enum, with 76 brand fonts bundled into the engine. The tenant is resolved from the request origin, so the same endpoint serves every firm and the code never has to ask which one it is dealing with.
Decision 02
The pipeline
A publish run is one parent job and N children. BullMQ's FlowProducer creates a ZIP job that is blocked until every child PDF job underneath it has completed, and the children run against a global concurrency cap so 40 portfolios cannot take out the renderer. The cap is one, and that is deliberate: a bulk run queues behind live traffic instead of starving it, and throughput comes from the pipeline around the render, not from rendering harder. Press run.
The first publish of the month. Twelve portfolios, one worker, one render that fails and comes back.
children 0/12in flight 0/10.0s
validate · 19 tabs
flow ▸ fan-out
render ▸ serial by design
zip ▸ s3
signed url ▸ email
rendercache hit, skippedfailedbackoffretryparent zip
press run
A recreation of the real flow, not a live system. Timings are compressed and every scenario runs on the same time axis, so a shorter run is shorter on screen. Everything it shows (the concurrency cap, the content-aware cache check, retry with backoff, deterministic job IDs, stall recovery, the parent job that waits on every child, the streaming ZIP) is how the production pipeline works.
Here are the parts worth pointing at; each button above runs one of them. Job IDs are deterministic, derived from the tenant, the portfolio and the content version, so a double-clicked publish button does not render the same document twice, and a sweep removes duplicates that slip through anyway. Before a child renders, it compares the content's publishedAt against the LastModified of the PDF already in S3, and skips the work entirely if the stored copy is newer. When a render does fail, the stale S3 object is deleted rather than left in place, because a half-correct factsheet that still downloads is worse than a missing one.
The ZIP never lands on disk. Archiver writes into a PassThrough stream that feeds an S3 multipart upload, so memory stays flat whether the run is 3 documents or 40. The user gets a presigned URL by email through Mailgun.
Decision 03
The Excel importer shows you only what changes
Nineteen tab modules, roughly 4,500 lines, one per category of data. Each one validates at cell level and reports failures by tab, row and column, because "import failed" is useless to someone holding a 19 tab workbook.
The part I am happiest with is the diff. Rows in these sheets get reordered constantly by whoever last touched them, and a naive comparison calls that a change to everything. The importer fingerprints each row structurally, so order carries no meaning, then produces a diff workbook showing only the cells that will actually change if the import proceeds. An operator can read that before committing to it.
Decision 04
Charts that survive being printed
Charts are drawn with D3 on the server, as SVG, then rasterised with node-canvas and placed into the pdfmake document. No headless browser in the path.
D3's default tick selection produced axis labels a compliance officer would query, so the tick algorithm is hand-written: it normalises the range to its order of magnitude and picks steps from a 1, 2, 5, 10 pattern, and a separate pass chooses date labels that land at an even stride across the axis.
The decumulation fan chart is the odd one. The Monte-Carlo projection behind it comes from a partner service reached over mutual TLS with a PKCS#12 client certificate, and the result is drawn onto node-canvas directly, because the shape (hundreds of overlapping percentile bands) is not something a chart library renders well at print resolution.
Results
What it does now
Before the platform, a firm's monthly pack was a designer rebuilding every document by hand from a spreadsheet that arrived by email, usually days after the data stopped being current. Now the firm uploads the workbook and the pack comes back in its own brand, each factsheet rendering in one to three seconds, with 18 firms live on the same engine.
18 firms in production, served by one engine and one deployment.
91 template generator classes, an 85 member template enum, 76 bundled brand fonts, roughly 49,600 lines in the PDF engine.
A financial metrics layer covering 6 metric types and 51 subtypes: cumulative returns, benchmark alignment, three year volatility.
Cognito JWKS authentication, role based access control, and a cross-tenant isolation guard, with draft and live publishing kept in separate per-tenant S3 namespaces.
Onboarding a new client is a configuration file and a template class.
The team
Working with others
Three of us touch the two repositories: me, a junior developer, and my lead. I started the platform from scratch with my lead in September 2024 and have owned the rendering engine and the queue behind it since. Nothing I write reaches the main line without my lead reviewing it.
Honestly
What I would do differently
Automated test coverage on this platform is thin. It grew fast under delivery pressure and testing PDF output is genuinely awkward, but both are explanations; neither is a defence. It is the thing I would fix first with more time.
I would not start by chasing a coverage number. I would write characterization tests against the formatters and the metrics layer first, since those are pure functions with real financial consequences and they are where a silent wrong number would come from. Then golden-file tests on a handful of representative templates: render, hash, fail loudly when the bytes move. That catches the class of bug that actually scares me, which is a layout change quietly shifting a number into the wrong column.