feat(phase-8): billing engine + invoice PDF + mark-paid + overdue escalation
Backend:
- app/services/invoice_pdf.py: Jinja2+WeasyPrint PDF generation, saves to
/app/data/invoices/{id}.pdf, updates Invoice.pdf_path
- app/templates/invoice.html: professional branded A4 invoice template with
school details, line items table, totals, payment instructions, paid receipt
- routers/billing.py: GET /invoices/{id}/pdf (auto-generate on demand, FileResponse),
POST /invoices/{id}/mark-paid (payment_method + reference → status=paid),
POST /trigger-generate-invoices (manual trigger), school_name in invoice list
- tasks/billing.py: fix missing func import in generate_monthly_invoices,
new billing.generate_invoice_pdf Celery task, auto-send email after
invoice creation, check_overdue upgraded with 7-day warning emails and
30-day school suspension + suspension email
Frontend:
- BillingPage.vue: full rewrite — status filter tabs, school names (not UUIDs),
PDF download button, mail icon, Mark Paid modal with method/reference fields,
overdue rows highlighted, pagination, Generate Invoices trigger button
- api.ts: markInvoicePaid, downloadInvoicePdf, triggerGenerateInvoices
This commit is contained in:
226
backend/app/templates/invoice.html
Normal file
226
backend/app/templates/invoice.html
Normal file
@@ -0,0 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 18mm 20mm 18mm; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 11pt; color: #1e293b; line-height: 1.5; }
|
||||
|
||||
/* Header */
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; }
|
||||
.brand { }
|
||||
.brand h1 { font-size: 20pt; font-weight: 700; color: #1e40af; letter-spacing: -0.5px; }
|
||||
.brand p { font-size: 9pt; color: #64748b; margin-top: 2px; }
|
||||
.invoice-meta { text-align: right; }
|
||||
.invoice-meta .invoice-number { font-size: 15pt; font-weight: 700; color: #1e293b; }
|
||||
.invoice-meta .invoice-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; }
|
||||
.invoice-meta .date { font-size: 10pt; color: #475569; margin-top: 4px; }
|
||||
|
||||
/* Divider */
|
||||
.divider { border: none; border-top: 2px solid #e2e8f0; margin: 20px 0; }
|
||||
.divider-thin { border: none; border-top: 1px solid #e2e8f0; margin: 12px 0; }
|
||||
|
||||
/* Parties */
|
||||
.parties { display: flex; gap: 40px; margin-bottom: 28px; }
|
||||
.party { flex: 1; }
|
||||
.party-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; margin-bottom: 6px; }
|
||||
.party-name { font-weight: 700; font-size: 12pt; color: #0f172a; }
|
||||
.party p { font-size: 9.5pt; color: #475569; margin-top: 1px; }
|
||||
|
||||
/* Status pill */
|
||||
.status-pill { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 8.5pt; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.status-draft { background: #f1f5f9; color: #64748b; }
|
||||
.status-sent { background: #eff6ff; color: #1d4ed8; }
|
||||
.status-paid { background: #f0fdf4; color: #15803d; }
|
||||
.status-overdue { background: #fef2f2; color: #dc2626; }
|
||||
.status-cancelled { background: #f1f5f9; color: #94a3b8; }
|
||||
|
||||
/* Invoice info grid */
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 28px; background: #f8fafc; border-radius: 8px; padding: 16px; }
|
||||
.info-cell .info-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; margin-bottom: 3px; }
|
||||
.info-cell .info-value { font-size: 10.5pt; font-weight: 600; color: #0f172a; }
|
||||
|
||||
/* Line items table */
|
||||
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||
.items-table thead th { background: #1e40af; color: white; font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.8px; padding: 9px 12px; text-align: left; }
|
||||
.items-table thead th:last-child,
|
||||
.items-table thead th:nth-child(3),
|
||||
.items-table thead th:nth-child(4) { text-align: right; }
|
||||
.items-table tbody tr:nth-child(even) { background: #f8fafc; }
|
||||
.items-table tbody td { padding: 10px 12px; font-size: 10pt; color: #334155; border-bottom: 1px solid #f1f5f9; }
|
||||
.items-table tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Totals */
|
||||
.totals { float: right; width: 260px; margin-bottom: 32px; }
|
||||
.totals table { width: 100%; border-collapse: collapse; }
|
||||
.totals td { padding: 5px 0; font-size: 10pt; color: #475569; }
|
||||
.totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.totals .total-row td { font-size: 13pt; font-weight: 700; color: #0f172a; border-top: 2px solid #e2e8f0; padding-top: 10px; margin-top: 4px; }
|
||||
.totals .paid-row td { color: #15803d; font-weight: 600; }
|
||||
.clearfix::after { content: ""; display: table; clear: both; }
|
||||
|
||||
/* Payment info */
|
||||
.payment-info { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 14px 16px; margin-bottom: 24px; }
|
||||
.payment-info h3 { font-size: 9.5pt; font-weight: 700; color: #1e40af; margin-bottom: 6px; }
|
||||
.payment-info p { font-size: 9.5pt; color: #1e3a8a; }
|
||||
.payment-info .ref { font-family: monospace; background: #dbeafe; padding: 2px 6px; border-radius: 3px; font-size: 9pt; }
|
||||
|
||||
/* Footer */
|
||||
.footer { margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }
|
||||
.footer p { font-size: 8.5pt; color: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="brand">
|
||||
<h1>TapTrack Hub</h1>
|
||||
<p>Cloud Control Plane for TapTrack Deployments</p>
|
||||
</div>
|
||||
<div class="invoice-meta">
|
||||
<div class="invoice-label">Invoice</div>
|
||||
<div class="invoice-number">{{ invoice.invoice_number }}</div>
|
||||
<div class="date">Issued {{ issued_date }}</div>
|
||||
<div style="margin-top:6px">
|
||||
<span class="status-pill status-{{ invoice.status }}">{{ invoice.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Bill To / From -->
|
||||
<div class="parties">
|
||||
<div class="party">
|
||||
<div class="party-label">Bill To</div>
|
||||
<div class="party-name">{{ school.name }}</div>
|
||||
{% if school.address %}<p>{{ school.address }}</p>{% endif %}
|
||||
{% if school.city %}<p>{{ school.city }}</p>{% endif %}
|
||||
{% if school.billing_email %}<p>{{ school.billing_email }}</p>{% endif %}
|
||||
{% if school.contact_phone %}<p>{{ school.contact_phone }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="party">
|
||||
<div class="party-label">From</div>
|
||||
<div class="party-name">TapTrack Hub</div>
|
||||
<p>Cloud Services</p>
|
||||
<p>support@taptrack.io</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice info -->
|
||||
<div class="info-grid">
|
||||
<div class="info-cell">
|
||||
<div class="info-label">Billing Period</div>
|
||||
<div class="info-value">{{ period_start }} — {{ period_end }}</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<div class="info-label">Due Date</div>
|
||||
<div class="info-value">{{ due_date if due_date else 'Upon receipt' }}</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<div class="info-label">Currency</div>
|
||||
<div class="info-value">{{ invoice.currency }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line items -->
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:55%">Description</th>
|
||||
<th style="width:15%">Qty</th>
|
||||
<th style="width:15%">Unit Price</th>
|
||||
<th style="width:15%">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in line_items %}
|
||||
<tr>
|
||||
<td>{{ item.description }}</td>
|
||||
<td class="num">{{ item.quantity }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(item.unit_price) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(item.amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not line_items %}
|
||||
{% if invoice.subscription_amount > 0 %}
|
||||
<tr>
|
||||
<td>Monthly Subscription — {{ school.name }}</td>
|
||||
<td class="num">1</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.subscription_amount) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.subscription_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if invoice.sms_credit_amount > 0 %}
|
||||
<tr>
|
||||
<td>SMS Credits</td>
|
||||
<td class="num">1</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.sms_credit_amount) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.sms_credit_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if invoice.other_amount > 0 %}
|
||||
<tr>
|
||||
<td>Other Charges</td>
|
||||
<td class="num">1</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.other_amount) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.other_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Totals + Payment info -->
|
||||
<div class="clearfix">
|
||||
<div class="totals">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Subtotal</td>
|
||||
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
|
||||
</tr>
|
||||
<tr class="total-row">
|
||||
<td>Total Due</td>
|
||||
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
|
||||
</tr>
|
||||
{% if invoice.paid_at %}
|
||||
<tr class="paid-row">
|
||||
<td>Paid</td>
|
||||
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if invoice.paid_at %}
|
||||
<div class="payment-info" style="background:#f0fdf4;border-color:#bbf7d0">
|
||||
<h3 style="color:#15803d">Payment Received</h3>
|
||||
<p>Paid on {{ paid_date }} via {{ invoice.payment_method or 'N/A' }}
|
||||
{% if invoice.payment_reference %} · Ref: <span class="ref">{{ invoice.payment_reference }}</span>{% endif %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="payment-info">
|
||||
<h3>Payment Instructions</h3>
|
||||
<p>Please log in to your TapTrack Hub school portal and submit payment by <strong>{{ due_date if due_date else 'the due date' }}</strong>.</p>
|
||||
<p style="margin-top:4px">For questions, contact <strong>support@taptrack.io</strong></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if invoice.notes %}
|
||||
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:12px 14px;margin-bottom:16px">
|
||||
<strong style="font-size:9pt;color:#92400e">Notes:</strong>
|
||||
<p style="font-size:9.5pt;color:#78350f;margin-top:3px">{{ invoice.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>TapTrack Hub · support@taptrack.io</p>
|
||||
<p>Generated {{ generated_date }} · {{ invoice.invoice_number }}</p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user