Skip to content

Flow: dropbet wallet — balance view + internal vault transfer

Balances trace: 4dc1101205d40a92fda7656b01be13f3 · Vault-balances trace: 149a40ec1947c4bd9957c729f0c698e1 · POST /accounting/to-vault trace: 077b2a5752bdda2ce180fb68e7d86ca9 · Jaeger: http://localhost:16686/ · E2E: tests-e2e/tests/dropbet-wallet.spec.ts Generated: 2026-04-16 · Author: prisma-otel-engineer · Services traced: ebit-api. Companion to dropbet-bet-place.md (task #31 — bets mutate the same UserBalance row this flow reads) and the forthcoming rt flow doc (task #36 — describes the socket side of BalanceUpdated). Scope: the internal virtual-balance viewing flow — what the dropbet FE hits to render the wallet widget + vault page + the round-trip between main balance and vault. Out of scope: crypto deposits / withdrawals, skindeck-deposit, affiliate claims, promo-claim balance writes, the admin USD-aggregation endpoint.

1. User-visible contract

Four HTTP endpoints, all mounted on AccountingController (apps/api/src/accounting/controllers/accounting.controller.ts) and all @UseGuards(JwtGuard). No @Throttle decorator — they ride the global bucket only. No Redis cache at any layer — every request is a live Prisma hit.

Balance list Vault-balance list To-vault From-vault
Method · path GET /accounting/balances GET /accounting/vault-balances POST /accounting/to-vault POST /accounting/from-vault
Body / params BalanceChangeVaultAmountDto: { currencyId: CurrencySymbolBalance, amount: Decimal } same
Response UserBalanceDto[] length 12 (gap-filled) UserVaultBalanceDto[] length 12 UserVaultBalanceChangeDto same
Cache none none n/a n/a
rt side effect none none BalanceUpdated publish (post-commit) same

UserBalanceDto (dto/user-balance.dto.ts:9-33) exposes { updatedAt, currencyId, amount, usdAmount } and @Excludes userId. UserVaultBalanceDto (lines 70-95) projects { updatedAt, currencyId, vaultAmount, usdAmount? }usdAmount here is computed by a @Transform that calls ExchangeRatesService.toUsd(vaultAmount, currencyId) at serialisation time, so two concurrent reads can return different USD values if the FX table updated in between. UserVaultBalanceChangeDto extends UserBalanceDto with { vaultAmount, beforeBalance, afterBalance, beforeVaultBalance, afterVaultBalance } for UI animation.

The gap-fill is the subtle part: UserBalanceRepository.findMany (user-balance.repository.ts:211-250) queries only the rows that exist in Postgres, then iterates listWalletCurrenciesOrdered() (:27-38) — which returns the twelve CurrencySymbolBalance entries (DBC first, then crypto alphabetical: BNB, BTC, ETH, LTC, POL, SOL, TETH, TRX, USDC, USDT, XRP) — and synthesises a zero row for every missing currency. The FE can therefore render the full wallet grid without branching on missing-row cases. TETH (test ETH) is included because the enum doesn't separate testnets from mainnets; a FE that wants to hide it must filter client-side.

2. Sequence diagram

Both reads collapse to the same shape — the vault variant swaps the prismaSelect projection. The mutating vault endpoints open a Prisma transaction that writes one UserBalance row AND one Transaction ledger row before committing, then fires the rt push.

