Files
TapTrack-Hub/backend/app/routers/billing.py
kevin-asprec 0e0803e417 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
2026-03-16 13:46:33 +08:00

344 lines
13 KiB
Python

"""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
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
billing_period_end: date
subscription_amount: float = 0.0
sms_credit_amount: float = 0.0
other_amount: float = 0.0
due_date: Optional[date] = None
notes: Optional[str] = None
line_items: list[dict] = []
class InvoiceUpdate(BaseModel):
status: Optional[InvoiceStatus] = None
paid_at: Optional[datetime] = None
payment_method: Optional[str] = None
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
# ── Helpers ───────────────────────────────────────────────────────────────────
def _inv_out(inv: Invoice, school_name: str | None = None) -> dict:
return {
"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(),
"subscription_amount": float(inv.subscription_amount),
"sms_credit_amount": float(inv.sms_credit_amount),
"other_amount": float(inv.other_amount),
"total_amount": float(inv.total_amount),
"currency": inv.currency,
"due_date": inv.due_date.isoformat() if inv.due_date else None,
"paid_at": inv.paid_at.isoformat() if inv.paid_at else None,
"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:
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),
status: Optional[InvoiceStatus] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(25),
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(Invoice).order_by(desc(Invoice.created_at))
if current_user.role != UserRole.super_admin:
stmt = stmt.where(Invoice.school_id == current_user.school_id)
elif school_id:
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()
# 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(
body: InvoiceCreate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
total = body.subscription_amount + body.sms_credit_amount + body.other_amount
count = (await db.execute(select(func.count()).select_from(Invoice))).scalar_one()
inv = Invoice(
school_id=body.school_id,
invoice_number=_next_invoice_number(count),
billing_period_start=body.billing_period_start,
billing_period_end=body.billing_period_end,
subscription_amount=body.subscription_amount,
sms_credit_amount=body.sms_credit_amount,
other_amount=body.other_amount,
total_amount=total,
due_date=body.due_date,
notes=body.notes,
)
db.add(inv)
await db.flush()
for item in body.line_items:
db.add(InvoiceLineItem(
invoice_id=inv.id,
description=item.get("description", ""),
quantity=item.get("quantity", 1),
unit_price=item.get("unit_price", 0),
amount=item.get("amount", 0),
))
await db.commit()
return _inv_out(inv)
@router.put("/invoices/{invoice_id}")
async def update_invoice(
invoice_id: str,
body: InvoiceUpdate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
if not inv:
raise HTTPException(404, "Invoice not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(inv, field, value)
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,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
from app.tasks.billing import send_invoice_email_task
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
if not inv:
raise HTTPException(404, "Invoice not found")
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,
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
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()
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),
"next_billing_date": sub.next_billing_date.isoformat() if sub.next_billing_date else None,
"is_active": sub.is_active,
}
@router.put("/subscriptions/{school_id}")
async def upsert_subscription(
school_id: str,
body: SubscriptionUpsert,
_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()
if sub:
sub.monthly_fee = body.monthly_fee
sub.sms_cost_per_message = body.sms_cost_per_message
sub.cycle = body.cycle
if body.next_billing_date:
sub.next_billing_date = body.next_billing_date
else:
sub = SchoolSubscription(school_id=school_id, **body.model_dump())
db.add(sub)
await db.commit()
return {"monthly_fee": float(sub.monthly_fee), "cycle": sub.cycle.value}