fix(wise): bound the walk to the job's budget, once per connection
A safety net behind the cursor fix rather than the fix itself: with pagination working a normal wallet finishes in a couple of requests. But a genuinely long history, or a provider answering slowly, would still get the job killed at 120s — and because `last_synced_at` is only written on success, that leaves every cycle restarting the same first sync, which is the state this connection was in. The deadline is the caller's, passed in, for two reasons. Wise creates one account per currency per profile, so a budget per wallet would multiply straight past the job's timeout — a three-wallet connection reproduced the original bug verbatim. And a caller under no time pressure (`banking:sync --sync` runs in-process, where the worker timeout does not apply) can pass null and walk as far as it likes. `WiseClient` now sets its own 15s/5s timeouts instead of inheriting the framework's 30s, so "the budget plus one in-flight request" is a bound this code actually owns. Matches EnableBankingProvider and InteractiveBrokersClient, which were the only two setting theirs. Stopping short is now a warning, not an info line: pages left behind are history the user silently does not get. `importPage` is extracted to keep `sync` under the complexity threshold, which the deadline had pushed it over.
This commit is contained in:
parent
a7523bfc4f
commit
35f12096c2
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<int, array<string, mixed>> $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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue