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
344 lines
13 KiB
Python
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}
|