cron.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. """
  2. app/cron.py
  3. ───────────
  4. Protected billing-scan daemon triggered via POST /api/cron/run.
  5. Authorization guard:
  6. The endpoint rejects any request unless the Authorization header carries
  7. a Bearer token that exactly matches the CRON_SECRET_TOKEN env variable.
  8. The comparison is done with hmac.compare_digest to prevent timing attacks.
  9. Execution order (per spec):
  10. 1. Sweep & Recover — retry all NotificationLog rows with status = "Failed"
  11. 2. Upcoming Reminders — send alerts for invoices where today < pay_date
  12. and today is one of the notification_dates intervals
  13. 3. Overdue Notices — send recurring alerts for invoices where today > pay_date
  14. at every multiple of overdue_recurring_days
  15. """
  16. from __future__ import annotations
  17. import hmac
  18. import logging
  19. from datetime import date
  20. from typing import Any, Dict
  21. from fastapi import APIRouter, Depends, Header, HTTPException, status
  22. from jinja2 import BaseLoader, Environment, TemplateSyntaxError
  23. from sqlalchemy import func
  24. from sqlalchemy.orm import Session
  25. from app.config import settings
  26. from app.crud import create_notification_log, update_notification_status
  27. from app.database import get_db
  28. from app.mail import send_email
  29. from app.models import (
  30. Invoice, NotificationLog,
  31. STATUS_DELIVERED, STATUS_RELAY, STATUS_FAILED,
  32. )
  33. router = APIRouter(prefix="/api/cron", tags=["cron"])
  34. logger = logging.getLogger(__name__)
  35. # Jinja2 environment — sandboxed, no filesystem loader
  36. _jinja = Environment(loader=BaseLoader(), autoescape=False)
  37. # ─────────────────────────────────────────────────────────────────────────────
  38. # Helpers
  39. # ─────────────────────────────────────────────────────────────────────────────
  40. def _verify_cron_token(authorization: str = Header(default=None)) -> None:
  41. """Constant-time bearer-token check. Raises 401/403 on failure."""
  42. if not authorization or not authorization.startswith("Bearer "):
  43. raise HTTPException(
  44. status_code=status.HTTP_401_UNAUTHORIZED,
  45. detail="Missing or malformed Authorization header.",
  46. )
  47. token = authorization[len("Bearer "):]
  48. if not hmac.compare_digest(
  49. token.encode("utf-8"),
  50. settings.CRON_SECRET_TOKEN.encode("utf-8"),
  51. ):
  52. raise HTTPException(
  53. status_code=status.HTTP_403_FORBIDDEN,
  54. detail="Invalid cron secret token.",
  55. )
  56. def _render(template_str: str, ctx: Dict[str, Any]) -> str:
  57. """Render a Jinja2 template string; fall back to raw string on syntax error."""
  58. try:
  59. return _jinja.from_string(template_str).render(**ctx)
  60. except TemplateSyntaxError as exc:
  61. logger.warning("Jinja2 syntax error in template: %s", exc)
  62. return template_str
  63. def _already_sent_today(db: Session, invoice_id: int, today: date) -> bool:
  64. """Return True if a successful (non-Failed) log exists for this invoice today."""
  65. return bool(
  66. db.query(NotificationLog)
  67. .filter(
  68. NotificationLog.invoice_id == invoice_id,
  69. func.date(NotificationLog.sent_at) == today,
  70. NotificationLog.status != STATUS_FAILED,
  71. )
  72. .first()
  73. )
  74. def _dispatch(
  75. db: Session,
  76. invoice: Invoice,
  77. subject: str,
  78. body: str,
  79. ctx: Dict[str, Any],
  80. ) -> str:
  81. """Send email and create a NotificationLog; returns delivery status."""
  82. client = invoice.client
  83. try:
  84. delivery_status = send_email(client.client_email, subject, body)
  85. create_notification_log(db, invoice.id, delivery_status, ctx)
  86. return delivery_status
  87. except Exception as exc:
  88. create_notification_log(db, invoice.id, STATUS_FAILED, ctx, str(exc))
  89. return STATUS_FAILED
  90. # ═══════════════════════════════════════════════════════════════════════════════
  91. # /api/cron/run
  92. # ═══════════════════════════════════════════════════════════════════════════════
  93. @router.post("/run", summary="Run billing scan (protected — requires CRON_SECRET_TOKEN)")
  94. def run_cron(
  95. db: Session = Depends(get_db),
  96. authorization: str = Header(default=None),
  97. ):
  98. _verify_cron_token(authorization)
  99. today = date.today()
  100. results: Dict[str, Any] = {
  101. "date": str(today),
  102. "recovered": 0,
  103. "reminders_sent": 0,
  104. "overdue_sent": 0,
  105. "errors": [],
  106. }
  107. # ══════════════════════════════════════════════════════════════════════════
  108. # STEP 1 — Sweep & Recover failed queue
  109. # ══════════════════════════════════════════════════════════════════════════
  110. failed_logs = (
  111. db.query(NotificationLog)
  112. .filter(NotificationLog.status == STATUS_FAILED)
  113. .all()
  114. )
  115. for log in failed_logs:
  116. invoice = db.query(Invoice).filter(Invoice.id == log.invoice_id).first()
  117. # Skip ghost references or already-paid invoices
  118. if not invoice or invoice.paid:
  119. continue
  120. client = invoice.client
  121. template = invoice.template
  122. ctx = {
  123. "client_name": client.client_name,
  124. "client_email": client.client_email,
  125. "invoice_number": invoice.invoice_number,
  126. "pay_date": str(invoice.pay_date),
  127. }
  128. try:
  129. body = _render(template.template_body, ctx)
  130. subject = _render(template.subject, ctx)
  131. new_status = send_email(client.client_email, subject, body)
  132. update_notification_status(db, log.id, new_status)
  133. results["recovered"] += 1
  134. except Exception as exc:
  135. logger.error("Recovery failed for log #%d: %s", log.id, exc)
  136. update_notification_status(db, log.id, STATUS_FAILED, str(exc))
  137. results["errors"].append(f"log#{log.id}: {exc}")
  138. # ══════════════════════════════════════════════════════════════════════════
  139. # STEP 2 — Upcoming reminders (today < pay_date, not yet paid)
  140. # ══════════════════════════════════════════════════════════════════════════
  141. upcoming = (
  142. db.query(Invoice)
  143. .filter(Invoice.paid.is_(False), Invoice.pay_date > today)
  144. .all()
  145. )
  146. for invoice in upcoming:
  147. days_until = (invoice.pay_date - today).days
  148. notification_days = invoice.notification_dates or []
  149. if days_until not in notification_days:
  150. continue
  151. if _already_sent_today(db, invoice.id, today):
  152. continue
  153. client = invoice.client
  154. template = invoice.template
  155. ctx = {
  156. "client_name": client.client_name,
  157. "client_email": client.client_email,
  158. "invoice_number": invoice.invoice_number,
  159. "pay_date": str(invoice.pay_date),
  160. "days_until": days_until,
  161. "trigger": "reminder",
  162. }
  163. result_status = _dispatch(
  164. db, invoice,
  165. _render(template.subject, ctx),
  166. _render(template.template_body, ctx),
  167. ctx,
  168. )
  169. if result_status != STATUS_FAILED:
  170. results["reminders_sent"] += 1
  171. else:
  172. results["errors"].append(f"invoice#{invoice.id} reminder failed")
  173. # ══════════════════════════════════════════════════════════════════════════
  174. # STEP 3 — Overdue notices (today > pay_date, recurring interval)
  175. # ══════════════════════════════════════════════════════════════════════════
  176. overdue = (
  177. db.query(Invoice)
  178. .filter(
  179. Invoice.paid.is_(False),
  180. Invoice.pay_date < today,
  181. Invoice.overdue_recurring_days > 0,
  182. )
  183. .all()
  184. )
  185. for invoice in overdue:
  186. days_overdue = (today - invoice.pay_date).days
  187. interval = invoice.overdue_recurring_days
  188. # Fire only on exact multiples of the interval (e.g. every 7 days)
  189. if days_overdue == 0 or days_overdue % interval != 0:
  190. continue
  191. if _already_sent_today(db, invoice.id, today):
  192. continue
  193. client = invoice.client
  194. template = invoice.template
  195. ctx = {
  196. "client_name": client.client_name,
  197. "client_email": client.client_email,
  198. "invoice_number": invoice.invoice_number,
  199. "pay_date": str(invoice.pay_date),
  200. "days_overdue": days_overdue,
  201. "trigger": "overdue",
  202. }
  203. result_status = _dispatch(
  204. db, invoice,
  205. _render(template.subject, ctx),
  206. _render(template.template_body, ctx),
  207. ctx,
  208. )
  209. if result_status != STATUS_FAILED:
  210. results["overdue_sent"] += 1
  211. else:
  212. results["errors"].append(f"invoice#{invoice.id} overdue notice failed")
  213. logger.info(
  214. "[CRON] %s → recovered=%d reminders=%d overdue=%d errors=%d",
  215. today,
  216. results["recovered"],
  217. results["reminders_sent"],
  218. results["overdue_sent"],
  219. len(results["errors"]),
  220. )
  221. return results