diff --git a/app/Services/Banking/WiseClient.php b/app/Services/Banking/WiseClient.php index e336f802..51ff0dee 100644 --- a/app/Services/Banking/WiseClient.php +++ b/app/Services/Banking/WiseClient.php @@ -13,6 +13,15 @@ class WiseClient { private const BASE_URL = 'https://api.wise.com'; + /** + * Explicit rather than the framework's 30s default: the caller's time budget is + * stated as "the budget plus one in-flight request", which only holds if this + * class owns that number. Matches the sibling banking clients. + */ + private const int HTTP_TIMEOUT_SECONDS = 15; + + private const int HTTP_CONNECT_TIMEOUT_SECONDS = 5; + public function __construct(private string $apiToken) {} /** @@ -101,6 +110,8 @@ class WiseClient private function client(): PendingRequest { return Http::baseUrl(self::BASE_URL) + ->timeout(self::HTTP_TIMEOUT_SECONDS) + ->connectTimeout(self::HTTP_CONNECT_TIMEOUT_SECONDS) ->withToken($this->apiToken) ->acceptJson() ->throw(function ($response, RequestException $exception) { diff --git a/app/Services/Banking/WiseTransactionSyncService.php b/app/Services/Banking/WiseTransactionSyncService.php index a280ccc3..e8b60e3a 100644 --- a/app/Services/Banking/WiseTransactionSyncService.php +++ b/app/Services/Banking/WiseTransactionSyncService.php @@ -4,6 +4,7 @@ namespace App\Services\Banking; use App\Enums\TransactionSource; use App\Models\Account; +use Carbon\CarbonInterface; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Log; @@ -15,9 +16,14 @@ class WiseTransactionSyncService * The account's `external_account_id` must be in the format * "{profileId}:{currency}" (e.g. "36875276:EUR"). * + * @param CarbonInterface|null $deadline Wall clock at which to stop paging and + * leave the rest for the next sync. The + * caller owns it because the budget is + * per connection, not per wallet. Null + * for callers under no time pressure. * @return int Number of new transactions created */ - public function sync(Account $account, WiseClient $client, string $dateFrom, string $dateTo): int + public function sync(Account $account, WiseClient $client, string $dateFrom, string $dateTo, ?CarbonInterface $deadline = null): int { if (! $account->external_account_id) { return 0; @@ -29,29 +35,18 @@ class WiseTransactionSyncService $until = $dateTo.'T23:59:59Z'; $cursor = null; $created = 0; + $ranOutOfBudget = false; do { $result = $client->getActivities((int) $profileId, $since, $until, $cursor); $activities = $result['activities'] ?? []; $cursor = $result['cursor'] ?? null; - foreach ($activities as $activity) { - // Skip zero-amount authorization checks and non-monetary types - if (($activity['type'] ?? '') === 'CARD_CHECK') { - continue; - } + $created += $this->importPage($account, $activities, $currency); - $parsed = $this->parseActivity($activity, $currency); - - if ($parsed === null) { - continue; - } - - if ($this->importTransaction($account, $activity, $parsed)) { - $created++; - } - } - } while ($cursor !== null && count($activities) > 0); + $hasMorePages = $cursor !== null && count($activities) > 0; + $ranOutOfBudget = $hasMorePages && $deadline !== null && now()->gte($deadline); + } while ($hasMorePages && ! $ranOutOfBudget); Log::info('Synced Wise transactions', [ 'account_id' => $account->id, @@ -61,6 +56,45 @@ class WiseTransactionSyncService 'date_to' => $dateTo, ]); + if ($ranOutOfBudget) { + // Warn, not info: pages were left behind and the next sync starts from + // last_synced_at, so this is history the user silently does not get. + Log::warning('Wise transaction walk stopped on its time budget', [ + 'account_id' => $account->id, + 'currency' => $currency, + 'new_transactions' => $created, + 'oldest_imported' => $account->transactions()->min('transaction_date'), + ]); + } + + return $created; + } + + /** + * @param array> $activities + * @return int Number of new transactions created from this page + */ + private function importPage(Account $account, array $activities, string $currency): int + { + $created = 0; + + foreach ($activities as $activity) { + // Skip zero-amount authorization checks and non-monetary types + if (($activity['type'] ?? '') === 'CARD_CHECK') { + continue; + } + + $parsed = $this->parseActivity($activity, $currency); + + if ($parsed === null) { + continue; + } + + if ($this->importTransaction($account, $activity, $parsed)) { + $created++; + } + } + return $created; }