Transactional webhook event persistence and Oban job dispatch (D2-24).
All three writes (webhook_event row, Oban job, accrue_events ledger entry)
succeed atomically or none do. Duplicate POSTs are idempotent via the
UNIQUE(processor, processor_event_id) constraint with on_conflict: :nothing.
Transaction shape
Ecto.Multi
|> run(:persist, ...) # check-then-insert with on_conflict guard
|> run(:maybe_enqueue, ...) # Oban job only for new events
|> run(:maybe_ledger, ...) # accrue_events only for new eventsDuplicate detection uses an explicit SELECT-then-INSERT pattern inside a
single Multi.run step. The on_conflict: :nothing guard on the INSERT
handles the race condition where a concurrent request inserts between
SELECT and INSERT. With binary_id autogenerate, Ecto generates UUIDs
client-side, so the Pitfall 2 approach (id: nil on conflict) does not
work -- the struct always has an id regardless of conflict.
Summary
Functions
Runs the transactional ingest pipeline for a verified webhook event.
Functions
@spec run(Plug.Conn.t(), atom(), LatticeStripe.Event.t(), binary(), atom() | nil) :: Plug.Conn.t()
Runs the transactional ingest pipeline for a verified webhook event.
Called by Accrue.Webhook.Plug after signature verification succeeds.
Returns the Plug.Conn with a 200 (success or duplicate) or 500 (failure).
Endpoint
Phase 5 (05-01, D5-01): the optional endpoint argument is persisted on
the accrue_webhook_events row so Accrue.Webhook.DispatchWorker can
branch on it and route :connect-scoped events to
Accrue.Webhook.ConnectHandler. Accepts either the atom :connect (which
persists as :connect) or any other value (persists as :default) so
existing Phase 2-4 multi-endpoint configs using names like :primary or
:unconfigured continue to land in the default lane without needing a
schema migration per endpoint name.