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