D
Engineering

Alur Wallet

Alur kritis wallet dari topup via DOKU hingga request withdraw. Mencakup OrderObserver trigger, deposit atomik, race condition, dan inkonsistensi status.

Wallet adalah jalur uang merchant. Ada dua alur: topup (kredit dari pembayaran order) dan withdraw (debit ke rekening bank). Pemrosesan withdraw sebenarnya tidak ada di dazoapp — hanya request, lalu disimpan PENDING.

Ringkasan alur

text
TOPUP (kredit):
  Customer bayar via DOKU
    → webhook DokuNotificationController/DokuCheckoutController
    → set order.payment_status='paid' + $order->save()
    → OrderObserver@updated trigger (hanya jika isDirty payment_status)
    → IF payment_method='instant_payment':
        WalletService::processDeposit()
          → WalletTransaction type='deposit' status='success'
          → $wallet->balance += $amount (TANPA lock)

WITHDRAW (debit):
  POST /api/wallet/withdraw
    → WalletController@withdraw
    → validasi PIN (inline, TANPA tracking gagal)
    → validasi amount >= 50000
    → WalletTransaction type='withdraw' status='PENDING'
    → DB::collection('wallets')->update(['$inc' => ['balance' => -$amount]])  ← atomic
    → selesai. Tidak ada approval/payout di dazoapp.
    → status PENDING → ?  (dikelola sistem eksternal, tidak ada di repo ini)

1. Topup flow

Endpoint & controller

Tidak ada endpoint topup langsung. Topup wallet terjadi implisit lewat pembayaran order.

EndpointHandlerFungsi
POST /api/payment/create-checkout (routes/api.php:801)DokuCheckoutController@createCheckoutBuat payment URL via DOKU
POST /api/payment/engine-bot/create-checkout (routes/api.php:805)EngineBotPaymentController@createCheckoutVariant untuk Engine Bot
GET /payment/pending/{orderId} (routes/api.php:812)DokuPaymentController@getPendingPaymentCek status pending
POST /payment/check-status/{orderId} (routes/api.php:813)DokuPaymentController@checkPaymentStatusPolling status real-time

Webhook DOKU

RouteHandlerVerifikasi
POST /api/doku/notification (routes/api.php:798)DokuNotificationController@handle (:18)HMAC-SHA256 inline (:36-53)
POST /api/doku/checkout-notification (routes/api.php:802)DokuCheckoutController@handleNotification (:311)DokuApiService@verifyCheckoutSignature (:321)

Catatan: Duitku (POST /api/duitku/webhook) hanya untuk subscription, tidak sentuh wallet.

Trigger update balance

  1. Webhook set order->payment_status = 'paid' lalu $order->save() (DokuNotificationController.php:78-79, DokuCheckoutController.php:385-400)
  2. OrderObserver@updated (app/Observers/OrderObserver.php:18) menangkap transisi payment_status'paid' (cek isDirty di :21-24)
  3. Hanya jika payment_method === 'instant_payment' (:32), panggil WalletService@processDeposit (:34, implementasi WalletService.php:340)
  4. processDeposit membuat WalletTransaction type='deposit' status='success' (:352-365) lalu $wallet->balance += $amount (:367-368)

2. Withdraw flow

Endpoint

POST /api/wallet/withdraw (routes/api.php:782) → WalletController@withdraw (:209). Middleware: auth, completed, loginlog, throttle:100,1.

Validasi

FieldAturanSumber
amountmin:50000 + cek eksplisit < 50000WalletController.php:212,258-260
pinsize:6, diverifikasi Hash::check inlineWalletController.php:213,253-255
bank_account_idrequiredWalletController.php:214

Yang dilakukan dazoapp

Request-only → PENDING. Controller membuat WalletTransaction dengan status='PENDING' dan mengurangi balance atomik, tanpa memanggil DOKU payout:

php
\DB::collection('wallets')
    ->where('id', $wallet->id)
    ->update([
        '$inc' => ['balance' => -$totalDeducted],
        ...
    ]);

(WalletController.php:300-309)

Field yang di-set saat request withdraw

FieldNilaiSumber
idUUID baruWalletController.php:277
wallet_id$wallet->id:279
store_id$userStore->store_id:280
user_idauth()->user()->id:281
type'withdraw' (literal, bukan konstanta TYPE_WITHDRAWAL='withdrawal'):282
amountinteger:283
feesarray dari PlatformSetting::getWithdrawFees():284
net_amount= amount (user terima full):285
status'PENDING' (uppercase, bukan konstanta 'pending' lowercase):286
reference_id'WDR-{timestamp}-{hash}':287
description"Penarikan ke {bank_name} - {account_number}":288
doku_payout_invoice'WD-{timestamp}-{hash}':289
rejection_reasonnull:290
bank_account{id, bank_code, bank_name, account_number, account_name}:291-297

