cover/Elixir.WalletTransfers.TransferStore.html

1 defmodule WalletTransfers.TransferStore do
2 @moduledoc """
3 ETS-backed GenServer for wallet transfer persistence.
4
5 Manages four ETS tables:
6 - `:wallet_transfers_store` — transfers keyed by transfer_id.
7 - `:wallet_transfers_refs` — secondary index: {reference, transfer_id} for uniqueness.
8 - `:wallet_transfers_user_idx` — bag index: {user_id, transfer_id} for user listings.
9 - `:wallet_transfers_idem_idx` — set index: {{user_id, idempotency_key}, transfer_id} for
10 atomic idempotency key uniqueness per user (ADR 0004/0005).
11
12 No database is required; this is an in-memory store for Phase 4 CI.
13 """
14
15 use GenServer
16
17 alias WalletTransfers.Transfer
18
19 @store_table :wallet_transfers_store
20 @refs_table :wallet_transfers_refs
21 @user_idx_table :wallet_transfers_user_idx
22 @idem_idx_table :wallet_transfers_idem_idx
23
24 # --- Client API ---
25
26 def start_link(opts) do
27
:-(
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
28 end
29
30 @doc """
31 Stores a new transfer.
32
33 Returns:
34 - `:ok` — transfer stored.
35 - `{:ok, existing_transfer}` — idempotency key already exists for this user; existing
36 transfer returned (replay semantics per ADR 0005).
37 - `{:error, :duplicate_reference}` — distinct reference collision.
38 """
39 @spec store(Transfer.t()) ::
40 :ok
41 | {:ok, Transfer.t()}
42 | {:error, :duplicate_reference}
43 def store(%Transfer{} = transfer) do
44 71 GenServer.call(__MODULE__, {:store, transfer})
45 end
46
47 @doc "Retrieves a transfer by transfer_id. Returns `{:ok, transfer}` or `{:error, :not_found}`."
48 @spec get(transfer_id :: String.t()) :: {:ok, Transfer.t()} | {:error, :not_found}
49 def get(transfer_id) do
50 55 GenServer.call(__MODULE__, {:get, transfer_id})
51 end
52
53 @doc "Updates an existing transfer. Returns `:ok` or `{:error, :not_found}`."
54 @spec update(Transfer.t()) :: :ok | {:error, :not_found}
55 def update(%Transfer{} = transfer) do
56 30 GenServer.call(__MODULE__, {:update, transfer})
57 end
58
59 @doc "Lists all transfers for a given user_id."
60 @spec list_by_user(user_id :: String.t()) :: [Transfer.t()]
61 def list_by_user(user_id) do
62 4 GenServer.call(__MODULE__, {:list_by_user, user_id})
63 end
64
65 @doc "Resets all store state. For test use only."
66 52 def reset, do: GenServer.call(__MODULE__, :reset)
67
68 # --- Server Callbacks ---
69
70 @impl true
71 def init(_opts) do
72
:-(
:ets.new(@store_table, [:set, :protected, :named_table])
73
:-(
:ets.new(@refs_table, [:set, :protected, :named_table])
74
:-(
:ets.new(@user_idx_table, [:bag, :protected, :named_table])
75
:-(
:ets.new(@idem_idx_table, [:set, :protected, :named_table])
76 {:ok, %{}}
77 end
78
79 @impl true
80 def handle_call({:store, transfer}, _from, state) do
81 71 result =
82 24 with :ok <- check_idempotency_key(transfer),
83 55 :ok <- check_reference(transfer) do
84 47 :ets.insert(@store_table, {transfer.transfer_id, transfer})
85 47 :ets.insert(@refs_table, {transfer.reference, transfer.transfer_id})
86 47 :ets.insert(@user_idx_table, {transfer.user_id, transfer.transfer_id})
87
88 47 if transfer.idempotency_key do
89 47 :ets.insert(
90 @idem_idx_table,
91 47 {{transfer.user_id, transfer.idempotency_key}, transfer.transfer_id}
92 )
93 end
94
95 :ok
96 end
97
98 71 {:reply, result, state}
99 end
100
101 @impl true
102 def handle_call({:get, transfer_id}, _from, state) do
103 55 result =
104 case :ets.lookup(@store_table, transfer_id) do
105 4 [] -> {:error, :not_found}
106 51 [{_key, transfer}] -> {:ok, transfer}
107 end
108
109 55 {:reply, result, state}
110 end
111
112 @impl true
113 def handle_call({:update, transfer}, _from, state) do
114 30 result =
115 30 case :ets.lookup(@store_table, transfer.transfer_id) do
116
:-(
[] ->
117 {:error, :not_found}
118
119 [{_key, _existing}] ->
120 30 :ets.insert(@store_table, {transfer.transfer_id, transfer})
121 :ok
122 end
123
124 30 {:reply, result, state}
125 end
126
127 @impl true
128 def handle_call({:list_by_user, user_id}, _from, state) do
129 4 transfer_ids =
130 :ets.lookup(@user_idx_table, user_id)
131 4 |> Enum.map(fn {_uid, tid} -> tid end)
132
133 4 transfers =
134 Enum.flat_map(transfer_ids, fn tid ->
135 4 case :ets.lookup(@store_table, tid) do
136
:-(
[] -> []
137 4 [{_key, transfer}] -> [transfer]
138 end
139 end)
140
141 4 {:reply, transfers, state}
142 end
143
144 @impl true
145 def handle_call(:reset, _from, state) do
146 52 :ets.delete_all_objects(@store_table)
147 52 :ets.delete_all_objects(@refs_table)
148 52 :ets.delete_all_objects(@user_idx_table)
149 52 :ets.delete_all_objects(@idem_idx_table)
150 52 {:reply, :ok, state}
151 end
152
153 # --- Private Helpers ---
154
155
:-(
defp check_idempotency_key(%Transfer{idempotency_key: nil}), do: :ok
156
157 defp check_idempotency_key(%Transfer{user_id: user_id, idempotency_key: key}) do
158 71 case :ets.lookup(@idem_idx_table, {user_id, key}) do
159 55 [] ->
160 :ok
161
162 [{_idem_key, transfer_id}] ->
163 16 case :ets.lookup(@store_table, transfer_id) do
164 16 [{_tid, existing}] -> {:ok, existing}
165
:-(
[] -> :ok
166 end
167 end
168 end
169
170 defp check_reference(%Transfer{reference: ref}) do
171 55 if :ets.member(@refs_table, ref) do
172 {:error, :duplicate_reference}
173 else
174 :ok
175 end
176 end
177 end
Line Hits Source