| 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 |