Skip to content
In production · since Aug 2026

A commission engine
nobody has to double-check.

Investment advisory firms calculate advisor payouts in spreadsheets. One broken formula means somebody gets paid less than they earned, and nobody finds out until the complaint arrives. splitOS replaces the spreadsheet with an isolated, test-covered calculation engine.

My role
Product, architecture and development
Timeline
Since 2026 — in production
Model
Multi-tenant B2B SaaS
Stack
Supabase · Cloudflare · no-bundler JS
The problem

The spreadsheet works until the day it doesn't

An advisor's payout isn't a multiplication. It involves a different rate for self-originated versus transferred portfolios, tax withholding, a desk cost that may apply to the margin or to net revenue, a guaranteed minimum that sometimes adds to and sometimes replaces the variable portion, and a leadership bonus on the team's production.

Each of those rules becomes another spreadsheet column. A few months in, nobody at the firm can explain why cell AC47 holds that formula — and nobody can audit a number they can't reproduce.

No audit trail

No record of which rule applied when the payment went out.

Payout mismatch

Calculated and paid amounts drift apart, and reconciliation is manual.

Advisor in the dark

They only learn what they earned once the money lands.

Hours of rework

Every month-end restarts the same manual process from scratch.

Architecture

Static at the edge, authority in the database

There is no application server. The front end is static and served from Cloudflare's edge; authority over who sees what lives in Postgres policies. Which means there is no endpoint to forget to protect — permission isn't in route code, it's a row-level policy in the database.

splitOS architecture in three layers The browser runs the interface and the calculation engine. Cloudflare Pages serves the static files and applies security headers. Supabase holds authentication, a Postgres database with row-level permissions, and receipt storage. Resend sends the transactional email. BROWSER Interface 22 ES modules Calculation engine pure · 39 asserts Spreadsheet import XLSX Single store load/save/sub EDGE — CLOUDFLARE PAGES Static files Per-route CSP HSTS Preview per branch Deploy on push custom domain SUPABASE Auth roles · reset Postgres row-level RLS Storage signed URLs TRANSACTIONAL Resend SMTP · invites Edge Fn lead capture splitOS architecture in three layers The browser runs the interface and the calculation engine. Cloudflare Pages serves the static files and applies security headers. Supabase holds authentication, a Postgres database with row-level permissions, and receipt storage. Resend sends the transactional email. BROWSER Interface 22 ES modules Calculation engine pure · 39 asserts Import XLSX spreadsheet Single store load/save/sub EDGE — CLOUDFLARE PAGES Static files Per-route CSP HSTS Preview per branch Deploy on push custom domain SUPABASE Auth roles · reset Postgres row-level RLS Storage signed URLs TRANSACTIONAL Resend SMTP · invites Edge Fn lead capture
The calculation engine runs in the browser and knows nothing about the DOM — the same function that renders the screen runs under Node during tests. Each advisor's view is published by the administrator and filtered by row-level policy: they don't receive their colleagues' data and then hide it in the UI, they never receive it at all.
Decision records

Five choices and what each one cost

Every architectural decision has a consequence. Recording only the upside is marketing, not engineering.

01

An isolated calculation engine, tested before anything else

Context
The payout math was duplicated across four places in the codebase. Fixing one rule meant remembering the other three, and any drift between them produced two different numbers for the same advisor.
Decision
Pull all the math into a module that knows nothing about the DOM: data in, result out. Runs in the browser and under Node unchanged. I covered the cases that change the final figure with 39 assertions — tax on the guaranteed minimum, desk-cost base, bonus on the team's variable portion, firm-side retention.
Consequence
A new rule becomes a test before it becomes a screen. Since the number ends up in someone's pocket, this was the first thing tested, not the last.
Accepted cost
Keeping the engine away from the DOM forces state to be passed explicitly. It's more verbose than reading straight off the form.
Plain JavaScriptnode:testGitHub Actions
02

