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,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()
|
||||
|
||||
Reference in New Issue
Block a user