cover/Elixir.WalletIntegrations.Adapters.StripeAdapter.html

1 defmodule WalletIntegrations.Adapters.StripeAdapter do
2 @moduledoc """
3 Stripe payment provider adapter for the wallet integrations layer (ADR 0008).
4
5 Implements `WalletIntegrations.ProviderAdapter` against the Stripe API.
6 Supports sandbox (test mode) and production operation via `stripe_secret_key` configuration.
7
8 ## Sandbox usage
9 Set the secret key to a Stripe test key (sk_test_...) in application config:
10
11 config :wallet_integrations,
12 stripe_secret_key: "sk_test_your_key_here"
13
14 Or inject via SecretProvider (recommended for production):
15
16 Application.get_env(:wallet_integrations, :stripe_secret_key)
17
18 ## Stripe API surface used
19 - `POST /v1/payment_intents` — initiate_payment
20 - `GET /v1/payment_intents/:id` — get_payment_status
21 - `POST /v1/payment_intents/:id/cancel` — cancel_payment
22 - `POST /v1/refunds` — refund_payment
23
24 ## Currency convention
25 Stripe uses minor units (e.g. paise for INR, cents for USD). Amounts passed here
26 must already be in minor units consistent with `AdapterRequest.amount`.
27 """
28
29 @behaviour WalletIntegrations.ProviderAdapter
30
31 alias WalletIntegrations.{AdapterRequest, AdapterResult, RetryPolicy, SecretProvider}
32
33 @stripe_base_url "https://api.stripe.com/v1"
34
35 @impl true
36 def initiate_payment(%AdapterRequest{} = request) do
37 1 with {:ok, secret_key} <- SecretProvider.get(:stripe_secret_key) do
38
:-(
policy = RetryPolicy.for_operation(:initiate_payment)
39
40
:-(
body =
41 URI.encode_query(%{
42
:-(
"amount" => to_string(request.amount),
43
:-(
"currency" => String.downcase(request.currency),
44 "payment_method_types[]" => "card",
45
:-(
"metadata[payment_id]" => request.payment_id,
46
:-(
"metadata[correlation_id]" => to_string(request.correlation_id),
47 "confirm" => "false"
48 })
49
50
:-(
headers = build_headers(secret_key, request.payment_id)
51
:-(
url = @stripe_base_url <> "/payment_intents"
52
53 call_with_retry(:initiate_payment, fn ->
54
:-(
http_post(url, headers, body, policy)
55 end)
56
:-(
|> parse_payment_intent_response()
57 else
58 1 {:error, _} ->
59 {:error, AdapterResult.build(:failed, provider_status_code: "credential_error", retryable: false)}
60 end
61 end
62
63 @impl true
64 def get_payment_status(provider_ref) do
65 1 with {:ok, secret_key} <- SecretProvider.get(:stripe_secret_key) do
66
:-(
policy = RetryPolicy.for_operation(:get_payment_status)
67
:-(
headers = build_headers(secret_key, nil)
68
:-(
url = @stripe_base_url <> "/payment_intents/" <> URI.encode(provider_ref)
69
70 call_with_retry(:get_payment_status, fn ->
71
:-(
http_get(url, headers, policy)
72 end)
73
:-(
|> parse_payment_intent_response()
74 else
75 1 {:error, _} ->
76 {:error, AdapterResult.build(:failed, provider_status_code: "credential_error", retryable: false)}
77 end
78 end
79
80 @impl true
81 def cancel_payment(provider_ref) do
82
:-(
with {:ok, secret_key} <- SecretProvider.get(:stripe_secret_key) do
83
:-(
policy = RetryPolicy.for_operation(:cancel_payment)
84
:-(
headers = build_headers(secret_key, nil)
85
:-(
url = @stripe_base_url <> "/payment_intents/" <> URI.encode(provider_ref) <> "/cancel"
86
87 call_with_retry(:cancel_payment, fn ->
88
:-(
http_post(url, headers, "", policy)
89 end)
90
:-(
|> parse_payment_intent_response()
91 else
92
:-(
{:error, _} ->
93 {:error, AdapterResult.build(:failed, provider_status_code: "credential_error", retryable: false)}
94 end
95 end
96
97 @impl true
98 def refund_payment(provider_ref, amount) do
99
:-(
with {:ok, secret_key} <- SecretProvider.get(:stripe_secret_key) do
100
:-(
policy = RetryPolicy.for_operation(:refund_payment)
101
102
:-(
body =
103 URI.encode_query(%{
104 "payment_intent" => provider_ref,
105
:-(
"amount" => to_string(amount)
106 })
107
108
:-(
headers = build_headers(secret_key, provider_ref)
109
:-(
url = @stripe_base_url <> "/refunds"
110
111 call_with_retry(:refund_payment, fn ->
112
:-(
http_post(url, headers, body, policy)
113 end)
114
:-(
|> parse_refund_response()
115 else
116
:-(
{:error, _} ->
117 {:error, AdapterResult.build(:failed, provider_status_code: "credential_error", retryable: false)}
118 end
119 end
120
121 # Private helpers
122
123 defp call_with_retry(operation, fun) do
124
:-(
policy = RetryPolicy.for_operation(operation)
125
:-(
do_retry(fun, 0, policy.max_attempts)
126 end
127
128 defp do_retry(fun, attempt, max_attempts) do
129
:-(
case fun.() do
130 {:ok, status, _headers, body} when status in 200..299 ->
131
:-(
{:ok, status, body}
132
133 {:ok, status, _headers, body} when status == 429 ->
134
:-(
if attempt < max_attempts - 1 do
135
:-(
:timer.sleep(RetryPolicy.calculate_backoff(attempt))
136
:-(
do_retry(fun, attempt + 1, max_attempts)
137 else
138 {:error, {:http_error, status, body}}
139 end
140
141 {:ok, status, _headers, body} when status in [500, 502, 503, 504] ->
142
:-(
if attempt < max_attempts - 1 do
143
:-(
:timer.sleep(RetryPolicy.calculate_backoff(attempt))
144
:-(
do_retry(fun, attempt + 1, max_attempts)
145 else
146 {:error, {:http_error, status, body}}
147 end
148
149
:-(
{:ok, status, _headers, body} ->
150 {:error, {:http_error, status, body}}
151
152 {:error, reason} = err ->
153
:-(
if RetryPolicy.retryable_error?(reason) and attempt < max_attempts - 1 do
154
:-(
:timer.sleep(RetryPolicy.calculate_backoff(attempt))
155
:-(
do_retry(fun, attempt + 1, max_attempts)
156 else
157
:-(
err
158 end
159 end
160 end
161
162 defp parse_payment_intent_response({:ok, _status, body}) do
163
:-(
hash = AdapterResult.hash_response(body)
164
165
:-(
case Jason.decode(body) do
166 {:ok, %{"id" => id, "status" => stripe_status}} ->
167
:-(
status = normalize_stripe_status(stripe_status)
168
:-(
retryable = status == :unknown
169
170 {:ok,
171 AdapterResult.build(status,
172 provider_reference: id,
173 provider_status_code: stripe_status,
174 raw_response_hash: hash,
175 retryable: retryable
176 )}
177
178
:-(
{:ok, %{"error" => %{"code" => code, "message" => msg}}} ->
179 {:error,
180 AdapterResult.build(:failed,
181 provider_status_code: code,
182 raw_response_hash: hash,
183 retryable: false,
184 metadata: %{error_message: msg}
185 )}
186
187
:-(
_ ->
188 {:error,
189 AdapterResult.build(:unknown,
190 raw_response_hash: hash,
191 retryable: true
192 )}
193 end
194 end
195
196 defp parse_payment_intent_response({:error, {:http_error, status_code, body}}) do
197
:-(
hash = AdapterResult.hash_response(body)
198
:-(
retryable = RetryPolicy.retryable_status_code?(status_code)
199
200 {:error,
201 AdapterResult.build(:failed,
202
:-(
provider_status_code: to_string(status_code),
203 raw_response_hash: hash,
204 retryable: retryable
205 )}
206 end
207
208 defp parse_payment_intent_response({:error, reason}) do
209
:-(
retryable = RetryPolicy.retryable_error?(reason)
210
211 {:error,
212
:-(
AdapterResult.build(if(retryable, do: :unknown, else: :failed),
213 provider_status_code: inspect(reason),
214 retryable: retryable
215 )}
216 end
217
218 defp parse_refund_response({:ok, _status, body}) do
219
:-(
hash = AdapterResult.hash_response(body)
220
221
:-(
case Jason.decode(body) do
222 {:ok, %{"id" => ref_id, "status" => refund_status}} ->
223
:-(
status =
224 case refund_status do
225
:-(
"succeeded" -> :completed
226
:-(
"pending" -> :pending
227
:-(
"failed" -> :failed
228
:-(
_ -> :unknown
229 end
230
231 {:ok,
232 AdapterResult.build(status,
233 provider_reference: ref_id,
234 provider_status_code: refund_status,
235 raw_response_hash: hash
236 )}
237
238
:-(
{:ok, %{"error" => %{"code" => code}}} ->
239 {:error,
240 AdapterResult.build(:failed,
241 provider_status_code: code,
242 raw_response_hash: hash,
243 retryable: false
244 )}
245
246
:-(
_ ->
247 {:error, AdapterResult.build(:unknown, raw_response_hash: hash, retryable: true)}
248 end
249 end
250
251
:-(
defp parse_refund_response(err), do: parse_payment_intent_response(err)
252
253
:-(
defp normalize_stripe_status("requires_payment_method"), do: :accepted
254
:-(
defp normalize_stripe_status("requires_confirmation"), do: :accepted
255
:-(
defp normalize_stripe_status("requires_action"), do: :pending
256
:-(
defp normalize_stripe_status("processing"), do: :pending
257
:-(
defp normalize_stripe_status("requires_capture"), do: :pending
258
:-(
defp normalize_stripe_status("succeeded"), do: :completed
259
:-(
defp normalize_stripe_status("canceled"), do: :failed
260
:-(
defp normalize_stripe_status(_), do: :unknown
261
262 defp build_headers(secret_key, idempotency_key) do
263
:-(
base = [
264 {"Authorization", "Bearer " <> secret_key},
265 {"Content-Type", "application/x-www-form-urlencoded"},
266 {"Stripe-Version", "2023-10-16"}
267 ]
268
269
:-(
if idempotency_key do
270 [{"Idempotency-Key", idempotency_key} | base]
271 else
272
:-(
base
273 end
274 end
275
276 defp http_post(url, headers, body, policy) do
277
:-(
client = WalletIntegrations.Http.client()
278
279
:-(
apply(client, :request, [
280 :post,
281 url,
282 headers,
283 body,
284
:-(
[connect_timeout: policy.connect_timeout, recv_timeout: policy.recv_timeout]
285 ])
286 end
287
288 defp http_get(url, headers, policy) do
289
:-(
client = WalletIntegrations.Http.client()
290
291
:-(
apply(client, :request, [
292 :get,
293 url,
294 headers,
295 "",
296
:-(
[connect_timeout: policy.connect_timeout, recv_timeout: policy.recv_timeout]
297 ])
298 end
299 end
Line Hits Source