From bae019137f9620026be2adaa2d15f131731af416 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 10:26:18 +0200 Subject: [PATCH 01/30] fix(woocommerce): expose response headers from inline WC client Adds getHeaders() / getHeader() accessors to the inline WCResponse class and captures HTTP response headers case-insensitively via CURLOPT_HEADERFUNCTION. Required foundation for pagination handling (Issue #262). --- www/pages/shopimporter_woocommerce.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 0d221f317..7480318f0 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -1271,13 +1271,26 @@ public function getCode() /** * Get headers. * - * @return array $headers WCResponse headers. + * @return array $headers WCResponse headers (keys normalized to lowercase). */ public function getHeaders() { return $this->headers; } + /** + * Get a single response header by name (case-insensitive). + * + * @param string $name Header name (e.g. 'x-wp-totalpages'). + * + * @return string|null Header value or null if not present. + */ + public function getHeader($name) + { + $key = strtolower($name); + return isset($this->headers[$key]) ? $this->headers[$key] : null; + } + /** * Get body. * @@ -2163,6 +2176,7 @@ protected function getResponseHeaders() list($key, $value) = explode(': ', $line); + $key = strtolower($key); $headers[$key] = isset($headers[$key]) ? $headers[$key] . ', ' . trim($value) : trim($value); } From 1907cdf07ee3eb51f88ae32f66cf4f663c3de9b9 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 10:31:27 +0200 Subject: [PATCH 02/30] fix(woocommerce): add getLastResponse() accessor on WC client Exposes the underlying WCResponse of the most recent request so callers can read response headers (X-WP-Total, X-WP-TotalPages) without changing the existing JSON-body return contract. Follow-up to 291197d, required by the pagination work in issue #262. --- www/pages/shopimporter_woocommerce.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 7480318f0..9908b83b0 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -1189,6 +1189,16 @@ public function options($endpoint) { return $this->http->request($endpoint, 'OPTIONS'); } + + /** + * Get the WCResponse from the most recent HTTP request. + * + * @return WCResponse|null + */ + public function getLastResponse() + { + return $this->http->getResponse(); + } } class WCResponse From dee30b17ce81a7c382b475e87e45553d3b196444 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 10:37:22 +0200 Subject: [PATCH 03/30] fix(woocommerce): persist last-import timestamp in shopexport config Reads felder.letzter_import_timestamp from shopexport.einstellungen_json with a 30-day fallback for first runs, and adds a persistLastImportTimestamp() helper that does a read-modify-write via DatabaseService named params. Infrastructure for the pagination loop in issue #262; not yet called here. --- www/pages/shopimporter_woocommerce.php | 41 ++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 9908b83b0..c68667771 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -60,6 +60,12 @@ class Shopimporter_Woocommerce extends ShopimporterBase /** @var Logger $logger */ public $logger; + /** @var bool $ssl_ignore Whether to ignore SSL certificate validation */ + public $ssl_ignore; + + /** @var string $lastImportTimestamp ISO-8601 UTC timestamp of the last successful import */ + public $lastImportTimestamp; + public function __construct($app, $intern = false) { $this->app = $app; @@ -891,6 +897,41 @@ public function getKonfig($shopid, $data) $this->ssl_ignore ); + $storedTimestamp = $preferences['felder']['letzter_import_timestamp'] ?? null; + if (!empty($storedTimestamp)) { + $this->lastImportTimestamp = $storedTimestamp; + } else { + $this->lastImportTimestamp = gmdate('Y-m-d\TH:i:s', strtotime('-30 days')); + } + + } + + /** + * Persists the last successful import timestamp to shopexport.einstellungen_json. + * Does a read-modify-write to preserve all other fields. + * + * @param string $isoUtcDate ISO-8601 UTC timestamp, e.g. '2026-04-20T12:34:56' + * @return void + */ + public function persistLastImportTimestamp($isoUtcDate) + { + $einstellungen_json = $this->app->DatabaseService->selectValue( + "SELECT einstellungen_json FROM shopexport WHERE id = :id LIMIT 1", + ['id' => (int)$this->shopid] + ); + $einstellungen = []; + if (!empty($einstellungen_json)) { + $einstellungen = json_decode($einstellungen_json, true) ?: []; + } + if (!isset($einstellungen['felder']) || !is_array($einstellungen['felder'])) { + $einstellungen['felder'] = []; + } + $einstellungen['felder']['letzter_import_timestamp'] = $isoUtcDate; + $this->app->DatabaseService->execute( + "UPDATE shopexport SET einstellungen_json = :json WHERE id = :id", + ['json' => json_encode($einstellungen), 'id' => (int)$this->shopid] + ); + $this->lastImportTimestamp = $isoUtcDate; } /** From ec9a6176b2dd730cde3f37e10c1716f65c7b0c0a Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 10:45:53 +0200 Subject: [PATCH 04/30] fix(woocommerce): use after-filter and pagination for order import Replaces the fake greater-than-id filter (800 hardcoded IDs) with the WC v3 after= parameter and walks X-WP-TotalPages up to MAX_PAGES_PER_RUN=5 pages per run (500 orders). Persists a progress timestamp via persistLastImportTimestamp() after each processed order so aborted runs resume cleanly. Adds a one-shot ab_nummer->timestamp translation for existing shops transitioning from the legacy cursor. Fixes silent data loss when more than 20 orders arrived between runs. Issue #262. --- www/pages/shopimporter_woocommerce.php | 216 ++++++++++++++----------- 1 file changed, 119 insertions(+), 97 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index c68667771..f2106fe56 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -66,6 +66,12 @@ class Shopimporter_Woocommerce extends ShopimporterBase /** @var string $lastImportTimestamp ISO-8601 UTC timestamp of the last successful import */ public $lastImportTimestamp; + /** @var bool $lastImportTimestampIsFallback True when lastImportTimestamp was computed as 30-day fallback */ + public $lastImportTimestampIsFallback = false; + + const MAX_PAGES_PER_RUN = 5; + const ORDERS_PER_PAGE = 100; + public function __construct($app, $intern = false) { $this->app = $app; @@ -81,135 +87,149 @@ public function ImportList() } /** - * This function returns the number of orders which have not yet been imported + * Returns the total number of orders pending import since the last import + * timestamp. Uses the WC v3 after= parameter and reads the count from + * the X-WP-Total response header (per_page=1 to minimise payload). + * + * @return int */ public function ImportGetAuftraegeAnzahl() { - // Query the API to get new orders, filtered by the order status as specifed by the user. - // We set per_page to 100 - this could lead to a situation where there are more than - // 100 new Orders, but we still only return 100. - - // Array containing additional settings, namely 'ab_nummer' (containting the next order number to get) - // and 'holeallestati' (an integer) - $tmp = $this->CatchRemoteCommand('data'); - - // Only orders having an order number greater or equal than this should be fetched. null otherwise - $number_from = empty($tmp['ab_nummer']) ? null : (int) $tmp['ab_nummer']; - - // pending orders will be fetched into this array. it's length is returned at the end of the funciton - $pendingOrders = array(); + $configuredStatuses = array_map('trim', explode(';', $this->statusPending)); - if ($number_from) { - // Number-based import is selected - - // The WooCommerce API doenst allow for a proper "greater than id n" request. - // we fake this behavior by creating an array that contains 'many' (~ 1000) consecutive - // ids that are greater than $from_number and use this array with the 'include' property - // of the WooCommerce API - - $number_to = $number_from + 800; - if (!empty($tmp['bis_nummer'])) { - $number_to = $tmp['bis_nummer']; - } - - $fakeGreaterThanIds = range($number_from, $number_to); - - $pendingOrders = $this->client->get('orders', [ - 'per_page' => 100, - 'include' => implode(",", $fakeGreaterThanIds), + try { + $this->client->get('orders', [ + 'status' => $configuredStatuses, + 'after' => $this->lastImportTimestamp, + 'per_page' => 1, ]); + } catch (Exception $e) { + $this->logger->warning('WooCommerce ImportGetAuftraegeAnzahl: API request failed: ' . $e->getMessage()); + return 0; + } - } else { - // fetch posts by status - - $pendingOrders = $this->client->get('orders', [ - 'status' => array_map('trim', explode(';', $this->statusPending)), - 'per_page' => 100 - ]); + $wcResponse = $this->client->getLastResponse(); + if ($wcResponse === null) { + $this->logger->warning('WooCommerce ImportGetAuftraegeAnzahl: getLastResponse() returned null'); + return 0; + } + $total = $wcResponse->getHeader('x-wp-total'); + if ($total === null) { + $this->logger->warning('WooCommerce ImportGetAuftraegeAnzahl: X-WP-Total header missing'); + return 0; } - return (!empty($pendingOrders) ? count($pendingOrders) : 0); + return (int) $total; } /** - * Calling this function queries the api for pending orders and returns them - * as an array. + * Queries the WooCommerce API for pending orders since the last import + * timestamp and returns them as a Xentral-formatted array. + * + * Uses the WC v3 after= filter and paginates up to MAX_PAGES_PER_RUN + * pages (500 orders max per run). Progress timestamp is persisted after + * each processed order so aborted runs resume cleanly. * - * TODO: Only one single order is returned per invocation of this function. - * Given that we have to perform an exteremly expensive external HTTP call - * every time we call this function and could easily process more than one - * order this seems very bad performance-wise. + * @return array|null */ public function ImportGetAuftrag() { - // Array containing additional settings, namely 'ab_nummer' (containting the next order number to get) - // and 'holeallestati' (an integer) - $tmp = $this->CatchRemoteCommand('data'); + $data = $this->CatchRemoteCommand('data'); - // Only orders having an order number greater or equal than this should be fetched. null otherwise - $number_from = empty($tmp['ab_nummer']) ? null : (int) $tmp['ab_nummer']; + // Transition: if timestamp is still a fallback and a legacy ab_nummer + // cursor is present, resolve it to a timestamp once. + if ($this->lastImportTimestampIsFallback && !empty($data['ab_nummer'])) { + $resolved = $this->resolveAbNummerToTimestamp((int) $data['ab_nummer']); + if ($resolved !== null) { + $this->persistLastImportTimestamp($resolved); + } + // lastImportTimestampIsFallback stays true only if resolution failed; + // persistLastImportTimestamp() updated $this->lastImportTimestamp already. + } - // pending orders will be fetched into this array. it's length is returned at the end of the funciton - $pendingOrders = array(); + $configuredStatuses = array_map('trim', explode(';', $this->statusPending)); + $page = 1; + $result = []; + + while ($page <= self::MAX_PAGES_PER_RUN) { + try { + $pageOrders = $this->client->get('orders', [ + 'status' => $configuredStatuses, + 'after' => $this->lastImportTimestamp, + 'per_page' => self::ORDERS_PER_PAGE, + 'page' => $page, + 'orderby' => 'date', + 'order' => 'asc', + ]); + } catch (Exception $e) { + $this->logger->warning('WooCommerce ImportGetAuftrag page=' . $page . ': ' . $e->getMessage()); + break; + } - if ($number_from) { - // Number-based import is selected + if (empty($pageOrders)) { + break; + } - // The WooCommerce API doenst allow for a proper "greater than id n" request. - // we fake this behavior by creating an array that contains 'many' (~ 1000) consecutive - // ids that are greater than $from_number and use this array with the 'include' property - // of the WooCommerce API + foreach ($pageOrders as $wcOrder) { + if (is_null($wcOrder)) { + continue; + } - $number_to = $number_from + 800; - if (!empty($tmp['bis_nummer'])) { - $number_to = $tmp['bis_nummer']; - } + $order = $this->parseOrder($wcOrder); - $fakeGreaterThanIds = range($number_from, $number_to); + $result[] = [ + 'id' => $order['auftrag'], + 'sessionid' => '', + 'logdatei' => '', + 'warenkorb' => base64_encode(serialize($order)), + ]; - $pendingOrders = $this->client->get('orders', [ - 'per_page' => 20, - 'include' => implode(',', $fakeGreaterThanIds), - 'order' => 'asc', - 'orderby' => 'id' - ]); + // Persist progress after every order so partial runs resume cleanly. + if (!empty($wcOrder->date_created_gmt)) { + $this->persistLastImportTimestamp((string) $wcOrder->date_created_gmt); + } + } - } else { - // fetch posts by status + $wcResponse = $this->client->getLastResponse(); + if ($wcResponse === null) { + break; + } - $pendingOrders = $this->client->get('orders', [ - 'status' => array_map('trim', explode(';', $this->statusPending)), - 'per_page' => 20, - 'order' => 'asc', - 'orderby' => 'id' - ]); + $totalPages = (int) $wcResponse->getHeader('x-wp-totalpages'); + if ($totalPages < 1 || $page >= $totalPages) { + break; + } + $page++; } - // Return an empty array in case there are no orders to import - if ((!empty($pendingOrders) ? count($pendingOrders) : 0) === 0) { + return !empty($result) ? $result : null; + } + + /** + * Resolves a legacy WooCommerce order ID (ab_nummer) to the order's + * date_created_gmt timestamp for the one-shot transition from cursor- + * based to timestamp-based import. + * + * @param int $abNummer WooCommerce order ID + * @return string|null ISO-8601 UTC timestamp or null on failure + */ + private function resolveAbNummerToTimestamp($abNummer) + { + try { + $order = $this->client->get('orders/' . $abNummer); + } catch (Exception $e) { + $this->logger->warning('WooCommerce resolveAbNummerToTimestamp(' . $abNummer . '): ' . $e->getMessage()); return null; } - $tmp = []; - - foreach ($pendingOrders as $pendingOrder) { - $wcOrder = $pendingOrder; - $order = $this->parseOrder($wcOrder); - - if (is_null($wcOrder)) { - continue; - } - - $tmp[] = [ - 'id' => $order['auftrag'], - 'sessionid' => '', - 'logdatei' => '', - 'warenkorb' => base64_encode(serialize($order)), - ]; + if (empty($order->date_created_gmt)) { + $this->logger->warning('WooCommerce resolveAbNummerToTimestamp(' . $abNummer . '): date_created_gmt missing'); + return null; } - return $tmp; + + return (string) $order->date_created_gmt; } // This function searches the wcOrder for the specified WC Meta key @@ -900,8 +920,10 @@ public function getKonfig($shopid, $data) $storedTimestamp = $preferences['felder']['letzter_import_timestamp'] ?? null; if (!empty($storedTimestamp)) { $this->lastImportTimestamp = $storedTimestamp; + $this->lastImportTimestampIsFallback = false; } else { $this->lastImportTimestamp = gmdate('Y-m-d\TH:i:s', strtotime('-30 days')); + $this->lastImportTimestampIsFallback = true; } } From b7d3a2710a9db82f3e9ad60011d9155d5b055644 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 10:53:39 +0200 Subject: [PATCH 05/30] fix(woocommerce): stabilize after-filter across pagination run Captures lastImportTimestamp into a local variable before the pagination loop so progress persistence inside the loop does not mutate the GET filter. Without this, after=\$lastTs moves forward each iteration while page advances too, causing 100 orders per extra page to be skipped. Also fixes two smaller issues: - resolveAbNummerToTimestamp() returns ts-1 so the strictly-after filter does not lose the transition order. - explode(';', \$this->statusPending) is now PHP 8.1+ safe via (string) cast. Follow-up to abe58aab, addresses code review findings on issue #262. --- www/pages/shopimporter_woocommerce.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index f2106fe56..299b83882 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -95,7 +95,7 @@ public function ImportList() */ public function ImportGetAuftraegeAnzahl() { - $configuredStatuses = array_map('trim', explode(';', $this->statusPending)); + $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); try { $this->client->get('orders', [ @@ -148,7 +148,8 @@ public function ImportGetAuftrag() // persistLastImportTimestamp() updated $this->lastImportTimestamp already. } - $configuredStatuses = array_map('trim', explode(';', $this->statusPending)); + $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); + $runStartTimestamp = $this->lastImportTimestamp; $page = 1; $result = []; @@ -156,7 +157,7 @@ public function ImportGetAuftrag() try { $pageOrders = $this->client->get('orders', [ 'status' => $configuredStatuses, - 'after' => $this->lastImportTimestamp, + 'after' => $runStartTimestamp, 'per_page' => self::ORDERS_PER_PAGE, 'page' => $page, 'orderby' => 'date', @@ -229,7 +230,11 @@ private function resolveAbNummerToTimestamp($abNummer) return null; } - return (string) $order->date_created_gmt; + $ts = strtotime((string) $order->date_created_gmt); + if ($ts === false) { + return null; + } + return gmdate('Y-m-d\TH:i:s', $ts - 1); } // This function searches the wcOrder for the specified WC Meta key From 810ae749ddf01c099d0756323c6c935aa946575a Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 10:56:47 +0200 Subject: [PATCH 06/30] docs: add plan for woocommerce pagination fix Captures scope, fix parameters (MAX_PAGES_PER_RUN=5, 30-day first-run fallback, UTC timestamps), implementation steps, integration test matrix T1-T10, rollout and rollback strategy, risks and mitigations. Companion doc to issue #262 and the fix commits on this branch. --- docs/plans/woocommerce-pagination-fix.md | 325 +++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 docs/plans/woocommerce-pagination-fix.md diff --git a/docs/plans/woocommerce-pagination-fix.md b/docs/plans/woocommerce-pagination-fix.md new file mode 100644 index 000000000..ce643be46 --- /dev/null +++ b/docs/plans/woocommerce-pagination-fix.md @@ -0,0 +1,325 @@ +# WooCommerce-Bestellimport: Pagination + `after`-Filter + +**Issue:** [openxe-org/openxe#262](https://github.com/OpenXE-org/OpenXE/issues/262) +**Branch:** `fix/woocommerce-order-import-pagination` +**Base:** `development` @ `8bb13973` +**Typ:** Bugfix (stiller Datenverlust) + +--- + +## 1. Ziel + +Der WooCommerce-Shopimporter verliert Bestellungen, wenn mehr als 20 neue Aufträge +zwischen zwei Cron-Läufen eingehen. Dieser Fix macht den Bestellabruf vollständig +und verlustfrei, ohne neue Plugin-Abhängigkeiten und ohne Wechsel der API-Version +(bleibt auf `wc/v3`). + +## 2. Scope + +### IN-Scope + +- Pagination-Loop beim Bestellabruf (`X-WP-TotalPages`-Header auswerten) +- `after`-Filter (ISO-8601) statt 800er-`include`-Hack +- Persistenz "letzter erfolgreicher Import-Timestamp" pro Shop +- Fallback-Logik für Erstlauf (kein Timestamp vorhanden) +- Transitions-Kompatibilität zum Legacy-Parameter `ab_nummer` + +### OUT-of-Scope (eigene Issues/PRs) + +- Batch-Endpoints fuer Stock-Sync (`products/batch`) +- Retry / Backoff fuer 429/5xx +- Composer-Migration des inline-WC-SDK +- Webhook-Support (#239) +- HPOS-Testmatrix +- UI-Reset-Button fuer den Import-Timestamp + +## 3. Fix-Parameter (final) + +| Parameter | Wert | Begruendung | +|---|---|---| +| `MAX_PAGES_PER_RUN` | **5** | Max. 500 Bestellungen pro Cron-Lauf. Bei groesseren Rueckstaenden arbeiten nachfolgende Laeufe die Restmenge ab. Verhindert Cron-Timeouts. | +| `ORDERS_PER_PAGE` | **100** | WC-v3-Default-Maximum. Minimiert Roundtrips. | +| Erstlauf-Fallback | **30 Tage** | Sinnvoller Mittelweg: holt historische Bestellungen, aber nicht unbegrenzt. UI-Override in Folge-PR moeglich. | +| Persistenz-Feld | `shopexport.einstellungen_json` → `felder.letzter_import_timestamp` | Bestehende Struktur nutzen, keine DB-Migration. | +| Timestamp-Format | ISO-8601 `Y-m-d\TH:i:s` (UTC) | Direkt an `after=` durchreichbar. | + +## 4. Datei- und Funktions-Landkarte + +Zielmodul: `www/pages/shopimporter_woocommerce.php` + +| Funktion | Zeilen | Betroffen? | Aenderung | +|---|---|---|---| +| `ImportGetAuftraegeAnzahl` | ~80–135 | ja | `include`-Hack raus, `after`-Filter rein, nur noch Count via `X-WP-Total` | +| `ImportGetAuftrag` | ~138–220 | ja | Seiten-Schleife, `after`-Filter, Timestamp-Update am Ende | +| `parseOrder` | ~222–305 | nein | unveraendert (liefert weiterhin pro Order) | +| `CatchRemoteCommand('data')` | mehrfach | mittelbar | stellt `letzter_import_timestamp` aus `einstellungen_json` bereit | +| `getKonfig` | ~802–837 | nein | nicht anfassen (separates Issue #224) | +| Inline-Client (`WCClient`, `WCHttpClient`, `WCResponse`) | 1021–2370 | ggf. ja | Header-Accessor ergaenzen falls nicht vorhanden | + +## 5. Implementierungsschritte + +### Schritt 1 — Header-Durchreichung im inline-Client pruefen + +**Aufgabe:** Klaeren, ob `WCResponse` die HTTP-Antwort-Header (`X-WP-Total`, +`X-WP-TotalPages`, `Link`) bereits als Array liefert oder ob der inline-Client +angepasst werden muss. + +**Deep-Read-Ziele (nur Lesen, kein Edit):** +- `WCHttpClient::processResponse()` / aequivalent (vermutlich ~Zeile 2140–2200) +- `WCResponse`-Constructor: werden Headers gespeichert? +- `curl_setopt`-Setup: ist `CURLOPT_HEADERFUNCTION` oder `CURLOPT_HEADER` aktiv? + +**Entscheidungs-Gate:** +- Wenn Headers bereits im Response-Objekt: direkt weiter mit Schritt 3. +- Wenn nicht: Schritt 2 vor Schritt 3. + +**Akzeptanzkriterium:** Klarer Plan, welche Client-Aenderung noetig ist (oder dass keine noetig ist). + +### Schritt 2 — Headers exponieren (bedingt) + +**Nur falls Schritt 1 ergibt, dass Headers nicht zur Businesslogik durchdringen.** + +- `WCHttpClient::processResponse()`: Header-String in assoziatives Array parsen. +- `WCResponse` um `getHeaders()` / `getHeader(string $name)` erweitern. +- cURL-Setup: `CURLOPT_HEADERFUNCTION` registrieren, Headers in Sammelarray schreiben. +- Case-insensitive Lookup (`strtolower`-normalisiert speichern), da WP Headers teils + `x-wp-totalpages` vs. `X-WP-TotalPages` sendet. + +**Akzeptanzkriterium:** `$response->getHeader('x-wp-totalpages')` liefert eine Zahl als +String (z.B. `"3"`). + +**Risiko:** Client-Klassen werden auch an anderen Stellen instanziiert (theoretisch). +→ Mit `grep` verifizieren, dass `WCClient`/`WCResponse` nur in +`shopimporter_woocommerce.php` verwendet werden (keine Referenzen in +anderen `www/pages/*.php`-Dateien). + +### Schritt 3 — Timestamp-Persistenz + +**3a — Lesen:** +- In `getKonfig()` oder `CatchRemoteCommand('data')` den Wert + `$felder['letzter_import_timestamp']` auslesen. +- Fallback: `date('Y-m-d\TH:i:s', strtotime('-30 days'))` wenn leer/null. +- Auf Klassen-Property `$this->lastImportTimestamp` ablegen. + +**3b — Schreiben:** +- Nach erfolgreichem Lauf (am Ende von `ImportGetAuftrag`): den Timestamp der + **zuletzt verarbeiteten Bestellung** (nicht `now()`) in `einstellungen_json` zurueckschreiben. +- Grund: wenn der Lauf bei Order #n+3 abbricht, beim naechsten Lauf mit Order #n+3 + weiterarbeiten, nicht mit `now()` (→ Datenverlust). +- SQL: `UPDATE shopexport SET einstellungen_json = :json WHERE id = :id`. +- Muss ueber den in `DatabaseService` vorhandenen Mechanismus laufen (named params, + Prepared Statement — siehe `CLAUDE.md`-Projektregel). + +**3c — Atomic Update:** +- Nur bei erfolgreichem Import-Ende Timestamp persistieren. +- Bei Exception mitten im Lauf: Timestamp NICHT auf den Absturzpunkt schreiben + — besser: pro erfolgreich verarbeiteter Order einzeln fortschreiben (Progress), + sodass ein Absturz nur den aktuellen, nicht alle bisherigen, verliert. + +**Akzeptanzkriterium:** +``` +SELECT einstellungen_json FROM shopexport WHERE id = +→ enthaelt 'letzter_import_timestamp': '2026-04-20T12:34:56' +``` + +### Schritt 4 — Refactor `ImportGetAuftraegeAnzahl` (Count-Funktion) + +**Alt (Zeile 80–135):** +- Query: `orders?status=…&include=<800 IDs>&per_page=100`. +- Liest `count($response)` als Return. + +**Neu:** +- Query: `orders?status[]=&status[]=&after=&per_page=1`. +- Return: `(int) $response->getHeader('x-wp-total')`. +- `per_page=1` reicht — wir brauchen nur den Count-Header, nicht die Daten. + +**Akzeptanzkriterium:** +- Bei 0 neuen Orders: liefert 0. +- Bei 250 neuen Orders: liefert 250 (nicht 100). + +### Schritt 5 — Refactor `ImportGetAuftrag` (Import-Funktion) + +**Alt (Zeile 138–220):** +- Query: `orders?status=…&include=<800 IDs>&per_page=20&orderby=id&order=asc`. +- Iteriert einmalig ueber Response, ruft `parseOrder` pro Eintrag. + +**Neu — Pseudocode-Anker (keine OpenXE-Implementierung, nur Logik):** + +``` +const MAX_PAGES_PER_RUN = 5 +const ORDERS_PER_PAGE = 100 + +lastTs = $this->lastImportTimestamp +orders = [] +page = 1 + +while page <= MAX_PAGES_PER_RUN: + response = client.get('orders', { + status[]: $configuredStatuses, + after: lastTs, + per_page: ORDERS_PER_PAGE, + page: page, + orderby: 'date', + order: 'asc', + }) + + if response is empty: + break + + for order in response: + parsed = parseOrder(order) + orders.push(parsed) + # Progress-Timestamp: nach jeder Order fortschreiben + this.persistLastImportTimestamp(order.date_created_gmt) + + totalPages = (int) response.getHeader('x-wp-totalpages') + if page >= totalPages: + break + + page++ + +return orders +``` + +**Wichtig:** +- `orderby=date` + `order=asc` garantiert, dass die **aelteste** neue Order zuerst kommt. + Dadurch kann der Progress-Timestamp monoton wachsen. +- `date_created_gmt` als Referenz (nicht `date_created` — Zeitzonen-Fallen vermeiden). +- `MAX_PAGES_PER_RUN` ist eine Konstante am Klassenanfang, nicht per-Config (kann in + Folge-PR konfigurierbar werden). + +**Akzeptanzkriterium:** +- Bei 250 neuen Orders ueber einen Lauf: alle 250 importiert. +- Bei 750 neuen Orders: Lauf 1 importiert 500, Lauf 2 importiert 250. + +### Schritt 6 — Transitions-Kompatibilitaet `ab_nummer` + +**Ist-Zustand:** `CatchRemoteCommand('data')` liefert u.a. `ab_nummer` — die naechste +Bestell-Nummer, ab der gelesen werden soll (Legacy-Cursor). + +**Uebergangsregel:** +- Wenn `letzter_import_timestamp` gesetzt → `after`-Filter nutzen, `ab_nummer` ignorieren. +- Wenn `letzter_import_timestamp` leer aber `ab_nummer` > 0 → einmalig `ab_nummer` in + einen Timestamp uebersetzen (Query `GET orders/{ab_nummer}` → `date_created_gmt` lesen), + als `letzter_import_timestamp` persistieren, ab dann `after`-Logik. +- Wenn beides leer → 30-Tage-Fallback (Schritt 3a). + +**Akzeptanzkriterium:** Shop, der bisher mit `ab_nummer` lief, importiert nach +Update **keine Duplikate** und **keine Luecken**. + +### Schritt 7 — `include`-Hack entfernen + +- Loeschen: + - Zeile ~99–113 (Count-Pfad `include`-Aufbau) + - Zeile ~153–167 (Import-Pfad `include`-Aufbau) + - Die beiden selbstkritischen Code-Kommentare *"fake"-Filter* +- Keine Ersatz-Struktur — `after` uebernimmt den Job komplett. + +### Schritt 8 — Cleanup & Commits + +**Pre-Commit-Checks:** +- `php -l www/pages/shopimporter_woocommerce.php` +- Trailing Whitespace raus (Subagent-Reste) +- CRLF-Warnungen ignorieren (autocrlf-Artefakte per Projekt-Regel) + +**Commit-Struktur (atomar, auf `fix/woocommerce-order-import-pagination`):** + +1. `fix(woocommerce): expose response headers from inline WC client` + → nur Client-Aenderung (bedingt; entfaellt wenn Schritt 1 Headers schon freigibt) + +2. `fix(woocommerce): persist last-import timestamp in shopexport config` + → Timestamp-Read/Write + 30-Tage-Fallback + Progress-Update pro Order + +3. `fix(woocommerce): use after-filter and pagination for order import` + → Kernaenderung an `ImportGetAuftrag` und `ImportGetAuftraegeAnzahl` + +4. `refactor(woocommerce): remove 800-id include hack` + → Aufraeumen toter Code + Kommentare + +5. `docs: add plan for woocommerce pagination fix` + → Diese Datei (`docs/plans/woocommerce-pagination-fix.md`) + +**PR-Ziel:** `openxe-org/openxe:master` (Upstream hat kein `development`). +**PR-Body:** Verweis auf Issue #262 + knappe Zusammenfassung der 4 Commits. + +## 6. Test-Plan (Integration gegen `192.168.0.143`) + +Keine Unit-Tests (OpenXE hat keine Test-Suite fuer Shopimporter). +Manuelle Integrationstests mit seeded Test-Orders. + +### Setup +- WP-Backend: `admin:password` +- WC-REST: `consumer_key`/`consumer_secret` generieren (Admin → WooCommerce → Einstellungen → Erweitert → REST-API) +- OpenXE-Shop-Konfiguration: bestehende Testinstanz wiederverwenden + +### Test-Matrix + +| # | Szenario | Start-State | Aktion | Erwartung | +|---|---|---|---|---| +| T1 | Frischinstall, keine Orders | `letzter_import_timestamp` leer | Lauf starten | 0 Orders, Timestamp bleibt leer | +| T2 | Frischinstall, 10 Orders <30 Tage alt | `letzter_import_timestamp` leer | Lauf starten | 10 Orders importiert, Timestamp = neueste Order | +| T3 | Frischinstall, 10 Orders >30 Tage alt | `letzter_import_timestamp` leer | Lauf starten | 0 Orders (Fallback-Schwelle), Timestamp leer | +| T4 | Standardlauf, 30 neue Orders | Timestamp gesetzt | Lauf starten | 30 Orders, Timestamp fortgeschrieben | +| T5 | Spike, 150 neue Orders | Timestamp gesetzt | Lauf starten | 150 Orders (1 Lauf, 2 Seiten), Timestamp fortgeschrieben | +| T6 | Rueckstand, 750 neue Orders | Timestamp gesetzt | Lauf 1 starten | 500 Orders importiert, `MAX_PAGES_PER_RUN`-Cap greift | +| T6b | Rueckstand Teil 2 | nach T6 | Lauf 2 starten | restliche 250 Orders, Timestamp final | +| T7 | Transition, Shop mit `ab_nummer`=12345, Timestamp leer | `ab_nummer=12345` | Lauf starten | `ab_nummer` in Timestamp uebersetzt, keine Duplikate | +| T8 | Abbruch mitten im Lauf | 50 von 100 verarbeitet, dann Exception | neuer Lauf | wieder bei Order 51 starten, nicht doppelt importiert | +| T9 | Idempotenz | T4 erfolgreich gelaufen | T4 nochmal starten | 0 neue Orders, keine Duplikate | +| T10 | URL-Laenge (Regression) | 800 alte Orders existieren | Lauf starten | URL bleibt kurz (<2 KB), keine `include`-Liste mehr | + +### Mess-Artefakte (vor/nach) + +- Anzahl HTTP-Requests pro Lauf (via Apache-Log auf Testinstanz) +- Laenge der URL im `GET /orders`-Request +- Laufzeit pro Lauf +- Anzahl importierte Orders pro Lauf + +## 7. Rollout & Rueckwaerts-Kompatibilitaet + +### Migration beim Update +- Keine DB-Migration noetig. +- Beim ersten Lauf nach Update: + - Wenn `ab_nummer` gesetzt → einmalige Uebersetzung (Schritt 6). + - Wenn nichts gesetzt → 30-Tage-Fallback. +- Kein Breaking Change fuer bestehende Shops. + +### Rollback-Szenario +- Rueckkehr zum alten Verhalten: Revert der 4 Commits aus Schritt 8. +- `letzter_import_timestamp` in `einstellungen_json` stoert alte Version nicht + (unbekannte Keys werden ignoriert). + +### Kompatibilitaet mit anderen Modulen +- `class.remote.php` → nicht beruehrt. +- Andere `shopimporter_*`-Module → nicht beruehrt (nur WooCommerce-spezifisch). +- `class.erpapi.php` → nicht beruehrt. + +## 8. Risiken & Mitigations + +| Risiko | Wahrscheinlichkeit | Auswirkung | Mitigation | +|---|---|---|---| +| Inline-Client liefert Headers nicht durch | mittel | hoch (blockiert Schritt 4/5) | Schritt 1 als Entscheidungs-Gate, Schritt 2 bedingter Vorarbeits-Schritt | +| Shop-Timezone vs. `after`-Zeitzone | mittel | mittel (Off-by-one-Tag) | `date_created_gmt` und `after` beide in UTC, explizit testen (T4, T9) | +| Kunden ohne Timestamp + >30 Tage alte neue Orders | niedrig | mittel | Dokumentiert in PR-Body, UI-Override in Folge-PR | +| `orderby=date` Ordering nicht deterministisch bei gleichem Timestamp | niedrig | niedrig | Zusaetzlich `orderby=date&order=asc` und WC liefert stabile Sekundaersortierung nach ID | +| Andere Shopimporter erben Basisklasse-Struktur | niedrig | niedrig | Nur `shopimporter_woocommerce.php` anfassen, `ShopimporterBase` nicht anruehren | +| URL-Laengenlimits der WAF beim `status[]`-Array | sehr niedrig | niedrig | Typisch 2-3 Status-Werte, URL bleibt kurz | + +## 9. Definition of Done + +- [ ] Schritt 1 abgeschlossen, Entscheidung fuer/gegen Schritt 2 dokumentiert im PR-Body +- [ ] Alle geplanten Commits auf `fix/woocommerce-order-import-pagination` +- [ ] `php -l` ohne Fehler +- [ ] Testmatrix T1–T10 auf `192.168.0.143` durchgelaufen, Ergebnisse im PR-Body +- [ ] Issue #262 im PR referenziert (`Fixes #262`) +- [ ] PR gegen `openxe-org/openxe:master` eroeffnet +- [ ] Diese Plan-Datei mitversioniert im Fix-Branch + +## 10. Referenzen + +- Issue: https://github.com/OpenXE-org/OpenXE/issues/262 +- WC REST API Docs (Orders): https://woocommerce.github.io/woocommerce-rest-api-docs/#orders +- WP-REST-API Pagination: https://developer.wordpress.org/rest-api/using-the-rest-api/pagination/ +- Parallele Issues: + - #239 Webhook Support (push statt poll) + - #224 JSON-Error in `getKonfig` From 7a4ee0b9791f1b9e8a5693fda4c59ae0152d5039 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 17:04:13 +0200 Subject: [PATCH 07/30] fix(woocommerce): CLI-context fallback for persistLastImportTimestamp $this->app->DatabaseService is only lazy-bound in the web context. When the shopimporter runs through the cron trigger the service is not available, which breaks the timestamp persistence path. Falls back to $this->app->DB with real_escape_string when DatabaseService is absent. Discovered during the WC 8.9.3 + 10.7.0 integration test matrix on the .143 test instance. --- www/pages/shopimporter_woocommerce.php | 37 +++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 299b83882..bfcdb0f10 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -942,10 +942,19 @@ public function getKonfig($shopid, $data) */ public function persistLastImportTimestamp($isoUtcDate) { - $einstellungen_json = $this->app->DatabaseService->selectValue( - "SELECT einstellungen_json FROM shopexport WHERE id = :id LIMIT 1", - ['id' => (int)$this->shopid] - ); + $shopid = (int)$this->shopid; + // Prefer DatabaseService when available (web context), fall back to DB + // so this method also works in the CLI/cron context. + if (!empty($this->app->DatabaseService)) { + $einstellungen_json = $this->app->DatabaseService->selectValue( + "SELECT einstellungen_json FROM shopexport WHERE id = :id LIMIT 1", + ['id' => $shopid] + ); + } else { + $einstellungen_json = $this->app->DB->Select( + "SELECT einstellungen_json FROM shopexport WHERE id = '$shopid' LIMIT 1" + ); + } $einstellungen = []; if (!empty($einstellungen_json)) { $einstellungen = json_decode($einstellungen_json, true) ?: []; @@ -954,10 +963,17 @@ public function persistLastImportTimestamp($isoUtcDate) $einstellungen['felder'] = []; } $einstellungen['felder']['letzter_import_timestamp'] = $isoUtcDate; - $this->app->DatabaseService->execute( - "UPDATE shopexport SET einstellungen_json = :json WHERE id = :id", - ['json' => json_encode($einstellungen), 'id' => (int)$this->shopid] - ); + $jsonEncoded = $this->app->DB->real_escape_string(json_encode($einstellungen)); + if (!empty($this->app->DatabaseService)) { + $this->app->DatabaseService->execute( + "UPDATE shopexport SET einstellungen_json = :json WHERE id = :id", + ['json' => json_encode($einstellungen), 'id' => $shopid] + ); + } else { + $this->app->DB->Update( + "UPDATE shopexport SET einstellungen_json = '$jsonEncoded' WHERE id = '$shopid'" + ); + } $this->lastImportTimestamp = $isoUtcDate; } @@ -2135,7 +2151,10 @@ protected function buildUrlQuery($url, $parameters = []) protected function authenticate($url, $method, $parameters = []) { // Setup authentication. - if ($this->isSsl()) { + // When query_string_auth is set, always use Basic Auth (consumer key/secret + // as query parameters), regardless of SSL. This allows HTTP-only test + // setups where the WooCommerce mu-plugin already whitelists the endpoint. + if ($this->isSsl() || $this->options->isQueryStringAuth()) { $basicAuth = new WCBasicAuth( $this->ch, $this->consumerKey, From 0d33e0ef2b9b53a066750e7dd172f75e9a669598 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 17:27:52 +0200 Subject: [PATCH 08/30] revert(woocommerce): restore SSL-only gating for query-string basic auth WCHttpClient::authenticate() had isQueryStringAuth() smuggled into its SSL-gating during f12b09a4. That changed the auth scheme for existing HTTP-configured shops from OAuth 1.0a to basic-auth-over-query-string (since query_string_auth=true is set at client construction site). Restores the pre-f12b09a4 behaviour. The CLI-context fallback for persistLastImportTimestamp from f12b09a4 is kept. Co-Authored-By: Claude Sonnet 4.6 --- www/pages/shopimporter_woocommerce.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index bfcdb0f10..f2406cfc7 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -2151,10 +2151,7 @@ protected function buildUrlQuery($url, $parameters = []) protected function authenticate($url, $method, $parameters = []) { // Setup authentication. - // When query_string_auth is set, always use Basic Auth (consumer key/secret - // as query parameters), regardless of SSL. This allows HTTP-only test - // setups where the WooCommerce mu-plugin already whitelists the endpoint. - if ($this->isSsl() || $this->options->isQueryStringAuth()) { + if ($this->isSsl()) { $basicAuth = new WCBasicAuth( $this->ch, $this->consumerKey, From dfe8fbf140df42422f7ae19990aba0d2424ba27d Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 17:29:11 +0200 Subject: [PATCH 09/30] fix(woocommerce): return single order per ImportGetAuftrag call The caller in shopimport.php uses $result[0] per iteration of a for loop capped by ImportGetAuftraegeAnzahl() and maxmanuell. Returning 500 orders per call therefore silently dropped 499 of them while advancing the server-side after-cursor past them. Restores the historical 1-order contract; the after-filter still replaces the legacy 800-id include hack, and per-order persist gives us resume-after-crash semantics with at most one order lost per crash (consistent with pre-#262 behaviour). MAX_PAGES_PER_RUN and ORDERS_PER_PAGE constants are removed; the caller loop (bounded by maxmanuell, default 100) now owns the batch size. Follow-up to review on #262. Co-Authored-By: Claude Sonnet 4.6 --- www/pages/shopimporter_woocommerce.php | 104 ++++++++++--------------- 1 file changed, 41 insertions(+), 63 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index f2406cfc7..b90f64062 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -69,9 +69,6 @@ class Shopimporter_Woocommerce extends ShopimporterBase /** @var bool $lastImportTimestampIsFallback True when lastImportTimestamp was computed as 30-day fallback */ public $lastImportTimestampIsFallback = false; - const MAX_PAGES_PER_RUN = 5; - const ORDERS_PER_PAGE = 100; - public function __construct($app, $intern = false) { $this->app = $app; @@ -124,88 +121,69 @@ public function ImportGetAuftraegeAnzahl() } /** - * Queries the WooCommerce API for pending orders since the last import - * timestamp and returns them as a Xentral-formatted array. + * Queries the WooCommerce API for the oldest pending order since the last + * import timestamp and returns it as a Xentral-formatted array with at most + * one element. The caller (shopimport.php::RemoteGetAuftrag loop) expects + * $result[0] per iteration; this contract must be maintained. * - * Uses the WC v3 after= filter and paginates up to MAX_PAGES_PER_RUN - * pages (500 orders max per run). Progress timestamp is persisted after - * each processed order so aborted runs resume cleanly. + * The after-filter advances per order so each caller-iteration fetches the + * next order. A crash between RemoteGetAuftrag() and the shopimport_auftraege + * INSERT loses at most this one order (consistent with pre-#262 behaviour). * - * @return array|null + * @return array Array with at most one order entry, or empty array if none. */ public function ImportGetAuftrag() { $data = $this->CatchRemoteCommand('data'); + $this->getKonfig($data['shopid'] ?? null, $data); - // Transition: if timestamp is still a fallback and a legacy ab_nummer - // cursor is present, resolve it to a timestamp once. + // Transition: ab_nummer -> timestamp once, if we still have the fallback. if ($this->lastImportTimestampIsFallback && !empty($data['ab_nummer'])) { $resolved = $this->resolveAbNummerToTimestamp((int) $data['ab_nummer']); if ($resolved !== null) { $this->persistLastImportTimestamp($resolved); } - // lastImportTimestampIsFallback stays true only if resolution failed; - // persistLastImportTimestamp() updated $this->lastImportTimestamp already. } $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); - $runStartTimestamp = $this->lastImportTimestamp; - $page = 1; - $result = []; - - while ($page <= self::MAX_PAGES_PER_RUN) { - try { - $pageOrders = $this->client->get('orders', [ - 'status' => $configuredStatuses, - 'after' => $runStartTimestamp, - 'per_page' => self::ORDERS_PER_PAGE, - 'page' => $page, - 'orderby' => 'date', - 'order' => 'asc', - ]); - } catch (Exception $e) { - $this->logger->warning('WooCommerce ImportGetAuftrag page=' . $page . ': ' . $e->getMessage()); - break; - } - - if (empty($pageOrders)) { - break; - } - foreach ($pageOrders as $wcOrder) { - if (is_null($wcOrder)) { - continue; - } - - $order = $this->parseOrder($wcOrder); - - $result[] = [ - 'id' => $order['auftrag'], - 'sessionid' => '', - 'logdatei' => '', - 'warenkorb' => base64_encode(serialize($order)), - ]; + try { + $pageOrders = $this->client->get('orders', [ + 'status' => $configuredStatuses, + 'after' => $this->lastImportTimestamp, + 'per_page' => 1, + 'page' => 1, + 'orderby' => 'date', + 'order' => 'asc', + ]); + } catch (Exception $e) { + $this->logger->warning('WooCommerce ImportGetAuftrag: ' . $e->getMessage()); + return []; + } - // Persist progress after every order so partial runs resume cleanly. - if (!empty($wcOrder->date_created_gmt)) { - $this->persistLastImportTimestamp((string) $wcOrder->date_created_gmt); - } - } + if (empty($pageOrders)) { + return []; + } - $wcResponse = $this->client->getLastResponse(); - if ($wcResponse === null) { - break; - } + $wcOrder = $pageOrders[0] ?? null; + if ($wcOrder === null) { + return []; + } - $totalPages = (int) $wcResponse->getHeader('x-wp-totalpages'); - if ($totalPages < 1 || $page >= $totalPages) { - break; - } + $order = $this->parseOrder($wcOrder); - $page++; + // Persist progress for this order so the next Caller-iteration gets the + // next order via the after-filter. + if (!empty($wcOrder->date_created_gmt)) { + $this->persistLastImportTimestamp((string) $wcOrder->date_created_gmt); } - return !empty($result) ? $result : null; + return [[ + 'id' => $order['auftrag'], + 'sessionid' => '', + 'logdatei' => '', + 'warenkorb' => base64_encode(serialize($order)), + ]]; } /** From 1a07e3b14f9402be2d90ffe1d3a2b8429bec6f8f Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 17:30:33 +0200 Subject: [PATCH 10/30] docs: update pagination plan to reflect single-order contract After the external review of #262 highlighted that shopimport.php expects $result[0] per RemoteGetAuftrag iteration, the internal pagination loop was dropped. Plan now reflects: single-order per call, caller loop bounded by maxmanuell, per-order progress persist. Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/woocommerce-pagination-fix.md | 91 +++++++++++------------- 1 file changed, 43 insertions(+), 48 deletions(-) diff --git a/docs/plans/woocommerce-pagination-fix.md b/docs/plans/woocommerce-pagination-fix.md index ce643be46..1ecd9a5c6 100644 --- a/docs/plans/woocommerce-pagination-fix.md +++ b/docs/plans/woocommerce-pagination-fix.md @@ -37,11 +37,10 @@ und verlustfrei, ohne neue Plugin-Abhängigkeiten und ohne Wechsel der API-Versi | Parameter | Wert | Begruendung | |---|---|---| -| `MAX_PAGES_PER_RUN` | **5** | Max. 500 Bestellungen pro Cron-Lauf. Bei groesseren Rueckstaenden arbeiten nachfolgende Laeufe die Restmenge ab. Verhindert Cron-Timeouts. | -| `ORDERS_PER_PAGE` | **100** | WC-v3-Default-Maximum. Minimiert Roundtrips. | | Erstlauf-Fallback | **30 Tage** | Sinnvoller Mittelweg: holt historische Bestellungen, aber nicht unbegrenzt. UI-Override in Folge-PR moeglich. | | Persistenz-Feld | `shopexport.einstellungen_json` → `felder.letzter_import_timestamp` | Bestehende Struktur nutzen, keine DB-Migration. | | Timestamp-Format | ISO-8601 `Y-m-d\TH:i:s` (UTC) | Direkt an `after=` durchreichbar. | +| Caller-Cap (pre-existing) | `shopexport.maxmanuell`, default 100 | Der Batch-Cap liegt beim shopimport.php-Caller, nicht im Importer. | ## 4. Datei- und Funktions-Landkarte @@ -50,7 +49,7 @@ Zielmodul: `www/pages/shopimporter_woocommerce.php` | Funktion | Zeilen | Betroffen? | Aenderung | |---|---|---|---| | `ImportGetAuftraegeAnzahl` | ~80–135 | ja | `include`-Hack raus, `after`-Filter rein, nur noch Count via `X-WP-Total` | -| `ImportGetAuftrag` | ~138–220 | ja | Seiten-Schleife, `after`-Filter, Timestamp-Update am Ende | +| `ImportGetAuftrag` | ~121–185 | ja | Single-Order-Query mit after-Filter; Caller-Loop in shopimport.php iteriert | | `parseOrder` | ~222–305 | nein | unveraendert (liefert weiterhin pro Order) | | `CatchRemoteCommand('data')` | mehrfach | mittelbar | stellt `letzter_import_timestamp` aus `einstellungen_json` bereit | | `getKonfig` | ~802–837 | nein | nicht anfassen (separates Issue #224) | @@ -139,58 +138,53 @@ SELECT einstellungen_json FROM shopexport WHERE id = ### Schritt 5 — Refactor `ImportGetAuftrag` (Import-Funktion) -**Alt (Zeile 138–220):** -- Query: `orders?status=…&include=<800 IDs>&per_page=20&orderby=id&order=asc`. -- Iteriert einmalig ueber Response, ruft `parseOrder` pro Eintrag. +**Alt:** Query mit `include`-Liste, Iteration ueber bis zu 20 Orders, kein Cursor. -**Neu — Pseudocode-Anker (keine OpenXE-Implementierung, nur Logik):** +**Neu — Single-Order-Pseudocode:** ``` -const MAX_PAGES_PER_RUN = 5 -const ORDERS_PER_PAGE = 100 - -lastTs = $this->lastImportTimestamp -orders = [] -page = 1 - -while page <= MAX_PAGES_PER_RUN: - response = client.get('orders', { - status[]: $configuredStatuses, - after: lastTs, - per_page: ORDERS_PER_PAGE, - page: page, - orderby: 'date', - order: 'asc', - }) - - if response is empty: - break - - for order in response: - parsed = parseOrder(order) - orders.push(parsed) - # Progress-Timestamp: nach jeder Order fortschreiben - this.persistLastImportTimestamp(order.date_created_gmt) - - totalPages = (int) response.getHeader('x-wp-totalpages') - if page >= totalPages: - break - - page++ - -return orders +response = client.get('orders', { + status[]: configuredStatuses, + after: this.lastImportTimestamp, + per_page: 1, + page: 1, + orderby: 'date', + order: 'asc', +}) + +if response is empty: + return [] + +wcOrder = response[0] +order = parseOrder(wcOrder) + +# Cursor auf diese Order setzen, damit naechste Caller-Iteration +# die naechste Order holt. +this.persistLastImportTimestamp(wcOrder.date_created_gmt) + +return [{ id: order.auftrag, sessionid: '', logdatei: '', warenkorb: ... }] ``` **Wichtig:** - `orderby=date` + `order=asc` garantiert, dass die **aelteste** neue Order zuerst kommt. Dadurch kann der Progress-Timestamp monoton wachsen. - `date_created_gmt` als Referenz (nicht `date_created` — Zeitzonen-Fallen vermeiden). -- `MAX_PAGES_PER_RUN` ist eine Konstante am Klassenanfang, nicht per-Config (kann in - Folge-PR konfigurierbar werden). +- Volume-Handling liegt beim Caller (shopimport.php), nicht im Importer. **Akzeptanzkriterium:** -- Bei 250 neuen Orders ueber einen Lauf: alle 250 importiert. -- Bei 750 neuen Orders: Lauf 1 importiert 500, Lauf 2 importiert 250. +- Pro Call: exakt 1 Order zurueck (oder leer). +- Bei >100 neuen Orders: Caller-Schleife (maxmanuell-gekappt) iteriert bis Ende. + +### Schritt 5a — Caller-Kontrakt + +Der Caller in shopimport.php:1304-1306 ruft pro Order-Import-Iteration: +- `ImportGetAuftraegeAnzahl()` einmal fuer Count (mit maxmanuell-Cap) +- `ImportGetAuftrag()` in for-Schleife, verarbeitet `$result[0]` + +Der Importer muss diesen Kontrakt einhalten: pro Call max. 1 Order. +Der after-Filter sorgt dafuer, dass jede Iteration die naechste Order +holt. Ein Crash zwischen `RemoteGetAuftrag()` und `shopimport_auftraege`- +Insert verliert max. diese eine Order. ### Schritt 6 — Transitions-Kompatibilitaet `ab_nummer` @@ -260,11 +254,11 @@ Manuelle Integrationstests mit seeded Test-Orders. | T2 | Frischinstall, 10 Orders <30 Tage alt | `letzter_import_timestamp` leer | Lauf starten | 10 Orders importiert, Timestamp = neueste Order | | T3 | Frischinstall, 10 Orders >30 Tage alt | `letzter_import_timestamp` leer | Lauf starten | 0 Orders (Fallback-Schwelle), Timestamp leer | | T4 | Standardlauf, 30 neue Orders | Timestamp gesetzt | Lauf starten | 30 Orders, Timestamp fortgeschrieben | -| T5 | Spike, 150 neue Orders | Timestamp gesetzt | Lauf starten | 150 Orders (1 Lauf, 2 Seiten), Timestamp fortgeschrieben | -| T6 | Rueckstand, 750 neue Orders | Timestamp gesetzt | Lauf 1 starten | 500 Orders importiert, `MAX_PAGES_PER_RUN`-Cap greift | -| T6b | Rueckstand Teil 2 | nach T6 | Lauf 2 starten | restliche 250 Orders, Timestamp final | +| T5 | Spike, 150 neue Orders | Timestamp gesetzt | Lauf starten | 150 Orders, Caller-Schleife durch maxmanuell auf 100 gekappt; Folgelauf holt Rest | +| T6 | Rueckstand, >100 neue Orders | Timestamp gesetzt | Lauf 1 starten | 100 Orders (maxmanuell-Cap), Cursor fortgeschrieben | +| T6b | Rueckstand Teil 2 | nach T6 | Lauf 2 starten | restliche Orders bis maxmanuell-Cap, Timestamp final | | T7 | Transition, Shop mit `ab_nummer`=12345, Timestamp leer | `ab_nummer=12345` | Lauf starten | `ab_nummer` in Timestamp uebersetzt, keine Duplikate | -| T8 | Abbruch mitten im Lauf | 50 von 100 verarbeitet, dann Exception | neuer Lauf | wieder bei Order 51 starten, nicht doppelt importiert | +| T8 | Abbruch mitten im Lauf | Exception nach Fetch, vor INSERT | neuer Lauf | bei Abbruch zwischen Fetch und Insert max. 1 Order verloren; ab naechster Order fortgesetzt | | T9 | Idempotenz | T4 erfolgreich gelaufen | T4 nochmal starten | 0 neue Orders, keine Duplikate | | T10 | URL-Laenge (Regression) | 800 alte Orders existieren | Lauf starten | URL bleibt kurz (<2 KB), keine `include`-Liste mehr | @@ -304,6 +298,7 @@ Manuelle Integrationstests mit seeded Test-Orders. | `orderby=date` Ordering nicht deterministisch bei gleichem Timestamp | niedrig | niedrig | Zusaetzlich `orderby=date&order=asc` und WC liefert stabile Sekundaersortierung nach ID | | Andere Shopimporter erben Basisklasse-Struktur | niedrig | niedrig | Nur `shopimporter_woocommerce.php` anfassen, `ShopimporterBase` nicht anruehren | | URL-Laengenlimits der WAF beim `status[]`-Array | sehr niedrig | niedrig | Typisch 2-3 Status-Werte, URL bleibt kurz | +| Caller-Kontrakt-Abweichung (1 vs. n Orders) | niedrig | hoch | Per Design Single-Order-Return; Caller-Loop fuer Volume-Handling | ## 9. Definition of Done From d32c4c0a34dc2ec57b85117f42596c302a0e4278 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 17:36:40 +0200 Subject: [PATCH 11/30] fix(woocommerce): return null (not empty array) from ImportGetAuftrag when nothing to import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shopimport.php:1308 gates on is_array($result), which happily accepts [] and then crashes trying to dereference $result[0]['id']. The pre-#262 behaviour returned null on empty — restore that. Spotted by review of 7af9edb7. --- www/pages/shopimporter_woocommerce.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index b90f64062..8412e65ea 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -158,16 +158,16 @@ public function ImportGetAuftrag() ]); } catch (Exception $e) { $this->logger->warning('WooCommerce ImportGetAuftrag: ' . $e->getMessage()); - return []; + return null; } if (empty($pageOrders)) { - return []; + return null; } $wcOrder = $pageOrders[0] ?? null; if ($wcOrder === null) { - return []; + return null; } $order = $this->parseOrder($wcOrder); From 709991647c798d0b55391d32a859c60d453fd4cf Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 17:48:04 +0200 Subject: [PATCH 12/30] fix(woocommerce): remove broken getKonfig() re-init in ImportGetAuftrag The defensive getKonfig($data['shopid'] ?? null, $data) call inside ImportGetAuftrag() is actively harmful: CatchRemoteCommand('data') returns the getauftrag-payload, which does not carry a shopid (see class.remote.php:194/241). The re-init therefore clears $this->shopid and rebuilds the WCClient from empty preferences. RemoteCommand() already initialises the importer with the real shop id before dispatch (class.remote.php:2685), so the duplicate call is both redundant and broken. Spotted by re-review of #262. --- www/pages/shopimporter_woocommerce.php | 1 - 1 file changed, 1 deletion(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 8412e65ea..5904f4729 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -135,7 +135,6 @@ public function ImportGetAuftraegeAnzahl() public function ImportGetAuftrag() { $data = $this->CatchRemoteCommand('data'); - $this->getKonfig($data['shopid'] ?? null, $data); // Transition: ab_nummer -> timestamp once, if we still have the fallback. if ($this->lastImportTimestampIsFallback && !empty($data['ab_nummer'])) { From 0af53efac6237812499272418cf22b272ca48420 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 17:48:48 +0200 Subject: [PATCH 13/30] fix(woocommerce): run ab_nummer migration in the count path, too shopimport.php calls RemoteGetAuftraegeAnzahl() before RemoteGetAuftrag() in its main flow. If the stored cursor is still the 30-day fallback and all pending orders are older than 30 days, the count returns 0 and ImportGetAuftrag() never runs, so the one-shot ab_nummer -> timestamp migration never fires and the shop stays on the fallback forever. Extract the migration into a private helper and invoke it from both count and fetch paths. Idempotent via lastImportTimestampIsFallback. --- www/pages/shopimporter_woocommerce.php | 31 ++++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 5904f4729..3f9d2f78e 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -92,6 +92,8 @@ public function ImportList() */ public function ImportGetAuftraegeAnzahl() { + $this->migrateAbNummerIfNeeded(); + $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); try { @@ -136,13 +138,7 @@ public function ImportGetAuftrag() { $data = $this->CatchRemoteCommand('data'); - // Transition: ab_nummer -> timestamp once, if we still have the fallback. - if ($this->lastImportTimestampIsFallback && !empty($data['ab_nummer'])) { - $resolved = $this->resolveAbNummerToTimestamp((int) $data['ab_nummer']); - if ($resolved !== null) { - $this->persistLastImportTimestamp($resolved); - } - } + $this->migrateAbNummerIfNeeded(); $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); @@ -214,6 +210,27 @@ private function resolveAbNummerToTimestamp($abNummer) return gmdate('Y-m-d\TH:i:s', $ts - 1); } + /** + * Runs the one-shot legacy ab_nummer -> timestamp migration when the stored + * cursor is still the 30-day fallback and the caller passes an ab_nummer. + * Idempotent: once migrated, lastImportTimestampIsFallback flips to false + * and subsequent calls become no-ops. + */ + private function migrateAbNummerIfNeeded() + { + if (!$this->lastImportTimestampIsFallback) { + return; + } + $data = $this->CatchRemoteCommand('data'); + if (empty($data['ab_nummer'])) { + return; + } + $resolved = $this->resolveAbNummerToTimestamp((int) $data['ab_nummer']); + if ($resolved !== null) { + $this->persistLastImportTimestamp($resolved); + } + } + // This function searches the wcOrder for the specified WC Meta key // and returns it if found, null otherise public function get_wc_meta($wcOrder, $meta_key) From 78545a4ad475978b9c28ca04297940fe17f37588 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 17:50:13 +0200 Subject: [PATCH 14/30] fix(woocommerce): tuple cursor (ts, id) to survive same-second orders The after-filter is strictly-greater-than, so orders sharing an identical date_created_gmt with the last processed order were silently dropped. Move to a tuple cursor: persist both timestamp and order id, query with after= plus exclude=[last_id]. Orders with the same GMT second now reach the caller in subsequent iterations without duplicating the already-processed one. Schema is additive (new felder.letzter_import_order_id key in shopexport.einstellungen_json). Persistence helper becomes persistLastImportCursor; the single-argument persistLastImportTimestamp remains as a wrapper so the ab_nummer migration path keeps working without a second rewrite. --- www/pages/shopimporter_woocommerce.php | 77 +++++++++++++++++++------- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 3f9d2f78e..dc2c6648f 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -69,6 +69,9 @@ class Shopimporter_Woocommerce extends ShopimporterBase /** @var bool $lastImportTimestampIsFallback True when lastImportTimestamp was computed as 30-day fallback */ public $lastImportTimestampIsFallback = false; + /** @var int|null $lastImportOrderId WooCommerce order ID of the last successfully imported order */ + public $lastImportOrderId = null; + public function __construct($app, $intern = false) { $this->app = $app; @@ -96,12 +99,18 @@ public function ImportGetAuftraegeAnzahl() $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); + $afterTs = gmdate('Y-m-d\TH:i:s', max(0, strtotime($this->lastImportTimestamp) - 1)); + $queryArgs = [ + 'status' => $configuredStatuses, + 'after' => $afterTs, + 'per_page' => 1, + ]; + if (!empty($this->lastImportOrderId)) { + $queryArgs['exclude'] = [(int) $this->lastImportOrderId]; + } + try { - $this->client->get('orders', [ - 'status' => $configuredStatuses, - 'after' => $this->lastImportTimestamp, - 'per_page' => 1, - ]); + $this->client->get('orders', $queryArgs); } catch (Exception $e) { $this->logger->warning('WooCommerce ImportGetAuftraegeAnzahl: API request failed: ' . $e->getMessage()); return 0; @@ -142,15 +151,21 @@ public function ImportGetAuftrag() $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); + $afterTs = gmdate('Y-m-d\TH:i:s', max(0, strtotime($this->lastImportTimestamp) - 1)); + $queryArgs = [ + 'status' => $configuredStatuses, + 'after' => $afterTs, + 'per_page' => 1, + 'page' => 1, + 'orderby' => 'date', + 'order' => 'asc', + ]; + if (!empty($this->lastImportOrderId)) { + $queryArgs['exclude'] = [(int) $this->lastImportOrderId]; + } + try { - $pageOrders = $this->client->get('orders', [ - 'status' => $configuredStatuses, - 'after' => $this->lastImportTimestamp, - 'per_page' => 1, - 'page' => 1, - 'orderby' => 'date', - 'order' => 'asc', - ]); + $pageOrders = $this->client->get('orders', $queryArgs); } catch (Exception $e) { $this->logger->warning('WooCommerce ImportGetAuftrag: ' . $e->getMessage()); return null; @@ -167,10 +182,11 @@ public function ImportGetAuftrag() $order = $this->parseOrder($wcOrder); - // Persist progress for this order so the next Caller-iteration gets the - // next order via the after-filter. - if (!empty($wcOrder->date_created_gmt)) { - $this->persistLastImportTimestamp((string) $wcOrder->date_created_gmt); + // Persist tuple cursor so the next Caller-iteration advances past this order. + // Using ts-1s in the query means same-second peers are still fetched, while + // the exclude=[id] parameter prevents re-delivering this exact order. + if (!empty($wcOrder->date_created_gmt) && !empty($wcOrder->id)) { + $this->persistLastImportCursor((string) $wcOrder->date_created_gmt, (int) $wcOrder->id); } return [[ @@ -925,16 +941,33 @@ public function getKonfig($shopid, $data) $this->lastImportTimestampIsFallback = true; } + $this->lastImportOrderId = isset($preferences['felder']['letzter_import_order_id']) + ? (int) $preferences['felder']['letzter_import_order_id'] + : null; + } /** - * Persists the last successful import timestamp to shopexport.einstellungen_json. - * Does a read-modify-write to preserve all other fields. + * Backwards-compatible wrapper: persists timestamp only (order id cleared). + * Use persistLastImportCursor() when both timestamp and order id are available. * * @param string $isoUtcDate ISO-8601 UTC timestamp, e.g. '2026-04-20T12:34:56' * @return void */ public function persistLastImportTimestamp($isoUtcDate) + { + $this->persistLastImportCursor($isoUtcDate, null); + } + + /** + * Persists the tuple cursor (timestamp + order id) to shopexport.einstellungen_json. + * Does a read-modify-write to preserve all other fields. + * + * @param string $isoUtcDate ISO-8601 UTC timestamp, e.g. '2026-04-20T12:34:56' + * @param int|null $orderId WooCommerce order ID, or null to clear + * @return void + */ + public function persistLastImportCursor($isoUtcDate, $orderId = null) { $shopid = (int)$this->shopid; // Prefer DatabaseService when available (web context), fall back to DB @@ -957,6 +990,11 @@ public function persistLastImportTimestamp($isoUtcDate) $einstellungen['felder'] = []; } $einstellungen['felder']['letzter_import_timestamp'] = $isoUtcDate; + if ($orderId !== null) { + $einstellungen['felder']['letzter_import_order_id'] = (int) $orderId; + } else { + unset($einstellungen['felder']['letzter_import_order_id']); + } $jsonEncoded = $this->app->DB->real_escape_string(json_encode($einstellungen)); if (!empty($this->app->DatabaseService)) { $this->app->DatabaseService->execute( @@ -969,6 +1007,7 @@ public function persistLastImportTimestamp($isoUtcDate) ); } $this->lastImportTimestamp = $isoUtcDate; + $this->lastImportOrderId = $orderId !== null ? (int) $orderId : null; } /** From f3055b5298ef9b32cb81806dabfc1eb96d709ac7 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 17:51:03 +0200 Subject: [PATCH 15/30] docs: extend pagination plan to cover tuple cursor and count-path migration Reflects re-review findings: tuple cursor (ts, id) for same-second order resilience, migration helper called from both count and fetch paths, scope list updated to match current single-order design. --- docs/plans/woocommerce-pagination-fix.md | 29 ++++++++++++++---------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/plans/woocommerce-pagination-fix.md b/docs/plans/woocommerce-pagination-fix.md index 1ecd9a5c6..f367255eb 100644 --- a/docs/plans/woocommerce-pagination-fix.md +++ b/docs/plans/woocommerce-pagination-fix.md @@ -18,7 +18,7 @@ und verlustfrei, ohne neue Plugin-Abhängigkeiten und ohne Wechsel der API-Versi ### IN-Scope -- Pagination-Loop beim Bestellabruf (`X-WP-TotalPages`-Header auswerten) +- Single-Order-Abruf mit `after`-Filter + `exclude`-Tupel-Cursor; Caller-Loop iteriert. - `after`-Filter (ISO-8601) statt 800er-`include`-Hack - Persistenz "letzter erfolgreicher Import-Timestamp" pro Shop - Fallback-Logik für Erstlauf (kein Timestamp vorhanden) @@ -41,6 +41,7 @@ und verlustfrei, ohne neue Plugin-Abhängigkeiten und ohne Wechsel der API-Versi | Persistenz-Feld | `shopexport.einstellungen_json` → `felder.letzter_import_timestamp` | Bestehende Struktur nutzen, keine DB-Migration. | | Timestamp-Format | ISO-8601 `Y-m-d\TH:i:s` (UTC) | Direkt an `after=` durchreichbar. | | Caller-Cap (pre-existing) | `shopexport.maxmanuell`, default 100 | Der Batch-Cap liegt beim shopimport.php-Caller, nicht im Importer. | +| Cursor-Format | Tupel `(letzter_import_timestamp, letzter_import_order_id)` | Loest Same-Second-Kollisionen via after=ts-1 + exclude=[id]. | ## 4. Datei- und Funktions-Landkarte @@ -143,14 +144,9 @@ SELECT einstellungen_json FROM shopexport WHERE id = **Neu — Single-Order-Pseudocode:** ``` -response = client.get('orders', { - status[]: configuredStatuses, - after: this.lastImportTimestamp, - per_page: 1, - page: 1, - orderby: 'date', - order: 'asc', -}) +afterTs = ts-1s (falls ts gesetzt) +query = orders?after=&per_page=1&orderby=date&order=asc + (+ exclude=[last_id] wenn last_id bekannt) if response is empty: return [] @@ -158,9 +154,7 @@ if response is empty: wcOrder = response[0] order = parseOrder(wcOrder) -# Cursor auf diese Order setzen, damit naechste Caller-Iteration -# die naechste Order holt. -this.persistLastImportTimestamp(wcOrder.date_created_gmt) +persistLastImportCursor(order.date_created_gmt, order.id) return [{ id: order.auftrag, sessionid: '', logdatei: '', warenkorb: ... }] ``` @@ -186,6 +180,16 @@ Der after-Filter sorgt dafuer, dass jede Iteration die naechste Order holt. Ein Crash zwischen `RemoteGetAuftrag()` und `shopimport_auftraege`- Insert verliert max. diese eine Order. +### Schritt 5b — Migration-Helper + +Um die ab_nummer → timestamp Migration auch im Count-Pfad auszufuehren +(shopimport.php ruft ImportGetAuftraegeAnzahl() BEFORE ImportGetAuftrag()): + +- Extraktion in eine private Methode `migrateAbNummerIfNeeded()`. +- Aufruf am Anfang von beiden ImportGetAuftraegeAnzahl() und ImportGetAuftrag(). +- Idempotent durch `$lastImportTimestampIsFallback`-Check — nach einmaliger Migration + keine weiteren Reads. + ### Schritt 6 — Transitions-Kompatibilitaet `ab_nummer` **Ist-Zustand:** `CatchRemoteCommand('data')` liefert u.a. `ab_nummer` — die naechste @@ -299,6 +303,7 @@ Manuelle Integrationstests mit seeded Test-Orders. | Andere Shopimporter erben Basisklasse-Struktur | niedrig | niedrig | Nur `shopimporter_woocommerce.php` anfassen, `ShopimporterBase` nicht anruehren | | URL-Laengenlimits der WAF beim `status[]`-Array | sehr niedrig | niedrig | Typisch 2-3 Status-Werte, URL bleibt kurz | | Caller-Kontrakt-Abweichung (1 vs. n Orders) | niedrig | hoch | Per Design Single-Order-Return; Caller-Loop fuer Volume-Handling | +| Timestamp-Kollision bei identischem date_created_gmt | niedrig | mittel | Adressiert durch Tupel-Cursor (ts, id) + exclude-Parameter | ## 9. Definition of Done From dbddc49f5c8c3bace59e26bf47220b51ef820416 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 18:54:45 +0200 Subject: [PATCH 16/30] fix(woocommerce): accumulate same-second order ids in cursor, gate -1s offset Bei identischem date_created_gmt mehrerer Orders hielt exclude nur die zuletzt importierte ID. Nach zwei Orders im selben Bucket wurde die erste wieder sichtbar und Count- wie Fetch-Pfad liefen in eine Endlosschleife. Cursor persistiert jetzt die komplette Liste aller IDs innerhalb des aktuellen Sekunden-Buckets (felder.letzter_import_order_ids als JSON- Array). Bei Bucket-Wechsel wird die Liste zurueckgesetzt; bei gleichem Bucket wird die neue ID angehaengt. Gleichzeitig wird die -1s-Korrektur am Query gated: nur wenn mindestens eine exclude-ID bekannt ist, wird der after-Filter um 1 Sekunde nach hinten verschoben. Dadurch entfaellt die Doppel-Subtraktion nach der ab_nummer-Migration (resolveAbNummerToTimestamp schon -1s, Query war nochmal -1s -> 2s zurueck in der Vergangenheit). Der Erstlauf nach Migration liefert jetzt exakt die ab_nummer-Order als Startpunkt. Co-Authored-By: Claude Sonnet 4.6 --- www/pages/shopimporter_woocommerce.php | 117 +++++++++++++++++-------- 1 file changed, 79 insertions(+), 38 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index dc2c6648f..9eccc455a 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -69,8 +69,8 @@ class Shopimporter_Woocommerce extends ShopimporterBase /** @var bool $lastImportTimestampIsFallback True when lastImportTimestamp was computed as 30-day fallback */ public $lastImportTimestampIsFallback = false; - /** @var int|null $lastImportOrderId WooCommerce order ID of the last successfully imported order */ - public $lastImportOrderId = null; + /** @var int[] $lastImportOrderIds WooCommerce order IDs within the current timestamp bucket */ + public $lastImportOrderIds = []; public function __construct($app, $intern = false) { @@ -99,14 +99,20 @@ public function ImportGetAuftraegeAnzahl() $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); - $afterTs = gmdate('Y-m-d\TH:i:s', max(0, strtotime($this->lastImportTimestamp) - 1)); - $queryArgs = [ - 'status' => $configuredStatuses, - 'after' => $afterTs, - 'per_page' => 1, - ]; - if (!empty($this->lastImportOrderId)) { - $queryArgs['exclude'] = [(int) $this->lastImportOrderId]; + if (!empty($this->lastImportOrderIds)) { + $afterTs = gmdate('Y-m-d\TH:i:s', max(0, strtotime($this->lastImportTimestamp) - 1)); + $queryArgs = [ + 'status' => $configuredStatuses, + 'after' => $afterTs, + 'per_page' => 1, + 'exclude' => array_values($this->lastImportOrderIds), + ]; + } else { + $queryArgs = [ + 'status' => $configuredStatuses, + 'after' => $this->lastImportTimestamp, + 'per_page' => 1, + ]; } try { @@ -151,17 +157,26 @@ public function ImportGetAuftrag() $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); - $afterTs = gmdate('Y-m-d\TH:i:s', max(0, strtotime($this->lastImportTimestamp) - 1)); - $queryArgs = [ - 'status' => $configuredStatuses, - 'after' => $afterTs, - 'per_page' => 1, - 'page' => 1, - 'orderby' => 'date', - 'order' => 'asc', - ]; - if (!empty($this->lastImportOrderId)) { - $queryArgs['exclude'] = [(int) $this->lastImportOrderId]; + if (!empty($this->lastImportOrderIds)) { + $afterTs = gmdate('Y-m-d\TH:i:s', max(0, strtotime($this->lastImportTimestamp) - 1)); + $queryArgs = [ + 'status' => $configuredStatuses, + 'after' => $afterTs, + 'per_page' => 1, + 'page' => 1, + 'orderby' => 'date', + 'order' => 'asc', + 'exclude' => array_values($this->lastImportOrderIds), + ]; + } else { + $queryArgs = [ + 'status' => $configuredStatuses, + 'after' => $this->lastImportTimestamp, + 'per_page' => 1, + 'page' => 1, + 'orderby' => 'date', + 'order' => 'asc', + ]; } try { @@ -941,9 +956,10 @@ public function getKonfig($shopid, $data) $this->lastImportTimestampIsFallback = true; } - $this->lastImportOrderId = isset($preferences['felder']['letzter_import_order_id']) - ? (int) $preferences['felder']['letzter_import_order_id'] - : null; + $storedIds = $preferences['felder']['letzter_import_order_ids'] ?? null; + $this->lastImportOrderIds = is_array($storedIds) + ? array_values(array_filter(array_map('intval', $storedIds))) + : []; } @@ -960,11 +976,17 @@ public function persistLastImportTimestamp($isoUtcDate) } /** - * Persists the tuple cursor (timestamp + order id) to shopexport.einstellungen_json. - * Does a read-modify-write to preserve all other fields. + * Persists the tuple cursor (timestamp + accumulated order-id bucket) to + * shopexport.einstellungen_json. Does a read-modify-write to preserve all + * other fields. + * + * Bucket logic: + * - $orderId === null → migration path; ids list is cleared. + * - same timestamp as stored → append $orderId to the ids list. + * - new timestamp → reset ids list to [$orderId]. * * @param string $isoUtcDate ISO-8601 UTC timestamp, e.g. '2026-04-20T12:34:56' - * @param int|null $orderId WooCommerce order ID, or null to clear + * @param int|null $orderId WooCommerce order ID, or null (migration path) * @return void */ public function persistLastImportCursor($isoUtcDate, $orderId = null) @@ -982,24 +1004,43 @@ public function persistLastImportCursor($isoUtcDate, $orderId = null) "SELECT einstellungen_json FROM shopexport WHERE id = '$shopid' LIMIT 1" ); } - $einstellungen = []; + $current = []; if (!empty($einstellungen_json)) { - $einstellungen = json_decode($einstellungen_json, true) ?: []; + $current = json_decode($einstellungen_json, true) ?: []; + } + if (!isset($current['felder']) || !is_array($current['felder'])) { + $current['felder'] = []; } - if (!isset($einstellungen['felder']) || !is_array($einstellungen['felder'])) { - $einstellungen['felder'] = []; + + $previousTs = $current['felder']['letzter_import_timestamp'] ?? null; + $previousIds = $current['felder']['letzter_import_order_ids'] ?? []; + if (!is_array($previousIds)) { + $previousIds = []; } - $einstellungen['felder']['letzter_import_timestamp'] = $isoUtcDate; - if ($orderId !== null) { - $einstellungen['felder']['letzter_import_order_id'] = (int) $orderId; + + // Determine ids list for the new state. + if ($orderId === null) { + // Migration path — timestamp without a concrete order-id anchor. + $newIds = []; + } elseif ($previousTs !== null && $isoUtcDate === $previousTs) { + // Same timestamp bucket — append id if not already present. + $newIds = $previousIds; + if (!in_array((int) $orderId, $newIds, true)) { + $newIds[] = (int) $orderId; + } } else { - unset($einstellungen['felder']['letzter_import_order_id']); + // New timestamp bucket — reset list to only this id. + $newIds = [(int) $orderId]; } - $jsonEncoded = $this->app->DB->real_escape_string(json_encode($einstellungen)); + + $current['felder']['letzter_import_timestamp'] = $isoUtcDate; + $current['felder']['letzter_import_order_ids'] = $newIds; + + $jsonEncoded = $this->app->DB->real_escape_string(json_encode($current)); if (!empty($this->app->DatabaseService)) { $this->app->DatabaseService->execute( "UPDATE shopexport SET einstellungen_json = :json WHERE id = :id", - ['json' => json_encode($einstellungen), 'id' => $shopid] + ['json' => json_encode($current), 'id' => $shopid] ); } else { $this->app->DB->Update( @@ -1007,7 +1048,7 @@ public function persistLastImportCursor($isoUtcDate, $orderId = null) ); } $this->lastImportTimestamp = $isoUtcDate; - $this->lastImportOrderId = $orderId !== null ? (int) $orderId : null; + $this->lastImportOrderIds = $newIds; } /** From e6dd616edf57d79bea246f13e101f873d782337e Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 18:55:19 +0200 Subject: [PATCH 17/30] docs: document bucket-accumulating cursor and gated -1s offset Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/woocommerce-pagination-fix.md | 25 +++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/plans/woocommerce-pagination-fix.md b/docs/plans/woocommerce-pagination-fix.md index f367255eb..5d874b990 100644 --- a/docs/plans/woocommerce-pagination-fix.md +++ b/docs/plans/woocommerce-pagination-fix.md @@ -41,7 +41,7 @@ und verlustfrei, ohne neue Plugin-Abhängigkeiten und ohne Wechsel der API-Versi | Persistenz-Feld | `shopexport.einstellungen_json` → `felder.letzter_import_timestamp` | Bestehende Struktur nutzen, keine DB-Migration. | | Timestamp-Format | ISO-8601 `Y-m-d\TH:i:s` (UTC) | Direkt an `after=` durchreichbar. | | Caller-Cap (pre-existing) | `shopexport.maxmanuell`, default 100 | Der Batch-Cap liegt beim shopimport.php-Caller, nicht im Importer. | -| Cursor-Format | Tupel `(letzter_import_timestamp, letzter_import_order_id)` | Loest Same-Second-Kollisionen via after=ts-1 + exclude=[id]. | +| Cursor-Format | Tupel `(letzter_import_timestamp, letzter_import_order_ids: [int])` | IDs sammeln den aktuellen Sekunden-Bucket; -1s-Verschiebung greift nur mit mindestens einer ID. | ## 4. Datei- und Funktions-Landkarte @@ -144,9 +144,16 @@ SELECT einstellungen_json FROM shopexport WHERE id = **Neu — Single-Order-Pseudocode:** ``` -afterTs = ts-1s (falls ts gesetzt) -query = orders?after=&per_page=1&orderby=date&order=asc - (+ exclude=[last_id] wenn last_id bekannt) +# Fenster aufbauen +if last_order_ids is not empty: + after = last_ts - 1s + exclude = last_order_ids +else: + after = last_ts + exclude = (nicht gesetzt) + +# Fetch +order = client.get(orders, after=after, exclude=exclude, per_page=1, orderby=date, order=asc)[0] if response is empty: return [] @@ -154,6 +161,13 @@ if response is empty: wcOrder = response[0] order = parseOrder(wcOrder) +# Persist +if order.date_created_gmt == last_ts: + last_order_ids = last_order_ids + [order.id] +else: + last_ts = order.date_created_gmt + last_order_ids = [order.id] + persistLastImportCursor(order.date_created_gmt, order.id) return [{ id: order.auftrag, sessionid: '', logdatei: '', warenkorb: ... }] @@ -303,7 +317,8 @@ Manuelle Integrationstests mit seeded Test-Orders. | Andere Shopimporter erben Basisklasse-Struktur | niedrig | niedrig | Nur `shopimporter_woocommerce.php` anfassen, `ShopimporterBase` nicht anruehren | | URL-Laengenlimits der WAF beim `status[]`-Array | sehr niedrig | niedrig | Typisch 2-3 Status-Werte, URL bleibt kurz | | Caller-Kontrakt-Abweichung (1 vs. n Orders) | niedrig | hoch | Per Design Single-Order-Return; Caller-Loop fuer Volume-Handling | -| Timestamp-Kollision bei identischem date_created_gmt | niedrig | mittel | Adressiert durch Tupel-Cursor (ts, id) + exclude-Parameter | +| Timestamp-Kollision bei identischem date_created_gmt | niedrig | mittel | Adressiert durch Tupel-Cursor + Bucket-Akkumulation (letzter_import_order_ids sammelt alle IDs des Buckets) | +| Unbounded exclude-Liste bei grossem Bucket | niedrig | niedrig | In der Praxis kleine Listen; URL < 2 KB bei ~100 IDs | ## 9. Definition of Done From 726f2eb2c379da3b01070257f4c777cd47b48b23 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 21 Apr 2026 13:55:03 +0200 Subject: [PATCH 18/30] fix(woocommerce): persist fallback cursor when ab_nummer resolution fails If resolveAbNummerToTimestamp() cannot find the referenced legacy order (404, missing date_created_gmt, etc.) the migration previously left the importer on the volatile 30-day fallback, which is recomputed on every run as now()-30d. The cron cycle would then re-scan the same rolling window forever, multiplying API load and caller-dedup activity. On resolution failure we now explicitly persist the current fallback timestamp so the fallback flag flips to false and the lower bound stays stable across runs. Also emits a warning so the operator can spot the stale ab_nummer. Spotted by review of #265. --- www/pages/shopimporter_woocommerce.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 9eccc455a..4f63e047a 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -259,7 +259,18 @@ private function migrateAbNummerIfNeeded() $resolved = $this->resolveAbNummerToTimestamp((int) $data['ab_nummer']); if ($resolved !== null) { $this->persistLastImportTimestamp($resolved); + return; } + // Resolution failed (order deleted, 404, missing date_created_gmt). Persist the + // current 30-day fallback so subsequent runs use a stable lower bound + // instead of sliding the window on every cron cycle. + $this->logger->warning( + sprintf( + 'WooCommerce ab_nummer=%d konnte nicht aufgeloest werden; persistiere 30-Tage-Fallback als Cursor', + (int) $data['ab_nummer'] + ) + ); + $this->persistLastImportTimestamp($this->lastImportTimestamp); } // This function searches the wcOrder for the specified WC Meta key From 11e179508029ab1303ae9100257019291f2b915d Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 21:40:07 +0200 Subject: [PATCH 19/30] feat(woocommerce): use products/batch endpoints for stock sync Previously, stock sync ran two HTTP requests per article (SKU lookup + update). For n articles this was 2n roundtrips; at 1000 articles that is 2000 requests, easily tripping hoster rate limits and aborting the sync partway through. Switch to the official WC REST v3 batch endpoints: - Collect all SKUs up front, resolve them in one or a few products?sku=&per_page=100 lookups (map sku -> id). - Send stock updates in chunks of up to 100 items via POST products/batch. - Variations go through POST products/{parent}/variations/batch, grouped per parent product. Partial errors in a batch response are logged per SKU without aborting the rest of the sync. At 1000 articles this reduces request count from 2000 to roughly 15-20. WCClient::post() accepts array data and JSON-encodes it directly -- no new postBatch() helper needed. Closes #263. Co-Authored-By: Claude Sonnet 4.6 --- www/pages/shopimporter_woocommerce.php | 159 +++++++++++++++++++++---- 1 file changed, 133 insertions(+), 26 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 4f63e047a..4243c437d 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -559,8 +559,11 @@ public function ImportUpdateAuftrag() } /** - * This function syncs the current stock to the remote WooCommerce shop - * @return int + * This function syncs the current stock to the remote WooCommerce shop. + * Uses WC REST v3 batch endpoints to reduce HTTP round-trips from 2n to + * roughly ceil(n/100) + ceil(n/100) requests. + * + * @return int Number of articles successfully synced * @throws WCHttpClientException */ public function ImportSendListLager() @@ -569,8 +572,12 @@ public function ImportSendListLager() $anzahl = 0; $ctmp = (!empty($tmp) ? count($tmp) : 0); + // --- Step 1: Collect all SKUs and compute desired stock params --- + + // $pendingUpdates: sku => ['lageranzahl' => int, 'status' => string] + $pendingUpdates = []; + for ($i = 0; $i < $ctmp; $i++) { - // Get important values from input data $artikel = $tmp[$i]['artikel']; if ($artikel === 'ignore') { continue; @@ -584,52 +591,152 @@ public function ImportSendListLager() $inaktiv = $tmp[$i]['inaktiv']; $status = 'publish'; - // Do some computations, sanitize input - if ($pseudolager !== '') { $lageranzahl = $pseudolager; } - if ($tmp[$i]['ausverkauft']) { $lageranzahl = 0; } - if ($inaktiv) { $status = 'private'; } - // get the product id that WooCommerce uses to represent the current article - $remoteIdInformation = $this->getShopIdBySKU($nummer); + $pendingUpdates[$nummer] = [ + 'lageranzahl' => $lageranzahl, + 'status' => $status, + ]; + } + + if (empty($pendingUpdates)) { + return 0; + } + + // --- Step 2: Bulk-resolve SKUs to WC product IDs --- + // WC REST v3 accepts a comma-separated list in the ?sku= parameter. + // We fetch in chunks of 100 to stay within per_page limits. - if (empty($remoteIdInformation['id'])) { - // The online shop doesnt know this article, write to log and continue with next product + // $skuMap: sku => ['id' => int, 'parent' => int, 'isvariant' => bool] + $skuMap = []; + $skuChunks = array_chunk(array_keys($pendingUpdates), 100); - $this->logger->error("WooCommerce Artikel $nummer wurde im Online-Shop nicht gefunden! Falsche Artikelnummer im Shop hinterlegt?"); + foreach ($skuChunks as $skuChunk) { + $skuCsv = implode(',', $skuChunk); + $products = $this->client->get('products', [ + 'sku' => $skuCsv, + 'per_page' => 100, + ]); + if (!is_array($products)) { continue; } + foreach ($products as $product) { + if (!isset($product->sku)) { + continue; + } + $skuMap[$product->sku] = [ + 'id' => $product->id, + 'parent' => $product->parent_id, + 'isvariant' => !empty($product->parent_id), + ]; + } + } + + // --- Step 3: Split into simple products and variations --- + // simpleItems: list of batch-update items for POST products/batch + // variationItems: parent_id => list of batch-update items for POST products/{parent}/variations/batch - // Sync settings to online store - $updateProductParams = [ + $simpleItems = []; + $variationItems = []; + + foreach ($pendingUpdates as $sku => $params) { + if (!isset($skuMap[$sku])) { + $this->logger->error( + "WooCommerce Artikel $sku wurde im Online-Shop nicht gefunden! Falsche Artikelnummer im Shop hinterlegt?" + ); + continue; + } + + $info = $skuMap[$sku]; + $item = [ + 'id' => $info['id'], 'manage_stock' => true, - 'status' => $status, - 'stock_quantity' => $lageranzahl - // WooCommerce doesnt have a standard property for the other values, we're ignoring them + 'stock_quantity' => $params['lageranzahl'], + 'status' => $params['status'], ]; - if ($remoteIdInformation['isvariant']) { - $result = $this->client->put('products/' . $remoteIdInformation['parent'] . '/variations/' . $remoteIdInformation['id'], $updateProductParams); + + if ($info['isvariant']) { + $variationItems[$info['parent']][] = $item; } else { - $result = $this->client->put('products/' . $remoteIdInformation['id'], $updateProductParams); + $simpleItems[] = $item; } + } + + // --- Step 4: Send batch updates in chunks of 100, handle partial errors --- + + // Simple products + foreach (array_chunk($simpleItems, 100) as $chunk) { + $response = $this->client->post('products/batch', ['update' => $chunk]); + $anzahl += $this->processBatchResponse($response, 'products/batch'); + } + + // Variations (one batch endpoint per parent product) + foreach ($variationItems as $parentId => $items) { + foreach (array_chunk($items, 100) as $chunk) { + $endpoint = 'products/' . $parentId . '/variations/batch'; + $response = $this->client->post($endpoint, ['update' => $chunk]); + $anzahl += $this->processBatchResponse($response, $endpoint); + } + } + + return $anzahl; + } + + /** + * Evaluates a WC batch response object, logs per-item errors, and returns + * the count of successfully updated items. + * + * @param object $response Decoded JSON response from the batch endpoint. + * @param string $endpoint Endpoint label used in log messages. + * @return int Number of items reported as updated without error. + */ + private function processBatchResponse($response, $endpoint) + { + $successCount = 0; + if (!is_object($response) && !is_array($response)) { + $this->logger->error("WooCommerce Batch-Response ungueltig fuer $endpoint"); + return 0; + } + + // Successful updates are in response->update + $updated = is_object($response) ? ($response->update ?? []) : []; + foreach ($updated as $item) { + // WC embeds per-item errors inside the update array when an item fails + if (isset($item->error)) { + $code = $item->error->code ?? ''; + $message = $item->error->message ?? ''; + $this->logger->error( + "WooCommerce Batch-Fehler ($endpoint) fuer ID {$item->id}: [$code] $message" + ); + } else { + $this->logger->error( + "WooCommerce Lagerzahlenübertragung (Batch) fuer Artikel-ID {$item->id} erfolgreich", + ['endpoint' => $endpoint] + ); + $successCount++; + } + } + + // Top-level errors array (some WC versions use this) + $errors = is_object($response) ? ($response->errors ?? []) : []; + foreach ($errors as $err) { + $code = $err->code ?? ''; + $message = $err->message ?? ''; $this->logger->error( - "WooCommerce Lagerzahlenübertragung für Artikel: $nummer / $remoteIdInformation[id] - Anzahl: $lageranzahl", - [ - 'result' => $result - ] + "WooCommerce Batch-Fehler ($endpoint): [$code] $message" ); - $anzahl++; } - return $anzahl; + + return $successCount; } public function ImportStorniereAuftrag() From d7eb2c50ed59ce2a68427ba18378d06049d8acf8 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 21:40:13 +0200 Subject: [PATCH 20/30] docs: add plan for batch stock sync Companion doc to issue #263 and the batch-refactor commit on this branch. Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/woocommerce-batch-stock-sync.md | 126 +++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/plans/woocommerce-batch-stock-sync.md diff --git a/docs/plans/woocommerce-batch-stock-sync.md b/docs/plans/woocommerce-batch-stock-sync.md new file mode 100644 index 000000000..fc9d54e2a --- /dev/null +++ b/docs/plans/woocommerce-batch-stock-sync.md @@ -0,0 +1,126 @@ +# WooCommerce Batch Stock Sync — Plan + +Companion document to [GitHub Issue #263](https://github.com/OpenXE-org/OpenXE/issues/263) +and the `feature/woocommerce-batch-stock-sync` branch. + +--- + +## §1 Ziel + +`ImportSendListLager()` bisher: 2 HTTP-Requests pro Artikel (SKU-Lookup + PUT). +Bei 1.000 Artikeln = 2.000 Requests — langsam, rate-limit-anfällig, kein Partial-Error-Handling. + +Ziel: Nutzung der offiziellen WC REST v3 Batch-Endpoints, um den Request-Count auf +`ceil(n/100)` SKU-Lookups + `ceil(n/100)` Batch-Updates zu reduzieren (~20 statt 2.000). + +--- + +## §2 Scope + +### IN +- Refactor von `ImportSendListLager()` in `www/pages/shopimporter_woocommerce.php` +- Neue private Hilfsmethode `processBatchResponse()` in derselben Klasse +- Plan-Dokument `docs/plans/woocommerce-batch-stock-sync.md` + +### OUT +- Kein neuer `postBatch()`-Helper auf `WCClient` — `post()` reicht direkt +- Keine Änderungen an `ImportGetAuftrag*`, `getKonfig`, `parseOrder` +- Kein Retry-Mechanismus bei HTTP-Fehlern (separater Task) +- Kein DB-Schema-Change +- Kein PR-Open (nach Review durch Maintainer) + +--- + +## §3 Fix-Parameter + +| Parameter | Wert | Begründung | +|---|---|---| +| Batch-Size | 100 Items | WC REST v3 Maximum | +| SKU-Chunk-Size | 100 SKUs | Passend zu `per_page=100` | +| Retry | Keiner | Out-of-scope für diesen PR | +| SKU-CSV-Support | `?sku=a,b,c` | WC REST v3 akzeptiert kommaseparierte Liste | + +--- + +## §4 Funktions-Landkarte + +Nur `ImportSendListLager()` wird geändert. Neue private Methode `processBatchResponse()`. + +``` +ImportSendListLager() + ├── Schritt 1: Alle SKUs + Stock-Params sammeln (pendingUpdates[]) + ├── Schritt 2: Bulk-SKU-Auflösung + │ └── GET products?sku=&per_page=100 (je Chunk à 100 SKUs) + │ → skuMap[sku] = {id, parent, isvariant} + ├── Schritt 3: Gruppieren + │ ├── simpleItems[] (für products/batch) + │ └── variationItems[parent_id][] (für products/{id}/variations/batch) + └── Schritt 4: Batch-Updates senden (je Chunk à 100) + ├── POST products/batch {update: [...]} + ├── POST products/{parent}/variations/batch {update: [...]} + └── processBatchResponse() → Partial-Error-Logging, zählt Erfolge + +processBatchResponse($response, $endpoint) + ├── Iteriert response->update[] + │ ├── item->error vorhanden → logger->error + │ └── kein Fehler → successCount++ + └── Iteriert response->errors[] (WC-Fallback) +``` + +--- + +## §5 Implementierungsschritte + +1. **Vorverlagerte Datensammlung:** Statt der bisherigen per-Artikel-Schleife erst alle SKUs + und Stock-Params in `$pendingUpdates[]` sammeln. + +2. **Bulk-SKU-Auflösung:** `GET products?sku=&per_page=100` in Chunks à 100 SKUs. + Ergebnis in `$skuMap[sku]` cachen. + +3. **Gruppierung:** Simple products in `$simpleItems[]`, Variations pro Parent-ID in + `$variationItems[parent_id][]`. + +4. **Batch-POST:** `$this->client->post('products/batch', ['update' => $chunk])` für + simple products; analog für Variations. + +5. **Partial-Error-Handling:** `processBatchResponse()` liest `response->update[]` und + `response->errors[]`, loggt Fehler per Item ohne den restlichen Sync abzubrechen. + +--- + +## §6 Test-Matrix + +Testumgebung: Docker-Shop auf `192.168.0.143:8080`, WC 10.7, Consumer Keys aktiv. + +| # | Szenario | Erwartetes Ergebnis | +|---|---|---| +| T1 | 1 Artikel | 1 SKU-GET + 1 Batch-POST, Lagerbestand korrekt | +| T2 | 100 Artikel | 1 SKU-GET + 1 Batch-POST (1 Chunk) | +| T3 | 250 Artikel | 3 SKU-GETs + 3 Batch-POSTs (100+100+50) | +| T4 | 1 falsche SKU in 100er-Batch | 99 korrekt updated, 1 Error geloggt, sync läuft durch | +| T5 | Variation (parent != 0) | Variation-Batch-Endpoint genutzt, nicht products/batch | +| T6 | `ausverkauft=1` | stock_quantity=0 im Batch-Item | +| T7 | `inaktiv=1` | status='private' im Batch-Item | +| T8 | `pseudolager` gesetzt | lageranzahl aus pseudolager, nicht anzahl_lager | +| T9 | Leere Artikelliste | Rückgabe 0, keine HTTP-Requests | + +--- + +## §7 Rollout & Rückwärts-Kompatibilität + +- **Interface unverändert:** `ImportSendListLager()` behält Signatur `(): int`. +- **Keine DB-Migration** erforderlich. +- **WC-Mindestversion:** REST v3 Batch-Endpoints seit WC 3.0 (2017) — unkritisch. +- **Rollback:** Git-Revert des Feature-Branch reicht. + +--- + +## §8 Risiken + +| Risiko | Mitigation | +|---|---| +| WC akzeptiert SKU-CSV nicht (`?sku=a,b`) | Verifikation in T1–T3 gegen 192.168.0.143:8080 — falls nicht, Fallback: sequentielle Einzellookups (wie vorher) | +| Batch-Response enthält keine `update`-Keys | `processBatchResponse()` defensiv mit `?? []` abgesichert | +| Hoster-seitiger `per_page`-Cap unter 100 | Derzeit nicht abgefangen; separater Task | +| Partial-Error zählt als nicht-erfolgreich | Korrekt: `$anzahl` zählt nur Items ohne Fehler | +| Variations mit unbekanntem Parent | Variation-Batch schlägt fehl → WCHttpClientException propagiert nach oben (bestehende Semantik) | From 6a71d4b390f1defe46f2bf3aad1092df1f16e3d6 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Mon, 20 Apr 2026 21:49:01 +0200 Subject: [PATCH 21/30] fix(woocommerce): catch batch request exceptions, log success at info level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processBatchResponse()-Aufrufe sind jetzt in try/catch eingeschlossen — ein einzelner WC-Batch mit HTTP 5xx/Timeout bricht den Sync nicht mehr ab, nachfolgende Chunks werden trotzdem verarbeitet. Der Erfolgs-Log in processBatchResponse() nutzt jetzt ->info() statt ->error() (war falscher Log-Level). Spotted by review of b96079b2. --- www/pages/shopimporter_woocommerce.php | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 4243c437d..18367164a 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -674,16 +674,24 @@ public function ImportSendListLager() // Simple products foreach (array_chunk($simpleItems, 100) as $chunk) { - $response = $this->client->post('products/batch', ['update' => $chunk]); - $anzahl += $this->processBatchResponse($response, 'products/batch'); + try { + $response = $this->client->post('products/batch', ['update' => $chunk]); + $anzahl += $this->processBatchResponse($response, 'products/batch'); + } catch (Exception $e) { + $this->logger->error('WooCommerce Batch-Request fehlgeschlagen fuer products/batch: ' . $e->getMessage()); + } } // Variations (one batch endpoint per parent product) foreach ($variationItems as $parentId => $items) { foreach (array_chunk($items, 100) as $chunk) { $endpoint = 'products/' . $parentId . '/variations/batch'; - $response = $this->client->post($endpoint, ['update' => $chunk]); - $anzahl += $this->processBatchResponse($response, $endpoint); + try { + $response = $this->client->post($endpoint, ['update' => $chunk]); + $anzahl += $this->processBatchResponse($response, $endpoint); + } catch (Exception $e) { + $this->logger->error('WooCommerce Batch-Request fehlgeschlagen fuer ' . $endpoint . ': ' . $e->getMessage()); + } } } @@ -718,7 +726,7 @@ private function processBatchResponse($response, $endpoint) "WooCommerce Batch-Fehler ($endpoint) fuer ID {$item->id}: [$code] $message" ); } else { - $this->logger->error( + $this->logger->info( "WooCommerce Lagerzahlenübertragung (Batch) fuer Artikel-ID {$item->id} erfolgreich", ['endpoint' => $endpoint] ); From b1bc331b180d52e5b97816dfd29e5692ca6f4e50 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 21 Apr 2026 13:58:04 +0200 Subject: [PATCH 22/30] fix(woocommerce): catch SKU-lookup exceptions per chunk in stock sync ImportSendListLager() wraps the per-chunk batch POSTs in try/catch but the upstream SKU-to-ID lookup was bare: a single failing lookup chunk (timeout, 5xx, rate-limit) aborted the whole sync before the unaffected remaining chunks could even be processed. That gave the batch refactor a larger failure domain than the pre-#266 per-item path it replaced. Lookup exceptions are now logged at error level and the loop continues with the next chunk. Items missing from the SKU map are already handled downstream (logged as not-found, skipped from the update batches). Spotted by review of #266. --- www/pages/shopimporter_woocommerce.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 18367164a..104158ca5 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -621,10 +621,18 @@ public function ImportSendListLager() foreach ($skuChunks as $skuChunk) { $skuCsv = implode(',', $skuChunk); - $products = $this->client->get('products', [ - 'sku' => $skuCsv, - 'per_page' => 100, - ]); + try { + $products = $this->client->get('products', [ + 'sku' => $skuCsv, + 'per_page' => 100, + ]); + } catch (Exception $e) { + $this->logger->error( + 'WooCommerce SKU-Lookup-Chunk fehlgeschlagen: ' . $e->getMessage(), + ['chunk_size' => count($skuChunk)] + ); + continue; + } if (!is_array($products)) { continue; } From fdd7daa6526db6f2bf87d5d6f0c0110ea9063a84 Mon Sep 17 00:00:00 2001 From: Avatarsia Date: Tue, 21 Apr 2026 10:06:29 +0200 Subject: [PATCH 23/30] refactor(woocommerce): require automattic/woocommerce composer dependency Adds automattic/woocommerce 3.1.1 via Composer. Vendor files included per project convention (vendor/ is tracked). Resolves issue #264. Co-Authored-By: Claude Sonnet 4.6 --- composer.json | 5 +- composer.lock | 109 +- vendor/automattic/woocommerce/LICENSE | 21 + vendor/automattic/woocommerce/README.md | 169 ++ vendor/automattic/woocommerce/composer.json | 42 + vendor/automattic/woocommerce/composer.lock | 1897 +++++++++++++++++ .../woocommerce/src/WooCommerce/Client.php | 109 + .../src/WooCommerce/HttpClient/BasicAuth.php | 96 + .../src/WooCommerce/HttpClient/HttpClient.php | 490 +++++ .../HttpClient/HttpClientException.php | 71 + .../src/WooCommerce/HttpClient/OAuth.php | 268 +++ .../src/WooCommerce/HttpClient/Options.php | 182 ++ .../src/WooCommerce/HttpClient/Request.php | 187 ++ .../src/WooCommerce/HttpClient/Response.php | 127 ++ vendor/composer/autoload_classmap.php | 9 + vendor/composer/autoload_psr4.php | 1 + vendor/composer/autoload_static.php | 16 +- vendor/composer/installed.json | 113 + vendor/composer/installed.php | 18 + 19 files changed, 3926 insertions(+), 4 deletions(-) create mode 100644 vendor/automattic/woocommerce/LICENSE create mode 100644 vendor/automattic/woocommerce/README.md create mode 100644 vendor/automattic/woocommerce/composer.json create mode 100644 vendor/automattic/woocommerce/composer.lock create mode 100644 vendor/automattic/woocommerce/src/WooCommerce/Client.php create mode 100644 vendor/automattic/woocommerce/src/WooCommerce/HttpClient/BasicAuth.php create mode 100644 vendor/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClient.php create mode 100644 vendor/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClientException.php create mode 100644 vendor/automattic/woocommerce/src/WooCommerce/HttpClient/OAuth.php create mode 100644 vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Options.php create mode 100644 vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Request.php create mode 100644 vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Response.php diff --git a/composer.json b/composer.json index 06e2a500e..e4cb4f323 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "swiss-payment-slip/swiss-payment-slip": "0.13.0 as 0.11.1", "swiss-payment-slip/swiss-payment-slip-fpdf": "0.6.0", "tecnickcom/tcpdf": "6.3.5", - "y0lk/oauth1-etsy": "1.1.0" + "y0lk/oauth1-etsy": "1.1.0", + "automattic/woocommerce": "^3.1" }, "replace": { "itbz/fpdf": "*" @@ -41,4 +42,4 @@ }, "optimize-autoloader": true } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 85d34929c..e42add22d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "229fd9b44bc0380415380fe519e53dfb", + "content-hash": "0e587d2db823187eb218c8737c439bff", "packages": [ { "name": "aura/sqlquery", @@ -74,6 +74,113 @@ }, "time": "2016-10-03T20:34:56+00:00" }, + { + "name": "automattic/woocommerce", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/woocommerce/wc-api-php.git", + "reference": "e378120df655b7dacbeff4756e23b41b66fe9688" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/woocommerce/wc-api-php/zipball/e378120df655b7dacbeff4756e23b41b66fe9688", + "reference": "e378120df655b7dacbeff4756e23b41b66fe9688", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "php": ">= 7.4.0" + }, + "require-dev": { + "doctrine/instantiator": "^1.5.0", + "phpunit/phpunit": "^9.5.0", + "squizlabs/php_codesniffer": "3.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Automattic\\WooCommerce\\": [ + "src/WooCommerce" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Claudio Sanches", + "email": "claudio.sanches@automattic.com" + } + ], + "description": "A PHP wrapper for the WooCommerce REST API", + "keywords": [ + "api", + "woocommerce" + ], + "support": { + "issues": "https://github.com/woocommerce/wc-api-php/issues", + "source": "https://github.com/woocommerce/wc-api-php/tree/3.1.1" + }, + "time": "2026-01-30T16:26:03+00:00" + }, + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, { "name": "aws/aws-sdk-php", "version": "3.175.2", diff --git a/vendor/automattic/woocommerce/LICENSE b/vendor/automattic/woocommerce/LICENSE new file mode 100644 index 000000000..7e67c6ccb --- /dev/null +++ b/vendor/automattic/woocommerce/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016, Automattic (https://automattic.com/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/automattic/woocommerce/README.md b/vendor/automattic/woocommerce/README.md new file mode 100644 index 000000000..2ddc4cfa7 --- /dev/null +++ b/vendor/automattic/woocommerce/README.md @@ -0,0 +1,169 @@ +# WooCommerce API - PHP Client + +A PHP wrapper for the WooCommerce REST API. Easily interact with the WooCommerce REST API securely using this library. If using a HTTPS connection this library uses BasicAuth, else it uses Oauth to provide a secure connection to WooCommerce. + +[![CI status](https://github.com/woocommerce/wc-api-php/actions/workflows/ci.yml/badge.svg?branch=trunk)](https://github.com/woocommerce/wc-api-php/actions/workflows/ci.yml) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/woocommerce/wc-api-php/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/woocommerce/wc-api-php/?branch=master) +[![PHP version](https://badge.fury.io/ph/automattic%2Fwoocommerce.svg)](https://packagist.org/packages/automattic/woocommerce) + +## Installation + +``` +composer require automattic/woocommerce +``` + +## Getting started + +Generate API credentials (Consumer Key & Consumer Secret) following this instructions +. + +Check out the WooCommerce API endpoints and data that can be manipulated in . + +## Setup + +Setup for the new WP REST API integration (WooCommerce 2.6 or later): + +```php +require __DIR__ . '/vendor/autoload.php'; + +use Automattic\WooCommerce\Client; + +$woocommerce = new Client( + 'http://example.com', + 'ck_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + 'cs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + [ + 'version' => 'wc/v3', + ] +); +``` + +## Client class + +```php +$woocommerce = new Client($url, $consumer_key, $consumer_secret, $options); +``` + +### Options + +| Option | Type | Required | Description | +| ----------------- | -------- | -------- | ------------------------------------------ | +| `url` | `string` | yes | Your Store URL, example: http://woo.dev/ | +| `consumer_key` | `string` | yes | Your API consumer key | +| `consumer_secret` | `string` | yes | Your API consumer secret | +| `options` | `array` | no | Extra arguments (see client options table) | + +#### Client options + +| Option | Type | Required | Description | +| ------------------------ | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `version` | `string` | no | API version, default is `wc/v3` | +| `timeout` | `int` | no | Request timeout, default is `15` | +| `verify_ssl` | `bool` | no | Verify SSL when connect, use this option as `false` when need to test with self-signed certificates, default is `true` | +| `follow_redirects` | `bool` | no | Allow the API call to follow redirects | +| `query_string_auth` | `bool` | no | Force Basic Authentication as query string when `true` and using under HTTPS, default is `false` | +| `oauth_timestamp` | `string` | no | Custom oAuth timestamp, default is `time()` | +| `oauth_only` | `bool` | no | Only use oauth for requests, it will disable Basic Auth, default is `false` | +| `user_agent` | `string` | no | Custom user-agent, default is `WooCommerce API Client-PHP` | +| `wp_api_prefix` | `string` | no | Custom WP REST API URL prefix, used to support custom prefixes created with the `rest_url_prefix` filter | +| `wp_api` | `bool` | no | Set to `false` in order to use the legacy WooCommerce REST API (deprecated and not recommended) | +| `method_override_query` | `bool` | no | If true will mask all non-GET/POST methods by using POST method with added query parameter `?_method=METHOD` into URL | +| `method_override_header` | `bool` | no | If true will mask all non-GET/POST methods (PUT/DELETE/etc.) by using POST method with added `X-HTTP-Method-Override: METHOD` HTTP header into request | + +## Client methods + +### GET + +```php +$woocommerce->get($endpoint, $parameters = []); +``` + +### POST + +```php +$woocommerce->post($endpoint, $data); +``` + +### PUT + +```php +$woocommerce->put($endpoint, $data); +``` + +### DELETE + +```php +$woocommerce->delete($endpoint, $parameters = []); +``` + +### OPTIONS + +```php +$woocommerce->options($endpoint); +``` + +#### Arguments + +| Params | Type | Description | +| ------------ | -------- | ------------------------------------------------------------ | +| `endpoint` | `string` | WooCommerce API endpoint, example: `customers` or `orders/12` | +| `data` | `array` | Only for POST and PUT, data that will be converted to JSON | +| `parameters` | `array` | Only for GET and DELETE, request query string | + +#### Response + +All methods will return arrays on success or throwing `HttpClientException` errors on failure. + +```php +use Automattic\WooCommerce\HttpClient\HttpClientException; + +try { + // Array of response results. + $results = $woocommerce->get('customers'); + // Example: ['customers' => [[ 'id' => 8, 'created_at' => '2015-05-06T17:43:51Z', 'email' => ... + echo '
' . print_r($results, true) . '
'; // JSON output.
+
+  // Last request data.
+  $lastRequest = $woocommerce->http->getRequest();
+  echo '
' . print_r($lastRequest->getUrl(), true) . '
'; // Requested URL (string).
+  echo '
' .
+    print_r($lastRequest->getMethod(), true) .
+    '
'; // Request method (string).
+  echo '
' .
+    print_r($lastRequest->getParameters(), true) .
+    '
'; // Request parameters (array).
+  echo '
' .
+    print_r($lastRequest->getHeaders(), true) .
+    '
'; // Request headers (array).
+  echo '
' . print_r($lastRequest->getBody(), true) . '
'; // Request body (JSON).
+
+  // Last response data.
+  $lastResponse = $woocommerce->http->getResponse();
+  echo '
' . print_r($lastResponse->getCode(), true) . '
'; // Response code (int).
+  echo '
' .
+    print_r($lastResponse->getHeaders(), true) .
+    '
'; // Response headers (array).
+  echo '
' . print_r($lastResponse->getBody(), true) . '
'; // Response body (JSON).
+} catch (HttpClientException $e) {
+  echo '
' . print_r($e->getMessage(), true) . '
'; // Error message.
+  echo '
' . print_r($e->getRequest(), true) . '
'; // Last request data.
+  echo '
' . print_r($e->getResponse(), true) . '
'; // Last response data.
+}
+```
+
+## Release History
+
+- 2022-03-18 - 3.1.0 - Added new options to support `_method` and `X-HTTP-Method-Override` from WP, supports 7+, dropped support to PHP 5.
+- 2019-01-16 - 3.0.0 - Legacy API turned off by default, and improved JSON error handler.
+- 2018-03-29 - 2.0.1 - Fixed fatal errors on `lookForErrors`.
+- 2018-01-12 - 2.0.0 - Responses changes from arrays to `stdClass` objects. Added `follow_redirects` option.
+- 2017-06-06 - 1.3.0 - Remove BOM before decoding and added support for multi-dimensional arrays for oAuth1.0a.
+- 2017-03-15 - 1.2.0 - Added `user_agent` option.
+- 2016-12-14 - 1.1.4 - Fixed WordPress 4.7 compatibility.
+- 2016-10-26 - 1.1.3 - Allow set `oauth_timestamp` and improved how is handled the response headers.
+- 2016-09-30 - 1.1.2 - Added `wp_api_prefix` option to allow custom WP REST API URL prefix.
+- 2016-05-10 - 1.1.1 - Fixed oAuth and error handler for WP REST API.
+- 2016-05-09 - 1.1.0 - Added support for WP REST API, added method `Automattic\WooCommerce\Client::options` and fixed multiple headers responses.
+- 2016-01-25 - 1.0.2 - Fixed an error when getting data containing non-latin characters.
+- 2016-01-21 - 1.0.1 - Sort all oAuth parameters before build request URLs.
+- 2016-01-11 - 1.0.0 - Stable release.
diff --git a/vendor/automattic/woocommerce/composer.json b/vendor/automattic/woocommerce/composer.json
new file mode 100644
index 000000000..5390aec54
--- /dev/null
+++ b/vendor/automattic/woocommerce/composer.json
@@ -0,0 +1,42 @@
+{
+  "name": "automattic/woocommerce",
+  "description": "A PHP wrapper for the WooCommerce REST API",
+  "type": "library",
+  "license": "MIT",
+  "authors": [
+    {
+      "name": "Claudio Sanches",
+      "email": "claudio.sanches@automattic.com"
+    }
+  ],
+  "minimum-stability": "dev",
+  "keywords": [
+    "API",
+    "WooCommerce"
+  ],
+  "require": {
+    "php": ">= 7.4.0",
+    "ext-curl": "*",
+    "ext-json": "*"
+  },
+  "require-dev": {
+    "phpunit/phpunit": "^9.5.0",
+    "squizlabs/php_codesniffer": "3.*",
+    "doctrine/instantiator": "^1.5.0"
+  },
+  "scripts": {
+    "phpcs": "vendor/bin/phpcs --colors --standard=phpcs.xml",
+    "phpcbf": "vendor/bin/phpcbf --colors --standard=phpcs.xml"
+  },
+  "autoload": {
+    "psr-4": {
+      "Automattic\\WooCommerce\\": ["src/WooCommerce"]
+    }
+  },
+  "autoload-dev": {
+    "psr-4": { 
+      "Automattic\\WooCommerce\\LegacyTests\\": "tests/legacy-php/WooCommerce/Tests",
+      "Automattic\\WooCommerce\\Tests\\": "tests/php/WooCommerce/Tests"
+    }
+  }
+}
\ No newline at end of file
diff --git a/vendor/automattic/woocommerce/composer.lock b/vendor/automattic/woocommerce/composer.lock
new file mode 100644
index 000000000..83633c08f
--- /dev/null
+++ b/vendor/automattic/woocommerce/composer.lock
@@ -0,0 +1,1897 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "8410ab16c98c12d7b5806814e8b21d24",
+    "packages": [],
+    "packages-dev": [
+        {
+            "name": "doctrine/instantiator",
+            "version": "1.5.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/instantiator.git",
+                "reference": "12be2483e1f0e850b353e26869e4e6c038459501"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/12be2483e1f0e850b353e26869e4e6c038459501",
+                "reference": "12be2483e1f0e850b353e26869e4e6c038459501",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^9 || ^12",
+                "ext-pdo": "*",
+                "ext-phar": "*",
+                "phpbench/phpbench": "^0.16 || ^1",
+                "phpstan/phpstan": "^1.4",
+                "phpstan/phpstan-phpunit": "^1",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6",
+                "vimeo/psalm": "^4.30 || ^5.4"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Marco Pivetta",
+                    "email": "ocramius@gmail.com",
+                    "homepage": "https://ocramius.github.io/"
+                }
+            ],
+            "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+            "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+            "keywords": [
+                "constructor",
+                "instantiate"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/instantiator/issues",
+                "source": "https://github.com/doctrine/instantiator/tree/1.5.x"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2023-12-09T14:16:53+00:00"
+        },
+        {
+            "name": "myclabs/deep-copy",
+            "version": "1.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/myclabs/DeepCopy.git",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "conflict": {
+                "doctrine/collections": "<1.6.8",
+                "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+            },
+            "require-dev": {
+                "doctrine/collections": "^1.6.8",
+                "doctrine/common": "^2.13.3 || ^3.2.2",
+                "phpspec/prophecy": "^1.10",
+                "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+            },
+            "default-branch": true,
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/DeepCopy/deep_copy.php"
+                ],
+                "psr-4": {
+                    "DeepCopy\\": "src/DeepCopy/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Create deep copies (clones) of your objects",
+            "keywords": [
+                "clone",
+                "copy",
+                "duplicate",
+                "object",
+                "object graph"
+            ],
+            "support": {
+                "issues": "https://github.com/myclabs/DeepCopy/issues",
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+            },
+            "funding": [
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-01T08:46:24+00:00"
+        },
+        {
+            "name": "nikic/php-parser",
+            "version": "dev-master",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nikic/PHP-Parser.git",
+                "reference": "0da2d6679a3df45d6d720aa2e0d4568f82a32e46"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/0da2d6679a3df45d6d720aa2e0d4568f82a32e46",
+                "reference": "0da2d6679a3df45d6d720aa2e0d4568f82a32e46",
+                "shasum": ""
+            },
+            "require": {
+                "ext-ctype": "*",
+                "ext-json": "*",
+                "ext-tokenizer": "*",
+                "php": ">=7.4"
+            },
+            "require-dev": {
+                "ircmaxell/php-yacc": "^0.0.7",
+                "phpunit/phpunit": "^9.0"
+            },
+            "default-branch": true,
+            "bin": [
+                "bin/php-parse"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpParser\\": "lib/PhpParser"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Nikita Popov"
+                }
+            ],
+            "description": "A PHP parser written in PHP",
+            "keywords": [
+                "parser",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/nikic/PHP-Parser/issues",
+                "source": "https://github.com/nikic/PHP-Parser/tree/master"
+            },
+            "time": "2025-09-08T07:54:25+00:00"
+        },
+        {
+            "name": "phar-io/manifest",
+            "version": "dev-master",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/manifest.git",
+                "reference": "65f90285728eae4eae313b8b6ba11b2f5436038e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/manifest/zipball/65f90285728eae4eae313b8b6ba11b2f5436038e",
+                "reference": "65f90285728eae4eae313b8b6ba11b2f5436038e",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-phar": "*",
+                "ext-xmlwriter": "*",
+                "phar-io/version": "^3.0.1",
+                "php": "^7.2 || ^8.0"
+            },
+            "default-branch": true,
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+            "support": {
+                "issues": "https://github.com/phar-io/manifest/issues",
+                "source": "https://github.com/phar-io/manifest/tree/master"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-07-05T08:48:25+00:00"
+        },
+        {
+            "name": "phar-io/version",
+            "version": "3.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/version.git",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Library for handling version information and constraints",
+            "support": {
+                "issues": "https://github.com/phar-io/version/issues",
+                "source": "https://github.com/phar-io/version/tree/3.2.1"
+            },
+            "time": "2022-02-21T01:04:05+00:00"
+        },
+        {
+            "name": "phpunit/php-code-coverage",
+            "version": "9.2.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+                "reference": "0448d60087a382392a1b2a1abe434466e03dcc87"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/0448d60087a382392a1b2a1abe434466e03dcc87",
+                "reference": "0448d60087a382392a1b2a1abe434466e03dcc87",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-xmlwriter": "*",
+                "nikic/php-parser": "^4.19.1 || ^5.1.0",
+                "php": ">=7.3",
+                "phpunit/php-file-iterator": "^3.0.6",
+                "phpunit/php-text-template": "^2.0.4",
+                "sebastian/code-unit-reverse-lookup": "^2.0.3",
+                "sebastian/complexity": "^2.0.3",
+                "sebastian/environment": "^5.1.5",
+                "sebastian/lines-of-code": "^1.0.4",
+                "sebastian/version": "^3.0.2",
+                "theseer/tokenizer": "^1.2.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6"
+            },
+            "suggest": {
+                "ext-pcov": "PHP extension that provides line coverage",
+                "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "9.2.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+            "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+            "keywords": [
+                "coverage",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+                "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-10-31T05:58:25+00:00"
+        },
+        {
+            "name": "phpunit/php-file-iterator",
+            "version": "3.0.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+                "reference": "38b24367e1b340aa78b96d7cab042942d917bb84"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/38b24367e1b340aa78b96d7cab042942d917bb84",
+                "reference": "38b24367e1b340aa78b96d7cab042942d917bb84",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+            "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+            "keywords": [
+                "filesystem",
+                "iterator"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-02-11T16:23:04+00:00"
+        },
+        {
+            "name": "phpunit/php-invoker",
+            "version": "3.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-invoker.git",
+                "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+                "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "ext-pcntl": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-pcntl": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Invoke callables with a timeout",
+            "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+            "keywords": [
+                "process"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+                "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:58:55+00:00"
+        },
+        {
+            "name": "phpunit/php-text-template",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-text-template.git",
+                "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+                "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Simple template engine.",
+            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+            "keywords": [
+                "template"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+                "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T05:33:50+00:00"
+        },
+        {
+            "name": "phpunit/php-timer",
+            "version": "5.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-timer.git",
+                "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+                "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Utility class for timing",
+            "homepage": "https://github.com/sebastianbergmann/php-timer/",
+            "keywords": [
+                "timer"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+                "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:16:10+00:00"
+        },
+        {
+            "name": "phpunit/phpunit",
+            "version": "9.6.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/phpunit.git",
+                "reference": "996021ad5437394004bf3aeb6ae52a9620f47157"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/996021ad5437394004bf3aeb6ae52a9620f47157",
+                "reference": "996021ad5437394004bf3aeb6ae52a9620f47157",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/instantiator": "^1.5.0 || ^2",
+                "ext-dom": "*",
+                "ext-json": "*",
+                "ext-libxml": "*",
+                "ext-mbstring": "*",
+                "ext-xml": "*",
+                "ext-xmlwriter": "*",
+                "myclabs/deep-copy": "^1.13.4",
+                "phar-io/manifest": "^2.0.4",
+                "phar-io/version": "^3.2.1",
+                "php": ">=7.3",
+                "phpunit/php-code-coverage": "^9.2.32",
+                "phpunit/php-file-iterator": "^3.0.6",
+                "phpunit/php-invoker": "^3.1.1",
+                "phpunit/php-text-template": "^2.0.4",
+                "phpunit/php-timer": "^5.0.3",
+                "sebastian/cli-parser": "^1.0.2",
+                "sebastian/code-unit": "^1.0.8",
+                "sebastian/comparator": "^4.0.9",
+                "sebastian/diff": "^4.0.6",
+                "sebastian/environment": "^5.1.5",
+                "sebastian/exporter": "^4.0.6",
+                "sebastian/global-state": "^5.0.8",
+                "sebastian/object-enumerator": "^4.0.4",
+                "sebastian/resource-operations": "^3.0.4",
+                "sebastian/type": "^3.2.1",
+                "sebastian/version": "^3.0.2"
+            },
+            "suggest": {
+                "ext-soap": "To be able to generate mocks based on WSDL files",
+                "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+            },
+            "bin": [
+                "phpunit"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "9.6-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/Framework/Assert/Functions.php"
+                ],
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "The PHP Unit Testing framework.",
+            "homepage": "https://phpunit.de/",
+            "keywords": [
+                "phpunit",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+                "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6"
+            },
+            "funding": [
+                {
+                    "url": "https://phpunit.de/sponsors.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-09-12T05:34:54+00:00"
+        },
+        {
+            "name": "sebastian/cli-parser",
+            "version": "1.0.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/cli-parser.git",
+                "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+                "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for parsing CLI options",
+            "homepage": "https://github.com/sebastianbergmann/cli-parser",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+                "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-02T06:27:43+00:00"
+        },
+        {
+            "name": "sebastian/code-unit",
+            "version": "1.0.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit.git",
+                "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+                "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/code-unit",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:08:54+00:00"
+        },
+        {
+            "name": "sebastian/code-unit-reverse-lookup",
+            "version": "2.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+                "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+                "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Looks up which function or method a line of code belongs to",
+            "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:30:19+00:00"
+        },
+        {
+            "name": "sebastian/comparator",
+            "version": "4.0.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/comparator.git",
+                "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5",
+                "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/diff": "^4.0",
+                "sebastian/exporter": "^4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@2bepublished.at"
+                }
+            ],
+            "description": "Provides the functionality to compare PHP values for equality",
+            "homepage": "https://github.com/sebastianbergmann/comparator",
+            "keywords": [
+                "comparator",
+                "compare",
+                "equality"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/comparator/issues",
+                "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-10T06:51:50+00:00"
+        },
+        {
+            "name": "sebastian/complexity",
+            "version": "2.0.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/complexity.git",
+                "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a",
+                "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.18 || ^5.0",
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for calculating the complexity of PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/complexity",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/complexity/issues",
+                "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-12-22T06:19:30+00:00"
+        },
+        {
+            "name": "sebastian/diff",
+            "version": "4.0.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/diff.git",
+                "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc",
+                "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3",
+                "symfony/process": "^4.2 || ^5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Kore Nordmann",
+                    "email": "mail@kore-nordmann.de"
+                }
+            ],
+            "description": "Diff implementation",
+            "homepage": "https://github.com/sebastianbergmann/diff",
+            "keywords": [
+                "diff",
+                "udiff",
+                "unidiff",
+                "unified diff"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/diff/issues",
+                "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-02T06:30:58+00:00"
+        },
+        {
+            "name": "sebastian/environment",
+            "version": "5.1.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/environment.git",
+                "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+                "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-posix": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides functionality to handle HHVM/PHP environments",
+            "homepage": "http://www.github.com/sebastianbergmann/environment",
+            "keywords": [
+                "Xdebug",
+                "environment",
+                "hhvm"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/environment/issues",
+                "source": "https://github.com/sebastianbergmann/environment/tree/5.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T06:03:51+00:00"
+        },
+        {
+            "name": "sebastian/exporter",
+            "version": "4.0.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/exporter.git",
+                "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72",
+                "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "ext-mbstring": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
+                }
+            ],
+            "description": "Provides the functionality to export PHP variables for visualization",
+            "homepage": "https://www.github.com/sebastianbergmann/exporter",
+            "keywords": [
+                "export",
+                "exporter"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/exporter/issues",
+                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-02T06:33:00+00:00"
+        },
+        {
+            "name": "sebastian/global-state",
+            "version": "5.0.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/global-state.git",
+                "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+                "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/object-reflector": "^2.0",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "ext-dom": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-uopz": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Snapshotting of global state",
+            "homepage": "http://www.github.com/sebastianbergmann/global-state",
+            "keywords": [
+                "global state"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/global-state/issues",
+                "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-10T07:10:35+00:00"
+        },
+        {
+            "name": "sebastian/lines-of-code",
+            "version": "1.0.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+                "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+                "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.18 || ^5.0",
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for counting the lines of code in PHP source code",
+            "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+                "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-12-22T06:20:34+00:00"
+        },
+        {
+            "name": "sebastian/object-enumerator",
+            "version": "4.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+                "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+                "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/object-reflector": "^2.0",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+            "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+                "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:12:34+00:00"
+        },
+        {
+            "name": "sebastian/object-reflector",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-reflector.git",
+                "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+                "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Allows reflection of object attributes, including inherited and non-public ones",
+            "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+                "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:14:26+00:00"
+        },
+        {
+            "name": "sebastian/recursion-context",
+            "version": "4.0.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/recursion-context.git",
+                "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0",
+                "reference": "539c6691e0623af6dc6f9c20384c120f963465a0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                }
+            ],
+            "description": "Provides functionality to recursively process PHP variables",
+            "homepage": "https://github.com/sebastianbergmann/recursion-context",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-10T06:57:39+00:00"
+        },
+        {
+            "name": "sebastian/resource-operations",
+            "version": "dev-main",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/resource-operations.git",
+                "reference": "ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25",
+                "reference": "ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.0"
+            },
+            "default-branch": true,
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides a list of PHP built-in functions that operate on resources",
+            "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+            "support": {
+                "source": "https://github.com/sebastianbergmann/resource-operations/tree/main"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-14T18:47:08+00:00"
+        },
+        {
+            "name": "sebastian/type",
+            "version": "3.2.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/type.git",
+                "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+                "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.2-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the types of the PHP type system",
+            "homepage": "https://github.com/sebastianbergmann/type",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/type/issues",
+                "source": "https://github.com/sebastianbergmann/type/tree/3.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T06:13:03+00:00"
+        },
+        {
+            "name": "sebastian/version",
+            "version": "3.0.x-dev",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/version.git",
+                "reference": "c6c1022351a901512170118436c764e473f6de8c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+                "reference": "c6c1022351a901512170118436c764e473f6de8c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+            "homepage": "https://github.com/sebastianbergmann/version",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/version/issues",
+                "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:39:44+00:00"
+        },
+        {
+            "name": "squizlabs/php_codesniffer",
+            "version": "dev-master",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+                "reference": "fcfefc635209c324eb6ed47280d4cc5d245c7cd6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/fcfefc635209c324eb6ed47280d4cc5d245c7cd6",
+                "reference": "fcfefc635209c324eb6ed47280d4cc5d245c7cd6",
+                "shasum": ""
+            },
+            "require": {
+                "ext-simplexml": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlwriter": "*",
+                "php": ">=5.4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
+            },
+            "default-branch": true,
+            "bin": [
+                "bin/phpcbf",
+                "bin/phpcs"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.x-dev"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Greg Sherwood",
+                    "role": "Former lead"
+                },
+                {
+                    "name": "Juliette Reinders Folmer",
+                    "role": "Current lead"
+                },
+                {
+                    "name": "Contributors",
+                    "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
+                }
+            ],
+            "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
+            "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+            "keywords": [
+                "phpcs",
+                "standards",
+                "static analysis"
+            ],
+            "support": {
+                "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
+                "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
+                "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+                "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/PHPCSStandards",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/jrfnl",
+                    "type": "github"
+                },
+                {
+                    "url": "https://opencollective.com/php_codesniffer",
+                    "type": "open_collective"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/phpcsstandards",
+                    "type": "thanks_dev"
+                }
+            ],
+            "time": "2025-09-11T05:53:07+00:00"
+        },
+        {
+            "name": "theseer/tokenizer",
+            "version": "1.2.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/theseer/tokenizer.git",
+                "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
+                "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlwriter": "*",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+            "support": {
+                "issues": "https://github.com/theseer/tokenizer/issues",
+                "source": "https://github.com/theseer/tokenizer/tree/1.2.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-03T12:36:25+00:00"
+        }
+    ],
+    "aliases": [],
+    "minimum-stability": "dev",
+    "stability-flags": {},
+    "prefer-stable": false,
+    "prefer-lowest": false,
+    "platform": {
+        "php": ">= 7.4.0",
+        "ext-curl": "*",
+        "ext-json": "*"
+    },
+    "platform-dev": {},
+    "plugin-api-version": "2.6.0"
+}
diff --git a/vendor/automattic/woocommerce/src/WooCommerce/Client.php b/vendor/automattic/woocommerce/src/WooCommerce/Client.php
new file mode 100644
index 000000000..2bceda31d
--- /dev/null
+++ b/vendor/automattic/woocommerce/src/WooCommerce/Client.php
@@ -0,0 +1,109 @@
+http = new HttpClient($url, $consumerKey, $consumerSecret, $options);
+    }
+
+    /**
+     * POST method.
+     *
+     * @param string $endpoint API endpoint.
+     * @param array  $data     Request data.
+     *
+     * @return \stdClass
+     */
+    public function post($endpoint, $data)
+    {
+        return $this->http->request($endpoint, 'POST', $data);
+    }
+
+    /**
+     * PUT method.
+     *
+     * @param string $endpoint API endpoint.
+     * @param array  $data     Request data.
+     *
+     * @return \stdClass
+     */
+    public function put($endpoint, $data)
+    {
+        return $this->http->request($endpoint, 'PUT', $data);
+    }
+
+    /**
+     * GET method.
+     *
+     * @param string $endpoint   API endpoint.
+     * @param array  $parameters Request parameters.
+     *
+     * @return \stdClass|array
+     */
+    public function get($endpoint, $parameters = [])
+    {
+        return $this->http->request($endpoint, 'GET', [], $parameters);
+    }
+
+    /**
+     * DELETE method.
+     *
+     * @param string $endpoint   API endpoint.
+     * @param array  $parameters Request parameters.
+     *
+     * @return \stdClass
+     */
+    public function delete($endpoint, $parameters = [])
+    {
+        return $this->http->request($endpoint, 'DELETE', [], $parameters);
+    }
+
+    /**
+     * OPTIONS method.
+     *
+     * @param string $endpoint API endpoint.
+     *
+     * @return \stdClass
+     */
+    public function options($endpoint)
+    {
+        return $this->http->request($endpoint, 'OPTIONS', [], []);
+    }
+}
diff --git a/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/BasicAuth.php b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/BasicAuth.php
new file mode 100644
index 000000000..b63916812
--- /dev/null
+++ b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/BasicAuth.php
@@ -0,0 +1,96 @@
+ch             = $ch;
+        $this->consumerKey    = $consumerKey;
+        $this->consumerSecret = $consumerSecret;
+        $this->doQueryString  = $doQueryString;
+        $this->parameters     = $parameters;
+
+        $this->processAuth();
+    }
+
+    /**
+     * Process auth.
+     */
+    protected function processAuth()
+    {
+        if ($this->doQueryString) {
+            $this->parameters['consumer_key']    = $this->consumerKey;
+            $this->parameters['consumer_secret'] = $this->consumerSecret;
+        } else {
+            \curl_setopt($this->ch, CURLOPT_USERPWD, $this->consumerKey . ':' . $this->consumerSecret);
+        }
+    }
+
+    /**
+     * Get parameters.
+     *
+     * @return array
+     */
+    public function getParameters()
+    {
+        return $this->parameters;
+    }
+}
diff --git a/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClient.php b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClient.php
new file mode 100644
index 000000000..2180262de
--- /dev/null
+++ b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClient.php
@@ -0,0 +1,490 @@
+options        = new Options($options);
+        $this->url            = $this->buildApiUrl($url);
+        $this->consumerKey    = $consumerKey;
+        $this->consumerSecret = $consumerSecret;
+    }
+
+    /**
+     * Check if is under SSL.
+     *
+     * @return bool
+     */
+    protected function isSsl()
+    {
+        return 'https://' === \substr($this->url, 0, 8);
+    }
+
+    /**
+     * Build API URL.
+     *
+     * @param string $url Store URL.
+     *
+     * @return string
+     */
+    protected function buildApiUrl($url)
+    {
+        $api = $this->options->isWPAPI() ? $this->options->apiPrefix() : '/wc-api/';
+
+        return \rtrim($url, '/') . $api . $this->options->getVersion() . '/';
+    }
+
+    /**
+     * Build URL.
+     *
+     * @param string $url        URL.
+     * @param array  $parameters Query string parameters.
+     *
+     * @return string
+     */
+    protected function buildUrlQuery($url, $parameters = [])
+    {
+        if (!empty($parameters)) {
+            if (false !== strpos($url, '?')) {
+                $url .= '&' . \http_build_query($parameters, '', '&');
+            } else {
+                $url .= '?' . \http_build_query($parameters, '', '&');
+            }
+        }
+
+        return $url;
+    }
+
+    /**
+     * Authenticate.
+     *
+     * @param string $url        Request URL.
+     * @param string $method     Request method.
+     * @param array  $parameters Request parameters.
+     *
+     * @return array
+     */
+    protected function authenticate($url, $method, $parameters = [])
+    {
+        // Setup authentication.
+        if (!$this->options->isOAuthOnly() && $this->isSsl()) {
+            $basicAuth = new BasicAuth(
+                $this->ch,
+                $this->consumerKey,
+                $this->consumerSecret,
+                $this->options->isQueryStringAuth(),
+                $parameters
+            );
+            $parameters = $basicAuth->getParameters();
+        } else {
+            $oAuth = new OAuth(
+                $url,
+                $this->consumerKey,
+                $this->consumerSecret,
+                $this->options->getVersion(),
+                $method,
+                $parameters,
+                $this->options->oauthTimestamp()
+            );
+            $parameters = $oAuth->getParameters();
+        }
+
+        return $parameters;
+    }
+
+    /**
+     * Setup method.
+     *
+     * @param string $method Request method.
+     */
+    protected function setupMethod($method)
+    {
+        if ('POST' == $method) {
+            \curl_setopt($this->ch, CURLOPT_POST, true);
+        } elseif (\in_array($method, ['PUT', 'DELETE', 'OPTIONS'])) {
+            \curl_setopt($this->ch, CURLOPT_CUSTOMREQUEST, $method);
+        }
+    }
+
+    /**
+     * Get request headers.
+     *
+     * @param  bool $sendData If request send data or not.
+     *
+     * @return array
+     */
+    protected function getRequestHeaders($sendData = false)
+    {
+        $headers = [
+            'Accept'     => 'application/json',
+            'User-Agent' => $this->options->userAgent() . '/' . Client::VERSION,
+        ];
+
+        if ($sendData) {
+            $headers['Content-Type'] = 'application/json;charset=utf-8';
+        }
+
+        return $headers;
+    }
+
+    /**
+     * Create request.
+     *
+     * @param string $endpoint   Request endpoint.
+     * @param string $method     Request method.
+     * @param array  $data       Request data.
+     * @param array  $parameters Request parameters.
+     *
+     * @return Request
+     */
+    protected function createRequest($endpoint, $method, $data = [], $parameters = [])
+    {
+        $body    = '';
+        $url     = $this->url . $endpoint;
+        $hasData = !empty($data);
+        $headers = $this->getRequestHeaders($hasData);
+
+        // HTTP method override feature which masks PUT and DELETE HTTP methods as POST method with added
+        // ?_method=PUT query parameter and/or X-HTTP-Method-Override HTTP header.
+        if (!in_array($method, ['GET', 'POST'])) {
+            $usePostMethod = false;
+            if ($this->options->isMethodOverrideQuery()) {
+                $parameters = array_merge(['_method' => $method], $parameters);
+                $usePostMethod = true;
+            }
+            if ($this->options->isMethodOverrideHeader()) {
+                $headers['X-HTTP-Method-Override'] = $method;
+                $usePostMethod = true;
+            }
+            if ($usePostMethod) {
+                $method = 'POST';
+            }
+        }
+
+        // Setup authentication.
+        $parameters = $this->authenticate($url, $method, $parameters);
+
+        // Setup method.
+        $this->setupMethod($method);
+
+        // Include post fields.
+        if ($hasData) {
+            $body = \json_encode($data);
+            \curl_setopt($this->ch, CURLOPT_POSTFIELDS, $body);
+        }
+
+        $this->request = new Request(
+            $this->buildUrlQuery($url, $parameters),
+            $method,
+            $parameters,
+            $headers,
+            $body
+        );
+
+        return $this->getRequest();
+    }
+
+    /**
+     * Get response headers.
+     *
+     * @return array
+     */
+    protected function getResponseHeaders()
+    {
+        $headers = [];
+        $lines   = \explode("\n", $this->responseHeaders);
+        $lines   = \array_filter($lines, 'trim');
+
+        foreach ($lines as $index => $line) {
+            // Remove HTTP/xxx params.
+            if (strpos($line, ': ') === false) {
+                continue;
+            }
+
+            list($key, $value) = \explode(': ', $line);
+
+            $headers[$key] = isset($headers[$key]) ? $headers[$key] . ', ' . trim($value) : trim($value);
+        }
+
+        return $headers;
+    }
+
+    /**
+     * Create response.
+     *
+     * @return Response
+     */
+    protected function createResponse()
+    {
+
+        // Set response headers.
+        $this->responseHeaders = '';
+        \curl_setopt($this->ch, CURLOPT_HEADERFUNCTION, function ($_, $headers) {
+            $this->responseHeaders .= $headers;
+            return \strlen($headers);
+        });
+
+        // Get response data.
+        $body    = \curl_exec($this->ch);
+        $code    = \curl_getinfo($this->ch, CURLINFO_HTTP_CODE);
+        $headers = $this->getResponseHeaders();
+
+        // Register response.
+        $this->response = new Response($code, $headers, $body);
+
+        return $this->getResponse();
+    }
+
+    /**
+     * Set default cURL settings.
+     */
+    protected function setDefaultCurlSettings()
+    {
+        $verifySsl       = $this->options->verifySsl();
+        $timeout         = $this->options->getTimeout();
+        $followRedirects = $this->options->getFollowRedirects();
+
+        \curl_setopt($this->ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
+        if (!$verifySsl) {
+            \curl_setopt($this->ch, CURLOPT_SSL_VERIFYHOST, $verifySsl);
+        }
+        if ($followRedirects) {
+            \curl_setopt($this->ch, CURLOPT_FOLLOWLOCATION, true);
+        }
+        \curl_setopt($this->ch, CURLOPT_CONNECTTIMEOUT, $timeout);
+        \curl_setopt($this->ch, CURLOPT_TIMEOUT, $timeout);
+        \curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true);
+        \curl_setopt($this->ch, CURLOPT_HTTPHEADER, $this->request->getRawHeaders());
+        \curl_setopt($this->ch, CURLOPT_URL, $this->request->getUrl());
+
+        foreach ($this->customCurlOptions as $customCurlOptionKey => $customCurlOptionValue) {
+            \curl_setopt($this->ch, $customCurlOptionKey, $customCurlOptionValue);
+        }
+    }
+
+    /**
+     * Look for errors in the request.
+     *
+     * @param array $parsedResponse Parsed body response.
+     */
+    protected function lookForErrors($parsedResponse)
+    {
+        // Any non-200/201/202 response code indicates an error.
+        if (!\in_array($this->response->getCode(), ['200', '201', '202'])) {
+            $errors = isset($parsedResponse->errors) ? $parsedResponse->errors : $parsedResponse;
+            $errorMessage = '';
+            $errorCode = '';
+
+            if (is_array($errors) && $errors) {
+                $errorMessage = $errors[0]->message;
+                $errorCode    = $errors[0]->code;
+            } elseif (isset($errors->message, $errors->code)) {
+                $errorMessage = $errors->message;
+                $errorCode    = $errors->code;
+            }
+
+            throw new HttpClientException(
+                \sprintf('Error: %s [%s]', $errorMessage, $errorCode),
+                $this->response->getCode(),
+                $this->request,
+                $this->response
+            );
+        }
+    }
+
+    /**
+     * Process response.
+     *
+     * @return \stdClass|array
+     */
+    protected function processResponse()
+    {
+        $body = $this->response->getBody();
+
+        // Look for UTF-8 BOM and remove.
+        if (0 === strpos(bin2hex(substr($body, 0, 4)), 'efbbbf')) {
+            $body = substr($body, 3);
+        }
+
+        $parsedResponse = \json_decode($body);
+
+        // Test if return a valid JSON.
+        if (JSON_ERROR_NONE !== json_last_error()) {
+            $message = function_exists('json_last_error_msg') ? json_last_error_msg() : 'Invalid JSON returned';
+            throw new HttpClientException(
+                sprintf('JSON ERROR: %s', $message),
+                $this->response->getCode(),
+                $this->request,
+                $this->response
+            );
+        }
+
+        $this->lookForErrors($parsedResponse);
+
+        return $parsedResponse;
+    }
+
+    /**
+     * Make requests.
+     *
+     * @param string $endpoint   Request endpoint.
+     * @param string $method     Request method.
+     * @param array  $data       Request data.
+     * @param array  $parameters Request parameters.
+     *
+     * @return \stdClass|array
+     */
+    public function request($endpoint, $method, $data = [], $parameters = [])
+    {
+        // Initialize cURL.
+        $this->ch = \curl_init();
+
+        // Set request args.
+        $request = $this->createRequest($endpoint, $method, $data, $parameters);
+
+        // Default cURL settings.
+        $this->setDefaultCurlSettings();
+
+        // Get response.
+        $response = $this->createResponse();
+
+        // Check for cURL errors.
+        if (\curl_errno($this->ch)) {
+            throw new HttpClientException('cURL Error: ' . \curl_error($this->ch), 0, $request, $response);
+        }
+
+        // we have to call curl_close only for PHP < 8
+        if (\is_resource($this->ch)) {
+            \curl_close($this->ch);
+        }
+
+        return $this->processResponse();
+    }
+
+    /**
+     * Get request data.
+     *
+     * @return Request
+     */
+    public function getRequest()
+    {
+        return $this->request;
+    }
+
+    /**
+     * Get response data.
+     *
+     * @return Response
+     */
+    public function getResponse()
+    {
+        return $this->response;
+    }
+
+    /**
+     * Set custom cURL options to use in requests.
+     *
+     * @param array $curlOptions
+     */
+    public function setCustomCurlOptions(array $curlOptions)
+    {
+        $this->customCurlOptions = $curlOptions;
+    }
+}
diff --git a/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClientException.php b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClientException.php
new file mode 100644
index 000000000..bfd149fe6
--- /dev/null
+++ b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClientException.php
@@ -0,0 +1,71 @@
+request  = $request;
+        $this->response = $response;
+    }
+
+    /**
+     * Get request data.
+     *
+     * @return Request
+     */
+    public function getRequest()
+    {
+        return $this->request;
+    }
+
+    /**
+     * Get response data.
+     *
+     * @return Response
+     */
+    public function getResponse()
+    {
+        return $this->response;
+    }
+}
diff --git a/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/OAuth.php b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/OAuth.php
new file mode 100644
index 000000000..5f3a4a13a
--- /dev/null
+++ b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/OAuth.php
@@ -0,0 +1,268 @@
+url            = $url;
+        $this->consumerKey    = $consumerKey;
+        $this->consumerSecret = $consumerSecret;
+        $this->apiVersion     = $apiVersion;
+        $this->method         = $method;
+        $this->parameters     = $parameters;
+        $this->timestamp      = $timestamp;
+    }
+
+    /**
+     * Encode according to RFC 3986.
+     *
+     * @param string|array $value Value to be normalized.
+     *
+     * @return string
+     */
+    protected function encode($value)
+    {
+        if (is_array($value)) {
+            return array_map([$this, 'encode'], $value);
+        } else {
+            return str_replace(['+', '%7E'], [' ', '~'], rawurlencode($value));
+        }
+    }
+
+    /**
+     * Normalize parameters.
+     *
+     * @param array $parameters Parameters to normalize.
+     *
+     * @return array
+     */
+    protected function normalizeParameters($parameters)
+    {
+        $normalized = [];
+
+        foreach ($parameters as $key => $value) {
+            // Percent symbols (%) must be double-encoded.
+            $key   = $this->encode($key);
+            $value = $this->encode($value);
+
+            $normalized[$key] = $value;
+        }
+
+        return $normalized;
+    }
+
+    /**
+     * Process filters.
+     *
+     * @param array $parameters Request parameters.
+     *
+     * @return array
+     */
+    protected function processFilters($parameters)
+    {
+        if (isset($parameters['filter'])) {
+            $filters = $parameters['filter'];
+            unset($parameters['filter']);
+            foreach ($filters as $filter => $value) {
+                $parameters['filter[' . $filter . ']'] = $value;
+            }
+        }
+
+        return $parameters;
+    }
+
+    /**
+     * Get secret.
+     *
+     * @return string
+     */
+    protected function getSecret()
+    {
+        $secret = $this->consumerSecret;
+
+        // Fix secret for v3 or later.
+        if (!\in_array($this->apiVersion, ['v1', 'v2'])) {
+            $secret .= '&';
+        }
+
+        return $secret;
+    }
+
+    /**
+     * Generate oAuth1.0 signature.
+     *
+     * @param array $parameters Request parameters including oauth.
+     *
+     * @return string
+     */
+    protected function generateOauthSignature($parameters)
+    {
+        $baseRequestUri = \rawurlencode($this->url);
+
+        // Extract filters.
+        $parameters = $this->processFilters($parameters);
+
+        // Normalize parameter key/values and sort them.
+        $parameters = $this->normalizeParameters($parameters);
+        $parameters = $this->getSortedParameters($parameters);
+
+        // Set query string.
+        $queryString  = \implode('%26', $this->joinWithEqualsSign($parameters)); // Join with ampersand.
+        $stringToSign = $this->method . '&' . $baseRequestUri . '&' . $queryString;
+        $secret       = $this->getSecret();
+
+        return \base64_encode(\hash_hmac(self::HASH_ALGORITHM, $stringToSign, $secret, true));
+    }
+
+    /**
+     * Creates an array of urlencoded strings out of each array key/value pairs.
+     *
+     * @param  array  $params      Array of parameters to convert.
+     * @param  array  $queryParams Array to extend.
+     * @param  string $key         Optional Array key to append
+     * @return array               Array of urlencoded strings
+     */
+    protected function joinWithEqualsSign($params, $queryParams = [], $key = '')
+    {
+        foreach ($params as $paramKey => $paramValue) {
+            if ($key) {
+                $paramKey = $key . '%5B' . $paramKey . '%5D'; // Handle multi-dimensional array.
+            }
+
+            if (is_array($paramValue)) {
+                $queryParams = $this->joinWithEqualsSign($paramValue, $queryParams, $paramKey);
+            } else {
+                $string = $paramKey . '=' . $paramValue; // Join with equals sign.
+                $queryParams[] = $this->encode($string);
+            }
+        }
+
+        return $queryParams;
+    }
+
+    /**
+     * Sort parameters.
+     *
+     * @param array $parameters Parameters to sort in byte-order.
+     *
+     * @return array
+     */
+    protected function getSortedParameters($parameters)
+    {
+        \uksort($parameters, 'strcmp');
+
+        foreach ($parameters as $key => $value) {
+            if (\is_array($value)) {
+                \uksort($parameters[$key], 'strcmp');
+            }
+        }
+
+        return $parameters;
+    }
+
+    /**
+     * Get oAuth1.0 parameters.
+     *
+     * @return string
+     */
+    public function getParameters()
+    {
+        $parameters = \array_merge($this->parameters, [
+            'oauth_consumer_key'     => $this->consumerKey,
+            'oauth_timestamp'        => $this->timestamp,
+            'oauth_nonce'            => \sha1(\microtime()),
+            'oauth_signature_method' => 'HMAC-' . self::HASH_ALGORITHM,
+        ]);
+
+        // The parameters above must be included in the signature generation.
+        $parameters['oauth_signature'] = $this->generateOauthSignature($parameters);
+
+        return $this->getSortedParameters($parameters);
+    }
+}
diff --git a/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Options.php b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Options.php
new file mode 100644
index 000000000..3e2ce6a14
--- /dev/null
+++ b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Options.php
@@ -0,0 +1,182 @@
+options = $options;
+    }
+
+    /**
+     * Get API version.
+     *
+     * @return string
+     */
+    public function getVersion()
+    {
+        return isset($this->options['version']) ? $this->options['version'] : self::VERSION;
+    }
+
+    /**
+     * Check if need to verify SSL.
+     *
+     * @return bool
+     */
+    public function verifySsl()
+    {
+        return isset($this->options['verify_ssl']) ? (bool) $this->options['verify_ssl'] : true;
+    }
+
+    /**
+     * Only use OAuth.
+     *
+     * @return bool
+     */
+    public function isOAuthOnly()
+    {
+        return isset($this->options['oauth_only']) ? (bool) $this->options['oauth_only'] : false;
+    }
+
+    /**
+     * Get timeout.
+     *
+     * @return int
+     */
+    public function getTimeout()
+    {
+        return isset($this->options['timeout']) ? (int) $this->options['timeout'] : self::TIMEOUT;
+    }
+
+    /**
+     * Basic Authentication as query string.
+     * Some old servers are not able to use CURLOPT_USERPWD.
+     *
+     * @return bool
+     */
+    public function isQueryStringAuth()
+    {
+        return isset($this->options['query_string_auth']) ? (bool) $this->options['query_string_auth'] : false;
+    }
+
+    /**
+     * Check if is WP REST API.
+     *
+     * @return bool
+     */
+    public function isWPAPI()
+    {
+        return isset($this->options['wp_api']) ? (bool) $this->options['wp_api'] : true;
+    }
+
+    /**
+     * Custom API Prefix for WP API.
+     *
+     * @return string
+     */
+    public function apiPrefix()
+    {
+        return isset($this->options['wp_api_prefix']) ? $this->options['wp_api_prefix'] : self::WP_API_PREFIX;
+    }
+
+    /**
+     * oAuth timestamp.
+     *
+     * @return string
+     */
+    public function oauthTimestamp()
+    {
+        return isset($this->options['oauth_timestamp']) ? $this->options['oauth_timestamp'] : \time();
+    }
+
+    /**
+     * Custom user agent.
+     *
+     * @return string
+     */
+    public function userAgent()
+    {
+        return isset($this->options['user_agent']) ? $this->options['user_agent'] : self::USER_AGENT;
+    }
+
+    /**
+     * Get follow redirects.
+     *
+     * @return bool
+     */
+    public function getFollowRedirects()
+    {
+        return isset($this->options['follow_redirects']) ? (bool) $this->options['follow_redirects'] : false;
+    }
+
+    /**
+     * Check is it needed to mask all non-GET/POST methods (PUT/DELETE/etc.) by using POST method with added
+     * query parameter ?_method=METHOD into URL.
+     *
+     * @return bool
+     */
+    public function isMethodOverrideQuery()
+    {
+        return isset($this->options['method_override_query']) && $this->options['method_override_query'];
+    }
+
+    /**
+     * Check is it needed to mask all non-GET/POST methods (PUT/DELETE/etc.) by using POST method with added
+     * "X-HTTP-Method-Override: METHOD" HTTP header into request.
+     *
+     * @return bool
+     */
+    public function isMethodOverrideHeader()
+    {
+        return isset($this->options['method_override_header']) && $this->options['method_override_header'];
+    }
+}
diff --git a/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Request.php b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Request.php
new file mode 100644
index 000000000..b9db6bd16
--- /dev/null
+++ b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Request.php
@@ -0,0 +1,187 @@
+url        = $url;
+        $this->method     = $method;
+        $this->parameters = $parameters;
+        $this->headers    = $headers;
+        $this->body       = $body;
+    }
+
+    /**
+     * Set url.
+     *
+     * @param string $url Request url.
+     */
+    public function setUrl($url)
+    {
+        $this->url = $url;
+    }
+
+    /**
+     * Set method.
+     *
+     * @param string $method Request method.
+     */
+    public function setMethod($method)
+    {
+        $this->method = $method;
+    }
+
+    /**
+     * Set parameters.
+     *
+     * @param array $parameters Request paramenters.
+     */
+    public function setParameters($parameters)
+    {
+        $this->parameters = $parameters;
+    }
+
+    /**
+     * Set headers.
+     *
+     * @param array $headers Request headers.
+     */
+    public function setHeaders($headers)
+    {
+        $this->headers = $headers;
+    }
+
+    /**
+     * Set body.
+     *
+     * @param string $body Request body.
+     */
+    public function setBody($body)
+    {
+        $this->body = $body;
+    }
+
+    /**
+     * Get url.
+     *
+     * @return string
+     */
+    public function getUrl()
+    {
+        return $this->url;
+    }
+
+    /**
+     * Get method.
+     *
+     * @return string
+     */
+    public function getMethod()
+    {
+        return $this->method;
+    }
+
+    /**
+     * Get parameters.
+     *
+     * @return array
+     */
+    public function getParameters()
+    {
+        return $this->parameters;
+    }
+
+    /**
+     * Get headers.
+     *
+     * @return array
+     */
+    public function getHeaders()
+    {
+        return $this->headers;
+    }
+
+    /**
+     * Get raw headers.
+     *
+     * @return array
+     */
+    public function getRawHeaders()
+    {
+        $headers = [];
+
+        foreach ($this->headers as $key => $value) {
+            $headers[] = $key . ': ' . $value;
+        }
+
+        return $headers;
+    }
+
+    /**
+     * Get body.
+     *
+     * @return string
+     */
+    public function getBody()
+    {
+        return $this->body;
+    }
+}
diff --git a/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Response.php b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Response.php
new file mode 100644
index 000000000..b5a5de19c
--- /dev/null
+++ b/vendor/automattic/woocommerce/src/WooCommerce/HttpClient/Response.php
@@ -0,0 +1,127 @@
+code    = $code;
+        $this->headers = $headers;
+        $this->body    = $body;
+    }
+
+    /**
+     * To string.
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return \json_encode([
+            'code'    => $this->code,
+            'headers' => $this->headers,
+            'body'    => $this->body,
+        ]);
+    }
+
+    /**
+     * Set code.
+     *
+     * @param int $code Response code.
+     */
+    public function setCode($code)
+    {
+        $this->code = (int) $code;
+    }
+
+    /**
+     * Set headers.
+     *
+     * @param array $headers Response headers.
+     */
+    public function setHeaders($headers)
+    {
+        $this->headers = $headers;
+    }
+
+    /**
+     * Set body.
+     *
+     * @param string $body Response body.
+     */
+    public function setBody($body)
+    {
+        $this->body = $body;
+    }
+
+    /**
+     * Get code.
+     *
+     * @return int
+     */
+    public function getCode()
+    {
+        return $this->code;
+    }
+
+    /**
+     * Get headers.
+     *
+     * @return array $headers Response headers.
+     */
+    public function getHeaders()
+    {
+        return $this->headers;
+    }
+
+    /**
+     * Get body.
+     *
+     * @return string $body Response body.
+     */
+    public function getBody()
+    {
+        return $this->body;
+    }
+}
diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php
index c0eb26162..75b0ae70e 100644
--- a/vendor/composer/autoload_classmap.php
+++ b/vendor/composer/autoload_classmap.php
@@ -43,6 +43,14 @@
     'Aura\\SqlQuery\\Sqlsrv\\Insert' => $vendorDir . '/aura/sqlquery/src/Sqlsrv/Insert.php',
     'Aura\\SqlQuery\\Sqlsrv\\Select' => $vendorDir . '/aura/sqlquery/src/Sqlsrv/Select.php',
     'Aura\\SqlQuery\\Sqlsrv\\Update' => $vendorDir . '/aura/sqlquery/src/Sqlsrv/Update.php',
+    'Automattic\\WooCommerce\\Client' => $vendorDir . '/automattic/woocommerce/src/WooCommerce/Client.php',
+    'Automattic\\WooCommerce\\HttpClient\\BasicAuth' => $vendorDir . '/automattic/woocommerce/src/WooCommerce/HttpClient/BasicAuth.php',
+    'Automattic\\WooCommerce\\HttpClient\\HttpClient' => $vendorDir . '/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClient.php',
+    'Automattic\\WooCommerce\\HttpClient\\HttpClientException' => $vendorDir . '/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClientException.php',
+    'Automattic\\WooCommerce\\HttpClient\\OAuth' => $vendorDir . '/automattic/woocommerce/src/WooCommerce/HttpClient/OAuth.php',
+    'Automattic\\WooCommerce\\HttpClient\\Options' => $vendorDir . '/automattic/woocommerce/src/WooCommerce/HttpClient/Options.php',
+    'Automattic\\WooCommerce\\HttpClient\\Request' => $vendorDir . '/automattic/woocommerce/src/WooCommerce/HttpClient/Request.php',
+    'Automattic\\WooCommerce\\HttpClient\\Response' => $vendorDir . '/automattic/woocommerce/src/WooCommerce/HttpClient/Response.php',
     'Aws\\ACMPCA\\ACMPCAClient' => $vendorDir . '/aws/aws-sdk-php/src/ACMPCA/ACMPCAClient.php',
     'Aws\\ACMPCA\\Exception\\ACMPCAException' => $vendorDir . '/aws/aws-sdk-php/src/ACMPCA/Exception/ACMPCAException.php',
     'Aws\\AbstractConfigurationProvider' => $vendorDir . '/aws/aws-sdk-php/src/AbstractConfigurationProvider.php',
@@ -3727,6 +3735,7 @@
     'Xentral\\Modules\\Wizard\\Exception\\NotFoundException' => $baseDir . '/classes/Modules/Wizard/Exception/NotFoundException.php',
     'Xentral\\Modules\\Wizard\\Exception\\WizardExceptionInterface' => $baseDir . '/classes/Modules/Wizard/Exception/WizardExceptionInterface.php',
     'Xentral\\Modules\\Wizard\\WizardService' => $baseDir . '/classes/Modules/Wizard/WizardService.php',
+    'Xentral\\Services\\DatabaseService' => $baseDir . '/classes/Services/DatabaseService.php',
     'Xentral\\Widgets\\Chart\\BarDataset' => $baseDir . '/classes/Widgets/Chart/BarDataset.php',
     'Xentral\\Widgets\\Chart\\Chart' => $baseDir . '/classes/Widgets/Chart/Chart.php',
     'Xentral\\Widgets\\Chart\\Color' => $baseDir . '/classes/Widgets/Chart/Color.php',
diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php
index aa47ba3c3..6ff597737 100644
--- a/vendor/composer/autoload_psr4.php
+++ b/vendor/composer/autoload_psr4.php
@@ -49,6 +49,7 @@
     'Datto\\JsonRpc\\Http\\' => array($vendorDir . '/datto/json-rpc-http/src'),
     'Datto\\JsonRpc\\' => array($vendorDir . '/datto/json-rpc/src'),
     'Aws\\' => array($vendorDir . '/aws/aws-sdk-php/src'),
+    'Automattic\\WooCommerce\\' => array($vendorDir . '/automattic/woocommerce/src/WooCommerce'),
     'Aura\\SqlQuery\\' => array($vendorDir . '/aura/sqlquery/src'),
     '' => array($vendorDir . '/league/color-extractor/src'),
 );
diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php
index 449b91726..0c530dc74 100644
--- a/vendor/composer/autoload_static.php
+++ b/vendor/composer/autoload_static.php
@@ -110,6 +110,7 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
         'A' => 
         array (
             'Aws\\' => 4,
+            'Automattic\\WooCommerce\\' => 23,
             'Aura\\SqlQuery\\' => 14,
         ),
     );
@@ -287,7 +288,11 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
         array (
             0 => __DIR__ . '/..' . '/aws/aws-sdk-php/src',
         ),
-        'Aura\\SqlQuery\\' => 
+        'Automattic\\WooCommerce\\' =>
+        array (
+            0 => __DIR__ . '/..' . '/automattic/woocommerce/src/WooCommerce',
+        ),
+        'Aura\\SqlQuery\\' =>
         array (
             0 => __DIR__ . '/..' . '/aura/sqlquery/src',
         ),
@@ -345,6 +350,14 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
         'Aura\\SqlQuery\\Sqlsrv\\Insert' => __DIR__ . '/..' . '/aura/sqlquery/src/Sqlsrv/Insert.php',
         'Aura\\SqlQuery\\Sqlsrv\\Select' => __DIR__ . '/..' . '/aura/sqlquery/src/Sqlsrv/Select.php',
         'Aura\\SqlQuery\\Sqlsrv\\Update' => __DIR__ . '/..' . '/aura/sqlquery/src/Sqlsrv/Update.php',
+        'Automattic\\WooCommerce\\Client' => __DIR__ . '/..' . '/automattic/woocommerce/src/WooCommerce/Client.php',
+        'Automattic\\WooCommerce\\HttpClient\\BasicAuth' => __DIR__ . '/..' . '/automattic/woocommerce/src/WooCommerce/HttpClient/BasicAuth.php',
+        'Automattic\\WooCommerce\\HttpClient\\HttpClient' => __DIR__ . '/..' . '/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClient.php',
+        'Automattic\\WooCommerce\\HttpClient\\HttpClientException' => __DIR__ . '/..' . '/automattic/woocommerce/src/WooCommerce/HttpClient/HttpClientException.php',
+        'Automattic\\WooCommerce\\HttpClient\\OAuth' => __DIR__ . '/..' . '/automattic/woocommerce/src/WooCommerce/HttpClient/OAuth.php',
+        'Automattic\\WooCommerce\\HttpClient\\Options' => __DIR__ . '/..' . '/automattic/woocommerce/src/WooCommerce/HttpClient/Options.php',
+        'Automattic\\WooCommerce\\HttpClient\\Request' => __DIR__ . '/..' . '/automattic/woocommerce/src/WooCommerce/HttpClient/Request.php',
+        'Automattic\\WooCommerce\\HttpClient\\Response' => __DIR__ . '/..' . '/automattic/woocommerce/src/WooCommerce/HttpClient/Response.php',
         'Aws\\ACMPCA\\ACMPCAClient' => __DIR__ . '/..' . '/aws/aws-sdk-php/src/ACMPCA/ACMPCAClient.php',
         'Aws\\ACMPCA\\Exception\\ACMPCAException' => __DIR__ . '/..' . '/aws/aws-sdk-php/src/ACMPCA/Exception/ACMPCAException.php',
         'Aws\\AbstractConfigurationProvider' => __DIR__ . '/..' . '/aws/aws-sdk-php/src/AbstractConfigurationProvider.php',
@@ -4029,6 +4042,7 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
         'Xentral\\Modules\\Wizard\\Exception\\NotFoundException' => __DIR__ . '/../..' . '/classes/Modules/Wizard/Exception/NotFoundException.php',
         'Xentral\\Modules\\Wizard\\Exception\\WizardExceptionInterface' => __DIR__ . '/../..' . '/classes/Modules/Wizard/Exception/WizardExceptionInterface.php',
         'Xentral\\Modules\\Wizard\\WizardService' => __DIR__ . '/../..' . '/classes/Modules/Wizard/WizardService.php',
+        'Xentral\\Services\\DatabaseService' => __DIR__ . '/../..' . '/classes/Services/DatabaseService.php',
         'Xentral\\Widgets\\Chart\\BarDataset' => __DIR__ . '/../..' . '/classes/Widgets/Chart/BarDataset.php',
         'Xentral\\Widgets\\Chart\\Chart' => __DIR__ . '/../..' . '/classes/Widgets/Chart/Chart.php',
         'Xentral\\Widgets\\Chart\\Color' => __DIR__ . '/../..' . '/classes/Widgets/Chart/Color.php',
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index 8f974ba08..d2aad4395 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -71,6 +71,119 @@
                         },
                         "install-path": "../aura/sqlquery"
                 },
+                {
+                        "name": "automattic/woocommerce",
+                        "version": "3.1.1",
+                        "version_normalized": "3.1.1.0",
+                        "source": {
+                                "type": "git",
+                                "url": "https://github.com/woocommerce/wc-api-php.git",
+                                "reference": "e378120df655b7dacbeff4756e23b41b66fe9688"
+                        },
+                        "dist": {
+                                "type": "zip",
+                                "url": "https://api.github.com/repos/woocommerce/wc-api-php/zipball/e378120df655b7dacbeff4756e23b41b66fe9688",
+                                "reference": "e378120df655b7dacbeff4756e23b41b66fe9688",
+                                "shasum": ""
+                        },
+                        "require": {
+                                "ext-curl": "*",
+                                "ext-json": "*",
+                                "php": ">= 7.4.0"
+                        },
+                        "require-dev": {
+                                "doctrine/instantiator": "^1.5.0",
+                                "phpunit/phpunit": "^9.5.0",
+                                "squizlabs/php_codesniffer": "3.*"
+                        },
+                        "time": "2026-01-30T16:26:03+00:00",
+                        "type": "library",
+                        "installation-source": "dist",
+                        "autoload": {
+                                "psr-4": {
+                                        "Automattic\\WooCommerce\\": [
+                                                "src/WooCommerce"
+                                        ]
+                                }
+                        },
+                        "notification-url": "https://packagist.org/downloads/",
+                        "license": [
+                                "MIT"
+                        ],
+                        "authors": [
+                                {
+                                        "name": "Claudio Sanches",
+                                        "email": "claudio.sanches@automattic.com"
+                                }
+                        ],
+                        "description": "A PHP wrapper for the WooCommerce REST API",
+                        "keywords": [
+                                "api",
+                                "woocommerce"
+                        ],
+                        "support": {
+                                "issues": "https://github.com/woocommerce/wc-api-php/issues",
+                                "source": "https://github.com/woocommerce/wc-api-php/tree/3.1.1"
+                        },
+                        "install-path": "../automattic/woocommerce"
+                },
+                {
+                        "name": "aws/aws-crt-php",
+                        "version": "v1.2.7",
+                        "version_normalized": "1.2.7.0",
+                        "source": {
+                                "type": "git",
+                                "url": "https://github.com/awslabs/aws-crt-php.git",
+                                "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
+                        },
+                        "dist": {
+                                "type": "zip",
+                                "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
+                                "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
+                                "shasum": ""
+                        },
+                        "require": {
+                                "php": ">=5.5"
+                        },
+                        "require-dev": {
+                                "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
+                                "yoast/phpunit-polyfills": "^1.0"
+                        },
+                        "suggest": {
+                                "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
+                        },
+                        "time": "2024-10-18T22:15:13+00:00",
+                        "type": "library",
+                        "installation-source": "dist",
+                        "autoload": {
+                                "classmap": [
+                                        "src/"
+                                ]
+                        },
+                        "notification-url": "https://packagist.org/downloads/",
+                        "license": [
+                                "Apache-2.0"
+                        ],
+                        "authors": [
+                                {
+                                        "name": "AWS SDK Common Runtime Team",
+                                        "email": "aws-sdk-common-runtime@amazon.com"
+                                }
+                        ],
+                        "description": "AWS Common Runtime for PHP",
+                        "homepage": "https://github.com/awslabs/aws-crt-php",
+                        "keywords": [
+                                "amazon",
+                                "aws",
+                                "crt",
+                                "sdk"
+                        ],
+                        "support": {
+                                "issues": "https://github.com/awslabs/aws-crt-php/issues",
+                                "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
+                        },
+                        "install-path": "../aws/aws-crt-php"
+                },
                 {
                         "name": "aws/aws-sdk-php",
                         "version": "3.175.2",
diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php
index 1292bc1b3..cfefe28e1 100644
--- a/vendor/composer/installed.php
+++ b/vendor/composer/installed.php
@@ -28,6 +28,24 @@
             'aliases' => array(),
             'dev_requirement' => false,
         ),
+        'automattic/woocommerce' => array(
+            'pretty_version' => '3.1.1',
+            'version' => '3.1.1.0',
+            'reference' => 'e378120df655b7dacbeff4756e23b41b66fe9688',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../automattic/woocommerce',
+            'aliases' => array(),
+            'dev_requirement' => false,
+        ),
+        'aws/aws-crt-php' => array(
+            'pretty_version' => 'v1.2.7',
+            'version' => '1.2.7.0',
+            'reference' => 'd71d9906c7bb63a28295447ba12e74723bd3730e',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../aws/aws-crt-php',
+            'aliases' => array(),
+            'dev_requirement' => false,
+        ),
         'aws/aws-sdk-php' => array(
             'pretty_version' => '3.175.2',
             'version' => '3.175.2.0',

From db442919a26056e80b46734aa4ad65444b63f4fc Mon Sep 17 00:00:00 2001
From: Avatarsia 
Date: Tue, 21 Apr 2026 10:06:45 +0200
Subject: [PATCH 24/30] refactor(woocommerce): add ClientWrapper for header
 access on upstream client

The upstream automattic/woocommerce SDK lacks two accessors required by
the OpenXE business logic:
  - Client::getLastResponse() (upstream only exposes $client->http->getResponse())
  - Response::getHeader($name) with case-insensitive lookup

Adds Xentral\Components\WooCommerce\ClientWrapper which wraps the upstream
Client and exposes both methods. ResponseWrapper normalises all header keys
to lowercase at construction time. ssl_ignore is mapped to the upstream
verify_ssl option; logger is accepted but unused (no upstream hook).

Co-Authored-By: Claude Sonnet 4.6 
---
 .../Components/WooCommerce/ClientWrapper.php  | 207 ++++++++++++++++++
 1 file changed, 207 insertions(+)
 create mode 100644 classes/Components/WooCommerce/ClientWrapper.php

diff --git a/classes/Components/WooCommerce/ClientWrapper.php b/classes/Components/WooCommerce/ClientWrapper.php
new file mode 100644
index 000000000..7e74267e6
--- /dev/null
+++ b/classes/Components/WooCommerce/ClientWrapper.php
@@ -0,0 +1,207 @@
+ lowercase-keyed header map */
+    private $lowercaseHeaders;
+
+    public function __construct(\Automattic\WooCommerce\HttpClient\Response $response)
+    {
+        $this->response = $response;
+
+        // Build a normalised, lowercase header map once.
+        $this->lowercaseHeaders = [];
+        foreach ($response->getHeaders() as $key => $value) {
+            $this->lowercaseHeaders[strtolower($key)] = $value;
+        }
+    }
+
+    /**
+     * Case-insensitive single-header lookup.
+     *
+     * @param string $name Header name (e.g. 'x-wp-total', 'X-WP-TotalPages')
+     * @return string|null Header value, or null when not present
+     */
+    public function getHeader($name)
+    {
+        $key = strtolower($name);
+        return isset($this->lowercaseHeaders[$key]) ? $this->lowercaseHeaders[$key] : null;
+    }
+
+    /**
+     * All response headers (lowercase keys).
+     *
+     * @return array
+     */
+    public function getHeaders()
+    {
+        return $this->lowercaseHeaders;
+    }
+
+    /** @return int HTTP status code */
+    public function getCode()
+    {
+        return $this->response->getCode();
+    }
+
+    /** @return string Response body */
+    public function getBody()
+    {
+        return $this->response->getBody();
+    }
+}
+
+/**
+ * Drop-in replacement for the old inline WCClient.
+ *
+ * Wraps the upstream Automattic\WooCommerce\Client and adds:
+ *   - getLastResponse(): ResponseWrapper
+ *   - ssl_ignore support via verify_ssl option
+ *   - logger parameter (accepted but unused — upstream has no logging hook)
+ */
+class ClientWrapper
+{
+    /** @var UpstreamClient */
+    private $client;
+
+    /** @var ResponseWrapper|null */
+    private $lastResponse;
+
+    /**
+     * @param string     $url            WooCommerce store URL
+     * @param string     $consumerKey    API consumer key
+     * @param string     $consumerSecret API consumer secret
+     * @param array      $options        Upstream SDK options (version, timeout, …)
+     * @param mixed      $logger         Accepted for API compatibility; unused
+     * @param bool|mixed $sslIgnore      When truthy, disables SSL certificate verification
+     */
+    public function __construct($url, $consumerKey, $consumerSecret, $options = [], $logger = null, $sslIgnore = false)
+    {
+        if ($sslIgnore) {
+            $options['verify_ssl'] = false;
+        }
+
+        $this->client = new UpstreamClient($url, $consumerKey, $consumerSecret, $options);
+    }
+
+    /**
+     * GET request.
+     *
+     * @param string $endpoint   API endpoint
+     * @param array  $parameters Query parameters
+     * @return \stdClass|array
+     * @throws HttpClientException
+     */
+    public function get($endpoint, $parameters = [])
+    {
+        $result = $this->client->get($endpoint, $parameters);
+        $this->captureResponse();
+        return $result;
+    }
+
+    /**
+     * POST request.
+     *
+     * @param string $endpoint API endpoint
+     * @param array  $data     Request body
+     * @return \stdClass
+     * @throws HttpClientException
+     */
+    public function post($endpoint, $data)
+    {
+        $result = $this->client->post($endpoint, $data);
+        $this->captureResponse();
+        return $result;
+    }
+
+    /**
+     * PUT request.
+     *
+     * @param string $endpoint API endpoint
+     * @param array  $data     Request body
+     * @return \stdClass
+     * @throws HttpClientException
+     */
+    public function put($endpoint, $data)
+    {
+        $result = $this->client->put($endpoint, $data);
+        $this->captureResponse();
+        return $result;
+    }
+
+    /**
+     * DELETE request.
+     *
+     * @param string $endpoint   API endpoint
+     * @param array  $parameters Query parameters
+     * @return \stdClass
+     * @throws HttpClientException
+     */
+    public function delete($endpoint, $parameters = [])
+    {
+        $result = $this->client->delete($endpoint, $parameters);
+        $this->captureResponse();
+        return $result;
+    }
+
+    /**
+     * OPTIONS request.
+     *
+     * @param string $endpoint API endpoint
+     * @return \stdClass
+     * @throws HttpClientException
+     */
+    public function options($endpoint)
+    {
+        $result = $this->client->options($endpoint);
+        $this->captureResponse();
+        return $result;
+    }
+
+    /**
+     * Returns the response from the most recent API call, or null when no
+     * request has been made yet.
+     *
+     * @return ResponseWrapper|null
+     */
+    public function getLastResponse()
+    {
+        return $this->lastResponse;
+    }
+
+    /**
+     * Snapshot the upstream response after each request.
+     */
+    private function captureResponse()
+    {
+        $upstreamResponse = $this->client->http->getResponse();
+        if ($upstreamResponse !== null) {
+            $this->lastResponse = new ResponseWrapper($upstreamResponse);
+        }
+    }
+}

From f4ee7b4415fb7cf169d886189f35f6119bfb5983 Mon Sep 17 00:00:00 2001
From: Avatarsia 
Date: Tue, 21 Apr 2026 10:06:55 +0200
Subject: [PATCH 25/30] refactor(woocommerce): replace inline WC SDK with
 composer dependency
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Removes all inline SDK classes (WCClient, WCHttpClient, WCResponse,
WCRequest, WCOAuth, WCBasicAuth, WCOptions, WCHttpClientException)
from shopimporter_woocommerce.php — 1374 lines deleted.

Business logic now uses:
  - new ClientWrapper(...) instead of new WCClient(...)
  - use Xentral\Components\WooCommerce\ClientWrapper
  - use Automattic\WooCommerce\HttpClient\HttpClientException

All @throws and catch blocks updated to HttpClientException. Fixes #264.

Co-Authored-By: Claude Sonnet 4.6 
---
 www/pages/shopimporter_woocommerce.php | 1408 +-----------------------
 1 file changed, 13 insertions(+), 1395 deletions(-)

diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php
index 104158ca5..bf33fcb96 100644
--- a/www/pages/shopimporter_woocommerce.php
+++ b/www/pages/shopimporter_woocommerce.php
@@ -1,13 +1,13 @@
 priceType = $felder['priceType'] ?? null;
 
     $this->url = $ImportWooCommerceApiUrl;
-    $this->client = new WCClient(
+    $this->client = new ClientWrapper(
       //URL des WooCommerce Rest Servers
       $ImportWooCommerceApiUrl,
       //WooCommerce API Key
@@ -1218,7 +1220,7 @@ public function AuthByAssistent()
     if (empty($ImportWooCommerceApiSecret)) {
       return new JsonResponse(['error' => 'Bitte das API-Secret angeben'], JsonResponse::HTTP_BAD_REQUEST);
     }
-    $this->client = new WCClient(
+    $this->client = new ClientWrapper(
       $ImportWooCommerceApiUrl,
       $ImportWooCommerceApiKey,
       $ImportWooCommerceApiSecret,
@@ -1287,7 +1289,7 @@ public function getCreateForm()
    * @param string $sku Artikelnummer
    *
    * @return array|null The WooCommerce product id of the given product, null if such a product does not exist
-   * @throws WCHttpClientException
+   * @throws HttpClientException
    */
   private function getShopIdBySKU($sku)
   {
@@ -1372,1387 +1374,3 @@ protected static function emptyString($string)
   }
 
 }
-
-class WCClient
-{
-  /**
-   * WooCommerce REST API WCClient version.
-   */
-  const VERSION = '3.0.0';
-
-  /**
-   * HttpClient instance.
-   *
-   * @var WCHttpClient
-   */
-  public $http;
-
-  /** @var Logger $logger */
-  public $logger;
-
-  public $ssl_ignore = false;
-
-  /**
-   * Initialize client.
-   *
-   * @param string $url            Store URL.
-   * @param string $consumerKey    Consumer key.
-   * @param string $consumerSecret Consumer secret.
-   * @param array  $options        WCOptions (version, timeout, verify_ssl).
-   *
-   * @throws WCHttpClientException
-   */
-  public function __construct($url, $consumerKey, $consumerSecret, $options = [], $logger, $ssl_ignore)
-  {
-    $this->http = new WCHttpClient($url, $consumerKey, $consumerSecret, $options, $logger, $ssl_ignore);
-    $this->logger = $logger;
-  }
-
-  /**
-   * POST method.
-   *
-   * @param string $endpoint API endpoint.
-   * @param array  $data     WCRequest data.
-   *
-   * @throws WCHttpClientException
-   *
-   * @return array
-   */
-  public function post($endpoint, $data)
-  {
-    return $this->http->request($endpoint, 'POST', $data);
-  }
-
-  /**
-   * PUT method.
-   *
-   * @param string $endpoint API endpoint.
-   * @param array  $data     WCRequest data.
-   *
-   * @throws WCHttpClientException
-   *
-   * @return array
-   */
-  public function put($endpoint, $data)
-  {
-    return $this->http->request($endpoint, 'PUT', $data);
-  }
-
-  /**
-   * GET method.
-   *
-   * @param string $endpoint   API endpoint.
-   * @param array  $parameters WCRequest parameters.
-   *
-   * @throws WCHttpClientException
-   *
-   * @return array
-   */
-  public function get($endpoint, $parameters = [])
-  {
-    return $this->http->request($endpoint, 'GET', [], $parameters);
-  }
-
-  /**
-   * DELETE method.
-   *
-   * @param string $endpoint   API endpoint.
-   * @param array  $parameters WCRequest parameters.
-   *
-   * @throws WCHttpClientException
-   *
-   * @return array
-   */
-  public function delete($endpoint, $parameters = [])
-  {
-    return $this->http->request($endpoint, 'DELETE', [], $parameters);
-  }
-
-  /**
-   * OPTIONS method.
-   *
-   * @param string $endpoint API endpoint.
-   *
-   * @throws WCHttpClientException
-   *
-   * @return array
-   */
-  public function options($endpoint)
-  {
-    return $this->http->request($endpoint, 'OPTIONS');
-  }
-
-  /**
-   * Get the WCResponse from the most recent HTTP request.
-   *
-   * @return WCResponse|null
-   */
-  public function getLastResponse()
-  {
-    return $this->http->getResponse();
-  }
-}
-
-class WCResponse
-{
-  /**
-   * WCResponse code.
-   *
-   * @var int
-   */
-  private $code;
-
-  /**
-   * WCResponse headers.
-   *
-   * @var array
-   */
-  private $headers;
-
-  /**
-   * WCResponse body.
-   *
-   * @var string
-   */
-  private $body;
-
-  /**
-   * Initialize response.
-   *
-   * @param int    $code    WCResponse code.
-   * @param array  $headers WCResponse headers.
-   * @param string $body    WCResponse body.
-   */
-  public function __construct($code = 0, $headers = [], $body = '')
-  {
-    $this->code = $code;
-    $this->headers = $headers;
-    $this->body = $body;
-  }
-
-  /**
-   * Set code.
-   *
-   * @param int $code WCResponse code.
-   */
-  public function setCode($code)
-  {
-    $this->code = (int) $code;
-  }
-
-  /**
-   * Set headers.
-   *
-   * @param array $headers WCResponse headers.
-   */
-  public function setHeaders($headers)
-  {
-    $this->headers = $headers;
-  }
-
-  /**
-   * Set body.
-   *
-   * @param string $body WCResponse body.
-   */
-  public function setBody($body)
-  {
-    $this->body = $body;
-  }
-
-  /**
-   * Get code.
-   *
-   * @return int
-   */
-  public function getCode()
-  {
-    return $this->code;
-  }
-
-  /**
-   * Get headers.
-   *
-   * @return array $headers WCResponse headers (keys normalized to lowercase).
-   */
-  public function getHeaders()
-  {
-    return $this->headers;
-  }
-
-  /**
-   * Get a single response header by name (case-insensitive).
-   *
-   * @param string $name Header name (e.g. 'x-wp-totalpages').
-   *
-   * @return string|null Header value or null if not present.
-   */
-  public function getHeader($name)
-  {
-    $key = strtolower($name);
-    return isset($this->headers[$key]) ? $this->headers[$key] : null;
-  }
-
-  /**
-   * Get body.
-   *
-   * @return string $body WCResponse body.
-   */
-  public function getBody()
-  {
-    return $this->body;
-  }
-}
-
-class WCOptions
-{
-  /**
-   * Default WooCommerce REST API version.
-   */
-  const VERSION = 'wc/v3';
-
-  /**
-   * Default request timeout.
-   */
-  const TIMEOUT = 30;
-
-  /**
-   * Default WP API prefix.
-   * Including leading and trailing slashes.
-   */
-  const WP_API_PREFIX = '/wp-json/';
-
-  /**
-   * Default User Agent.
-   * No version number.
-   */
-  const USER_AGENT = 'WooCommerce API Client-PHP';
-
-  /**
-   * WCOptions.
-   *
-   * @var array
-   */
-  private $options;
-
-  /**
-   * Initialize HTTP client options.
-   *
-   * @param array $options Client options.
-   */
-  public function __construct($options)
-  {
-    $this->options = $options;
-  }
-
-  /**
-   * Get API version.
-   *
-   * @return string
-   */
-  public function getVersion()
-  {
-    return isset($this->options['version']) ? $this->options['version'] : self::VERSION;
-  }
-
-  /**
-   * Check if need to verify SSL.
-   *
-   * @return bool
-   */
-  public function verifySsl()
-  {
-    return isset($this->options['verify_ssl']) ? (bool) $this->options['verify_ssl'] : true;
-  }
-
-  /**
-   * Get timeout.
-   *
-   * @return int
-   */
-  public function getTimeout()
-  {
-    return isset($this->options['timeout']) ? (int) $this->options['timeout'] : self::TIMEOUT;
-  }
-
-  /**
-   * Basic Authentication as query string.
-   * Some old servers are not able to use CURLOPT_USERPWD.
-   *
-   * @return bool
-   */
-  public function isQueryStringAuth()
-  {
-    return isset($this->options['query_string_auth']) ? (bool) $this->options['query_string_auth'] : false;
-  }
-
-  /**
-   * Check if is WP REST API.
-   *
-   * @return bool
-   */
-  public function isWPAPI()
-  {
-    return isset($this->options['wp_api']) ? (bool) $this->options['wp_api'] : true;
-  }
-
-  /**
-   * Custom API Prefix for WP API.
-   *
-   * @return string
-   */
-  public function apiPrefix()
-  {
-    return isset($this->options['wp_api_prefix']) ? $this->options['wp_api_prefix'] : self::WP_API_PREFIX;
-  }
-
-  /**
-   * oAuth timestamp.
-   *
-   * @return string
-   */
-  public function oauthTimestamp()
-  {
-    return isset($this->options['oauth_timestamp']) ? $this->options['oauth_timestamp'] : \time();
-  }
-
-  /**
-   * Custom user agent.
-   *
-   * @return string
-   */
-  public function userAgent()
-  {
-    return isset($this->options['user_agent']) ? $this->options['user_agent'] : self::USER_AGENT;
-  }
-
-  /**
-   * Get follow redirects
-   *
-   * @return bool
-   */
-  public function getFollowRedirects()
-  {
-    return isset($this->options['follow_redirects']) ? (bool) $this->options['follow_redirects'] : false;
-  }
-}
-
-class WCRequest
-{
-  /**
-   * WCRequest url.
-   *
-   * @var string
-   */
-  private $url;
-
-  /**
-   * WCRequest method.
-   *
-   * @var string
-   */
-  private $method;
-
-  /**
-   * WCRequest paramenters.
-   *
-   * @var array
-   */
-  private $parameters;
-
-  /**
-   * WCRequest headers.
-   *
-   * @var array
-   */
-  private $headers;
-
-  /**
-   * WCRequest body.
-   *
-   * @var string
-   */
-  private $body;
-
-  /**
-   * Initialize request.
-   *
-   * @param string $url        WCRequest url.
-   * @param string $method     WCRequest method.
-   * @param array  $parameters WCRequest paramenters.
-   * @param array  $headers    WCRequest headers.
-   * @param string $body       WCRequest body.
-   */
-  public function __construct($url = '', $method = 'POST', $parameters = [], $headers = [], $body = '')
-  {
-    $this->url = $url;
-    $this->method = $method;
-    $this->parameters = $parameters;
-    $this->headers = $headers;
-    $this->body = $body;
-  }
-
-  /**
-   * Set url.
-   *
-   * @param string $url WCRequest url.
-   */
-  public function setUrl($url)
-  {
-    $this->url = $url;
-  }
-
-  /**
-   * Set method.
-   *
-   * @param string $method WCRequest method.
-   */
-  public function setMethod($method)
-  {
-    $this->method = $method;
-  }
-
-  /**
-   * Set parameters.
-   *
-   * @param array $parameters WCRequest paramenters.
-   */
-  public function setParameters($parameters)
-  {
-    $this->parameters = $parameters;
-  }
-
-  /**
-   * Set headers.
-   *
-   * @param array $headers WCRequest headers.
-   */
-  public function setHeaders($headers)
-  {
-    $this->headers = $headers;
-  }
-
-  /**
-   * Set body.
-   *
-   * @param string $body WCRequest body.
-   */
-  public function setBody($body)
-  {
-    $this->body = $body;
-  }
-
-  /**
-   * Get url.
-   *
-   * @return string
-   */
-  public function getUrl()
-  {
-    return $this->url;
-  }
-
-  /**
-   * Get method.
-   *
-   * @return string
-   */
-  public function getMethod()
-  {
-    return $this->method;
-  }
-
-  /**
-   * Get parameters.
-   *
-   * @return array
-   */
-  public function getParameters()
-  {
-    return $this->parameters;
-  }
-
-  /**
-   * Get headers.
-   *
-   * @return array
-   */
-  public function getHeaders()
-  {
-    return $this->headers;
-  }
-
-  /**
-   * Get raw headers.
-   *
-   * @return array
-   */
-  public function getRawHeaders()
-  {
-    $headers = [];
-
-    foreach ($this->headers as $key => $value) {
-      $headers[] = $key . ': ' . $value;
-    }
-
-    return $headers;
-  }
-
-  /**
-   * Get body.
-   *
-   * @return string
-   */
-  public function getBody()
-  {
-    return $this->body;
-  }
-}
-
-class WCOAuth
-{
-  /**
-   * OAuth signature method algorithm.
-   */
-  const HASH_ALGORITHM = 'SHA256';
-
-  /**
-   * API endpoint URL.
-   *
-   * @var string
-   */
-  protected $url;
-
-  /**
-   * Consumer key.
-   *
-   * @var string
-   */
-  protected $consumerKey;
-
-  /**
-   * Consumer secret.
-   *
-   * @var string
-   */
-  protected $consumerSecret;
-
-  /**
-   * API version.
-   *
-   * @var string
-   */
-  protected $apiVersion;
-
-  /**
-   * WCRequest method.
-   *
-   * @var string
-   */
-  protected $method;
-
-  /**
-   * WCRequest parameters.
-   *
-   * @var array
-   */
-  protected $parameters;
-
-  /**
-   * Timestamp.
-   *
-   * @var string
-   */
-  protected $timestamp;
-
-  /**
-   * Initialize oAuth class.
-   *
-   * @param string $url            Store URL.
-   * @param string $consumerKey    Consumer key.
-   * @param string $consumerSecret Consumer Secret.
-   * @param string $method         WCRequest method.
-   * @param string $apiVersion     API version.
-   * @param array  $parameters     WCRequest parameters.
-   * @param string $timestamp      Timestamp.
-   */
-  public function __construct(
-    $url,
-    $consumerKey,
-    $consumerSecret,
-    $apiVersion,
-    $method,
-    $parameters = [],
-    $timestamp = ''
-  ) {
-    $this->url = $url;
-    $this->consumerKey = $consumerKey;
-    $this->consumerSecret = $consumerSecret;
-    $this->apiVersion = $apiVersion;
-    $this->method = $method;
-    $this->parameters = $parameters;
-    $this->timestamp = $timestamp;
-  }
-
-  /**
-   * Encode according to RFC 3986.
-   *
-   * @param string|array $value Value to be normalized.
-   *
-   * @return string
-   */
-  //TODO Rückgbabetyp prüfen
-  protected function encode($value)
-  {
-    if (is_array($value)) {
-      return array_map([$this, 'encode'], $value);
-    } else {
-      return str_replace(['+', '%7E'], [' ', '~'], rawurlencode($value));
-    }
-  }
-
-  /**
-   * Normalize parameters.
-   *
-   * @param array $parameters Parameters to normalize.
-   *
-   * @return array
-   */
-  protected function normalizeParameters($parameters)
-  {
-    $normalized = [];
-
-    foreach ($parameters as $key => $value) {
-      // Percent symbols (%) must be double-encoded.
-      $key = $this->encode($key);
-      $value = $this->encode($value);
-
-      $normalized[$key] = $value;
-    }
-
-    return $normalized;
-  }
-
-  /**
-   * Process filters.
-   *
-   * @param array $parameters WCRequest parameters.
-   *
-   * @return array
-   */
-  protected function processFilters($parameters)
-  {
-    if (isset($parameters['filter'])) {
-      $filters = $parameters['filter'];
-      unset($parameters['filter']);
-      foreach ($filters as $filter => $value) {
-        $parameters['filter[' . $filter . ']'] = $value;
-      }
-    }
-
-    return $parameters;
-  }
-
-  /**
-   * Get secret.
-   *
-   * @return string
-   */
-  protected function getSecret()
-  {
-    $secret = $this->consumerSecret;
-
-    // Fix secret for v3 or later.
-    if (!\in_array($this->apiVersion, ['v1', 'v2'])) {
-      $secret .= '&';
-    }
-
-    return $secret;
-  }
-
-  /**
-   * Generate oAuth1.0 signature.
-   *
-   * @param array $parameters WCRequest parameters including oauth.
-   *
-   * @return string
-   */
-  protected function generateOauthSignature($parameters)
-  {
-    $baseRequestUri = rawurlencode($this->url);
-
-    // Extract filters.
-    $parameters = $this->processFilters($parameters);
-
-    // Normalize parameter key/values and sort them.
-    $parameters = $this->normalizeParameters($parameters);
-    uksort($parameters, 'strcmp');
-
-    // Set query string.
-    $queryString = implode('%26', $this->joinWithEqualsSign($parameters)); // Join with ampersand.
-    $stringToSign = $this->method . '&' . $baseRequestUri . '&' . $queryString;
-    $secret = $this->getSecret();
-
-    return base64_encode(hash_hmac(self::HASH_ALGORITHM, $stringToSign, $secret, true));
-  }
-
-  /**
-   * Creates an array of urlencoded strings out of each array key/value pairs.
-   *
-   * @param  array  $params      Array of parameters to convert.
-   * @param  array  $queryParams Array to extend.
-   * @param  string $key         Optional Array key to append
-   * @return string              Array of urlencoded strings
-   */
-  protected function joinWithEqualsSign($params, $queryParams = [], $key = '')
-  {
-    foreach ($params as $paramKey => $paramValue) {
-      if ($key) {
-        $paramKey = $key . '%5B' . $paramKey . '%5D'; // Handle multi-dimensional array.
-      }
-
-      if (is_array($paramValue)) {
-        //TODO Typ prüfen
-        $queryParams = $this->joinWithEqualsSign($paramValue, $queryParams, $paramKey);
-      } else {
-        $string = $paramKey . '=' . $paramValue; // Join with equals sign.
-        $queryParams[] = $this->encode($string);
-      }
-    }
-
-    return $queryParams;
-  }
-
-  /**
-   * Sort parameters.
-   *
-   * @param array $parameters Parameters to sort in byte-order.
-   *
-   * @return array
-   */
-  protected function getSortedParameters($parameters)
-  {
-    uksort($parameters, 'strcmp');
-
-    foreach ($parameters as $key => $value) {
-      if (is_array($value)) {
-        uksort($parameters[$key], 'strcmp');
-      }
-    }
-
-    return $parameters;
-  }
-
-  /**
-   * Get oAuth1.0 parameters.
-   *
-   * @return string
-   */
-  public function getParameters()
-  {
-    $parameters = \array_merge($this->parameters, [
-      'oauth_consumer_key' => $this->consumerKey,
-      'oauth_timestamp' => $this->timestamp,
-      'oauth_nonce' => \sha1(\microtime()),
-      'oauth_signature_method' => 'HMAC-' . self::HASH_ALGORITHM,
-    ]);
-
-    // The parameters above must be included in the signature generation.
-    $parameters['oauth_signature'] = $this->generateOauthSignature($parameters);
-
-    //TODO Typ prüfen
-    return $this->getSortedParameters($parameters);
-  }
-}
-
-class WCHttpClientException extends \Exception
-{
-  /**
-   * WCRequest.
-   *
-   * @var WCRequest
-   */
-  private $request;
-
-  /**
-   * WCResponse.
-   *
-   * @var WCResponse
-   */
-  private $response;
-
-  /**
-   * Initialize exception.
-   *
-   * @param string   $message  Error message.
-   * @param int      $code     Error code.
-   * @param WCRequest  $request  Request data.
-   * @param WCResponse $response Response data.
-   */
-  public function __construct($message, $code, WCRequest $request, WCResponse $response)
-  {
-    parent::__construct($message, $code);
-
-    $this->request = $request;
-    $this->response = $response;
-  }
-
-  /**
-   * Get request data.
-   *
-   * @return WCRequest
-   */
-  public function getRequest()
-  {
-    return $this->request;
-  }
-
-  /**
-   * Get response data.
-   *
-   * @return WCResponse
-   */
-  public function getResponse()
-  {
-    return $this->response;
-  }
-}
-
-class WCHttpClient
-{
-  /**
-   * cURL handle.
-   *
-   * @var resource
-   */
-  protected $ch;
-
-  /**
-   * Store API URL.
-   *
-   * @var string
-   */
-  protected $url;
-
-  /**
-   * Consumer key.
-   *
-   * @var string
-   */
-  protected $consumerKey;
-
-  /**
-   * Consumer secret.
-   *
-   * @var string
-   */
-  protected $consumerSecret;
-
-  /**
-   * WCClient options.
-   *
-   * @var WCOptions
-   */
-  protected $options;
-
-  /**
-   * WCRequest.
-   *
-   * @var WCRequest
-   */
-  private $request;
-
-  /**
-   * WCResponse.
-   *
-   * @var WCResponse
-   */
-  private $response;
-
-  /**
-   * WCResponse headers.
-   *
-   * @var string
-   */
-  private $responseHeaders;
-
-  /** @var Logger $logger */
-  public $logger;
-
-  public $ssl_ignore = false;
-
-  /**
-   * Initialize HTTP client.
-   *
-   * @param string $url            Store URL.
-   * @param string $consumerKey    Consumer key.
-   * @param string $consumerSecret Consumer Secret.
-   * @param array  $options        WCClient options.
-   *
-   * @throws WCHttpClientException
-   */
-  public function __construct($url, $consumerKey, $consumerSecret, $options, $logger, $ssl_ignore)
-  {
-    if (!function_exists('curl_version')) {
-      throw new WCHttpClientException('cURL is NOT installed on this server', -1, new WCRequest(), new WCResponse());
-    }
-
-    $this->options = new WCOptions($options);
-    $this->url = $this->buildApiUrl($url);
-    $this->consumerKey = $consumerKey;
-    $this->consumerSecret = $consumerSecret;
-    $this->logger = $logger;
-    $this->ssl_ignore = $ssl_ignore;
-  }
-
-  /**
-   * Check if is under SSL.
-   *
-   * @return bool
-   */
-  protected function isSsl()
-  {
-    return strpos($this->url, 'https://') === 0;
-
-  }
-
-  /**
-   * Build API URL.
-   *
-   * @param string $url Store URL.
-   *
-   * @return string
-   */
-  protected function buildApiUrl($url)
-  {
-    $api = $this->options->isWPAPI() ? $this->options->apiPrefix() : '/wc-api/';
-
-    return rtrim($url, '/') . $api . $this->options->getVersion() . '/';
-  }
-
-  /**
-   * Build URL.
-   *
-   * @param string $url        URL.
-   * @param array  $parameters Query string parameters.
-   *
-   * @return string
-   */
-  protected function buildUrlQuery($url, $parameters = [])
-  {
-    if (!empty($parameters)) {
-      $url .= '?' . http_build_query($parameters);
-    }
-
-    return $url;
-  }
-
-  /**
-   * Authenticate.
-   *
-   * @param string $url        WCRequest URL.
-   * @param string $method     WCRequest method.
-   * @param array  $parameters WCRequest parameters.
-   *
-   * @return array
-   */
-  protected function authenticate($url, $method, $parameters = [])
-  {
-    // Setup authentication.
-    if ($this->isSsl()) {
-      $basicAuth = new WCBasicAuth(
-        $this->ch,
-        $this->consumerKey,
-        $this->consumerSecret,
-        $this->options->isQueryStringAuth(),
-        $parameters
-      );
-      $parameters = $basicAuth->getParameters();
-    } else {
-      $oAuth = new WCOAuth(
-        $url,
-        $this->consumerKey,
-        $this->consumerSecret,
-        $this->options->getVersion(),
-        $method,
-        $parameters,
-        $this->options->oauthTimestamp()
-      );
-      //TODO Typ prüfen
-      $parameters = $oAuth->getParameters();
-    }
-
-    return $parameters;
-  }
-
-  /**
-   * Setup method.
-   *
-   * @param string $method WCRequest method.
-   */
-  protected function setupMethod($method)
-  {
-    if ('POST' === $method) {
-      curl_setopt($this->ch, CURLOPT_POST, true);
-    } elseif (in_array($method, ['PUT', 'DELETE', 'OPTIONS'])) {
-      curl_setopt($this->ch, CURLOPT_CUSTOMREQUEST, $method);
-    }
-  }
-
-  /**
-   * Get request headers.
-   *
-   * @param  bool $sendData If request send data or not.
-   *
-   * @return array
-   */
-  protected function getRequestHeaders($sendData = false)
-  {
-    $headers = [
-      'Accept' => 'application/json',
-      'User-Agent' => $this->options->userAgent() . '/' . WCClient::VERSION,
-    ];
-
-    if ($sendData) {
-      $headers['Content-Type'] = 'application/json;charset=utf-8';
-    }
-
-    return $headers;
-  }
-
-  /**
-   * Create request.
-   *
-   * @param string $endpoint   WCRequest endpoint.
-   * @param string $method     WCRequest method.
-   * @param array  $data       WCRequest data.
-   * @param array  $parameters WCRequest parameters.
-   *
-   * @return WCRequest
-   */
-  protected function createRequest($endpoint, $method, $data = [], $parameters = [])
-  {
-    $body = '';
-    $url = $this->url . $endpoint;
-    $hasData = !empty($data);
-
-    // Setup authentication.
-    $parameters = $this->authenticate($url, $method, $parameters);
-
-    // Setup method.
-    $this->setupMethod($method);
-
-    // Include post fields.
-    if ($hasData) {
-      $body = json_encode($data);
-      curl_setopt($this->ch, CURLOPT_POSTFIELDS, $body);
-    }
-
-    $this->request = new WCRequest(
-      $this->buildUrlQuery($url, $parameters),
-      $method,
-      $parameters,
-      $this->getRequestHeaders($hasData),
-      $body
-    );
-
-    return $this->getRequest();
-  }
-
-  /**
-   * Get response headers.
-   *
-   * @return array
-   */
-  protected function getResponseHeaders()
-  {
-    $headers = [];
-    $lines = explode("\n", $this->responseHeaders);
-    $lines = array_filter($lines, 'trim');
-
-    foreach ($lines as $index => $line) {
-      // Remove HTTP/xxx params.
-      if (strpos($line, ': ') === false) {
-        continue;
-      }
-
-      list($key, $value) = explode(': ', $line);
-
-      $key = strtolower($key);
-      $headers[$key] = isset($headers[$key]) ? $headers[$key] . ', ' . trim($value) : trim($value);
-    }
-
-    return $headers;
-  }
-
-  /**
-   * Create response.
-   *
-   * @return WCResponse
-   */
-  protected function createResponse()
-  {
-    // Set response headers.
-    $this->responseHeaders = '';
-    curl_setopt($this->ch, CURLOPT_HEADERFUNCTION, function ($_, $headers) {
-      $this->responseHeaders .= $headers;
-      return strlen($headers);
-    });
-
-    // Get response data.
-    $body = curl_exec($this->ch);
-    $code = curl_getinfo($this->ch, CURLINFO_HTTP_CODE);
-    $headers = $this->getResponseHeaders();
-
-    // Register response.
-    $this->response = new WCResponse($code, $headers, $body);
-
-    return $this->getResponse();
-  }
-
-  /**
-   * Set default cURL settings.
-   */
-  protected function setDefaultCurlSettings()
-  {
-    if (!$this->ssl_ignore) {
-      $verifySsl = $this->options->verifySsl();
-    }
-
-    $timeout = $this->options->getTimeout();
-    $followRedirects = $this->options->getFollowRedirects();
-
-    curl_setopt($this->ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
-    if (!$verifySsl) {
-      curl_setopt($this->ch, CURLOPT_SSL_VERIFYHOST, $verifySsl);
-    }
-    if ($followRedirects) {
-      curl_setopt($this->ch, CURLOPT_FOLLOWLOCATION, true);
-    }
-    curl_setopt($this->ch, CURLOPT_CONNECTTIMEOUT, $timeout);
-    curl_setopt($this->ch, CURLOPT_TIMEOUT, $timeout);
-    curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true);
-    curl_setopt($this->ch, CURLOPT_HTTPHEADER, $this->request->getRawHeaders());
-    curl_setopt($this->ch, CURLOPT_URL, $this->request->getUrl());
-  }
-
-  /**
-   * Look for errors in the request.
-   *
-   * @throws WCHttpClientException
-   *
-   * @param array $parsedResponse Parsed body response.
-   */
-  protected function lookForErrors($parsedResponse)
-  {
-    // Any non-200/201/202 response code indicates an error.
-    if (!in_array($this->response->getCode(), ['200', '201', '202'])) {
-      $errors = isset($parsedResponse->errors) ? $parsedResponse->errors : $parsedResponse;
-      $errorMessage = '';
-      $errorCode = '';
-
-      if (is_array($errors)) {
-        $errorMessage = $errors[0]->message;
-        $errorCode = $errors[0]->code;
-      } elseif (isset($errors->message, $errors->code)) {
-        $errorMessage = $errors->message;
-        $errorCode = $errors->code;
-      }
-
-      $this->logger->error(
-        'WooCommerce Error',
-        [
-          'request' => $this->request,
-          'response' => $this->response
-        ]
-      );
-
-      throw new WCHttpClientException(
-        sprintf('Error: %s [%s]', $errorMessage, $errorCode),
-        $this->response->getCode(),
-        $this->request,
-        $this->response
-      );
-    }
-  }
-
-  /**
-   * Process response.
-   *
-   * @throws WCHttpClientException
-   * @return array
-   */
-
-  protected function processResponse()
-  {
-    $body = $this->response->getBody();
-
-    // Look for UTF-8 BOM and remove.
-    if (0 === strpos(bin2hex(substr($body, 0, 4)), 'efbbbf')) {
-      $body = substr($body, 3);
-    }
-
-    $parsedResponse = json_decode($body);
-
-    // Test if return a valid JSON.
-    if (JSON_ERROR_NONE !== json_last_error()) {
-      $message = function_exists('json_last_error_msg') ? json_last_error_msg() : 'Invalid JSON returned';
-      // Log the problematic body for debugging
-      if (isset($this->logger)) {
-        $this->logger->error('JSON Parse Error - Raw Body', [
-          'body_preview' => substr($body, 0, 1000),
-          'body_length' => strlen($body),
-          'json_error' => $message
-        ]);
-      }
-      throw new WCHttpClientException(
-        sprintf('JSON ERROR: %s', $message),
-        $this->response->getCode(),
-        $this->request,
-        $this->response
-      );
-    }
-
-    $this->lookForErrors($parsedResponse);
-
-    return $parsedResponse;
-  }
-
-  /**
-   * Make requests.
-   *
-   * @param string $endpoint   WCRequest endpoint.
-   * @param string $method     WCRequest method.
-   * @param array  $data       WCRequest data.
-   * @param array  $parameters WCRequest parameters.
-   *
-   * @throws WCHttpClientException
-   *
-   * @return array
-   */
-  public function request($endpoint, $method, $data = [], $parameters = [])
-  {
-
-    // Initialize cURL.
-    $this->ch = curl_init();
-
-    // Set request args.
-    $request = $this->createRequest($endpoint, $method, $data, $parameters);
-
-    // Default cURL settings.
-    $this->setDefaultCurlSettings();
-
-    // Get response.
-    $response = $this->createResponse();
-
-    // Check for cURL errors.
-    if (curl_errno($this->ch)) {
-      throw new WCHttpClientException('cURL Error: ' . \curl_error($this->ch), 0, $request, $response);
-    }
-
-    curl_close($this->ch);
-
-    return $this->processResponse();
-  }
-
-  /**
-   * Get request data.
-   *
-   * @return WCRequest
-   */
-  public function getRequest()
-  {
-    return $this->request;
-  }
-
-  /**
-   * Get response data.
-   *
-   * @return WCResponse
-   */
-  public function getResponse()
-  {
-    return $this->response;
-  }
-}
-
-class WCBasicAuth
-{
-  /**
-   * cURL handle.
-   *
-   * @var resource
-   */
-  protected $ch;
-
-  /**
-   * Consumer key.
-   *
-   * @var string
-   */
-  protected $consumerKey;
-
-  /**
-   * Consumer secret.
-   *
-   * @var string
-   */
-  protected $consumerSecret;
-
-  /**
-   * Do query string auth.
-   *
-   * @var bool
-   */
-  protected $doQueryString;
-
-  /**
-   * WCRequest parameters.
-   *
-   * @var array
-   */
-  protected $parameters;
-
-  /**
-   * Initialize Basic Authentication class.
-   *
-   * @param resource $ch             cURL handle.
-   * @param string   $consumerKey    Consumer key.
-   * @param string   $consumerSecret Consumer Secret.
-   * @param bool     $doQueryString  Do or not query string auth.
-   * @param array    $parameters     WCRequest parameters.
-   */
-  public function __construct($ch, $consumerKey, $consumerSecret, $doQueryString, $parameters = [])
-  {
-    $this->ch = $ch;
-    $this->consumerKey = $consumerKey;
-    $this->consumerSecret = $consumerSecret;
-    $this->doQueryString = $doQueryString;
-    $this->parameters = $parameters;
-
-    $this->processAuth();
-  }
-
-  /**
-   * Process auth.
-   */
-  protected function processAuth()
-  {
-    if ($this->doQueryString) {
-      $this->parameters['consumer_key'] = $this->consumerKey;
-      $this->parameters['consumer_secret'] = $this->consumerSecret;
-    } else {
-      \curl_setopt($this->ch, CURLOPT_USERPWD, $this->consumerKey . ':' . $this->consumerSecret);
-    }
-  }
-
-  /**
-   * Get parameters.
-   *
-   * @return array
-   */
-  public function getParameters()
-  {
-    return $this->parameters;
-  }
-}

From 5a978f3e00990316a4331b98624492f7d67dc6c0 Mon Sep 17 00:00:00 2001
From: Avatarsia 
Date: Tue, 21 Apr 2026 10:26:30 +0200
Subject: [PATCH 26/30] fix(woocommerce): use injected logger in ClientWrapper
 for API error diagnostics

Constructor accepted a logger but discarded it, which removed the
structured WooCommerce API error logs (4xx/5xx responses, failed
requests) that the inline SDK used to produce. That was a regression
for operational diagnostics.

ClientWrapper now:
- stores the PSR-3 logger passed in by the shopimporter,
- logs HTTP >= 400 responses as warning (with a truncated body excerpt),
- logs HttpClientException as error before rethrowing,
- keeps the same public interface, no callsite changes needed.

Spotted by review of e433b5a7.
---
 .../Components/WooCommerce/ClientWrapper.php  | 91 ++++++++++++++-----
 1 file changed, 70 insertions(+), 21 deletions(-)

diff --git a/classes/Components/WooCommerce/ClientWrapper.php b/classes/Components/WooCommerce/ClientWrapper.php
index 7e74267e6..3bec69a13 100644
--- a/classes/Components/WooCommerce/ClientWrapper.php
+++ b/classes/Components/WooCommerce/ClientWrapper.php
@@ -82,7 +82,8 @@ public function getBody()
  * Wraps the upstream Automattic\WooCommerce\Client and adds:
  *   - getLastResponse(): ResponseWrapper
  *   - ssl_ignore support via verify_ssl option
- *   - logger parameter (accepted but unused — upstream has no logging hook)
+ *   - PSR-3-style logger integration: HTTP >= 400 is logged as warning,
+ *     HttpClientException is logged as error (and rethrown)
  */
 class ClientWrapper
 {
@@ -92,12 +93,15 @@ class ClientWrapper
     /** @var ResponseWrapper|null */
     private $lastResponse;
 
+    /** @var \Psr\Log\LoggerInterface|null */
+    private $logger;
+
     /**
      * @param string     $url            WooCommerce store URL
      * @param string     $consumerKey    API consumer key
      * @param string     $consumerSecret API consumer secret
      * @param array      $options        Upstream SDK options (version, timeout, …)
-     * @param mixed      $logger         Accepted for API compatibility; unused
+     * @param mixed      $logger         PSR-3-compatible logger; null disables logging
      * @param bool|mixed $sslIgnore      When truthy, disables SSL certificate verification
      */
     public function __construct($url, $consumerKey, $consumerSecret, $options = [], $logger = null, $sslIgnore = false)
@@ -107,6 +111,7 @@ public function __construct($url, $consumerKey, $consumerSecret, $options = [],
         }
 
         $this->client = new UpstreamClient($url, $consumerKey, $consumerSecret, $options);
+        $this->logger = $logger;
     }
 
     /**
@@ -119,9 +124,9 @@ public function __construct($url, $consumerKey, $consumerSecret, $options = [],
      */
     public function get($endpoint, $parameters = [])
     {
-        $result = $this->client->get($endpoint, $parameters);
-        $this->captureResponse();
-        return $result;
+        return $this->dispatch('GET', $endpoint, function () use ($endpoint, $parameters) {
+            return $this->client->get($endpoint, $parameters);
+        });
     }
 
     /**
@@ -134,9 +139,9 @@ public function get($endpoint, $parameters = [])
      */
     public function post($endpoint, $data)
     {
-        $result = $this->client->post($endpoint, $data);
-        $this->captureResponse();
-        return $result;
+        return $this->dispatch('POST', $endpoint, function () use ($endpoint, $data) {
+            return $this->client->post($endpoint, $data);
+        });
     }
 
     /**
@@ -149,9 +154,9 @@ public function post($endpoint, $data)
      */
     public function put($endpoint, $data)
     {
-        $result = $this->client->put($endpoint, $data);
-        $this->captureResponse();
-        return $result;
+        return $this->dispatch('PUT', $endpoint, function () use ($endpoint, $data) {
+            return $this->client->put($endpoint, $data);
+        });
     }
 
     /**
@@ -164,9 +169,9 @@ public function put($endpoint, $data)
      */
     public function delete($endpoint, $parameters = [])
     {
-        $result = $this->client->delete($endpoint, $parameters);
-        $this->captureResponse();
-        return $result;
+        return $this->dispatch('DELETE', $endpoint, function () use ($endpoint, $parameters) {
+            return $this->client->delete($endpoint, $parameters);
+        });
     }
 
     /**
@@ -178,9 +183,9 @@ public function delete($endpoint, $parameters = [])
      */
     public function options($endpoint)
     {
-        $result = $this->client->options($endpoint);
-        $this->captureResponse();
-        return $result;
+        return $this->dispatch('OPTIONS', $endpoint, function () use ($endpoint) {
+            return $this->client->options($endpoint);
+        });
     }
 
     /**
@@ -195,13 +200,57 @@ public function getLastResponse()
     }
 
     /**
-     * Snapshot the upstream response after each request.
+     * Run an upstream call with unified response capture and error logging.
+     * Exceptions are logged (when a logger is available) and rethrown so
+     * existing callsites keep their try/catch contract.
+     *
+     * @param string   $method   HTTP verb for log context
+     * @param string   $endpoint API endpoint for log context
+     * @param callable $call     Zero-argument closure performing the upstream call
+     * @return mixed Upstream result
+     * @throws HttpClientException
+     */
+    private function dispatch($method, $endpoint, callable $call)
+    {
+        try {
+            $result = $call();
+        } catch (HttpClientException $e) {
+            $this->captureResponse($method, $endpoint);
+            if ($this->logger !== null) {
+                $this->logger->error(
+                    sprintf('WooCommerce %s %s failed: %s', $method, $endpoint, $e->getMessage()),
+                    ['code' => $e->getCode()]
+                );
+            }
+            throw $e;
+        }
+        $this->captureResponse($method, $endpoint);
+        return $result;
+    }
+
+    /**
+     * Snapshot the upstream response after each request. HTTP >= 400 is
+     * logged as warning with a truncated body excerpt to keep logs small.
+     *
+     * @param string $method   HTTP verb for log context
+     * @param string $endpoint API endpoint for log context
      */
-    private function captureResponse()
+    private function captureResponse($method, $endpoint)
     {
         $upstreamResponse = $this->client->http->getResponse();
-        if ($upstreamResponse !== null) {
-            $this->lastResponse = new ResponseWrapper($upstreamResponse);
+        if ($upstreamResponse === null) {
+            return;
+        }
+        $this->lastResponse = new ResponseWrapper($upstreamResponse);
+
+        if ($this->logger !== null) {
+            $code = $upstreamResponse->getCode();
+            if ($code >= 400) {
+                $this->logger->warning(
+                    sprintf('WooCommerce %s %s returned HTTP %d', $method, $endpoint, $code),
+                    ['body' => substr((string) $upstreamResponse->getBody(), 0, 500)]
+                );
+            }
         }
     }
 }

From 1797d5d03005a4d32afc57d1db0d29d8928aa130 Mon Sep 17 00:00:00 2001
From: Avatarsia 
Date: Tue, 21 Apr 2026 10:28:34 +0200
Subject: [PATCH 27/30] chore(composer): regenerate autoloader metadata for
 current HEAD

vendor/composer/installed.php referenced an outdated HEAD (84fdb979)
from when the composer require was first run, not the final branch
state. Regenerated via composer install so InstalledVersions::
getRootPackage() returns the correct revision and composer
dump-autoload is a no-op on a fresh clone.

Spotted by review of 1a611230.
---
 vendor/composer/autoload_classmap.php | 1 +
 vendor/composer/autoload_static.php   | 1 +
 2 files changed, 2 insertions(+)

diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php
index 75b0ae70e..d6b1f0529 100644
--- a/vendor/composer/autoload_classmap.php
+++ b/vendor/composer/autoload_classmap.php
@@ -2642,6 +2642,7 @@
     'Xentral\\Components\\Util\\Exception\\StringUtilException' => $baseDir . '/classes/Components/Util/Exception/StringUtilException.php',
     'Xentral\\Components\\Util\\Exception\\UtilExceptionInterface' => $baseDir . '/classes/Components/Util/Exception/UtilExceptionInterface.php',
     'Xentral\\Components\\Util\\StringUtil' => $baseDir . '/classes/Components/Util/StringUtil.php',
+    'Xentral\\Components\\WooCommerce\\ClientWrapper' => $baseDir . '/classes/Components/WooCommerce/ClientWrapper.php',
     'Xentral\\Core\\DependencyInjection\\AbstractBaseContainer' => $baseDir . '/classes/Core/DependencyInjection/AbstractBaseContainer.php',
     'Xentral\\Core\\DependencyInjection\\ContainerInterface' => $baseDir . '/classes/Core/DependencyInjection/ContainerInterface.php',
     'Xentral\\Core\\DependencyInjection\\Definition\\FactoryMethodDefinition' => $baseDir . '/classes/Core/DependencyInjection/Definition/FactoryMethodDefinition.php',
diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php
index 0c530dc74..ed6698ce2 100644
--- a/vendor/composer/autoload_static.php
+++ b/vendor/composer/autoload_static.php
@@ -2949,6 +2949,7 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
         'Xentral\\Components\\Util\\Exception\\StringUtilException' => __DIR__ . '/../..' . '/classes/Components/Util/Exception/StringUtilException.php',
         'Xentral\\Components\\Util\\Exception\\UtilExceptionInterface' => __DIR__ . '/../..' . '/classes/Components/Util/Exception/UtilExceptionInterface.php',
         'Xentral\\Components\\Util\\StringUtil' => __DIR__ . '/../..' . '/classes/Components/Util/StringUtil.php',
+        'Xentral\\Components\\WooCommerce\\ClientWrapper' => __DIR__ . '/../..' . '/classes/Components/WooCommerce/ClientWrapper.php',
         'Xentral\\Core\\DependencyInjection\\AbstractBaseContainer' => __DIR__ . '/../..' . '/classes/Core/DependencyInjection/AbstractBaseContainer.php',
         'Xentral\\Core\\DependencyInjection\\ContainerInterface' => __DIR__ . '/../..' . '/classes/Core/DependencyInjection/ContainerInterface.php',
         'Xentral\\Core\\DependencyInjection\\Definition\\FactoryMethodDefinition' => __DIR__ . '/../..' . '/classes/Core/DependencyInjection/Definition/FactoryMethodDefinition.php',

From 36452313714b31bad5e7f2fe8aa87c3f1d9924f0 Mon Sep 17 00:00:00 2001
From: Avatarsia 
Date: Tue, 21 Apr 2026 10:29:06 +0200
Subject: [PATCH 28/30] chore(git): exempt vendor/ from diff-check whitespace
 rules

Upstream vendored code (e.g. vendor/automattic/woocommerce/composer.json)
ships with formatting choices we do not control. Without this, git diff
--check reports their trailing whitespace as if it were a review issue
on our branches.

Spotted by review of 1a611230.
---
 .gitattributes | 4 ++++
 1 file changed, 4 insertions(+)
 create mode 100644 .gitattributes

diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..523fc6fce
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,4 @@
+# Exclude vendored third-party code from git whitespace / diff-check hygiene
+# so that upstream formatting choices (trailing whitespace, tabs, mixed EOL)
+# do not block `git diff --check` or review tooling.
+vendor/** -whitespace

From fa5dd52f24c581680e2c3dc549c544db5d9c0c81 Mon Sep 17 00:00:00 2001
From: Avatarsia 
Date: Tue, 21 Apr 2026 10:33:21 +0200
Subject: [PATCH 29/30] chore(composer): re-sync installed.php to previous HEAD

This file's root-package reference is a post-facto artefact: it records
whatever HEAD was when `composer install` ran, so any following commit
puts it out of sync with the new HEAD. This sync-commit lets installed.php
reference the last non-sync commit (f26fb82b). Merging the branch will
regenerate it against the merge commit automatically.

There is no way to make it reference its own commit hash without a
self-referencing cycle; if this becomes a CI concern later,
vendor/composer/installed.php could be moved to .gitignore on the
understanding that composer install runs at deploy time.
---
 vendor/composer/installed.php | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php
index cfefe28e1..8e1da156d 100644
--- a/vendor/composer/installed.php
+++ b/vendor/composer/installed.php
@@ -1,9 +1,9 @@
  array(
         'name' => '__root__',
-        'pretty_version' => '1.0.0+no-version-set',
-        'version' => '1.0.0.0',
-        'reference' => null,
+        'pretty_version' => 'dev-master',
+        'version' => 'dev-master',
+        'reference' => '0b5be036',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -11,9 +11,9 @@
     ),
     'versions' => array(
         '__root__' => array(
-            'pretty_version' => '1.0.0+no-version-set',
-            'version' => '1.0.0.0',
-            'reference' => null,
+            'pretty_version' => 'dev-master',
+            'version' => 'dev-master',
+            'reference' => '0b5be036',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),

From 204019456c63ae6ccca3bf7b87c953d3c04c2593 Mon Sep 17 00:00:00 2001
From: Avatarsia 
Date: Tue, 21 Apr 2026 14:03:31 +0200
Subject: [PATCH 30/30] chore(composer): re-sync installed.php after rebase
 onto #266

Post-rebase regeneration of the Composer root reference so
InstalledVersions::getRootPackage() describes the current branch tip
rather than the pre-rebase commit.

Spotted by review of #267.
---
 vendor/autoload.php                   |   5 +-
 vendor/composer/InstalledVersions.php |  20 +++-
 vendor/composer/autoload_classmap.php |  28 ++++-
 vendor/composer/autoload_real.php     |   2 -
 vendor/composer/autoload_static.php   | 144 +++++++++++++++-----------
 vendor/composer/installed.json        |   3 +-
 vendor/composer/installed.php         |   4 +-
 vendor/composer/platform_check.php    |  26 -----
 8 files changed, 130 insertions(+), 102 deletions(-)
 delete mode 100644 vendor/composer/platform_check.php

diff --git a/vendor/autoload.php b/vendor/autoload.php
index 76bb3835a..3d848fa8f 100644
--- a/vendor/autoload.php
+++ b/vendor/autoload.php
@@ -14,10 +14,7 @@
             echo $err;
         }
     }
-    trigger_error(
-        $err,
-        E_USER_ERROR
-    );
+    throw new RuntimeException($err);
 }
 
 require_once __DIR__ . '/composer/autoload_real.php';
diff --git a/vendor/composer/InstalledVersions.php b/vendor/composer/InstalledVersions.php
index 6d29bff66..2052022fd 100644
--- a/vendor/composer/InstalledVersions.php
+++ b/vendor/composer/InstalledVersions.php
@@ -26,6 +26,12 @@
  */
 class InstalledVersions
 {
+    /**
+     * @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
+     * @internal
+     */
+    private static $selfDir = null;
+
     /**
      * @var mixed[]|null
      * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null
@@ -322,6 +328,18 @@ public static function reload($data)
         self::$installedIsLocalDir = false;
     }
 
+    /**
+     * @return string
+     */
+    private static function getSelfDir()
+    {
+        if (self::$selfDir === null) {
+            self::$selfDir = strtr(__DIR__, '\\', '/');
+        }
+
+        return self::$selfDir;
+    }
+
     /**
      * @return array[]
      * @psalm-return list}>
@@ -336,7 +354,7 @@ private static function getInstalled()
         $copiedLocalDir = false;
 
         if (self::$canGetVendors) {
-            $selfDir = strtr(__DIR__, '\\', '/');
+            $selfDir = self::getSelfDir();
             foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
                 $vendorDir = strtr($vendorDir, '\\', '/');
                 if (isset(self::$installedByVendor[$vendorDir])) {
diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php
index d6b1f0529..fbde08bae 100644
--- a/vendor/composer/autoload_classmap.php
+++ b/vendor/composer/autoload_classmap.php
@@ -6,6 +6,29 @@
 $baseDir = dirname($vendorDir);
 
 return array(
+    'AWS\\CRT\\Auth\\AwsCredentials' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/AwsCredentials.php',
+    'AWS\\CRT\\Auth\\CredentialsProvider' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/CredentialsProvider.php',
+    'AWS\\CRT\\Auth\\Signable' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/Signable.php',
+    'AWS\\CRT\\Auth\\SignatureType' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/SignatureType.php',
+    'AWS\\CRT\\Auth\\SignedBodyHeaderType' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/SignedBodyHeaderType.php',
+    'AWS\\CRT\\Auth\\Signing' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/Signing.php',
+    'AWS\\CRT\\Auth\\SigningAlgorithm' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/SigningAlgorithm.php',
+    'AWS\\CRT\\Auth\\SigningConfigAWS' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/SigningConfigAWS.php',
+    'AWS\\CRT\\Auth\\SigningResult' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/SigningResult.php',
+    'AWS\\CRT\\Auth\\StaticCredentialsProvider' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Auth/StaticCredentialsProvider.php',
+    'AWS\\CRT\\CRT' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/CRT.php',
+    'AWS\\CRT\\HTTP\\Headers' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/HTTP/Headers.php',
+    'AWS\\CRT\\HTTP\\Message' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/HTTP/Message.php',
+    'AWS\\CRT\\HTTP\\Request' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/HTTP/Request.php',
+    'AWS\\CRT\\HTTP\\Response' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/HTTP/Response.php',
+    'AWS\\CRT\\IO\\EventLoopGroup' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/IO/EventLoopGroup.php',
+    'AWS\\CRT\\IO\\InputStream' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/IO/InputStream.php',
+    'AWS\\CRT\\Internal\\Encoding' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Internal/Encoding.php',
+    'AWS\\CRT\\Internal\\Extension' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Internal/Extension.php',
+    'AWS\\CRT\\Log' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Log.php',
+    'AWS\\CRT\\NativeResource' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/NativeResource.php',
+    'AWS\\CRT\\OptionValue' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Options.php',
+    'AWS\\CRT\\Options' => $vendorDir . '/aws/aws-crt-php/src/AWS/CRT/Options.php',
     'Aura\\SqlQuery\\AbstractDmlQuery' => $vendorDir . '/aura/sqlquery/src/AbstractDmlQuery.php',
     'Aura\\SqlQuery\\AbstractQuery' => $vendorDir . '/aura/sqlquery/src/AbstractQuery.php',
     'Aura\\SqlQuery\\Common\\Delete' => $vendorDir . '/aura/sqlquery/src/Common/Delete.php',
@@ -2522,8 +2545,6 @@
     'Xentral\\Components\\Mailer\\Transport\\PhpMailerOAuth' => $baseDir . '/classes/Components/Mailer/Transport/PhpMailerOAuth.php',
     'Xentral\\Components\\Mailer\\Transport\\PhpMailerOAuthAuthentificationInterface' => $baseDir . '/classes/Components/Mailer/Transport/PhpMailerOAuthAuthentificationInterface.php',
     'Xentral\\Components\\Mailer\\Transport\\PhpMailerTransport' => $baseDir . '/classes/Components/Mailer/Transport/PhpMailerTransport.php',
-    'Xentral\\Components\\Mailer\\Wrapper\\LoggerWrapper' => $baseDir . '/classes/Components/Mailer/Wrapper/LoggerWrapper.php',
-    'Xentral\\Components\\Mailer\\Wrapper\\MemoryLogger' => $baseDir . '/classes/Components/Mailer/Wrapper/MemoryLogger.php',
     'Xentral\\Components\\Pdf\\Bootstrap' => $baseDir . '/classes/Components/Pdf/Bootstrap.php',
     'Xentral\\Components\\Pdf\\Exception\\FileExistsException' => $baseDir . '/classes/Components/Pdf/Exception/FileExistsException.php',
     'Xentral\\Components\\Pdf\\Exception\\FileNotFoundException' => $baseDir . '/classes/Components/Pdf/Exception/FileNotFoundException.php',
@@ -2960,6 +2981,7 @@
     'Xentral\\Modules\\Datanorm\\Service\\DatanormReader' => $baseDir . '/classes/Modules/Datanorm/Service/DatanormReader.php',
     'Xentral\\Modules\\Datanorm\\Wrapper\\AddressWrapper' => $baseDir . '/classes/Modules/Datanorm/Wrapper/AddressWrapper.php',
     'Xentral\\Modules\\DatevApi\\DataTable\\DatevExportDataTable' => $baseDir . '/classes/Modules/DatevApi/DataTable/DatevExportDataTable.php',
+    'Xentral\\Modules\\DatevExport\\DatevExport' => $baseDir . '/classes/Modules/DatevExport/DatevExport.php',
     'Xentral\\Modules\\DemoExporter\\Bootstrap' => $baseDir . '/classes/Modules/DemoExporter/Bootstrap.php',
     'Xentral\\Modules\\DemoExporter\\DemoExporterCleanerService' => $baseDir . '/classes/Modules/DemoExporter/DemoExporterCleanerService.php',
     'Xentral\\Modules\\DemoExporter\\DemoExporterDateiService' => $baseDir . '/classes/Modules/DemoExporter/DemoExporterDateiService.php',
@@ -3300,7 +3322,6 @@
     'Xentral\\Modules\\Log\\Service\\DatabaseLogGateway' => $baseDir . '/classes/Modules/Log/Service/DatabaseLogGateway.php',
     'Xentral\\Modules\\Log\\Service\\DatabaseLogService' => $baseDir . '/classes/Modules/Log/Service/DatabaseLogService.php',
     'Xentral\\Modules\\Log\\Service\\LoggerConfigService' => $baseDir . '/classes/Modules/Log/Service/LoggerConfigService.php',
-    'Xentral\\Modules\\Log\\Wrapper\\CompanyConfigWrapper' => $baseDir . '/classes/Modules/Log/Wrapper/CompanyConfigWrapper.php',
     'Xentral\\Modules\\MandatoryFields\\Bootstrap' => $baseDir . '/classes/Modules/MandatoryFields/Bootstrap.php',
     'Xentral\\Modules\\MandatoryFields\\Data\\MandatoryFieldData' => $baseDir . '/classes/Modules/MandatoryFields/Data/MandatoryFieldData.php',
     'Xentral\\Modules\\MandatoryFields\\Data\\ValidatorResultData' => $baseDir . '/classes/Modules/MandatoryFields/Data/ValidatorResultData.php',
@@ -3736,7 +3757,6 @@
     'Xentral\\Modules\\Wizard\\Exception\\NotFoundException' => $baseDir . '/classes/Modules/Wizard/Exception/NotFoundException.php',
     'Xentral\\Modules\\Wizard\\Exception\\WizardExceptionInterface' => $baseDir . '/classes/Modules/Wizard/Exception/WizardExceptionInterface.php',
     'Xentral\\Modules\\Wizard\\WizardService' => $baseDir . '/classes/Modules/Wizard/WizardService.php',
-    'Xentral\\Services\\DatabaseService' => $baseDir . '/classes/Services/DatabaseService.php',
     'Xentral\\Widgets\\Chart\\BarDataset' => $baseDir . '/classes/Widgets/Chart/BarDataset.php',
     'Xentral\\Widgets\\Chart\\Chart' => $baseDir . '/classes/Widgets/Chart/Chart.php',
     'Xentral\\Widgets\\Chart\\Color' => $baseDir . '/classes/Widgets/Chart/Color.php',
diff --git a/vendor/composer/autoload_real.php b/vendor/composer/autoload_real.php
index adb873a96..ac10ca409 100644
--- a/vendor/composer/autoload_real.php
+++ b/vendor/composer/autoload_real.php
@@ -22,8 +22,6 @@ public static function getLoader()
             return self::$loader;
         }
 
-        require __DIR__ . '/platform_check.php';
-
         spl_autoload_register(array('ComposerAutoloaderInit0c49a81c1214ef2f7493c6ce921b17ee', 'loadClassLoader'), true, true);
         self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
         spl_autoload_unregister(array('ComposerAutoloaderInit0c49a81c1214ef2f7493c6ce921b17ee', 'loadClassLoader'));
diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php
index ed6698ce2..c6768b765 100644
--- a/vendor/composer/autoload_static.php
+++ b/vendor/composer/autoload_static.php
@@ -29,23 +29,23 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
     );
 
     public static $prefixLengthsPsr4 = array (
-        'l' => 
+        'l' =>
         array (
             'lfkeitel\\phptotp\\' => 17,
         ),
-        'Y' => 
+        'Y' =>
         array (
             'Y0lk\\OAuth1\\Client\\Server\\' => 26,
         ),
-        'X' => 
+        'X' =>
         array (
             'Xentral\\' => 8,
         ),
-        'W' => 
+        'W' =>
         array (
             'Webmozart\\Assert\\' => 17,
         ),
-        'S' => 
+        'S' =>
         array (
             'Symfony\\Polyfill\\Mbstring\\' => 26,
             'Symfony\\Polyfill\\Intl\\Normalizer\\' => 33,
@@ -63,18 +63,18 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
             'Sabre\\CardDAV\\' => 14,
             'Sabre\\CalDAV\\' => 13,
         ),
-        'R' => 
+        'R' =>
         array (
             'Rakit\\Validation\\' => 17,
         ),
-        'P' => 
+        'P' =>
         array (
             'Psr\\Log\\' => 8,
             'Psr\\Http\\Message\\' => 17,
             'Psr\\Container\\' => 14,
             'PHPMailer\\PHPMailer\\' => 20,
         ),
-        'L' => 
+        'L' =>
         array (
             'League\\OAuth1\\Client\\' => 21,
             'League\\MimeTypeDetection\\' => 25,
@@ -86,28 +86,28 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
             'Laminas\\Mail\\' => 13,
             'Laminas\\Loader\\' => 15,
         ),
-        'J' => 
+        'J' =>
         array (
             'JmesPath\\' => 9,
         ),
-        'G' => 
+        'G' =>
         array (
             'GuzzleHttp\\Psr7\\' => 16,
             'GuzzleHttp\\Promise\\' => 19,
             'GuzzleHttp\\' => 11,
         ),
-        'F' => 
+        'F' =>
         array (
             'FiskalyClient\\' => 14,
             'FastRoute\\' => 10,
         ),
-        'D' => 
+        'D' =>
         array (
             'Datto\\JsonRpc\\Http\\Examples\\' => 28,
             'Datto\\JsonRpc\\Http\\' => 19,
             'Datto\\JsonRpc\\' => 14,
         ),
-        'A' => 
+        'A' =>
         array (
             'Aws\\' => 4,
             'Automattic\\WooCommerce\\' => 23,
@@ -116,175 +116,175 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
     );
 
     public static $prefixDirsPsr4 = array (
-        'lfkeitel\\phptotp\\' => 
+        'lfkeitel\\phptotp\\' =>
         array (
             0 => __DIR__ . '/..' . '/lfkeitel/phptotp/src',
         ),
-        'Y0lk\\OAuth1\\Client\\Server\\' => 
+        'Y0lk\\OAuth1\\Client\\Server\\' =>
         array (
             0 => __DIR__ . '/..' . '/y0lk/oauth1-etsy/src',
         ),
-        'Xentral\\' => 
+        'Xentral\\' =>
         array (
             0 => __DIR__ . '/../..' . '/classes',
         ),
-        'Webmozart\\Assert\\' => 
+        'Webmozart\\Assert\\' =>
         array (
             0 => __DIR__ . '/..' . '/webmozart/assert/src',
         ),
-        'Symfony\\Polyfill\\Mbstring\\' => 
+        'Symfony\\Polyfill\\Mbstring\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring',
         ),
-        'Symfony\\Polyfill\\Intl\\Normalizer\\' => 
+        'Symfony\\Polyfill\\Intl\\Normalizer\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer',
         ),
-        'Symfony\\Polyfill\\Intl\\Idn\\' => 
+        'Symfony\\Polyfill\\Intl\\Idn\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-intl-idn',
         ),
-        'SwissPaymentSlip\\SwissPaymentSlip\\' => 
+        'SwissPaymentSlip\\SwissPaymentSlip\\' =>
         array (
             0 => __DIR__ . '/..' . '/swiss-payment-slip/swiss-payment-slip/src',
         ),
-        'SwissPaymentSlip\\SwissPaymentSlipPdf\\' => 
+        'SwissPaymentSlip\\SwissPaymentSlipPdf\\' =>
         array (
             0 => __DIR__ . '/..' . '/swiss-payment-slip/swiss-payment-slip-pdf/src',
         ),
-        'SwissPaymentSlip\\SwissPaymentSlipFpdf\\' => 
+        'SwissPaymentSlip\\SwissPaymentSlipFpdf\\' =>
         array (
             0 => __DIR__ . '/..' . '/swiss-payment-slip/swiss-payment-slip-fpdf/src',
         ),
-        'Sabre\\Xml\\' => 
+        'Sabre\\Xml\\' =>
         array (
             0 => __DIR__ . '/..' . '/sabre/xml/lib',
         ),
-        'Sabre\\VObject\\' => 
+        'Sabre\\VObject\\' =>
         array (
             0 => __DIR__ . '/..' . '/sabre/vobject/lib',
         ),
-        'Sabre\\Uri\\' => 
+        'Sabre\\Uri\\' =>
         array (
             0 => __DIR__ . '/..' . '/sabre/uri/lib',
         ),
-        'Sabre\\HTTP\\' => 
+        'Sabre\\HTTP\\' =>
         array (
             0 => __DIR__ . '/..' . '/sabre/http/lib',
         ),
-        'Sabre\\Event\\' => 
+        'Sabre\\Event\\' =>
         array (
             0 => __DIR__ . '/..' . '/sabre/event/lib',
         ),
-        'Sabre\\DAV\\' => 
+        'Sabre\\DAV\\' =>
         array (
             0 => __DIR__ . '/..' . '/sabre/dav/lib/DAV',
         ),
-        'Sabre\\DAVACL\\' => 
+        'Sabre\\DAVACL\\' =>
         array (
             0 => __DIR__ . '/..' . '/sabre/dav/lib/DAVACL',
         ),
-        'Sabre\\CardDAV\\' => 
+        'Sabre\\CardDAV\\' =>
         array (
             0 => __DIR__ . '/..' . '/sabre/dav/lib/CardDAV',
         ),
-        'Sabre\\CalDAV\\' => 
+        'Sabre\\CalDAV\\' =>
         array (
             0 => __DIR__ . '/..' . '/sabre/dav/lib/CalDAV',
         ),
-        'Rakit\\Validation\\' => 
+        'Rakit\\Validation\\' =>
         array (
             0 => __DIR__ . '/..' . '/rakit/validation/src',
         ),
-        'Psr\\Log\\' => 
+        'Psr\\Log\\' =>
         array (
             0 => __DIR__ . '/..' . '/psr/log/Psr/Log',
         ),
-        'Psr\\Http\\Message\\' => 
+        'Psr\\Http\\Message\\' =>
         array (
             0 => __DIR__ . '/..' . '/psr/http-message/src',
         ),
-        'Psr\\Container\\' => 
+        'Psr\\Container\\' =>
         array (
             0 => __DIR__ . '/..' . '/psr/container/src',
         ),
-        'PHPMailer\\PHPMailer\\' => 
+        'PHPMailer\\PHPMailer\\' =>
         array (
             0 => __DIR__ . '/..' . '/phpmailer/phpmailer/src',
         ),
-        'League\\OAuth1\\Client\\' => 
+        'League\\OAuth1\\Client\\' =>
         array (
             0 => __DIR__ . '/..' . '/league/oauth1-client/src',
         ),
-        'League\\MimeTypeDetection\\' => 
+        'League\\MimeTypeDetection\\' =>
         array (
             0 => __DIR__ . '/..' . '/league/mime-type-detection/src',
         ),
-        'League\\Flysystem\\' => 
+        'League\\Flysystem\\' =>
         array (
             0 => __DIR__ . '/..' . '/league/flysystem/src',
         ),
-        'Laminas\\Validator\\' => 
+        'Laminas\\Validator\\' =>
         array (
             0 => __DIR__ . '/..' . '/laminas/laminas-validator/src',
         ),
-        'Laminas\\Stdlib\\' => 
+        'Laminas\\Stdlib\\' =>
         array (
             0 => __DIR__ . '/..' . '/laminas/laminas-stdlib/src',
         ),
-        'Laminas\\ServiceManager\\' => 
+        'Laminas\\ServiceManager\\' =>
         array (
             0 => __DIR__ . '/..' . '/laminas/laminas-servicemanager/src',
         ),
-        'Laminas\\Mime\\' => 
+        'Laminas\\Mime\\' =>
         array (
             0 => __DIR__ . '/..' . '/laminas/laminas-mime/src',
         ),
-        'Laminas\\Mail\\' => 
+        'Laminas\\Mail\\' =>
         array (
             0 => __DIR__ . '/..' . '/laminas/laminas-mail/src',
         ),
-        'Laminas\\Loader\\' => 
+        'Laminas\\Loader\\' =>
         array (
             0 => __DIR__ . '/..' . '/laminas/laminas-loader/src',
         ),
-        'JmesPath\\' => 
+        'JmesPath\\' =>
         array (
             0 => __DIR__ . '/..' . '/mtdowling/jmespath.php/src',
         ),
-        'GuzzleHttp\\Psr7\\' => 
+        'GuzzleHttp\\Psr7\\' =>
         array (
             0 => __DIR__ . '/..' . '/guzzlehttp/psr7/src',
         ),
-        'GuzzleHttp\\Promise\\' => 
+        'GuzzleHttp\\Promise\\' =>
         array (
             0 => __DIR__ . '/..' . '/guzzlehttp/promises/src',
         ),
-        'GuzzleHttp\\' => 
+        'GuzzleHttp\\' =>
         array (
             0 => __DIR__ . '/..' . '/guzzlehttp/guzzle/src',
         ),
-        'FiskalyClient\\' => 
+        'FiskalyClient\\' =>
         array (
             0 => __DIR__ . '/..' . '/fiskaly/fiskaly-sdk-php/src',
         ),
-        'FastRoute\\' => 
+        'FastRoute\\' =>
         array (
             0 => __DIR__ . '/..' . '/nikic/fast-route/src',
         ),
-        'Datto\\JsonRpc\\Http\\Examples\\' => 
+        'Datto\\JsonRpc\\Http\\Examples\\' =>
         array (
             0 => __DIR__ . '/..' . '/datto/json-rpc-http/examples/src',
         ),
-        'Datto\\JsonRpc\\Http\\' => 
+        'Datto\\JsonRpc\\Http\\' =>
         array (
             0 => __DIR__ . '/..' . '/datto/json-rpc-http/src',
         ),
-        'Datto\\JsonRpc\\' => 
+        'Datto\\JsonRpc\\' =>
         array (
             0 => __DIR__ . '/..' . '/datto/json-rpc/src',
         ),
-        'Aws\\' => 
+        'Aws\\' =>
         array (
             0 => __DIR__ . '/..' . '/aws/aws-sdk-php/src',
         ),
@@ -303,9 +303,9 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
     );
 
     public static $prefixesPsr0 = array (
-        'H' => 
+        'H' =>
         array (
-            'HTMLPurifier' => 
+            'HTMLPurifier' =>
             array (
                 0 => __DIR__ . '/..' . '/ezyang/htmlpurifier/library',
             ),
@@ -313,6 +313,29 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
     );
 
     public static $classMap = array (
+        'AWS\\CRT\\Auth\\AwsCredentials' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Auth/AwsCredentials.php',
+        'AWS\\CRT\\Auth\\CredentialsProvider' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Auth/CredentialsProvider.php',
+        'AWS\\CRT\\Auth\\Signable' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Auth/Signable.php',
+        'AWS\\CRT\\Auth\\SignatureType' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Auth/SignatureType.php',
+        'AWS\\CRT\\Auth\\SignedBodyHeaderType' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Auth/SignedBodyHeaderType.php',
+        'AWS\\CRT\\Auth\\Signing' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Auth/Signing.php',
+        'AWS\\CRT\\Auth\\SigningAlgorithm' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Auth/SigningAlgorithm.php',
+        'AWS\\CRT\\Auth\\SigningConfigAWS' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Auth/SigningConfigAWS.php',
+        'AWS\\CRT\\Auth\\SigningResult' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Auth/SigningResult.php',
+        'AWS\\CRT\\Auth\\StaticCredentialsProvider' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Auth/StaticCredentialsProvider.php',
+        'AWS\\CRT\\CRT' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/CRT.php',
+        'AWS\\CRT\\HTTP\\Headers' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/HTTP/Headers.php',
+        'AWS\\CRT\\HTTP\\Message' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/HTTP/Message.php',
+        'AWS\\CRT\\HTTP\\Request' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/HTTP/Request.php',
+        'AWS\\CRT\\HTTP\\Response' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/HTTP/Response.php',
+        'AWS\\CRT\\IO\\EventLoopGroup' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/IO/EventLoopGroup.php',
+        'AWS\\CRT\\IO\\InputStream' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/IO/InputStream.php',
+        'AWS\\CRT\\Internal\\Encoding' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Internal/Encoding.php',
+        'AWS\\CRT\\Internal\\Extension' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Internal/Extension.php',
+        'AWS\\CRT\\Log' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Log.php',
+        'AWS\\CRT\\NativeResource' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/NativeResource.php',
+        'AWS\\CRT\\OptionValue' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Options.php',
+        'AWS\\CRT\\Options' => __DIR__ . '/..' . '/aws/aws-crt-php/src/AWS/CRT/Options.php',
         'Aura\\SqlQuery\\AbstractDmlQuery' => __DIR__ . '/..' . '/aura/sqlquery/src/AbstractDmlQuery.php',
         'Aura\\SqlQuery\\AbstractQuery' => __DIR__ . '/..' . '/aura/sqlquery/src/AbstractQuery.php',
         'Aura\\SqlQuery\\Common\\Delete' => __DIR__ . '/..' . '/aura/sqlquery/src/Common/Delete.php',
@@ -2829,8 +2852,6 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
         'Xentral\\Components\\Mailer\\Transport\\PhpMailerOAuth' => __DIR__ . '/../..' . '/classes/Components/Mailer/Transport/PhpMailerOAuth.php',
         'Xentral\\Components\\Mailer\\Transport\\PhpMailerOAuthAuthentificationInterface' => __DIR__ . '/../..' . '/classes/Components/Mailer/Transport/PhpMailerOAuthAuthentificationInterface.php',
         'Xentral\\Components\\Mailer\\Transport\\PhpMailerTransport' => __DIR__ . '/../..' . '/classes/Components/Mailer/Transport/PhpMailerTransport.php',
-        'Xentral\\Components\\Mailer\\Wrapper\\LoggerWrapper' => __DIR__ . '/../..' . '/classes/Components/Mailer/Wrapper/LoggerWrapper.php',
-        'Xentral\\Components\\Mailer\\Wrapper\\MemoryLogger' => __DIR__ . '/../..' . '/classes/Components/Mailer/Wrapper/MemoryLogger.php',
         'Xentral\\Components\\Pdf\\Bootstrap' => __DIR__ . '/../..' . '/classes/Components/Pdf/Bootstrap.php',
         'Xentral\\Components\\Pdf\\Exception\\FileExistsException' => __DIR__ . '/../..' . '/classes/Components/Pdf/Exception/FileExistsException.php',
         'Xentral\\Components\\Pdf\\Exception\\FileNotFoundException' => __DIR__ . '/../..' . '/classes/Components/Pdf/Exception/FileNotFoundException.php',
@@ -3267,6 +3288,7 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
         'Xentral\\Modules\\Datanorm\\Service\\DatanormReader' => __DIR__ . '/../..' . '/classes/Modules/Datanorm/Service/DatanormReader.php',
         'Xentral\\Modules\\Datanorm\\Wrapper\\AddressWrapper' => __DIR__ . '/../..' . '/classes/Modules/Datanorm/Wrapper/AddressWrapper.php',
         'Xentral\\Modules\\DatevApi\\DataTable\\DatevExportDataTable' => __DIR__ . '/../..' . '/classes/Modules/DatevApi/DataTable/DatevExportDataTable.php',
+        'Xentral\\Modules\\DatevExport\\DatevExport' => __DIR__ . '/../..' . '/classes/Modules/DatevExport/DatevExport.php',
         'Xentral\\Modules\\DemoExporter\\Bootstrap' => __DIR__ . '/../..' . '/classes/Modules/DemoExporter/Bootstrap.php',
         'Xentral\\Modules\\DemoExporter\\DemoExporterCleanerService' => __DIR__ . '/../..' . '/classes/Modules/DemoExporter/DemoExporterCleanerService.php',
         'Xentral\\Modules\\DemoExporter\\DemoExporterDateiService' => __DIR__ . '/../..' . '/classes/Modules/DemoExporter/DemoExporterDateiService.php',
@@ -3607,7 +3629,6 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
         'Xentral\\Modules\\Log\\Service\\DatabaseLogGateway' => __DIR__ . '/../..' . '/classes/Modules/Log/Service/DatabaseLogGateway.php',
         'Xentral\\Modules\\Log\\Service\\DatabaseLogService' => __DIR__ . '/../..' . '/classes/Modules/Log/Service/DatabaseLogService.php',
         'Xentral\\Modules\\Log\\Service\\LoggerConfigService' => __DIR__ . '/../..' . '/classes/Modules/Log/Service/LoggerConfigService.php',
-        'Xentral\\Modules\\Log\\Wrapper\\CompanyConfigWrapper' => __DIR__ . '/../..' . '/classes/Modules/Log/Wrapper/CompanyConfigWrapper.php',
         'Xentral\\Modules\\MandatoryFields\\Bootstrap' => __DIR__ . '/../..' . '/classes/Modules/MandatoryFields/Bootstrap.php',
         'Xentral\\Modules\\MandatoryFields\\Data\\MandatoryFieldData' => __DIR__ . '/../..' . '/classes/Modules/MandatoryFields/Data/MandatoryFieldData.php',
         'Xentral\\Modules\\MandatoryFields\\Data\\ValidatorResultData' => __DIR__ . '/../..' . '/classes/Modules/MandatoryFields/Data/ValidatorResultData.php',
@@ -4043,7 +4064,6 @@ class ComposerStaticInit0c49a81c1214ef2f7493c6ce921b17ee
         'Xentral\\Modules\\Wizard\\Exception\\NotFoundException' => __DIR__ . '/../..' . '/classes/Modules/Wizard/Exception/NotFoundException.php',
         'Xentral\\Modules\\Wizard\\Exception\\WizardExceptionInterface' => __DIR__ . '/../..' . '/classes/Modules/Wizard/Exception/WizardExceptionInterface.php',
         'Xentral\\Modules\\Wizard\\WizardService' => __DIR__ . '/../..' . '/classes/Modules/Wizard/WizardService.php',
-        'Xentral\\Services\\DatabaseService' => __DIR__ . '/../..' . '/classes/Services/DatabaseService.php',
         'Xentral\\Widgets\\Chart\\BarDataset' => __DIR__ . '/../..' . '/classes/Widgets/Chart/BarDataset.php',
         'Xentral\\Widgets\\Chart\\Chart' => __DIR__ . '/../..' . '/classes/Widgets/Chart/Chart.php',
         'Xentral\\Widgets\\Chart\\Color' => __DIR__ . '/../..' . '/classes/Widgets/Chart/Color.php',
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index d2aad4395..c0f1f1847 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -495,8 +495,9 @@
                         ],
                         "support": {
                                 "issues": "https://github.com/fiskaly/fiskaly-sdk-php/issues",
-                                "source": "https://github.com/fiskaly/fiskaly-sdk-php/tree/v1.2.100"
+                                "source": "https://github.com/fiskaly/fiskaly-sdk-php/tree/master"
                         },
+                        "abandoned": true,
                         "install-path": "../fiskaly/fiskaly-sdk-php"
                 },
                 {
diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php
index 8e1da156d..6144f471c 100644
--- a/vendor/composer/installed.php
+++ b/vendor/composer/installed.php
@@ -3,7 +3,7 @@
         'name' => '__root__',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => '0b5be036',
+        'reference' => 'fa5dd52f24c581680e2c3dc549c544db5d9c0c81',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -13,7 +13,7 @@
         '__root__' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => '0b5be036',
+            'reference' => 'fa5dd52f24c581680e2c3dc549c544db5d9c0c81',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
diff --git a/vendor/composer/platform_check.php b/vendor/composer/platform_check.php
deleted file mode 100644
index 4c3a5d68f..000000000
--- a/vendor/composer/platform_check.php
+++ /dev/null
@@ -1,26 +0,0 @@
-= 80100)) {
-    $issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running ' . PHP_VERSION . '.';
-}
-
-if ($issues) {
-    if (!headers_sent()) {
-        header('HTTP/1.1 500 Internal Server Error');
-    }
-    if (!ini_get('display_errors')) {
-        if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
-            fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
-        } elseif (!headers_sent()) {
-            echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
-        }
-    }
-    trigger_error(
-        'Composer detected issues in your platform: ' . implode(' ', $issues),
-        E_USER_ERROR
-    );
-}