# Shopify Order Import — How It Works

Orders arrive in Cydekick from Shopify via two separate entry points. Both use the
same shared service so the mapping logic only ever lives in one place.

---

## Entry Points

### 1. Import Orders button (manual sync)
**File:** `plugins/webkul/channels/src/Jobs/SyncShopifyOrders.php`

Triggered when the user presses **Import Orders** in the Channels UI. Runs as a
queued job. Fetches new orders from the Shopify REST API using a `since_id` cursor
(stored in cache) so only orders newer than the last run are fetched.

The job also runs two reconciliation passes after importing:
- **Inline cancellation** — any order in the current fetch batch that Shopify reports
  as cancelled is marked cancelled using Shopify's actual `cancelled_at` date.
- **Full reconciliation** — all orders in Cydekick with `status = open` for this
  channel are checked against Shopify in batches of 250. This catches voided/cancelled
  orders that are older than the `since_id` cursor and would never appear in a normal
  sync response.

### 2. Shopify webhook (real-time)
**File:** `plugins/webkul/channel-orders/src/Http/Controllers/Webhook/ShopifyOrdersController.php`

Shopify calls this endpoint immediately when a new order is placed. HMAC signature is
verified using the channel's webhook secret. The order is created synchronously so
Shopify gets a `201` response within its timeout window.

A separate webhook handles cancellations:
`plugins/webkul/channel-orders/src/Http/Controllers/Webhook/ShopifyCancellationsController.php`

---

## Shared Service (single source of truth)

**File:** `plugins/webkul/channels/src/Services/ShopifyOrderImporter.php`

Both entry points call `ShopifyOrderImporter::createFromPayload(Channel, array, ?client)`.

**Any change to how a Shopify order maps to a Cydekick order must be made here.**
The sync job and webhook controller are just thin callers — they contain no mapping
logic themselves.

### What the service does

| Step | Detail |
|------|--------|
| Partner lookup | Matches on email. Creates a new Partner with billing/delivery addresses if not found. |
| Payment status | Maps Shopify `financial_status` → Cydekick `payment_status`: `paid` → `paid`, `authorized` → `authorized`, anything else → `unpaid`. |
| Auth expiry | For `authorized` orders, calls the Shopify Transactions API to get `authorisation_expires_at`. Requires `$client` to be passed — without it the field stays null permanently (the sync job does not re-visit already-imported orders). |
| VAT-inclusive pricing | When `taxes_included = true`, Shopify prices include VAT. The service strips the VAT out (`price / (1 + rate/100)`) before storing so `OrderLineObserver` can apply tax correctly on top of the ex-VAT base. |
| Fulfilment | If `fulfillment_status = fulfilled`, the order is created as `processed`, tracking number/carrier are pulled from `fulfillments[0]`, and `processed_at` is set to the fulfilment date. |
| Shipping carrier | Uses `fulfillments[0].tracking_company` for fulfilled orders, falls back to `shipping_lines[0].title` for unfulfilled ones. |
| Timestamps | `created_at` / `updated_at` are backdated to Shopify's `created_at` using `withoutEvents` / `forceFill` to avoid triggering observers. |
| Order lines | Each line item is mapped with VAT stripping applied. Unlinked SKUs (no entry in `channels_sku_mappings`) get `[UNLINKED]` prefixed to their name and a description explaining the missing mapping. |

### $client parameter

```php
// Sync job — passes the Shopify API client so auth expiry is fetched
$importer->createFromPayload($channel, $shopifyOrder, $client);

// Webhook — also passes the client (already instantiated for HMAC check)
$importer->createFromPayload($channel, $payload, $client);
```

---

## Supporting Files

### Shopify API client
**File:** `plugins/webkul/channels/src/Services/ShopifyClient.php`

Key methods used by the importer:
- `getOrders(?string $sinceId)` — paginated order fetch
- `getOrdersByIds(array $ids)` — batch status check for reconciliation (used with `?ids=...&status=any`)
- `getOrderTransactions(string $orderId)` — fetches transactions to get `authorization_expires_at`

### Order model
**File:** `plugins/webkul/channel-orders/src/Models/Order.php`

Notable fields:
- `payment_status` — cast to `PaymentStatus` enum (`paid`, `authorized`, `unpaid`)
- `authorisation_expires_at` — nullable datetime, populated for authorized payments
- `status` — `open`, `processed`, `cancelled`

### PaymentStatus enum
**File:** `plugins/webkul/channel-orders/src/Enums/PaymentStatus.php`

Defines the three statuses and their display colours:
- `paid` → green
- `authorized` → orange (warning)
- `unpaid` → yellow (warning)

### Order sync UI / channels view
**File:** `plugins/webkul/channels/resources/views/filament/pages/order-sync.blade.php`

The Channels list page. Cancelled rows are struck-through with a grey **Void** pill.
Authorized rows show an orange **Authorized** pill plus the expiry date.

---

## Data Flow Diagram

```
Shopify
  │
  ├─── orders/create webhook ──► ShopifyOrdersController
  │                                       │
  │                                       ▼
  └─── Import Orders button ──► SyncShopifyOrders (job)
                                          │
                                          ▼
                               ShopifyOrderImporter::createFromPayload()
                                          │
                               ┌──────────┴──────────┐
                               ▼                     ▼
                         Order + OrderLines      Partner (find or create)
```

---

## Common Gotchas

**Adding a new Shopify field to orders?**
Edit `ShopifyOrderImporter::createFromPayload()` only. Do not add mapping logic to
the sync job or webhook controller.

**VAT is wrong on imported orders?**
Check that `taxes_included` is being read from the payload. The fix is in the service
(divides `price / (1 + rate/100)` when true). `OrderLineObserver` re-applies tax on
top of unit_price, so unit_price must always be stored ex-VAT.

**Auth expiry not showing?**
The `$client` must be passed to `createFromPayload`. If null, the Transactions API
call is skipped and `authorisation_expires_at` stays null.

**Old orders not cancelling?**
The `since_id` cursor means the main sync loop never sees orders older than the last
import. The full reconciliation pass at the end of `SyncShopifyOrders::handle()` covers
this — it checks all `open` orders for the channel against Shopify.

**Webhook not receiving orders?**
Check that the Shopify webhook is registered pointing to:
`/webhook/shopify/orders` (see `z_notes/webhooks.rmd` for setup steps).
The controller verifies the HMAC using `$channel->driver()->verifyWebhookHmac()`.
