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 dashboard on a demo studio: the next class, who is expected, what was collected and what is outstanding. This is the screen the desk lives in.
The dashboard on a demo studio: the next class, who is expected, what was collected and what is outstanding. This is the screen the desk lives in.

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

Browser
Next.js server actions
Prisma tenant client
PostgreSQL
The studio filter is injected by a Prisma extension, so no query has to remember it. Work a user triggers takes the studio from their login, never from an id in the request. Background jobs get it from the cron or event that woke them and use the same scoped client.

Background path

Event or cron
Inngest function
PostgreSQL
EventLog
Anything that has to survive a retry runs as a durable job, not inside the request. Every mutation through the core services writes an append-only 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.

SCHEDULINGPEOPLEMONEYScheduleClassSessionprojects four weeks aheadtemplate, never edited for a one-offStudentEnrolment / MembershipAttendanceone realoccurrencedemand against one dancerInvoiceAllocationTransactionoldest firstmoney for one dancer, payer kept separately
The spine as rows. Everything downstream points at a ClassSession, and every unit of money is for one dancer.

The decisions

Recurring classes: materialise, or compute on read?

Problem

A class repeats weekly, but attendance, substitutions, cancellations and invoices all have to attach to one specific Tuesday, not to "the Tuesday class".

Obvious answer

Store the recurrence rule and expand it when the calendar is read.

Why it breaks

An expanded occurrence is a value, not a row, so nothing can hold a foreign key to it, and the moment a register needs to point at one specific Tuesday you are inventing identifiers and hoping they survive the next edit of the rule.

What I built

Two entities. A 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

The codebase calls this the Shadow and the Reality. It costs a merge on every read, because the week view reconciles real sessions against templates and a session always wins for its own date, but in exchange everything downstream has a stable identity to point at.

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.

The week calendar on a demo studio: real sessions and template ghosts merged into one view. A session always wins for its own date.
The week calendar on a demo studio: real sessions and template ghosts merged into one view. A session always wins for its own date.

Who is teaching: copy the answer, or resolve it?

Problem

A class has a default teacher, a weekday can have a different one, and a single session can have a substitute. All three are real and they change at different rates.

Obvious answer

Write the resolved teacher onto each session when it is created.

Why it breaks

Change the class teacher and you have to rewrite every future session, except the ones a human overrode on purpose, which you can no longer tell apart from the ones you wrote yourself.

What I built

Resolved at read time: session substitute → weekday teacher → class teacher. Nothing is copied down.

Why

A class-level change shows on every future session with no rewrite, and an override stays an override because it is the only thing ever stored. The cost is one batched lookup per read.

Money received and money accounted for

Problem

A parent pays at the desk, the books have to stay per dancer, per class, per month, and the amount handed over rarely matches the amount asked.

Obvious answer

Attach the payment to the invoice it settles.

Why it breaks

It holds until the first payment that covers one and a half invoices, or arrives in two parts, or arrives before the invoice exists, and then a half-paid invoice has nowhere to say how far it got.

What I built

Three entities. An 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

Money received and money accounted for are two facts, so they are two rows. A transaction is never edited: a mistake is voided, with who and why left on it, and the money is recorded again. An invoice can be corrected after issue, but the edit writes its before and after to the log in the same transaction, because in Lumo an invoice is an internal charge, not a tax document.

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.

Payments on a demo studio: collected, outstanding and billed are three different sums, a partial payment shows how far it got, and money with no invoice yet sits as credit.
Payments on a demo studio: collected, outstanding and billed are three different sums, a partial payment shows how far it got, and money with no invoice yet sits as credit.

Jobs that can safely run twice

Problem

Sessions are projected nightly, on Mondays and on every schedule change. Invoices come from membership renewals, the monthly cron and private-lesson bookings. Crons overlap deploys and Inngest retries failed steps. None of it may charge anyone twice.

Obvious answer

Check whether the row exists before writing it.

Why it breaks

Two workers can both pass that check before either writes, and the failure is an invoice a parent actually receives.

What I built

Idempotency keys a human can read, 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

The database decides, not the application. A retried job loses the race instead of writing twice, and when something looks wrong in production I can read the log instead of decoding it.

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.