Expose php artisan etsy:mcp as JSON-RPC stdio next to laravel-boost, reuse the stored HickLab3D shop connection, create listings as drafts only, and skip Etsy v3 conversation send because that API does not exist.
628 lines
27 KiB
PHP
628 lines
27 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 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): 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],
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<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.');
|
|
}
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
public function getListingInventory(EtsyConnection $connection, int $listingId): array
|
|
{
|
|
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
|
|
{
|
|
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<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
|
|
{
|
|
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');
|
|
}
|
|
|
|
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<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,
|
|
];
|
|
}
|
|
$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'),
|
|
];
|
|
}
|
|
}
|