Flow: dropbet wallet — balance view + internal vault transfer¶
Balances trace:
4dc1101205d40a92fda7656b01be13f3· Vault-balances trace:149a40ec1947c4bd9957c729f0c698e1·POST /accounting/to-vaulttrace:077b2a5752bdda2ce180fb68e7d86ca9· Jaeger: http://localhost:16686/ · E2E:tests-e2e/tests/dropbet-wallet.spec.tsGenerated: 2026-04-16 · Author: prisma-otel-engineer · Services traced:ebit-api. Companion todropbet-bet-place.md(task #31 — bets mutate the sameUserBalancerow this flow reads) and the forthcoming rt flow doc (task #36 — describes the socket side ofBalanceUpdated). 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-vault → from-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. getBalances → findMany({ user: request.user }) (user-balance.service.ts:46-50); getVaultBalances → findManyVaultBalance(...); 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() })— emitsprisma: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 inUserVaultBalanceDto.prismaSelect()(dropsamount+userId, selectsvaultAmount); shape and timing are otherwise identical (28 spans in149a40ec…). - Write (to-/from-vault):
UserBalance.update— 3.94 ms. OneUPDATE user_balance SET amount = amount - N, vault_amount = vault_amount + N WHERE user_id = ? AND currency_id = ?(or the mirror forfrom-vault). NoWHERE amount >= Nguard and noCHECK (amount >= 0)on the main side (onlyvault_amount_checkdefends the vault side). Observed live:amount=1000.282+ transfer10003.82→ 201 withafterBalance=-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 (FindManyTransactionsQuery → PaginatedDto<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¶
- SF-013 —
to-vaulthas no overdraft guard, balance can go negative.UserBalanceRepository.toVaultissues a single UPDATE withamount: { decrement: N }and noWHERE amount >= Nclause; no PostgresCHECK (amount >= 0)on the main side (onlyvault_amount_checkon the vault side). Observed live:amount=1000.282+ transfer10003.82→ 201 withafterBalance=-9003.538. Fix: gate the repo UPDATE withwhere: { amount: { gte: N } }and map rowcount=0 toACCOUNTING_BALANCE_INSUFFICIENT. The E2E pins the broken state; a fix trips it loudly. - SF-014 — Zero caching on wallet reads. Every
GET /accounting/balancesis a Prisma round-trip plus up-to-12 synchronousExchangeRatesService.toUsdcalls. 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@Cacheableonaccounting:balances:{userId}invalidated by theBalanceUpdatedpublisher would cut it to one Redisget. - 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: addGET /accounting/transactionspaginated on(userId, createdAt desc). - SF-016 —
ClientGatewayper-socket filter is O(n_sockets).handleServerEvent(client.gateway.ts:306-315) iteratesthis.clientSockets.forEach(c => c.user.id === id && c.emit(...))on everyBalanceUpdated. 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). - SF-017 —
usdAmountis 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. - SF-018 —
TETHindistinguishable fromETHat the API layer.CurrencySymbolBalancedoesn'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 W3Ctraceparent. Fix lands with task #36 via inlinetraceparentin theEventMessageenvelope. - Promo-active check uses hydrated
request.user.claimedPromoCodes. If stale (JWT issued, promo claimed mid-session), a user could slipto-vaultthrough. A freshfindFirst UserPromoCodeper 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 toCurrencySymbolBalancerequire 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. Awallet.currencies_present{userId}counter alongside task #25 would let product track crypto adoption. - Admin aggregate deferred.
UserBalanceService.findUsdBalancere-usesfindManyand sums USD — called from the bo backoffice, out of this flow; worth a separate line-item in task #40.