跳到主要内容

Wallet Service

Status

Active

Date

2026-04-28

Owners

  • Platform Backend

Last Verified Commit

7a579730

Runtime

API + standalone worker

Purpose

wallet_service is the single money writer in servers_v2. All balance mutations, approved deposits, approved withdrawals, and wallet-owned finance side effects must terminate here.

Primary Entry Points

  • /internal/wallet/*

Main route groups:

  • wallet commands
  • finance and query endpoints
  • topology-driven wallet commands under /internal/wallet/v2/*
  • wallet topology and policy seed/read endpoints
  • Plisio callback handling

Protection model:

  • owner-command and query routes under /internal/wallet/* require X-Internal-Service-Token
  • gateway, admin_service, rolling_service, promotion_service, game_service, and recon_service are the intended internal callers
  • compatibility exception: POST /internal/wallet/plisio/callback remains public because it is invoked by the external Plisio gateway
  • compatibility exception: POST /player/deposit/plisio/callback is kept as the old public Plisio webhook URL during cutover
  • POST /internal/wallet/plisio/agree stays protected because it is an operator-triggered internal action

Topology Wallet Model

The topology-driven wallet path is the clean Ruby Wallet implementation. It supports both the split RUBY_SPLIT_V1 topology and the unified RUBY_UNIFIED_V1 topology. It does not use legacy flat player money columns as canonical state.

Current internal endpoints:

  • GET /internal/wallet/topology/active
  • GET /internal/wallet/topology/{code}
  • PUT /internal/wallet/topology/{code}
  • PUT /internal/wallet/topology/{code}/activate
  • PUT /internal/wallet/policy/{policy_key}
  • PUT /internal/wallet/policy/{policy_key}/activate
  • POST /internal/wallet/topology/seed-ruby-split-v1
  • POST /internal/wallet/topology/seed-ruby-unified-v1
  • POST /internal/wallet/v2/bets/authorize
  • POST /internal/wallet/v2/bets/settle
  • POST /internal/wallet/v2/bets/rollback
  • POST /internal/wallet/v2/deposits/approve
  • POST /internal/wallet/v2/deposits/approve-by-transaction
  • POST /internal/wallet/v2/withdrawals/create
  • POST /internal/wallet/v2/withdrawals/refund
  • POST /internal/wallet/v2/adjustments
  • POST /internal/wallet/v2/promotions/credit-points
  • POST /internal/wallet/v2/coupons/grant
  • POST /internal/wallet/v2/promotions/reverse
  • POST /internal/wallet/v2/transfers
  • POST /internal/wallet/v2/points/transfer
  • GET /internal/wallet/v2/players/{player_id}/snapshot

Topology and policy drafts must use a new document version when editing a published row. A draft save is not allowed to overwrite an ACTIVE or SUPERSEDED topology/policy version; published rows only change through the explicit activation flow.

Topology switches must activate the topology and matching policy in the same wallet_service transaction. Use PUT /internal/wallet/topology/{code}/activate with document, policy_key, and policy_document; the service validates the policy against the target topology, supersedes the previous active topology and policy, upserts bucket types, and activates the new pair atomically. Direct PUT /internal/wallet/policy/{policy_key}/activate is only valid for replacing the policy on the currently active topology.

Legacy flat bet writers under /internal/wallet/bets/* are kept only as compatibility route names and fail closed. New betting integrations must use the /internal/wallet/v2/bets/* topology endpoints.

Player-facing rebate transfer should continue to proxy through /internal/wallet/transfer-rebate; that compatibility route supplies the legacy default target and records rebate_transfer daily stats before calling the topology points-transfer command.

The topology-wallet bet lifecycle writes:

  • wallet.wallet_bet_authorization
  • wallet.wallet_ledgerappend-only at the DB layer post-0036; UPDATE/DELETE on a finalised row + TRUNCATE of the table raise loud plpgsql exceptions via wallet.wallet_ledger_append_only(). The legacy compatibility ledger player_balance_log got the same trigger pair in 0036.
  • wallet.wallet_bucket
  • wallet.wallet_coupon_grant
  • wallet.wallet_idempotency

Other wallet-owned aggregates (read by the same service, written via the same wallet command paths or worker loops): wallet_account, wallet_bucket_type, wallet_transfer, wallet outbox table, wallet_inbox, wallet_dead_letter. All of the above carry brand_id per ADR-009.

Settlement and rollback must use the authorization's stored policy snapshot and funding breakdown. They must not recompute sources from the current policy. Topology settle and rollback also publish the same wallet outbox events as the legacy path so rolling_service and projections continue receiving bet progress and rollback signals. If a winning bet was funded by both withdrawable and non-withdrawable sources, settlement credits winnings proportionally: the withdrawable-funded share goes back to the shared withdrawable bucket, and the remaining share follows the provider group's configured normal-wallet payout destination. Coupon-funded rolling attribution is published with the explicit COUPON target so coupon rollings cannot advance unrelated normal or bonus rollings.

Deposit approval, withdrawal create/refund, admin adjustment, promotion, coupon, points transfer, normal-wallet transfer, Plisio approval, recon auto-approval, and snapshot commands are available on the topology-wallet route family. The old /internal/wallet/transfer-withdrawal/{type} entrypoint is kept only as a player-facing compatibility alias and now performs an allowed full normal-wallet transfer: sport moves casino normal to sports normal, while live&slots / live_slots moves sports normal to casino normal. Normal-wallet transfers inherit unfinished source-bucket rolling transactionally by transfer ratio; they do not fail open or silently ignore the inherit_rolling policy.

Compatibility note:

  • legacy internal routes such as /internal/wallet/withdraw, /internal/wallet/withdraw/*/decline, /internal/wallet/adjust, and /internal/wallet/rolling/apply-* now delegate to topology bucket commands
  • rolling records now carry optional topology/policy metadata so sports, live, and slots progress can be attributed by provider type and source bucket without re-reading current policy
  • legacy /internal/wallet/deposit, /internal/wallet/deposit/agree, and /internal/wallet/deposit/recycle now create, approve, and recycle deposit orders against topology buckets
  • manual deposit/agree bonus overrides are persisted to the deposit order before topology approval, so the credited bucket amount and daily bonus stats match the operator-approved values
  • cash deposit auto-approval now debits the topology withdrawable bucket and credits the configured target bucket instead of mutating legacy flat cash columns

