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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user