Просмотр исходного кода

Add order import, hierarchical category dropdowns, and handmade/UPC fields

- Admin: Import Orders from Reverb button (AJAX) pulls paginated orders into OC,
  deduplicates via oc_reverb_order_map, decrements stock, logs each import
- Admin: Reverb category dropdowns now use <optgroup> grouped by root category
  (parses full_name field from /categories/flat)
- Admin: added handmade checkbox and UPC/EAN radio buttons to per-product tab
- Catalog: order deduplication now uses oc_reverb_order_map (not comment search);
  pollOrders handles pagination and updated_start_date
- ProductMapper: title and description HTML-decoded; manufacturer mapped to make field
- ReverbApi: corrected orders endpoint to /my/orders/selling/all
- DB: oc_reverb_order_map table; handmade + upc_does_not_apply columns on product map
- Docs: CLAUDE.md and README.md updated to reflect current implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 1 неделя назад
Родитель
Сommit
ce675a918a

+ 82 - 50
CLAUDE.md

@@ -28,12 +28,17 @@ The extension must provide an admin panel (`Admin > Extensions > Modules > Rever
 - Select sync direction: One-way (OpenCart → Reverb) or Both-ways (bidirectional)
 - Select which OpenCart categories are eligible for sync
 - Manually trigger a full catalogue sync
+- Manually trigger an order import from Reverb
 
 ### Product Page Integration
 
 Each product in the OpenCart admin must show a toggle/checkbox to:
 
 - Enable or disable syncing that individual product to Reverb
+- Set the item condition (Mint, Excellent, Good, etc.)
+- Set a per-product Reverb category override (or leave blank to use the category mapping default)
+- Mark the item as handmade
+- Declare whether the item has a UPC/EAN or is exempt
 - Show the Reverb listing ID and a direct link once the product is listed
 
 ### Sync Scope (Bidirectional)
@@ -54,17 +59,18 @@ Each product in the OpenCart admin must show a toggle/checkbox to:
 ```
 upload/
 ├── admin/
-│   ├── controller/extension/module/reverb/
-│   │   └── reverb.php              # Admin settings + manual sync trigger
-│   ├── language/en-gb/extension/module/reverb/
+│   ├── controller/extension/module/
+│   │   └── reverb.php              # Admin settings, manual sync, order import, AJAX endpoints
+│   ├── language/en-gb/extension/module/
 │   │   └── reverb.php              # All user-facing strings
-│   ├── model/extension/module/reverb/
-│   │   └── reverb.php              # DB interactions (token storage, product map)
-│   └── view/template/extension/module/reverb/
-│       └── reverb.twig             # Admin settings form
+│   ├── model/extension/module/
+│   │   └── reverb.php              # DB interactions, sync logic, order import
+│   └── view/template/extension/module/
+│       ├── reverb.twig             # Admin settings form (tabs: Settings, Category Mapping, Sync Log)
+│       └── reverb_product.twig    # Per-product Reverb tab (loaded via AJAX)
 ├── catalog/
-│   └── controller/extension/module/reverb/
-│       └── reverb.php              # Webhook endpoint for Reverb callbacks
+│   └── controller/extension/module/
+│       └── reverb.php              # Webhook receiver + cron polling endpoint
 └── system/
     └── library/reverb/
         ├── ReverbApi.php           # HTTP client wrapping the Reverb REST API
@@ -72,9 +78,9 @@ upload/
         └── OrderMapper.php         # Maps Reverb order payload → OC order format
 ```
 
-OCMOD patch file (`install.xml`) injects the per-product toggle into the product edit page without modifying core files.
+OCMOD patch file (`install.xml`) injects the per-product Reverb tab into the product edit page without modifying core files.
 
-The upload folder and install.xml is zipped into a file called  reverb.ocmod.zip for installing via the extension installer in opencart admin backend
+The `upload/` folder and `install.xml` are zipped into `reverb.ocmod.zip` for installation via Extensions > Installer in the OpenCart admin backend.
 
 ### Database Tables
 
@@ -82,12 +88,25 @@ The extension creates and manages the following tables.
 
 **`oc_reverb_product_map`**
 
-| Column             | Type         | Notes                              |
-|--------------------|--------------|------------------------------------|
-| `product_id`       | INT          | FK to `oc_product`                 |
-| `reverb_listing_id`| VARCHAR(64)  | Reverb's listing ID once synced    |
-| `sync_enabled`     | TINYINT(1)   | Per-product on/off toggle          |
-| `last_synced_at`   | DATETIME     | Timestamp of last successful sync  |
+| Column                 | Type         | Notes                                        |
+|------------------------|--------------|----------------------------------------------|
+| `product_id`           | INT          | FK to `oc_product`                           |
+| `reverb_listing_id`    | VARCHAR(64)  | Reverb's listing ID once synced              |
+| `sync_enabled`         | TINYINT(1)   | Per-product on/off toggle (default 0)        |
+| `condition_uuid`       | VARCHAR(64)  | Reverb condition UUID                        |
+| `reverb_category_uuid` | VARCHAR(64)  | Reverb category UUID (overrides mapping)     |
+| `handmade`             | TINYINT(1)   | Whether the item is handmade (default 0)     |
+| `upc_does_not_apply`   | TINYINT(1)   | Whether item is UPC/EAN exempt (default 1)   |
+| `last_synced_at`       | DATETIME     | Timestamp of last successful sync            |
+
+**`oc_reverb_order_map`**
+
+| Column                | Type         | Notes                                        |
+|-----------------------|--------------|----------------------------------------------|
+| `reverb_order_number` | VARCHAR(64)  | Primary key; Reverb order number             |
+| `order_id`            | INT          | Corresponding OC order ID                    |
+| `reverb_status`       | VARCHAR(32)  | Reverb order status at time of import        |
+| `created_at`          | DATETIME     | When the order was imported                  |
 
 **`oc_reverb_sync_log`**
 
@@ -108,7 +127,10 @@ API token, sync mode, and category whitelist are stored in OpenCart's native `oc
 2. **Per-product toggle:** Stored in `oc_reverb_product_map.sync_enabled`; defaults to `0` (disabled) for all products.
 3. **Category filter:** Admin selects OpenCart categories; only products in those categories AND with `sync_enabled = 1` are synced.
 4. **Webhooks vs polling:** Use Reverb webhooks for real-time order/listing updates; fall back to a scheduled cURL ping (OpenCart cron or system cron calling `catalog/controller/extension/module/reverb/cron`) for stock/price polling.
-5. **Image sync:** Upload OpenCart product images to Reverb via `POST /listings/{id}/photos`. Only push images OC → Reverb; do not pull images back.
+5. **Image sync:** Product images are submitted as a flat array of absolute URL strings inside the `PUT /listings/{id}` payload (`{"photos": ["https://..."], "publish": true}`). There is no separate photo upload endpoint in Reverb API v3. Images are pushed OC → Reverb only; never pulled back. The store URL is read from `config_url` with fallback to `HTTPS_CATALOG` / `HTTP_CATALOG` constants from `admin/config.php`.
+6. **Order deduplication:** Imported orders are tracked in `oc_reverb_order_map` keyed by `reverb_order_number`. Both the admin import action and the catalog webhook handler check this table before creating a new OC order.
+7. **Category hierarchy:** Reverb categories are fetched from `/categories/flat` and cached in `oc_setting` for 24 hours. The `full_name` field (e.g. `"Guitars > Electric Guitars"`) is parsed to group categories by root name for `<optgroup>` rendering in dropdowns.
+8. **String encoding:** OC stores product names and descriptions with HTML entities and tags. `ProductMapper::toReverb()` applies `html_entity_decode()` to the title and converts HTML markup to plain text for the description before sending to Reverb.
 
 ## Reverb API Essentials
 
@@ -130,55 +152,64 @@ Key endpoints:
 | --- | --- | --- |
 | `/my/listings` | GET | List all existing seller listings |
 | `/listings` | POST | Create a new listing |
-| `/listings/{id}` | PUT | Update an existing listing |
+| `/listings/{id}` | PUT | Update listing (also accepts `photos` URL array and `publish: true`) |
 | `/listings/{id}/end` | PUT | End/delist a listing |
-| `/listings/{id}/photos` | POST | Upload a photo to a listing |
-| `/my/orders` | GET | Fetch seller orders |
-| `/my/orders/{order_number}` | GET | Fetch a single order |
+| `/my/orders/selling/all` | GET | Fetch all seller orders (supports `updated_start_date`, `page`, `per_page`) |
+| `/my/orders/selling/{order_number}` | GET | Fetch a single order |
 | `/webhooks` | POST | Register a webhook endpoint |
-| `/categories/flat` | GET | Get the full Reverb category tree |
+| `/categories/flat` | GET | Get the full Reverb category tree (each entry has `uuid`, `full_name`, `name`) |
+| `/listing_conditions` | GET | Get available item conditions |
 
+When creating API token in Reverb these are the available scopes:
 
-When creating API in reverb these are the options available.
-
-Scopes allow you to set the permissions of your token.
+| Scope | Permission |
 | --- | --- |
 | public | Read publicly available data |
-| read_feedback | Read feedback that you have sent or received |
-| read_payouts |  |
-| write_feedback | Write feedback about your transactions on the site |
-| read_listings | Read all of your listings with your sales and bump data |
-| write_listings | Create/update your listings (inventory, price, etc) and add a listing to sales / bump |
-| read_lists | Read your watch list / feed |
-| write_lists | Update your watch list / feed |
-| read_messages | Retrieve your messages |
-| write_messages | Post and update messages as you |
-| read_offers | Read your offers |
-| write_offers | Make offers on listings on your behalf |
+| read_listings | Read all of your listings with sales and bump data |
+| write_listings | Create/update listings (inventory, price, etc) |
 | read_orders | Read all your orders |
 | write_orders | Update the status of your orders |
-| read_profile | Get the details about your account and shop, such as name and email address |
-| write_profile | Update settings for your shop (name, address, vacation, etc) |
-| read_reviews | Read your reviews of listings |
-| write_reviews | Write reviews of listings on your behalf |
-| read_addresses | write_addresses |
-
+| read_profile | Get account and shop details |
+| write_profile | Update shop settings |
+| read_feedback | Read feedback sent or received |
+| write_feedback | Write feedback about transactions |
+| read_messages | Retrieve messages |
+| write_messages | Post and update messages |
+| read_offers | Read offers |
+| write_offers | Make offers on listings |
+| read_reviews | Read listing reviews |
+| write_reviews | Write listing reviews |
+| read_lists | Read watch list / feed |
+| write_lists | Update watch list / feed |
+| read_payouts | Read payout data |
+| read_addresses / write_addresses | Read / write addresses |
 
 ## Sync Flow
 
 ### OpenCart → Reverb (triggered on product save event or manual sync)
 
 1. Check product has `sync_enabled = 1` AND is in an allowed category.
-2. Map OC product fields to a Reverb listing payload via `ProductMapper::toReverb()`.
-3. If `reverb_listing_id` exists in the map → `PUT /listings/{id}`; otherwise → `POST /listings` and store the returned ID.
-4. Upload any new/changed images via `POST /listings/{id}/photos`.
+2. Map OC product fields to a Reverb listing payload via `ProductMapper::toReverb()`:
+   - Title: `html_entity_decode()` applied
+   - Description: br/p tags → newlines, `strip_tags()`, `html_entity_decode()`
+   - Includes `make` (manufacturer), `handmade`, `upc_does_not_apply`, condition, category, shipping rates
+3. Collect product images; build absolute URLs using store URL. Append `photos` array and `publish: true` to the payload.
+4. If `reverb_listing_id` exists → `PUT /listings/{id}`; otherwise → `POST /listings` and store the returned ID.
 5. Update `last_synced_at` and log result in `oc_reverb_sync_log`.
 
-### Reverb → OpenCart (webhook or scheduled poll)
+### Reverb → OpenCart: Order Import (admin manual trigger or cron)
+
+1. Admin clicks "Import Orders from Reverb" → `extension/module/reverb/importOrders` (AJAX).
+2. Model calls `importOrdersFromReverb()`: paginates `GET /my/orders/selling/all` with optional `updated_start_date` from last import.
+3. Skips already-imported orders (check `oc_reverb_order_map`) and non-actionable statuses (cancelled, unpaid, etc.).
+4. Directly inserts into `oc_order`, `oc_order_product`, `oc_order_total`, `oc_order_history`. Decrements OC product stock.
+5. Saves to `oc_reverb_order_map`. Updates `module_reverb_order_last_sync` setting.
+
+### Reverb → OpenCart: Webhooks / Cron
 
-1. Receive webhook POST to `catalog/controller/extension/module/reverb/webhook` (or poll `/my/listings` + `/my/orders`).
-2. For listing updates: look up OC product by `reverb_listing_id`, update price / stock / description via model.
-3. For new orders: map Reverb order payload to OC order via `OrderMapper::toOpenCart()`, create via `$this->model_checkout_order->addOrder()`.
+1. Receive webhook POST to `catalog/controller/extension/module/reverb/webhook` (or cron polls listings + orders).
+2. For `listing/update`: look up OC product by `reverb_listing_id`, update price/stock/description.
+3. For `order/create`: check `oc_reverb_order_map` for duplicates, map via `OrderMapper::toOpenCart()`, create via `model_checkout_order->addOrder()`, save to `oc_reverb_order_map`.
 4. Log result.
 
 ## OpenCart 4.x Compatibility Notes
@@ -195,4 +226,5 @@ Scopes allow you to set the permissions of your token.
 - All user-visible strings must be defined in the language file, not hardcoded in controllers or templates
 - Wrap all Reverb API calls in try/catch; log errors to `oc_reverb_sync_log` rather than surfacing raw exceptions to the UI
 - The OCMOD XML file is required for injecting the per-product toggle without modifying core OpenCart files
+- Schema migrations run via `migrate()` called from `saveProductMap()` and `getProductMap()` — uses `information_schema` to add columns only if missing, safe to run repeatedly
 - Test against a Reverb sandbox account before connecting a live store

+ 48 - 35
README.md

@@ -7,12 +7,12 @@ An OpenCart 3.x extension that synchronises your product catalogue with your [Re
 ## Features
 
 - **Bidirectional sync** — push products from OpenCart to Reverb and pull updates back
-- **Order import** — Reverb orders are automatically created as OpenCart orders
+- **Order import** — Reverb orders are imported as OpenCart orders (manual trigger or cron)
 - **Selective sync** — choose which categories are eligible, with a per-product on/off toggle
-- **Image sync** — product images are uploaded to Reverb listings automatically
+- **Image sync** — product images are included in Reverb listings automatically
 - **Price & stock sync** — changes on either platform propagate to the other
-- **Category mapping** — map each OpenCart category to the correct Reverb category
-- **Per-product condition** — set the Reverb condition (Mint, Excellent, Good, etc.) per product
+- **Category mapping** — map each OpenCart category to the correct Reverb category, with grouped dropdowns organised by root category
+- **Per-product settings** — set condition (Mint, Excellent, Good, etc.), category override, handmade flag, and UPC/EAN exemption per product
 - **Webhook support** — real-time updates from Reverb via webhook endpoint
 - **Cron fallback** — scheduled polling when webhooks are not available
 - **Sync log** — full activity log visible in the admin panel
@@ -21,12 +21,12 @@ An OpenCart 3.x extension that synchronises your product catalogue with your [Re
 
 ## Requirements
 
-| Requirement | Version |
-| --- | --- |
-| OpenCart | 3.x (tested on 3.0.3.x) |
-| PHP | 7.4 or higher (8.1+ recommended) |
-| cURL | Enabled |
-| Reverb Account | Seller account with API access |
+| Requirement     | Version                          |
+|-----------------|----------------------------------|
+| OpenCart        | 3.x (tested on 3.0.3.x)          |
+| PHP             | 7.4 or higher (8.1+ preferred)   |
+| cURL            | Enabled                          |
+| Reverb Account  | Seller account with API access   |
 
 ---
 
@@ -48,17 +48,17 @@ upload/
         ├── ReverbApi.php
         ├── ProductMapper.php
         └── OrderMapper.php
-reverb.ocmod.xml
+install.xml
+reverb.ocmod.zip
 ```
 
 ---
 
 ## Installation
 
-1. Copy the contents of the `upload/` folder into your OpenCart root directory.
-2. In your OpenCart admin, go to **Extensions > Installer** and upload `reverb.ocmod.xml`.
-3. Go to **Extensions > Modifications** and click **Refresh**.
-4. Go to **Extensions > Extensions**, select **Modules**, find **Reverb Integration**, click **Install**, then **Edit**.
+1. In your OpenCart admin, go to **Extensions > Installer** and upload `reverb.ocmod.zip`.
+2. Go to **Extensions > Modifications** and click **Refresh**.
+3. Go to **Extensions > Extensions**, select **Modules**, find **Reverb Integration**, click **Install**, then **Edit**.
 
 ---
 
@@ -67,15 +67,15 @@ reverb.ocmod.xml
 ### 1. API Token
 
 1. Log in to [reverb.com/au](https://reverb.com/au) and go to **Account Settings > API Keys**.
-2. Generate a personal access token.
+2. Generate a personal access token with the scopes: `read_listings`, `write_listings`, `read_orders`.
 3. Paste it into the **API Token** field in the Reverb module settings and save.
 
 ### 2. Sync Direction
 
-| Option | Behaviour |
-| --- | --- |
-| One-way (OpenCart  Reverb) | Products are pushed to Reverb only; changes on Reverb are ignored |
-| Both ways | Changes on either platform sync to the other; Reverb orders are imported |
+| Option                       | Behaviour                                                                     |
+|------------------------------|-------------------------------------------------------------------------------|
+| One-way (OpenCart -> Reverb) | Products are pushed to Reverb only; changes on Reverb are ignored             |
+| Both ways                    | Changes on either platform sync to the other; Reverb orders are also imported |
 
 ### 3. Shipping Rates
 
@@ -83,24 +83,36 @@ Set a flat AU domestic rate and an optional international rate. These are applie
 
 ### 4. Category Mapping
 
-Select which OpenCart categories are eligible for sync in the **Settings** tab. Then switch to the **Category Mapping** tab and map each selected category to the corresponding Reverb category.
+Select which OpenCart categories are eligible for sync in the **Settings** tab. Then switch to the **Category Mapping** tab and map each selected category to the corresponding Reverb category. Categories are grouped by root (e.g. Guitars, Amps, Effects) for easy navigation.
 
-### 5. Per-Product Toggle
+### 5. Per-Product Settings
 
-On each product's edit page a **Reverb** tab appears. Enable **List on Reverb**, choose the item condition, and optionally override the Reverb category for that product. Once synced, the Reverb listing ID and a direct link appear here.
+On each product's edit page a **Reverb** tab appears. Options:
+
+- **List on Reverb** — enable/disable sync for this product
+- **Condition** — required by Reverb (Mint, Excellent, Good, etc.)
+- **Reverb Category** — override the category mapping for this product (optional)
+- **Handmade** — check if the item is handmade or custom-built
+- **UPC / EAN** — declare whether the item has a barcode or is exempt
+
+Once synced, the Reverb listing ID and a direct link to the listing appear on this tab.
+
+### 6. Order Import
+
+Click **Import Orders from Reverb** on the Settings page to pull new Reverb orders into OpenCart. Each order is recorded in `oc_reverb_order_map` to prevent duplicates. Subsequent imports only fetch orders updated since the last run.
 
 ---
 
 ## Sync Behaviour
 
-| Field | OpenCart → Reverb | Reverb → OpenCart |
-| --- | :---: | :---: |
-| Name / Title | ✓ | ✓ |
-| Description | ✓ | ✓ |
-| Price | ✓ | ✓ |
-| Stock / Quantity | ✓ | ✓ |
-| Images | ✓ | — |
-| Orders | — | ✓ |
+| Field          | OpenCart → Reverb | Reverb → OpenCart |
+|----------------|:-----------------:|:-----------------:|
+| Name / Title   | ✓                 | ✓                 |
+| Description    | ✓                 | ✓                 |
+| Price          | ✓                 | ✓                 |
+| Stock / Qty    | ✓                 | ✓                 |
+| Images         | ✓                 | —                 |
+| Orders         | —                 | ✓                 |
 
 ---
 
@@ -132,10 +144,11 @@ Set `module_reverb_cron_token` in `oc_setting` to match.
 
 ## Database Tables
 
-| Table | Purpose |
-| --- | --- |
-| `oc_reverb_product_map` | Links OC products to Reverb listing IDs; stores per-product sync settings |
-| `oc_reverb_sync_log` | Records every push/pull operation with status and error detail |
+| Table                    | Purpose                                                               |
+|--------------------------|-----------------------------------------------------------------------|
+| `oc_reverb_product_map`  | Links OC products to Reverb listing IDs; stores per-product settings  |
+| `oc_reverb_order_map`    | Tracks imported Reverb orders to prevent duplicates                   |
+| `oc_reverb_sync_log`     | Records every push/pull operation with status and error detail        |
 
 ---
 

+ 56 - 14
upload/admin/controller/extension/module/reverb.php

@@ -39,6 +39,7 @@ class ControllerExtensionModuleReverb extends Controller {
             'entry_shipping_domestic', 'help_shipping_domestic',
             'entry_shipping_international', 'help_shipping_international',
             'help_sync_categories', 'button_sync_now',
+            'text_order_import', 'button_import_orders',
             'text_category_mapping_help', 'text_no_categories',
             'column_oc_category', 'column_reverb_category',
             'column_date', 'column_product', 'column_direction', 'column_status', 'column_message',
@@ -79,8 +80,9 @@ class ControllerExtensionModuleReverb extends Controller {
         $data['categories'] = $this->getCategoryTree();
 
         // Category mappings for the mapping tab
-        $data['category_mappings']    = $this->model_extension_module_reverb->getCategoryMappings();
-        $data['reverb_categories']    = $this->model_extension_module_reverb->getReverbCategories();
+        $data['category_mappings']             = $this->model_extension_module_reverb->getCategoryMappings();
+        $data['reverb_categories']             = $this->model_extension_module_reverb->getReverbCategories();
+        $data['reverb_categories_grouped']     = $this->model_extension_module_reverb->getReverbCategoriesGrouped();
         $data['module_reverb_category_mappings'] = $data['category_mappings'];
 
         // Sync log
@@ -91,10 +93,11 @@ class ControllerExtensionModuleReverb extends Controller {
         $data['success']       = $this->session->data['success'] ?? '';
         unset($this->session->data['success']);
 
-        $data['action']     = $this->url->link('extension/module/reverb', 'user_token=' . $this->session->data['user_token'], true);
-        $data['cancel']     = $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=module', true);
-        $data['sync_url']   = $this->url->link('extension/module/reverb/sync', 'user_token=' . $this->session->data['user_token'], true);
-        $data['categories_url'] = $this->url->link('extension/module/reverb/reverbCategories', 'user_token=' . $this->session->data['user_token'], true);
+        $data['action']          = $this->url->link('extension/module/reverb', 'user_token=' . $this->session->data['user_token'], true);
+        $data['cancel']          = $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=module', true);
+        $data['sync_url']        = $this->url->link('extension/module/reverb/sync', 'user_token=' . $this->session->data['user_token'], true);
+        $data['import_url']      = $this->url->link('extension/module/reverb/importOrders', 'user_token=' . $this->session->data['user_token'], true);
+        $data['categories_url']  = $this->url->link('extension/module/reverb/reverbCategories', 'user_token=' . $this->session->data['user_token'], true);
 
         $data['header']      = $this->load->controller('common/header');
         $data['column_left'] = $this->load->controller('common/column_left');
@@ -226,6 +229,45 @@ class ControllerExtensionModuleReverb extends Controller {
         }
     }
 
+    // -------------------------------------------------------------------------
+    // Order import (AJAX)
+    // -------------------------------------------------------------------------
+
+    public function importOrders() {
+        $this->load->language('extension/module/reverb');
+        $this->load->model('extension/module/reverb');
+
+        $this->response->addHeader('Content-Type: application/json');
+
+        if (ob_get_level()) {
+            ob_clean();
+        }
+
+        try {
+            if (!$this->user->hasPermission('modify', 'extension/module/reverb')) {
+                $this->response->setOutput(json_encode(['success' => false, 'error' => $this->language->get('error_permission')]));
+                return;
+            }
+
+            $settings = $this->buildSettings();
+
+            if (empty($settings['api_token'])) {
+                $this->response->setOutput(json_encode(['success' => false, 'error' => $this->language->get('error_api_token')]));
+                return;
+            }
+
+            $result = $this->model_extension_module_reverb->importOrdersFromReverb($settings);
+
+            $this->response->setOutput(json_encode([
+                'success' => true,
+                'message' => sprintf($this->language->get('text_orders_imported'), $result['imported'], $result['skipped']),
+            ]));
+
+        } catch (Exception $e) {
+            $this->response->setOutput(json_encode(['success' => false, 'error' => $e->getMessage()]));
+        }
+    }
+
     private function safeLog($product_id, $direction, $status, $message) {
         try {
             $this->model_extension_module_reverb->log($product_id, $direction, $status, $message);
@@ -252,14 +294,14 @@ class ControllerExtensionModuleReverb extends Controller {
         $reverb_row = $this->model_extension_module_reverb->getProductMap($product_id);
 
         $data = [
-            'reverb_sync_enabled'      => $reverb_row ? (int)$reverb_row['sync_enabled']      : 0,
-            'reverb_condition_uuid'    => $reverb_row ? $reverb_row['condition_uuid']          : '',
-            'reverb_category_uuid'     => $reverb_row ? $reverb_row['reverb_category_uuid']   : '',
-            'reverb_listing_id'        => $reverb_row ? $reverb_row['reverb_listing_id']       : '',
-            'reverb_handmade'          => $reverb_row ? (int)$reverb_row['handmade']           : 0,
-            'reverb_upc_does_not_apply'=> $reverb_row ? (int)$reverb_row['upc_does_not_apply'] : 1,
-            'reverb_conditions'        => $this->model_extension_module_reverb->getListingConditions(),
-            'reverb_categories'        => $this->model_extension_module_reverb->getReverbCategories(),
+            'reverb_sync_enabled'       => $reverb_row ? (int)$reverb_row['sync_enabled']      : 0,
+            'reverb_condition_uuid'     => $reverb_row ? $reverb_row['condition_uuid']          : '',
+            'reverb_category_uuid'      => $reverb_row ? $reverb_row['reverb_category_uuid']   : '',
+            'reverb_listing_id'         => $reverb_row ? $reverb_row['reverb_listing_id']       : '',
+            'reverb_handmade'           => $reverb_row ? (int)$reverb_row['handmade']           : 0,
+            'reverb_upc_does_not_apply' => $reverb_row ? (int)$reverb_row['upc_does_not_apply'] : 1,
+            'reverb_conditions'         => $this->model_extension_module_reverb->getListingConditions(),
+            'reverb_categories_grouped' => $this->model_extension_module_reverb->getReverbCategoriesGrouped(),
         ];
 
         $this->response->setOutput($this->load->view('extension/module/reverb_product', $data));

+ 5 - 0
upload/admin/language/en-gb/extension/module/reverb.php

@@ -60,6 +60,11 @@ $_['text_reverb_listing_id']     = 'Reverb Listing';
 $_['text_view_on_reverb']        = 'View on Reverb';
 $_['text_not_synced']            = 'Not yet synced';
 
+// Order import
+$_['text_order_import']          = 'Order Import';
+$_['button_import_orders']       = 'Import Orders from Reverb';
+$_['text_orders_imported']       = 'Import complete. %d order(s) imported, %d skipped.';
+
 // Success / error messages
 $_['text_success']               = 'Settings saved successfully.';
 $_['text_sync_complete']         = 'Sync complete. %d product(s) pushed, %d error(s).';

+ 278 - 0
upload/admin/model/extension/module/reverb.php

@@ -21,6 +21,16 @@ class ModelExtensionModuleReverb extends Model {
         ");
         $this->migrate();
 
+        $this->db->query("
+            CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "reverb_order_map` (
+                `reverb_order_number` VARCHAR(64) NOT NULL,
+                `order_id`            INT(11) NOT NULL DEFAULT 0,
+                `reverb_status`       VARCHAR(32) NOT NULL DEFAULT '',
+                `created_at`          DATETIME NOT NULL,
+                PRIMARY KEY (`reverb_order_number`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
+        ");
+
         $this->db->query("
             CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "reverb_sync_log` (
                 `log_id`     INT(11) NOT NULL AUTO_INCREMENT,
@@ -39,6 +49,7 @@ class ModelExtensionModuleReverb extends Model {
     public function uninstall() {
         $this->db->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "reverb_product_map`");
         $this->db->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "reverb_sync_log`");
+        $this->db->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "reverb_order_map`");
     }
 
     public function migrate() {
@@ -47,6 +58,16 @@ class ModelExtensionModuleReverb extends Model {
         $done = true;
         $this->addColumnIfMissing(DB_PREFIX . 'reverb_product_map', 'handmade',           'TINYINT(1) NOT NULL DEFAULT 0');
         $this->addColumnIfMissing(DB_PREFIX . 'reverb_product_map', 'upc_does_not_apply', 'TINYINT(1) NOT NULL DEFAULT 1');
+        // Create order map table for upgrades from earlier versions
+        $this->db->query("
+            CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "reverb_order_map` (
+                `reverb_order_number` VARCHAR(64) NOT NULL,
+                `order_id`            INT(11) NOT NULL DEFAULT 0,
+                `reverb_status`       VARCHAR(32) NOT NULL DEFAULT '',
+                `created_at`          DATETIME NOT NULL,
+                PRIMARY KEY (`reverb_order_number`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci
+        ");
     }
 
     private function addColumnIfMissing($table, $column, $definition) {
@@ -251,6 +272,27 @@ class ModelExtensionModuleReverb extends Model {
         }
     }
 
+    /**
+     * Return Reverb categories grouped by their root (top-level) category name.
+     * Parses the full_name field (e.g. "Guitars > Electric Guitars > Solidbody").
+     *
+     * @return array  ['Root Name' => [['uuid'=>..., 'full_name'=>..., 'name'=>...], ...], ...]
+     */
+    public function getReverbCategoriesGrouped() {
+        $flat    = $this->getReverbCategories();
+        $grouped = [];
+
+        foreach ($flat as $cat) {
+            $full = $cat['full_name'] ?? $cat['name'] ?? '';
+            $parts = array_map('trim', explode('>', $full));
+            $root  = $parts[0] ?: 'Other';
+            $grouped[$root][] = $cat;
+        }
+
+        ksort($grouped);
+        return $grouped;
+    }
+
     // -------------------------------------------------------------------------
     // Sync log
     // -------------------------------------------------------------------------
@@ -340,6 +382,242 @@ class ModelExtensionModuleReverb extends Model {
         return $listing_id;
     }
 
+    // -------------------------------------------------------------------------
+    // Order import (Reverb → OpenCart)
+    // -------------------------------------------------------------------------
+
+    public function importOrdersFromReverb(array $settings) {
+        require_once(DIR_SYSTEM . 'library/reverb/ReverbApi.php');
+        $this->migrate();
+
+        $api        = $this->getApi();
+        $last_sync  = $this->config->get('module_reverb_order_last_sync');
+        $imported   = 0;
+        $skipped    = 0;
+        $page       = 1;
+
+        do {
+            $response    = $api->getOrders($page, 50, $last_sync ?: null);
+            $orders      = $response['orders']      ?? [];
+            $total_pages = (int)($response['total_pages'] ?? 1);
+
+            foreach ($orders as $reverb_order) {
+                $order_number = $reverb_order['order_number'] ?? null;
+                if (!$order_number) { $skipped++; continue; }
+
+                // Skip already-imported orders
+                $existing = $this->db->query("
+                    SELECT order_id FROM `" . DB_PREFIX . "reverb_order_map`
+                    WHERE `reverb_order_number` = '" . $this->db->escape($order_number) . "'
+                ");
+                if ($existing->num_rows) { $skipped++; continue; }
+
+                // Skip cancelled/unpaid
+                $status = $reverb_order['status'] ?? '';
+                if (in_array($status, ['cancelled', 'blocked', 'unpaid', 'payment_pending', 'pending_review'])) {
+                    $skipped++; continue;
+                }
+
+                $order_id = $this->createOcOrderFromReverb($reverb_order, $settings);
+                if ($order_id) {
+                    $this->db->query("
+                        INSERT INTO `" . DB_PREFIX . "reverb_order_map`
+                        (`reverb_order_number`, `order_id`, `reverb_status`, `created_at`)
+                        VALUES (
+                            '" . $this->db->escape($order_number) . "',
+                            '" . (int)$order_id . "',
+                            '" . $this->db->escape($status) . "',
+                            NOW()
+                        )
+                    ");
+                    $this->log(0, 'pull', 'success', 'Imported Reverb order #' . $order_number . ' → OC order #' . $order_id);
+                    $imported++;
+                }
+            }
+            $page++;
+        } while ($page <= $total_pages);
+
+        // Save timestamp so next import only fetches newer orders
+        $this->load->model('setting/setting');
+        $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_order_last_sync', date('c'));
+
+        return ['imported' => $imported, 'skipped' => $skipped];
+    }
+
+    private function createOcOrderFromReverb(array $o, array $settings) {
+        // Buyer name
+        $buyer_name  = trim($o['buyer_name'] ?? 'Reverb Buyer');
+        $np          = explode(' ', $buyer_name, 2);
+        $firstname   = $np[0];
+        $lastname    = $np[1] ?? '';
+
+        // Shipping address
+        $addr        = $o['shipping_address'] ?? [];
+        $anp         = explode(' ', trim($addr['name'] ?? $buyer_name), 2);
+        $ship_first  = $anp[0];
+        $ship_last   = $anp[1] ?? '';
+
+        // Amounts
+        $product_amt  = (float)($o['amount_product']['amount']  ?? $o['amount_product']  ?? 0);
+        $shipping_amt = (float)($o['shipping']['amount']        ?? $o['shipping']        ?? 0);
+        $total_amt    = (float)($o['total']['amount']           ?? $o['total']           ?? ($product_amt + $shipping_amt));
+        $currency     = $o['total']['currency'] ?? $settings['currency'] ?? 'AUD';
+        $qty          = (int)($o['quantity'] ?? 1);
+
+        // OC order status mapping
+        $status_map  = ['paid' => 2, 'shipped' => 3, 'received' => 5, 'picked_up' => 5];
+        $status_id   = $status_map[$o['status'] ?? ''] ?? 1;
+
+        // Match OC product by Reverb listing ID or SKU
+        $listing_id  = (string)($o['listing_id'] ?? $o['product_id'] ?? '');
+        $sku         = $o['sku'] ?? '';
+        $title       = $o['title'] ?? 'Reverb Item';
+
+        $oc_product  = ($listing_id ? $this->findOcProductByListingId($listing_id) : null)
+                    ?: ($sku        ? $this->findOcProductBySku($sku)               : null);
+
+        $product_id    = $oc_product ? (int)$oc_product['product_id'] : 0;
+        $product_name  = $oc_product ? $oc_product['name']  : $title;
+        $product_model = $oc_product ? $oc_product['model'] : $sku;
+
+        // Currency ID from OC DB
+        $cq          = $this->db->query("SELECT currency_id FROM `" . DB_PREFIX . "currency` WHERE code = '" . $this->db->escape($currency) . "' LIMIT 1");
+        $currency_id = $cq->num_rows ? (int)$cq->row['currency_id'] : 1;
+
+        $date_added  = date('Y-m-d H:i:s', strtotime($o['paid_at'] ?? $o['created_at'] ?? 'now'));
+        $store_name  = $this->db->escape($this->config->get('config_name') ?? '');
+        $store_url   = $this->db->escape($settings['store_url'] ?? '');
+        $lang_id     = (int)$this->config->get('config_language_id');
+        $comment     = $this->db->escape('Reverb order #' . ($o['order_number'] ?? ''));
+
+        $this->db->query("
+            INSERT INTO `" . DB_PREFIX . "order` SET
+            `invoice_prefix`          = 'REV-',
+            `invoice_no`              = 0,
+            `store_id`                = 0,
+            `store_name`              = '$store_name',
+            `store_url`               = '$store_url',
+            `customer_id`             = 0,
+            `customer_group_id`       = 1,
+            `firstname`               = '" . $this->db->escape($firstname) . "',
+            `lastname`                = '" . $this->db->escape($lastname) . "',
+            `email`                   = '" . $this->db->escape($o['buyer_id'] ?? '') . "',
+            `telephone`               = '" . $this->db->escape($addr['phone'] ?? '') . "',
+            `fax`                     = '',
+            `custom_field`            = '{}',
+            `payment_firstname`       = '" . $this->db->escape($ship_first) . "',
+            `payment_lastname`        = '" . $this->db->escape($ship_last) . "',
+            `payment_company`         = '',
+            `payment_address_1`       = '" . $this->db->escape($addr['street_address'] ?? '') . "',
+            `payment_address_2`       = '" . $this->db->escape($addr['extended_address'] ?? '') . "',
+            `payment_city`            = '" . $this->db->escape($addr['locality'] ?? '') . "',
+            `payment_postcode`        = '" . $this->db->escape($addr['postal_code'] ?? '') . "',
+            `payment_country`         = '" . $this->db->escape($addr['country_code'] ?? '') . "',
+            `payment_country_id`      = 0,
+            `payment_zone`            = '" . $this->db->escape($addr['region'] ?? '') . "',
+            `payment_zone_id`         = 0,
+            `payment_address_format`  = '',
+            `payment_custom_field`    = '{}',
+            `payment_method`          = 'Reverb',
+            `payment_code`            = 'reverb',
+            `shipping_firstname`      = '" . $this->db->escape($ship_first) . "',
+            `shipping_lastname`       = '" . $this->db->escape($ship_last) . "',
+            `shipping_company`        = '',
+            `shipping_address_1`      = '" . $this->db->escape($addr['street_address'] ?? '') . "',
+            `shipping_address_2`      = '" . $this->db->escape($addr['extended_address'] ?? '') . "',
+            `shipping_city`           = '" . $this->db->escape($addr['locality'] ?? '') . "',
+            `shipping_postcode`       = '" . $this->db->escape($addr['postal_code'] ?? '') . "',
+            `shipping_country`        = '" . $this->db->escape($addr['country_code'] ?? '') . "',
+            `shipping_country_id`     = 0,
+            `shipping_zone`           = '" . $this->db->escape($addr['region'] ?? '') . "',
+            `shipping_zone_id`        = 0,
+            `shipping_address_format` = '',
+            `shipping_custom_field`   = '{}',
+            `shipping_method`         = 'Reverb',
+            `shipping_code`           = 'reverb.reverb',
+            `comment`                 = '$comment',
+            `total`                   = '" . number_format($total_amt, 4, '.', '') . "',
+            `order_status_id`         = $status_id,
+            `affiliate_id`            = 0,
+            `commission`              = '0.0000',
+            `marketing_id`            = 0,
+            `tracking`                = '',
+            `language_id`             = $lang_id,
+            `currency_id`             = $currency_id,
+            `currency_code`           = '" . $this->db->escape($currency) . "',
+            `currency_value`          = '1.00000000',
+            `ip`                      = '',
+            `forwarded_ip`            = '',
+            `user_agent`              = 'Reverb Import',
+            `accept_language`         = '',
+            `date_added`              = '" . $this->db->escape($date_added) . "',
+            `date_modified`           = NOW()
+        ");
+
+        $order_id = (int)$this->db->getLastId();
+        if (!$order_id) return null;
+
+        // Order product
+        $unit_price = $qty > 0 ? $product_amt / $qty : $product_amt;
+        $this->db->query("
+            INSERT INTO `" . DB_PREFIX . "order_product` SET
+            `order_id`   = $order_id,
+            `product_id` = $product_id,
+            `master_id`  = 0,
+            `name`       = '" . $this->db->escape($product_name) . "',
+            `model`      = '" . $this->db->escape($product_model) . "',
+            `quantity`   = $qty,
+            `price`      = '" . number_format($unit_price, 4, '.', '') . "',
+            `total`      = '" . number_format($product_amt, 4, '.', '') . "',
+            `tax`        = '0.0000',
+            `reward`     = 0
+        ");
+
+        // Totals
+        $this->db->query("INSERT INTO `" . DB_PREFIX . "order_total` SET `order_id`=$order_id, `code`='sub_total', `title`='Sub-Total', `value`='" . number_format($product_amt, 4, '.', '') . "', `sort_order`=1");
+        if ($shipping_amt > 0) {
+            $this->db->query("INSERT INTO `" . DB_PREFIX . "order_total` SET `order_id`=$order_id, `code`='shipping', `title`='Shipping', `value`='" . number_format($shipping_amt, 4, '.', '') . "', `sort_order`=3");
+        }
+        $this->db->query("INSERT INTO `" . DB_PREFIX . "order_total` SET `order_id`=$order_id, `code`='total', `title`='Total', `value`='" . number_format($total_amt, 4, '.', '') . "', `sort_order`=9");
+
+        // History
+        $this->db->query("INSERT INTO `" . DB_PREFIX . "order_history` SET `order_id`=$order_id, `order_status_id`=$status_id, `notify`=0, `comment`='Imported from Reverb', `date_added`=NOW()");
+
+        // Decrement OC stock
+        if ($product_id) {
+            $this->db->query("UPDATE `" . DB_PREFIX . "product` SET `quantity`=GREATEST(0,`quantity`-$qty) WHERE `product_id`=$product_id");
+        }
+
+        return $order_id;
+    }
+
+    private function findOcProductByListingId($listing_id) {
+        if (!$listing_id) return null;
+        $q = $this->db->query("
+            SELECT p.product_id, pd.name, p.model, p.price
+            FROM `" . DB_PREFIX . "reverb_product_map` r
+            JOIN `" . DB_PREFIX . "product` p ON p.product_id = r.product_id
+            JOIN `" . DB_PREFIX . "product_description` pd ON pd.product_id = p.product_id
+                AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
+            WHERE r.reverb_listing_id = '" . $this->db->escape($listing_id) . "'
+            LIMIT 1
+        ");
+        return $q->num_rows ? $q->row : null;
+    }
+
+    private function findOcProductBySku($sku) {
+        if (!$sku) return null;
+        $q = $this->db->query("
+            SELECT p.product_id, pd.name, p.model, p.price
+            FROM `" . DB_PREFIX . "product` p
+            JOIN `" . DB_PREFIX . "product_description` pd ON pd.product_id = p.product_id
+                AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
+            WHERE p.model = '" . $this->db->escape($sku) . "'
+            LIMIT 1
+        ");
+        return $q->num_rows ? $q->row : null;
+    }
+
     // -------------------------------------------------------------------------
     // Utility
     // -------------------------------------------------------------------------

+ 53 - 8
upload/admin/view/template/extension/module/reverb.twig

@@ -141,6 +141,17 @@
               </div>
             </div>
 
+            <h4>{{ text_order_import }}</h4>
+
+            <div class="form-group">
+              <div class="col-sm-offset-2 col-sm-10">
+                <button type="button" id="btn-import-orders" class="btn btn-info">
+                  <i class="fa fa-download"></i> {{ button_import_orders }}
+                </button>
+                <span id="import-result" class="help-block" style="display:none;"></span>
+              </div>
+            </div>
+
           </div><!-- #tab-settings -->
 
           <!-- ================================================================ -->
@@ -168,14 +179,18 @@
                       <select name="module_reverb_category_mappings[{{ category.category_id }}]"
                               class="form-control">
                         <option value="">-- Select Reverb Category --</option>
-                        {% for rc in reverb_categories %}
-                        <option value="{{ rc.uuid }}"
-                          {% if module_reverb_category_mappings[category.category_id] is defined
-                               and module_reverb_category_mappings[category.category_id] == rc.uuid %}
-                               selected
-                          {% endif %}>
-                          {{ rc.full_name|default(rc.name) }}
-                        </option>
+                        {% for group_name, group_cats in reverb_categories_grouped %}
+                        <optgroup label="{{ group_name }}">
+                          {% for rc in group_cats %}
+                          <option value="{{ rc.uuid }}"
+                            {% if module_reverb_category_mappings[category.category_id] is defined
+                                 and module_reverb_category_mappings[category.category_id] == rc.uuid %}
+                                 selected
+                            {% endif %}>
+                            {{ rc.full_name|default(rc.name) }}
+                          </option>
+                          {% endfor %}
+                        </optgroup>
                         {% endfor %}
                       </select>
                     </td>
@@ -262,6 +277,36 @@ $(function() {
             }
         });
     });
+
+    $('#btn-import-orders').on('click', function() {
+        var $btn    = $(this);
+        var $result = $('#import-result');
+
+        $btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Importing...');
+        $result.hide();
+
+        var _p = new URLSearchParams(window.location.search);
+        var _importUrl = 'index.php?route=extension/module/reverb/importOrders&user_token=' + encodeURIComponent(_p.get('user_token') || '');
+
+        $.ajax({
+            url: _importUrl,
+            type: 'GET',
+            dataType: 'json',
+            success: function(data) {
+                if (data.success) {
+                    $result.removeClass('text-danger').addClass('text-success').text(data.message).show();
+                } else {
+                    $result.removeClass('text-success').addClass('text-danger').text(data.error).show();
+                }
+            },
+            error: function() {
+                $result.removeClass('text-success').addClass('text-danger').text('Import request failed.').show();
+            },
+            complete: function() {
+                $btn.prop('disabled', false).html('<i class="fa fa-download"></i> {{ button_import_orders }}');
+            }
+        });
+    });
 });
 </script>
 

+ 9 - 5
upload/admin/view/template/extension/module/reverb_product.twig

@@ -40,11 +40,15 @@
       <div class="col-sm-6">
         <select name="reverb_category_uuid" class="form-control">
           <option value="">-- Use category mapping default --</option>
-          {% for rc in reverb_categories %}
-          <option value="{{ rc.uuid }}"
-            {% if reverb_category_uuid == rc.uuid %}selected="selected"{% endif %}>
-            {{ rc.full_name|default(rc.name) }}
-          </option>
+          {% for group_name, group_cats in reverb_categories_grouped %}
+          <optgroup label="{{ group_name }}">
+            {% for rc in group_cats %}
+            <option value="{{ rc.uuid }}"
+              {% if reverb_category_uuid == rc.uuid %}selected="selected"{% endif %}>
+              {{ rc.full_name|default(rc.name) }}
+            </option>
+            {% endfor %}
+          </optgroup>
           {% endfor %}
         </select>
         <p class="help-block">Leave blank to use the default mapping from the Reverb module settings.</p>

+ 42 - 17
upload/catalog/controller/extension/module/reverb.php

@@ -123,31 +123,36 @@ class ControllerExtensionModuleReverb extends Controller {
 
         $order_number = $reverb_order['order_number'];
 
-        // Check for duplicate — avoid creating the same order twice
+        // Check for duplicate via order map table
         $check = $this->db->query("
-            SELECT order_id FROM `" . DB_PREFIX . "order`
-            WHERE comment LIKE '%" . $this->db->escape('Reverb Order #' . $order_number) . "%'
+            SELECT order_id FROM `" . DB_PREFIX . "reverb_order_map`
+            WHERE `reverb_order_number` = '" . $this->db->escape($order_number) . "'
             LIMIT 1
         ");
         if ($check->num_rows) {
             return;
         }
 
-        // Find the matching OC product by SKU or listing_id
-        $listing_id = (string)($reverb_order['listing']['id'] ?? '');
+        // Skip non-actionable statuses
+        $status = $reverb_order['status'] ?? '';
+        if (in_array($status, ['cancelled', 'blocked', 'unpaid', 'payment_pending', 'pending_review'])) {
+            return;
+        }
+
+        // Find the matching OC product by listing_id or SKU
+        $listing_id = (string)($reverb_order['listing']['id'] ?? $reverb_order['listing_id'] ?? '');
         $oc_product = $this->findProductByListingId($listing_id);
 
         if (!$oc_product) {
-            $sku = $reverb_order['listing']['sku'] ?? '';
+            $sku = $reverb_order['listing']['sku'] ?? $reverb_order['sku'] ?? '';
             $oc_product = $sku ? $this->findProductBySku($sku) : null;
         }
 
         if (!$oc_product) {
-            // Create a placeholder product entry so the order can still be recorded
             $oc_product = [
                 'product_id' => 0,
-                'name'       => $reverb_order['listing']['title'] ?? 'Reverb Product',
-                'model'      => $reverb_order['listing']['sku'] ?? '',
+                'name'       => $reverb_order['listing']['title'] ?? $reverb_order['title'] ?? 'Reverb Product',
+                'model'      => $reverb_order['listing']['sku'] ?? $reverb_order['sku'] ?? '',
                 'price'      => $reverb_order['total']['amount'] ?? 0,
             ];
         }
@@ -170,7 +175,7 @@ class ControllerExtensionModuleReverb extends Controller {
         $order_id = $this->model_checkout_order->addOrder($order_data);
 
         // Decrease OC stock for the matched product
-        if ($oc_product['product_id']) {
+        if (!empty($oc_product['product_id'])) {
             $this->db->query("
                 UPDATE `" . DB_PREFIX . "product`
                 SET quantity = GREATEST(0, quantity - " . (int)($reverb_order['quantity'] ?? 1) . ")
@@ -178,8 +183,20 @@ class ControllerExtensionModuleReverb extends Controller {
             ");
         }
 
+        // Record in order map to prevent future duplicates
+        $this->db->query("
+            INSERT IGNORE INTO `" . DB_PREFIX . "reverb_order_map`
+            (`reverb_order_number`, `order_id`, `reverb_status`, `created_at`)
+            VALUES (
+                '" . $this->db->escape($order_number) . "',
+                '" . (int)$order_id . "',
+                '" . $this->db->escape($status) . "',
+                NOW()
+            )
+        ");
+
         $this->model_extension_module_reverb->log(
-            $oc_product['product_id'],
+            (int)$oc_product['product_id'],
             'pull',
             'success',
             'Created OC order #' . $order_id . ' from Reverb order #' . $order_number
@@ -214,13 +231,21 @@ class ControllerExtensionModuleReverb extends Controller {
             $token = $this->config->get('module_reverb_api_token');
             if (!$token) return;
 
-            $api      = new ReverbApi($token);
-            $response = $api->getOrders();
-            $orders   = $response['orders'] ?? [];
+            $api       = new ReverbApi($token);
+            $last_sync = $this->config->get('module_reverb_order_last_sync') ?: null;
+            $page      = 1;
+
+            do {
+                $response    = $api->getOrders($page, 50, $last_sync);
+                $orders      = $response['orders']      ?? [];
+                $total_pages = (int)($response['total_pages'] ?? 1);
+
+                foreach ($orders as $order) {
+                    $this->handleOrderCreate(['order' => $order]);
+                }
+                $page++;
+            } while ($page <= $total_pages);
 
-            foreach ($orders as $order) {
-                $this->handleOrderCreate(['order' => $order]);
-            }
         } catch (Exception $e) {
             $this->model_extension_module_reverb->log(0, 'pull', 'error', 'Poll orders failed: ' . $e->getMessage());
         }

+ 7 - 6
upload/system/library/reverb/ReverbApi.php

@@ -43,15 +43,16 @@ class ReverbApi {
     // Orders
     // -------------------------------------------------------------------------
 
-    public function getOrders($page = 1, $per_page = 50) {
-        return $this->request('GET', '/my/orders', [
-            'page'     => $page,
-            'per_page' => $per_page,
-        ]);
+    public function getOrders($page = 1, $per_page = 50, $updated_start_date = null) {
+        $params = ['page' => $page, 'per_page' => $per_page];
+        if ($updated_start_date) {
+            $params['updated_start_date'] = $updated_start_date;
+        }
+        return $this->request('GET', '/my/orders/selling/all', $params);
     }
 
     public function getOrder($order_number) {
-        return $this->request('GET', '/my/orders/' . urlencode($order_number));
+        return $this->request('GET', '/my/orders/selling/' . urlencode($order_number));
     }
 
     // -------------------------------------------------------------------------