# eBay Connector — Full Implementation Plan

Built on top of `ebay-connector-plan.md` (existing Shopify-architecture context) plus:
- eBay Sell Inventory API / Fulfillment API / Account API / Taxonomy API (developer.ebay.com)
- eBay SDKs & Widgets page (no official PHP SDK for the Sell APIs — see §0)
- Linnworks eBay Configurator pattern (docs.linnworks.com) — used as the UX model for §4

This is a **plan only** — no implementation. Each section says which existing file to
read/copy from and exactly what differs.

---

## 0. SDK reality check (important correction to plan blindly against "the eBay SDK")

eBay's [SDKs & Widgets](https://developer.ebay.com/develop/sdks-and-widgets) page lists:

- **Trading API SDKs** (.NET, Java) — legacy XML API, not what we want for the Sell suite
- **OAuth client libraries** — Node.js, Python, .NET, Android — **no official PHP OAuth client**
- **Digital Signature SDKs** (Java, Node, .NET, PHP*, Go) — only needed if you opt into
  eBay's Digital Signatures for APIs program (currently required for a small set of
  sensitive Trading API calls, not required for Inventory/Fulfillment/Account APIs)
- **Taxonomy Metadata SDK** (Java only) — diffing aspect metadata, not required for v1
- No SDK at all for **Sell Inventory API**, **Sell Fulfillment API**, or **Sell Account
  API** — these are plain REST/JSON, meant to be called directly

**Decision:** `EbayClient` is a hand-rolled Guzzle-based REST client, same pattern as
`ShopifyClient`'s REST calls (not GraphQL, since eBay's Sell suite is REST-only — this
matches what the original plan already assumed). No third-party PHP eBay package is
pulled in; this keeps the dependency footprint identical to the Shopify integration and
avoids trusting an unofficial/abandoned composer package for token handling and
encrypted-field storage. This should be flagged to the team as a deliberate choice, since
it means writing our own request signing (Digital Signatures) support later if eBay
mandates it for the Sell APIs on a future date.

---

## 1. eBay API surface actually needed

| API | Base path | Used for |
|---|---|---|
| **Sell Account API v1** | `/sell/account/v1` | `optInToProgram` (Business Policies), `getFulfillmentPolicies`, `getPaymentPolicies`, `getReturnPolicies`, `createInventoryLocation` |
| **Sell Inventory API v1** | `/sell/inventory/v1` | `createOrReplaceInventoryItem`, `bulkCreateOrReplaceInventoryItem`, `createOffer`, `bulkCreateOffer`, `updateOffer`, `publishOffer`, `bulkPublishOffer`, `withdrawOffer`, `updateQuantity` (via inventory item PUT) |
| **Sell Fulfillment API v1** | `/sell/fulfillment/v1` | `getOrders`, `getOrder`, `createShippingFulfillment` (submit tracking) |
| **Commerce Taxonomy API v1** | `/commerce/taxonomy/v1` | `getCategorySuggestions`, `getCategoryTree`, `getItemAspectsForCategory` |
| **Identity / OAuth** | `https://api.ebay.com/identity/v1/oauth2/token` | code→token exchange, refresh |

Key structural fact confirmed from eBay's docs (`managing-inventory-and-offers`,
`inventory-item-to-offer`, `publishing-offers`): the Inventory API has exactly three
entities — **Location → Inventory Item → Offer** — and `publishOffer` is what actually
creates the live listing and returns `listingId`. This validates the original plan's
model 1:1; no changes needed to §3.2/§3.3 of the base document.

**Business Policies are mandatory, not optional.** eBay's docs state a seller must
`optInToProgram` for Business Policies, and Payment/Fulfillment/Return policy IDs are
**required** fields on `createOffer` before `publishOffer` will succeed. This directly
shapes the configurator design in §4 — there is no "raw shipping cost / raw payment
terms" input like old Trading API listings had; everything routes through policy IDs
fetched from the Account API and cached in `channels_ebay_policies` (already planned in
the base document, §3.2).

---

