whisper-money/app/Services/Banking/WiseClient.php

125 lines
4.1 KiB
PHP

<?php
namespace App\Services\Banking;
use App\Exceptions\Banking\TransientBankingProviderException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
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) {}
/**
* @return array<int, array{id: int, type: string, details: array}>
*/
public function getProfiles(): array
{
return $this->get('/v1/profiles');
}
/**
* Get the multi-currency borderless account for a profile.
*
* @return array{id?: int, profileId?: int, balances?: array}
*/
public function getBorderlessAccount(int $profileId): array
{
$accounts = $this->get('/v2/borderless-accounts', ['profileId' => $profileId]);
return $accounts[0] ?? [];
}
/**
* Fetch paginated monetary activities for a profile.
* Use `since`/`until` (ISO 8601) for date range and `cursor` for pagination.
*
* The names are asymmetric and it matters: Wise returns the cursor as
* `cursor` but only reads it back as `nextCursor`. Sending it as `cursor` is
* silently ignored, so every request returns the first page again.
*
* @return array{activities?: array, cursor?: string|null}
*/
public function getActivities(int $profileId, string $since, string $until, ?string $cursor = null): array
{
$params = [
'size' => 100,
'since' => $since,
'until' => $until,
];
if ($cursor !== null) {
$params['nextCursor'] = $cursor;
}
return $this->get("/v1/profiles/{$profileId}/activities", $params);
}
/**
* An unattended sync cannot do anything about Wise being down or slow, so a
* timeout or a 5xx is reclassified as transient: the job logs it as a
* warning, keeps it out of Sentry and retries later. Statuses the caller
* acts on - 401/403 auth failures, 429 rate limits - stay as they are.
*
* @param array<string, mixed> $params
* @return array<mixed>
*/
private function get(string $url, array $params = []): array
{
try {
$response = $this->client()->get($url, $params);
$response->throw();
} catch (ConnectionException $e) {
throw new TransientBankingProviderException(
'Wise did not respond in time.',
provider: 'wise',
previous: $e,
);
} catch (RequestException $e) {
if (! $e->response->serverError()) {
throw $e;
}
throw new TransientBankingProviderException(
'Wise could not serve the request right now.',
provider: 'wise',
statusCode: $e->response->status(),
previous: $e,
);
}
/** A 200 with an empty body used to degrade to an empty result, not a TypeError. */
return $response->json() ?? [];
}
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) {
Log::log($response->serverError() ? 'warning' : 'error', 'Wise API error', [
'status' => $response->status(),
'body' => $response->json(),
]);
});
}
}