sequenceDiagram
  participant U as Browser (authenticated)
  participant MW as Express + JwtGuard
  participant C as AccountingController
  participant S as UserBalanceService
  participant R as UserBalanceRepository
  participant PG as Postgres (Prisma)
  participant AS as AccountingService
  participant RD as Redis pub/sub
  participant RT as rt :4001 ClientGateway
  alt List balances / vault-balances
    U->>MW: GET /accounting/[vault-]balances (cookie: access_token)
    MW->>C: getBalances / getVaultBalances(req.user)
    C->>S: findMany({user}) / findManyVaultBalance({user})
    S->>R: findMany({userId})
    R->>PG: prisma.userBalance.findMany({where:{userId, currencyId:{in: CurrencySymbol[]}}, select: ...prismaSelect()})
    PG-->>R: existing rows (may be 0…12)
    R->>R: gap-fill against listWalletCurrenciesOrdered (DBC-first, crypto alpha)
    R->>R: ExchangeRatesService.toUsd(amount, currencyId) per row (balances only)
    R-->>U: 200 [12 × UserBalanceDto | UserVaultBalanceDto]
  else Vault transfer (to-vault / from-vault)
    U->>MW: POST /accounting/to-vault {currencyId, amount}
    MW->>C: toVault(body, req.user)
    C->>S: toVault({currencyId, amount, user})
    S->>S: reject if user.claimedPromoCodes.some(p.isActive) ⇒ PROMO_CODE_IS_ACTIVE
    S->>AS: createTransaction({userId, currencyId, amount, type:WITHDRAW, tag:VAULT})
    AS->>PG: BEGIN
    AS->>R: toVault({userId, currencyId, amount}) — single UPDATE amount -= N, vaultAmount += N
    PG-->>AS: row (new amount/vaultAmount)
    AS->>PG: transaction.create(type=WITHDRAW, tag=VAULT, before/after, payload)
    AS->>PG: COMMIT
    Note over AS: PrismaTransactional.onClosed fires post-commit
    AS->>RD: PUBLISH server_channel_event.BalanceUpdated {amount, currencyId, updatedAt, user:{id}}
    RD-->>RT: relay
    RT-->>U: socket.emit('BalanceUpdated', {amount, currencyId, updatedAt, __m})
    AS-->>U: 201 UserVaultBalanceChangeDto
  end

The E2E asserts the write-side effect is observable via a follow-up read: after a dice bet the next GET /accounting/balances shows the net delta on the DBC row, and the vault round-trip (to-vaultfrom-vault of equal amount) returns the row to the pre-transfer amount.

3. Component diagram

Edges are numbered in request-flow order across all five endpoints (reads first, then the vault transfer, then the WS-only ledger read). Section §4 below has the same numbers — each (N) on the diagram has its own §4.N subsection, so you can click straight through.

flowchart TD
    %% Datastores
    pg[("Postgres<br/>UserBalance · Transaction")]
    rd[("Redis (cache)<br/>BalanceUpdated pub/sub · BullMQ updateSessionQueue")]

    %% Dropbet browser
    subgraph fe["ebit-fe (Next.js, dropbet)"]
        q["Wallet widget + vault page<br/><i>GET balances · GET vault-balances · POST to-/from-vault</i>"]
    end

    %% Primary api process
    subgraph api["ebit-api :4000"]
        ctrl["AccountingController<br/><i>5 endpoints, all @UseGuards(JwtGuard)</i>"]
        svc["UserBalanceService<br/><i>findMany · findManyVaultBalance · toVault · fromVault (promo-active guard)</i>"]
        repo["UserBalanceRepository<br/><i>findMany + gap-fill · split-UPDATE toVault/fromVault (SF-013)</i>"]
        acc["AccountingService.createTransaction<br/><i>opens Prisma tx · onClosed publish</i>"]
        cur["CurrencyService.listWalletCurrenciesOrdered<br/><i>frozen 12-entry CurrencySymbolBalance (SF-018)</i>"]
        fx["ExchangeRatesService.toUsd<br/><i>per-row USD at serialise time (SF-017)</i>"]
        wsCtrl["AccountingGatewayController<br/><i>WS Private.TransactionFindMany — no HTTP twin (SF-015)</i>"]
    end

    %% rt process (separate deploy unit)
    subgraph rt["ebit-rt :4001 (different deploy unit)"]
        cg["ClientGateway.handleServerEvent<br/><i>O(n_sockets) per-user filter (SF-016)</i>"]
    end

    %% (1)-(3) Read entry: HTTP → controller → service → repo
    q -- "(1) 5 endpoints (cookie: access_token)" --> ctrl
    ctrl -- "(2) findMany / findManyVaultBalance / toVault / fromVault" --> svc
    svc -- "(3) read pass-through" --> repo

    %% (4) Vault transfer: service → AccountingService (promo-active guard at svc)
    svc -- "(4) createTransaction (WITHDRAW|DEPOSIT tag=VAULT)" --> acc

    %% (5) Accounting → repo inside the Prisma tx (split UPDATE)
    acc -- "(5) toVault/fromVault inside tx" --> repo

    %% (6) Repo → Postgres: findMany read + split-UPDATE write
    repo -- "(6) UserBalance findMany · UPDATE amount/vaultAmount (SF-013 no overdraft)" --> pg

    %% (7)-(8) Read-only side-deps for gap-fill + FX
    repo -- "(7) listWalletCurrenciesOrdered gap-fill" --> cur
    repo -- "(8) toUsd per row (balances only; vault uses @Transform)" --> fx

    %% (9) Ledger row inside the same Prisma tx
    acc -- "(9) Transaction.create (deterministic id, game_id=null)" --> pg

    %% (10) Post-commit publish
    acc -- "(10) PUBLISH BalanceUpdated (onClosed)" --> rd

    %% (11) rt subscriber relays to the socket
    rd -- "(11) subscribe + per-user fan-out" --> cg

    %% (12) WS-only ledger read (no HTTP route)
    wsCtrl -- "(12) Transaction.findMany (Private.TransactionFindMany)" --> pg

    %% Style: datastores stand out
    classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
    class pg,rd db;

