reverb.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. <?php
  2. class ControllerExtensionModuleReverb extends Controller {
  3. // -------------------------------------------------------------------------
  4. // Webhook receiver
  5. // URL: index.php?route=extension/module/reverb/webhook
  6. // -------------------------------------------------------------------------
  7. public function webhook() {
  8. $raw = file_get_contents('php://input');
  9. // Validate Reverb signature if a secret is configured
  10. $secret = $this->config->get('module_reverb_webhook_secret');
  11. if ($secret) {
  12. $signature = $_SERVER['HTTP_REVERB_SIGNATURE'] ?? '';
  13. if (!hash_equals(hash_hmac('sha256', $raw, $secret), $signature)) {
  14. http_response_code(401);
  15. exit('Unauthorized');
  16. }
  17. }
  18. $payload = json_decode($raw, true);
  19. if (!$payload || empty($payload['event'])) {
  20. http_response_code(400);
  21. exit('Bad Request');
  22. }
  23. $this->load->model('extension/module/reverb');
  24. try {
  25. switch ($payload['event']) {
  26. case 'listing/update':
  27. $this->handleListingUpdate($payload);
  28. break;
  29. case 'order/create':
  30. $this->handleOrderCreate($payload);
  31. break;
  32. default:
  33. // Unknown event — acknowledge silently
  34. break;
  35. }
  36. } catch (Exception $e) {
  37. $this->model_extension_module_reverb->log(0, 'pull', 'error', 'Webhook error: ' . $e->getMessage());
  38. http_response_code(500);
  39. exit('Internal Error');
  40. }
  41. http_response_code(200);
  42. exit('OK');
  43. }
  44. // -------------------------------------------------------------------------
  45. // Cron endpoint — polls Reverb for updates (fallback when webhooks not used)
  46. // URL: index.php?route=extension/module/reverb/cron&cron_token=SECRET
  47. // -------------------------------------------------------------------------
  48. public function cron() {
  49. $cron_token = $this->config->get('module_reverb_cron_token');
  50. if ($cron_token && ($this->request->get['cron_token'] ?? '') !== $cron_token) {
  51. http_response_code(403);
  52. exit('Forbidden');
  53. }
  54. $this->load->model('extension/module/reverb');
  55. $direction = $this->config->get('module_reverb_sync_direction') ?? 'push';
  56. if ($direction === 'both') {
  57. $this->pollListingUpdates();
  58. $this->pollOrders();
  59. }
  60. // Push any products that haven't been synced yet
  61. $this->pushPendingProducts();
  62. exit('Done');
  63. }
  64. // -------------------------------------------------------------------------
  65. // Internal: listing update (Reverb → OpenCart)
  66. // -------------------------------------------------------------------------
  67. private function handleListingUpdate(array $payload) {
  68. $listing = $payload['listing'] ?? [];
  69. if (empty($listing['id'])) {
  70. return;
  71. }
  72. $listing_id = (string)$listing['id'];
  73. $query = $this->db->query("
  74. SELECT product_id FROM `" . DB_PREFIX . "reverb_product_map`
  75. WHERE reverb_listing_id = '" . $this->db->escape($listing_id) . "'
  76. ");
  77. if (!$query->num_rows) {
  78. return;
  79. }
  80. $product_id = (int)$query->row['product_id'];
  81. require_once(DIR_SYSTEM . 'library/reverb/ProductMapper.php');
  82. $updates = ProductMapper::fromReverb($listing);
  83. if (!empty($updates)) {
  84. $this->load->model('catalog/product');
  85. $this->model_catalog_product->editProduct($product_id, $updates);
  86. $this->model_extension_module_reverb->log($product_id, 'pull', 'success', 'Updated from Reverb listing ' . $listing_id);
  87. }
  88. }
  89. // -------------------------------------------------------------------------
  90. // Internal: new order (Reverb → OpenCart)
  91. // -------------------------------------------------------------------------
  92. private function handleOrderCreate(array $payload) {
  93. $reverb_order = $payload['order'] ?? [];
  94. if (empty($reverb_order['order_number'])) {
  95. return;
  96. }
  97. $order_number = $reverb_order['order_number'];
  98. // Check for duplicate via order map table
  99. $check = $this->db->query("
  100. SELECT order_id FROM `" . DB_PREFIX . "reverb_order_map`
  101. WHERE `reverb_order_number` = '" . $this->db->escape($order_number) . "'
  102. LIMIT 1
  103. ");
  104. if ($check->num_rows) {
  105. return;
  106. }
  107. // Skip non-actionable statuses
  108. $status = $reverb_order['status'] ?? '';
  109. if (in_array($status, ['cancelled', 'blocked', 'unpaid', 'payment_pending', 'pending_review'])) {
  110. return;
  111. }
  112. // Find the matching OC product by listing_id or SKU
  113. $listing_id = (string)($reverb_order['listing']['id'] ?? $reverb_order['listing_id'] ?? '');
  114. $oc_product = $this->findProductByListingId($listing_id);
  115. if (!$oc_product) {
  116. $sku = $reverb_order['listing']['sku'] ?? $reverb_order['sku'] ?? '';
  117. $oc_product = $sku ? $this->findProductBySku($sku) : null;
  118. }
  119. if (!$oc_product) {
  120. $oc_product = [
  121. 'product_id' => 0,
  122. 'name' => $reverb_order['listing']['title'] ?? $reverb_order['title'] ?? 'Reverb Product',
  123. 'model' => $reverb_order['listing']['sku'] ?? $reverb_order['sku'] ?? '',
  124. 'price' => $reverb_order['total']['amount'] ?? 0,
  125. ];
  126. }
  127. require_once(DIR_SYSTEM . 'library/reverb/OrderMapper.php');
  128. $store_info = [
  129. 'store_id' => $this->config->get('config_store_id') ?? 0,
  130. 'store_name' => $this->config->get('config_name') ?? '',
  131. 'store_url' => $this->config->get('config_url') ?? '',
  132. 'language_id' => $this->config->get('config_language_id') ?? 1,
  133. 'currency_id' => 1,
  134. 'currency_code' => $this->config->get('config_currency') ?? 'AUD',
  135. 'currency_value' => 1,
  136. ];
  137. $order_data = OrderMapper::toOpenCart($reverb_order, $oc_product, $store_info);
  138. $this->load->model('checkout/order');
  139. $order_id = $this->model_checkout_order->addOrder($order_data);
  140. // Decrease OC stock for the matched product
  141. if (!empty($oc_product['product_id'])) {
  142. $this->db->query("
  143. UPDATE `" . DB_PREFIX . "product`
  144. SET quantity = GREATEST(0, quantity - " . (int)($reverb_order['quantity'] ?? 1) . ")
  145. WHERE product_id = '" . (int)$oc_product['product_id'] . "'
  146. ");
  147. }
  148. // Record in order map to prevent future duplicates
  149. $this->db->query("
  150. INSERT IGNORE INTO `" . DB_PREFIX . "reverb_order_map`
  151. (`reverb_order_number`, `order_id`, `reverb_status`, `created_at`)
  152. VALUES (
  153. '" . $this->db->escape($order_number) . "',
  154. '" . (int)$order_id . "',
  155. '" . $this->db->escape($status) . "',
  156. NOW()
  157. )
  158. ");
  159. $this->model_extension_module_reverb->log(
  160. (int)$oc_product['product_id'],
  161. 'pull',
  162. 'success',
  163. 'Created OC order #' . $order_id . ' from Reverb order #' . $order_number
  164. );
  165. }
  166. // -------------------------------------------------------------------------
  167. // Internal: cron helpers
  168. // -------------------------------------------------------------------------
  169. private function pollListingUpdates() {
  170. try {
  171. require_once(DIR_SYSTEM . 'library/reverb/ReverbApi.php');
  172. $token = $this->config->get('module_reverb_api_token');
  173. if (!$token) return;
  174. $api = new ReverbApi($token);
  175. $response = $api->getListings();
  176. $listings = $response['listings'] ?? [];
  177. foreach ($listings as $listing) {
  178. $this->handleListingUpdate(['listing' => $listing]);
  179. }
  180. } catch (Exception $e) {
  181. $this->model_extension_module_reverb->log(0, 'pull', 'error', 'Poll listings failed: ' . $e->getMessage());
  182. }
  183. }
  184. private function pollOrders() {
  185. try {
  186. require_once(DIR_SYSTEM . 'library/reverb/ReverbApi.php');
  187. $token = $this->config->get('module_reverb_api_token');
  188. if (!$token) return;
  189. $api = new ReverbApi($token);
  190. $last_sync = $this->config->get('module_reverb_order_last_sync') ?: null;
  191. $page = 1;
  192. do {
  193. $response = $api->getOrders($page, 50, $last_sync);
  194. $orders = $response['orders'] ?? [];
  195. $total_pages = (int)($response['total_pages'] ?? 1);
  196. foreach ($orders as $order) {
  197. $this->handleOrderCreate(['order' => $order]);
  198. }
  199. $page++;
  200. } while ($page <= $total_pages);
  201. } catch (Exception $e) {
  202. $this->model_extension_module_reverb->log(0, 'pull', 'error', 'Poll orders failed: ' . $e->getMessage());
  203. }
  204. }
  205. private function pushPendingProducts() {
  206. $this->load->model('setting/setting');
  207. $allowed_categories = $this->config->get('module_reverb_sync_categories') ?? [];
  208. if (empty($allowed_categories)) return;
  209. $settings = [
  210. 'api_token' => $this->config->get('module_reverb_api_token'),
  211. 'shipping_domestic' => $this->config->get('module_reverb_shipping_domestic') ?? '0',
  212. 'shipping_international' => $this->config->get('module_reverb_shipping_international') ?? '0',
  213. 'currency' => $this->config->get('config_currency') ?? 'AUD',
  214. 'store_url' => $this->config->get('config_url') ?? '',
  215. ];
  216. $products = $this->model_extension_module_reverb->getSyncEnabledProducts((array)$allowed_categories);
  217. foreach ($products as $product) {
  218. try {
  219. $this->model_extension_module_reverb->syncProductToReverb($product, $product, $settings);
  220. $this->model_extension_module_reverb->log($product['product_id'], 'push', 'success', 'Cron sync: ' . $product['name']);
  221. } catch (Exception $e) {
  222. $this->model_extension_module_reverb->log($product['product_id'], 'push', 'error', $e->getMessage());
  223. }
  224. }
  225. }
  226. // -------------------------------------------------------------------------
  227. // DB lookups
  228. // -------------------------------------------------------------------------
  229. private function findProductByListingId($listing_id) {
  230. if (!$listing_id) return null;
  231. $query = $this->db->query("
  232. SELECT p.product_id, pd.name, p.model, p.price
  233. FROM `" . DB_PREFIX . "reverb_product_map` r
  234. JOIN `" . DB_PREFIX . "product` p ON p.product_id = r.product_id
  235. JOIN `" . DB_PREFIX . "product_description` pd ON pd.product_id = p.product_id
  236. AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
  237. WHERE r.reverb_listing_id = '" . $this->db->escape($listing_id) . "'
  238. LIMIT 1
  239. ");
  240. return $query->num_rows ? $query->row : null;
  241. }
  242. private function findProductBySku($sku) {
  243. if (!$sku) return null;
  244. $query = $this->db->query("
  245. SELECT p.product_id, pd.name, p.model, p.price
  246. FROM `" . DB_PREFIX . "product` p
  247. JOIN `" . DB_PREFIX . "product_description` pd ON pd.product_id = p.product_id
  248. AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
  249. WHERE p.model = '" . $this->db->escape($sku) . "'
  250. LIMIT 1
  251. ");
  252. return $query->num_rows ? $query->row : null;
  253. }
  254. }