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:
kevin-asprec
2026-03-16 13:46:33 +08:00
parent e45e63903d
commit 0e0803e417
9 changed files with 920 additions and 85 deletions

View File

@@ -1,7 +1,10 @@
"""Billing and invoice endpoints."""
import os
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
@@ -10,9 +13,15 @@ from app.auth.dependencies import require_super_admin, get_current_user
from app.database import get_db
from app.models.user import HubUser, UserRole
from app.models.billing import Invoice, InvoiceStatus, InvoiceLineItem, SchoolSubscription, BillingCycle
from app.models.school import School
router = APIRouter(prefix="/api/billing", tags=["billing"])
PDF_DIR = Path(os.getenv("PDF_DIR", "/app/data/invoices"))
# ── Pydantic schemas ──────────────────────────────────────────────────────────
class InvoiceCreate(BaseModel):
school_id: str
billing_period_start: date
@@ -31,15 +40,26 @@ class InvoiceUpdate(BaseModel):
payment_reference: Optional[str] = None
notes: Optional[str] = None
class MarkPaidBody(BaseModel):
payment_method: str = "bank_transfer"
payment_reference: Optional[str] = None
paid_at: Optional[datetime] = None
class SubscriptionUpsert(BaseModel):
monthly_fee: float
sms_cost_per_message: float = 1.0
cycle: BillingCycle = BillingCycle.monthly
next_billing_date: Optional[date] = None
def _inv_out(inv: Invoice) -> dict:
# ── Helpers ───────────────────────────────────────────────────────────────────
def _inv_out(inv: Invoice, school_name: str | None = None) -> dict:
return {
"id": inv.id, "school_id": inv.school_id, "invoice_number": inv.invoice_number,
"id": inv.id,
"school_id": inv.school_id,
"school_name": school_name,
"invoice_number": inv.invoice_number,
"status": inv.status.value,
"billing_period_start": inv.billing_period_start.isoformat(),
"billing_period_end": inv.billing_period_end.isoformat(),
@@ -53,14 +73,17 @@ def _inv_out(inv: Invoice) -> dict:
"payment_method": inv.payment_method,
"payment_reference": inv.payment_reference,
"email_sent_at": inv.email_sent_at.isoformat() if inv.email_sent_at else None,
"pdf_path": inv.pdf_path,
"created_at": inv.created_at.isoformat(),
"notes": inv.notes,
}
def _next_invoice_number(existing_count: int) -> str:
from datetime import date
return f"INV-{date.today().strftime('%Y%m')}-{existing_count + 1:04d}"
# ── Invoice list / create / update ────────────────────────────────────────────
@router.get("/invoices")
async def list_invoices(
school_id: Optional[str] = Query(None),
@@ -77,9 +100,23 @@ async def list_invoices(
stmt = stmt.where(Invoice.school_id == school_id)
if status:
stmt = stmt.where(Invoice.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
invoices = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
return {"items": [_inv_out(i) for i in invoices], "total": total, "page": page, "per_page": per_page}
# Bulk-load school names
school_ids = {i.school_id for i in invoices}
school_map: dict[str, str] = {}
if school_ids:
schools = (await db.execute(select(School).where(School.id.in_(school_ids)))).scalars().all()
school_map = {s.id: s.name for s in schools}
return {
"items": [_inv_out(i, school_map.get(i.school_id)) for i in invoices],
"total": total,
"page": page,
"per_page": per_page,
}
@router.post("/invoices", status_code=201)
async def create_invoice(
@@ -129,6 +166,111 @@ async def update_invoice(
await db.commit()
return _inv_out(inv)
# ── Mark as paid ──────────────────────────────────────────────────────────────
@router.post("/invoices/{invoice_id}/mark-paid")
async def mark_invoice_paid(
invoice_id: str,
body: MarkPaidBody,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Mark an invoice as paid with payment details."""
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
if not inv:
raise HTTPException(404, "Invoice not found")
if inv.status == InvoiceStatus.paid:
raise HTTPException(400, "Invoice is already marked as paid")
if inv.status == InvoiceStatus.cancelled:
raise HTTPException(400, "Cannot mark a cancelled invoice as paid")
inv.status = InvoiceStatus.paid
inv.paid_at = body.paid_at or datetime.now(timezone.utc)
inv.payment_method = body.payment_method
inv.payment_reference = body.payment_reference
await db.commit()
return _inv_out(inv)
# ── Invoice PDF ───────────────────────────────────────────────────────────────
@router.get("/invoices/{invoice_id}/pdf")
async def get_invoice_pdf(
invoice_id: str,
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Serve the invoice PDF. Generates it on demand if not yet created.
Super admins can access any invoice; school admins only their own.
"""
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
if not inv:
raise HTTPException(404, "Invoice not found")
# Authorization
if current_user.role != UserRole.super_admin and inv.school_id != current_user.school_id:
raise HTTPException(403, "Access denied")
pdf_path = PDF_DIR / f"{invoice_id}.pdf"
# Generate if not yet on disk
if not pdf_path.exists():
try:
from app.tasks.billing import generate_invoice_pdf_task
# Run synchronously for the HTTP request (small invoice = fast)
# The task is also available as an async Celery task for batch use
_generate_pdf_sync(invoice_id)
# Update db record
inv_fresh = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
if inv_fresh:
inv_fresh.pdf_path = str(pdf_path)
await db.commit()
except Exception as e:
raise HTTPException(500, f"PDF generation failed: {e}")
if not pdf_path.exists():
raise HTTPException(500, "PDF generation failed — file not found after generation")
school = (await db.execute(select(School).where(School.id == inv.school_id))).scalar_one_or_none()
school_slug = school.slug if school else invoice_id[:8]
filename = f"invoice-{inv.invoice_number}-{school_slug}.pdf"
return FileResponse(
path=str(pdf_path),
media_type="application/pdf",
filename=filename,
)
def _generate_pdf_sync(invoice_id: str) -> str:
"""Synchronous PDF generation (for use from the HTTP request handler)."""
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.services.invoice_pdf import generate_invoice_pdf
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
Session = sessionmaker(bind=engine)
db = Session()
try:
path = generate_invoice_pdf(invoice_id, db)
# Update pdf_path in the record
from app.models.billing import Invoice
inv = db.get(Invoice, invoice_id)
if inv:
inv.pdf_path = path
db.commit()
return path
finally:
db.close()
engine.dispose()
# ── Email dispatch ────────────────────────────────────────────────────────────
@router.post("/invoices/{invoice_id}/send-email")
async def send_invoice_email(
invoice_id: str,
@@ -142,6 +284,21 @@ async def send_invoice_email(
send_invoice_email_task.delay(invoice_id)
return {"message": "Email queued"}
# ── Trigger monthly invoice generation ───────────────────────────────────────
@router.post("/trigger-generate-invoices")
async def trigger_generate_invoices(
_admin: HubUser = Depends(require_super_admin),
):
"""Manually trigger the monthly invoice generation Celery task."""
from app.tasks.billing import generate_monthly_invoices
generate_monthly_invoices.delay()
return {"message": "Invoice generation task queued"}
# ── Subscriptions ─────────────────────────────────────────────────────────────
@router.get("/subscriptions/{school_id}")
async def get_subscription(
school_id: str,
@@ -150,12 +307,15 @@ async def get_subscription(
):
if current_user.role != UserRole.super_admin and current_user.school_id != school_id:
raise HTTPException(403)
sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none()
sub = (await db.execute(
select(SchoolSubscription).where(SchoolSubscription.school_id == school_id)
)).scalar_one_or_none()
if not sub:
raise HTTPException(404, "No subscription found")
return {
"id": sub.id, "school_id": sub.school_id, "cycle": sub.cycle.value,
"monthly_fee": float(sub.monthly_fee), "sms_cost_per_message": float(sub.sms_cost_per_message),
"monthly_fee": float(sub.monthly_fee),
"sms_cost_per_message": float(sub.sms_cost_per_message),
"next_billing_date": sub.next_billing_date.isoformat() if sub.next_billing_date else None,
"is_active": sub.is_active,
}
@@ -167,7 +327,9 @@ async def upsert_subscription(
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none()
sub = (await db.execute(
select(SchoolSubscription).where(SchoolSubscription.school_id == school_id)
)).scalar_one_or_none()
if sub:
sub.monthly_fee = body.monthly_fee
sub.sms_cost_per_message = body.sms_cost_per_message

View File

@@ -0,0 +1,70 @@
"""Invoice PDF generation using Jinja2 + WeasyPrint."""
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
# PDF output directory (mounted as a Docker volume in production)
PDF_DIR = Path(os.getenv("PDF_DIR", "/app/data/invoices"))
def generate_invoice_pdf(invoice_id: str, db_session) -> str:
"""
Render the invoice HTML template and convert to PDF via WeasyPrint.
Args:
invoice_id: UUID of the Invoice to generate
db_session: synchronous SQLAlchemy session (used from Celery tasks)
Returns:
Absolute path to the generated PDF file.
"""
from app.models.billing import Invoice, InvoiceLineItem
from app.models.school import School
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
# Load invoice + line items + school
inv = db_session.get(Invoice, invoice_id)
if not inv:
raise ValueError(f"Invoice {invoice_id} not found")
school = db_session.get(School, inv.school_id)
line_items = db_session.query(InvoiceLineItem).filter_by(invoice_id=invoice_id).all()
# Format dates
def _fmt(d) -> str:
if not d:
return ""
if hasattr(d, "strftime"):
return d.strftime("%B %d, %Y")
return str(d)
context = {
"invoice": inv,
"school": school,
"line_items": line_items,
"issued_date": _fmt(inv.created_at),
"period_start": _fmt(inv.billing_period_start),
"period_end": _fmt(inv.billing_period_end),
"due_date": _fmt(inv.due_date),
"paid_date": _fmt(inv.paid_at),
"generated_date": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
}
# Render Jinja2 template
template_dir = Path(__file__).parent.parent / "templates"
env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=True)
template = env.get_template("invoice.html")
html_content = template.render(**context)
# Generate PDF
PDF_DIR.mkdir(parents=True, exist_ok=True)
output_path = PDF_DIR / f"{invoice_id}.pdf"
HTML(string=html_content).write_pdf(str(output_path))
logger.info("Generated PDF: %s", output_path)
return str(output_path)

View File

@@ -1,45 +1,71 @@
"""Celery tasks: invoice generation, email, overdue checks."""
"""Celery tasks: invoice generation, PDF, email, overdue escalation."""
import logging
from datetime import date, timedelta
from datetime import date, timedelta, datetime, timezone
from app.worker import celery_app
logger = logging.getLogger(__name__)
def _make_session():
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
engine = create_engine(
db_url.replace("postgresql+asyncpg://", "postgresql://"),
pool_pre_ping=True,
)
return sessionmaker(bind=engine)()
def _hub_url() -> str:
import os
return os.getenv("HUB_BASE_URL", "http://localhost:8090")
@celery_app.task(name="billing.generate_monthly_invoices")
def generate_monthly_invoices():
"""On the 1st: create draft invoices for all active schools with a subscription."""
from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem, BillingCycle
from sqlalchemy import select, func
from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem
from app.models.school import School, SchoolStatus
from sqlalchemy import select
from datetime import date
db = _make_session()
try:
today = date.today()
period_start = date(today.year, today.month, 1)
prev_month = (period_start - timedelta(days=1))
prev_month = period_start - timedelta(days=1)
billing_start = date(prev_month.year, prev_month.month, 1)
billing_end = period_start - timedelta(days=1)
subs = db.execute(select(SchoolSubscription).where(SchoolSubscription.is_active == True)).scalars().all()
subs = db.execute(
select(SchoolSubscription).where(SchoolSubscription.is_active == True)
).scalars().all()
count = db.execute(select(func.count()).select_from(Invoice)).scalar_one()
created = 0
for sub in subs:
school = db.get(School, sub.school_id)
if not school or school.status != SchoolStatus.active:
continue
# Avoid duplicate invoices for the same period
existing = db.execute(
select(Invoice).where(
Invoice.school_id == sub.school_id,
Invoice.billing_period_start == billing_start,
)
).scalar_one_or_none()
if existing:
continue
total = float(sub.monthly_fee)
inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}"
count += 1
created += 1
inv = Invoice(
school_id=sub.school_id,
invoice_number=inv_num,
@@ -51,6 +77,7 @@ def generate_monthly_invoices():
)
db.add(inv)
db.flush()
db.add(InvoiceLineItem(
invoice_id=inv.id,
description=f"Monthly subscription — {school.name}",
@@ -58,21 +85,47 @@ def generate_monthly_invoices():
unit_price=float(sub.monthly_fee),
amount=float(sub.monthly_fee),
))
# Auto-send invoice email
send_invoice_email_task.delay(inv.id)
db.commit()
logger.info(f"Generated {len(subs)} invoices for {billing_start}")
logger.info("Generated %d invoices for %s", created, billing_start)
except Exception as e:
db.rollback()
logger.error(f"generate_monthly_invoices error: {e}")
logger.error("generate_monthly_invoices error: %s", e)
finally:
db.close()
@celery_app.task(name="billing.generate_invoice_pdf")
def generate_invoice_pdf_task(invoice_id: str):
"""Generate PDF for a single invoice and update the pdf_path field."""
from app.services.invoice_pdf import generate_invoice_pdf
from app.models.billing import Invoice
db = _make_session()
try:
path = generate_invoice_pdf(invoice_id, db)
inv = db.get(Invoice, invoice_id)
if inv:
inv.pdf_path = path
db.commit()
logger.info("Invoice PDF generated: %s", path)
return path
except Exception as e:
logger.error("generate_invoice_pdf_task error for %s: %s", invoice_id, e)
raise
finally:
db.close()
@celery_app.task(name="billing.send_invoice_email")
def send_invoice_email_task(invoice_id: str):
"""Send invoice email to school billing contact."""
"""Send invoice email to school billing contact and set status to 'sent'."""
from app.models.billing import Invoice, InvoiceStatus
from app.models.school import School
from app.services.email import send_email
from sqlalchemy import select
db = _make_session()
try:
@@ -82,44 +135,135 @@ def send_invoice_email_task(invoice_id: str):
school = db.get(School, inv.school_id)
if not school or not school.billing_email:
return
body = f"""Dear {school.contact_name or school.name},
Please find your invoice {inv.invoice_number} for the period {inv.billing_period_start} to {inv.billing_period_end}.
Amount Due: PHP {float(inv.total_amount):,.2f}
Due Date: {inv.due_date}
Please log in to your TapTrack Hub portal to view and pay your invoice.
Thank you,
TapTrack Hub Team
"""
send_email(to=school.billing_email, subject=f"Invoice {inv.invoice_number} — TapTrack Hub", body=body)
from datetime import datetime, timezone
body = (
f"Dear {school.contact_name or school.name},\n\n"
f"Please find your invoice {inv.invoice_number} for the period "
f"{inv.billing_period_start} to {inv.billing_period_end}.\n\n"
f"Amount Due: PHP {float(inv.total_amount):,.2f}\n"
f"Due Date: {inv.due_date or 'Upon receipt'}\n\n"
f"Please log in to your TapTrack Hub school portal to view and download your invoice:\n"
f"{_hub_url()}/portal/billing\n\n"
f"Thank you,\nTapTrack Hub Team"
)
send_email(
to=school.billing_email,
subject=f"Invoice {inv.invoice_number} — TapTrack Hub",
body=body,
)
inv.email_sent_at = datetime.now(timezone.utc)
if inv.status.value == "draft":
if inv.status == InvoiceStatus.draft:
inv.status = InvoiceStatus.sent
db.commit()
finally:
db.close()
@celery_app.task(name="billing.check_overdue")
def check_overdue():
"""Mark overdue invoices and send warning emails."""
from app.models.billing import Invoice, InvoiceStatus
"""
Daily task: mark overdue invoices and escalate.
- sent + past due_date → overdue
- overdue 7+ days → warning email
- overdue 30+ days → suspend school + suspension email
"""
from sqlalchemy import select, and_
from app.models.billing import Invoice, InvoiceStatus
from app.models.school import School, SchoolStatus
from app.services.email import send_email
db = _make_session()
try:
today = date.today()
overdue = db.execute(
# 1. Mark newly overdue
newly_overdue = db.execute(
select(Invoice).where(
and_(Invoice.status == InvoiceStatus.sent, Invoice.due_date < today, Invoice.due_date != None)
and_(
Invoice.status == InvoiceStatus.sent,
Invoice.due_date != None,
Invoice.due_date < today,
)
)
).scalars().all()
for inv in overdue:
for inv in newly_overdue:
inv.status = InvoiceStatus.overdue
db.commit()
logger.info(f"Marked {len(overdue)} invoices as overdue")
logger.info("Marked %d invoices as overdue", len(newly_overdue))
# 2. Warning email: overdue 7+ days (but not yet 30)
warn_cutoff = today - timedelta(days=7)
suspend_cutoff = today - timedelta(days=30)
warn_invoices = db.execute(
select(Invoice).where(
and_(
Invoice.status == InvoiceStatus.overdue,
Invoice.due_date != None,
Invoice.due_date <= warn_cutoff,
Invoice.due_date > suspend_cutoff,
)
)
).scalars().all()
for inv in warn_invoices:
school = db.get(School, inv.school_id)
if school and school.billing_email:
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] Overdue Invoice — {inv.invoice_number}",
body=(
f"Dear {school.contact_name or school.name},\n\n"
f"Your invoice {inv.invoice_number} for PHP {float(inv.total_amount):,.2f} "
f"was due on {inv.due_date} and is now overdue.\n\n"
f"Please settle this invoice immediately to avoid account suspension.\n\n"
f"Log in to your portal: {_hub_url()}/portal/billing\n\n"
f"TapTrack Hub Team"
),
)
logger.info("Sent %d overdue warning emails", len(warn_invoices))
# 3. Suspend: overdue 30+ days
suspend_invoices = db.execute(
select(Invoice).where(
and_(
Invoice.status == InvoiceStatus.overdue,
Invoice.due_date != None,
Invoice.due_date <= suspend_cutoff,
)
)
).scalars().all()
suspended = 0
for inv in suspend_invoices:
school = db.get(School, inv.school_id)
if school and school.status == SchoolStatus.active:
school.status = SchoolStatus.suspended
suspended += 1
logger.warning(
"Suspended school %s — overdue invoice %s (30+ days)",
school.name, inv.invoice_number,
)
if school.billing_email:
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] Account Suspended — Invoice {inv.invoice_number}",
body=(
f"Dear {school.contact_name or school.name},\n\n"
f"Your TapTrack account has been suspended due to unpaid invoice "
f"{inv.invoice_number} (PHP {float(inv.total_amount):,.2f}), "
f"which was due on {inv.due_date}.\n\n"
f"SMS notifications and automated reports are now disabled.\n\n"
f"To restore service, please contact support@taptrack.io immediately.\n\n"
f"TapTrack Hub Team"
),
)
db.commit()
logger.info("Suspended %d schools for non-payment", suspended)
except Exception as e:
db.rollback()
logger.error("check_overdue error: %s", e)
finally:
db.close()

View 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>