cover/Elixir.WalletTransfers.Transfer.html

1 defmodule WalletTransfers.Transfer do
2 @moduledoc """
3 Transfer struct and lifecycle state machine.
4
5 A transfer represents a money movement between two accounts in the wallet
6 system. It belongs to a user, has a source account, destination account,
7 amount, currency, and a status tracking its lifecycle.
8
9 Lifecycle:
10 :initiated -> :reserved (via reserve/2)
11 :reserved -> :completed (via complete/1)
12 :reserved -> :failed (via fail/2)
13 :initiated -> :canceled (via cancel/2)
14
15 Terminal states: :completed, :failed, :canceled
16
17 TypedId prefix: `trf_`
18 """
19
20 alias WalletSharedKernel.TypedId
21
22 @terminal_states [:completed, :failed, :canceled]
23 @valid_types [:internal, :p2p, :external]
24
25 @enforce_keys [
26 :transfer_id,
27 :idempotency_key,
28 :user_id,
29 :from_account_id,
30 :to_account_id,
31 :amount,
32 :currency,
33 :type,
34 :status,
35 :reference,
36 :initiated_at
37 ]
38 1 defstruct [
39 :transfer_id,
40 :idempotency_key,
41 :user_id,
42 :from_account_id,
43 :to_account_id,
44 :amount,
45 :currency,
46 :reference,
47 :correlation_id,
48 :failure_reason,
49 :initiated_at,
50 :reserved_at,
51 :completed_at,
52 :failed_at,
53 :canceled_at,
54 type: :internal,
55 status: :initiated,
56 fee_amount: 0,
57 metadata: %{}
58 ]
59
60 @type transfer_type :: :internal | :p2p | :external
61 @type status ::
62 :initiated | :reserved | :completed | :failed | :canceled
63
64 @type t :: %__MODULE__{
65 transfer_id: String.t(),
66 idempotency_key: String.t(),
67 user_id: String.t(),
68 from_account_id: String.t(),
69 to_account_id: String.t(),
70 amount: pos_integer(),
71 currency: String.t(),
72 type: transfer_type(),
73 status: status(),
74 reference: String.t(),
75 fee_amount: non_neg_integer(),
76 correlation_id: String.t() | nil,
77 initiated_at: DateTime.t(),
78 reserved_at: DateTime.t() | nil,
79 completed_at: DateTime.t() | nil,
80 failed_at: DateTime.t() | nil,
81 canceled_at: DateTime.t() | nil,
82 failure_reason: String.t() | nil,
83 metadata: map()
84 }
85
86 @doc """
87 Creates a new transfer for the given user.
88
89 Options:
90 - `idempotency_key` — caller-supplied key for safe retries.
91 - `type` — transfer type (`:internal` | `:p2p` | `:external`), default `:internal`.
92 - `reference` — unique business reference string; auto-generated if not provided.
93 - `correlation_id` — propagate into events.
94 - `fee_amount` — fee in minor units, default 0.
95 - `metadata` — arbitrary metadata map.
96 """
97 @spec new(
98 user_id :: String.t(),
99 from_account_id :: String.t(),
100 to_account_id :: String.t(),
101 amount :: pos_integer(),
102 currency :: String.t(),
103 opts :: keyword()
104 ) :: t()
105
:-(
def new(user_id, from_account_id, to_account_id, amount, currency, opts \\ []) do
106 96 %__MODULE__{
107 transfer_id: TypedId.generate("trf"),
108 idempotency_key:
109 Keyword.get(opts, :idempotency_key, WalletSharedKernel.Correlation.new_request_id()),
110 user_id: user_id,
111 from_account_id: from_account_id,
112 to_account_id: to_account_id,
113 amount: amount,
114 currency: currency,
115 type: Keyword.get(opts, :type, :internal),
116 status: :initiated,
117 reference: Keyword.get(opts, :reference, TypedId.generate("ref")),
118 fee_amount: Keyword.get(opts, :fee_amount, 0),
119 correlation_id: Keyword.get(opts, :correlation_id),
120 initiated_at: DateTime.utc_now(),
121 reserved_at: nil,
122 completed_at: nil,
123 failed_at: nil,
124 canceled_at: nil,
125 failure_reason: nil,
126 metadata: Keyword.get(opts, :metadata, %{})
127 }
128 end
129
130 @doc """
131 Transitions an initiated transfer to reserved, recording when funds were reserved.
132
133 Returns `{:ok, transfer}` or `{:error, :invalid_transition}`.
134 """
135 @spec reserve(t(), reserved_at :: DateTime.t()) :: {:ok, t()} | {:error, :invalid_transition}
136 36 def reserve(transfer, reserved_at \\ nil)
137
138 def reserve(%__MODULE__{status: :initiated} = transfer, reserved_at) do
139 30 ts = reserved_at || DateTime.utc_now()
140 {:ok, %{transfer | status: :reserved, reserved_at: ts}}
141 end
142
143 7 def reserve(%__MODULE__{}, _reserved_at), do: {:error, :invalid_transition}
144
145 @doc """
146 Transitions a reserved transfer to completed.
147
148 Returns `{:ok, transfer}` or `{:error, :invalid_transition}`.
149 """
150 @spec complete(t()) :: {:ok, t()} | {:error, :invalid_transition}
151 12 def complete(%__MODULE__{status: :reserved} = transfer) do
152 {:ok, %{transfer | status: :completed, completed_at: DateTime.utc_now()}}
153 end
154
155 7 def complete(%__MODULE__{}), do: {:error, :invalid_transition}
156
157 @doc """
158 Transitions a reserved transfer to failed with a reason.
159
160 Returns `{:ok, transfer}` or `{:error, :invalid_transition}`.
161 """
162 @spec fail(t(), reason :: String.t()) :: {:ok, t()} | {:error, :invalid_transition}
163 5 def fail(%__MODULE__{status: :reserved} = transfer, reason) do
164 {:ok, %{transfer | status: :failed, failed_at: DateTime.utc_now(), failure_reason: reason}}
165 end
166
167 4 def fail(%__MODULE__{}, _reason), do: {:error, :invalid_transition}
168
169 @doc """
170 Transitions an initiated transfer to canceled with a reason.
171
172 Returns `{:ok, transfer}` or `{:error, :invalid_transition}`.
173 """
174 @spec cancel(t(), reason :: String.t()) :: {:ok, t()} | {:error, :invalid_transition}
175 7 def cancel(%__MODULE__{status: :initiated} = transfer, reason) do
176 {:ok,
177 %{transfer | status: :canceled, canceled_at: DateTime.utc_now(), failure_reason: reason}}
178 end
179
180 4 def cancel(%__MODULE__{}, _reason), do: {:error, :invalid_transition}
181
182 @doc "Returns true if the transfer is in a terminal state (completed, failed, or canceled)."
183 @spec terminal?(t()) :: boolean()
184 5 def terminal?(%__MODULE__{status: status}), do: status in @terminal_states
185
186 @doc "Returns the list of valid transfer types."
187
:-(
def valid_types, do: @valid_types
188
189 @doc "Returns the list of terminal states."
190
:-(
def terminal_states, do: @terminal_states
191 end
Line Hits Source