*/ public const REQUIRED_SCOPES = ['listings_r', 'listings_w', 'transactions_r', 'transactions_w', 'shops_r']; /** @var list */ public const LISTING_STATES = ['draft', 'active', 'inactive', 'expired', 'sold_out']; 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' => implode(' ', self::REQUIRED_SCOPES), '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->request('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)), 'scopes' => isset($tokens['scope']) ? trim((string) $tokens['scope']) : null, ]); } /** Paid, unshipped orders that still need dispatching, excluding cancelled and fully refunded ones. */ public function receipts(EtsyConnection $connection, bool $fresh = false): array { return $this->cache->remember('etsy.receipts.'.$connection->shop_id, 300, function () use ($connection): array { $response = $this->request('GET', 'application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [ 'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 50, ]); $results = array_values(array_filter($response['results'] ?? [], fn ($receipt): bool => is_array($receipt) && ! $this->isCancelledOrRefunded($receipt))); return ['count' => count($results), 'results' => $results] + $response; }, $fresh); } /** @param array $receipt */ public function isCancelledOrRefunded(array $receipt): bool { if (in_array(strtolower((string) ($receipt['status'] ?? '')), ['canceled', 'cancelled'], true) || ! empty($receipt['was_canceled'])) { return true; } $total = (float) ($receipt['grandtotal']['amount'] ?? 0) / max(1, (int) ($receipt['grandtotal']['divisor'] ?? 1)); $refunded = collect($receipt['refunds'] ?? [])->sum(fn ($refund): float => (float) ($refund['amount']['amount'] ?? 0) / max(1, (int) ($refund['amount']['divisor'] ?? 1))); return $total > 0 && $refunded >= $total; } public function listings(EtsyConnection $connection, bool $fresh = false): array { return $this->cache->remember('etsy.listings.'.$connection->shop_id, 600, fn (): array => $this->request( 'GET', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), ['state' => 'active', 'limit' => 20], ), $fresh); } /** * @return array{ * configured: bool, * connected: bool, * shop_name: ?string, * shop_id: mixed, * configured_shop_name: string, * token_expires_at: ?string, * token_expired: ?bool, * required_scopes: list, * connected_scopes: ?list, * missing_scopes: list, * reconnect_required: bool, * reconnect_reason: ?string * } */ public function shopStatus(): array { $configured = $this->configured(); $connection = EtsyConnection::first(); $connectedScopes = $this->scopeList($connection?->scopes); $missing = []; $reconnect = false; $reason = null; if (! $configured) { $reason = 'Etsy application credentials are not configured. Save them on the Settings page or set ETSY_KEYSTRING and ETSY_SHARED_SECRET.'; } elseif (! $connection) { $reason = 'Etsy shop is not connected. An admin must connect the shop in the dashboard.'; } elseif (strcasecmp($connection->shop_name, $this->credentials()['shop_name']) !== 0) { $reconnect = true; $reason = 'The Etsy account does not own the configured HickLab3D shop.'; } elseif ($connectedScopes === null) { $reconnect = true; $reason = 'OAuth scopes are unknown on this connection. An admin must reconnect Etsy in the dashboard after deploying expanded shop-management scopes.'; } else { $missing = array_values(array_diff(self::REQUIRED_SCOPES, $connectedScopes)); $reconnect = $missing !== []; $reason = $reconnect ? 'Missing OAuth scopes: '.implode(', ', $missing).'. An admin must reconnect Etsy in the dashboard.' : null; } return [ 'configured' => $configured, 'connected' => $connection !== null, 'shop_name' => $connection?->shop_name, 'shop_id' => $connection?->shop_id, 'configured_shop_name' => $this->credentials()['shop_name'], 'token_expires_at' => $connection?->expires_at?->toIso8601String(), 'token_expired' => $connection ? $connection->expires_at->isPast() : null, 'required_scopes' => self::REQUIRED_SCOPES, 'connected_scopes' => $connectedScopes, 'missing_scopes' => $missing, 'reconnect_required' => $reconnect, 'reconnect_reason' => $reason, ]; } public function connectedShop(): EtsyConnection { if (! $this->configured()) { throw new RuntimeException('Etsy application credentials are not configured.'); } $connection = EtsyConnection::first(); if (! $connection) { throw new RuntimeException('Etsy shop is not connected. An admin must connect the shop in the dashboard.'); } if (strcasecmp($connection->shop_name, $this->credentials()['shop_name']) !== 0) { throw new RuntimeException('The Etsy account does not own the configured HickLab3D shop.'); } return $connection; } /** * @return array{count: int, limit: int, offset: int, state: string, results: list>} */ public function listListings(EtsyConnection $connection, string $state = 'active', int $limit = 25, int $offset = 0): array { $state = strtolower($state); if (! in_array($state, self::LISTING_STATES, true)) { throw new RuntimeException('Listing state must be one of: '.implode(', ', self::LISTING_STATES).'.'); } [$limit, $offset] = $this->page($limit, $offset); $response = $this->request('GET', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [ 'state' => $state, 'limit' => $limit, 'offset' => $offset, ]); return [ 'count' => (int) ($response['count'] ?? count($response['results'] ?? [])), 'limit' => $limit, 'offset' => $offset, 'state' => $state, 'results' => array_map(fn (array $listing): array => $this->summarizeListing($listing), $this->results($response)), ]; } /** * @return array */ public function getListing(EtsyConnection $connection, int $listingId): array { $listing = $this->request('GET', 'application/listings/'.$listingId, $this->accessToken($connection)); $this->assertListingBelongsToShop($connection, $listing); return $this->summarizeListing($listing, detailed: true); } /** * @param array $payload * @return array */ public function createDraftListing(EtsyConnection $connection, array $payload): array { foreach (['quantity', 'title', 'description', 'price', 'who_made', 'when_made', 'taxonomy_id'] as $field) { if (! array_key_exists($field, $payload) || $payload[$field] === '' || $payload[$field] === null) { throw new RuntimeException("Draft listing requires {$field}."); } } unset($payload['state'], $payload['shop_id'], $payload['listing_id']); $listing = $this->request('POST', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), body: $payload); $this->cache->forget('etsy.listings.'.$connection->shop_id); return $this->summarizeListing($listing, detailed: true); } /** * @param array $payload * @return array */ public function updateListing(EtsyConnection $connection, int $listingId, array $payload): array { if (isset($payload['state']) && strtolower((string) $payload['state']) === 'active') { throw new RuntimeException('Activating a listing requires etsy_publish_listing with confirm=true. HL3D listings stay draft until the operator authorises going live.'); } unset($payload['shop_id'], $payload['listing_id']); $listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: $payload); $this->assertListingBelongsToShop($connection, $listing); $this->cache->forget('etsy.listings.'.$connection->shop_id); return $this->summarizeListing($listing, detailed: true); } /** * @return array */ public function publishListing(EtsyConnection $connection, int $listingId, bool $confirm = false): array { if (! $confirm) { throw new RuntimeException('Publishing a listing requires confirm=true. HL3D listings stay draft until the operator authorises going live.'); } $listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: ['state' => 'active']); $this->assertListingBelongsToShop($connection, $listing); $this->cache->forget('etsy.listings.'.$connection->shop_id); return $this->summarizeListing($listing, detailed: true); } /** * @return array */ public function getListingInventory(EtsyConnection $connection, int $listingId): array { return $this->request('GET', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection)); } /** * @param array $payload * @return array */ public function updateListingInventory(EtsyConnection $connection, int $listingId, array $payload): array { if (! isset($payload['products']) && array_key_exists('quantity', $payload)) { $payload = ['products' => $this->productsWithQuantity($this->getListingInventory($connection, $listingId), (int) $payload['quantity'])]; } if (! isset($payload['products']) || ! is_array($payload['products'])) { throw new RuntimeException('Inventory update requires products or quantity.'); } $inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: ['products' => $payload['products']], jsonBody: true); $this->cache->forget('etsy.listings.'.$connection->shop_id); return $inventory; } /** * @param array{was_paid?: bool|string|null, was_shipped?: bool|string|null, limit?: int, offset?: int} $filters * @return array{count: int, limit: int, offset: int, results: list>} */ public function listReceipts(EtsyConnection $connection, array $filters = []): array { [$limit, $offset] = $this->page((int) ($filters['limit'] ?? 25), (int) ($filters['offset'] ?? 0)); $query = ['limit' => $limit, 'offset' => $offset]; foreach (['was_paid', 'was_shipped'] as $flag) { if (array_key_exists($flag, $filters) && $filters[$flag] !== null && $filters[$flag] !== '') { $query[$flag] = filter_var($filters[$flag], FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false'; } } $response = $this->request('GET', 'application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), $query); return [ 'count' => (int) ($response['count'] ?? count($response['results'] ?? [])), 'limit' => $limit, 'offset' => $offset, 'results' => array_map(fn (array $receipt): array => $this->summarizeReceipt($receipt), $this->results($response)), ]; } /** * @return array */ public function getReceipt(EtsyConnection $connection, int $receiptId): array { $receipt = $this->request('GET', 'application/shops/'.$connection->shop_id.'/receipts/'.$receiptId, $this->accessToken($connection)); return $this->summarizeReceipt($receipt, detailed: true); } /** * @param array $extra * @return array */ public function createReceiptShipment(EtsyConnection $connection, int $receiptId, string $trackingCode, string $carrierName, array $extra = []): array { if ($trackingCode === '' || $carrierName === '') { throw new RuntimeException('Marking a receipt shipped requires tracking_code and carrier_name.'); } $body = ['tracking_code' => $trackingCode, 'carrier_name' => $carrierName]; if (array_key_exists('send_bcc', $extra) && $extra['send_bcc'] !== null) { $body['send_bcc'] = filter_var($extra['send_bcc'], FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false'; } if (filled($extra['note_to_buyer'] ?? null)) { $body['note_to_buyer'] = (string) $extra['note_to_buyer']; } $shipment = $this->request('POST', 'application/shops/'.$connection->shop_id.'/receipts/'.$receiptId.'/tracking', $this->accessToken($connection), body: $body); $this->cache->forget('etsy.receipts.'.$connection->shop_id); return [ 'receipt_id' => $shipment['receipt_id'] ?? $receiptId, 'tracking_code' => $shipment['tracking_code'] ?? $trackingCode, 'carrier_name' => $shipment['carrier_name'] ?? $carrierName, 'shop_id' => $connection->shop_id, ]; } /** * @param array{limit?: int, offset?: int} $filters * @return array{count: int, limit: int, offset: int, results: list>, conversations_api: array} */ public function listReceiptMessages(EtsyConnection $connection, array $filters = []): array { $page = $this->listReceipts($connection, $filters); $results = array_values(array_filter($page['results'], fn (array $receipt): bool => ($receipt['has_buyer_message'] ?? false) || ($receipt['has_seller_message'] ?? false))); return [ 'count' => count($results), 'limit' => $page['limit'], 'offset' => $page['offset'], 'results' => $results, 'conversations_api' => $this->conversationsGap(), ]; } /** * @return array */ public function getReceiptMessages(EtsyConnection $connection, int $receiptId): array { $receipt = $this->getReceipt($connection, $receiptId); return [ 'receipt_id' => $receipt['receipt_id'] ?? $receiptId, 'message_from_buyer' => $receipt['message_from_buyer'] ?? null, 'message_from_seller' => $receipt['message_from_seller'] ?? null, 'message_from_payment' => $receipt['message_from_payment'] ?? null, 'conversations_api' => $this->conversationsGap(), ]; } public function redirectUri(): string { return $this->credentials()['redirect_uri'] ?: route('etsy.callback'); } /** * Public wrapper around the on-demand token refresh logic, for the * etsy:refresh-token scheduled command to call proactively so the OAuth * connection stays alive even during quiet periods with no receipts or * listings activity to trigger a refresh as a side effect. */ public function refreshAccessToken(EtsyConnection $connection): string { return $this->accessToken($connection); } 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)), ...isset($tokens['scope']) ? ['scopes' => trim((string) $tokens['scope'])] : [], ]); return $tokens['access_token']; } /** * @param array $query * @param array|null $body * @return array */ private function request(string $method, string $path, string $accessToken, array $query = [], ?array $body = null, bool $jsonBody = false): array { $credentials = $this->credentials(); $pending = Http::withHeaders(['x-api-key' => $credentials['keystring'].':'.$credentials['shared_secret']]) ->withToken($accessToken)->acceptJson()->connectTimeout(3)->timeout(15); $url = self::API_URL.'/'.$path; $method = strtoupper($method); try { $response = match ($method) { 'GET', 'DELETE' => $pending->{strtolower($method)}($url, $query), default => $jsonBody ? $pending->asJson()->{strtolower($method)}($url, $body ?? []) : $pending->asForm()->{strtolower($method)}($url, $body ?? []), }; $decoded = $response->throw()->json(); } catch (RequestException $exception) { throw new RuntimeException($this->httpErrorMessage($exception), (int) $exception->response?->status(), $exception); } return is_array($decoded) ? $decoded : []; } private function httpErrorMessage(RequestException $exception): string { $status = $exception->response?->status(); $body = $exception->response?->json(); $etsy = is_array($body) ? (string) ($body['error'] ?? $body['error_description'] ?? '') : ''; $message = 'Etsy API request failed'.($status ? " (HTTP {$status})" : '').($etsy !== '' ? ': '.$etsy : '.'); if ($status === 403) { $message .= ' This usually means the stored OAuth token is missing write scopes. An admin must reconnect Etsy in the dashboard.'; } return $message; } /** * @param array $listing */ private function assertListingBelongsToShop(EtsyConnection $connection, array $listing): void { if (isset($listing['shop_id']) && (int) $listing['shop_id'] !== (int) $connection->shop_id) { throw new RuntimeException('That listing does not belong to the connected HickLab3D shop.'); } } /** * @param array $listing * @return array */ private function summarizeListing(array $listing, bool $detailed = false): array { $summary = [ 'listing_id' => $listing['listing_id'] ?? null, 'shop_id' => $listing['shop_id'] ?? null, 'title' => $listing['title'] ?? null, 'state' => $listing['state'] ?? null, 'quantity' => $listing['quantity'] ?? null, 'price' => $listing['price'] ?? null, 'url' => $listing['url'] ?? null, 'taxonomy_id' => $listing['taxonomy_id'] ?? null, 'last_modified_timestamp' => $listing['last_modified_timestamp'] ?? null, ]; if ($detailed) { $summary['description'] = $listing['description'] ?? null; $summary['who_made'] = $listing['who_made'] ?? null; $summary['when_made'] = $listing['when_made'] ?? null; $summary['tags'] = $listing['tags'] ?? []; $summary['materials'] = $listing['materials'] ?? []; $summary['shipping_profile_id'] = $listing['shipping_profile_id'] ?? null; $summary['is_personalizable'] = $listing['is_personalizable'] ?? null; } return $summary; } /** * @param array $receipt * @return array */ private function summarizeReceipt(array $receipt, bool $detailed = false): array { $summary = [ 'receipt_id' => $receipt['receipt_id'] ?? null, 'status' => $receipt['status'] ?? null, 'was_paid' => $receipt['was_paid'] ?? null, 'was_shipped' => $receipt['was_shipped'] ?? null, 'was_canceled' => $receipt['was_canceled'] ?? null, 'create_timestamp' => $receipt['create_timestamp'] ?? null, 'grandtotal' => $receipt['grandtotal'] ?? null, 'name' => $receipt['name'] ?? null, 'city' => $receipt['city'] ?? null, 'state' => $receipt['state'] ?? null, 'zip' => $receipt['zip'] ?? null, 'country_iso' => $receipt['country_iso'] ?? null, 'has_buyer_message' => filled($receipt['message_from_buyer'] ?? null), 'has_seller_message' => filled($receipt['message_from_seller'] ?? null), ]; if ($detailed) { $summary['first_line'] = $receipt['first_line'] ?? null; $summary['second_line'] = $receipt['second_line'] ?? null; $summary['message_from_buyer'] = $receipt['message_from_buyer'] ?? null; $summary['message_from_seller'] = $receipt['message_from_seller'] ?? null; $summary['message_from_payment'] = $receipt['message_from_payment'] ?? null; } return $summary; } /** * @param array $inventory * @return list> */ private function productsWithQuantity(array $inventory, int $quantity): array { $products = []; foreach ($inventory['products'] ?? [] as $product) { if (! is_array($product)) { continue; } $offerings = []; foreach ($product['offerings'] ?? [] as $offering) { if (! is_array($offering)) { continue; } $price = $offering['price'] ?? 0; if (is_array($price)) { $price = (float) ($price['amount'] ?? 0) / max(1, (int) ($price['divisor'] ?? 1)); } $offerings[] = [ 'price' => $price, 'quantity' => $quantity, 'is_enabled' => $offering['is_enabled'] ?? true, ]; } $products[] = [ 'sku' => $product['sku'] ?? '', 'property_values' => $product['property_values'] ?? [], 'offerings' => $offerings, ]; } return $products; } /** * @return array{available: bool, reason: string, docs: list} */ private function conversationsGap(): array { return [ 'available' => false, 'reason' => 'Etsy Open API v3 has no shop conversation/inbox endpoints and no conversations_* OAuth scopes. Receipt notes (message_from_buyer / message_from_seller) are the supported v3 surface. Sending a buyer-inbox reply is not available.', 'docs' => [ 'https://developer.etsy.com/documentation/essentials/authentication', 'https://developer.etsy.com/documentation/reference', 'https://github.com/etsy/open-api/discussions/1645', ], ]; } /** * @return list> */ private function results(array $response): array { return array_values(array_filter($response['results'] ?? [], 'is_array')); } /** * @return array{0: int, 1: int} */ private function page(int $limit, int $offset): array { return [max(1, min(100, $limit)), max(0, $offset)]; } /** * @return list|null */ private function scopeList(?string $scopes): ?array { if ($scopes === null || trim($scopes) === '') { return null; } return array_values(array_filter(preg_split('/\s+/', trim($scopes)) ?: [])); } /** @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'), ]; } }