Billing & Payments
Tables: ledger_entries, stripe_webhook_events
ledger_entries
Light double-entry-style accounting so balances are explainable. A student’s balance is simply:
SUM(debits) - SUM(credits) -- per for_student_user_id
Key columns:
for_student_user_id— money is tracked per student, not per parent. A parent’s balance is the sum over their children.accounting_typeENUM(debit,credit) — charges are debits; payments, scholarships, and downward adjustments are credits.entry_typeENUM(registration,lessons,recital_fee,installment_plan_fee,payment,scholarship_application,other) — what kind of line this is.amount_centsINT UNSIGNED— integer cents: exact math, and the same unit Stripe uses.semester_id— ties money to a term. It is what the parent portal groups a family’s balance by, and what “are they on schedule?” is judged against: half the term’s charges two weeks before it starts, the rest before the first lesson — or, on the installment plan, by the semester’s second installment due date (semesters without one fall back to the old half-way-lesson rule). Only entries that genuinely belong to no term — an old book fee, say — leave itNULL.ON DELETE SET NULL.stripe_checkout_session_id/stripe_payment_intent_id— set on entries recorded from Stripe.created_by_user_id—NULLfor webhook-recorded Stripe payments.
Idempotency
Recording money must survive being attempted twice — the Stripe webhook and the browser’s return trip can both try to record the same payment. Two unique keys guarantee only one wins:
- (
stripe_checkout_session_id,for_student_user_id) — for Checkout payments - (
stripe_payment_intent_id,for_student_user_id) — for embedded card-form payments, where there is a PaymentIntent but no Checkout Session
Charge posting is idempotent too: confirming a reservation posts its debits
at most once per student + semester + entry type
(Billing::postSemesterConfirmationCharges), un-confirming reverses them
(reverseSemesterConfirmationCharges), and the daily installment-fee sweep
never charges a fee that is already live on the term.
The policy: line-item confirmation first
No admin action charges a family silently. Confirming, un-confirming, or deleting a confirmed reservation shows a charge-review dialog itemizing the debits (or reversal credits) first, and the endpoints refuse the change without its acknowledgement; duration changes go through their own review page.
How entries are created
- Confirming a reservation posts the semester’s registration, lessons,
and recital-fee debits, priced from the
semestersrow. The installment plan fee posts only when the admin ticks “include installment fee” in the confirmation dialog. - The daily installment-fee sweep
(
www/bin/apply_installment_fees.php, run nightly by cron — seedocs/cron.md): from the second day of a semester on, confirmed students who still owe part of that term’s balance and don’t already carry the fee are charged it automatically. Idempotent; entries carry aNULLcreated_by_user_idlike webhook writes. Admins can preview (dry-run) and trigger it from Admin › Maintenance › Installment Fee Sweep. - Online payment — Stripe (Checkout for registration; Elements on the
parent Balance & Payments page). Recorded by
Billing::recordStripePaymentorrecordStripeIntentPayment. - Manual payment — an admin accepting a check or cash
(
Billing::recordManualPayment). - Scholarship — an admin-specified credit
(
Billing::applyScholarship). - Custom entry — an admin-specified debit or credit for anything else
(
Billing::addCustomEntry). - Duration change — the refund credit and new charge posted from the accounting review page.
- CSV import — opening balances when migrating an existing roster into the portal (semester wizard step 8); re-uploading the same file is a no-op (rows already on the ledger are skipped), though identical rows within one file are deliberately all imported.
stripe_webhook_events
A dedup ledger for incoming Stripe webhook deliveries: stripe_event_id is
unique, so an event id is processed at most once and webhook retries (and the
success-redirect fallback) are harmless. Also stores event_type, the raw
payload_json, and processed_at for debugging.
