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:
0
backend/app/tasks/__init__.py
Normal file
0
backend/app/tasks/__init__.py
Normal file
125
backend/app/tasks/billing.py
Normal file
125
backend/app/tasks/billing.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Celery tasks: invoice generation, email, overdue checks."""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.worker import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _make_session():
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
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)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
@celery_app.task(name="billing.generate_monthly_invoices")
|
||||
def generate_monthly_invoices():
|
||||
"""On the 1st: create draft invoices for all active schools with a subscription."""
|
||||
from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem, BillingCycle
|
||||
from app.models.school import School, SchoolStatus
|
||||
from sqlalchemy import select
|
||||
from datetime import date
|
||||
|
||||
db = _make_session()
|
||||
try:
|
||||
today = date.today()
|
||||
period_start = date(today.year, today.month, 1)
|
||||
prev_month = (period_start - timedelta(days=1))
|
||||
billing_start = date(prev_month.year, prev_month.month, 1)
|
||||
billing_end = period_start - timedelta(days=1)
|
||||
|
||||
subs = db.execute(select(SchoolSubscription).where(SchoolSubscription.is_active == True)).scalars().all()
|
||||
count = db.execute(select(func.count()).select_from(Invoice)).scalar_one()
|
||||
|
||||
for sub in subs:
|
||||
school = db.get(School, sub.school_id)
|
||||
if not school or school.status != SchoolStatus.active:
|
||||
continue
|
||||
total = float(sub.monthly_fee)
|
||||
inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}"
|
||||
count += 1
|
||||
inv = Invoice(
|
||||
school_id=sub.school_id,
|
||||
invoice_number=inv_num,
|
||||
billing_period_start=billing_start,
|
||||
billing_period_end=billing_end,
|
||||
subscription_amount=float(sub.monthly_fee),
|
||||
total_amount=total,
|
||||
due_date=period_start + timedelta(days=14),
|
||||
)
|
||||
db.add(inv)
|
||||
db.flush()
|
||||
db.add(InvoiceLineItem(
|
||||
invoice_id=inv.id,
|
||||
description=f"Monthly subscription — {school.name}",
|
||||
quantity=1,
|
||||
unit_price=float(sub.monthly_fee),
|
||||
amount=float(sub.monthly_fee),
|
||||
))
|
||||
db.commit()
|
||||
logger.info(f"Generated {len(subs)} invoices for {billing_start}")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"generate_monthly_invoices error: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@celery_app.task(name="billing.send_invoice_email")
|
||||
def send_invoice_email_task(invoice_id: str):
|
||||
"""Send invoice email to school billing contact."""
|
||||
from app.models.billing import Invoice, InvoiceStatus
|
||||
from app.models.school import School
|
||||
from app.services.email import send_email
|
||||
from sqlalchemy import select
|
||||
|
||||
db = _make_session()
|
||||
try:
|
||||
inv = db.get(Invoice, invoice_id)
|
||||
if not inv:
|
||||
return
|
||||
school = db.get(School, inv.school_id)
|
||||
if not school or not school.billing_email:
|
||||
return
|
||||
body = f"""Dear {school.contact_name or school.name},
|
||||
|
||||
Please find your invoice {inv.invoice_number} for the period {inv.billing_period_start} to {inv.billing_period_end}.
|
||||
|
||||
Amount Due: PHP {float(inv.total_amount):,.2f}
|
||||
Due Date: {inv.due_date}
|
||||
|
||||
Please log in to your TapTrack Hub portal to view and pay your invoice.
|
||||
|
||||
Thank you,
|
||||
TapTrack Hub Team
|
||||
"""
|
||||
send_email(to=school.billing_email, subject=f"Invoice {inv.invoice_number} — TapTrack Hub", body=body)
|
||||
from datetime import datetime, timezone
|
||||
inv.email_sent_at = datetime.now(timezone.utc)
|
||||
if inv.status.value == "draft":
|
||||
inv.status = InvoiceStatus.sent
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@celery_app.task(name="billing.check_overdue")
|
||||
def check_overdue():
|
||||
"""Mark overdue invoices and send warning emails."""
|
||||
from app.models.billing import Invoice, InvoiceStatus
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
db = _make_session()
|
||||
try:
|
||||
today = date.today()
|
||||
overdue = db.execute(
|
||||
select(Invoice).where(
|
||||
and_(Invoice.status == InvoiceStatus.sent, Invoice.due_date < today, Invoice.due_date != None)
|
||||
)
|
||||
).scalars().all()
|
||||
for inv in overdue:
|
||||
inv.status = InvoiceStatus.overdue
|
||||
db.commit()
|
||||
logger.info(f"Marked {len(overdue)} invoices as overdue")
|
||||
finally:
|
||||
db.close()
|
||||
41
backend/app/tasks/license.py
Normal file
41
backend/app/tasks/license.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Celery task: license expiry checks and alerts."""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.worker import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@celery_app.task(name="license.check_expiry")
|
||||
def check_expiry():
|
||||
"""Send expiry warning emails for licenses expiring in 30, 14, or 7 days."""
|
||||
import os
|
||||
from sqlalchemy import create_engine, select, and_
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models.license import License, LicenseStatus
|
||||
from app.models.school import School
|
||||
from app.services.email import send_email
|
||||
|
||||
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)
|
||||
db = sessionmaker(bind=engine)()
|
||||
try:
|
||||
today = date.today()
|
||||
for days_ahead in [30, 14, 7]:
|
||||
target = today + timedelta(days=days_ahead)
|
||||
expiring = db.execute(
|
||||
select(License).where(
|
||||
and_(License.expires_at == target, License.status == LicenseStatus.active)
|
||||
)
|
||||
).scalars().all()
|
||||
for lic in expiring:
|
||||
school = db.get(School, lic.school_id)
|
||||
if school and school.billing_email:
|
||||
send_email(
|
||||
to=school.billing_email,
|
||||
subject=f"[TapTrack Hub] License expires in {days_ahead} days — {school.name}",
|
||||
body=f"Your TapTrack license for {school.name} expires on {lic.expires_at}. Please contact us to renew.",
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
35
backend/app/tasks/reports.py
Normal file
35
backend/app/tasks/reports.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Celery task: send monthly reports to schools."""
|
||||
import logging
|
||||
from app.worker import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@celery_app.task(name="reports.send_monthly_reports")
|
||||
def send_monthly_reports():
|
||||
"""Send monthly attendance and SMS report email to each active school."""
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models.school import School, SchoolStatus
|
||||
from app.services.email import send_email
|
||||
|
||||
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)
|
||||
db = sessionmaker(bind=engine)()
|
||||
try:
|
||||
today = date.today()
|
||||
prev_month_end = date(today.year, today.month, 1) - timedelta(days=1)
|
||||
prev_month_start = date(prev_month_end.year, prev_month_end.month, 1)
|
||||
schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all()
|
||||
for school in schools:
|
||||
if not school.billing_email:
|
||||
continue
|
||||
send_email(
|
||||
to=school.billing_email,
|
||||
subject=f"Monthly Report — {school.name} — {prev_month_start.strftime('%B %Y')}",
|
||||
body=f"Dear {school.contact_name or school.name},\n\nPlease find your monthly summary for {prev_month_start.strftime('%B %Y')} in your TapTrack Hub portal.\n\nSMS Credits Remaining: {float(school.sms_credits):.0f}\n\nLog in to view full details.\n\nThank you,\nTapTrack Hub Team",
|
||||
)
|
||||
logger.info(f"Sent monthly reports to {len(schools)} schools")
|
||||
finally:
|
||||
db.close()
|
||||
102
backend/app/tasks/sms.py
Normal file
102
backend/app/tasks/sms.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Celery task: process pending SMS jobs via Semaphore."""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import create_engine, select, update, and_
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.worker import celery_app
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _make_sync_engine():
|
||||
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
|
||||
return create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True, pool_size=2)
|
||||
|
||||
_engine = _make_sync_engine()
|
||||
_Session = sessionmaker(bind=_engine)
|
||||
|
||||
@celery_app.task(name="sms.process_queue")
|
||||
def process_sms_queue():
|
||||
"""Process up to 20 pending SMS jobs per run via Semaphore API."""
|
||||
from app.models.sms import SmsJob, SmsJobStatus
|
||||
from app.models.school import School
|
||||
|
||||
db = _Session()
|
||||
try:
|
||||
jobs = db.execute(
|
||||
select(SmsJob).where(SmsJob.status == SmsJobStatus.pending).limit(20)
|
||||
).scalars().all()
|
||||
|
||||
for job in jobs:
|
||||
school = db.get(School, job.school_id)
|
||||
if not school or float(school.sms_credits) <= 0:
|
||||
job.status = SmsJobStatus.cancelled
|
||||
job.error_message = "Insufficient credits"
|
||||
db.commit()
|
||||
continue
|
||||
|
||||
result = _send_semaphore(job.recipient_phone, job.message, job.sender_name)
|
||||
if result["success"]:
|
||||
job.status = SmsJobStatus.sent
|
||||
job.sent_at = datetime.now(timezone.utc)
|
||||
job.semaphore_message_id = result.get("message_id")
|
||||
# Deduct credit
|
||||
from app.models.sms import SmsCreditLedger, SmsCreditTx
|
||||
school.sms_credits = float(school.sms_credits) - 1.0
|
||||
db.add(SmsCreditLedger(
|
||||
school_id=school.id,
|
||||
tx_type=SmsCreditTx.deduct,
|
||||
amount=-1.0,
|
||||
balance_after=float(school.sms_credits),
|
||||
description=f"SMS sent to {job.recipient_phone}",
|
||||
reference_id=job.id,
|
||||
))
|
||||
# Low credit alert
|
||||
if float(school.sms_credits) <= school.sms_credit_low_threshold:
|
||||
send_low_credit_alert.delay(school.id)
|
||||
else:
|
||||
job.retry_count += 1
|
||||
if job.retry_count >= 5:
|
||||
job.status = SmsJobStatus.failed
|
||||
job.error_message = result.get("error")
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _send_semaphore(phone: str, message: str, sender: str) -> dict:
|
||||
try:
|
||||
with httpx.Client(timeout=15) as client:
|
||||
resp = client.post(settings.SEMAPHORE_URL, data={
|
||||
"apikey": settings.SEMAPHORE_API_KEY,
|
||||
"number": phone,
|
||||
"message": message,
|
||||
"sendername": sender,
|
||||
})
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
msg_id = str(data[0].get("message_id", "")) if isinstance(data, list) and data else None
|
||||
return {"success": True, "message_id": msg_id}
|
||||
return {"success": False, "error": f"HTTP {resp.status_code}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@celery_app.task(name="sms.send_low_credit_alert")
|
||||
def send_low_credit_alert(school_id: str):
|
||||
"""Send low credit warning email to school billing contact."""
|
||||
from app.services.email import send_email
|
||||
from app.models.school import School
|
||||
db = _Session()
|
||||
try:
|
||||
school = db.get(School, school_id)
|
||||
if school and school.billing_email:
|
||||
send_email(
|
||||
to=school.billing_email,
|
||||
subject=f"[TapTrack Hub] Low SMS Credits — {school.name}",
|
||||
body=f"Your SMS credit balance for {school.name} is low ({float(school.sms_credits):.0f} remaining). Please top up to continue sending SMS notifications.",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user