38686-vm/core/signals.py
Konrad du Plessis a52d841c00 feat(webhooks): outbound payslip webhook → Make.com / Zapier / n8n
First integration: when a PayrollRecord is created, the app POSTs a
JSON summary to WEBHOOK_PAYSLIP_URL (env var) if set. Unset = feature
OFF; no network call, no behaviour change. Typical destination: a
Make.com / Zapier / n8n Catch-Hook URL that fans the event out to
Google Sheets / Airtable / Slack / etc. — no more Python required.

This is the highest-leverage starter integration per the research plan
at ~/.claude/plans/prancy-painting-brook.md (Section B). One webhook
sender unlocks 5000+ downstream destinations via visual workflow UIs,
and the pattern (post_save signal + env-var gate + try/except) becomes
the template for future event types.

Implementation:
  - core/signals.py (new) — post_save receiver on PayrollRecord;
    fires only on created=True; short-circuits when env var empty;
    swallows all network errors with a WARNING log so payslip save
    is never blocked
  - core/apps.py — ready() imports signals for dispatcher registration
  - config/settings.py — reads WEBHOOK_PAYSLIP_URL env var (default "")
  - requirements.txt — adds requests>=2.32.0 (de facto Python HTTP lib,
    no prior outbound-HTTP code in the codebase)
  - CLAUDE.md — documents the env var + the non-fatal failure contract
    + points at PayslipWebhookTests for the behavioural spec

Payload shape: event, payslip_id, worker_id, worker_name, amount_paid
(as string for Decimal safety), payslip_date (ISO), work_log_count,
adjustment_count, admin_url. No unbounded text fields; no secrets.

Tests (4 new, PayslipWebhookTests):
  - fires when configured with right payload
  - no-op when env var unset
  - swallows ConnectionError without breaking PayrollRecord.save()
  - does NOT refire on subsequent .save() of an existing record
Full suite: 73/73.

Risks + rollback: trivial. Revert the commit, no data impact. Make.com
handles its own retries; if the webhook is down we just miss events
until it comes back.

Out of scope for v1 (deferred): other event types (adjustment.created,
loan.issued), HMAC signing, in-app retry queue, inbound webhooks, AI
integrations, public read-only API. All are on the roadmap in the plan
doc; each follows the same signal-based pattern and is cheap to add.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:39:01 +02:00

94 lines
4.2 KiB
Python

# === core/signals.py ===
# Django signal handlers that bridge in-app events to the outside world.
#
# Currently hosts ONE handler: a post_save hook on PayrollRecord that
# posts a JSON summary to settings.WEBHOOK_PAYSLIP_URL whenever a new
# payslip is created. Intended to feed Make.com / Zapier / n8n scenarios
# that fan the event out to Google Sheets, Airtable, Slack, etc.
#
# Design notes:
# - Fires on `created=True` ONLY (post_save is also called on updates;
# we don't care about those — the payslip is a new event, not a diff).
# - Feature is OFF by default: if WEBHOOK_PAYSLIP_URL is empty/unset,
# the handler short-circuits before any network call.
# - Failures are NEVER fatal. By the time post_save fires, the database
# transaction has already committed — the payslip exists regardless
# of what happens here. So we log and move on. No retry, no queue.
# If you need retry semantics, add them in the receiving service
# (Make.com and Zapier both retry failed webhook deliveries by
# default).
# - Payload is deliberately small + stable. No unbounded text fields
# (adjustment descriptions, PDFs, etc.) — those stay in the app.
# Receivers can always fetch the admin_url for the full record.
import logging
import requests
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from core.models import PayrollRecord
logger = logging.getLogger(__name__)
# How long to wait for the webhook endpoint to respond before giving up.
# Short enough to not block the Django worker for long; long enough that
# a slow Make.com scenario still usually succeeds.
_WEBHOOK_TIMEOUT_SECONDS = 5
@receiver(post_save, sender=PayrollRecord)
def send_payslip_webhook(sender, instance, created, **kwargs):
"""Post a JSON summary to WEBHOOK_PAYSLIP_URL when a PayrollRecord is created.
Non-fatal on any failure — the payslip is already saved by the
time we get here, so a missed webhook is a notification problem,
not a data-integrity problem.
"""
# Only fire on creation, not on every save (edits, reassignments, etc.)
if not created:
return
# Feature flag: if the env var is empty/unset, do nothing.
url = (getattr(settings, "WEBHOOK_PAYSLIP_URL", "") or "").strip()
if not url:
return
# --- Build the payload ---
# Every key is a simple scalar (string, int, null) so it survives
# JSON round-tripping cleanly. Decimals become strings to preserve
# the "R 5420.00" look downstream (Google Sheets, Make.com etc.
# don't always round-trip Decimal precision cleanly).
payload = {
"event": "payslip.created",
"payslip_id": instance.id,
"worker_id": instance.worker_id,
"worker_name": instance.worker.name,
"amount_paid": str(instance.amount_paid),
# instance.date is the payslip date, not a wall-clock timestamp
# (PayrollRecord has no created_at field). Good enough for a
# "created on what day" marker on the receiving side.
"payslip_date": instance.date.isoformat() if instance.date else None,
# These counts require extra queries; fine at 1 payslip/second
# rates (we're never firing this in a loop).
"work_log_count": instance.work_logs.count(),
"adjustment_count": instance.adjustments.count(), # related_name on PayrollAdjustment.payroll_record FK
# Fully-qualified URL so a Slack/Sheets link works for the
# recipient without requiring them to know the host.
"admin_url": f"https://foxlog.flatlogic.app/payroll/payslip/{instance.id}/",
}
# --- Make the network call ---
# Any exception is swallowed + logged. The bare `except Exception`
# is deliberate here: we want to catch ConnectionError, Timeout,
# SSL errors, malformed JSON responses, DNS failures, even a
# BaseException that some misconfigured middleware might raise.
# Nothing downstream of this handler depends on success.
try:
requests.post(url, json=payload, timeout=_WEBHOOK_TIMEOUT_SECONDS)
except Exception as e: # noqa: BLE001 — see comment above
logger.warning(
"Payslip webhook failed for PayrollRecord #%s: %s: %s",
instance.id, type(e).__name__, e,
)