Status transisi: PENDING → ?

Definisi di WalletTransaction.php:25-38:

text
pending → processing → success → failed → cancelled
PENDING → APPROVED → PROCESSING → SUCCESS / FAILED
PENDING → REJECTED

APPROVED dan REJECTED didokumentasikan sebagai status yang ditangani admin (komentar WalletTransaction.php:33-38). Tapi:

WalletService::requestWithdrawal() adalah dead code

Method lengkap (WalletService.php:211) mengimplementasikan DOKU payout + rollback — tapi tidak pernah dipanggil dari controller/route mana pun. Controller WalletController@withdraw punya implementasi inline sendiri dengan field berbeda (fees, bank_account, doku_payout_invoice di controller vs metadata.bank_code di service).

3. Balance & transaction model

Model Wallet (app/Models/Wallet.php)

  • Collection: wallets, MongoDB, use SoftDeletes
  • $guarded = [] (mass-assignable semua field)
  • $hidden = ['pin_hash']
  • Field utama: balance (int), pending_balance (int), status ('active'/'inactive', default 'inactive'), doku_sub_account_id, bank_accounts (array embedded)
  • Appended attributes (virtual, di-recompute saat baca):
    • processing_balance — sum WalletTransaction type='withdraw' status='PENDING' (Wallet.php:78-84)
    • available_balance — query real-time ke DOKU getSubAccountBalance minus pending withdraw (:100-123)
    • total_withdrawn — sum transaksi withdraw status='SUCCESS' (:89-95)

Model WalletTransaction (app/Models/WalletTransaction.php)

  • Collection: wallet_transactions, tidak SoftDeletes
  • Type konstanta: deposit, withdrawal, settlementtapi controller pakai 'withdraw' literal
  • Status konstanta (lowercase): pending, processing, success, failed, cancelledtapi controller pakai 'PENDING' uppercase
  • Casts: metadata→array, amount/fee/net_amount→integer, completed_at/created_at/updated_at→datetime

4. Race condition & security

Tidak ada pessimistic lock

  • WalletController@withdraw pakai DB::beginTransaction() (:275) + MongoDB atomic $inc (:300-309) — atomic di level dokumen, tapi tidak cegah double-request antar request paralel sebelum validasi balance
  • WalletService@processDeposit (:367-368) dan requestWithdrawal (:264-266) tanpa transaksi/lock sama sekali
  • lockForUpdate hanya dipakai di OrderController (:2248,2257) — bukan wallet

Idempotency webhook tidak fully aman

Tidak ada idempotency key eksplisit. Yang ada hanya pengecekan status:

  • DokuNotificationController.php:73-75: skip jika order.payment_status === 'paid'
  • DokuCheckoutController.php:376-382: sama

Race antara 2 webhook sampai bersamaan bisa menyebabkan processDeposit terpanggil 2x (OrderObserver trigger di updated event, dan 2 save paralel bisa lewat cek isDirty).

Minimum amount & PIN

  • Controller: min:50000 (WalletController.php:212)
  • Config: doku.wallet.min_withdrawal = 10000 (config/doku.php:27) — hanya dipakai di WalletService@requestWithdrawal yang tidak pernah dipanggil

5. Cross-reference ke admin-dazo

Inkonsistensi yang ditemukan

MasalahDetail
Type literal vs konstantaWalletTransaction::TYPE_WITHDRAWAL = 'withdrawal' tapi controller tulis type='withdraw'. Filter processing_balance di Wallet.php:81 pakai 'withdraw' (cocok controller, tidak cocok konstanta)
Status caseSTATUS_PENDING = 'pending' (lowercase) tapi controller tulis 'PENDING' (uppercase). Filter Wallet.php:82 pakai 'PENDING'
Dead codeWalletService@requestWithdrawal tidak pernah dipanggil
Implementasi paralelWalletService@processDeposit & controller withdraw tidak share logic — dua implementasi paralel

Referensi file

FilePeran
app/Models/Wallet.phpModel + accessor virtual
app/Models/WalletTransaction.phpModel transaksi + konstanta
app/Http/Controllers/WalletController.phpEndpoint withdraw + PIN verification
app/Services/WalletService.phpDeposit logic + dead code withdrawal
app/Services/DokuApiService.phpIntegrasi DOKU (payout tidak dipakai)
app/Observers/OrderObserver.phpTrigger deposit saat order paid
app/Http/Controllers/DokuCheckoutController.phpCreate checkout + handle webhook
app/Http/Controllers/DokuNotificationController.phpHandle webhook generik