4. Per-step walkthrough

Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. Three captured traces back this walkthrough: balances 4dc1101205d4… (10.79 ms, 28 spans), vault-balances 149a40ec… (28 spans, same shape), and POST /accounting/to-vault 077b2a57… (23.07 ms, 40 spans). from-vault is the mirror of to-vault and shares its step numbers. The shared 12-span middleware prefix (auth-session:…, user:details:<id>, zscore online_users, evalsha bull:updateSessionQueue) is identical to every JWT-guarded request and is covered in dropbet-sign-in.md §4.1.

4.1 Step (1) — five endpoints reach AccountingController

AccountingController mounts GET /accounting/balances (line 27), GET /accounting/vault-balances, POST /accounting/to-vault (lines 42-52), and POST /accounting/from-vault, all behind @UseGuards(JwtGuard) with no @Throttle. The fifth surface — Private.TransactionFindMany — does not reach this controller (see §4.12). The JWT-decoded request.user is the only source of userId, so cross-user reads are structurally impossible.

4.2 Step (2) — controller hands off to UserBalanceService

Each handler is a one-liner. getBalancesfindMany({ user: request.user }) (user-balance.service.ts:46-50); getVaultBalancesfindManyVaultBalance(...); toVault / fromVault → the corresponding service method (user-balance.service.ts:81-100). The vault methods perform the promo-active veto at the service layer before delegating: args.user.claimedPromoCodes?.some(p => p.isActive) throws ApiCode.PROMO_CODE_IS_ACTIVE to stop a user mid-promo from stashing wager-blocked funds.

4.3 Step (3) — read pass-through to UserBalanceRepository

findMany / findManyVaultBalance are thin pass-throughs at the service layer; the repo is where the read work actually happens. No DB calls at this step itself — the work is in step (6) plus the gap-fill / FX legs in steps (7)–(8).

4.4 Step (4) — vault transfer enters AccountingService.createTransaction

On clear of the promo guard, the vault service delegates to AccountingService.createTransaction({ type: WITHDRAW|DEPOSIT, tag: VAULT }) — the same general-purpose ledger writer used by bets (dropbet-bet-place.md §4.2). It wraps the work in PrismaTransactional.execute, opening one prisma:client:transaction span that parents steps (5), (6 write), (9), and the post-commit publish in (10).

4.5 Step (5) — AccountingService calls the repo toVault/fromVault inside the tx

UserBalanceRepository.toVault/fromVault (user-balance.repository.ts:116-172) is invoked inside the Prisma transaction opened in step (4). Both methods return a row shaped for UserVaultBalanceChangeDto.prismaSelect() so the response carries { amount, vaultAmount, beforeBalance, afterBalance, beforeVaultBalance, afterVaultBalance } for UI animation. The actual SQL fires on the next edge.

4.6 Step (6) — Postgres UserBalance read and split-UPDATE

Two distinct SQL shapes hit the same table via the repo:

  • Read (balances / vault-balances): prisma.userBalance.findMany({ where: { userId, currencyId: { in: Object.values(CurrencySymbol) } }, select: UserBalanceDto.prismaSelect() }) — emits prisma:client:operation (1.97 ms, model=UserBalance, method=findMany) → prisma:engine:query (1.20 ms) → prisma:engine:db_query (0.62 ms). The vault variant swaps in UserVaultBalanceDto.prismaSelect() (drops amount + userId, selects vaultAmount); shape and timing are otherwise identical (28 spans in 149a40ec…).
  • Write (to-/from-vault): UserBalance.update — 3.94 ms. One UPDATE user_balance SET amount = amount - N, vault_amount = vault_amount + N WHERE user_id = ? AND currency_id = ? (or the mirror for from-vault). No WHERE amount >= N guard and no CHECK (amount >= 0) on the main side (only vault_amount_check defends the vault side). Observed live: amount=1000.282 + transfer 10003.82 → 201 with afterBalance=-9003.538. SF-013.

