PluginProbe
Route ‑ Shipping Protection / 2.4.10
Route ‑ Shipping Protection v2.4.10
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.10, at includes/tracking-providers/class-routeapp-pirate-ship.php

429 lines 17.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 * Extracts tracking numbers from Pirate Ship notes, creates shipments in Route API
151 * for new tracking, and cancels removed tracking (only if it belongs to Pirate Ship).
152 *
153 * @param int $order_id The WooCommerce order ID.
154 * @param object $route_app The Route app instance with routeapp_api_client.
155 * @return bool False on API error or no data, true on success.
156 */
157 public function parse_order_notes($order_id, $route_app)
158 {
159 // Step 1: Get order notes (oldest first for proper deduplication)
160 $order_notes = wc_get_order_notes([
161 'order_id' => $order_id,
162 'orderby' => 'date_created',
163 'order' => 'ASC',
164 ]);
165
166 // Nothing to process if no notes exist
167 if (empty($order_notes)) {
168 return false;
169 }
170
171 // Step 2: Extract tracking from Pirate Ship notes
172 $pirateship_tracking = $this->extract_tracking_from_notes($order_notes);
173
174 // No Pirate Ship tracking numbers found in notes - nothing to process
175 if (empty($pirateship_tracking)) {
176 return false;
177 }
178
179 // Step 3: Deduplicate (newest note wins for same tracking number)
180 $pirateship_tracking = $this->deduplicate_tracking($pirateship_tracking);
181
182 $product_ids = $this->get_order_products($order_id);
183
184 // Step 4: Create shipments for NEW tracking numbers
185 foreach ($pirateship_tracking as $track_data) {
186 // create_shipment() checks Route API and only creates if needed.
187 if (!$this->create_shipment($track_data, $order_id, $product_ids, $route_app)) {
188 return false;
189 }
190 }
191
192 // Step 5: Handle cancellations and updates
193 // Get what's currently stored (handle HTML links from other providers)
194 $stored_raw = self::get_meta_data($order_id, 'routeapp_shipment_tracking_number');
195 $stored_tracking_numbers = $this->extract_plain_tracking_numbers($stored_raw);
196
197 $pirateship_numbers = array_column($pirateship_tracking, 'tracking_number');
198 // Find tracking that was removed from notes
199 $removed_numbers = array_diff($stored_tracking_numbers, $pirateship_numbers);
200
201 if (!empty($removed_numbers)) {
202 // Check ownership - only cancel if WE created this tracking
203 $tracking_owner = self::get_meta_data($order_id, self::META_EXTERNAL_TRACKING_PLUGIN);
204
205 if ($tracking_owner === self::PLUGIN_SLUG) {
206 // Safe to cancel - this is OUR tracking that was removed
207 $this->cancel_removed_tracking($stored_tracking_numbers, $pirateship_numbers, $order_id, $product_ids, $route_app);
208 }
209 // else: tracking belongs to another provider (UPS, ShipWorks, etc.) - don't touch it
210 }
211
212 // Step 6: Update stored tracking if we have new data
213 if (!empty($pirateship_numbers)) {
214 $last_pirateship = end($pirateship_tracking);
215 $last_stored = !empty($stored_tracking_numbers) ? end($stored_tracking_numbers) : '';
216
217 // Detect if stored value contains HTML (strip_tags will return different value if HTML exists)
218 $contains_html = !empty($stored_raw) && $stored_raw !== strip_tags($stored_raw);
219
220 // Persist if: tracking changed OR stored value has HTML that needs normalization
221 if ($last_pirateship['tracking_number'] !== $last_stored || $contains_html) {
222 // Persist new tracking + set ownership marker (normalizes HTML to plain text)
223 $this->persist_tracking_numbers($pirateship_tracking, $order_id);
224 }
225 }
226
227 return true;
228 }
229
230 /**
231 * Extract tracking data from order notes.
232 *
233 * @param array $order_notes Array of WooCommerce order note objects.
234 * @return array Array of parsed tracking data arrays with 'tracking_number' and 'courier_id'.
235 */
236 private function extract_tracking_from_notes($order_notes)
237 {
238 $tracking = [];
239 foreach ($order_notes as $order_note) {
240 $track_data = $this->parse_individual_order_note($order_note->content);
241 if (isset($track_data['tracking_number'], $track_data['courier_id'])) {
242 $tracking[] = $track_data;
243 }
244 }
245 return $tracking;
246 }
247
248 /**
249 * Deduplicate tracking entries by tracking_number, keeping the last occurrence.
250 *
251 * Pirate Ship may produce multiple order notes referencing the same
252 * tracking number (e.g., shipment purchased then re-synced, possibly
253 * under a different carrier). The stored tracking-number meta must
254 * contain each number only once, so the most recent note wins — its
255 * courier_id is what gets persisted for that tracking number.
256 *
257 * @param array $tracking_data Array of tracking data arrays with 'courier_id' and 'tracking_number'.
258 * @return array Deduplicated array (most recent entry per tracking number), indexed numerically from 0.
259 */
260 private function deduplicate_tracking($tracking_data)
261 {
262 $by_number = [];
263 foreach ($tracking_data as $entry) {
264 $by_number[$entry['tracking_number']] = $entry;
265 }
266 return array_values($by_number);
267 }
268
269 /**
270 * Create a shipment on the Route API if it doesn't already exist.
271 *
272 * @param array $track_data Parsed tracking data with 'tracking_number' and 'courier_id'.
273 * @param int $order_id The WooCommerce order ID.
274 * @param array $product_ids Array of product IDs in the order.
275 * @param object $route_app The Route app instance.
276 * @return bool True on success or skip, false on error.
277 */
278 private function create_shipment($track_data, $order_id, $product_ids, $route_app)
279 {
280 $tracking_number = $track_data['tracking_number'];
281
282 $shipmentResponse = $route_app->routeapp_api_client->get_shipment($tracking_number, $order_id);
283 if (is_wp_error($shipmentResponse) || (isset($shipmentResponse['response']['code']) && $shipmentResponse['response']['code'] == 200)) {
284 return true;
285 }
286
287 $params = array(
288 'source_order_id' => $order_id,
289 'source_product_ids' => $product_ids,
290 'courier_id' => $track_data['courier_id']
291 );
292 $response = $route_app->routeapp_api_client->create_shipment($tracking_number, $params);
293
294 if (is_wp_error($response)) {
295 $routeapp_public = self::get_route_public_instance();
296 if ($routeapp_public) {
297 $params['tracking_id'] = $tracking_number;
298 $routeapp_public->routeapp_log(
299 new Exception($response->get_error_message()),
300 array('params' => $params, 'method' => 'POST', 'endpoint' => 'shipments')
301 );
302 }
303 return false;
304 }
305 return true;
306 }
307
308 /**
309 * Cancel tracking numbers that were removed from order notes.
310 *
311 * @param array $existing_tracking Previously stored tracking numbers.
312 * @param array $current_tracking Currently found tracking numbers.
313 * @param int $order_id The WooCommerce order ID.
314 * @param array $product_ids Array of product IDs in the order.
315 * @param object $route_app The Route app instance.
316 * @return void
317 */
318 private function cancel_removed_tracking($existing_tracking, $current_tracking, $order_id, $product_ids, $route_app)
319 {
320 if (empty($existing_tracking) || $existing_tracking[0] == '') {
321 return;
322 }
323
324 foreach ($existing_tracking as $tracking_number) {
325 if (!in_array($tracking_number, $current_tracking, true)) {
326 $route_app->routeapp_cancel_tracking_order($order_id, $tracking_number, $product_ids);
327 }
328 }
329 }
330
331 /**
332 * Persist the most recent tracking pair to order meta.
333 *
334 *
335 * @param array $tracking_pairs Array of tracking data arrays with 'courier_id' and 'tracking_number'.
336 * @param int $order_id The WooCommerce order ID.
337 * @return void
338 */
339 private function persist_tracking_numbers($tracking_pairs, $order_id)
340 {
341 if (empty($tracking_pairs)) {
342 return;
343 }
344 $last = end($tracking_pairs);
345 self::update_post_meta_value($order_id, 'routeapp_shipment_api_called', 'success');
346 self::update_post_meta_value($order_id, 'routeapp_shipment_tracking_number', $last['tracking_number']);
347 self::update_post_meta_value($order_id, self::META_EXTERNAL_TRACKING_PLUGIN, self::PLUGIN_SLUG);
348 self::update_post_meta_value($order_id, self::META_EXTERNAL_TRACKING_COURIER, $last['courier_id']);
349 }
350
351 /**
352 * Parse an individual order note for Pirate Ship tracking data.
353 *
354 * Matches the confirmed Pirate Ship note format:
355 * "Order shipped via {CARRIER} with tracking number {NUMBER}"
356 *
357 * Carrier can be multi-word (e.g., "USPS Priority Mail", "UPS Ground").
358 * Pirate Ship is a multi-carrier aggregator so the courier_id reflects
359 * the actual carrier (USPS, UPS, FedEx, DHL, etc.), not "Pirate Ship".
360 *
361 * @param string $note The order note content text.
362 * @return array|false Associative array with 'courier_id' and 'tracking_number', or false if not a tracking note.
363 */
364 private function parse_individual_order_note($note) {
365 // Strip HTML first — Pirate Ship wraps tracking numbers in anchor tags
366 // linking to the carrier's tracking page (e.g., <a href="...">123456</a>).
367 $clean_note = strip_tags($note);
368 if (preg_match('/shipped\s+via\s+(.+?)\s+with\s+tracking\s+number\s+(\S+)/i', $clean_note, $matches)) {
369 return [
370 'courier_id' => strtoupper(trim($matches[1])),
371 'tracking_number' => $this->sanitize_value($matches[2]),
372 ];
373 }
374 return false;
375 }
376
377 /**
378 * Sanitize a tracking number value.
379 *
380 * Strips HTML tags (Pirate Ship wraps tracking numbers in anchor tags
381 * linking to the carrier's tracking page), removes periods, and keeps
382 * only alphanumeric characters.
383 *
384 * @param string $value The raw tracking number string, possibly with HTML.
385 * @return string The sanitized alphanumeric tracking number.
386 */
387 private function sanitize_value($value) {
388 $value = strip_tags($value);
389 $value = preg_replace('/[^A-Za-z0-9]/', '', $value);
390 return $value;
391 }
392
393 /**
394 * Extract plain tracking numbers from stored meta value.
395 *
396 * Handles HTML anchor tags (from other providers like UPS),
397 * pipe-separated values, and plain text. Reuses existing
398 * sanitize_value() to normalize all tracking numbers.
399 *
400 * @param string $stored_value Raw value from routeapp_shipment_tracking_number meta.
401 * @return array List of plain tracking numbers (sanitized, deduplicated).
402 */
403 private function extract_plain_tracking_numbers($stored_value) {
404 if (empty($stored_value)) {
405 return [];
406 }
407
408 // Multiple tracking numbers are separated by |||
409 $parts = explode(self::SEPARATOR_PIPE, $stored_value);
410 $plain_numbers = [];
411
412 foreach ($parts as $part) {
413 $part = trim($part);
414 if (empty($part)) {
415 continue;
416 }
417
418 // Reuse existing sanitize_value() - strips HTML and normalizes
419 $sanitized = $this->sanitize_value($part);
420 if (!empty($sanitized)) {
421 $plain_numbers[] = $sanitized;
422 }
423 }
424
425 // Remove duplicates
426 return array_values(array_unique($plain_numbers));
427 }
428 }
429