feat(phase-1): TapTrack Hub initial scaffold

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
This commit is contained in:
kevin-asprec
2026-03-16 07:26:06 +08:00
commit 73a17aaf9a
107 changed files with 4764 additions and 0 deletions

View File

@@ -0,0 +1,181 @@
"""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}