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:
kevin-asprec
2026-03-16 13:46:33 +08:00
parent e45e63903d
commit 0e0803e417
9 changed files with 920 additions and 85 deletions

View File

@@ -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()