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>
94 lines
4.2 KiB
Python
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,
|
|
)
|