Manage billing from the control panel

A Billing tab (tiers, their dated prices, and everything we are owed), a billing
panel on each club (plan, periods, payment history, invoice), and the dues on the
dashboard and the club tables.

Every state change goes through the billing service, and a BillingError surfaces
as a message rather than a 500 -- so "that period is waived", "no price in force",
"already billed for that period" and a missing PDF library all explain themselves
instead of crashing.

The dashboard now separates the two pots of money that were previously one word.
"Revenue per month" was CLUB SHOP revenue -- members paying their clubs, which is
never ours -- sitting on our dashboard under a label that implied it was income.
It is now "Platform dues per month" (what clubs paid us) with the club-shop series
renamed club_revenue, and the club tables carry a Plan column and what each club
owes us, annotated in the same single query.

Rate changes are add-only in the UI as well as the model: the price form creates a
dated row and never edits the last one, and a test asserts that raising the rate
leaves an already-open period at the amount it was billed at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 01:52:43 +02:00
parent 60bfac9881
commit 29d61eeea8
17 changed files with 908 additions and 12 deletions

View File

@@ -3195,6 +3195,9 @@
.hidden {
display: none;
}
.inline {
display: inline;
}
.inline-block {
display: inline-block;
}
@@ -3558,6 +3561,9 @@
.pb-2 {
padding-bottom: calc(var(--spacing) * 2);
}
.pl-8 {
padding-left: calc(var(--spacing) * 8);
}
.text-center {
text-align: center;
}
@@ -3839,6 +3845,12 @@
--badge-fg: var(--color-error-content);
}
}
.badge-info {
@layer daisyui.l1.l2 {
--badge-color: var(--color-info);
--badge-fg: var(--color-info-content);
}
}
.badge-neutral {
@layer daisyui.l1.l2 {
--badge-color: var(--color-neutral);