"""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}