Full project scaffold for TapTrack Hub — cloud SaaS control plane for managing on-prem TapTrack school deployments. ## Infrastructure - Docker Compose: backend (gunicorn+uvicorn), Celery worker + beat, frontend (Vite build + nginx), PostgreSQL 15, Redis 7, nginx proxy - Dockerfile for backend and frontend, nginx reverse proxy config ## Backend (FastAPI + SQLAlchemy async + Celery) Database schema (10 tables): hub_users, schools, licenses, sms_jobs, sms_credit_ledger, invoices, invoice_line_items, school_subscriptions, support_tickets, ticket_replies, audit_logs, announcements Auth: JWT (python-jose) + bcrypt + role-based FastAPI dependencies (get_current_user, require_super_admin, require_school_admin) Routers (11): auth, schools, licenses, sms, billing, tickets, users, dashboard, school_portal, announcements, sync Celery tasks (6): sms.process_queue, billing.generate_monthly_invoices, billing.send_invoice_email, billing.check_overdue, license.check_expiry, reports.send_monthly_reports Services: SMTP email helper (smtplib + Jinja2) Seed script: creates super admin admin@taptrack.io ## Frontend (Vue 3 + Vite + Pinia + Tailwind CSS) Router: 14 routes across super admin + school portal layouts Stores: Pinia auth store with localStorage persistence API client: full axios client for all backend endpoints Layouts: AppLayout (super admin), PortalLayout (school), AuthLayout Components: AppSidebar, PortalSidebar, SidebarItem, KpiCard, StatusBadge, ToastStack Pages: Login, Dashboard, Schools, SchoolDetail, Licenses, SMS, Billing, Tickets, TicketDetail, Users, Announcements, 404 Portal pages: Overview, Billing, SMS Reports, Tickets, Profile ## PAUL Planning Files - .paul/ROADMAP.md: full 15-phase roadmap with detailed scope - .paul/STATE.md: current position, tech stack, architecture notes - .paul/phases/01-setup/01-PLAN.md: complete Phase 1 plan (done) - .paul/phases/02 through 15: README stubs for all future phases
182 lines
7.1 KiB
Python
182 lines
7.1 KiB
Python
"""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}
|