Implements BILLING.md. The architecture was sound -- snapshot-on-Due, dated prices, asymmetric dry-run commands are all kept -- so this fixes the three hardcoded assumptions rather than rewriting. The real defect: grace ran from period_END, so an annual club used the whole unpaid year plus 45 days (~410 days) before anything switched it off. Grace now runs from the period START, and every clock is per-plan. - Tier -> Plan (+ TierPrice -> PlanPrice, and every FK). Migration 0004 is hand-written: run non-interactively, makemigrations emits DeleteModel+CreateModel and drops every price, subscription and due. Its two RemoveConstraints must come first, or SQLite's table-rebuild tries to render a constraint over a just-renamed column. Verified by round-tripping real rows through it. - Plan gains duration_months / renewal_lead_days / grace_days / is_trial, with CheckConstraints and a matching clean() so the form reports an impossible plan instead of 500ing on IntegrityError. - Existing dues keep their stored grace_until. Re-deriving it would put the date in the past for every open annual period and archive the entire paying customer base on the next --commit run. - Trials take their length from the trial plan's own duration_months; start_trial() loses its trial_months argument. - New BillingNotice service drives a club-facing warning: every level on the dashboard, and on every management page once urgent. - send_billing_reminders emails club admins, once per escalation level so a daily cron is not a daily email. SMTP settings are env-driven and provider-agnostic; the backend defaults to console. - Paying does not auto-restore an archived club -- the control panel surfaces a Reactivate prompt instead, since a club can also be archived by hand.
56 lines
1.8 KiB
Python
56 lines
1.8 KiB
Python
from django.contrib import admin
|
|
|
|
from .models import Due, DuePayment, Plan, PlanPrice, Subscription
|
|
|
|
|
|
class PlanPriceInline(admin.TabularInline):
|
|
model = PlanPrice
|
|
extra = 0
|
|
|
|
|
|
@admin.register(Plan)
|
|
class PlanAdmin(admin.ModelAdmin):
|
|
list_display = ["name", "duration_months", "renewal_lead_days", "grace_days", "is_trial", "is_active"]
|
|
list_filter = ["is_active", "is_trial"]
|
|
search_fields = ["name"]
|
|
prepopulated_fields = {"slug": ["name"]}
|
|
inlines = [PlanPriceInline]
|
|
|
|
|
|
@admin.register(PlanPrice)
|
|
class PlanPriceAdmin(admin.ModelAdmin):
|
|
list_display = ["plan", "amount", "active_from"]
|
|
list_filter = ["plan"]
|
|
|
|
|
|
@admin.register(Subscription)
|
|
class SubscriptionAdmin(admin.ModelAdmin):
|
|
list_display = ["club", "plan", "auto_renew", "auto_archive"]
|
|
list_filter = ["plan", "auto_renew", "auto_archive"]
|
|
search_fields = ["club__name"]
|
|
|
|
|
|
class DuePaymentInline(admin.TabularInline):
|
|
model = DuePayment
|
|
extra = 0
|
|
readonly_fields = ["recorded_by"]
|
|
|
|
|
|
@admin.register(Due)
|
|
class DueAdmin(admin.ModelAdmin):
|
|
list_display = ["club", "plan", "period_start", "period_end", "grace_until", "amount", "amount_paid", "status"]
|
|
list_filter = ["status", "plan"]
|
|
search_fields = ["club__name"]
|
|
# Money is settled by the billing service, which re-derives these from the payments.
|
|
# period_end/grace_until are snapshots taken when the period opened -- editing a plan
|
|
# afterwards must not move them, and neither should a hand edit here.
|
|
readonly_fields = ["amount_paid", "status", "paid_at", "period_end", "grace_until"]
|
|
inlines = [DuePaymentInline]
|
|
|
|
|
|
@admin.register(DuePayment)
|
|
class DuePaymentAdmin(admin.ModelAdmin):
|
|
list_display = ["due", "amount", "method", "paid_at", "recorded_by"]
|
|
list_filter = ["method"]
|
|
search_fields = ["due__club__name", "reference"]
|