Backend: - app/models/email_log.py: EmailLog table (school_id, to, subject, type, status, error, sent_at) with EmailType + EmailStatus enums - migrations/002_phase9_email_logs.py: Alembic migration for email_logs table - app/templates/email/: 6 Jinja2 HTML templates — base layout, invoice, low_credit, license_expiry, overdue_warning, suspension - app/services/email.py: enhanced send_email() — accepts template_name+context for HTML rendering, logs every attempt to email_logs, retries up to 3x on transient SMTP failure with exponential backoff - app/routers/email.py: GET /api/email/logs (paginated, filterable by type/status/school), POST /api/email/test (send test email, super admin) - tasks/billing.py: invoice + overdue warning + suspension emails now use HTML templates - tasks/sms.py: low credit alert now uses HTML template - tasks/license.py: expiry warning now uses HTML template - app/main.py + migrations/env.py: wire in email_log model + email router Frontend: - EmailLogsPage.vue: table with to/subject/type badge/status badge/sent_at/error, type+status filters, pagination, Send Test Email modal - router/index.ts: /email-logs route - AppSidebar.vue: Email Logs nav item - api.ts: getEmailLogs, sendTestEmail
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""Celery tasks: invoice generation, PDF, email, overdue escalation."""
|
|
import logging
|
|
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,
|
|
)
|
|
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 sqlalchemy import select, func
|
|
from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem
|
|
from app.models.school import School, SchoolStatus
|
|
|
|
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()
|
|
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,
|
|
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),
|
|
))
|
|
|
|
# Auto-send invoice email
|
|
send_invoice_email_task.delay(inv.id)
|
|
|
|
db.commit()
|
|
logger.info("Generated %d invoices for %s", created, billing_start)
|
|
except Exception as e:
|
|
db.rollback()
|
|
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 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
|
|
|
|
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
|
|
|
|
plain = (
|
|
f"Dear {school.contact_name or school.name},\n\n"
|
|
f"Your invoice {inv.invoice_number} for PHP {float(inv.total_amount):,.2f} "
|
|
f"covering {inv.billing_period_start} to {inv.billing_period_end} is ready.\n"
|
|
f"Due: {inv.due_date or 'Upon receipt'}\n\n"
|
|
f"Log in: {_hub_url()}/portal/billing\n\nTapTrack Hub Team"
|
|
)
|
|
send_email(
|
|
to=school.billing_email,
|
|
subject=f"Invoice {inv.invoice_number} — TapTrack Hub",
|
|
body=plain,
|
|
template_name="email/invoice.html",
|
|
context={
|
|
"contact_name": school.contact_name or school.name,
|
|
"school_name": school.name,
|
|
"invoice_number": inv.invoice_number,
|
|
"period_start": str(inv.billing_period_start),
|
|
"period_end": str(inv.billing_period_end),
|
|
"amount": f"{float(inv.total_amount):,.2f}",
|
|
"due_date": str(inv.due_date) if inv.due_date else "Upon receipt",
|
|
},
|
|
email_type="invoice",
|
|
school_id=school.id,
|
|
)
|
|
inv.email_sent_at = datetime.now(timezone.utc)
|
|
if inv.status == InvoiceStatus.draft:
|
|
inv.status = InvoiceStatus.sent
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@celery_app.task(name="billing.check_overdue")
|
|
def check_overdue():
|
|
"""
|
|
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()
|
|
|
|
# 1. Mark newly overdue
|
|
newly_overdue = db.execute(
|
|
select(Invoice).where(
|
|
and_(
|
|
Invoice.status == InvoiceStatus.sent,
|
|
Invoice.due_date != None,
|
|
Invoice.due_date < today,
|
|
)
|
|
)
|
|
).scalars().all()
|
|
for inv in newly_overdue:
|
|
inv.status = InvoiceStatus.overdue
|
|
db.commit()
|
|
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:
|
|
days_overdue = (today - inv.due_date).days
|
|
days_until_suspension = max(0, 30 - days_overdue)
|
|
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"Invoice {inv.invoice_number} (PHP {float(inv.total_amount):,.2f}) "
|
|
f"was due on {inv.due_date} and is now overdue.\n"
|
|
f"Please settle immediately to avoid suspension.\n\n"
|
|
f"Portal: {_hub_url()}/portal/billing\n\nTapTrack Hub Team"
|
|
),
|
|
template_name="email/overdue_warning.html",
|
|
context={
|
|
"contact_name": school.contact_name or school.name,
|
|
"school_name": school.name,
|
|
"invoice_number": inv.invoice_number,
|
|
"amount": f"{float(inv.total_amount):,.2f}",
|
|
"due_date": str(inv.due_date),
|
|
"days_overdue": days_overdue,
|
|
"days_until_suspension": days_until_suspension,
|
|
},
|
|
email_type="overdue_warning",
|
|
school_id=school.id,
|
|
)
|
|
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"Your TapTrack account for {school.name} has been suspended.\n"
|
|
f"Invoice {inv.invoice_number} (PHP {float(inv.total_amount):,.2f}) "
|
|
f"was due on {inv.due_date} and remains unpaid.\n\n"
|
|
f"Contact support@taptrack.io to restore service."
|
|
),
|
|
template_name="email/suspension.html",
|
|
context={
|
|
"contact_name": school.contact_name or school.name,
|
|
"school_name": school.name,
|
|
"invoice_number": inv.invoice_number,
|
|
"amount": f"{float(inv.total_amount):,.2f}",
|
|
"due_date": str(inv.due_date),
|
|
},
|
|
email_type="suspension",
|
|
school_id=school.id,
|
|
)
|
|
|
|
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()
|