## 2. What changes vs. the original plan document

Nothing in §3.1–3.3 of the base document needs correcting — it already matches eBay's
real API shape. What was **missing** is a first-class **Configurator** concept
equivalent to Shopify's `listing_configurators`, but eBay-shaped and exposed as its own
Filament UI (Linnworks-style), rather than just "add eBay columns to the existing table."
That's the main addition this plan makes — see §4.

---

## 3. Database schema (migrations)

### 3.1 `channels_ebay_sku_mappings`
```
id
product_id            FK -> products
channel_id            FK -> channels_channels
ebay_sku              string, unique per channel (mirrors product SKU, eBay's inventory item key)
ebay_offer_id         string, nullable
ebay_listing_id       string, nullable   -- set only after publishOffer succeeds
ebay_inventory_item_group_key  string, nullable  -- for multi-variation listings
condition_id          integer, nullable  -- overrides configurator default if set
category_id           string, nullable   -- overrides configurator default if set
configurator_id       FK -> channels_ebay_configurators, nullable
last_pushed_at        timestamp, nullable
timestamps

unique(channel_id, product_id)
unique(channel_id, ebay_sku)
```

### 3.2 `channels_ebay_tokens`
```
id
channel_id       FK -> channels_channels, unique
access_token     text, encrypted
refresh_token    text, encrypted
expires_at       timestamp        -- access token expiry (~2h)
refresh_expires_at timestamp      -- refresh token expiry (~18mo), warn before this lapses
scopes           json
timestamps
```

### 3.3 `channels_ebay_policies`
```
id
channel_id          FK -> channels_channels
policy_type          enum: fulfillment | payment | return
ebay_policy_id        string
name                 string          -- display name from eBay, for the configurator dropdown
marketplace_id        string          -- e.g. EBAY_GB — policies are per-marketplace
raw                  json            -- full policy payload cached
fetched_at            timestamp
timestamps

unique(channel_id, policy_type, ebay_policy_id)
```

### 3.4 `channels_ebay_configurators`  (new — the Linnworks-style piece, see §4)
```
id
channel_id                  FK -> channels_channels
name                        string          -- "UK Brake Parts", "US Electronics" etc, admin-facing
marketplace_id               string          -- EBAY_GB, EBAY_US...
category_id                  string          -- eBay leaf category ID (from Taxonomy API)
category_path                 string, nullable -- cached human-readable path, display only
condition_id                  integer
listing_format                 enum: FIXED_PRICE  (eBay Sell Inventory only supports fixed-price; no auction)
listing_duration                string          -- 'GTC' (Good 'Til Cancelled) for FIXED_PRICE
merchant_location_key           string
fulfillment_policy_id            string
payment_policy_id                string
return_policy_id                 string
store_category_id                 string, nullable  -- eBay Store category, optional
item_specifics_template            json           -- key -> value or key -> product-attribute-mapping
description_template               longtext        -- HTML template with [{PRODUCT_*}] placeholder tags
pricing_markup_percent              decimal, nullable
enable_strike_through_price          boolean, default false
is_default                          boolean, default false  -- fallback if product has none assigned
timestamps
```

### 3.5 Extend `channels_location_mappings`
Add nullable `ebay_merchant_location_key` column (per base doc §7) rather than only
storing it in `settings` — needed because a channel could theoretically have more than
one eBay merchant location later (multi-warehouse sellers), and keeping it in the
mapping table is consistent with how Shopify locations are already modeled.

---

## 4. The Configurator (Linnworks-equivalent) — new piece

### 4.1 Why this is needed, not just "extend `listing_configurators`"

Linnworks' eBay Configurator is a **named, reusable, category-bound template**: an admin
creates "UK Brake Parts" once, sets its eBay account, site, category, condition,
returns/payment/shipping terms, and description HTML template, and then assigns it to
many products. The existing `listing_configurators` table in the base document is
Shopify-shaped (title template, markup %, vendor/type/tags) and is described as
reusable-with-added-columns. In practice eBay configurators carry materially different,
category-scoped required fields (condition ID, category ID, three policy IDs, item
specifics schema) — cramming this into the Shopify table as nullable columns would make
that table's form conditionally render a completely different field set per platform,
which is exactly the kind of divergence the base document's §3.2 decision (parallel
tables instead of nullable pollution) already argues against for SKU mappings. The same
logic applies here: **`channels_ebay_configurators` is a parallel table**, not an
extension of `listing_configurators`.

