September 2026 · 13 min read
Lumo: a studio management platform
Lumo is the software my wife's dance studio runs on, and two more studios in Romania and Ukraine with it, since the end of June 2026. Scheduling, enrolment, attendance, invoicing and the ledger behind all of it. I built it alone and I run it alone, which means every shortcut I took I also have to operate, and the assumptions in the code meet a front desk every day. This is the shape of it and the decisions.
- Role
- Founder and sole engineer: architecture, build, operations
- Stack
- Next.js 16 · TypeScript · PostgreSQL · Prisma · Inngest · Stripe (wired) · next-intl · Vercel
- Status
- Production since late June 2026. Three live studios in Romania and Ukraine
- Source
- Proprietary. This case study covers architecture, decisions and outcomes rather than code

The problem
I did not set out to build "dance studio software". I set out to replace the spreadsheet, the WhatsApp thread and the paper register that a studio uses to get through one week, and the first thing the desk taught me is that a studio is not a list of students. A student is not an enrolment, because the same child is in ballet on Tuesday and contemporary on Thursday at different prices with different teachers. A student is not the payer, because most of them are children and one parent pays for two or three of them, which is why the 460 accounts in the system split into 299 students, 128 parents and 32 staff. A payment is not an invoice, because cash arrives late, in parts, or for the wrong month. And attendance is not what gets billed: in a membership studio a check-in consumes a credit that was already paid for, in a fixed-price class it is a register entry and the money moves monthly, and only a private lesson is invoiced when it is booked, and cancelled if the dancer does not come.
So the platform is built around one spine, a student, their enrolment or membership, the sessions it entitles them to and the money it generates, and most of the engineering below is keeping those links consistent while people at a desk edit every part of them.
The system
Request path
Background path
EventLog row in the same transaction as the change.One Next.js app on Vercel, one PostgreSQL on Neon in the same region, and the background jobs are Inngest functions inside the same deploy. The event that wakes a job is sent after the commit, and no job trusts it: the projector, the billing crons and the reminder scan all re-derive their work from the database on a timer, so the event makes the work happen now and the schedule makes sure it happens at all. That is the whole reason there is no broker and no outbox at this scale.
The decisions
Recurring classes: materialise, or compute on read?
Problem
Obvious answer
Why it breaks
What I built
Schedule is the template and is never changed for a one-off. A ClassSession is one real occurrence, projected four weeks ahead by a job, and it is the row everything attaches to. Past the window the calendar draws "ghosts" from the template, so the year ahead looks full without being stored.Why
951 sessions have been materialised so far, 355 of them in the current window, and the projector writes between 9 and 30 a day and about 105 on a Monday when the window rolls forward. Small numbers on purpose: the window keeps the stored set proportional to next month, not to history.

Who is teaching: copy the answer, or resolve it?
Problem
Obvious answer
Why it breaks
What I built
session substitute → weekday teacher → class teacher. Nothing is copied down.Why
Money received and money accounted for
Problem
Obvious answer
Why it breaks
What I built
Invoice is a demand against one dancer. A Transaction is money credited to one dancer, with the payer kept as a separate fact on the row. An Allocation links some of that money to some of that demand, oldest invoice first. Available credit is transactions minus allocations, computed on read, never stored. Amounts are integer minor units in the studio's currency.Why
1,038 payments, 1,024 allocations, 1,181 invoices, all of it card or cash recorded at the desk; Stripe is wired and has not taken a payment yet. Four invoices were paid in instalments. Sixty-two were voided and recorded again.

