reverb.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  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. $this->load->library('reverb/ProductMapper');
  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 — avoid creating the same order twice
  99. $check = $this->db->query("
  100. SELECT order_id FROM `" . DB_PREFIX . "order`
  101. WHERE comment LIKE '%" . $this->db->escape('Reverb Order #' . $order_number) . "%'
  102. LIMIT 1
  103. ");
  104. if ($check->num_rows) {
  105. return;
  106. }
  107. // Find the matching OC product by SKU or listing_id
  108. $listing_id = (string)($reverb_order['listing']['id'] ?? '');
  109. $oc_product = $this->findProductByListingId($listing_id);
  110. if (!$oc_product) {
  111. $sku = $reverb_order['listing']['sku'] ?? '';
  112. $oc_product = $sku ? $this->findProductBySku($sku) : null;
  113. }
  114. if (!$oc_product) {
  115. // Create a placeholder product entry so the order can still be recorded
  116. $oc_product = [
  117. 'product_id' => 0,
  118. 'name' => $reverb_order['listing']['title'] ?? 'Reverb Product',
  119. 'model' => $reverb_order['listing']['sku'] ?? '',
  120. 'price' => $reverb_order['total']['amount'] ?? 0,
  121. ];
  122. }
  123. $this->load->library('reverb/OrderMapper');
  124. $store_info = [
  125. 'store_id' => $this->config->get('config_store_id') ?? 0,
  126. 'store_name' => $this->config->get('config_name') ?? '',
  127. 'store_url' => $this->config->get('config_url') ?? '',
  128. 'language_id' => $this->config->get('config_language_id') ?? 1,
  129. 'currency_id' => 1,
  130. 'currency_code' => $this->config->get('config_currency') ?? 'AUD',
  131. 'currency_value' => 1,
  132. ];
  133. $order_data = OrderMapper::toOpenCart($reverb_order, $oc_product, $store_info);
  134. $this->load->model('checkout/order');
  135. $order_id = $this->model_checkout_order->addOrder($order_data);
  136. // Decrease OC stock for the matched product
  137. if ($oc_product['product_id']) {
  138. $this->db->query("
  139. UPDATE `" . DB_PREFIX . "product`
  140. SET quantity = GREATEST(0, quantity - " . (int)($reverb_order['quantity'] ?? 1) . ")
  141. WHERE product_id = '" . (int)$oc_product['product_id'] . "'
  142. ");
  143. }
  144. $this->model_extension_module_reverb->log(
  145. $oc_product['product_id'],
  146. 'pull',
  147. 'success',
  148. 'Created OC order #' . $order_id . ' from Reverb order #' . $order_number
  149. );
  150. }
  151. // -------------------------------------------------------------------------
  152. // Internal: cron helpers
  153. // -------------------------------------------------------------------------
  154. private function pollListingUpdates() {
  155. try {
  156. $this->load->library('reverb/ReverbApi');
  157. $token = $this->config->get('module_reverb_api_token');
  158. if (!$token) return;
  159. $api = new ReverbApi($token);
  160. $response = $api->getListings();
  161. $listings = $response['listings'] ?? [];
  162. foreach ($listings as $listing) {
  163. $this->handleListingUpdate(['listing' => $listing]);
  164. }
  165. } catch (Exception $e) {
  166. $this->model_extension_module_reverb->log(0, 'pull', 'error', 'Poll listings failed: ' . $e->getMessage());
  167. }
  168. }
  169. private function pollOrders() {
  170. try {
  171. $this->load->library('reverb/ReverbApi');
  172. $token = $this->config->get('module_reverb_api_token');
  173. if (!$token) return;
  174. $api = new ReverbApi($token);
  175. $response = $api->getOrders();
  176. $orders = $response['orders'] ?? [];
  177. foreach ($orders as $order) {
  178. $this->handleOrderCreate(['order' => $order]);
  179. }
  180. } catch (Exception $e) {
  181. $this->model_extension_module_reverb->log(0, 'pull', 'error', 'Poll orders failed: ' . $e->getMessage());
  182. }
  183. }
  184. private function pushPendingProducts() {
  185. $this->load->model('setting/setting');
  186. $allowed_categories = $this->config->get('module_reverb_sync_categories') ?? [];
  187. if (empty($allowed_categories)) return;
  188. $settings = [
  189. 'api_token' => $this->config->get('module_reverb_api_token'),
  190. 'shipping_domestic' => $this->config->get('module_reverb_shipping_domestic') ?? '0',
  191. 'shipping_international' => $this->config->get('module_reverb_shipping_international') ?? '0',
  192. 'currency' => $this->config->get('config_currency') ?? 'AUD',
  193. 'store_url' => $this->config->get('config_url') ?? '',
  194. ];
  195. $products = $this->model_extension_module_reverb->getSyncEnabledProducts((array)$allowed_categories);
  196. foreach ($products as $product) {
  197. try {
  198. $this->model_extension_module_reverb->syncProductToReverb($product, $product, $settings);
  199. $this->model_extension_module_reverb->log($product['product_id'], 'push', 'success', 'Cron sync: ' . $product['name']);
  200. } catch (Exception $e) {
  201. $this->model_extension_module_reverb->log($product['product_id'], 'push', 'error', $e->getMessage());
  202. }
  203. }
  204. }
  205. // -------------------------------------------------------------------------
  206. // DB lookups
  207. // -------------------------------------------------------------------------
  208. private function findProductByListingId($listing_id) {
  209. if (!$listing_id) return null;
  210. $query = $this->db->query("
  211. SELECT p.product_id, pd.name, p.model, p.price
  212. FROM `" . DB_PREFIX . "reverb_product_map` r
  213. JOIN `" . DB_PREFIX . "product` p ON p.product_id = r.product_id
  214. JOIN `" . DB_PREFIX . "product_description` pd ON pd.product_id = p.product_id
  215. AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
  216. WHERE r.reverb_listing_id = '" . $this->db->escape($listing_id) . "'
  217. LIMIT 1
  218. ");
  219. return $query->num_rows ? $query->row : null;
  220. }
  221. private function findProductBySku($sku) {
  222. if (!$sku) return null;
  223. $query = $this->db->query("
  224. SELECT p.product_id, pd.name, p.model, p.price
  225. FROM `" . DB_PREFIX . "product` p
  226. JOIN `" . DB_PREFIX . "product_description` pd ON pd.product_id = p.product_id
  227. AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
  228. WHERE p.model = '" . $this->db->escape($sku) . "'
  229. LIMIT 1
  230. ");
  231. return $query->num_rows ? $query->row : null;
  232. }
  233. }