720 lines
32 KiB
PHP
720 lines
32 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\EtsyAppCredential;
|
|
use App\Models\EtsyConnection;
|
|
use Illuminate\Http\Client\RequestException;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\Rule;
|
|
use RuntimeException;
|
|
|
|
class Etsy
|
|
{
|
|
private const API_URL = 'https://api.etsy.com/v3';
|
|
|
|
/** @var list<string> */
|
|
public const REQUIRED_SCOPES = ['listings_r', 'listings_w', 'transactions_r', 'transactions_w', 'shops_r'];
|
|
|
|
/** @var list<string> */
|
|
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<string, mixed> $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<string>,
|
|
* connected_scopes: ?list<string>,
|
|
* missing_scopes: list<string>,
|
|
* 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<array<string, mixed>>}
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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);
|
|
}
|
|
|
|
private function requireScope(EtsyConnection $connection, string $scope): void
|
|
{
|
|
if (! in_array($scope, $this->scopeList($connection->scopes) ?? [], true)) {
|
|
throw new RuntimeException('An admin must reconnect Etsy in the dashboard to grant '.$scope.'.');
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function validatedListingPayload(array $payload, bool $creating): array
|
|
{
|
|
unset($payload['listing_id']);
|
|
if ($creating) {
|
|
unset($payload['shop_id'], $payload['state']);
|
|
}
|
|
$allowedFields = $creating
|
|
? 'title,description,price,quantity,who_made,when_made,taxonomy_id,shipping_profile_id,tags,materials,type'
|
|
: 'title,description,taxonomy_id,tags,state';
|
|
$required = $creating ? 'required' : 'sometimes';
|
|
$rules = [
|
|
'listing' => ['required', 'array:'.$allowedFields],
|
|
'listing.title' => [$required, 'string', 'max:140'],
|
|
'listing.description' => [$required, 'string'],
|
|
'listing.price' => [$required, 'numeric', 'gt:0'],
|
|
'listing.quantity' => [$required, 'integer', 'min:1'],
|
|
'listing.who_made' => [$required, Rule::in(['i_did', 'someone_else', 'collective'])],
|
|
'listing.when_made' => [$required, 'string', 'max:32'],
|
|
'listing.taxonomy_id' => [$required, 'integer', 'min:1'],
|
|
'listing.shipping_profile_id' => ['sometimes', 'integer', 'min:1'],
|
|
'listing.tags' => ['sometimes', 'array', 'max:13'],
|
|
'listing.tags.*' => ['string', 'max:20'],
|
|
'listing.materials' => ['sometimes', 'array'],
|
|
'listing.materials.*' => ['string'],
|
|
'listing.type' => ['sometimes', Rule::in(['physical', 'download', 'both'])],
|
|
'listing.state' => ['sometimes', Rule::in(['draft', 'inactive'])],
|
|
];
|
|
$validator = Validator::make(['listing' => $payload], $rules);
|
|
if ($validator->fails()) {
|
|
throw new RuntimeException('Invalid listing payload: '.$validator->errors()->first());
|
|
}
|
|
|
|
return $validator->validated()['listing'];
|
|
}
|
|
|
|
public function createDraftListing(EtsyConnection $connection, array $payload): array
|
|
{
|
|
$this->requireScope($connection, 'listings_w');
|
|
$payload = $this->validatedListingPayload($payload, true);
|
|
$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<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
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.');
|
|
}
|
|
$this->requireScope($connection, 'listings_w');
|
|
$payload = $this->validatedListingPayload($payload, false);
|
|
$this->getListing($connection, $listingId);
|
|
$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<string, mixed>
|
|
*/
|
|
public function publishListing(EtsyConnection $connection, int $listingId, bool $confirm = false): array
|
|
{
|
|
$this->requireScope($connection, 'listings_w');
|
|
if (! $confirm) {
|
|
throw new RuntimeException('Publishing a listing requires confirm=true. HL3D listings stay draft until the operator authorises going live.');
|
|
}
|
|
$this->getListing($connection, $listingId);
|
|
$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<string, mixed>
|
|
*/
|
|
public function getListingInventory(EtsyConnection $connection, int $listingId): array
|
|
{
|
|
$this->getListing($connection, $listingId);
|
|
|
|
return $this->request('GET', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function updateListingInventory(EtsyConnection $connection, int $listingId, array $payload): array
|
|
{
|
|
$this->requireScope($connection, 'listings_w');
|
|
$validator = Validator::make($payload, [
|
|
'quantity' => ['required_without:products', 'integer', 'min:0'],
|
|
'products' => ['required_without:quantity', 'array', 'min:1'],
|
|
'products.*' => ['array:sku,property_values,offerings'],
|
|
'products.*.sku' => ['sometimes', 'string'],
|
|
'products.*.property_values' => ['sometimes', 'array'],
|
|
'products.*.offerings' => ['required', 'array', 'min:1'],
|
|
'products.*.offerings.*' => ['array:price,quantity,is_enabled,readiness_state_id'],
|
|
'products.*.offerings.*.price' => ['required', 'numeric', 'gt:0'],
|
|
'products.*.offerings.*.quantity' => ['required', 'integer', 'min:0'],
|
|
'products.*.offerings.*.is_enabled' => ['required', 'boolean'],
|
|
'products.*.offerings.*.readiness_state_id' => ['sometimes', 'integer', 'min:1'],
|
|
]);
|
|
if ($validator->fails()) {
|
|
throw new RuntimeException('Invalid inventory payload: '.$validator->errors()->first());
|
|
}
|
|
$this->getListing($connection, $listingId);
|
|
$currentInventory = $this->getListingInventory($connection, $listingId);
|
|
if (! isset($payload['products']) && array_key_exists('quantity', $payload)) {
|
|
$payload = ['products' => $this->productsWithQuantity($currentInventory, (int) $payload['quantity'])];
|
|
}
|
|
if (! isset($payload['products']) || ! is_array($payload['products'])) {
|
|
throw new RuntimeException('Inventory update requires products or quantity.');
|
|
}
|
|
$body = ['products' => $payload['products']];
|
|
foreach (['price_on_property', 'quantity_on_property', 'sku_on_property', 'readiness_state_on_property'] as $field) {
|
|
if (array_key_exists($field, $currentInventory)) {
|
|
$body[$field] = $currentInventory[$field];
|
|
}
|
|
}
|
|
$inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: $body, 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<array<string, mixed>>}
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed> $extra
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function createReceiptShipment(EtsyConnection $connection, int $receiptId, string $trackingCode, string $carrierName, array $extra = []): array
|
|
{
|
|
$this->requireScope($connection, 'transactions_w');
|
|
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<array<string, mixed>>, conversations_api: array<string, mixed>}
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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, 600);
|
|
}
|
|
|
|
private function accessToken(EtsyConnection $connection, int $marginSeconds = 0): string
|
|
{
|
|
return $connection->getConnection()->transaction(function () use ($connection, $marginSeconds): string {
|
|
// Lock the shared database row, not a per-process cache: HTTP, MCP
|
|
// and scheduler workers must re-read the latest rotating token.
|
|
$current = EtsyConnection::query()->whereKey($connection->getKey())->lockForUpdate()->firstOrFail();
|
|
if ($current->expires_at->greaterThan(now()->addSeconds($marginSeconds))) {
|
|
$connection->setRawAttributes($current->getAttributes(), true);
|
|
|
|
return $current->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' => $current->refresh_token,
|
|
])->throw()->json();
|
|
if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) {
|
|
throw new RuntimeException('Etsy did not refresh the shop connection.');
|
|
}
|
|
$current->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'])] : [],
|
|
]);
|
|
$connection->setRawAttributes($current->getAttributes(), true);
|
|
|
|
return $current->access_token;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $query
|
|
* @param array<string, mixed>|null $body
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed> $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<string, mixed> $listing
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed> $receipt
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, mixed> $inventory
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
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,
|
|
...array_intersect_key($offering, array_flip(['readiness_state_id'])),
|
|
];
|
|
}
|
|
$products[] = [
|
|
'sku' => $product['sku'] ?? '',
|
|
'property_values' => $product['property_values'] ?? [],
|
|
'offerings' => $offerings,
|
|
];
|
|
}
|
|
|
|
return $products;
|
|
}
|
|
|
|
/**
|
|
* @return array{available: bool, reason: string, docs: list<string>}
|
|
*/
|
|
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<array<string, mixed>>
|
|
*/
|
|
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<string>|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'),
|
|
];
|
|
}
|
|
}
|