| 1 |
|
defmodule WalletIntegrations.Commands.IngestCallback do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Ingest an inbound provider webhook/callback. |
| 4 |
|
|
| 5 |
|
Pipeline: |
| 6 |
|
1. Hash payload for audit traceability. |
| 7 |
|
2. Build and store CallbackRecord (status: :pending). |
| 8 |
|
3. Emit CallbackReceived event. |
| 9 |
|
4. Verify signature and freshness via CallbackVerifier. |
| 10 |
|
5. On verification success: mark :verified, enqueue CallbackWorker. |
| 11 |
|
6. On verification failure: mark :rejected, emit CallbackRejected, return {:error, :rejected}. |
| 12 |
|
7. Callback dedup is enforced atomically via InboxStore before any side effect. |
| 13 |
|
|
| 14 |
|
Returns `{:ok, CallbackRecord.t()}` on successful ingestion, |
| 15 |
|
`{:error, :rejected}` on signature/freshness failure, |
| 16 |
|
`{:error, :duplicate}` when inbox already has this message_id. |
| 17 |
|
""" |
| 18 |
|
|
| 19 |
|
alias WalletIntegrations.{CallbackRecord, CallbackStore, InboxStore, JobQueue} |
| 20 |
|
alias WalletIntegrations.{CallbackVerifier, SecretProvider} |
| 21 |
|
alias WalletIntegrations.Workers.CallbackWorker |
| 22 |
|
alias WalletIntegrations.Events.{CallbackReceived, CallbackRejected} |
| 23 |
|
alias WalletIntegrations.AdapterResult |
| 24 |
|
alias WalletObservability.AuditEvent |
| 25 |
|
|
| 26 |
|
@spec execute(params :: map(), opts :: keyword()) :: |
| 27 |
|
{:ok, CallbackRecord.t()} | {:error, :rejected | :duplicate | term()} |
| 28 |
22 |
def execute(params, opts \\ []) do |
| 29 |
11 |
correlation_id = |
| 30 |
|
Keyword.get(opts, :correlation_id, WalletSharedKernel.Correlation.new_correlation_id()) |
| 31 |
|
|
| 32 |
11 |
provider = Map.get(params, :provider, :stripe) |
| 33 |
11 |
raw_body = Map.get(params, :raw_body, "") |
| 34 |
11 |
signature_header = Map.get(params, :signature_header, "") |
| 35 |
11 |
event_type = Map.get(params, :event_type, "unknown") |
| 36 |
11 |
message_id = Map.get(params, :message_id, derive_message_id(provider, raw_body)) |
| 37 |
|
|
| 38 |
11 |
payload_hash = AdapterResult.hash_response(raw_body) |
| 39 |
|
|
| 40 |
|
# Dedup check via InboxStore before building any domain records |
| 41 |
11 |
case InboxStore.claim(message_id, :wallet_integrations, callback_id: nil) do |
| 42 |
2 |
{:duplicate, _existing} -> |
| 43 |
|
{:error, :duplicate} |
| 44 |
|
|
| 45 |
|
{:ok, _inbox} -> |
| 46 |
9 |
callback = |
| 47 |
|
CallbackRecord.new(provider, event_type, payload_hash, |
| 48 |
|
raw_signature: signature_header, |
| 49 |
|
inbox_message_id: message_id, |
| 50 |
|
correlation_id: correlation_id, |
| 51 |
|
metadata: Map.get(params, :metadata, %{}) |
| 52 |
|
) |
| 53 |
|
|
| 54 |
9 |
with :ok <- CallbackStore.store(callback) do |
| 55 |
9 |
emit_event( |
| 56 |
9 |
CallbackReceived.build(callback.callback_id, |
| 57 |
|
provider: provider, |
| 58 |
|
event_type: event_type, |
| 59 |
|
payload_hash: payload_hash, |
| 60 |
|
correlation_id: correlation_id |
| 61 |
|
) |
| 62 |
|
) |
| 63 |
|
|
| 64 |
9 |
case verify_callback(provider, raw_body, signature_header) do |
| 65 |
|
:ok -> |
| 66 |
6 |
{:ok, verified} = CallbackRecord.verify(callback) |
| 67 |
6 |
:ok = CallbackStore.update(verified) |
| 68 |
|
|
| 69 |
6 |
{:ok, _job_id} = |
| 70 |
|
JobQueue.enqueue(:integrations_callback, CallbackWorker, %{ |
| 71 |
6 |
"callback_id" => verified.callback_id, |
| 72 |
|
"correlation_id" => correlation_id |
| 73 |
|
}) |
| 74 |
|
|
| 75 |
6 |
emit_audit(callback.callback_id, correlation_id, :success, %{provider: provider, event_type: event_type}) |
| 76 |
|
{:ok, verified} |
| 77 |
|
|
| 78 |
|
{:error, reason} -> |
| 79 |
3 |
{:ok, rejected} = CallbackRecord.reject(callback, to_string(reason)) |
| 80 |
3 |
:ok = CallbackStore.update(rejected) |
| 81 |
|
|
| 82 |
3 |
emit_event( |
| 83 |
3 |
CallbackRejected.build(callback.callback_id, |
| 84 |
|
provider: provider, |
| 85 |
3 |
rejection_reason: to_string(reason), |
| 86 |
|
correlation_id: correlation_id |
| 87 |
|
) |
| 88 |
|
) |
| 89 |
|
|
| 90 |
3 |
emit_audit(callback.callback_id, correlation_id, :failure, %{ |
| 91 |
|
provider: provider, |
| 92 |
|
rejection_reason: reason |
| 93 |
|
}) |
| 94 |
|
|
| 95 |
|
{:error, :rejected} |
| 96 |
|
end |
| 97 |
|
end |
| 98 |
|
end |
| 99 |
|
rescue |
| 100 |
:-( |
error -> {:error, error} |
| 101 |
|
end |
| 102 |
|
|
| 103 |
|
defp verify_callback(:stripe, raw_body, signature_header) do |
| 104 |
9 |
case SecretProvider.get(:stripe_webhook_secret) do |
| 105 |
|
{:ok, secret} -> |
| 106 |
4 |
CallbackVerifier.verify_stripe(raw_body, signature_header, secret) |
| 107 |
|
|
| 108 |
|
{:error, :secret_not_found} -> |
| 109 |
|
# In CI/test mode with no secret configured, skip signature verification |
| 110 |
5 |
if Application.get_env(:wallet_integrations, :skip_webhook_verification, false) do |
| 111 |
|
:ok |
| 112 |
|
else |
| 113 |
|
{:error, :webhook_secret_not_configured} |
| 114 |
|
end |
| 115 |
|
end |
| 116 |
|
end |
| 117 |
|
|
| 118 |
:-( |
defp verify_callback(_provider, _raw_body, _signature_header), do: :ok |
| 119 |
|
|
| 120 |
|
defp derive_message_id(:stripe, raw_body) do |
| 121 |
11 |
hash = :crypto.hash(:sha256, raw_body) |> Base.encode16(case: :lower) |
| 122 |
11 |
"stripe_cb:#{hash}" |
| 123 |
|
end |
| 124 |
|
|
| 125 |
|
defp derive_message_id(provider, raw_body) do |
| 126 |
:-( |
hash = :crypto.hash(:sha256, raw_body) |> Base.encode16(case: :lower) |
| 127 |
:-( |
"#{provider}_cb:#{hash}" |
| 128 |
|
end |
| 129 |
|
|
| 130 |
12 |
defp emit_event(event) do |
| 131 |
12 |
pubsub = Application.get_env(:wallet_integrations, :pubsub, WalletWeb.PubSub) |
| 132 |
12 |
apply(Phoenix.PubSub, :broadcast, [pubsub, "wallet_integrations:events", {:domain_event, event}]) |
| 133 |
|
rescue |
| 134 |
:-( |
_ -> :ok |
| 135 |
|
end |
| 136 |
|
|
| 137 |
9 |
defp emit_audit(callback_id, correlation_id, outcome, metadata) do |
| 138 |
9 |
audit = |
| 139 |
|
AuditEvent.build(:integrations, "ingest_callback", "callback", callback_id, |
| 140 |
|
outcome, |
| 141 |
|
correlation_id: correlation_id, |
| 142 |
|
metadata: metadata |
| 143 |
|
) |
| 144 |
|
|
| 145 |
9 |
:telemetry.execute([:wallet_integrations, :audit], %{}, audit) |
| 146 |
|
rescue |
| 147 |
:-( |
_ -> :ok |
| 148 |
|
end |
| 149 |
|
end |