RosterChief charging the clubs, which is a different domain from `shop` (a club charging its members). Nothing here is club-scoped: these rows reference a Club, they are not owned by one, and no club user ever sees them. - Tier + TierPrice. Prices are dated, not keyed by year: a rate change is one row with a future active_from, and price_on(day) answers "what was in force then". A tier with no price yet returns None, which callers must treat as "cannot bill" -- never as free. - Due: one rolling-year period per club, with a 45-day grace tail. The tier and the amount are SNAPSHOTS taken when the period opens. Raise the price and last year's period must still say what was actually charged; reading it back through the tier would silently rewrite financial history. - DuePayment: partial payments accumulate. amount_paid is re-summed from the payments on every change, never incremented -- an increment drifts the moment a payment is deleted, and the drift still looks like money. - Invoice: PDF via WeasyPrint, rendered on demand from the frozen snapshot. Only the number is stored, in one platform-wide series (unlike the shop's per-club order numbers), and re-issuing returns the existing one rather than burning a number -- a gap in an invoice series is a question you don't want to answer. WeasyPrint is imported lazily: it binds to native pango/cairo, and the app, the tests and every other page must still run on a machine without them. - archive_overdue_clubs reports by default and archives only with --commit. That asymmetry is deliberate: this switches off paying customers, so a bad clock or a cron misconfiguration should cost an email, not a morning of angry clubs. A club with auto_archive off is spared entirely. Renewal continues from the last period end, not from the payment date: a club that pays two months late has still used those two months. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
107 lines
4.6 KiB
HTML
107 lines
4.6 KiB
HTML
{% load static %}
|
|
|
|
{% comment %}
|
|
Rendered by WeasyPrint, not by a browser: this is a standalone document with its own
|
|
print stylesheet. It deliberately does NOT pull in app.css — daisyUI is built for a
|
|
screen, and half of it (dark theme, flex layouts) means nothing on paper.
|
|
{% endcomment %}
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>{{ invoice.number }}</title>
|
|
<style>
|
|
@page {
|
|
size: A4;
|
|
margin: 20mm;
|
|
@bottom-center {
|
|
content: "RosterChief — invoice {{ invoice.number }} — page " counter(page) " of " counter(pages);
|
|
font-size: 8pt;
|
|
color: #666;
|
|
}
|
|
}
|
|
body { font-family: sans-serif; font-size: 10pt; color: #111; }
|
|
h1 { font-size: 20pt; margin: 0 0 2mm; }
|
|
.muted { color: #666; }
|
|
.header { display: flex; justify-content: space-between; margin-bottom: 12mm; }
|
|
.parties { display: flex; justify-content: space-between; margin-bottom: 10mm; }
|
|
.parties h2 { font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; margin: 0 0 2mm; }
|
|
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
|
|
th { text-align: left; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.5pt; color: #666; border-bottom: 1px solid #ccc; padding: 2mm 0; }
|
|
td { padding: 2mm 0; border-bottom: 1px solid #eee; }
|
|
.right { text-align: right; }
|
|
.total td { font-weight: bold; border-bottom: 2px solid #111; border-top: 1px solid #111; }
|
|
.balance { font-size: 12pt; font-weight: bold; }
|
|
.paid { color: #15803d; }
|
|
.owed { color: #b91c1c; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div>
|
|
<h1>RosterChief</h1>
|
|
<div class="muted">Club & team management</div>
|
|
</div>
|
|
<div class="right">
|
|
<h1>Invoice</h1>
|
|
<div><strong>{{ invoice.number }}</strong></div>
|
|
<div class="muted">Issued {{ invoice.issued_at|date:"j F Y" }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="parties">
|
|
<div>
|
|
<h2>Billed to</h2>
|
|
<div><strong>{{ club.name }}</strong></div>
|
|
<div class="muted">{{ club.slug }}.rosterchief.app</div>
|
|
</div>
|
|
<div class="right">
|
|
<h2>Period</h2>
|
|
<div>{{ due.period_start|date:"j F Y" }} — {{ due.period_end|date:"j F Y" }}</div>
|
|
<div class="muted">Payable by {{ due.grace_until|date:"j F Y" }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Description</th>
|
|
<th class="right">Amount</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
<strong>{{ due.tier.name }}</strong> — platform subscription
|
|
<div class="muted">{{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }}</div>
|
|
</td>
|
|
<td class="right">€{{ due.amount|floatformat:2 }}</td>
|
|
</tr>
|
|
{% for payment in payments %}
|
|
<tr>
|
|
<td class="muted">
|
|
Payment received {{ payment.paid_at|date:"j M Y" }} ({{ payment.get_method_display }}{% if payment.reference %}, {{ payment.reference }}{% endif %})
|
|
</td>
|
|
<td class="right muted">−€{{ payment.amount|floatformat:2 }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
<tr class="total">
|
|
<td>Balance due</td>
|
|
<td class="right balance {% if due.balance > 0 %}owed{% else %}paid{% endif %}">€{{ due.balance|floatformat:2 }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
{% if due.status == "paid" %}
|
|
<p class="paid"><strong>Paid in full.</strong> Thank you.</p>
|
|
{% elif due.status == "waived" %}
|
|
<p class="muted"><strong>Waived.</strong> Nothing is owed for this period.</p>
|
|
{% else %}
|
|
<p class="muted">
|
|
Payable by <strong>{{ due.grace_until|date:"j F Y" }}</strong>. Unpaid past that date the club is archived: its
|
|
subdomain stops resolving, though nothing is deleted.
|
|
</p>
|
|
{% endif %}
|
|
</body>
|
|
</html>
|