"""Billing and invoice endpoints.""" from datetime import date, datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query 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 router = APIRouter(prefix="/api/billing", tags=["billing"]) 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 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: return { "id": inv.id, "school_id": inv.school_id, "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, "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}" @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() return {"items": [_inv_out(i) 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) @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"} @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}