""" app/cron.py ─────────── Protected billing-scan daemon triggered via POST /api/cron/run. Authorization guard: The endpoint rejects any request unless the Authorization header carries a Bearer token that exactly matches the CRON_SECRET_TOKEN env variable. The comparison is done with hmac.compare_digest to prevent timing attacks. Execution order (per spec): 1. Sweep & Recover — retry all NotificationLog rows with status = "Failed" 2. Upcoming Reminders — send alerts for invoices where today < pay_date and today is one of the notification_dates intervals 3. Overdue Notices — send recurring alerts for invoices where today > pay_date at every multiple of overdue_recurring_days """ from __future__ import annotations import hmac import logging from datetime import date from typing import Any, Dict from fastapi import APIRouter, Depends, Header, HTTPException, status from jinja2 import BaseLoader, Environment, TemplateSyntaxError from sqlalchemy import func from sqlalchemy.orm import Session from app.config import settings from app.crud import create_notification_log, update_notification_status from app.database import get_db from app.mail import send_email from app.models import ( Invoice, NotificationLog, STATUS_DELIVERED, STATUS_RELAY, STATUS_FAILED, ) router = APIRouter(prefix="/api/cron", tags=["cron"]) logger = logging.getLogger(__name__) # Jinja2 environment — sandboxed, no filesystem loader _jinja = Environment(loader=BaseLoader(), autoescape=False) # ───────────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────────── def _verify_cron_token(authorization: str = Header(default=None)) -> None: """Constant-time bearer-token check. Raises 401/403 on failure.""" if not authorization or not authorization.startswith("Bearer "): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing or malformed Authorization header.", ) token = authorization[len("Bearer "):] if not hmac.compare_digest( token.encode("utf-8"), settings.CRON_SECRET_TOKEN.encode("utf-8"), ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid cron secret token.", ) def _render(template_str: str, ctx: Dict[str, Any]) -> str: """Render a Jinja2 template string; fall back to raw string on syntax error.""" try: return _jinja.from_string(template_str).render(**ctx) except TemplateSyntaxError as exc: logger.warning("Jinja2 syntax error in template: %s", exc) return template_str def _already_sent_today(db: Session, invoice_id: int, today: date) -> bool: """Return True if a successful (non-Failed) log exists for this invoice today.""" return bool( db.query(NotificationLog) .filter( NotificationLog.invoice_id == invoice_id, func.date(NotificationLog.sent_at) == today, NotificationLog.status != STATUS_FAILED, ) .first() ) def _dispatch( db: Session, invoice: Invoice, subject: str, body: str, ctx: Dict[str, Any], ) -> str: """Send email and create a NotificationLog; returns delivery status.""" client = invoice.client try: delivery_status = send_email(client.client_email, subject, body) create_notification_log(db, invoice.id, delivery_status, ctx) return delivery_status except Exception as exc: create_notification_log(db, invoice.id, STATUS_FAILED, ctx, str(exc)) return STATUS_FAILED # ═══════════════════════════════════════════════════════════════════════════════ # /api/cron/run # ═══════════════════════════════════════════════════════════════════════════════ @router.post("/run", summary="Run billing scan (protected — requires CRON_SECRET_TOKEN)") def run_cron( db: Session = Depends(get_db), authorization: str = Header(default=None), ): _verify_cron_token(authorization) today = date.today() results: Dict[str, Any] = { "date": str(today), "recovered": 0, "reminders_sent": 0, "overdue_sent": 0, "errors": [], } # ══════════════════════════════════════════════════════════════════════════ # STEP 1 — Sweep & Recover failed queue # ══════════════════════════════════════════════════════════════════════════ failed_logs = ( db.query(NotificationLog) .filter(NotificationLog.status == STATUS_FAILED) .all() ) for log in failed_logs: invoice = db.query(Invoice).filter(Invoice.id == log.invoice_id).first() # Skip ghost references or already-paid invoices if not invoice or invoice.paid: continue client = invoice.client template = invoice.template ctx = { "client_name": client.client_name, "client_email": client.client_email, "invoice_number": invoice.invoice_number, "pay_date": str(invoice.pay_date), } try: body = _render(template.template_body, ctx) subject = _render(template.subject, ctx) new_status = send_email(client.client_email, subject, body) update_notification_status(db, log.id, new_status) results["recovered"] += 1 except Exception as exc: logger.error("Recovery failed for log #%d: %s", log.id, exc) update_notification_status(db, log.id, STATUS_FAILED, str(exc)) results["errors"].append(f"log#{log.id}: {exc}") # ══════════════════════════════════════════════════════════════════════════ # STEP 2 — Upcoming reminders (today < pay_date, not yet paid) # ══════════════════════════════════════════════════════════════════════════ upcoming = ( db.query(Invoice) .filter(Invoice.paid.is_(False), Invoice.pay_date > today) .all() ) for invoice in upcoming: days_until = (invoice.pay_date - today).days notification_days = invoice.notification_dates or [] if days_until not in notification_days: continue if _already_sent_today(db, invoice.id, today): continue client = invoice.client template = invoice.template ctx = { "client_name": client.client_name, "client_email": client.client_email, "invoice_number": invoice.invoice_number, "pay_date": str(invoice.pay_date), "days_until": days_until, "trigger": "reminder", } result_status = _dispatch( db, invoice, _render(template.subject, ctx), _render(template.template_body, ctx), ctx, ) if result_status != STATUS_FAILED: results["reminders_sent"] += 1 else: results["errors"].append(f"invoice#{invoice.id} reminder failed") # ══════════════════════════════════════════════════════════════════════════ # STEP 3 — Overdue notices (today > pay_date, recurring interval) # ══════════════════════════════════════════════════════════════════════════ overdue = ( db.query(Invoice) .filter( Invoice.paid.is_(False), Invoice.pay_date < today, Invoice.overdue_recurring_days > 0, ) .all() ) for invoice in overdue: days_overdue = (today - invoice.pay_date).days interval = invoice.overdue_recurring_days # Fire only on exact multiples of the interval (e.g. every 7 days) if days_overdue == 0 or days_overdue % interval != 0: continue if _already_sent_today(db, invoice.id, today): continue client = invoice.client template = invoice.template ctx = { "client_name": client.client_name, "client_email": client.client_email, "invoice_number": invoice.invoice_number, "pay_date": str(invoice.pay_date), "days_overdue": days_overdue, "trigger": "overdue", } result_status = _dispatch( db, invoice, _render(template.subject, ctx), _render(template.template_body, ctx), ctx, ) if result_status != STATUS_FAILED: results["overdue_sent"] += 1 else: results["errors"].append(f"invoice#{invoice.id} overdue notice failed") logger.info( "[CRON] %s → recovered=%d reminders=%d overdue=%d errors=%d", today, results["recovered"], results["reminders_sent"], results["overdue_sent"], len(results["errors"]), ) return results