Index inventory on user_balance: just the composite PK @@id([userId, currencyId]). At enum-cardinality (12 rows/user) the per-user findMany is O(12) regardless of index choice.

4.7 Step (7) — listWalletCurrenciesOrdered gap-fill (reads only)

After the read, the repo iterates CurrencyService.listWalletCurrenciesOrdered() — a frozen 12-element array (DBC first, then crypto alphabetical: BNB, BTC, ETH, LTC, POL, SOL, TETH, TRX, USDC, USDT, XRP) — and synthesises { userId, currencyId, amount: 0, usdAmount: 0, updatedAt: now() } (balances) or { currencyId, vaultAmount: 0, updatedAt: now() } (vault) for every missing currency. The FE can render the full wallet grid without branching on missing-row cases. TETH (testnet ETH) is indistinguishable from ETH at the API layer — SF-018, FE has to denylist client-side. The array is loaded at boot, so adding a CurrencySymbolBalance member requires a restart.

4.8 Step (8) — ExchangeRatesService.toUsd per-row FX (balances only)

For rows that exist, the balances repo path calls ExchangeRatesService.toUsd(amount, currencyId) synchronously per row. The rate table is an in-process singleton (refresh path not in this trace); no fan-out spans. Rates are recomputed per request, not cached on the row, so two back-to-back reads can return different USD totals if the rate table refreshed in between — SF-017. The vault variant skips this leg at the repo layer; instead UserVaultBalanceDto's usdAmount? @Transform runs at class-transformer time, so the span tree contains no per-row FX side-effects on GET /accounting/vault-balances.

4.9 Step (9) — Transaction.create ledger row inside the same tx

Transaction.create — 2.66 ms. Ledger row with type=WITHDRAW|DEPOSIT, tag=VAULT, deterministic id tx-<dir>-<cur>--<uuid>, before_balance / after_balance, game_id=null (vault movement is not a game settlement). prisma:engine:commit_transaction — 1.62 ms — closes the tx after this insert. The general-purpose nature of createTransaction is why bets (dropbet-bet-place.md §4.4) and vault transfers share the same writer — only the type/tag/game_id triple differs.

4.10 Step (10) — PUBLISH BalanceUpdated post-commit via onClosed

PrismaTransactional.onClosed fires post-commit (so side effects are only visible if the DB write succeeded). One publish span — server_channel_event.BalanceUpdated, 0.42 ms — carrying { amount, currencyId, updatedAt, toastMessage?, __m:{ts,windowId?}, user:{ id } }. A second evalsha hits bull:updateSessionQueue (the session-touch every authenticated request fires; not specific to vault). There's no bull:bet_settled_queue enqueue on this path — vault movement is pure accounting.

4.11 Step (11) — rt ClientGateway.handleServerEvent per-socket fan-out