`ListingConfigurator` (Shopify) and `EbayConfigurator` should both implement a shared,
thin `ConfiguratorContract` (`getTitleTemplate()`, `apply(Product $product): array`) so
`ManageListingsV2` can treat "apply configurator to product" generically regardless of
platform — this is the one piece of shared abstraction worth adding.

### 4.2 Mapping Linnworks' tabs onto eBay's real API fields

| Linnworks tab | Linnworks concept | eBay Sell API equivalent | Where it lives here |
|---|---|---|---|
| Main | Account, Site, Listing type, Currency, Duration | `channel_id`, `marketplace_id`, `listing_format` (fixed-price only), currency implied by marketplace | `channels_ebay_configurators` |
| Returns | Return terms | `return_policy_id` (from Account API, no freeform terms allowed) | `channels_ebay_configurators.return_policy_id` |
| Payments | Payment methods | `payment_policy_id` | `channels_ebay_configurators.payment_policy_id` |
| Shipping | Shipping services + costs | `fulfillment_policy_id` | `channels_ebay_configurators.fulfillment_policy_id` |
| Store | eBay Store categories | `store_category_id` | `channels_ebay_configurators.store_category_id` |
| HTML Template | `[{PRODUCT_DESCRIPTION}]` tag template | `product.description` field on the inventory item | `channels_ebay_configurators.description_template`, tags resolved in `EbayDescriptionRenderer` (see §6.4) |
| *(not in Linnworks, required by eBay's modern API)* | — | `category_id` (Taxonomy-validated), `condition_id`, `item_specifics_template` (Taxonomy `getItemAspectsForCategory`-validated) | new fields, no Linnworks equivalent since old Trading API was less strict about structured aspects |

**Key difference from Linnworks worth calling out to the team:** Linnworks lets you type
free-text return/payment/shipping terms per configurator because it was built against
the old Trading API. eBay's modern Inventory API **only accepts policy IDs** — so the
"Payments/Shipping/Returns" tabs in our configurator UI are **read-only pickers**
populated from `channels_ebay_policies` (which is synced from the seller's actual eBay
Business Policies), not free-text fields. This is simpler to build than Linnworks' UI,
but it means sellers must first set up Business Policies on eBay.com or via the Account
API before any configurator can be marked complete.

### 4.3 Configurator UI (new Filament Resource, not just conditional fields)

Unlike Shopify's `listing_configurators` (managed inline on the Channel or product
screen per the base document), eBay configurators are **named and category-scoped**
enough to warrant their own resource, mirroring Linnworks' "Configs > eBay
Configurators > Add New" flow:

- New Filament Resource: `EbayConfiguratorResource`
  (`plugins/webkul/channels/src/Filament/Resources/EbayConfiguratorResource.php`)
- List page: name, channel, marketplace, category path, condition, "complete?" badge
  (green if all 3 policy IDs + category + condition are set, amber otherwise — same idea
  as Linnworks flagging incomplete configs)
- Form, tabbed to match §4.2:
  - **Main**: channel select, marketplace select, name
  - **Category & Condition**: category search (calls `EbayClient::getCategorySuggestions`
    live via a Filament `Select::searchable()` backed by a controller action, not a
    static list — categories are too large to preload), condition select scoped to what's
    valid for that category (`getItemAspectsForCategory` also returns allowed condition
    values per category in some verticals — surface this if present)
  - **Policies**: three `Select` fields fed by `channels_ebay_policies` filtered to
    `marketplace_id`, with a "Refresh from eBay" button that re-hits the Account API
  - **Item specifics**: dynamic key-value form built from `getItemAspectsForCategory`
    response — required aspects marked, each mapped to either a static value or a
    product-attribute binding (e.g. `Brand` → `product.brand`)
  - **Description template**: rich text / HTML editor, same `[{PRODUCT_*}]` tag support
    as Linnworks (`[{PRODUCT_TITLE}]`, `[{PRODUCT_DESCRIPTION}]`, `[{PRODUCT_IMAGE_1}]`,
    plus a fixed footer block for logo/returns/payment blurb, matching Linnworks'
    "put logo and policy info in the template" convention)
  - **Pricing**: markup %, strike-through toggle (reuses base doc §6 flag)
