Accrue.Webhook.ConnectHandler (accrue v0.3.0)

Copy Markdown View Source

Webhook handler for events arriving on the :connect endpoint.

Stripe Connect platforms receive two distinct webhook streams:

  1. Platform events (/webhooks/stripe) — account-scoped events targeting the platform account itself; dispatched to Accrue.Webhook.DefaultHandler.
  2. Connect events (/webhooks/stripe/connect) — events relayed by Stripe from a connected account (account.updated, account.application.{authorized,deauthorized}, capability.updated, payout.*, person.*); dispatched to this module.

The Accrue.Webhook.DispatchWorker reads row.endpoint from the persisted accrue_webhook_events row and selects the handler module at dispatch time. This module implements the Connect-specific reducers.

Reducer shape

Every reducer runs inside Accrue.Repo.transact/1 with an Accrue.Events.record/1 call appended so the state mutation and audit row commit atomically. For events whose payload is needed beyond the %Accrue.Webhook.Event{} struct's object_id, the raw accrue_webhook_events.data jsonb row is refetched via ctx.webhook_event_id — the handler never trusts the lean Event struct alone for anything beyond routing.

Out-of-order delivery (Pitfall 3)

Stripe does not guarantee webhook delivery order. An account.updated for a just-created account can arrive before the local accrue_connect_accounts row has been inserted. The reducer handles this by calling Accrue.Connect.retrieve_account/2, which runs the canonical processor round-trip and upserts the local row via Accrue.Connect.Account.force_status_changeset/2 — so a missing row is seeded from the latest Stripe state in the same transaction that would otherwise have failed. Later, in-order events arriving on a now-settled row update it the same way (idempotent).

The reducer uses "refetch canonical" rather than "compare timestamps" for stale-event detection: because the Stripe round-trip always returns the current account state, a stale webhook replay overwrites the row with the same values it already has — a functional no-op — rather than clobbering later events with older snapshot data (Pitfall 3).

Deauthorization tombstoning

account.application.deauthorized NEVER hard-deletes the local row. It stamps deauthorized_at via force_status_changeset/2 so the row survives for audit, and emits an ops telemetry event [:accrue, :ops, :connect_account_deauthorized] via Accrue.Telemetry.Ops.

Scope

Reducers for person.* log a debug line and no-op — Custom-account person KYC is deferred to v1.x.