Dependencies

  • PostgreSQL
  • Redis
  • Plisio secret for fail-closed public callback verification

Background Work

wallet_worker publishes wallet outbox rows to Redis Streams and runs the hourly ledger-vs-balance reconciliation.

Current worker responsibilities:

  • ensure wallet stream exists
  • publish unpublished outbox rows
  • hourly ledger-vs-balance reconciliation (read-only drift detector covering buckets + coupon grants; app/tasks/ledger_reconciliation.py) — supervised here, not in the API process, so multi-replica API deploys do not double-run/alert
  • expose heartbeat-backed worker health

Owned Data

  • wallet idempotency state
  • wallet outbox rows
  • wallet inbox rows
  • wallet dead-letter rows
  • wallet topology and policy documents
  • wallet bucket type definitions
  • wallet account and bucket balances
  • wallet transfer records
  • wallet coupon grants
  • wallet bet authorizations
  • wallet ledger rows
  • approved deposit and withdrawal write semantics
  • balance logs and wallet-owned side effects

Events

Emits (currently in wallet_outboxwallet:events stream):

  • BET_SETTLED_CONFIRMED
  • BET_ROLLED_BACK_CONFIRMED
  • ROLLING_COMPLETED_CONFIRMED
  • DEPOSIT_APPROVED
  • WITHDRAW_APPROVED

Reserved (event-type constants declared in servers_v2/shared/contracts/src/rgb_contracts/events/wallet.py but no producer wired — consumers MUST NOT rely on these arriving):

  • BET_AUTHORIZED
  • PROMOTION_CREDITED
  • PROMOTION_REVERSED
  • COUPON_EXPIRED

