PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / API / V1 / Print_Jobs_Controller.php

Print_Jobs_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.18, at includes/API/V1/Print_Jobs_Controller.php

1,269 lines 39.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Print Jobs REST controller.
4 *
5 * @package WCPOS\WooCommercePOS\API\V1
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V1;
9
10 use WCPOS\WooCommercePOS\Logger;
11 use WCPOS\WooCommercePOS\Interfaces\Push_Provider_Adapter_Interface;
12 use WCPOS\WooCommercePOS\Services\Print_Job_Lifecycle;
13 use WCPOS\WooCommercePOS\Services\Cloud_Print_Relay_Service;
14 use WCPOS\WooCommercePOS\Services\Cloud_Print_Registry;
15 use WCPOS\WooCommercePOS\Services\Cloud_Print_Trigger_Service;
16 use WCPOS\WooCommercePOS\Services\PrintNode_Client;
17 use WCPOS\WooCommercePOS\Services\Print_Job_Service;
18 use WCPOS\WooCommercePOS\Services\Provider;
19 use WCPOS\WooCommercePOS\Services\Star_Online_Client;
20 use WP_Error;
21 use WP_REST_Controller;
22 use WP_REST_Request;
23 use WP_REST_Response;
24 use WP_REST_Server;
25
26 use const WCPOS\WooCommercePOS\SHORT_NAME;
27
28 /**
29 * Print_Jobs_Controller class.
30 */
31 class Print_Jobs_Controller extends WP_REST_Controller {
32 /**
33 * Legacy Epson timeout.
34 *
35 * @deprecated Use Epson_Sdp_Adapter::EPSON_SDP_PRINT_TIMEOUT_MS.
36 */
37 const EPSON_SDP_PRINT_TIMEOUT_MS = \WCPOS\WooCommercePOS\Services\Providers\Epson_Sdp_Adapter::EPSON_SDP_PRINT_TIMEOUT_MS;
38
39 /**
40 * Endpoint namespace.
41 *
42 * @var string
43 */
44 protected $namespace = SHORT_NAME . '/v1';
45
46 /**
47 * Route base.
48 *
49 * @var string
50 */
51 protected $rest_base = 'print-jobs';
52
53 /**
54 * Job store.
55 *
56 * @var Print_Job_Service
57 */
58 protected $jobs;
59
60 /**
61 * Cloud printer registry.
62 *
63 * @var Cloud_Print_Registry
64 */
65 protected $registry;
66
67 /**
68 * Constructor.
69 */
70 public function __construct() {
71 $this->jobs = new Print_Job_Service();
72 $this->registry = new Cloud_Print_Registry();
73 }
74
75 /**
76 * Declare routes with special permission-gate handling.
77 *
78 * @return array<string, string[]> Route classifications.
79 */
80 public function wcpos_route_classifications(): array {
81 return array(
82 'public' => array(
83 "/{$this->namespace}/{$this->rest_base}/relay-verification",
84 ),
85 'printer_token' => array(
86 "/{$this->namespace}/{$this->rest_base}/cloudprnt",
87 "/{$this->namespace}/{$this->rest_base}/epson-sdp",
88 ),
89 );
90 }
91
92 /**
93 * Register routes.
94 */
95 public function register_routes(): void {
96 register_rest_route(
97 $this->namespace,
98 '/' . $this->rest_base,
99 array(
100 array(
101 'methods' => WP_REST_Server::READABLE,
102 'callback' => array( $this, 'get_items' ),
103 'permission_callback' => array( $this, 'manage_permissions_check' ),
104 ),
105 array(
106 'methods' => WP_REST_Server::CREATABLE,
107 'callback' => array( $this, 'create_item' ),
108 'permission_callback' => array( $this, 'manage_permissions_check' ),
109 ),
110 )
111 );
112
113 register_rest_route(
114 $this->namespace,
115 '/' . $this->rest_base . '/(?P<id>[\d]+)',
116 array(
117 array(
118 'methods' => WP_REST_Server::READABLE,
119 'callback' => array( $this, 'get_item' ),
120 'permission_callback' => array( $this, 'manage_permissions_check' ),
121 ),
122 array(
123 'methods' => WP_REST_Server::DELETABLE,
124 'callback' => array( $this, 'delete_item' ),
125 'permission_callback' => array( $this, 'manage_permissions_check' ),
126 ),
127 )
128 );
129
130 register_rest_route(
131 $this->namespace,
132 '/' . $this->rest_base . '/(?P<id>[\d]+)/reprint',
133 array(
134 array(
135 'methods' => WP_REST_Server::CREATABLE,
136 'callback' => array( $this, 'reprint_item' ),
137 'permission_callback' => array( $this, 'manage_permissions_check' ),
138 ),
139 )
140 );
141
142 register_rest_route(
143 $this->namespace,
144 '/' . $this->rest_base . '/queue',
145 array(
146 array(
147 'methods' => WP_REST_Server::READABLE,
148 'callback' => array( $this, 'get_queue' ),
149 'permission_callback' => array( $this, 'manage_permissions_check' ),
150 ),
151 )
152 );
153
154 register_rest_route(
155 $this->namespace,
156 '/' . $this->rest_base . '/queue/cancel',
157 array(
158 array(
159 'methods' => WP_REST_Server::CREATABLE,
160 'callback' => array( $this, 'cancel_queue' ),
161 'permission_callback' => array( $this, 'manage_permissions_check' ),
162 ),
163 )
164 );
165
166 register_rest_route(
167 $this->namespace,
168 '/' . $this->rest_base . '/queue/delete',
169 array(
170 array(
171 'methods' => WP_REST_Server::CREATABLE,
172 'callback' => array( $this, 'delete_queue' ),
173 'permission_callback' => array( $this, 'manage_permissions_check' ),
174 ),
175 )
176 );
177
178 register_rest_route(
179 $this->namespace,
180 '/' . $this->rest_base . '/test',
181 array(
182 'methods' => WP_REST_Server::CREATABLE,
183 'callback' => array( $this, 'test_print' ),
184 'permission_callback' => array( $this, 'manage_permissions_check' ),
185 )
186 );
187
188 register_rest_route(
189 $this->namespace,
190 '/' . $this->rest_base . '/relay-verification',
191 array(
192 'methods' => WP_REST_Server::READABLE,
193 'callback' => array( $this, 'relay_verification' ),
194 'permission_callback' => '__return_true',
195 )
196 );
197
198 register_rest_route(
199 $this->namespace,
200 '/' . $this->rest_base . '/relay/register',
201 array(
202 'methods' => WP_REST_Server::CREATABLE,
203 'callback' => array( $this, 'relay_register' ),
204 'permission_callback' => array( $this, 'relay_manage_permissions_check' ),
205 )
206 );
207
208 register_rest_route(
209 $this->namespace,
210 '/' . $this->rest_base . '/cloudprnt',
211 array(
212 array(
213 'methods' => array( 'POST', 'GET', 'DELETE' ),
214 'callback' => array( $this, 'cloudprnt' ),
215 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
216 ),
217 )
218 );
219
220 // Path-credential form: Star printers URL-encode the configured query
221 // string on the wire (& becomes %26), so printer_id/pt can never
222 // arrive as query parameters — but the path is transmitted verbatim.
223 register_rest_route(
224 $this->namespace,
225 '/' . $this->rest_base . '/cloudprnt/(?P<printer_id>[^/]+)/(?P<pt>[^/]+)',
226 array(
227 array(
228 'methods' => array( 'POST', 'GET', 'DELETE' ),
229 'callback' => array( $this, 'cloudprnt' ),
230 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
231 ),
232 )
233 );
234
235 register_rest_route(
236 $this->namespace,
237 '/' . $this->rest_base . '/epson-sdp',
238 array(
239 array(
240 'methods' => WP_REST_Server::CREATABLE,
241 'callback' => array( $this, 'epson_sdp' ),
242 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
243 ),
244 )
245 );
246
247 register_rest_route(
248 $this->namespace,
249 '/' . $this->rest_base . '/epson-sdp/(?P<printer_id>[^/]+)/(?P<pt>[^/]+)',
250 array(
251 array(
252 'methods' => WP_REST_Server::CREATABLE,
253 'callback' => array( $this, 'epson_sdp' ),
254 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
255 ),
256 )
257 );
258
259 register_rest_route(
260 $this->namespace,
261 '/printnode/printers',
262 array(
263 array(
264 'methods' => WP_REST_Server::CREATABLE,
265 'callback' => array( $this, 'printnode_printers' ),
266 'permission_callback' => array( $this, 'manage_permissions_check' ),
267 ),
268 )
269 );
270
271 register_rest_route(
272 $this->namespace,
273 '/star-online/devices',
274 array(
275 array(
276 'methods' => WP_REST_Server::CREATABLE,
277 'callback' => array( $this, 'star_online_devices' ),
278 'permission_callback' => array( $this, 'manage_permissions_check' ),
279 ),
280 )
281 );
282 }
283
284 /**
285 * Proxy the PrintNode account's printer list for the add-printer wizard.
286 *
287 * The API key is supplied in the POST body (never the URL/query, so it does
288 * not leak through logs or history) and is used only for this request; it is
289 * never returned. Only id/name/state are surfaced to the client.
290 *
291 * @param WP_REST_Request $request Request.
292 *
293 * @return \WP_REST_Response|WP_Error
294 */
295 public function printnode_printers( $request ) {
296 // The API key is a secret: read it from the request body only, never the
297 // query string, so it can't leak through server logs or browser history.
298 // get_param() merges query + body, so it is deliberately avoided here.
299 $query = $request->get_query_params();
300 if ( isset( $query['api_key'] ) ) {
301 return new WP_Error(
302 'wcpos_printnode_api_key_in_query',
303 __( 'The PrintNode API key must be sent in the request body, not the query string.', 'woocommerce-pos' ),
304 array( 'status' => 400 )
305 );
306 }
307
308 // JSON bodies land in the JSON param set, form-encoded bodies in POST;
309 // read both (cast handles the null-on-absent case) and never the query set.
310 $json = (array) $request->get_json_params();
311 $body = (array) $request->get_body_params();
312 $api_key = (string) ( $json['api_key'] ?? $body['api_key'] ?? '' );
313 if ( '' === $api_key ) {
314 return new WP_Error(
315 'wcpos_printnode_missing_api_key',
316 __( 'A PrintNode API key is required.', 'woocommerce-pos' ),
317 array( 'status' => 400 )
318 );
319 }
320
321 $result = ( new PrintNode_Client( $api_key ) )->printers();
322 if ( is_wp_error( $result ) ) {
323 // A rejected key is a client input error (the value just typed into
324 // the wizard) → 400 so the UI can prompt for a correct key. Any other
325 // PrintNode failure is an upstream/transport error → 502 (matching
326 // test_print_printnode()).
327 $status = 'wcpos_printnode_unauthorized' === $result->get_error_code() ? 400 : 502;
328
329 return new WP_Error(
330 'wcpos_printnode_printers_failed',
331 $result->get_error_message(),
332 array( 'status' => $status )
333 );
334 }
335
336 $printers = array();
337 foreach ( (array) $result as $printer ) {
338 if ( ! is_array( $printer ) || ! isset( $printer['id'] ) ) {
339 continue;
340 }
341 $printers[] = array(
342 'id' => (int) $printer['id'],
343 'name' => (string) ( $printer['name'] ?? '' ),
344 'state' => (string) ( $printer['state'] ?? '' ),
345 );
346 }
347
348 return new WP_REST_Response( array( 'printers' => $printers ), 200 );
349 }
350
351 /**
352 * Proxy the stario.online device list for the add-printer wizard.
353 *
354 * @param WP_REST_Request $request Request.
355 *
356 * @return \WP_REST_Response|WP_Error
357 */
358 public function star_online_devices( $request ) {
359 $query = $request->get_query_params();
360 if ( isset( $query['api_key'] ) ) {
361 return new WP_Error(
362 'wcpos_star_online_api_key_in_query',
363 __( 'The Star Online API key must be sent in the request body, not the query string.', 'woocommerce-pos' ),
364 array( 'status' => 400 )
365 );
366 }
367
368 $json = (array) $request->get_json_params();
369 $body = (array) $request->get_body_params();
370 $api_key = (string) ( $json['api_key'] ?? $body['api_key'] ?? '' );
371 $url = (string) ( $json['cloudprnt_url'] ?? $body['cloudprnt_url'] ?? '' );
372
373 $api_base = Star_Online_Client::api_base_from_cloudprnt_url( $url );
374 $group = Star_Online_Client::group_from_cloudprnt_url( $url );
375 if ( '' === $api_key || null === $api_base || '' === $group ) {
376 return new WP_Error(
377 'wcpos_star_online_invalid_request',
378 __( 'A Star Online API key and a valid stario.online CloudPRNT URL are required.', 'woocommerce-pos' ),
379 array( 'status' => 400 )
380 );
381 }
382
383 $result = ( new Star_Online_Client( $api_base, $api_key ) )->devices( $group );
384 if ( is_wp_error( $result ) ) {
385 return $result;
386 }
387
388 $devices = array();
389 foreach ( $result as $device ) {
390 if ( ! \is_array( $device ) || empty( $device['AccessIdentifier'] ) ) {
391 continue;
392 }
393 $state = 'unknown';
394 $status = isset( $device['Status'] ) && \is_array( $device['Status'] ) ? $device['Status'] : array();
395 if ( array_key_exists( 'Online', $status ) ) {
396 $state = $status['Online'] ? 'online' : 'offline';
397 }
398 $devices[] = array(
399 'id' => (string) $device['AccessIdentifier'],
400 'name' => (string) ( $device['ClientType'] ?? $device['AccessIdentifier'] ),
401 'state' => $state,
402 );
403 }
404
405 return new WP_REST_Response( array( 'devices' => $devices ), 200 );
406 }
407
408
409 /**
410 * Sanitize a job filter that may arrive as a scalar or as a list.
411 *
412 * These routes declare no arg schema, so a caller can send `status=failed`
413 * or `status[]=pending&status[]=failed`. filters_to_meta_query() turns a
414 * list into an IN clause, so flattening one to a string here would silently
415 * narrow the query (and warn on the array-to-string cast).
416 *
417 * @param mixed $value Raw request parameter.
418 *
419 * @return array|string
420 */
421 private function sanitize_filter( $value ) {
422 if ( \is_array( $value ) ) {
423 return array_map(
424 function ( $item ): string {
425 return sanitize_text_field( \is_scalar( $item ) ? (string) $item : '' );
426 },
427 $value
428 );
429 }
430
431 return sanitize_text_field( \is_scalar( $value ) ? (string) $value : '' );
432 }
433
434 /**
435 * List print jobs.
436 *
437 * @param WP_REST_Request $request Request.
438 *
439 * @return \WP_REST_Response
440 */
441 public function get_items( $request ) {
442 return rest_ensure_response(
443 $this->jobs->query(
444 array(
445 'printer_id' => $this->sanitize_filter( $request->get_param( 'printer_id' ) ),
446 'status' => $this->sanitize_filter( $request->get_param( 'status' ) ),
447 )
448 )
449 );
450 }
451
452 /**
453 * Get a print job.
454 *
455 * @param WP_REST_Request $request Request.
456 *
457 * @return \WP_REST_Response|WP_Error
458 */
459 public function get_item( $request ) {
460 $job = $this->jobs->get( (int) $request->get_param( 'id' ) );
461 if ( null === $job ) {
462 return new WP_Error(
463 'wcpos_print_job_not_found',
464 __( 'Print job not found.', 'woocommerce-pos' ),
465 array( 'status' => 404 )
466 );
467 }
468
469 return rest_ensure_response( $job );
470 }
471
472 /**
473 * Cancel a print job.
474 *
475 * @param WP_REST_Request $request Request.
476 *
477 * @return \WP_REST_Response|WP_Error
478 */
479 public function delete_item( $request ) {
480 $id = (int) $request->get_param( 'id' );
481 $job = $this->jobs->get( $id );
482 if ( null === $job ) {
483 return new WP_Error(
484 'wcpos_print_job_not_found',
485 __( 'Print job not found.', 'woocommerce-pos' ),
486 array( 'status' => 404 )
487 );
488 }
489
490 // Without force this route cancels: it takes a waiting job out of the
491 // running but leaves the row as history. With force the row goes for
492 // good, which is the only way to clear a terminal job before its
493 // retention window expires.
494 if ( rest_sanitize_boolean( $request->get_param( 'force' ) ) ) {
495 if ( ! $this->jobs->delete( $id ) ) {
496 return new WP_Error(
497 'wcpos_print_job_not_deleted',
498 __( 'The print job could not be deleted.', 'woocommerce-pos' ),
499 array( 'status' => 500 )
500 );
501 }
502
503 return rest_ensure_response(
504 array(
505 'deleted' => true,
506 'previous' => $job,
507 )
508 );
509 }
510
511 if ( ! $this->jobs->cancel_if_waiting( $id ) ) {
512 return new WP_Error(
513 'wcpos_print_job_not_cancellable',
514 __( 'Only pending or claimed print jobs can be cancelled.', 'woocommerce-pos' ),
515 array( 'status' => 409 )
516 );
517 }
518
519 return rest_ensure_response( $this->jobs->get( $id ) );
520 }
521
522 /**
523 * The admin queue view: paginated jobs (payloads stripped), status counts,
524 * and per-printer backlog with last-seen data for staleness banners.
525 *
526 * @param WP_REST_Request $request Request.
527 *
528 * @return \WP_REST_Response
529 */
530 public function get_queue( $request ) {
531 $per_page = (int) $request->get_param( 'per_page' );
532 $per_page = min( 100, max( 1, 0 === $per_page ? 20 : $per_page ) );
533 $page = max( 1, (int) $request->get_param( 'page' ) );
534
535 $status = $this->sanitize_filter( $request->get_param( 'status' ) );
536 $exclude_retried = 'active' === $status;
537 if ( 'active' === $status ) {
538 // The default queue view: everything not yet terminal-successful.
539 $status = array(
540 Print_Job_Service::STATUS_PENDING,
541 Print_Job_Service::STATUS_CLAIMED,
542 Print_Job_Service::STATUS_FAILED,
543 );
544 }
545 $filters = array(
546 'printer_id' => $this->sanitize_filter( $request->get_param( 'printer_id' ) ),
547 'status' => $status,
548 'exclude_retried' => $exclude_retried,
549 );
550
551 $jobs = array_map(
552 function ( array $job ): array {
553 $order = $job['order_id'] ? wc_get_order( $job['order_id'] ) : false;
554 if ( $order ) {
555 $job['order_number'] = (string) $order->get_order_number();
556 $job['order_edit_url'] = $order->get_edit_order_url();
557 }
558
559 return $job;
560 },
561 $this->jobs->query_rows(
562 array_merge(
563 $filters,
564 array(
565 'limit' => $per_page,
566 'page' => $page,
567 // Newest first: the job an admin opens the queue to check
568 // on is the one that just fired, not the oldest survivor.
569 'order' => 'DESC',
570 )
571 )
572 )
573 );
574
575 // One grouped query covers all status counts and every printer's
576 // backlog — the view refreshes every 30 s, so summary cost must not
577 // scale with printer count.
578 $summary = $this->jobs->status_summary();
579
580 $counts = array();
581 foreach ( array(
582 Print_Job_Service::STATUS_PENDING,
583 Print_Job_Service::STATUS_CLAIMED,
584 Print_Job_Service::STATUS_PRINTED,
585 Print_Job_Service::STATUS_FAILED,
586 Print_Job_Service::STATUS_CANCELLED,
587 ) as $status ) {
588 $counts[ $status ] = 0;
589 foreach ( $summary as $per_status ) {
590 $counts[ $status ] += isset( $per_status[ $status ] ) ? $per_status[ $status ]['count'] : 0;
591 }
592 }
593 $counts['failed_unresolved'] = 0;
594 foreach ( $summary as $per_status ) {
595 if ( isset( $per_status[ Print_Job_Service::STATUS_FAILED ] ) ) {
596 $counts['failed_unresolved'] += $per_status[ Print_Job_Service::STATUS_FAILED ]['unresolved_count'];
597 }
598 }
599
600 $printers = array();
601 foreach ( $this->registry->get_printers() as $printer ) {
602 $printer_id = (string) ( $printer['id'] ?? '' );
603 if ( '' === $printer_id ) {
604 continue;
605 }
606 // Waiting = pending + claimed: a printer that fetched a job and
607 // then died leaves it claimed forever with zero pending — that
608 // backlog must still trip the stale banner.
609 $waiting = 0;
610 $oldest = '';
611 foreach ( array( Print_Job_Service::STATUS_PENDING, Print_Job_Service::STATUS_CLAIMED ) as $status ) {
612 if ( ! isset( $summary[ $printer_id ][ $status ] ) ) {
613 continue;
614 }
615 $waiting += $summary[ $printer_id ][ $status ]['count'];
616 $created = $summary[ $printer_id ][ $status ]['oldest_gmt'];
617 if ( '' !== $created && ( '' === $oldest || $created < $oldest ) ) {
618 $oldest = $created;
619 }
620 }
621 $printers[] = array(
622 'printer_id' => $printer_id,
623 'name' => (string) ( $printer['name'] ?? $printer_id ),
624 // Push providers (PrintNode, Star Online) never poll, so
625 // last-seen staleness is meaningless for them — the UI must
626 // not show a "never fetched" banner. A missing provider
627 // defaults to star-cloudprnt exactly like the print path, so
628 // legacy rows without the field keep their stale warnings.
629 'polling' => Provider::is_polling(
630 Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null )
631 ),
632 'pending' => $waiting,
633 'oldest_pending_gmt' => $oldest,
634 'last_seen' => $this->registry->get_seen( $printer_id ),
635 );
636 }
637
638 return rest_ensure_response(
639 array(
640 'jobs' => $jobs,
641 'total' => $this->jobs->count( $filters ),
642 'page' => $page,
643 'per_page' => $per_page,
644 'summary' => array(
645 'counts' => $counts,
646 'printers' => $printers,
647 ),
648 )
649 );
650 }
651
652 /**
653 * Bulk-cancel waiting jobs by explicit ids or for a whole printer.
654 *
655 * @param WP_REST_Request $request Request.
656 *
657 * @return \WP_REST_Response
658 */
659 public function cancel_queue( $request ) {
660 $ids = $request->get_param( 'ids' );
661 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
662
663 $cancelled = $this->jobs->cancel_waiting(
664 array(
665 'ids' => \is_array( $ids ) ? $ids : array(),
666 'printer_id' => $printer_id,
667 )
668 );
669
670 return rest_ensure_response( array( 'cancelled' => $cancelled ) );
671 }
672
673 /**
674 * Permanently delete queue rows.
675 *
676 * Unlike cancel_queue(), this removes history: any status may be deleted,
677 * because the point is clearing a queue the admin no longer wants to look
678 * at rather than stopping work. Waiting jobs are cancelled on the way out
679 * so nothing is left half-claimed.
680 *
681 * @param WP_REST_Request $request Request.
682 *
683 * @return \WP_REST_Response|WP_Error
684 */
685 public function delete_queue( $request ) {
686 $ids = $request->get_param( 'ids' );
687 if ( ! \is_array( $ids ) || empty( $ids ) ) {
688 return new WP_Error(
689 'wcpos_print_job_no_ids',
690 __( 'No print jobs were selected.', 'woocommerce-pos' ),
691 array( 'status' => 400 )
692 );
693 }
694 foreach ( $ids as $id ) {
695 if ( ( ! \is_int( $id ) && ! \is_string( $id ) ) || ! ctype_digit( (string) $id ) || (int) $id < 1 ) {
696 return new WP_Error(
697 'wcpos_print_job_invalid_ids',
698 __( 'One or more selected print jobs are invalid.', 'woocommerce-pos' ),
699 array( 'status' => 400 )
700 );
701 }
702 }
703
704 $deleted = 0;
705 foreach ( array_map( 'intval', $ids ) as $id ) {
706 if ( $id > 0 && $this->jobs->delete( $id ) ) {
707 ++$deleted;
708 }
709 }
710
711 return rest_ensure_response( array( 'deleted' => $deleted ) );
712 }
713
714 /**
715 * Reprint a print job by copying it to a new pending job.
716 *
717 * @param WP_REST_Request $request Request.
718 *
719 * @return \WP_REST_Response|WP_Error
720 */
721 public function reprint_item( $request ) {
722 $source = $this->jobs->get( (int) $request->get_param( 'id' ) );
723 if ( null === $source ) {
724 return new WP_Error(
725 'wcpos_print_job_not_found',
726 __( 'Print job not found.', 'woocommerce-pos' ),
727 array( 'status' => 404 )
728 );
729 }
730 if ( $source['retried_to'] > 0 ) {
731 return new WP_Error(
732 'wcpos_print_job_already_retried',
733 __( 'This print job has already been retried.', 'woocommerce-pos' ),
734 array(
735 'status' => 409,
736 'retried_to' => $source['retried_to'],
737 )
738 );
739 }
740 if ( '' === $source['payload'] && '' === $source['template_id'] ) {
741 // A stripped raw job has nothing left to print — refuse loudly
742 // rather than queue a blank receipt.
743 return new WP_Error(
744 'wcpos_print_job_source_expired',
745 __( 'This job\'s stored receipt has been cleaned up and it has no template to re-render from.', 'woocommerce-pos' ),
746 array( 'status' => 410 )
747 );
748 }
749 $content_type = $source['content_type'];
750 $pn_kind = $source['pn_kind'];
751 if ( '' !== $source['template_id'] ) {
752 $template = Print_Job_Service::load_template( (string) $source['template_id'] );
753 if ( null === $template && $source['order_id'] > 0 ) {
754 // render_payload() takes its template branch on
755 // order_id + template_id and returns nothing when the
756 // template is gone — the stored payload is never reached.
757 // Queueing here would 201 a job that can only ever fail,
758 // so refuse for the same reason the stripped-payload guard
759 // above does.
760 return new WP_Error(
761 'wcpos_print_job_source_expired',
762 __( 'This job\'s template no longer exists, so it cannot be re-rendered.', 'woocommerce-pos' ),
763 array( 'status' => 410 )
764 );
765 }
766 $printer = $this->registry->get_printer( (string) $source['printer_id'] );
767 if ( null !== $printer ) {
768 // Provider::format() refreshes both halves together. A legacy job
769 // can carry a media type from before the provider declared its own,
770 // but content_type and pn_kind must keep agreeing: reprinting a
771 // raw (escpos) PrintNode job through printer_content_type()
772 // relabels it application/pdf in the queue view, even though
773 // submit still sends raw bytes off the stored pn_kind.
774 $fmt = null === $template ? array( 'kind' => '' ) : Provider::format( $printer, $template );
775 if ( '' === (string) $fmt['kind'] ) {
776 // No loadable template, or one this printer can no longer
777 // render. Refresh from the provider's declared type only
778 // when no stored kind can contradict it; otherwise the
779 // source pairing is the best answer left.
780 if ( '' === $pn_kind ) {
781 $content_type = Provider::printer_content_type( $printer );
782 }
783 } else {
784 $content_type = $fmt['content_type'];
785 $pn_kind = Provider::stores_job_kind( Provider::normalize( (string) ( $printer['provider'] ?? '' ) ) )
786 ? $fmt['kind']
787 : '';
788 }
789 }
790 }
791 $new_id = $this->jobs->create(
792 array(
793 'printer_id' => $source['printer_id'],
794 'content_type' => $content_type,
795 'payload' => $source['payload'],
796 'order_id' => $source['order_id'] ? $source['order_id'] : null,
797 'format' => $source['format'] ? $source['format'] : null,
798 // Template-backed jobs (auto-print) carry no stored payload —
799 // the render metadata must survive the copy or the reprint
800 // renders nothing.
801 'template_id' => '' !== $source['template_id'] ? $source['template_id'] : null,
802 'pn_kind' => '' !== $pn_kind ? $pn_kind : null,
803 'auto_open_drawer' => $source['auto_open_drawer'],
804 'drawer_connector' => $source['drawer_connector'],
805 )
806 );
807 if ( $new_id <= 0 ) {
808 return new WP_Error(
809 'wcpos_print_job_create_failed',
810 __( 'Print job could not be created.', 'woocommerce-pos' ),
811 array( 'status' => 500 )
812 );
813 }
814 if ( Print_Job_Service::STATUS_FAILED === $source['status'] && ! $this->jobs->mark_retried( (int) $source['id'], $new_id ) ) {
815 wp_delete_post( $new_id, true );
816
817 return new WP_Error(
818 'wcpos_print_job_retry_failed',
819 __( 'Print job retry could not be recorded.', 'woocommerce-pos' ),
820 array( 'status' => 500 )
821 );
822 }
823
824 // Push providers (PrintNode, Star Online) never poll the queue — their
825 // jobs only move when CRON_SUBMIT fires. Without this the replacement
826 // job stays pending forever and Retry silently does nothing.
827 $printer = $this->registry->get_printer( (string) $source['printer_id'] );
828 $provider = null !== $printer ? (string) ( $printer['provider'] ?? '' ) : '';
829 if ( Provider::requires_submit( $provider ) ) {
830 wp_schedule_single_event( time(), Cloud_Print_Trigger_Service::CRON_SUBMIT, array( $new_id ) );
831 }
832
833 $response = rest_ensure_response( $this->jobs->get( $new_id ) );
834 $response->set_status( 201 );
835
836 return $response;
837 }
838
839
840 /**
841 * Star CloudPRNT poll/fetch/confirm endpoint.
842 *
843 * @param WP_REST_Request $request Request.
844 *
845 * @return \WP_REST_Response|WP_Error
846 */
847 public function cloudprnt( $request ) {
848 return $this->handle_provider_poll( $request, 'star-cloudprnt' );
849 }
850
851 /**
852 * Permission check for printer-token routes.
853 *
854 * @param WP_REST_Request $request Request.
855 *
856 * @return bool|WP_Error
857 */
858 public function printer_token_permissions_check( $request ) {
859 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
860 $token = (string) $request->get_param( 'pt' );
861
862 if ( ! $this->registry->verify_token( $printer_id, $token ) ) {
863 Logger::warning(
864 sprintf(
865 '%s: authentication failed for printer "%s".',
866 $request->get_route(),
867 $printer_id
868 )
869 );
870
871 return new WP_Error(
872 'wcpos_print_job_invalid_token',
873 __( 'Invalid printer token.', 'woocommerce-pos' ),
874 array( 'status' => 401 )
875 );
876 }
877
878 return true;
879 }
880
881
882
883
884 /**
885 * Parse the vendor request and serve the lifecycle's plain-data response.
886 *
887 * @param WP_REST_Request $request Request.
888 * @param string $provider_key Protocol selected by the route.
889 * @return WP_REST_Response|WP_Error
890 */
891 private function handle_provider_poll( WP_REST_Request $request, string $provider_key ) {
892 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
893 $printer = $this->registry->get_printer( $printer_id );
894 if ( null === $printer ) {
895 return new WP_Error( 'wcpos_print_job_invalid_token', __( 'Invalid printer token.', 'woocommerce-pos' ), array( 'status' => 401 ) );
896 }
897 $poll = Provider::poll_adapter( $provider_key )->parse(
898 array(
899 'params' => $request->get_params(),
900 'body' => (string) $request->get_body(),
901 'json' => 'POST' === $request->get_method() ? $request->get_json_params() : null,
902 'method' => $request->get_method(),
903 'route' => $request->get_route(),
904 )
905 );
906 $result = ( new Print_Job_Lifecycle( $this->jobs, $this->registry ) )->handle_poll( $provider_key, $printer, $poll );
907 if ( \is_string( $result['body'] ) ) {
908 $response = $this->serve_raw( $result['body'], $result['headers']['Content-Type'], $result['headers'] );
909 $response->set_status( $result['status'] );
910 return $response;
911 }
912 return new WP_REST_Response( $result['body'], $result['status'], $result['headers'] );
913 }
914
915 /**
916 * Serve raw bytes from a REST callback.
917 *
918 * @param string $body Response body.
919 * @param string $content_type Content type.
920 * @param array<string, string> $headers Extra response headers.
921 *
922 * @return \WP_REST_Response
923 */
924 private function serve_raw( string $body, string $content_type, array $headers = array() ) {
925 return Raw_Response::serve( $body, $content_type, $headers );
926 }
927
928
929 /**
930 * Epson Server Direct Print poll/result endpoint.
931 *
932 * @param WP_REST_Request $request Request.
933 *
934 * @return \WP_REST_Response
935 */
936 public function epson_sdp( $request ) {
937 return $this->handle_provider_poll( $request, 'epson-sdp' );
938 }
939
940 /**
941 * Enqueue a print job (raw payload or order-based).
942 *
943 * @param WP_REST_Request $request Request.
944 *
945 * @return \WP_REST_Response|WP_Error
946 */
947 public function create_item( $request ) {
948 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
949 if ( '' === $printer_id ) {
950 return new WP_Error(
951 'wcpos_print_job_missing_printer',
952 __( 'A printer_id is required.', 'woocommerce-pos' ),
953 array( 'status' => 400 )
954 );
955 }
956
957 $payload = (string) $request->get_param( 'payload' );
958 $format = (string) $request->get_param( 'format' );
959 $template_id = sanitize_text_field( (string) $request->get_param( 'template_id' ) );
960 $order_id = (int) $request->get_param( 'order_id' );
961 $drawer_options = $this->drawer_options_from_request( $request );
962
963 $printer = $this->registry->get_printer( $printer_id );
964 $is_template_job = 0 !== $order_id && '' !== $template_id;
965 $validation = $this->validate_job_for_printer( $printer, $payload, $format, $is_template_job );
966 if ( is_wp_error( $validation ) ) {
967 return $validation;
968 }
969
970 $provider = null !== $printer ? (string) ( $printer['provider'] ?? '' ) : '';
971
972 // PrintNode never polls, so a raw payload could never be delivered — a
973 // PrintNode job must be order-based (rendered + submitted out-of-band).
974 if ( 'printnode' === $provider && ( 0 === $order_id || '' === $template_id ) ) {
975 return new WP_Error(
976 'wcpos_print_job_printnode_requires_template',
977 __( 'PrintNode print jobs require an order and a template.', 'woocommerce-pos' ),
978 array( 'status' => 400 )
979 );
980 }
981
982 // Order-based job: render server-side from the order + template, deriving
983 // the wire format from the printer's provider (shared with the auto-print
984 // trigger). Star/Epson are fetched on poll; PrintNode is submitted.
985 if ( 0 !== $order_id && '' !== $template_id ) {
986 if ( null === $printer ) {
987 // Without a known printer there is no provider to render for, and
988 // the job could never be polled/submitted — fail loudly rather
989 // than enqueue a job that silently never prints.
990 return new WP_Error(
991 'wcpos_print_job_unknown_printer',
992 __( 'Unknown printer.', 'woocommerce-pos' ),
993 array( 'status' => 404 )
994 );
995 }
996
997 return $this->create_order_job( $printer_id, $printer, $order_id, $template_id, $drawer_options );
998 }
999
1000 $id = $this->jobs->create(
1001 array(
1002 'printer_id' => $printer_id,
1003 'content_type' => (string) $request->get_param( 'content_type' ),
1004 'payload' => $payload,
1005 'order_id' => $order_id,
1006 'format' => $format,
1007 )
1008 );
1009 if ( $id <= 0 ) {
1010 return new WP_Error(
1011 'wcpos_print_job_create_failed',
1012 __( 'Print job could not be created.', 'woocommerce-pos' ),
1013 array( 'status' => 500 )
1014 );
1015 }
1016
1017 $response = rest_ensure_response( $this->jobs->get( $id ) );
1018 $response->set_status( 201 );
1019
1020 return $response;
1021 }
1022
1023 /**
1024 * Enqueue an order-based job, deriving the wire format from the printer's
1025 * provider via the shared trigger-service helper.
1026 *
1027 * @param string $printer_id Registered printer id.
1028 * @param array $printer Registered printer config.
1029 * @param int $order_id Order id to render.
1030 * @param string $template_id Template id (numeric) or virtual slug.
1031 * @param array $drawer_options Drawer options.
1032 *
1033 * @return \WP_REST_Response|WP_Error
1034 */
1035 private function create_order_job( string $printer_id, array $printer, int $order_id, string $template_id, array $drawer_options = array() ) {
1036 if ( ! wc_get_order( $order_id ) ) {
1037 // Surface the bad order up front rather than enqueue a job that
1038 // render_payload() can only ever resolve to an empty (never-printing) payload.
1039 return new WP_Error(
1040 'wcpos_print_job_unknown_order',
1041 __( 'Unknown order.', 'woocommerce-pos' ),
1042 array( 'status' => 404 )
1043 );
1044 }
1045
1046 $template = Print_Job_Service::load_template( $template_id );
1047 if ( null === $template ) {
1048 return new WP_Error(
1049 'wcpos_print_job_unknown_template',
1050 __( 'Unknown template.', 'woocommerce-pos' ),
1051 array( 'status' => 400 )
1052 );
1053 }
1054
1055 $id = Cloud_Print_Trigger_Service::enqueue_order_job(
1056 $this->jobs,
1057 $printer_id,
1058 $printer,
1059 $order_id,
1060 $template_id,
1061 $template,
1062 $drawer_options
1063 );
1064 if ( $id <= 0 ) {
1065 return new WP_Error(
1066 'wcpos_print_job_template_not_printable',
1067 __( 'The selected template cannot be printed on this printer.', 'woocommerce-pos' ),
1068 array( 'status' => 400 )
1069 );
1070 }
1071
1072 $response = rest_ensure_response( $this->jobs->get( $id ) );
1073 $response->set_status( 201 );
1074
1075 return $response;
1076 }
1077
1078 /**
1079 * Enqueue a diagnostic test print for a registered printer.
1080 *
1081 * @param WP_REST_Request $request Request.
1082 *
1083 * @return \WP_REST_Response|WP_Error
1084 */
1085 public function test_print( $request ) {
1086 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
1087 $printer = $this->registry->get_printer( $printer_id );
1088 if ( null === $printer ) {
1089 return new WP_Error(
1090 'wcpos_print_job_unknown_printer',
1091 __( 'Unknown printer.', 'woocommerce-pos' ),
1092 array( 'status' => 404 )
1093 );
1094 }
1095
1096 // Legacy printer rows saved before the provider field existed must test
1097 // as the default provider, not fall through to the no-diagnostic error.
1098 $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
1099
1100 $adapter = Provider::adapter( $provider );
1101 if ( $adapter instanceof Push_Provider_Adapter_Interface ) {
1102 return $adapter->test_print( $printer );
1103 }
1104 $diag = $adapter->diagnostic( (string) $printer['name'] );
1105
1106 $id = $this->jobs->create(
1107 array(
1108 'printer_id' => $printer_id,
1109 'content_type' => $diag['content_type'],
1110 'payload' => $diag['payload'],
1111 )
1112 );
1113 if ( $id <= 0 ) {
1114 return new WP_Error(
1115 'wcpos_print_job_create_failed',
1116 __( 'Print job could not be created.', 'woocommerce-pos' ),
1117 array( 'status' => 500 )
1118 );
1119 }
1120
1121 $response = rest_ensure_response( $this->jobs->get( $id ) );
1122 $response->set_status( 201 );
1123
1124 return $response;
1125 }
1126
1127
1128
1129 /**
1130 * Extract sanitized cash-drawer options from a REST request.
1131 *
1132 * @param WP_REST_Request $request Request.
1133 *
1134 * @return array{auto_open_drawer:bool, drawer_connector:string}
1135 */
1136 private function drawer_options_from_request( WP_REST_Request $request ): array {
1137 $auto = $request->get_param( 'autoOpenDrawer' );
1138 if ( null === $auto ) {
1139 $auto = $request->get_param( 'auto_open_drawer' );
1140 }
1141
1142 $connector = $request->get_param( 'drawerConnector' );
1143 if ( null === $connector ) {
1144 $connector = $request->get_param( 'drawer_connector' );
1145 }
1146
1147 return array(
1148 'auto_open_drawer' => rest_sanitize_boolean( $auto ),
1149 'drawer_connector' => Print_Job_Service::normalize_drawer_connector( (string) $connector ),
1150 );
1151 }
1152
1153 /**
1154 * Validate a job against the target printer's provider.
1155 *
1156 * @param array|null $printer Registered printer, or null when unknown.
1157 * @param string $payload Base64 payload (raw jobs).
1158 * @param string $format Render format (order-based jobs).
1159 * @param bool $is_template_job Whether this is an order/template job.
1160 *
1161 * @return true|WP_Error
1162 */
1163 private function validate_job_for_printer( ?array $printer, string $payload, string $format, bool $is_template_job ) {
1164 if ( null === $printer ) {
1165 return true;
1166 }
1167 $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
1168
1169 if ( 'epos-xml' === Provider::wire_format( $provider, 'thermal' ) ) {
1170 if ( '' !== $payload ) {
1171 return new WP_Error(
1172 'wcpos_print_job_incompatible',
1173 __( 'Epson Server Direct Print accepts order-based ePOS-Print jobs only, not raw payloads.', 'woocommerce-pos' ),
1174 array( 'status' => 400 )
1175 );
1176 }
1177 if ( '' !== $format && 'epos-xml' !== $format ) {
1178 return new WP_Error(
1179 'wcpos_print_job_incompatible',
1180 __( 'Epson Server Direct Print requires the epos-xml format.', 'woocommerce-pos' ),
1181 array( 'status' => 400 )
1182 );
1183 }
1184
1185 return true;
1186 }
1187
1188 if ( 'epos-xml' === $format ) {
1189 return new WP_Error(
1190 'wcpos_print_job_incompatible',
1191 __( 'Star CloudPRNT does not accept the epos-xml format.', 'woocommerce-pos' ),
1192 array( 'status' => 400 )
1193 );
1194 }
1195
1196 // The fixed-layout 'escpos' adapter emits a language StarPRNT-native
1197 // printers cannot decode, and the fixed-layout 'starprnt' adapter is a
1198 // placeholder that emits marker text, not wire bytes. Fail these jobs
1199 // loudly instead of queueing bytes the printer will reject.
1200 if ( ! $is_template_job && 'star-cloudprnt' === $provider && in_array( $format, array( 'escpos', 'starprnt' ), true ) ) {
1201 return new WP_Error(
1202 'wcpos_print_job_incompatible',
1203 __( 'Star CloudPRNT printers require order-based template jobs or a raw payload.', 'woocommerce-pos' ),
1204 array( 'status' => 400 )
1205 );
1206 }
1207
1208 return true;
1209 }
1210
1211 /**
1212 * Serve the pending relay verification token (public; consent callback).
1213 *
1214 * @return \WP_REST_Response|WP_Error
1215 */
1216 public function relay_verification() {
1217 $token = Cloud_Print_Relay_Service::pending_verification_token();
1218 if ( null === $token ) {
1219 return new WP_Error(
1220 'wcpos_relay_no_pending_verification',
1221 __( 'No relay verification is pending.', 'woocommerce-pos' ),
1222 array( 'status' => 404 )
1223 );
1224 }
1225
1226 return rest_ensure_response( array( 'token' => $token ) );
1227 }
1228
1229 /**
1230 * Register this site with the WCPOS Cloud Print relay.
1231 *
1232 * @return \WP_REST_Response|WP_Error
1233 */
1234 public function relay_register() {
1235 $result = Cloud_Print_Relay_Service::register_site();
1236
1237 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
1238 }
1239
1240 /**
1241 * Permission check for relay registration routes.
1242 *
1243 * Registering rotates the site's relay credentials, so it needs the
1244 * settings-management capability, not the cashier-level print capability.
1245 */
1246 public function relay_manage_permissions_check(): bool {
1247 return current_user_can( 'manage_woocommerce_pos' );
1248 }
1249
1250 /**
1251 * Check permissions for cashier-level print job actions.
1252 *
1253 * @param WP_REST_Request $request Request.
1254 *
1255 * @return bool|WP_Error
1256 */
1257 public function manage_permissions_check( $request ) {
1258 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
1259 return new WP_Error(
1260 'wcpos_rest_insufficient_permissions',
1261 __( 'Sorry, you cannot manage print jobs.', 'woocommerce-pos' ),
1262 array( 'status' => rest_authorization_required_code() )
1263 );
1264 }
1265
1266 return true;
1267 }
1268 }
1269