Virtual-currency wallets with an append-only ledger.
Currencies are free-form string codes ("gold", "gems", "energy") — the
game decides which exist. Every balance change is atomic and recorded in the
ledger, so two concurrent spends can never overspend and every mutation is
auditable.
Usage (server-side / hooks)
Economy.grant(user_id, "gold", 100, reason: "match_reward")
case Economy.spend(user_id, "gold", 30, reason: "store_purchase") do
{:ok, balance} -> :ok
{:error, :insufficient_funds} -> :not_enough_gold
end
Economy.balance(user_id, "gold") #=> 70
Economy.balances(user_id) #=> %{"gold" => 70}Idempotency
Pass :idempotency_key so a retried request (network retry, at-least-once job)
can't double-apply — the second call is a no-op that returns the current
balance:
Economy.grant(user_id, "gems", 5, idempotency_key: "purchase:#{order_id}")Safety
These are server-authoritative: expose them from hooks and admin tools, never as a raw client "add currency" endpoint. Clients only read their wallet.
Summary
Functions
Current balance of one currency (0 when the user has no wallet for it).
All non-zero balances for a user, as a %{currency => balance} map.
Add amount of currency to a user's wallet.
Remove amount of currency from a user's wallet, atomically.
Subscribe the calling process to a user's live wallet updates.
Stop receiving a user's wallet updates.
Types
@type currency() :: String.t()
@type user_id() :: Ecto.UUID.t()
Functions
@spec balance(user_id(), currency()) :: non_neg_integer()
Current balance of one currency (0 when the user has no wallet for it).
@spec balances(user_id()) :: %{required(currency()) => non_neg_integer()}
All non-zero balances for a user, as a %{currency => balance} map.
@spec grant(user_id(), currency(), non_neg_integer(), keyword()) :: {:ok, non_neg_integer()} | {:error, term()}
Add amount of currency to a user's wallet.
Options: :reason (ledger label), :idempotency_key, :metadata.
Returns {:ok, new_balance}.
@spec spend(user_id(), currency(), non_neg_integer(), keyword()) :: {:ok, non_neg_integer()} | {:error, :insufficient_funds | term()}
Remove amount of currency from a user's wallet, atomically.
Returns {:ok, new_balance} or {:error, :insufficient_funds} — the balance
is never left negative.
Subscribe the calling process to a user's live wallet updates.
@spec unsubscribe(user_id()) :: :ok
Stop receiving a user's wallet updates.