- `product_id` → `configurator_id` assignment happens from `ManageListingsV2` (bulk
  action: "Assign configurator"), same interaction pattern as Linnworks assigning a
  config to selected items before "Create".

### 4.4 Validation before allowing "Push"/"Create" actions

A configurator must be "complete" (category + condition + all 3 policy IDs +
merchant_location_key on the channel) before `EbayBatchCreateJob` will process any
product assigned to it — fail fast in the job's DB-preload phase and log a clear
`JobLogger` error per skipped SKU rather than letting the eBay API 400 error surface
raw. This mirrors how Shopify jobs should already fail gracefully on missing required
fields.

---

## 5. `EbayClient` service — methods, signatures, return types

`plugins/webkul/channels/src/Services/EbayClient.php`, implements new
`EbayDriverInterface` (`plugins/webkul/channels/src/Contracts/EbayDriverInterface.php`).
Read `ShopifyClient.php` first for the retry/backoff and encrypted-token-storage pattern
to mirror.

```php
interface EbayDriverInterface
{
    // --- OAuth ---
    public function getAuthorizationUrl(string $redirectUri, string $state): string;
    public function exchangeCodeForTokens(string $code): EbayTokenSet;      // access, refresh, expires_at
    public function refreshAccessToken(Channel $channel): EbayTokenSet;
    public function getValidAccessToken(Channel $channel): string;          // refreshes if <5min from expiry

    // --- Account API (business policies + locations) ---
    public function optInToProgram(string $programType = 'SELLING_POLICY_MANAGEMENT'): bool;
    public function getFulfillmentPolicies(string $marketplaceId): array;   // EbayPolicy[]
    public function getPaymentPolicies(string $marketplaceId): array;
    public function getReturnPolicies(string $marketplaceId): array;
    public function createInventoryLocation(string $merchantLocationKey, array $locationData): bool;
    public function getInventoryLocations(): array;

    // --- Taxonomy API ---
    public function getDefaultCategoryTreeId(string $marketplaceId): string;
    public function getCategorySuggestions(string $categoryTreeId, string $query): array; // for configurator search box
    public function getItemAspectsForCategory(string $categoryTreeId, string $categoryId): array; // required/optional aspects + allowed values

    // --- Inventory API: items ---
    public function createOrReplaceInventoryItem(string $sku, array $itemPayload): bool;
    public function bulkCreateOrReplaceInventoryItem(array $items): array;  // max 25/call; returns per-SKU success/error
    public function getInventoryItem(string $sku): ?array;
    public function deleteInventoryItem(string $sku): bool;

    // --- Inventory API: offers ---
    public function createOffer(array $offerPayload): string;              // returns offerId
    public function bulkCreateOffer(array $offers): array;                 // max 25/call
    public function updateOffer(string $offerId, array $offerPayload): bool;
    public function getOffer(string $offerId): ?array;
    public function publishOffer(string $offerId): string;                 // returns listingId
    public function bulkPublishOffer(array $offerIds): array;              // max 25/call
    public function withdrawOffer(string $offerId): bool;
    public function updateQuantity(string $sku, int $quantity): bool;      // PATCH via createOrReplaceInventoryItem availability

    // --- Fulfillment API: orders ---
    public function getOrders(array $params = []): array;                  // supports 'filter' (creationdate range), pagination
    public function getOrder(string $orderId): ?array;
    public function createShippingFulfillment(string $orderId, array $trackingData): bool;
}
```

