jonnify·codemojex /codemojex/the-economy/the-transactional-wallet

Codemojex · C5.2 · a dive of The Economy and the Bank

The transactional wallet.

Balances live in Postgres and move only inside database transactions. The wallet is the one system where correctness outranks speed, and it buys that correctness with a row lock and a paired ledger row — not a single-writer process. Every mutation locks, checks, writes, and records, all or nothing.

Grounding — echo/…/codemojex/wallet.ex (debit/5, lock/1, txn!/5, buy_in/2, insert_buy_in/3) · schemas/player.ex (the players_non_negative CHECK) · the wallet + golden-economy stories.

One balance change, one transaction — select an outcome
1. LOCK FOR UPDATE 2. CHECK have ≥ amount? 3. WRITE new balance 4. TXN paired ledger row COMMIT — all or nothing ROLLBACK :insufficient nothing written
sufficient → the row is locked FOR UPDATE, the balance covers the amount, the new balance is written, and the paired TXN row is inserted — then the transaction commits. Balance and ledger move together or not at all.
§1

Lock, check, write, ledger

wallet.ex · debit/5

Every mutation is one Repo.transaction of four steps. The row lock serializes only same-player mutations, so the whole field is never funnelled through one writer process — the database does the work a single-writer GenServer used to, and scales with it.

echo/…/codemojex/wallet.ex · debit/5 source
defp debit(player, currency, amount, reason, ref) do
  Repo.transaction(fn ->
    case lock(player) do                          # 1. SELECT … FOR UPDATE
      nil -> Repo.rollback(:no_player)
      p ->
        have = Map.get(p, currency)
        if have < amount do
          Repo.rollback(:insufficient)            # 2. the non-negative check
        else
          update!(p, %{currency => have - amount})    # 3. write the balance
          txn!(player, currency, -amount, reason, ref) # 4. the paired TXN row
          have - amount
        end
    end
  end)
end

defp lock(id), do: Repo.one(from p in Player, where: p.id == ^id, lock: "FOR UPDATE")

The wallet story pins the guard: a player with zero keys cannot guess in a paid room — the guess is refused for insufficient funds and the balance is still zero, never below. The players_non_negative CHECK is the database backstop if application logic ever slips:

echo/…/codemojex/schemas/player.ex · the non-negative guard source
defp guard(cs) do
  @balances
  |> Enum.reduce(cs, &validate_number(&2, &1, greater_than_or_equal_to: 0))
  |> check_constraint(:keys, name: :players_non_negative, message: "balance cannot go negative")
end

Alongside every write, txn!/5 inserts an append-only TXN row in the same transaction, so a statement is an ordered query (Codemojex.Ledger.history/2) and no balance ever moved without a paired record. The ledger gives correctness; the row lock gives concurrency.

§2

The two-sided, exactly-once buy-in

wallet.ex · buy_in/2

Wallet.buy_in/2 is the money headline of the Golden Room: one transaction that locks the games row and the player row, debits the entry fee, funds the pool, and books the membership through a partial unique index so a re-join cannot double-charge. Select a case below.

The buy-in through the partial unique index — select a case
INSERT buy_in TXN on_conflict: :nothing partial unique index (player, ref) reason=buy_in :wrote → :member debit fee · fund pool · book house :suppressed → :already_member no mutation
first buy-in → the buy_in TXN inserts cleanly, so the count rises: the transaction debits the entry fee, funds the pool by the fee waterfall, books the house cut, and returns :member. Exactly one buy_in row now exists for this player and game.
echo/…/codemojex/wallet.ex · insert_buy_in/3 (Pattern A) source
defp insert_buy_in(player, game, before) do
  tid = EchoData.BrandedId.generate!("TXN")
  {:ok, _row} =
    %Transaction{}
    |> Transaction.changeset(%{id: tid, player: player, currency: "keys", delta: 0, reason: "buy_in", ref: game})
    |> Repo.insert(
      on_conflict: :nothing,
      conflict_target: {:unsafe_fragment, "(player, ref) WHERE reason = 'buy_in'"}
    )
  if buy_in_count(game) > before, do: :wrote, else: :suppressed
end

Exactly-once lives in the ledger, not in a returned struct: on_conflict: :nothing returns a loaded struct carrying the minted id even when the write was suppressed, so the caller compares the buy-in count before and after — the count rose iff a row was actually written. The golden-economy story pins it: a re-join debits keys once, moves the pool once, leaves exactly one buy_in TXN, and answers :already_member.

§3

References

sources · related

Sources

Related