Skip to main content

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.

TypeEntry pointbuyerTypeHas gameProviderId
Wallet-onlyPlayer Web (gateway)userNo
Direct-to-gamePlayer Web (gateway)userYes
Multi-gamePlayer Web (gateway)userMultiple (in metadata)
CDN credit purchaseAgent/Shop portal (gateway)agent | shop | super_agentNo
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:

syncStatusMeaning
noneWallet-only, no game sync needed
pendingWallet credited, queued for game API sync
in_progressGame API call in flight
completedGame confirmed the balance
failedGame 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

FieldDescription
fromWalletIdSource wallet (debited)
toWalletIdDestination wallet (credited)
typecredit or debit
amountAmount transferred
balanceBefore / balanceAfterSnapshot for audit
costRateRate snapshot on payer row
platformFeeRate / platformFeeAmountFee snapshot
transactionIdIdempotency key (suffixed :payer / :payee)
referenceType / referenceIdLinks to originating entity
versionOptimistic 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
note

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

FieldTypeDescription
transactionIdSTRING UNIQUEInternal reference. Format: TEMP_${ts}_${userId} at creation
externalTransactionIdSTRINGProvider's invoice/payment ID
buyerTypeSTRINGuser | agent | shop | super_agent
buyerIdUUIDActual owner for CDN transactions
effectiveConfigLevelSTRINGSnapshot of shop | agent | super_agent at creation
typeENUMdeposit | withdrawal
providerSTRINGlinkmepay | btcpay | zeroxprocessing | internal
amountDECIMAL 15,2Transaction amount
currencySTRINGUSD | VND | WBTC (ARB1) | ...
statusENUMSee State Machine
platformFeeRateDECIMAL 5,4Fee rate snapshot (e.g. 0.0500 = 5%)
platformFeeAmountDECIMAL 18,8Absolute fee amount
paymentMethodSTRINGbank_transfer | qr_code | cash_app | ...
paymentUrlTEXTRedirect URL for player
expiresAtDATEnow + DEPOSIT_TIMEOUT_MS (default 60 min)
gameProviderIdUUIDSet for direct-to-game deposits
walletFlowENUMnone | master_to_game | game_to_master
syncStatusENUMnone | pending | in_progress | completed | failed
syncMetadataJSONBPer-phase tracking object (see below)
callbackDataJSONBRaw webhook/IPN payload
retryCountINTEGERRetry attempts for game sync
nextRetryAtDATEScheduled retry timestamp
versionINTEGEROptimistic locking counter

syncMetadata fields written per phase

FieldWritten byMeaning
walletCreditedAtprocessWalletOnlyDepositISO timestamp — idempotency guard
walletTransactionIdprocessWalletOnlyDepositID of the WalletTransaction ledger row
gameWalletCreditedAtprocessGameProviderDepositISO timestamp — idempotency guard
gameWalletTransactionIdprocessGameProviderDepositID of the GameWalletTransaction row
stageprocessGameProviderDeposit'game_wallet_credited'
multiGameProcessedAtprocessMultiGameDepositISO timestamp — idempotency guard
allocationResultsprocessMultiGameDepositArray of per-game outcomes
receivedNetUsd0x webhook handlerActual net USD received (partial payment)
lastSyncSourcesettlement layer'ipn'
costRateAtPurchaseCDN depositRate snapshot for credit calculation

API Endpoints

Player-facing (requires player auth)

MethodPathDescription
POST/api/payment/depositCreate a deposit. Returns paymentUrl.
GET/api/payment/deposit-historyList player's deposit transactions
GET/api/payment/balanceGet player's wallet balance
GET/api/payment/reserved-balance-detailBreak down reserved balance
GET/api/payment/supported-methodsList active payment methods
GET/api/payment/provider-fee-rateGet fee rate for a provider/method
GET/api/payment/zeroxprocessing/coinsList supported 0x coins

Shop-facing (requires shop auth)

MethodPathDescription
POST/api/shop/depositsCashier: transfer credits to a player
GET/api/shop/depositsList shop's deposit history

Webhook / IPN (public — verified by signature)

MethodPathProvider
POST/api/payment/linkmepay/ipn/:transaction_idLinkMePay IPN
POST/api/payment/linkmepay/collect-ipn/:transaction_idLinkMePay collect IPN
GET/api/payment/linkmepay/success/:transaction_idLinkMePay redirect (success)
GET/api/payment/linkmepay/cancel/:transaction_idLinkMePay redirect (cancel)
POST/api/payment/btcpay/webhookBTCPay webhook
POST/api/payment/zeroxprocessing/webhook0x Processing webhook

Error Handling

Non-blocking (system continues on error)

OperationOn error
Fee rate resolutionDefaults to feeRate = 0
effectiveConfigLevel resolutionDefaults to 'shop'
USD wallet recordingLogged, does not block wallet credit
Gamify trackingRuns in setImmediate, never throws to caller

Blocking (throws, rolls back)

OperationError
User status ≠ 'active'400 — User inactive
Invalid amount400 — Invalid amount
Unknown game provider400 — Provider not found
Payment adapter failure500 — Gateway error
Webhook signature invalid403 — Signature mismatch
Invalid state transition409 — 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.