A separate trace on the rt service (ioredis pub/sub doesn't propagate W3C traceparent — see §7). apps/rt/src/gateway/client.gateway.ts:289-321 subscribes to server_channel_event.BalanceUpdated and iterates this.clientSockets.forEach(c => c.user.id === id && c.emit(...))O(n_sockets) per balance change, scanning every public-feed socket on every event. SF-016. A per-user room (this.server.to('user:'+id).emit) would make it O(1).

4.12 Step (12) — WS-only ledger read Private.TransactionFindMany

The twin to this flow — the "transactions" tab — is not on AccountingController. apps/api/src/accounting/controllers/accounting-gateway.controller.ts exposes Private.TransactionFindMany over the rt socket (FindManyTransactionsQueryPaginatedDto<TransactionDto>), which means a user browsing via curl/Postman has no HTTP path to their own ledger even though the balance read is plain REST. SF-015. Cheap fix: add GET /accounting/transactions paginated on (userId, createdAt desc). Pairs with task #36 (rt flow doc).

5. Data model

One Prisma model (UserBalance) backs the whole read path; the write path additionally appends to Transaction.

Object R/W Fields touched Notes
UserBalance (api.prisma:363-377) R (list) updatedAt, amount, currencyId, userId Composite PK @@id([userId, currencyId])no other indexes. findMany uses a PK range scan on userId (Postgres picks the PK's BTree prefix); at 12 rows/user the plan is a seq-on-range either way.
UserBalance R (vault list) updatedAt, currencyId, vaultAmount projection only; amount omitted on wire.
UserBalance W (vault transfer) amount, vaultAmount, updatedAt single UPDATE mutates both columns; Postgres vault_amount_check CHECK constraint prevents vault_amount < 0 (the error surfaces as ACCOUNTING_BALANCE_INSUFFICIENT via accounting.service.ts:327-330). No matching amount_check — see SF-013.
Transaction (api.prisma:712-752) W (vault transfer) type=WITHDRAW|DEPOSIT, tag=VAULT, amount, before_balance, after_balance, game_id=null, payload deterministic id tx-<dir>-<cur>--<uuid> (no game); game_id null for vault txns.
CurrencySymbolBalance enum R (static) 12 members: DBC, BNB, BTC, ETH, LTC, POL, SOL, TETH, TRX, USDC, USDT, XRP libs/accounting/src/currency/currency.const.ts; the enum is the exhaustive set for gap-fill.
Redis pub/sub server_channel_event.BalanceUpdated W (vault transfer) {amount, currencyId, updatedAt, toastMessage?, __m:{ts,windowId?}} targeted at user:{id} emitted from accounting.service.ts:365-386; relayed by apps/rt/src/gateway/client.gateway.ts:289-321 per-socket by client.user.id match.

Index inventory on user_balance: just the composite PK. At the seed fixture's user-count (~dozens) that's irrelevant, but if the platform grows past six-figure user counts the per-user scan is O(12) anyway (enum cardinality cap), so the PK alone is sufficient by construction.

6. Failure modes

  1. SF-013 — to-vault has no overdraft guard, balance can go negative. UserBalanceRepository.toVault issues a single UPDATE with amount: { decrement: N } and no WHERE amount >= N clause; no Postgres CHECK (amount >= 0) on the main side (only vault_amount_check on the vault side). Observed live: amount=1000.282 + transfer 10003.82 → 201 with afterBalance=-9003.538. Fix: gate the repo UPDATE with where: { amount: { gte: N } } and map rowcount=0 to ACCOUNTING_BALANCE_INSUFFICIENT. The E2E pins the broken state; a fix trips it loudly.
  2. SF-014 — Zero caching on wallet reads. Every GET /accounting/balances is a Prisma round-trip plus up-to-12 synchronous ExchangeRatesService.toUsd calls. The FE widget re-renders on every socket push AND polls on tab-focus — this is among the hottest JWT-guarded read paths. A short-TTL @Cacheable on accounting:balances:{userId} invalidated by the BalanceUpdated publisher would cut it to one Redis get.
  3. SF-015 — Transaction ledger has no HTTP endpoint. Balances are REST but the ledger is served only over rt via Private.TransactionFindMany. Curl-debugging a "where did my 0.1 DBC go" ticket forces the operator into the backoffice route. Cheap fix: add GET /accounting/transactions paginated on (userId, createdAt desc).
  4. SF-016 — ClientGateway per-socket filter is O(n_sockets). handleServerEvent (client.gateway.ts:306-315) iterates this.clientSockets.forEach(c => c.user.id === id && c.emit(...)) on every BalanceUpdated. With thousands of public-feed sockets, each balance change scans the whole map. A per-user room (this.server.to('user:'+id).emit) would be O(1).
  5. SF-017 — usdAmount is request-time FX. Rates are recomputed per request, not cached on the row. Two back-to-back reads can return different USD totals if the rate table refreshed in between. Either snapshot FX once per request or stamp USD at write time.
  6. SF-018 — TETH indistinguishable from ETH at the API layer. CurrencySymbolBalance doesn't encode testnet-vs-mainnet; FE has to denylist client-side. Low priority but confusing.

7. Unresolved

  • No rt-side end-to-end trace of BalanceUpdated. Publisher and rt subscriber live in separate traces — ioredis pub/sub doesn't propagate W3C traceparent. Fix lands with task #36 via inline traceparent in the EventMessage envelope.
  • Promo-active check uses hydrated request.user.claimedPromoCodes. If stale (JWT issued, promo claimed mid-session), a user could slip to-vault through. A fresh findFirst UserPromoCode per call would fix it; currently skipped. Validate the session-refresh re-hydration path.
  • CurrencyService.getBalanceCurrencies() is an in-process array loaded at boot. Post-boot additions to CurrencySymbolBalance require a restart. Called out so future enum migrations don't surprise operators.
  • No metric on gap-fill hit-rate. The wire can't distinguish a user with all 12 currencies seeded from one with only DBC. A wallet.currencies_present{userId} counter alongside task #25 would let product track crypto adoption.
  • Admin aggregate deferred. UserBalanceService.findUsdBalance re-uses findMany and sums USD — called from the bo backoffice, out of this flow; worth a separate line-item in task #40.