| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- """
- 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
|