Notes carried over from the base doc, confirmed against eBay's docs:
- `bulkCreateOffer`/`bulkCreateOrReplaceInventoryItem`/`bulkPublishOffer` cap at 25 items
  per call — this governs `EbayBatchSyncJob`'s chunk size (base doc already said 25,
  correct, keep it).
- Publishing requires business-policy opt-in + all 3 policy IDs + a merchant location +
  at least one image + a quantity — validate all of this client-side before calling
  `publishOffer` to avoid burning rate-limited calls on guaranteed-to-fail requests.

---

## 6. Jobs

Same 4 jobs as the base document (§3.4), with the configurator now driving the payload
build. Read `ShopifyBatchSyncJob.php` and `ShopifyBatchCreateJob.php` first for the
phase-pipeline pattern (DB preload → build → API call → DB writes, each phase logged via
`JobLogger`) and mirror the phase names.

### 6.1 `EbayBatchSyncJob`
Phases:
1. **DB preload** — load products + their `channels_ebay_sku_mappings` +
   `channels_ebay_configurators` (with policies) for the batch
2. **Validate configurator completeness** (§4.4) — skip + log incomplete ones
3. **Build inventory item payloads** — title, `product.aspects` (from configurator's
   `item_specifics_template` resolved against product attributes), condition, images,
   `availability.shipToLocationAvailability.quantity`
4. **`bulkCreateOrReplaceInventoryItem`** (chunks of 25)
5. **Build offer payloads** — price (from `channels_product_prices`, markup applied),
   policy IDs, category, merchant location, marketplace
6. **`bulkCreateOffer`** (new) or **`updateOffer`** (existing, looped — no bulk-update
   endpoint for offers, confirm during implementation whether `bulkUpdatePriceQuantity`
   fits the price/qty-only case to save calls)
7. **`bulkPublishOffer`** for any offer still `UNPUBLISHED`
8. **DB writes** — update `ebay_offer_id`, `ebay_listing_id`, `last_pushed_at`,
   `channel_listings.status`/`last_synced_at`/`last_error`

### 6.2 `EbayBatchCreateJob`
Same pipeline as 6.1 but for products with no `channels_ebay_sku_mappings` row yet —
creates the mapping row first, then runs the same build→create→publish phases.

### 6.3 `SyncEbayOrders`
Polls `getOrders` with a `creationdate` filter since last successful run (store
watermark in `channels_channels.settings.ebay_orders_last_synced_at`), maps payload via
new `EbayOrderImporter` (mirror `ShopifyOrderImporter`, read that file first — base doc
§8 already calls this out). Acknowledgement/tracking submission is a **separate**
scheduled job or triggered from the existing fulfilment/shipping event that already
fires for Shopify orders, reusing whatever dispatches tracking numbers today rather than
duplicating that trigger logic.

### 6.4 `RefreshEbayTokenJob`
Scheduled every 60–90 minutes (access tokens last 2h; refreshing at a safety margin
avoids a sync job hitting an expired token mid-run). Also checks
`refresh_expires_at` and raises an admin-visible warning via `JobLogger`/notification
well before the ~18-month refresh-token expiry, since that requires the admin to
re-authorize manually — no silent failure mode.

### 6.5 `EbayDescriptionRenderer` (small new service, not a job)
Resolves `[{PRODUCT_*}]` tags in `description_template` against a given product —
`PRODUCT_TITLE`, `PRODUCT_DESCRIPTION`, `PRODUCT_IMAGE_1..N`, `PRODUCT_SKU`,
`PRODUCT_PRICE`. Called from both batch jobs when building the inventory item payload.
Keep this as an isolated, unit-testable class rather than inlining string replacement in
the job, since the tag set will likely grow (Linnworks' full tag list is much larger —
start minimal, extend on request).

---

## 7. OAuth flow

