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:
181
backend/app/routers/billing.py
Normal file
181
backend/app/routers/billing.py
Normal 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}
|
||||
Reference in New Issue
Block a user