"""Celery tasks: invoice generation, email, overdue checks.""" import logging from datetime import date, timedelta 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)() @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 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)) 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() for sub in subs: school = db.get(School, sub.school_id) if not school or school.status != SchoolStatus.active: continue total = float(sub.monthly_fee) inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}" count += 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), )) db.commit() logger.info(f"Generated {len(subs)} invoices for {billing_start}") except Exception as e: db.rollback() logger.error(f"generate_monthly_invoices error: {e}") 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.""" 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: 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 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 inv.email_sent_at = datetime.now(timezone.utc) if inv.status.value == "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 from sqlalchemy import select, and_ db = _make_session() try: today = date.today() overdue = db.execute( select(Invoice).where( and_(Invoice.status == InvoiceStatus.sent, Invoice.due_date < today, Invoice.due_date != None) ) ).scalars().all() for inv in overdue: inv.status = InvoiceStatus.overdue db.commit() logger.info(f"Marked {len(overdue)} invoices as overdue") finally: db.close()