Row-level permissions, with functions to break recursion

Context
Three roles with different views: the firm owner sees everything, a co-administrator partner sees the same, an advisor sees only their own compensation. Filtering that on the front end would mean shipping all the data and hiding the rest — which isn't filtering at all.
Decision
Row-level policies in Postgres, with membership verified against the email signed into the token. Policies that needed to query one another became functions with privileges fixed at definition time, which breaks the recursion that was stalling evaluation.
Consequence
A UI bug can't turn into a data leak. The database refuses the row before any screen exists.
Accepted cost
Debugging a policy is more tedious than debugging an if. A policy mistake shows up as "no results", not as an error message.
Postgres RLSJWTSupabase Auth
03

Receipts behind signed, short-lived links

Context
PDF payment receipts started out stored as text in the browser. They blew past the local storage quota and didn't exist on any other device.
Decision
Binaries in the bucket, organised by firm and by advisor, with metadata in the database. Downloads generate a signed URL valid for a few minutes, and the bucket policy guarantees each person can only sign for their own file.
Consequence
Receipts reachable from any device, with no permanent link circulating over email.
Accepted cost
Expired links generate support questions. It needed a clear in-product message explaining why.
Supabase StorageSigned URLsBucket policies
04

A production incident that became a written rule

Context
On 29 July 2026 the landing page's lead form stopped submitting. The symptom was generic: Failed to fetch. Nothing had changed in the form itself.
Root cause
Two blocks in the headers file matched the same URL, and both declared a content security policy. When a browser receives two policies, it applies the intersection of the two — the stricter one wins. The tighter global policy was cancelling out the external origins allowed by the specific one.
Decision
A golden rule recorded in the file itself: a path never receives a content policy from more than one block. The wildcard now declares only headers that can't intersect. Every route that needs a policy gets its own exclusive block.
Consequence
A new route requires a new block, and that's written in the file the next person will open. It also recorded how to investigate without locking yourself out: use the mode that only reports violations instead of blocking them.
CSPCloudflare PagesPostmortem
05

From one file to 22 modules, without a bundler

Context
The system started as a single file. Styles with five layers of overrides and over a hundred forced declarations, eight loose storage keys, and the calculation firing on every keystroke.
Decision
Split into modules by responsibility, consolidate state into a single store with load, save and subscribe, strip the style override layers, and reduce the calculation to one run per typing pause, reusing the previous result when the input hasn't changed.
Consequence
Continuous integration checks the syntax of every module and runs the engine tests on every proposed change. No build step: what's in the repository is what runs in production.
Recorded debt
Event handlers declared inline in the HTML are still there. While they exist, the content policy has to allow inline script — which is precisely the part that blocks injection. It's on the board, with the strong version of the policy already written and commented out, waiting.
ES ModulesNo bundlerCI
Current state

Where the product stands

22
ES modules in production
39
assertions on the calculation engine
3
roles isolated at the database level
0
build steps in the deploy

The MVP shipped in August 2026 and the product opened to the market in September. The roadmap after that covers payment-provider integrations and expansion into other variable-compensation sectors — insurance, consumer credit and group purchasing all share the same commission problem.

What isn't here: the database schema, the policies in detail, project identifiers and commercial terms. A product running with client money inside it doesn't publish its map.

If this resembles your case

What carries over

This set of pieces isn't specific to commissions. It's the same skeleton behind any operation where a calculated number has to be defensible.

  • Business rules living in a spreadsheet today, turned into a tested engine with an audit trail
  • Roles with different views, resolved in the database rather than in the UI
  • Sensitive documents behind temporary, traceable access
  • Continuous delivery with no build step and a preview per branch
  • Migrating data that already lives in the browser to the cloud, losing nothing
Next step

Got a calculation nobody can audit?

Thirty minutes is usually enough for me to tell you whether it's my kind of problem — and if it isn't, who to talk to instead.

← See the other projects