feat(phase-8): billing engine + invoice PDF + mark-paid + overdue escalation
Backend:
- app/services/invoice_pdf.py: Jinja2+WeasyPrint PDF generation, saves to
/app/data/invoices/{id}.pdf, updates Invoice.pdf_path
- app/templates/invoice.html: professional branded A4 invoice template with
school details, line items table, totals, payment instructions, paid receipt
- routers/billing.py: GET /invoices/{id}/pdf (auto-generate on demand, FileResponse),
POST /invoices/{id}/mark-paid (payment_method + reference → status=paid),
POST /trigger-generate-invoices (manual trigger), school_name in invoice list
- tasks/billing.py: fix missing func import in generate_monthly_invoices,
new billing.generate_invoice_pdf Celery task, auto-send email after
invoice creation, check_overdue upgraded with 7-day warning emails and
30-day school suspension + suspension email
Frontend:
- BillingPage.vue: full rewrite — status filter tabs, school names (not UUIDs),
PDF download button, mail icon, Mark Paid modal with method/reference fields,
overdue rows highlighted, pagination, Generate Invoices trigger button
- api.ts: markInvoicePaid, downloadInvoicePdf, triggerGenerateInvoices
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
"""Billing and invoice endpoints."""
|
||||
import os
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
@@ -10,9 +13,15 @@ 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
|
||||
from app.models.school import School
|
||||
|
||||
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
||||
|
||||
PDF_DIR = Path(os.getenv("PDF_DIR", "/app/data/invoices"))
|
||||
|
||||
|
||||
# ── Pydantic schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
school_id: str
|
||||
billing_period_start: date
|
||||
@@ -31,15 +40,26 @@ class InvoiceUpdate(BaseModel):
|
||||
payment_reference: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class MarkPaidBody(BaseModel):
|
||||
payment_method: str = "bank_transfer"
|
||||
payment_reference: Optional[str] = None
|
||||
paid_at: Optional[datetime] = 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:
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _inv_out(inv: Invoice, school_name: str | None = None) -> dict:
|
||||
return {
|
||||
"id": inv.id, "school_id": inv.school_id, "invoice_number": inv.invoice_number,
|
||||
"id": inv.id,
|
||||
"school_id": inv.school_id,
|
||||
"school_name": school_name,
|
||||
"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(),
|
||||
@@ -53,14 +73,17 @@ def _inv_out(inv: Invoice) -> dict:
|
||||
"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,
|
||||
"pdf_path": inv.pdf_path,
|
||||
"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}"
|
||||
|
||||
|
||||
# ── Invoice list / create / update ────────────────────────────────────────────
|
||||
|
||||
@router.get("/invoices")
|
||||
async def list_invoices(
|
||||
school_id: Optional[str] = Query(None),
|
||||
@@ -77,9 +100,23 @@ async def list_invoices(
|
||||
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}
|
||||
|
||||
# Bulk-load school names
|
||||
school_ids = {i.school_id for i in invoices}
|
||||
school_map: dict[str, str] = {}
|
||||
if school_ids:
|
||||
schools = (await db.execute(select(School).where(School.id.in_(school_ids)))).scalars().all()
|
||||
school_map = {s.id: s.name for s in schools}
|
||||
|
||||
return {
|
||||
"items": [_inv_out(i, school_map.get(i.school_id)) for i in invoices],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
}
|
||||
|
||||
@router.post("/invoices", status_code=201)
|
||||
async def create_invoice(
|
||||
@@ -129,6 +166,111 @@ async def update_invoice(
|
||||
await db.commit()
|
||||
return _inv_out(inv)
|
||||
|
||||
|
||||
# ── Mark as paid ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/invoices/{invoice_id}/mark-paid")
|
||||
async def mark_invoice_paid(
|
||||
invoice_id: str,
|
||||
body: MarkPaidBody,
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mark an invoice as paid with payment details."""
|
||||
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
|
||||
if not inv:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
if inv.status == InvoiceStatus.paid:
|
||||
raise HTTPException(400, "Invoice is already marked as paid")
|
||||
if inv.status == InvoiceStatus.cancelled:
|
||||
raise HTTPException(400, "Cannot mark a cancelled invoice as paid")
|
||||
|
||||
inv.status = InvoiceStatus.paid
|
||||
inv.paid_at = body.paid_at or datetime.now(timezone.utc)
|
||||
inv.payment_method = body.payment_method
|
||||
inv.payment_reference = body.payment_reference
|
||||
await db.commit()
|
||||
return _inv_out(inv)
|
||||
|
||||
|
||||
# ── Invoice PDF ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/invoices/{invoice_id}/pdf")
|
||||
async def get_invoice_pdf(
|
||||
invoice_id: str,
|
||||
current_user: HubUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Serve the invoice PDF. Generates it on demand if not yet created.
|
||||
Super admins can access any invoice; school admins only their own.
|
||||
"""
|
||||
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
|
||||
if not inv:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
|
||||
# Authorization
|
||||
if current_user.role != UserRole.super_admin and inv.school_id != current_user.school_id:
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
pdf_path = PDF_DIR / f"{invoice_id}.pdf"
|
||||
|
||||
# Generate if not yet on disk
|
||||
if not pdf_path.exists():
|
||||
try:
|
||||
from app.tasks.billing import generate_invoice_pdf_task
|
||||
# Run synchronously for the HTTP request (small invoice = fast)
|
||||
# The task is also available as an async Celery task for batch use
|
||||
_generate_pdf_sync(invoice_id)
|
||||
# Update db record
|
||||
inv_fresh = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
|
||||
if inv_fresh:
|
||||
inv_fresh.pdf_path = str(pdf_path)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"PDF generation failed: {e}")
|
||||
|
||||
if not pdf_path.exists():
|
||||
raise HTTPException(500, "PDF generation failed — file not found after generation")
|
||||
|
||||
school = (await db.execute(select(School).where(School.id == inv.school_id))).scalar_one_or_none()
|
||||
school_slug = school.slug if school else invoice_id[:8]
|
||||
filename = f"invoice-{inv.invoice_number}-{school_slug}.pdf"
|
||||
|
||||
return FileResponse(
|
||||
path=str(pdf_path),
|
||||
media_type="application/pdf",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
def _generate_pdf_sync(invoice_id: str) -> str:
|
||||
"""Synchronous PDF generation (for use from the HTTP request handler)."""
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.services.invoice_pdf import generate_invoice_pdf
|
||||
|
||||
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)
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
try:
|
||||
path = generate_invoice_pdf(invoice_id, db)
|
||||
# Update pdf_path in the record
|
||||
from app.models.billing import Invoice
|
||||
inv = db.get(Invoice, invoice_id)
|
||||
if inv:
|
||||
inv.pdf_path = path
|
||||
db.commit()
|
||||
return path
|
||||
finally:
|
||||
db.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ── Email dispatch ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/invoices/{invoice_id}/send-email")
|
||||
async def send_invoice_email(
|
||||
invoice_id: str,
|
||||
@@ -142,6 +284,21 @@ async def send_invoice_email(
|
||||
send_invoice_email_task.delay(invoice_id)
|
||||
return {"message": "Email queued"}
|
||||
|
||||
|
||||
# ── Trigger monthly invoice generation ───────────────────────────────────────
|
||||
|
||||
@router.post("/trigger-generate-invoices")
|
||||
async def trigger_generate_invoices(
|
||||
_admin: HubUser = Depends(require_super_admin),
|
||||
):
|
||||
"""Manually trigger the monthly invoice generation Celery task."""
|
||||
from app.tasks.billing import generate_monthly_invoices
|
||||
generate_monthly_invoices.delay()
|
||||
return {"message": "Invoice generation task queued"}
|
||||
|
||||
|
||||
# ── Subscriptions ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/subscriptions/{school_id}")
|
||||
async def get_subscription(
|
||||
school_id: str,
|
||||
@@ -150,12 +307,15 @@ async def get_subscription(
|
||||
):
|
||||
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()
|
||||
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),
|
||||
"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,
|
||||
}
|
||||
@@ -167,7 +327,9 @@ async def upsert_subscription(
|
||||
_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()
|
||||
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
|
||||
|
||||
70
backend/app/services/invoice_pdf.py
Normal file
70
backend/app/services/invoice_pdf.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Invoice PDF generation using Jinja2 + WeasyPrint."""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# PDF output directory (mounted as a Docker volume in production)
|
||||
PDF_DIR = Path(os.getenv("PDF_DIR", "/app/data/invoices"))
|
||||
|
||||
|
||||
def generate_invoice_pdf(invoice_id: str, db_session) -> str:
|
||||
"""
|
||||
Render the invoice HTML template and convert to PDF via WeasyPrint.
|
||||
|
||||
Args:
|
||||
invoice_id: UUID of the Invoice to generate
|
||||
db_session: synchronous SQLAlchemy session (used from Celery tasks)
|
||||
|
||||
Returns:
|
||||
Absolute path to the generated PDF file.
|
||||
"""
|
||||
from app.models.billing import Invoice, InvoiceLineItem
|
||||
from app.models.school import School
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from weasyprint import HTML
|
||||
|
||||
# Load invoice + line items + school
|
||||
inv = db_session.get(Invoice, invoice_id)
|
||||
if not inv:
|
||||
raise ValueError(f"Invoice {invoice_id} not found")
|
||||
|
||||
school = db_session.get(School, inv.school_id)
|
||||
line_items = db_session.query(InvoiceLineItem).filter_by(invoice_id=invoice_id).all()
|
||||
|
||||
# Format dates
|
||||
def _fmt(d) -> str:
|
||||
if not d:
|
||||
return ""
|
||||
if hasattr(d, "strftime"):
|
||||
return d.strftime("%B %d, %Y")
|
||||
return str(d)
|
||||
|
||||
context = {
|
||||
"invoice": inv,
|
||||
"school": school,
|
||||
"line_items": line_items,
|
||||
"issued_date": _fmt(inv.created_at),
|
||||
"period_start": _fmt(inv.billing_period_start),
|
||||
"period_end": _fmt(inv.billing_period_end),
|
||||
"due_date": _fmt(inv.due_date),
|
||||
"paid_date": _fmt(inv.paid_at),
|
||||
"generated_date": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
||||
}
|
||||
|
||||
# Render Jinja2 template
|
||||
template_dir = Path(__file__).parent.parent / "templates"
|
||||
env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=True)
|
||||
template = env.get_template("invoice.html")
|
||||
html_content = template.render(**context)
|
||||
|
||||
# Generate PDF
|
||||
PDF_DIR.mkdir(parents=True, exist_ok=True)
|
||||
output_path = PDF_DIR / f"{invoice_id}.pdf"
|
||||
|
||||
HTML(string=html_content).write_pdf(str(output_path))
|
||||
logger.info("Generated PDF: %s", output_path)
|
||||
|
||||
return str(output_path)
|
||||
@@ -1,45 +1,71 @@
|
||||
"""Celery tasks: invoice generation, email, overdue checks."""
|
||||
"""Celery tasks: invoice generation, PDF, email, overdue escalation."""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, timedelta, datetime, timezone
|
||||
|
||||
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)
|
||||
engine = create_engine(
|
||||
db_url.replace("postgresql+asyncpg://", "postgresql://"),
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _hub_url() -> str:
|
||||
import os
|
||||
return os.getenv("HUB_BASE_URL", "http://localhost:8090")
|
||||
|
||||
|
||||
@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 sqlalchemy import select, func
|
||||
from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem
|
||||
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))
|
||||
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()
|
||||
subs = db.execute(
|
||||
select(SchoolSubscription).where(SchoolSubscription.is_active == True)
|
||||
).scalars().all()
|
||||
|
||||
count = db.execute(select(func.count()).select_from(Invoice)).scalar_one()
|
||||
created = 0
|
||||
|
||||
for sub in subs:
|
||||
school = db.get(School, sub.school_id)
|
||||
if not school or school.status != SchoolStatus.active:
|
||||
continue
|
||||
|
||||
# Avoid duplicate invoices for the same period
|
||||
existing = db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.school_id == sub.school_id,
|
||||
Invoice.billing_period_start == billing_start,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
total = float(sub.monthly_fee)
|
||||
inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}"
|
||||
count += 1
|
||||
created += 1
|
||||
|
||||
inv = Invoice(
|
||||
school_id=sub.school_id,
|
||||
invoice_number=inv_num,
|
||||
@@ -51,6 +77,7 @@ def generate_monthly_invoices():
|
||||
)
|
||||
db.add(inv)
|
||||
db.flush()
|
||||
|
||||
db.add(InvoiceLineItem(
|
||||
invoice_id=inv.id,
|
||||
description=f"Monthly subscription — {school.name}",
|
||||
@@ -58,21 +85,47 @@ def generate_monthly_invoices():
|
||||
unit_price=float(sub.monthly_fee),
|
||||
amount=float(sub.monthly_fee),
|
||||
))
|
||||
|
||||
# Auto-send invoice email
|
||||
send_invoice_email_task.delay(inv.id)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Generated {len(subs)} invoices for {billing_start}")
|
||||
logger.info("Generated %d invoices for %s", created, billing_start)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"generate_monthly_invoices error: {e}")
|
||||
logger.error("generate_monthly_invoices error: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="billing.generate_invoice_pdf")
|
||||
def generate_invoice_pdf_task(invoice_id: str):
|
||||
"""Generate PDF for a single invoice and update the pdf_path field."""
|
||||
from app.services.invoice_pdf import generate_invoice_pdf
|
||||
from app.models.billing import Invoice
|
||||
|
||||
db = _make_session()
|
||||
try:
|
||||
path = generate_invoice_pdf(invoice_id, db)
|
||||
inv = db.get(Invoice, invoice_id)
|
||||
if inv:
|
||||
inv.pdf_path = path
|
||||
db.commit()
|
||||
logger.info("Invoice PDF generated: %s", path)
|
||||
return path
|
||||
except Exception as e:
|
||||
logger.error("generate_invoice_pdf_task error for %s: %s", invoice_id, e)
|
||||
raise
|
||||
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."""
|
||||
"""Send invoice email to school billing contact and set status to 'sent'."""
|
||||
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:
|
||||
@@ -82,44 +135,135 @@ def send_invoice_email_task(invoice_id: str):
|
||||
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
|
||||
body = (
|
||||
f"Dear {school.contact_name or school.name},\n\n"
|
||||
f"Please find your invoice {inv.invoice_number} for the period "
|
||||
f"{inv.billing_period_start} to {inv.billing_period_end}.\n\n"
|
||||
f"Amount Due: PHP {float(inv.total_amount):,.2f}\n"
|
||||
f"Due Date: {inv.due_date or 'Upon receipt'}\n\n"
|
||||
f"Please log in to your TapTrack Hub school portal to view and download your invoice:\n"
|
||||
f"{_hub_url()}/portal/billing\n\n"
|
||||
f"Thank you,\nTapTrack Hub Team"
|
||||
)
|
||||
send_email(
|
||||
to=school.billing_email,
|
||||
subject=f"Invoice {inv.invoice_number} — TapTrack Hub",
|
||||
body=body,
|
||||
)
|
||||
inv.email_sent_at = datetime.now(timezone.utc)
|
||||
if inv.status.value == "draft":
|
||||
if inv.status == InvoiceStatus.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
|
||||
"""
|
||||
Daily task: mark overdue invoices and escalate.
|
||||
- sent + past due_date → overdue
|
||||
- overdue 7+ days → warning email
|
||||
- overdue 30+ days → suspend school + suspension email
|
||||
"""
|
||||
from sqlalchemy import select, and_
|
||||
from app.models.billing import Invoice, InvoiceStatus
|
||||
from app.models.school import School, SchoolStatus
|
||||
from app.services.email import send_email
|
||||
|
||||
db = _make_session()
|
||||
try:
|
||||
today = date.today()
|
||||
overdue = db.execute(
|
||||
|
||||
# 1. Mark newly overdue
|
||||
newly_overdue = db.execute(
|
||||
select(Invoice).where(
|
||||
and_(Invoice.status == InvoiceStatus.sent, Invoice.due_date < today, Invoice.due_date != None)
|
||||
and_(
|
||||
Invoice.status == InvoiceStatus.sent,
|
||||
Invoice.due_date != None,
|
||||
Invoice.due_date < today,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
for inv in overdue:
|
||||
for inv in newly_overdue:
|
||||
inv.status = InvoiceStatus.overdue
|
||||
db.commit()
|
||||
logger.info(f"Marked {len(overdue)} invoices as overdue")
|
||||
logger.info("Marked %d invoices as overdue", len(newly_overdue))
|
||||
|
||||
# 2. Warning email: overdue 7+ days (but not yet 30)
|
||||
warn_cutoff = today - timedelta(days=7)
|
||||
suspend_cutoff = today - timedelta(days=30)
|
||||
|
||||
warn_invoices = db.execute(
|
||||
select(Invoice).where(
|
||||
and_(
|
||||
Invoice.status == InvoiceStatus.overdue,
|
||||
Invoice.due_date != None,
|
||||
Invoice.due_date <= warn_cutoff,
|
||||
Invoice.due_date > suspend_cutoff,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for inv in warn_invoices:
|
||||
school = db.get(School, inv.school_id)
|
||||
if school and school.billing_email:
|
||||
send_email(
|
||||
to=school.billing_email,
|
||||
subject=f"[TapTrack Hub] Overdue Invoice — {inv.invoice_number}",
|
||||
body=(
|
||||
f"Dear {school.contact_name or school.name},\n\n"
|
||||
f"Your invoice {inv.invoice_number} for PHP {float(inv.total_amount):,.2f} "
|
||||
f"was due on {inv.due_date} and is now overdue.\n\n"
|
||||
f"Please settle this invoice immediately to avoid account suspension.\n\n"
|
||||
f"Log in to your portal: {_hub_url()}/portal/billing\n\n"
|
||||
f"TapTrack Hub Team"
|
||||
),
|
||||
)
|
||||
logger.info("Sent %d overdue warning emails", len(warn_invoices))
|
||||
|
||||
# 3. Suspend: overdue 30+ days
|
||||
suspend_invoices = db.execute(
|
||||
select(Invoice).where(
|
||||
and_(
|
||||
Invoice.status == InvoiceStatus.overdue,
|
||||
Invoice.due_date != None,
|
||||
Invoice.due_date <= suspend_cutoff,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
suspended = 0
|
||||
for inv in suspend_invoices:
|
||||
school = db.get(School, inv.school_id)
|
||||
if school and school.status == SchoolStatus.active:
|
||||
school.status = SchoolStatus.suspended
|
||||
suspended += 1
|
||||
logger.warning(
|
||||
"Suspended school %s — overdue invoice %s (30+ days)",
|
||||
school.name, inv.invoice_number,
|
||||
)
|
||||
if school.billing_email:
|
||||
send_email(
|
||||
to=school.billing_email,
|
||||
subject=f"[TapTrack Hub] Account Suspended — Invoice {inv.invoice_number}",
|
||||
body=(
|
||||
f"Dear {school.contact_name or school.name},\n\n"
|
||||
f"Your TapTrack account has been suspended due to unpaid invoice "
|
||||
f"{inv.invoice_number} (PHP {float(inv.total_amount):,.2f}), "
|
||||
f"which was due on {inv.due_date}.\n\n"
|
||||
f"SMS notifications and automated reports are now disabled.\n\n"
|
||||
f"To restore service, please contact support@taptrack.io immediately.\n\n"
|
||||
f"TapTrack Hub Team"
|
||||
),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
logger.info("Suspended %d schools for non-payment", suspended)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("check_overdue error: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
226
backend/app/templates/invoice.html
Normal file
226
backend/app/templates/invoice.html
Normal file
@@ -0,0 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 18mm 20mm 18mm; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 11pt; color: #1e293b; line-height: 1.5; }
|
||||
|
||||
/* Header */
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; }
|
||||
.brand { }
|
||||
.brand h1 { font-size: 20pt; font-weight: 700; color: #1e40af; letter-spacing: -0.5px; }
|
||||
.brand p { font-size: 9pt; color: #64748b; margin-top: 2px; }
|
||||
.invoice-meta { text-align: right; }
|
||||
.invoice-meta .invoice-number { font-size: 15pt; font-weight: 700; color: #1e293b; }
|
||||
.invoice-meta .invoice-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; }
|
||||
.invoice-meta .date { font-size: 10pt; color: #475569; margin-top: 4px; }
|
||||
|
||||
/* Divider */
|
||||
.divider { border: none; border-top: 2px solid #e2e8f0; margin: 20px 0; }
|
||||
.divider-thin { border: none; border-top: 1px solid #e2e8f0; margin: 12px 0; }
|
||||
|
||||
/* Parties */
|
||||
.parties { display: flex; gap: 40px; margin-bottom: 28px; }
|
||||
.party { flex: 1; }
|
||||
.party-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 1px; color: #94a3b8; margin-bottom: 6px; }
|
||||
.party-name { font-weight: 700; font-size: 12pt; color: #0f172a; }
|
||||
.party p { font-size: 9.5pt; color: #475569; margin-top: 1px; }
|
||||
|
||||
/* Status pill */
|
||||
.status-pill { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 8.5pt; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.status-draft { background: #f1f5f9; color: #64748b; }
|
||||
.status-sent { background: #eff6ff; color: #1d4ed8; }
|
||||
.status-paid { background: #f0fdf4; color: #15803d; }
|
||||
.status-overdue { background: #fef2f2; color: #dc2626; }
|
||||
.status-cancelled { background: #f1f5f9; color: #94a3b8; }
|
||||
|
||||
/* Invoice info grid */
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 28px; background: #f8fafc; border-radius: 8px; padding: 16px; }
|
||||
.info-cell .info-label { font-size: 8pt; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; margin-bottom: 3px; }
|
||||
.info-cell .info-value { font-size: 10.5pt; font-weight: 600; color: #0f172a; }
|
||||
|
||||
/* Line items table */
|
||||
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||
.items-table thead th { background: #1e40af; color: white; font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.8px; padding: 9px 12px; text-align: left; }
|
||||
.items-table thead th:last-child,
|
||||
.items-table thead th:nth-child(3),
|
||||
.items-table thead th:nth-child(4) { text-align: right; }
|
||||
.items-table tbody tr:nth-child(even) { background: #f8fafc; }
|
||||
.items-table tbody td { padding: 10px 12px; font-size: 10pt; color: #334155; border-bottom: 1px solid #f1f5f9; }
|
||||
.items-table tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Totals */
|
||||
.totals { float: right; width: 260px; margin-bottom: 32px; }
|
||||
.totals table { width: 100%; border-collapse: collapse; }
|
||||
.totals td { padding: 5px 0; font-size: 10pt; color: #475569; }
|
||||
.totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.totals .total-row td { font-size: 13pt; font-weight: 700; color: #0f172a; border-top: 2px solid #e2e8f0; padding-top: 10px; margin-top: 4px; }
|
||||
.totals .paid-row td { color: #15803d; font-weight: 600; }
|
||||
.clearfix::after { content: ""; display: table; clear: both; }
|
||||
|
||||
/* Payment info */
|
||||
.payment-info { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 14px 16px; margin-bottom: 24px; }
|
||||
.payment-info h3 { font-size: 9.5pt; font-weight: 700; color: #1e40af; margin-bottom: 6px; }
|
||||
.payment-info p { font-size: 9.5pt; color: #1e3a8a; }
|
||||
.payment-info .ref { font-family: monospace; background: #dbeafe; padding: 2px 6px; border-radius: 3px; font-size: 9pt; }
|
||||
|
||||
/* Footer */
|
||||
.footer { margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }
|
||||
.footer p { font-size: 8.5pt; color: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="brand">
|
||||
<h1>TapTrack Hub</h1>
|
||||
<p>Cloud Control Plane for TapTrack Deployments</p>
|
||||
</div>
|
||||
<div class="invoice-meta">
|
||||
<div class="invoice-label">Invoice</div>
|
||||
<div class="invoice-number">{{ invoice.invoice_number }}</div>
|
||||
<div class="date">Issued {{ issued_date }}</div>
|
||||
<div style="margin-top:6px">
|
||||
<span class="status-pill status-{{ invoice.status }}">{{ invoice.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<!-- Bill To / From -->
|
||||
<div class="parties">
|
||||
<div class="party">
|
||||
<div class="party-label">Bill To</div>
|
||||
<div class="party-name">{{ school.name }}</div>
|
||||
{% if school.address %}<p>{{ school.address }}</p>{% endif %}
|
||||
{% if school.city %}<p>{{ school.city }}</p>{% endif %}
|
||||
{% if school.billing_email %}<p>{{ school.billing_email }}</p>{% endif %}
|
||||
{% if school.contact_phone %}<p>{{ school.contact_phone }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="party">
|
||||
<div class="party-label">From</div>
|
||||
<div class="party-name">TapTrack Hub</div>
|
||||
<p>Cloud Services</p>
|
||||
<p>support@taptrack.io</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice info -->
|
||||
<div class="info-grid">
|
||||
<div class="info-cell">
|
||||
<div class="info-label">Billing Period</div>
|
||||
<div class="info-value">{{ period_start }} — {{ period_end }}</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<div class="info-label">Due Date</div>
|
||||
<div class="info-value">{{ due_date if due_date else 'Upon receipt' }}</div>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<div class="info-label">Currency</div>
|
||||
<div class="info-value">{{ invoice.currency }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line items -->
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:55%">Description</th>
|
||||
<th style="width:15%">Qty</th>
|
||||
<th style="width:15%">Unit Price</th>
|
||||
<th style="width:15%">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in line_items %}
|
||||
<tr>
|
||||
<td>{{ item.description }}</td>
|
||||
<td class="num">{{ item.quantity }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(item.unit_price) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(item.amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not line_items %}
|
||||
{% if invoice.subscription_amount > 0 %}
|
||||
<tr>
|
||||
<td>Monthly Subscription — {{ school.name }}</td>
|
||||
<td class="num">1</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.subscription_amount) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.subscription_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if invoice.sms_credit_amount > 0 %}
|
||||
<tr>
|
||||
<td>SMS Credits</td>
|
||||
<td class="num">1</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.sms_credit_amount) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.sms_credit_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if invoice.other_amount > 0 %}
|
||||
<tr>
|
||||
<td>Other Charges</td>
|
||||
<td class="num">1</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.other_amount) }}</td>
|
||||
<td class="num">{{ invoice.currency }} {{ "{:,.2f}".format(invoice.other_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Totals + Payment info -->
|
||||
<div class="clearfix">
|
||||
<div class="totals">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Subtotal</td>
|
||||
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
|
||||
</tr>
|
||||
<tr class="total-row">
|
||||
<td>Total Due</td>
|
||||
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
|
||||
</tr>
|
||||
{% if invoice.paid_at %}
|
||||
<tr class="paid-row">
|
||||
<td>Paid</td>
|
||||
<td>{{ invoice.currency }} {{ "{:,.2f}".format(invoice.total_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if invoice.paid_at %}
|
||||
<div class="payment-info" style="background:#f0fdf4;border-color:#bbf7d0">
|
||||
<h3 style="color:#15803d">Payment Received</h3>
|
||||
<p>Paid on {{ paid_date }} via {{ invoice.payment_method or 'N/A' }}
|
||||
{% if invoice.payment_reference %} · Ref: <span class="ref">{{ invoice.payment_reference }}</span>{% endif %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="payment-info">
|
||||
<h3>Payment Instructions</h3>
|
||||
<p>Please log in to your TapTrack Hub school portal and submit payment by <strong>{{ due_date if due_date else 'the due date' }}</strong>.</p>
|
||||
<p style="margin-top:4px">For questions, contact <strong>support@taptrack.io</strong></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if invoice.notes %}
|
||||
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:12px 14px;margin-bottom:16px">
|
||||
<strong style="font-size:9pt;color:#92400e">Notes:</strong>
|
||||
<p style="font-size:9.5pt;color:#78350f;margin-top:3px">{{ invoice.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>TapTrack Hub · support@taptrack.io</p>
|
||||
<p>Generated {{ generated_date }} · {{ invoice.invoice_number }}</p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user