cover/Elixir.WalletIntegrations.PaymentRequest.html

1 defmodule WalletIntegrations.PaymentRequest do
2 @moduledoc """
3 Tracks the lifecycle of an outbound payment request to an external provider.
4
5 This struct bridges the wallet domain (TransferId) with the provider domain (ProviderReference).
6 It lives in WalletIntegrations and is managed by the PaymentRequestStore.
7
8 State machine:
9 - `:pending` — created; waiting for worker dispatch.
10 - `:dispatched` — submitted to provider; awaiting acknowledgement.
11 - `:accepted` — provider accepted the payment request.
12 - `:completed` — payment confirmed complete by provider.
13 - `:failed` — payment definitively failed.
14 - `:unknown` — outcome unclear; routed to reconciliation.
15 - `:canceled` — payment canceled before completion.
16 - `:refunded` — refund accepted by provider.
17
18 Terminal states: :completed, :failed, :canceled, :refunded
19
20 ADR 0008: external failures must never violate ledger invariants.
21 """
22
23 alias WalletSharedKernel.TypedId
24
25 @terminal_states [:completed, :failed, :canceled, :refunded]
26
27 @enforce_keys [:request_id, :transfer_id, :provider, :amount, :currency, :status, :created_at]
28
:-(
defstruct [
29 :request_id,
30 :transfer_id,
31 :provider,
32 :amount,
33 :currency,
34 :idempotency_key,
35 :provider_reference,
36 :provider_status_code,
37 :status,
38 :failure_reason,
39 :retryable,
40 :correlation_id,
41 :created_at,
42 :updated_at,
43 metadata: %{}
44 ]
45
46 @type status ::
47 :pending | :dispatched | :accepted | :completed | :failed | :unknown | :canceled | :refunded
48
49 @type t :: %__MODULE__{
50 request_id: String.t(),
51 transfer_id: String.t(),
52 provider: atom(),
53 amount: pos_integer(),
54 currency: String.t(),
55 idempotency_key: String.t() | nil,
56 provider_reference: String.t() | nil,
57 provider_status_code: String.t() | nil,
58 status: status(),
59 failure_reason: String.t() | nil,
60 retryable: boolean(),
61 correlation_id: String.t() | nil,
62 created_at: DateTime.t(),
63 updated_at: DateTime.t() | nil,
64 metadata: map()
65 }
66
67 @doc "Build a new payment request for the given transfer."
68 @spec new(transfer_id :: String.t(), provider :: atom(), amount :: pos_integer(), currency :: String.t(), opts :: keyword()) :: t()
69
:-(
def new(transfer_id, provider, amount, currency, opts \\ []) do
70 21 %__MODULE__{
71 request_id: TypedId.generate("pyr"),
72 transfer_id: transfer_id,
73 provider: provider,
74 amount: amount,
75 currency: currency,
76 idempotency_key: Keyword.get(opts, :idempotency_key),
77 status: :pending,
78 retryable: true,
79 correlation_id: Keyword.get(opts, :correlation_id),
80 created_at: DateTime.utc_now(),
81 metadata: Keyword.get(opts, :metadata, %{})
82 }
83 end
84
85 @spec dispatch(t()) :: {:ok, t()} | {:error, :invalid_transition}
86 16 def dispatch(%__MODULE__{status: s} = pr) when s in [:pending, :dispatched] do
87 {:ok, %{pr | status: :dispatched, updated_at: DateTime.utc_now()}}
88 end
89 1 def dispatch(%__MODULE__{}), do: {:error, :invalid_transition}
90
91 @spec accept(t(), provider_ref :: String.t(), opts :: keyword()) :: {:ok, t()} | {:error, :invalid_transition}
92 9 def accept(%__MODULE__{status: s} = pr, provider_ref, opts \\ [])
93 when s in [:dispatched, :pending] do
94 {:ok, %{pr |
95 status: :accepted,
96 provider_reference: provider_ref,
97 provider_status_code: Keyword.get(opts, :provider_status_code),
98 updated_at: DateTime.utc_now()
99 }}
100 end
101
:-(
def accept(%__MODULE__{}), do: {:error, :invalid_transition}
102
103 @spec complete(t(), opts :: keyword()) :: {:ok, t()} | {:error, :invalid_transition}
104 1 def complete(pr, opts \\ [])
105 3 def complete(%__MODULE__{status: s} = pr, opts) when s in [:accepted, :dispatched, :pending] do
106 {:ok, %{pr |
107 status: :completed,
108 3 provider_reference: Keyword.get(opts, :provider_reference, pr.provider_reference),
109 provider_status_code: Keyword.get(opts, :provider_status_code),
110 updated_at: DateTime.utc_now()
111 }}
112 end
113
:-(
def complete(%__MODULE__{}, _opts), do: {:error, :invalid_transition}
114
115 @spec fail(t(), reason :: String.t(), opts :: keyword()) :: {:ok, t()} | {:error, :invalid_transition}
116 3 def fail(%__MODULE__{status: s} = pr, reason, opts \\ []) when s not in [:completed, :refunded] do
117 {:ok, %{pr |
118 status: :failed,
119 failure_reason: reason,
120 retryable: Keyword.get(opts, :retryable, false),
121 updated_at: DateTime.utc_now()
122 }}
123 end
124
:-(
def fail(%__MODULE__{}), do: {:error, :invalid_transition}
125
126 @spec mark_unknown(t()) :: {:ok, t()} | {:error, :invalid_transition}
127 4 def mark_unknown(%__MODULE__{status: s} = pr) when s not in [:completed, :refunded, :canceled] do
128 {:ok, %{pr | status: :unknown, updated_at: DateTime.utc_now()}}
129 end
130
:-(
def mark_unknown(%__MODULE__{}), do: {:error, :invalid_transition}
131
132 @spec cancel(t()) :: {:ok, t()} | {:error, :invalid_transition}
133
:-(
def cancel(%__MODULE__{status: s} = pr) when s in [:pending, :dispatched, :accepted] do
134 {:ok, %{pr | status: :canceled, updated_at: DateTime.utc_now()}}
135 end
136
:-(
def cancel(%__MODULE__{}), do: {:error, :invalid_transition}
137
138 @spec terminal?(t()) :: boolean()
139 2 def terminal?(%__MODULE__{status: s}), do: s in @terminal_states
140
141 @spec terminal_states() :: [atom()]
142
:-(
def terminal_states, do: @terminal_states
143 end
Line Hits Source