Jobs that can safely run twice
Problem
Obvious answer
Why it breaks
What I built
session-projection:{scheduleId}:{date} and invoice-gen:{sessionId}:{enrollmentId}, enforced by a unique constraint on EventLog, one projector run per studio at a time, and underneath that plain uniqueness on the rows themselves: one session per schedule and date, one transaction per key.Why
About 9,100 job runs in seven weeks, 362 invoices created by jobs across nine run days, 33 of them membership renewals, and no duplicate invoice has reached a parent, which is context rather than proof; the proof is the constraint.
The first ledger, and what broke
The first ledger did not survive its second family. That was the prototype I ran through 2025, before any studio was live, and it was simple on purpose but simple in the wrong places: every invoice belonged to a class, money was a float, and a month was one string that had to mean both "collected in" and "settles". The case that broke it was the most ordinary one at a desk, a parent with two children, one of them discounted, cash for one and a bank transfer for the other, and my first fix was to bolt a group-payment layer on top, extra columns on the payment row with its own routes, a hook and two dialogs. I deleted it six days later.
The obvious reading is that I had missed a feature. The truer one is that I had missed a fact: the payer and the dancer are two different people, so they had to be two different columns on the same row, and once that was true the parent needed no table and no balance of their own. The rebuild that went live has that ledger, and its money columns were converted to integer minor units five days before the first studio came on, so no real money ever touched a float.
Then production used it in a way I had not planned for. There are zero payments split across invoices. What the desk did instead was pay four invoices in instalments and void sixty-two, because the demand itself was wrong, and the correction path I had built as a safety valve turned out to be the daily path. If I had looked at the void count in week two instead of month two, that would have been the first screen I polished.
The bill
In July, with two studios live, Lumo went past its background-job quota, 50,880 executions against 50,000, and the money showed up on the database bill, not the job bill. Inngest charges per step, not per job, so a four-step job costs five executions, and I had not priced that. Neon scales its compute to zero when nothing is querying it, and a reminder poll that ran every five minutes meant nothing ever stopped querying it, so the database stayed awake all month for a poll that mostly found nothing to do. Two jobs were 98 percent of the total: that poll, and a notification dispatcher that ran once per recipient.
The five-minute poll had been a decision, not an oversight. I chose it so a reminder would land within five minutes of its time, and I called the precision free because each run was cheap, and the bill said otherwise. The fix went out on 2026-07-30. The poll became hourly, per studio, and it now emits each reminder as a future-dated event that Inngest fires at the exact minute, so the coarser poll lost no precision, and I kept polling instead of going event-driven because one code path creates sessions without an event and an event-driven scheduler would have missed them silently. The dispatcher now takes one event with the whole recipient list, so a class of sixteen costs one run. The same commit had to fix reminders that were actually being lost, because the window was exactly one interval wide and any drift dropped them, and cancellations that went to staff only, so dancers had turned up to cancelled classes.
102% → under 25%
Share of the job quota used, July to August, with a third studio added
Inngest, 2026-09-17
$45.68 → $10.01
Database bill, July to August
Neon invoices, 2026-09-02
The lesson is specific to this workload: cost tracked the clock, not the work, and it did so twice, once in job executions and once in database hours. A five-minute poll and a per-person fan-out are fine patterns. They were wrong here.
What I did not build
A separate worker service. The jobs are Inngest functions in the same codebase and the same deploy, because at 9,100 runs in seven weeks a second deployable would add a version-skew problem and a second place to configure the tenant client and give me nothing the durable-function model does not.
An event bus or an outbox. No job trusts the event, every job re-derives its work from the database on a timer, and the idempotency keys make the second run harmless. That covers it at this scale.
Denormalised copies. Teachers are resolved at read time, credit and amounts owed are sums. Both cost a query and save the whole class of bugs where two copies of a fact disagree.
Multi-region anything. Compute and database sit in one region. That was not always true: before launch the functions ran in the provider's default US region while the database sat in Europe, every query crossed the Atlantic at about 100 ms, and pinning compute next to the data fixed it. The studios are in Europe and there is no availability or residency requirement, so a distant customer would force a re-measurement, not a deployment.
What holds the rules when I am not looking
A rule that depends on me noticing is a rule that will be broken on a tired evening, so the checks that matter are the ones I cannot skip: typecheck, lint and the unit suite on every commit and push, the tenant rule checked by a lint and by a runtime assertion, and the billing suites run under three time zones because a studio's month boundary is local.
The part that actually catches things is the QA run on staging before a production deploy. The scenarios are written first, each named after something that broke at a real studio, so a tester can tell a cosmetic difference from a repeat of the bug. Five agents run the pass, one per persona from admin to student, each signed in as a real session and each on its own Neon branch so their writes cannot collide, and a sixth agent collates the findings with a repro for every failure before I decide to ship. The 2026-09-13 pass was 59 scenarios and 6 failures, and the re-test after the fix found that the fix had moved a cycle a day earlier. One root cause, cycle boundaries stored at studio-local midnight and read in UTC, and that is where the three-time-zone run came from.
Migrations get the same treatment: I branch production on Neon and run the real migrate against the branch. For v1.5.0 that rehearsal corrected my own review, which said a column had never been written when 734 of 1,040 rows already carried a value. The obvious version is that the rehearsal checks the migration. The truer one is that it checks me.
Production today
Usage
3
Live studios, Romania and Ukraine, three languages, two currencies
prod DB, 2026-09-17
266
Active students, 270 active enrolments
prod DB, 2026-09-17
~460
User accounts (299 students, 128 parents, 32 staff)
prod DB, 2026-09-17
951
Class sessions materialised, 355 in the four-week window
prod DB, 2026-09-17
Operational volume
1,747
Attendance records, 1,328 in the last 30 days
prod DB, 2026-09-17
1,181
Invoices issued, 997 in the last 30 days
prod DB, 2026-09-17
1,038
Payments recorded, 913 in the last 30 days
prod DB, 2026-09-17
10,957
Event-log rows, 68% written by jobs
prod DB, 2026-09-17
~9,100
Background job runs, seven weeks
Inngest usage, 2026-09-17
5,247
Notifications sent, 90 days
prod DB, 2026-09-17
About a thousand invoices and nine hundred payments a month across three studios is the real operating rate, and it is the rate the ledger has to stay correct at.
Engineering
364 → 185 ms
Median time to mark attendance, before and after the latency work of 2026-09-10; p95 696 → 612 ms
prod trace table, 2026-09-17
$45.68 → $10.01
Monthly database bill, July to August, with a third studio added
Neon invoices, 2026-09-02
Deploy counts, test counts and build times are left out on purpose. They say the pipeline runs, not that the system behaves. One number that did not go the way I wanted: latency work on 2026-09-10 halved the median for saving a schedule, 378 ms to 136 ms, while the p95 got worse, 571 ms to 866 ms, on 13 calls before and 67 after, and the sample is too small to know if the tail is real, so it stays on the list and not in the win column.
What changes at ten studios
Studio count is the wrong unit, because the work grows on four axes. Per studio the crons are one run each and stay flat. Per session, projection and reminders follow how many classes a studio runs a week, which varies more between studios than the count does. Per student, attendance and billing follow enrolments, and that is the axis the ledger has to stay correct on. Per recipient, notifications were the only axis that grew faster than linear before the batching, and now they are one run per class. At today's mix ten studios is about three times today's volume on every axis and nothing above changes. What would change the design is geography and code volume: a distant customer would make me re-measure latency, availability and residency and the answer depends on which one fails, and every new query on a model without a studio column is one more place the tenant lint has to catch.
What I would do differently
The trace table from the first deploy. It is the most useful operational thing in the system and it went in after launch, so the first two months are unmeasured.
The correction flow first. The desk voids and recharges far more than it pays in instalments, and I would have designed that screen before the allocation screen.
Neither was found by an outage. Both were found by writing this down, and that is how it works for me: I build first, and I only write about a thing once it is built and I am happy with it.
The product is at lumo.dance. The source is private.