PluginProbe
Route ‑ Shipping Protection / 2.4.5
Route ‑ Shipping Protection v2.4.5
2.4.19 2.4.18 2.4.17 2.4.16 2.4.15 2.4.14 2.4.13 2.4.12 2.4.11 2.4.10 2.4.9 2.4.8 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.2.4 2.2.5 2.2.6 2.2.8 2.2.9 2.3.0 2.3.1 All 153 releases
routeapp / includes / tracking-providers / class-routeapp-pirate-ship.php

class-routeapp-pirate-ship.php in Route ‑ Shipping Protection 2.4.5, at includes/tracking-providers/class-routeapp-pirate-ship.php

375 lines 15.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Pirate Ship external shipping provider tracking integration.
5 *
6 * Handles tracking data extraction from WooCommerce order notes written by
7 * Pirate Ship via the WooCommerce Legacy REST API. Unlike plugin-based providers,
8 * activation is driven by merchant config (routeapp_external_tracking_providers)
9 * rather than plugin detection. Pirate Ship is a multi-carrier aggregator
10 * supporting USPS, UPS, FedEx, DHL, and others.
11 *
12 * @package Routeapp\TrackingProviders
13 * @since 2.3.4
14 */
15 class Routeapp_PirateShip extends Routeapp_WooCommerce_Common_Tracking_Provider implements Routeapp_WooCommerce_Tracking_Provider
16 {
17 /**
18 * Slug identifying this external tracking plugin.
19 *
20 * Mirrors the value stored in the `routeapp_external_tracking_providers`
21 * option and is written to the `_routeapp_external_tracking_plugin` meta
22 * so downstream consumers can tell which external provider produced the
23 * tracking data on an order.
24 */
25 const PLUGIN_SLUG = 'pirate_ship';
26
27 /**
28 * Meta key marking the external tracking plugin used on an order.
29 *
30 * Underscore-prefixed so WordPress treats it as protected meta and hides
31 * it from the admin Custom Fields panel — merchants shouldn't see or edit
32 * this marker directly.
33 */
34 const META_EXTERNAL_TRACKING_PLUGIN = '_routeapp_external_tracking_plugin';
35
36 /**
37 * Meta key storing the carrier for the most recent external tracking note.
38 *
39 * Single value matching the single tracking number stored in
40 * `routeapp_shipment_tracking_number`. Underscore-prefixed for the same
41 * protected-meta reason as the plugin marker above.
42 */
43 const META_EXTERNAL_TRACKING_COURIER = '_routeapp_external_tracking_courier';
44
45 /**
46 * Check if Pirate Ship integration is enabled via merchant config.
47 *
48 * Uses the routeapp_external_tracking_providers option instead of
49 * checking active_plugins, since Pirate Ship has no WooCommerce plugin.
50 *
51 * @return bool True if 'pirate_ship' is selected in external tracking providers setting.
52 */
53 public function is_active()
54 {
55 return in_array(self::PLUGIN_SLUG, (array) get_option('routeapp_external_tracking_providers', array()));
56 }
57
58 /**
59 * Get the global Routeapp_Public instance.
60 *
61 * @return Routeapp_Public|null The public plugin instance or null.
62 */
63 public static function get_route_public_instance(){
64 global $routeapp_public;
65 return $routeapp_public;
66 }
67
68 /**
69 * Get shipping provider names from order notes.
70 *
71 * Queries all order note types (no type filter) since Pirate Ship
72 * writes notes via the REST API as customer or internal notes,
73 * not system notes.
74 *
75 * @param int $order_id The WooCommerce order ID.
76 * @return array Associative array of [tracking_number => courier_id].
77 */
78 public function get_shipping_provider_name($order_id)
79 {
80 $couriers_array = [];
81 $order_notes = wc_get_order_notes([
82 'order_id' => $order_id,
83 'orderby' => 'date_created',
84 'order' => 'ASC',
85 ]);
86 if (count($order_notes)>0) {
87 foreach ($order_notes as $order_note) {
88 $track_data = $this->parse_individual_order_note($order_note->content);
89 if (isset($track_data['tracking_number'], $track_data['courier_id'])) {
90 $couriers_array[$track_data['tracking_number']] = $track_data['courier_id'];
91 }
92 }
93 }
94 return $couriers_array;
95 }
96
97 /**
98 * Get shipping info from order notes for Route API shipment sync.
99 *
100 * Returns an array of shipping records with order ID, product IDs,
101 * courier, and tracking number for each parsed Pirate Ship note.
102 *
103 * @param int $order_id The WooCommerce order ID.
104 * @return array Array of shipping info arrays with source_order_id,
105 * source_product_ids, courier_id, and tracking_number.
106 */
107 public function get_shipping_info($order_id)
108 {
109 $shipping_info = [];
110 $order_notes = wc_get_order_notes([
111 'order_id' => $order_id,
112 'orderby' => 'date_created',
113 'order' => 'ASC',
114 ]);
115 if (count($order_notes)>0) {
116 $product_ids = $this->get_order_products($order_id);
117 foreach ($order_notes as $order_note) {
118 $track_data = $this->parse_individual_order_note($order_note->content);
119 if (isset($track_data['tracking_number'], $track_data['courier_id'])) {
120 $shipping_info[] = array(
121 'source_order_id' => $order_id,
122 'source_product_ids' => $product_ids,
123 'courier_id' => $track_data['courier_id'],
124 'tracking_number' => $track_data['tracking_number']
125 );
126 }
127 }
128 }
129 return $shipping_info;
130 }
131
132 /**
133 * No-op update method.
134 *
135 * Pirate Ship has no plugin to trigger updates; tracking data
136 * arrives exclusively via order notes parsed by parse_order_notes().
137 *
138 * @param int $order_id The WooCommerce order ID.
139 * @param Routeapp_Public $api_client The Route API client instance.
140 * @return void
141 */
142 public function update($order_id, $api_client)
143 {
144 return;
145 }
146
147 /**
148 * Parse order notes for Pirate Ship tracking data and sync with Route API.
149 *
150 * Reads all order notes (no type filter), extracts tracking numbers and
151 * carrier IDs, creates new shipments via the Route API for newly found
152 * tracking numbers, and cancels shipments for tracking numbers that have
153 * been removed from the order notes.
154 *
155 * @param int $order_id The WooCommerce order ID.
156 * @param object $route_app The Route app instance with routeapp_api_client.
157 * @return bool False on API error or no data, true on success.
158 */
159 public function parse_order_notes($order_id, $route_app)
160 {
161 // Fetch notes in chronological order (oldest first) so that
162 // `end($tracking_pairs)` in persist_tracking_numbers picks the
163 // most recent tracking note, and dedup's "last wins" semantics
164 // resolve to the newest carrier for a repeated tracking number.
165 $order_notes = wc_get_order_notes([
166 'order_id' => $order_id,
167 'orderby' => 'date_created',
168 'order' => 'ASC',
169 ]);
170 $existing_tracking_number = self::get_meta_data($order_id, 'routeapp_shipment_tracking_number');
171 $existing_tracking_numbers = !empty($existing_tracking_number) ? [$existing_tracking_number] : [];
172
173 if (empty($order_notes) && empty($existing_tracking_numbers)) {
174 return false;
175 }
176
177 $product_ids = $this->get_order_products($order_id);
178 $current_tracking_numbers = $this->extract_tracking_from_notes($order_notes);
179
180 if (count($order_notes) > 0) {
181 $current_tracking_numbers = $this->deduplicate_tracking($current_tracking_numbers);
182 $current_numbers = array_column($current_tracking_numbers, 'tracking_number');
183
184 foreach ($current_tracking_numbers as $track_data) {
185 if (in_array($track_data['tracking_number'], $existing_tracking_numbers, true)) {
186 continue;
187 }
188 if (!$this->create_shipment($track_data, $order_id, $product_ids, $route_app)) {
189 return false;
190 }
191 }
192
193 if (!empty($current_numbers)) {
194 $old_tracking_removed = !empty($existing_tracking_number) && !in_array($existing_tracking_number, $current_numbers, true);
195 $last_tracking = end($current_tracking_numbers);
196 $has_new_latest = $last_tracking['tracking_number'] !== $existing_tracking_number;
197
198 if ($old_tracking_removed) {
199 $this->cancel_removed_tracking($existing_tracking_numbers, $current_numbers, $order_id, $product_ids, $route_app);
200 }
201 if ($has_new_latest) {
202 $this->persist_tracking_numbers($current_tracking_numbers, $order_id);
203 }
204 }
205
206 return true;
207 }
208
209 return false;
210 }
211
212 /**
213 * Extract tracking data from order notes.
214 *
215 * @param array $order_notes Array of WooCommerce order note objects.
216 * @return array Array of parsed tracking data arrays with 'tracking_number' and 'courier_id'.
217 */
218 private function extract_tracking_from_notes($order_notes)
219 {
220 $tracking = [];
221 foreach ($order_notes as $order_note) {
222 $track_data = $this->parse_individual_order_note($order_note->content);
223 if (isset($track_data['tracking_number'], $track_data['courier_id'])) {
224 $tracking[] = $track_data;
225 }
226 }
227 return $tracking;
228 }
229
230 /**
231 * Deduplicate tracking entries by tracking_number, keeping the last occurrence.
232 *
233 * Pirate Ship may produce multiple order notes referencing the same
234 * tracking number (e.g., shipment purchased then re-synced, possibly
235 * under a different carrier). The stored tracking-number meta must
236 * contain each number only once, so the most recent note wins — its
237 * courier_id is what gets persisted for that tracking number.
238 *
239 * @param array $tracking_data Array of tracking data arrays with 'courier_id' and 'tracking_number'.
240 * @return array Deduplicated array (most recent entry per tracking number), indexed numerically from 0.
241 */
242 private function deduplicate_tracking($tracking_data)
243 {
244 $by_number = [];
245 foreach ($tracking_data as $entry) {
246 $by_number[$entry['tracking_number']] = $entry;
247 }
248 return array_values($by_number);
249 }
250
251 /**
252 * Create a shipment on the Route API if it doesn't already exist.
253 *
254 * @param array $track_data Parsed tracking data with 'tracking_number' and 'courier_id'.
255 * @param int $order_id The WooCommerce order ID.
256 * @param array $product_ids Array of product IDs in the order.
257 * @param object $route_app The Route app instance.
258 * @return bool True on success or skip, false on error.
259 */
260 private function create_shipment($track_data, $order_id, $product_ids, $route_app)
261 {
262 $tracking_number = $track_data['tracking_number'];
263
264 $shipmentResponse = $route_app->routeapp_api_client->get_shipment($tracking_number, $order_id);
265 if (is_wp_error($shipmentResponse) || (isset($shipmentResponse['response']['code']) && $shipmentResponse['response']['code'] == 200)) {
266 return true;
267 }
268
269 $params = array(
270 'source_order_id' => $order_id,
271 'source_product_ids' => $product_ids,
272 'courier_id' => $track_data['courier_id']
273 );
274 $response = $route_app->routeapp_api_client->create_shipment($tracking_number, $params);
275
276 if (is_wp_error($response)) {
277 $routeapp_public = self::get_route_public_instance();
278 if ($routeapp_public) {
279 $params['tracking_id'] = $tracking_number;
280 $routeapp_public->routeapp_log(
281 new Exception($response->get_error_message()),
282 array('params' => $params, 'method' => 'POST', 'endpoint' => 'shipments')
283 );
284 }
285 return false;
286 }
287 return true;
288 }
289
290 /**
291 * Cancel tracking numbers that were removed from order notes.
292 *
293 * @param array $existing_tracking Previously stored tracking numbers.
294 * @param array $current_tracking Currently found tracking numbers.
295 * @param int $order_id The WooCommerce order ID.
296 * @param array $product_ids Array of product IDs in the order.
297 * @param object $route_app The Route app instance.
298 * @return void
299 */
300 private function cancel_removed_tracking($existing_tracking, $current_tracking, $order_id, $product_ids, $route_app)
301 {
302 if (empty($existing_tracking) || $existing_tracking[0] == '') {
303 return;
304 }
305
306 foreach ($existing_tracking as $tracking_number) {
307 if (!in_array($tracking_number, $current_tracking, true)) {
308 $route_app->routeapp_cancel_tracking_order($order_id, $tracking_number, $product_ids);
309 }
310 }
311 }
312
313 /**
314 * Persist the most recent tracking pair to order meta.
315 *
316 *
317 * @param array $tracking_pairs Array of tracking data arrays with 'courier_id' and 'tracking_number'.
318 * @param int $order_id The WooCommerce order ID.
319 * @return void
320 */
321 private function persist_tracking_numbers($tracking_pairs, $order_id)
322 {
323 if (empty($tracking_pairs)) {
324 return;
325 }
326 $last = end($tracking_pairs);
327 self::update_post_meta_value($order_id, 'routeapp_shipment_api_called', 'success');
328 self::update_post_meta_value($order_id, 'routeapp_shipment_tracking_number', $last['tracking_number']);
329 self::update_post_meta_value($order_id, self::META_EXTERNAL_TRACKING_PLUGIN, self::PLUGIN_SLUG);
330 self::update_post_meta_value($order_id, self::META_EXTERNAL_TRACKING_COURIER, $last['courier_id']);
331 }
332
333 /**
334 * Parse an individual order note for Pirate Ship tracking data.
335 *
336 * Matches the confirmed Pirate Ship note format:
337 * "Order shipped via {CARRIER} with tracking number {NUMBER}"
338 *
339 * Carrier can be multi-word (e.g., "USPS Priority Mail", "UPS Ground").
340 * Pirate Ship is a multi-carrier aggregator so the courier_id reflects
341 * the actual carrier (USPS, UPS, FedEx, DHL, etc.), not "Pirate Ship".
342 *
343 * @param string $note The order note content text.
344 * @return array|false Associative array with 'courier_id' and 'tracking_number', or false if not a tracking note.
345 */
346 private function parse_individual_order_note($note) {
347 // Strip HTML first — Pirate Ship wraps tracking numbers in anchor tags
348 // linking to the carrier's tracking page (e.g., <a href="...">123456</a>).
349 $clean_note = strip_tags($note);
350 if (preg_match('/shipped\s+via\s+(.+?)\s+with\s+tracking\s+number\s+(\S+)/i', $clean_note, $matches)) {
351 return [
352 'courier_id' => strtoupper(trim($matches[1])),
353 'tracking_number' => $this->sanitize_value($matches[2]),
354 ];
355 }
356 return false;
357 }
358
359 /**
360 * Sanitize a tracking number value.
361 *
362 * Strips HTML tags (Pirate Ship wraps tracking numbers in anchor tags
363 * linking to the carrier's tracking page), removes periods, and keeps
364 * only alphanumeric characters.
365 *
366 * @param string $value The raw tracking number string, possibly with HTML.
367 * @return string The sanitized alphanumeric tracking number.
368 */
369 private function sanitize_value($value) {
370 $value = strip_tags($value);
371 $value = preg_replace('/[^A-Za-z0-9]/', '', $value);
372 return $value;
373 }
374 }
375