Music Teacher's Helper lost a 7-year customer to billing bugs — and the three questions a billing cron has to answer
A Capterra review and the cron-job design questions every studio billing tool has to answer: per-month idempotency, an audit row, studio-anchored timezones.
In December 2018, an anonymized piano teacher posted a Capterra review of Music Teacher's Helper after seven years on the platform. The sentence I keep coming back to is nine words long:
"Too many bugs - and when they affect your billing, it's no longer a useful or trustworthy tool."
— An anonymized Capterra review, 2018-12-12, piano teacher with 7+ years on Music Teacher's Helper
What lodges in my head about this review is the word trustworthy. That is not the word you use about a tool with the wrong icon on a button or a slow page load. It is the word you use about a colleague, a contractor, or — what makes it sting — a piece of infrastructure you've been resting your livelihood on for seven years and now can't rely on. The reviewer was writing about a category change: the software went from a tool to a liability.
The scene this review is naming is one I have heard, in some variant, from every studio owner I've talked to whose billing has misfired. The 1st of the month passes. A parent emails on a Saturday asking why their card was charged twice. The teacher opens the tool, looks at the screen in front of them, and cannot tell whether the run fired once or twice, on which day, for which student, with which amount. A refund goes out. An apology goes out. Some weeks later it happens again, with a different parent. The thing that broke isn't a single bug — it's the muscle memory the teacher had built around the tool, and seven years in, the muscle is no longer load-bearing.
I want to take the word "trustworthy" seriously, walk through what billing bugs in a music studio look like from inside the studio, and then talk about the class of design choice that produces — or prevents — this outcome. The Segnoly side of the answer lives in one file, lib/auto-invoices.ts, in a function called runMonthlyInvoiceJob().
What "bugs that affect your billing" actually means
A music studio's billing month has a particular shape. Lessons happen across thirty-odd days. On the 1st of the following month, the studio runs invoices against the lessons that happened, using the rate cards and policies that apply. Cards get charged or invoices get emailed. The teacher closes their laptop and starts the new month.
When that pipeline misfires, it does so in three structural ways, and each one breaks trust in a different muscle.
The run silently doesn't fire for some students. The cron job ran. Most invoices went out. But for three of your twenty students, nothing happened — no invoice, no charge, no email. You don't notice on the 1st because there's no error screen, just the absence of a row. You notice around the 14th when the bank balance is wrong, or never, because the parent pays the next month's invoice as if everything is fine. The money you taught for in March is gone.
The run double-fires and parents get charged twice. A deploy goes out on the morning of the 1st. The cron runs as usual. Something about the deploy makes it run again. Parents wake up to two charges. You can refund the money, but you cannot refund the moment a long-term parent learned that your software charged them twice and you had no idea until they told you. That is when "trustworthy" stops describing the tool.
The run fires but the invoices are wrong. Wrong amounts, wrong students, wrong date range — the cron didn't fail, it ran on the wrong window, or against a stale rate card, or against a copy of the student list that hadn't been updated for the kid who withdrew in February. Half a dozen invoices go out that don't match the lessons. You don't catch it until parents start asking why their bill jumped.
In all three, the harm is not the bug itself — bugs happen — but the gap between the failure and your ability to see it. The reviewer was using a tool for seven years and could not, on the 2nd of the month, sit down and confirm: yes, the billing job for April fired once, on this date, for these students, with these amounts, and here is the proof. Without that confirmation, every silent month is an act of faith. After enough silent failures, the faith runs out. That is the architecture of the word "trustworthy."
The class of design choice that produces this
Now the hedge. I do not know Music Teacher's Helper's internal architecture. I have never read a line of their code. I cannot claim what MTH did or didn't do.
What I can do is tell you what design choices in this class of code produce the symptom the reviewer describes, because I have built a billing cron job and I know what it has to answer.
A monthly billing job is, at its core, three questions and three answers. If you skip any of the answers, the symptom is some version of what the reviewer wrote.
Question 1: how do you make sure the job fires exactly once per studio per month? Cron runners reboot. Deploy scripts re-run startup hooks. A retry-on-failure policy decides the job timed out and kicks it again from a clean state. None of these events are exotic. The answer is an idempotency key — a unique row the job tries to insert before it does any work, with the (studio, month) pair as the primary key. Insert fails on duplicate, the second execution returns "already ran" and exits. The key is not a flag, not an application-level check — it is a primary-key constraint, enforced by the database, that an in-memory bug cannot bypass.
Question 2: did the run fire? Specifically: after the fact, can you tell whether a given month's run completed, partially completed, or never fired at all? This is the question the reviewer needed an answer to and couldn't find. The answer is an audit log row, written at run time, that records who or what fired the job, when, against what window, and what it produced. Not a log line that disappears at the next deploy. A row in an audit table that survives, queryable, three months later when a parent asks why March looked the way it did. Without that row, every "did the job run?" question is answered by inference from side effects — the exact failure mode the reviewer is describing.
Question 3: whose calendar are you billing against? Most cron systems run in UTC. Most music studios do not. A studio in Sydney teaching a lesson at 10pm local time on April 30th is teaching, from UTC's perspective, in May. A job that anchors its "previous month" window to UTC will either bill April lessons in March, or miss them entirely, or include some of April and exclude others, with the cutoff falling on the international date line. The answer is to anchor the window to the studio's own timezone. The job has to ask the teacher's calendar what April was, not the server's.
The symptom the reviewer described — a billing tool you cannot trust because you cannot see whether it did what it said it did — is exactly the symptom that the class of architecture without those three answers produces. The fix is not at the bug level. The fix is at the design level, and the design is decided once, in the cron job's data model.
What lib/auto-invoices.ts:runMonthlyInvoiceJob() actually does
The function is 43 lines long. It lives in lib/auto-invoices.ts in the Segnoly repo. It answers all three of the questions above, in the same order.
Idempotency. The function constructs a string called runKey, of the form auto_invoices:2026-04 for the scheduled monthly run against April. That key is the primary key of the job_runs table — declared in db/schema.ts as key: text("key").primaryKey(). The function tries to insert a row with that key before it does any invoicing work. If a row with that key already exists, SQLite refuses the insert, the function catches the error, and returns { ran: false, reason: "already_ran" } without touching invoices. There is no in-memory flag, no Redis lock that can go stale, no application-level check a buggy deploy can skip. The database itself is the guarantee. A second execution for the same (studio, month) cannot proceed.
A manual "Run now" button (opts.force = true) generates a distinct key — auto_invoices:2026-04:manual:2026-05-02T14:31:00Z — so a teacher who fixed an attendance mistake can re-run once, deliberately, without the key blocking them.
Audit log. After the invoices are generated, the function calls audit() with a structured row: actor (system or the user who clicked the manual button), action: "create", entity: "invoice", and a diff object that records the event type, the month label, the count of invoices created, and the from/to ISO timestamps that defined the window. Three months from now, a teacher who wants to know "did the April run fire? how many invoices? against what window?" can answer it from one query against the audit table — not from inference, not from logs that may have rotated.
Timezone. The window for "previous month" is computed in a helper called previousMonthWindow. It takes the current UTC time, projects it into the studio's configured timezone, reads what month it is there, subtracts one, then anchors the from and to instants to midnight in the studio's timezone. A Sydney studio's April window starts at midnight April 1 Sydney time and ends at 23:59:59 April 30 Sydney time — even though, in UTC, those endpoints are eleven hours offset from where the cron runner thinks the day boundaries are. The teacher's calendar wins.
Three questions. Three answers. The function is 43 lines.
The audit-row test
If you're evaluating a billing tool — Segnoly, MTH, anything else — here's the test I'd run before signing up, and especially before migrating a studio with existing history onto it.
Ask the vendor for the database row that proves April's billing run fired. Not the dashboard. Not a screenshot of the invoice list. The row. They will call it different things — an audit-table entry, a run-log row, a job-runs record, an event-log line. The name doesn't matter; the shape does. You're looking for a row keyed on something like (studio_id, month) that records when the job fired, against what window, and what it produced. If the vendor can show you that row, the tool has the foundation you need. If what they show you is a log line that rotates after thirty days, or "we can check the logs on our end" — that is not the answer. That is the failure mode the Capterra reviewer was naming.
I am not arguing Segnoly is bug-free. I have shipped bugs. I will ship more. What I am arguing is that some classes of bug should not be possible in the first place, and a monthly billing job that fires twice or fails silently is one of them. The cost to prevent it is one primary-key column and one audit row per run. The cost of not preventing it is the seven-year customer the Capterra reviewer was, the one who finally wrote "no longer trustworthy" and walked away.
A vendor who can show you the row will not silently skip a run. A vendor who cannot is one deploy away from the review this post opens with.
Building Segnoly — billing and automation for independent music teachers. The waitlist is open for the first cohort.