first(); if ($existing) { return $existing; } $wallet = Wallet::lockForUpdate()->find($this->walletFor($user, $tenantId, 'deposit')->id); if ((float) $wallet->available_balance < $amount) { throw ValidationException::withMessages(['wallet' => 'Saldo deposit agen pada tenant ini tidak mencukupi.']); } $transaction = WalletTransaction::create([ 'uuid' => Str::uuid(), 'transaction_number' => 'WLT-'.now()->format('YmdHis').'-'.Str::upper(Str::random(6)), 'type' => 'voucher_purchase', 'status' => 'processing', 'initiated_by' => $user->id, 'amount' => $amount, 'fee' => 0, 'idempotency_key' => $idempotencyKey, 'description' => $description, ]); $this->post($wallet, $transaction, 'debit', $amount); $transaction->update(['status' => 'completed', 'completed_at' => now()]); return $transaction; }); } public function wallets(User $user, ?int $tenantId) { if (! $tenantId) { return collect(); } $balances = collect([$this->balanceData($this->walletFor($user, $tenantId, 'deposit'), 'deposit', 'Saldo Deposit')]); $usesGlobalGateway = TenantPaymentSetting::where('tenant_id', $tenantId) ->where('payment_mode', 'platform_gateway')->whereNotNull('platform_gateway_account_id')->exists(); if ($usesGlobalGateway && ($user->isMasterAdmin() || $user->isTenantOwner($tenantId))) { $balances->push($this->balanceData($this->walletFor(Tenant::findOrFail($tenantId), $tenantId, 'tenant_settlement'), 'tenant_settlement', 'Saldo Settlement Tenant')); } $agent = VoucherAgent::where('tenant_id', $tenantId)->where('user_id', $user->id) ->where('payment_type', 'postpaid')->where('status', 'active')->first(); if ($agent) { $balances->push([ 'id' => "credit-{$agent->id}", 'uuid' => $agent->uuid, 'tenant_id' => $tenantId, 'wallet_type' => 'voucher_credit', 'balance_kind' => 'credit', 'label' => 'Saldo Kredit Voucher', 'credit_limit' => $agent->credit_limit, 'used_credit' => $agent->outstanding_balance, 'remaining_credit' => number_format(max(0, (float) $agent->credit_limit - (float) $agent->outstanding_balance), 2, '.', ''), 'available_balance' => number_format(max(0, (float) $agent->credit_limit - (float) $agent->outstanding_balance), 2, '.', ''), 'pending_balance' => '0.00', 'locked_balance' => '0.00', 'currency' => 'IDR', 'status' => $agent->status, 'ledger_available' => false, 'can_withdraw' => false, 'can_transfer' => false, 'usage_note' => 'Khusus untuk pembelian voucher hotspot pada tenant ini.', ]); } return $balances->values(); } public function ledger(Wallet $wallet, User $actor, ?int $tenantId, array $filters) { $this->authorizeWallet($wallet, $actor, $tenantId); return $wallet->ledgerEntries()->with('transaction:id,uuid,transaction_number,type,status,description,created_at') ->latest('id')->paginate($filters['per_page'] ?? 10); } public function transfer(Wallet $source, Wallet $destination, float $amount, User $actor, ?int $tenantId): WalletTransaction { $this->authorizeWallet($source, $actor, $tenantId); $this->authorizeWallet($destination, $actor, $tenantId); if ((int) $source->tenant_id !== (int) $destination->tenant_id || (int) $source->tenant_id !== (int) $tenantId) { throw ValidationException::withMessages(['destination_wallet_id' => 'Saldo tidak dapat dipindahkan antar tenant.']); } if ($source->is($destination)) { throw ValidationException::withMessages(['destination_wallet_id' => 'Dompet tujuan harus berbeda.']); } if ($amount <= 0) { throw ValidationException::withMessages(['amount' => 'Nominal harus lebih dari nol.']); } return DB::transaction(function () use ($source, $destination, $amount, $actor) { $wallets = Wallet::whereIn('id', [$source->id, $destination->id])->lockForUpdate()->get()->keyBy('id'); $from = $wallets[$source->id]; $to = $wallets[$destination->id]; if ((float) $from->available_balance < $amount) { throw ValidationException::withMessages(['amount' => 'Saldo tidak mencukupi.']); } $transaction = $this->newTransaction('transfer', $amount, $actor, 'Pindah saldo antar dompet'); $this->post($from, $transaction, 'debit', $amount); $this->post($to, $transaction, 'credit', $amount); $transaction->update(['status' => 'completed', 'completed_at' => now()]); return $transaction->load('entries.wallet'); }); } public function requestWithdrawal(Wallet $wallet, array $data, User $actor, ?int $tenantId): WalletWithdrawal { $this->authorizeWallet($wallet, $actor, $tenantId); if ($wallet->wallet_type !== 'tenant_settlement') { throw ValidationException::withMessages(['wallet' => 'Saldo deposit user hanya dapat digunakan untuk transaksi layanan dan tidak dapat ditarik tunai.']); } return DB::transaction(function () use ($wallet, $data, $actor) { $locked = Wallet::lockForUpdate()->findOrFail($wallet->id); $total = (float) $data['amount'] + (float) ($data['fee'] ?? 0); if ((float) $locked->available_balance < $total) { throw ValidationException::withMessages(['amount' => 'Saldo tidak mencukupi.']); } $transaction = $this->newTransaction('withdrawal', $data['amount'], $actor, 'Permintaan penarikan saldo', $data['fee'] ?? 0); $this->post($locked, $transaction, 'debit', $total); return WalletWithdrawal::create([ 'uuid' => Str::uuid(), 'wallet_id' => $locked->id, 'wallet_transaction_id' => $transaction->id, 'requested_by' => $actor->id, 'amount' => $data['amount'], 'fee' => $data['fee'] ?? 0, 'bank_code' => $data['bank_code'], 'account_number' => $data['account_number'], 'account_name' => $data['account_name'], 'status' => 'pending_approval', ]); }); } private function walletFor(Model $owner, int $tenantId, string $type): Wallet { return Wallet::firstOrCreate( ['owner_type' => $owner->getMorphClass(), 'owner_id' => $owner->getKey(), 'tenant_id' => $tenantId, 'wallet_type' => $type, 'currency' => 'IDR'], ['uuid' => Str::uuid(), 'status' => 'active'] ); } private function balanceData(Wallet $wallet, string $kind, string $label): array { return [ ...$wallet->toArray(), 'balance_kind' => $kind, 'label' => $label, 'ledger_available' => true, 'can_withdraw' => $kind === 'tenant_settlement', 'can_transfer' => false, 'usage_note' => $kind === 'deposit' ? 'Dapat digunakan untuk transaksi layanan pada tenant ini.' : 'Dana tenant dari transaksi melalui payment gateway global.', ]; } private function newTransaction(string $type, float $amount, User $actor, string $description, float $fee = 0): WalletTransaction { return WalletTransaction::create([ 'uuid' => Str::uuid(), 'transaction_number' => 'WLT-'.now()->format('YmdHis').'-'.Str::upper(Str::random(6)), 'type' => $type, 'status' => 'processing', 'initiated_by' => $actor->id, 'amount' => $amount, 'fee' => $fee, 'idempotency_key' => (string) Str::uuid(), 'description' => $description, ]); } private function post(Wallet $wallet, WalletTransaction $transaction, string $type, float $amount): void { $before = (float) $wallet->available_balance; $after = $type === 'credit' ? $before + $amount : $before - $amount; WalletLedgerEntry::create([ 'wallet_id' => $wallet->id, 'wallet_transaction_id' => $transaction->id, 'entry_type' => $type, 'balance_bucket' => 'available', 'amount' => $amount, 'balance_before' => $before, 'balance_after' => $after, 'description' => $transaction->description, ]); $wallet->update(['available_balance' => $after, 'version' => $wallet->version + 1]); } private function authorizeWallet(Wallet $wallet, User $actor, ?int $tenantId): void { if ($actor->isMasterAdmin()) { return; } $sameTenant = $tenantId && (int) $wallet->tenant_id === (int) $tenantId; $allowed = $sameTenant && (($wallet->owner_type === $actor->getMorphClass() && (int) $wallet->owner_id === (int) $actor->id) || ($actor->isTenantOwner($tenantId) && $wallet->owner_type === (new Tenant)->getMorphClass() && (int) $wallet->owner_id === (int) $tenantId)); abort_unless($allowed, 403, 'Saldo tidak dapat diakses.'); } }