Deposit System — Technical Reference
This page is a technical deep-dive into the deposit system. It covers the full service architecture, each deposit flow in detail, payment gateway adapters, game wallet sync, the transaction state machine, and all relevant API endpoints.
For the conceptual overview of wallets, credits, and fee economics, see Finance System.
Service Architecture
Deposit processing is split across three layers:
┌─────────────────────────────────────────────────────────────────┐
│ ENTRY LAYER │
│ depositService.js cdnDepositBizService.js │
│ (player online deposit) (shop CDN credit purchase) │
└──────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────┐
│ GATEWAY LAYER │
│ linkMePayDepositAdapter.js │
│ btcPayDepositAdapter.js │
│ zeroxProcessingDepositAdapter.js │
└──────────────────────────────────┬──────────────────────────────┘
│ IPN / webhook callback
┌──────────────────────────────────▼──────────────────────────────┐
│ SETTLEMENT LAYER │
│ depositCallbackService.js │
│ └── depositSettlementCoreService.js │
│ ├── processWalletOnlyDeposit() │
│ ├── processGameProviderDeposit() │
│ ├── processMultiGameDeposit() │
│ └── settleCdnDepositFromPaymentTx() │
└──────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────┐
│ INFRA LAYER │
│ walletTransferService.js gameWalletSyncService.js │
│ walletService.js usdWalletService.js │
└─────────────────────────────────────────────────────────────────┘
Deposit Types
There are four distinct deposit paths. The system routes to the correct one based on the transaction's buyerType, gameProviderId, and syncMetadata.
| Type | Entry point | buyerType | Has gameProviderId |
|---|---|---|---|
| Wallet-only | Player Web (gateway) | user | No |
| Direct-to-game | Player Web (gateway) | user | Yes |
| Multi-game | Player Web (gateway) | user | Multiple (in metadata) |
| CDN credit purchase | Agent/Shop portal (gateway) | agent | shop | super_agent | No |
| Shop counter (cashier) | Shop Portal (internal) | — | No |
Flow 1 — Wallet-Only Deposit
Player deposits via payment gateway; credits land in the master wallet only.
Step 1 — depositService.processDeposit()
1. Validate user (status = 'active')
2. Validate amount (positive, finite, within min/max limits)
3. Resolve platform fee rate:
resolveProviderFeeRate({ provider, operation: 'deposit', methodId })
→ platformFeeRate, platformFeeAmount = amount × platformFeeRate
[Non-blocking: defaults to 0 on error]
4. Resolve effectiveConfigLevel:
superAgentConfigLevelService.resolveConfigLevelByPlayerId(userId)
→ 'shop' | 'agent' | 'super_agent' [Fallback: 'shop']
5. Create PaymentTransaction { status: 'pending', walletFlow: 'none', syncStatus: 'none' }
6. Call gateway adapter → get paymentUrl
7. Return { transactionId, paymentUrl, amount, status: 'pending', expiresAt }
transactionId format at creation: TEMP_${Date.now()}_${userId}
Step 2 — IPN / Webhook received
See Payment Gateway Callbacks for per-provider details.
Status transitions: pending → processing → completed
Step 3 — depositSettlementCoreService.processWalletOnlyDeposit()
Idempotency guard: skip if syncMetadata.walletCreditedAt exists
1. walletTransferService.creditWallet('player', userId, amount, ...)
→ Wallet.balance += amount
→ WalletTransaction ledger row created
2. usdWalletService.recordDeposit({ ownerType, ownerId, grossUsd, netUsd, ... })
→ Credits shop/agent USD wallet with the net USD received
3. syncMetadata updated:
{ walletCreditedAt: ISO, walletTransactionId, lastSyncSource: 'ipn' }
Flow 2 — Direct-to-Game Deposit
Player selects a game platform on the deposit screen. Credits go directly into the game after the standard gateway flow.
Routing condition: transaction.gameProviderId is set and gameProvider.depositType ≠ 'manual'
After payment confirmed — processGameProviderDeposit()
DB Transaction 1 (short lock):
1. Load + lock UserGameWallet
2. Validate amount ≤ walletBalance
3. Update GameWalletTransaction status → 'processing'
4. Re-validate wallet balance (double-check)
External API call (outside transaction):
adapter.credit({ userId, amount, referenceId, metadata })
→ On error: Update GameWalletTransaction → 'failed' (separate txn)
DB Transaction 2 (short lock):
1. Re-lock UserGameWallet
2. Re-validate balance and status unchanged
3. Update UserGameWallet.walletBalance
4. GameWalletTransaction → 'completed'
5. Record USD deposit to payer tier
Async:
enqueueDepositSync(transaction.id)
→ gameWalletSyncService syncs walletBalance → inGameBalance via game API
syncStatus field tracks the game sync phase:
syncStatus | Meaning |
|---|---|
none | Wallet-only, no game sync needed |
pending | Wallet credited, queued for game API sync |
in_progress | Game API call in flight |
completed | Game confirmed the balance |
failed | Game API returned an error |
Flow 3 — Multi-Game Deposit
Player allocates one payment across multiple game providers. Allocation amounts are stored in syncMetadata.allocations.
After payment confirmed — processMultiGameDeposit()
Idempotency guard: skip if syncMetadata.multiGameProcessedAt exists
DB Transaction:
1. Credit main wallet (first time only):
creditPlayerMainWallet(userId, amount)
→ syncMetadata.mainWalletCreditedAt set
2. Commit pending balance reservation (if any):
walletService.commitReservation(userId, pendingAmount, reserveTxId)
→ syncMetadata.pendingBalanceCommittedAt set
3. Debit main wallet for game allocations:
debitAmount = totalAllocations - pendingBalance (if applicable)
4. For each allocation:
├── depositType = 'manual':
│ → Refund to main wallet
│ → Queue for manual_pending (admin approval required)
└── depositType = 'auto':
→ Load/create UserGameWallet
→ walletBalance += allocationAmount
→ Create GameWalletTransaction { status: 'pending' }
After commit:
5. Create manual deposit requests (for manual_pending allocations)
manualDepositService.createManualDepositRequest(...)
6. enqueueDepositSync(transaction.id)
→ Processes all auto allocations in parallel
syncMetadata shape after multi-game processing:
{
"multiGameProcessedAt": "2026-05-09T10:00:00.000Z",
"allocationsProcessed": 3,
"allocationsManualPending": 1,
"totalAmountDeposited": 50.00,
"allocationResults": [
{ "gameProviderId": "uuid", "amount": 20, "status": "completed" },
{ "gameProviderId": "uuid", "amount": 20, "status": "completed" },
{ "gameProviderId": "uuid", "amount": 10, "status": "manual_pending" }
]
}
Flow 4 — CDN Credit Purchase (Agent/Shop via Gateway)
An agent or shop buys credits for themselves using a payment gateway. After payment, their credit wallet is topped up.
Routing condition: buyerType is agent, shop, or super_agent
After payment confirmed — settleCdnDepositFromPaymentTx()
1. Compute gross/net USD:
platformFeeAmount = gross × platformFeeRate
netUsd = gross - platformFeeAmount
Special case (0x insufficient status):
If syncMetadata.receivedNetUsd exists (partial payment received):
grossUsd = netUsd / (1 - platformFeeRate)
2. Resolve cost rate:
From metadata.costRateAtPurchase or metadata.costRate
Fallback: infer as creditAmount / amount
→ Error if not finite or ≤ 0
3. Compute credits to issue:
creditComputed = grossUsd / costRate
4. Credit CDN owner:
externalCreditCdnPurchase({
ownerType: buyerType,
ownerId: buyerId,
creditAmount: creditComputed,
platformFeeRate: 0 ← fee already applied to USD
})
5. Record USD deposit to payer tier:
usdWalletService.recordDeposit({
ownerType, ownerId, currency: 'USD',
grossUsd, netUsd, provider,
referenceType: 'payment_transaction',
referenceId: paymentTx.id
})
Flow 5 — Shop Counter Deposit (Cashier)
Physical deposit by shop staff. No payment gateway.
cdnDepositBizService.confirmDeposit(shopId, playerId, amount)
1. Verify shop is active, player belongs to shop
2. Generate requestId (UUID)
3. Get/create shop wallet and player wallet
4. walletTransferService.transfer(shopWalletId, playerWalletId, amount, {
ledgerType: 'credit',
transactionId: `player_deposit:${requestId}`,
description: 'Shop→player deposit',
platformFeeRate: 0,
costRate: shop.costRate ?? 1
})
5. Create commissionLog entry:
{ userId: playerId, shopId, agentId, sourceTransactionId, depositAmount }
A CashierTransaction row is also created:
{
shopId, cashierId, shiftId,
playerId,
type: 'deposit_credits',
amount,
paymentMethod: 'cash' | 'card' | 'bank_transfer',
cashDelta: amount // change in physical cash at counter
}
Payment Gateway Callbacks
LinkMePay — POST /api/payment/linkmepay/ipn/:transaction_id
1. Save raw + normalized payload to CallbackLog
2. Verify HMAC signature (linkMePayService.verifyCallback)
→ 403 if invalid (unless ALLOW_UNVERIFIED_LINKMEPAY_CALLBACKS=true)
3. Find PaymentTransaction by :transaction_id
4. transaction.updateCallback(callbackData)
→ Maps provider fields: biz_no → transaction_id
→ Maps state/status → internal status
5. Route by resulting status:
├── 'processing': check callback for failure signal
│ ├── state=3 or status in [failed,fail,error] → mark 'failed'
│ └── else → updateStatus('completed') → processCompletedDeposit()
└── 'completed': → processCompletedDeposit()
0x Processing — POST /api/payment/zeroxprocessing/webhook
Payload type detection:
Has PaymentId / BillingID / BillingId → Deposit
Has ID + Address → Withdrawal
ClientId starts with 'agent:'|'shop:' → CDN static wallet
Deposit flow:
1. Lookup by externalTransactionId (PaymentId field)
2. Validate settlement:
status='success' → OK
status='insufficient' → OK only if receivedUsd ≥ expected × (1 - platformFeeRate)
other → Not settled, skip
3. If insufficient: capture receivedNetUsd in syncMetadata
4. _markTxCompletedWithFallback(transaction)
5. processCompletedDeposit(transaction)
BTCPay — POST /api/payment/btcpay/webhook
Security:
Verify HMAC via btcpay-sig header
→ 401 if secret configured but signature missing or invalid
Flow:
1. Lookup PaymentTransaction by externalTransactionId
2. Detect event type: invoice vs payout
3. Map BTCPay event → internal status
4. If settled → processCompletedDeposit(transaction)
Wallet Transfer Service
walletTransferService.transfer() is the core engine for all credit movements. It writes a double-entry ledger.
Ledger row structure
| Field | Description |
|---|---|
fromWalletId | Source wallet (debited) |
toWalletId | Destination wallet (credited) |
type | credit or debit |
amount | Amount transferred |
balanceBefore / balanceAfter | Snapshot for audit |
costRate | Rate snapshot on payer row |
platformFeeRate / platformFeeAmount | Fee snapshot |
transactionId | Idempotency key (suffixed :payer / :payee) |
referenceType / referenceId | Links to originating entity |
version | Optimistic lock |
Payer cascade (when shop wallet is insufficient)
The service walks up the hierarchy to find the first wallet with sufficient balance:
For player deposits:
player → shop → agent → super_agent → system (infinite fallback)
For shop:
shop → agent → super_agent → system
For agent:
agent → super_agent → system
Each level is locked sequentially. The first wallet with balance ≥ required is used. The system wallet is the final fallback and never refuses.
Game Wallet Sync
After a direct-to-game deposit is wallet-credited, an async job syncs the balance to the game provider's API.
gameWalletSyncService.processSingleGameWalletTransactionSync()
Transaction 1 (short):
- Load + lock UserGameWallet
- Reload + lock GameWalletTransaction
- Validate walletBalance ≥ amount
- Update status → 'processing'
- Release locks
External API call (outside transaction — long operation):
adapter.credit({ userId, amount, referenceId, metadata })
→ On error:
Update GameWalletTransaction → 'failed' (separate transaction)
Return
Transaction 2 (short):
- Re-lock both records
- Validate balance and status unchanged (safety check)
- Update GameWalletTransaction → 'completed'
- Store syncDetails in metadata
The two short transactions + one external API call pattern (instead of one long transaction) is intentional. It minimises database lock contention while maintaining consistency via re-validation in Transaction 2.
Transaction State Machine
All status transitions on PaymentTransaction are validated by transactionStateMachine.js before any mutation.
Deposit:
pending ──► processing ──► completed (success path)
└──► failed (gateway error)
└──► expired (DEPOSIT_TIMEOUT_MS exceeded)
└──► cancelled (user or admin cancel)
awaiting_admin ──► completed (manual game deposit approved)
└──► rejected
retry_scheduled ──► processing (retry worker picks up)
Withdrawal:
pending ──► approved ──► processing ──► completed
└──► rejected
Invalid transitions are rejected — the service throws before touching any wallet.
Cash App Amount Adjustment
For Cash App payments, the amount charged to the gateway differs from the credits the player receives.
getCashAppAmount(userAmount) → { userAmount, cashAppAmount, difference }
userAmount = amount the player is credited
cashAppAmount = amount charged to Cash App (adjusted for fixed fee structure)
difference = cashAppAmount - userAmount
Stored in: metadata.cashAppAdjustment
Passed to gateway adapter: cashAppAmount (not userAmount)
Fee Calculation
Fee is resolved per transaction at creation time and stored as a snapshot on PaymentTransaction.
platformFeeRate = resolveProviderFeeRate({ provider, operation: 'deposit', methodId })
platformFeeAmount = amount × platformFeeRate
// Net amount credited to player:
netAmount = amount - platformFeeAmount
Resolution order (from PaymentProviderFeeConfig):
1. Exact: (provider, 'deposit', methodId)
2. Fallback: (provider, 'deposit', 'all')
3. Default: 0
platformFeeRate and platformFeeAmount on the transaction are immutable snapshots — they do not change if the fee config is updated later.
PaymentTransaction Key Fields Reference
| Field | Type | Description |
|---|---|---|
transactionId | STRING UNIQUE | Internal reference. Format: TEMP_${ts}_${userId} at creation |
externalTransactionId | STRING | Provider's invoice/payment ID |
buyerType | STRING | user | agent | shop | super_agent |
buyerId | UUID | Actual owner for CDN transactions |
effectiveConfigLevel | STRING | Snapshot of shop | agent | super_agent at creation |
type | ENUM | deposit | withdrawal |
provider | STRING | linkmepay | btcpay | zeroxprocessing | internal |
amount | DECIMAL 15,2 | Transaction amount |
currency | STRING | USD | VND | WBTC (ARB1) | ... |
status | ENUM | See State Machine |
platformFeeRate | DECIMAL 5,4 | Fee rate snapshot (e.g. 0.0500 = 5%) |
platformFeeAmount | DECIMAL 18,8 | Absolute fee amount |
paymentMethod | STRING | bank_transfer | qr_code | cash_app | ... |
paymentUrl | TEXT | Redirect URL for player |
expiresAt | DATE | now + DEPOSIT_TIMEOUT_MS (default 60 min) |
gameProviderId | UUID | Set for direct-to-game deposits |
walletFlow | ENUM | none | master_to_game | game_to_master |
syncStatus | ENUM | none | pending | in_progress | completed | failed |
syncMetadata | JSONB | Per-phase tracking object (see below) |
callbackData | JSONB | Raw webhook/IPN payload |
retryCount | INTEGER | Retry attempts for game sync |
nextRetryAt | DATE | Scheduled retry timestamp |
version | INTEGER | Optimistic locking counter |
syncMetadata fields written per phase
| Field | Written by | Meaning |
|---|---|---|
walletCreditedAt | processWalletOnlyDeposit | ISO timestamp — idempotency guard |
walletTransactionId | processWalletOnlyDeposit | ID of the WalletTransaction ledger row |
gameWalletCreditedAt | processGameProviderDeposit | ISO timestamp — idempotency guard |
gameWalletTransactionId | processGameProviderDeposit | ID of the GameWalletTransaction row |
stage | processGameProviderDeposit | 'game_wallet_credited' |
multiGameProcessedAt | processMultiGameDeposit | ISO timestamp — idempotency guard |
allocationResults | processMultiGameDeposit | Array of per-game outcomes |
receivedNetUsd | 0x webhook handler | Actual net USD received (partial payment) |
lastSyncSource | settlement layer | 'ipn' |
costRateAtPurchase | CDN deposit | Rate snapshot for credit calculation |
API Endpoints
Player-facing (requires player auth)
| Method | Path | Description |
|---|---|---|
POST | /api/payment/deposit | Create a deposit. Returns paymentUrl. |
GET | /api/payment/deposit-history | List player's deposit transactions |
GET | /api/payment/balance | Get player's wallet balance |
GET | /api/payment/reserved-balance-detail | Break down reserved balance |
GET | /api/payment/supported-methods | List active payment methods |
GET | /api/payment/provider-fee-rate | Get fee rate for a provider/method |
GET | /api/payment/zeroxprocessing/coins | List supported 0x coins |
Shop-facing (requires shop auth)
| Method | Path | Description |
|---|---|---|
POST | /api/shop/deposits | Cashier: transfer credits to a player |
GET | /api/shop/deposits | List shop's deposit history |
Webhook / IPN (public — verified by signature)
| Method | Path | Provider |
|---|---|---|
POST | /api/payment/linkmepay/ipn/:transaction_id | LinkMePay IPN |
POST | /api/payment/linkmepay/collect-ipn/:transaction_id | LinkMePay collect IPN |
GET | /api/payment/linkmepay/success/:transaction_id | LinkMePay redirect (success) |
GET | /api/payment/linkmepay/cancel/:transaction_id | LinkMePay redirect (cancel) |
POST | /api/payment/btcpay/webhook | BTCPay webhook |
POST | /api/payment/zeroxprocessing/webhook | 0x Processing webhook |
Error Handling
Non-blocking (system continues on error)
| Operation | On error |
|---|---|
| Fee rate resolution | Defaults to feeRate = 0 |
effectiveConfigLevel resolution | Defaults to 'shop' |
| USD wallet recording | Logged, does not block wallet credit |
| Gamify tracking | Runs in setImmediate, never throws to caller |
Blocking (throws, rolls back)
| Operation | Error |
|---|---|
User status ≠ 'active' | 400 — User inactive |
| Invalid amount | 400 — Invalid amount |
| Unknown game provider | 400 — Provider not found |
| Payment adapter failure | 500 — Gateway error |
| Webhook signature invalid | 403 — Signature mismatch |
| Invalid state transition | 409 — Transition not allowed |
| Wallet balance insufficient (after cascade) | 500 — Should never happen (system is fallback) |
Retry pattern
Game wallet sync failures are retried via walletService.debit/credit idempotency — the transactionId key prevents double-application. The retry worker checks nextRetryAt and increments retryCount on each attempt.