Identical shape to base doc §3.5 (`EbayAuthController::redirect()` /
`::callback()` mirroring `ShopifyAuthController`), with one addition: immediately after
token exchange in `callback()`, chain-call `optInToProgram()` and then
`getFulfillmentPolicies()`/`getPaymentPolicies()`/`getReturnPolicies()` to populate
`channels_ebay_policies` right away, so the configurator's policy dropdowns aren't empty
on first use. Surface a clear message if opt-in fails (seller hasn't set up Business
Policies on eBay yet) rather than leaving the channel silently half-connected.

---

## 8. UI changes

### 8.1 `ChannelResource` form (base doc §3.6, unchanged)
eBay-conditional fields: `settings.ebay_site_id`/`marketplace_id`,
`settings.merchant_location_key`, `settings.ebay_environment` (sandbox/production),
`settings.default_condition_id` (fallback only — real default now lives on the
configurator's `is_default` row, see §3.4).

### 8.2 New `EbayConfiguratorResource` (this plan's addition, §4.3)

### 8.3 `ManageListingsV2` (base doc §3.6, extended)
- New bulk action "Assign configurator" (eBay channels only) — dropdown of that
  channel's configurators, sets `channels_ebay_sku_mappings.configurator_id` for the
  selected products; block "Push"/"Create" for products with no configurator assigned
  and surface them in a new **Needs Configurator** tab alongside the existing
  Listed/Needs Update/Not Synced/Not Linked/Errors tabs
- "Push to eBay" → `EbayBatchSyncJob`
- "Create on eBay" → `EbayBatchCreateJob`
- "End Listing" → `withdrawOffer` (no Shopify equivalent, base doc already notes this)
- Extend the `@elseif` chain in `manage-listings-v2.blade.php` per base doc's existing
  guidance — no fork of the view

---

## 9. ServiceProvider / registration changes

- Register 3 new migrations + `EbayConfiguratorResource` in
  `ChannelServiceProvider`/`ChannelPlugin` (mirror however `ListingConfigurator` is
  currently registered)
- Register `RefreshEbayTokenJob` on the scheduler (`$schedule->job(...)->everyNinetyMinutes()` or similar)
- Register eBay OAuth routes in `plugins/webkul/channels/routes/web.php`
  (`/channels/ebay/redirect`, `/channels/ebay/callback`)
- Add `case Ebay = 'ebay'` to `ChannelPlatform` enum, pointing `getDriverClass()` at
  `EbayClient`

---

## 10. Files to read before implementing (superset of base doc §9)

All files listed in the base document's §9, plus:

| File | Why |
|---|---|
| `plugins/webkul/channels/src/Models/ListingConfigurator.php` | Model to parallel (not extend) for `EbayConfigurator` |
| `plugins/webkul/channels/src/Filament/Resources/ListingConfiguratorResource.php` (if it exists as its own resource, else wherever configurators are currently edited) | UI pattern to mirror for `EbayConfiguratorResource` |
| Wherever `ChannelProductPriceObserver` and `ProductQuantityObserver` currently dispatch Shopify jobs | Need the same hooks to also dispatch eBay price/quantity syncs when a product has an active eBay mapping |

---

## 11. Suggested build order

1. Migrations (§3) + enum case + `EbayDriverInterface` skeleton
2. `EbayClient` OAuth + Account API methods only → get `EbayAuthController` working
   end-to-end (connect a sandbox channel, confirm policies land in
   `channels_ebay_policies`)
3. `EbayConfiguratorResource` UI, wired to real sandbox policies/categories
4. `EbayClient` Inventory API methods → `EbayBatchCreateJob` against sandbox, single
   product, manually verified listing appears in sandbox Seller Hub
5. `EbayBatchSyncJob` (update path) + quantity/price observer hooks
6. `ManageListingsV2` bulk actions + Needs Configurator tab
7. `EbayOrderImporter` + `SyncEbayOrders` + tracking submission
8. `RefreshEbayTokenJob` on schedule, load-test token refresh under a real sync run
9. Production credentials + go-live checklist (Business Policies confirmed live,
   merchant location created, at least one configurator marked complete per category in
   use)

Do not implement — this is the plan only, per the original prompt's convention.
