bankingConnection->id; } public function handle(TransactionSyncService $transactionSync, BalanceSyncService $balanceSync): void { $connection = $this->bankingConnection; $startTime = microtime(true); if ($connection->isEnableBanking() && $connection->isExpired()) { $connection->update(['status' => BankingConnectionStatus::Expired]); Log::info('Banking connection expired, skipping sync', ['connection_id' => $connection->id]); $this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'expired']); return; } if (! $this->isSyncableStatus($connection)) { $this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'not_syncable', 'status' => $connection->status->value]); return; } try { $isFirstSync = ! $connection->last_synced_at || $this->fullSync; $metadata = []; if ($connection->isIndexaCapital()) { $this->syncIndexaCapital($connection, $isFirstSync); } elseif ($connection->isBinance()) { $this->syncBinance($connection, $isFirstSync); } elseif ($connection->isBitpanda()) { $this->syncBitpanda($connection); } else { $metadata = $this->syncEnableBanking($connection, $transactionSync, $balanceSync, $isFirstSync); } $connection->update([ 'status' => BankingConnectionStatus::Active, 'last_synced_at' => now(), 'error_message' => null, 'consecutive_sync_failures' => 0, ]); $this->logSyncAttempt($connection, BankingSyncLogStatus::Success, $startTime, metadata: $metadata ?: null); } catch (\Throwable $e) { Log::error('Banking sync failed', [ 'connection_id' => $connection->id, 'error' => $e->getMessage(), 'attempt' => $this->attempts(), ]); if ($this->isRateLimitError($e)) { $this->logSyncAttempt($connection, BankingSyncLogStatus::Failed, $startTime, $e); return; } $this->logSyncAttempt($connection, BankingSyncLogStatus::Failed, $startTime, $e); if ($this->isAuthError($e)) { $this->handlePermanentError($connection, $e); return; } $this->handleTemporaryError($connection, $e); } } /** * Handle permanent errors (auth failures) that should not be retried. */ private function handlePermanentError(BankingConnection $connection, \Throwable $e): void { $connection->update([ 'status' => BankingConnectionStatus::Error, 'error_message' => $this->friendlyErrorMessage($e), 'consecutive_sync_failures' => self::MAX_SCHEDULED_RETRIES + 1, ]); if ($this->isApiKeyProvider($connection)) { Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail( $connection->user, $connection, )); } $this->fail($e); throw $e; } /** * Handle temporary errors that may resolve on retry. */ private function handleTemporaryError(BankingConnection $connection, \Throwable $e): void { $isFinalAttempt = $this->attempts() >= $this->tries; if ($isFinalAttempt) { $connection->update([ 'status' => BankingConnectionStatus::Error, 'error_message' => $this->friendlyErrorMessage($e), 'consecutive_sync_failures' => $connection->consecutive_sync_failures + 1, ]); } throw $e; } /** * Whether the connection status allows syncing. * Allows both Active and Error (for auto-retry from scheduled runs). */ private function isSyncableStatus(BankingConnection $connection): bool { return in_array($connection->status, [ BankingConnectionStatus::Active, BankingConnectionStatus::Error, ]); } private function logSyncAttempt( BankingConnection $connection, BankingSyncLogStatus $status, float $startTime, ?\Throwable $error = null, ?array $metadata = null, ): void { $durationMs = (int) round((microtime(true) - $startTime) * 1000); BankingSyncLog::create([ 'banking_connection_id' => $connection->id, 'status' => $status, 'attempt' => $this->attempts(), 'error_message' => $error?->getMessage(), 'error_class' => $error ? get_class($error) : null, 'duration_ms' => $durationMs, 'metadata' => $metadata, 'created_at' => now(), ]); } private function syncIndexaCapital(BankingConnection $connection, bool $isFirstSync): void { $client = new IndexaCapitalClient($connection->api_token); $syncService = new IndexaCapitalBalanceSyncService; $connection->load('accounts'); foreach ($connection->accounts as $account) { $syncService->sync($account, $client, $isFirstSync); } } private function syncBinance(BankingConnection $connection, bool $isFirstSync): void { $client = new BinanceClient($connection->api_token, $connection->api_secret); $syncService = app(BinanceBalanceSyncService::class); $connection->load('accounts'); foreach ($connection->accounts as $account) { if ($isFirstSync) { $syncService->syncCurrentBalance($account, $client); SyncBinanceHistoricalBalancesJob::dispatch($account)->delay(now()->addSeconds(30)); } else { $syncService->sync($account, $client, isFirstSync: false); } } } private function syncBitpanda(BankingConnection $connection): void { $client = new BitpandaClient($connection->api_token); $syncService = app(BitpandaBalanceSyncService::class); $connection->load('accounts'); foreach ($connection->accounts as $account) { $syncService->sync($account, $client); } } /** * @return array */ private function syncEnableBanking(BankingConnection $connection, TransactionSyncService $transactionSync, BalanceSyncService $balanceSync, bool $isFirstSync): array { $dateFrom = $isFirstSync ? now()->subYear()->toDateString() : ($connection->last_synced_at?->toDateString() ?? now()->subMonth()->toDateString()); $dateTo = now()->toDateString(); $strategy = $isFirstSync ? 'longest' : null; $transactionsPerBank = []; $connection->load('accounts.bank'); foreach ($connection->accounts as $account) { if ($account->isLinked()) { $lastTransaction = $account->transactions() ->latest('transaction_date') ->first(); $linkedDateFrom = $lastTransaction ? $lastTransaction->transaction_date->toDateString() : $dateFrom; $created = $transactionSync->sync($account, $linkedDateFrom, $dateTo, $strategy, saveDailyBalances: false); $balanceSync->sync($account); } else { $created = $transactionSync->sync($account, $dateFrom, $dateTo, $strategy); $balanceSync->sync($account); if ($isFirstSync) { $balanceSync->calculateHistoricalBalances($account); } } if ($created > 0) { $bankName = $account->bank->name ?? __('Unknown Bank'); $transactionsPerBank[$bankName] = ($transactionsPerBank[$bankName] ?? 0) + $created; } } $totalTransactions = array_sum($transactionsPerBank); if (! $isFirstSync && $totalTransactions > 0) { Mail::to($connection->user)->send(new BankTransactionsSyncedEmail( $connection->user, $totalTransactions, $transactionsPerBank, )); } return ['transactions_synced' => $totalTransactions, 'transactions_per_bank' => $transactionsPerBank]; } private function friendlyErrorMessage(\Throwable $e): string { if ($e instanceof RequestException) { $status = $e->response->status(); return match (true) { $status === 429 => __('Rate limit exceeded. Please wait a few minutes and try again.'), $status === 401 || $status === 403 => __('Authentication failed. Your credentials may have expired or been revoked.'), $status >= 500 => __('The provider is experiencing issues. Please try again later.'), default => __('Failed to sync with the provider. Please try again later.'), }; } return __('An unexpected error occurred during sync. Please try again later.'); } private function isRateLimitError(\Throwable $e): bool { return $e instanceof RequestException && $e->response->status() === 429; } private function isAuthError(\Throwable $e): bool { return $e instanceof RequestException && in_array($e->response->status(), [401, 403]); } private function isApiKeyProvider(BankingConnection $connection): bool { return $connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda(); } }