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