Files
HickLab3D_Dashboard/app/Services/Etsy.php
T
HickmeisterandClaude Sonnet 5.5 2fe4fbd91e
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s
Cache integration API reads and refresh them on a schedule
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:01:32 +01:00

135 lines
5.7 KiB
PHP

<?php
namespace App\Services;
use App\Models\EtsyAppCredential;
use App\Models\EtsyConnection;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class Etsy
{
private const API_URL = 'https://api.etsy.com/v3';
public function __construct(private IntegrationCache $cache) {}
/** @var array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string}|null */
private ?array $credentials = null;
public function configured(): bool
{
$credentials = $this->credentials();
return filled($credentials['keystring']) && filled($credentials['shared_secret']);
}
public function authorizationUrl(string $state, string $verifier): string
{
if (! $this->configured()) {
throw new RuntimeException('Etsy application credentials are not configured.');
}
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return 'https://www.etsy.com/oauth/connect?'.http_build_query([
'response_type' => 'code', 'redirect_uri' => $this->redirectUri(),
'scope' => 'transactions_r listings_r', 'client_id' => $this->credentials()['keystring'],
'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256',
], '', '&', PHP_QUERY_RFC3986);
}
public function connect(string $code, string $verifier): EtsyConnection
{
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
->post(self::API_URL.'/public/oauth/token', [
'grant_type' => 'authorization_code', 'client_id' => $this->credentials()['keystring'],
'redirect_uri' => $this->redirectUri(), 'code' => $code, 'code_verifier' => $verifier,
])->throw()->json();
$userId = explode('.', $tokens['access_token'] ?? '')[0];
if (! ctype_digit($userId) || empty($tokens['refresh_token'])) {
throw new RuntimeException('Etsy returned an invalid OAuth response.');
}
$shop = $this->get('application/users/'.$userId.'/shops', $tokens['access_token']);
if (empty($shop['shop_id'])) {
throw new RuntimeException('This Etsy account does not own a shop.');
}
if (strcasecmp($shop['shop_name'] ?? '', $this->credentials()['shop_name']) !== 0) {
throw new RuntimeException('The Etsy account does not own the configured HickLab3D shop.');
}
return EtsyConnection::updateOrCreate(['shop_id' => $shop['shop_id']], [
'shop_name' => $shop['shop_name'] ?? 'Etsy shop',
'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'],
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)),
]);
}
public function receipts(EtsyConnection $connection, bool $fresh = false): array
{
return $this->cache->remember('etsy.receipts.'.$connection->shop_id, 300, fn (): array => $this->get(
'application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [
'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 20,
]), $fresh);
}
public function listings(EtsyConnection $connection): array
{
return $this->cache->remember('etsy.listings.'.$connection->shop_id, 600, fn (): array => $this->get(
'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [
'state' => 'active', 'limit' => 20,
]));
}
private function accessToken(EtsyConnection $connection): string
{
if ($connection->expires_at->isFuture()) {
return $connection->access_token;
}
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
->post(self::API_URL.'/public/oauth/token', [
'grant_type' => 'refresh_token', 'client_id' => $this->credentials()['keystring'],
'refresh_token' => $connection->refresh_token,
])->throw()->json();
if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) {
throw new RuntimeException('Etsy did not refresh the shop connection.');
}
$connection->update([
'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'],
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)),
]);
return $tokens['access_token'];
}
private function get(string $path, string $accessToken, array $query = []): array
{
$credentials = $this->credentials();
return Http::withHeaders(['x-api-key' => $credentials['keystring'].':'.$credentials['shared_secret']])
->withToken($accessToken)->acceptJson()->connectTimeout(3)->timeout(10)
->get(self::API_URL.'/'.$path, $query)->throw()->json() ?? [];
}
public function redirectUri(): string
{
return $this->credentials()['redirect_uri'] ?: route('etsy.callback');
}
/** @return array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string} */
private function credentials(): array
{
if ($this->credentials !== null) {
return $this->credentials;
}
$stored = EtsyAppCredential::find(1);
return $this->credentials = [
'keystring' => $stored?->keystring ?: config('services.etsy.keystring'),
'shared_secret' => $stored?->shared_secret ?: config('services.etsy.shared_secret'),
'shop_name' => $stored?->shop_name ?: config('services.etsy.shop_name', 'HickLab3D'),
'redirect_uri' => $stored?->redirect_uri ?: config('services.etsy.redirect_uri'),
];
}
}