Authoritative split is in docs/architecture/event-catalog.md. The pathname:// prefix is required because Docusaurus loads docs/services/ and docs/architecture/ as separate content-docs plugin instances (see docs/docusaurus/docusaurus.config.ts) and a plain ../architecture/... markdown link cannot resolve across plugin boundaries — onBrokenMarkdownLinks: 'throw' would fail the docs build.

Adding a reserved event to the live stream requires a single PR that (a) wires the producer (write_outbox_row in the same transaction as the business write) and (b) flips its row from Reserved to Emits in both files.

Consumes:

  • none as a required upstream stream consumer

Health

  • API /health checks DB and Redis
  • wallet_worker health is heartbeat-backed and tied to outbox publication supervision

Key Env Vars

  • DATABASE_URL
  • REDIS_URL
  • DB_SEARCH_PATH
  • ENABLE_OUTBOX_POLLER
  • MULTI_BRAND_ENFORCEMENT — required; one of off / observe / enforce. Production target is enforce post-Phase-16. Drives multi_brand_enforcement_mode{service="wallet_service"} gauge.
  • WALLET_BRAND_SIGNATURE_REQUIREon is the production/default posture; off is only a temporary local or rollback override. When on, unsigned brand-scoped writes are rejected with 403 brand_signature_missing in enforce mode. See "Brand-signature enforcement flip" in docs/runbooks/multi-brand/multi-brand-isolation-rollout.md.
  • BRAND_SIGNING_KEYrequired in production when MULTI_BRAND_ENFORCEMENT=enforce AND WALLET_BRAND_SIGNATURE_REQUIRE=on. HMAC-SHA256 secret shared with the 6 wallet write callers (gateway, admin_service, game_service, promotion_service, recon_service, rolling_service). Boot guard assert_brand_signing_key_configured refuses to start in the bad combination; runtime fail-closed branch + wallet_brand_signature_misconfigured_total{mode="enforce"} is the in-flight defence.
  • BRAND_SIGNING_KEY_PREV — Optional. When provided, wallet_service accepts X-Brand-Signature signed by EITHER the current BRAND_SIGNING_KEY or BRAND_SIGNING_KEY_PREV. Use this for zero-downtime key rotation: set both, wait one rotation window (signed requests in flight must complete), drop the PREV. See multi-brand-isolation-rollout.md:435-447 for the full procedure. Leaving PREV empty (default) means no rotation overlap window — direct flip will 403 in-flight signed requests.
  • PER_CALLER_TOKEN_REQUIREDon is the Phase 16 target; when set, the legacy single-shared-token fallback hard-rejects (403 + internal_caller_token_legacy_rejected_total) instead of accepting (T4-D-I2).
  • INTERNAL_SERVICE_TOKEN_GATEWAYrequired in production; per-caller token consumed when gateway calls into wallet.
  • INTERNAL_SERVICE_TOKEN_AGENTrequired in production; per-caller token for agent_service.
  • INTERNAL_SERVICE_TOKEN_ADMINrequired in production; per-caller token for admin_service.
  • INTERNAL_SERVICE_TOKEN_PROMOTIONrequired in production; per-caller token for promotion_service.
  • INTERNAL_SERVICE_TOKEN_ROLLINGrequired in production; per-caller token for rolling_service.
  • INTERNAL_SERVICE_TOKEN_GAMErequired in production; per-caller token for game_service.
  • INTERNAL_SERVICE_TOKEN_RECONrequired in production; per-caller token for recon_service.
  • PLISIO_SECRETrequired in production; boot guard assert_plisio_secret_configured refuses to start the service when missing in production/staging/prod envs; the Plisio webhook HMAC-SHA1 verification keys off this value.
  • INTERNAL_SERVICE_TOKEN — legacy single-shared-token; accepted only while PER_CALLER_TOKEN_REQUIRED is unset/off. Phase 16 release gate requires this env var to be absent on every wallet pod.

Multi-Brand Constraints

Per ADR-009:

  • every wallet-owned aggregate carries brand_id: wallet_account, wallet_bucket, wallet_bucket_type, wallet_coupon_grant, wallet_bet_authorization, wallet_ledger, wallet_transfer, wallet_idempotency, wallet outbox, wallet_inbox, wallet_dead_letter
  • wallet_topology and wallet_policy documents are brand-scoped; uniqueness becomes (brand_id, code, version) and (brand_id, topology_code, version, policy_key) respectively; the partial unique active indexes (uq_wallet_topology_single_active, uq_wallet_policy_active_key) are rebuilt to include brand_id so each brand has its own active row simultaneously
  • wallet_bucket_type uniqueness becomes (brand_id, topology_code, topology_version, code) so two brands may share the same topology_code (e.g. both inherit RUBY_SPLIT_V1) without bucket-type collisions
  • topology activation safety checks (per ADR-005) are scoped to the activating brand; cross-brand state never blocks activation
  • wallet_idempotency uniqueness becomes (brand_id, idempotency_key); wallet_bet_authorization provider-bet uniqueness becomes (brand_id, provider_type, provider_id, bet_id)
  • wallet_service checks every command's target row brand against the request brand and behaves per MULTI_BRAND_ENFORCEMENT (the same flag as gateway): observe logs + counts via wallet_cross_brand_rejected_total{command,mode} and proceeds using the request brand as the authoritative scope; enforce hard-rejects. Money mutations never use a brand inferred from the target row alone
  • internal wallet routes require X-Brand-Id; the Plisio public callback resolves brand from the deposit transaction record (which carries brand_id from creation time)
  • ADR-005's "single money writer" rule is unchanged; brand isolation does not loosen it
  • internal-service authentication uses per-caller-service tokens (INTERNAL_SERVICE_TOKEN_GATEWAY, INTERNAL_SERVICE_TOKEN_AGENT, etc.); a compromise of one caller's token cannot impersonate another caller
  • brand-scoped wallet write commands carry X-Brand-Signature: HMAC_SHA256(BRAND_SIGNING_KEY, caller_service|brand_id|request_id|timestamp); missing or invalid signature is rejected in enforce mode (logged + counted in observe via wallet_brand_signature_failed_total)
  • wallet outbox events include brand_id in payload so downstream rolling, promotion, and game services can scope their reactions

Two error codes carry the brand-isolation rejection responses (raised from app/api/routes/wallet.py and app/api/routes/bucket_wallet.py):

  • WALLET_BRAND_MISMATCH — the wallet command's target row brand does not match the request brand (resolved from X-Brand-Id plus the per-route brand-context resolver). Returned from both bucket_wallet and legacy wallet write paths in enforce mode; in observe mode the same condition is logged and counted (wallet_cross_brand_rejected_total{command,mode}) instead of rejected. Operator action: confirm the caller is targeting the correct brand; check X-Brand-Id propagation.
  • WALLET_BRAND_SIGNATURE_REJECTED — the inbound brand-scoped command is missing X-Brand-Signature or the signature does not verify against BRAND_SIGNING_KEY. Returned in enforce mode; in observe mode the same condition is logged and counted via wallet_brand_signature_failed_total. Operator action: confirm BRAND_SIGNING_KEY is provisioned (boot guard assert_brand_signing_key_configured refuses to start if not), and that the calling service is signing with the same key.

Both envelopes carry the standard {error_code, error_message, request_id} shape.

Tests

  • cd servers_v2/wallet_service && uv run pytest
  • key suites:
    • tests/test_outbox_poller.py
    • tests/test_events_health.py
    • tests/test_worker_health.py
    • tests/test_internal_auth.py
    • tests/test_wallet_topology_policy.py
    • tests/test_wallet_topology_store.py
    • tests/test_wallet_topology_routes.py
    • tests/test_wallet_bucket_commands.py