PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
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 1.9.13 All 162 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.17, at includes/API/V1/Print_Jobs_Controller.php

1,791 lines 58.9 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\Services\Cloud_Print_Diagnostic;
12 use WCPOS\WooCommercePOS\Services\Cloud_Print_Media_Types;
13 use WCPOS\WooCommercePOS\Services\Cloud_Print_Poll_Request;
14 use WCPOS\WooCommercePOS\Services\Cloud_Print_Relay_Service;
15 use WCPOS\WooCommercePOS\Services\Cloud_Print_Registry;
16 use WCPOS\WooCommercePOS\Services\Cloud_Print_Trigger_Service;
17 use WCPOS\WooCommercePOS\Services\PrintNode_Client;
18 use WCPOS\WooCommercePOS\Services\Print_Format_Resolver;
19 use WCPOS\WooCommercePOS\Services\Print_Job_Service;
20 use WCPOS\WooCommercePOS\Services\Provider;
21 use WCPOS\WooCommercePOS\Services\Star_Online_Client;
22 use WP_Error;
23 use WP_REST_Controller;
24 use WP_REST_Request;
25 use WP_REST_Response;
26 use WP_REST_Server;
27
28 use const WCPOS\WooCommercePOS\SHORT_NAME;
29
30 /**
31 * Print_Jobs_Controller class.
32 */
33 class Print_Jobs_Controller extends WP_REST_Controller {
34 /**
35 * Milliseconds an Epson Server Direct Print printer waits for the local
36 * print device to become printable before it gives up and reports
37 * EX_TIMEOUT.
38 *
39 * Epson allows 5000–300000. The previous 10000 (near the floor) was
40 * unforgiving for printers that briefly go not-ready — a paper change, a
41 * sleep/wake, or a momentary network blip — declaring the job failed
42 * before the printer could recover. 60000 gives those transient states
43 * room to clear without letting a genuinely offline printer hang for long.
44 */
45 const EPSON_SDP_PRINT_TIMEOUT_MS = 60000;
46
47 /**
48 * Endpoint namespace.
49 *
50 * @var string
51 */
52 protected $namespace = SHORT_NAME . '/v1';
53
54 /**
55 * Route base.
56 *
57 * @var string
58 */
59 protected $rest_base = 'print-jobs';
60
61 /**
62 * Job store.
63 *
64 * @var Print_Job_Service
65 */
66 protected $jobs;
67
68 /**
69 * Cloud printer registry.
70 *
71 * @var Cloud_Print_Registry
72 */
73 protected $registry;
74
75 /**
76 * Constructor.
77 */
78 public function __construct() {
79 $this->jobs = new Print_Job_Service();
80 $this->registry = new Cloud_Print_Registry();
81 }
82
83 /**
84 * Declare routes with special permission-gate handling.
85 *
86 * @return array<string, string[]> Route classifications.
87 */
88 public function wcpos_route_classifications(): array {
89 return array(
90 'public' => array(
91 "/{$this->namespace}/{$this->rest_base}/relay-verification",
92 ),
93 'printer_token' => array(
94 "/{$this->namespace}/{$this->rest_base}/cloudprnt",
95 "/{$this->namespace}/{$this->rest_base}/epson-sdp",
96 ),
97 );
98 }
99
100 /**
101 * Register routes.
102 */
103 public function register_routes(): void {
104 register_rest_route(
105 $this->namespace,
106 '/' . $this->rest_base,
107 array(
108 array(
109 'methods' => WP_REST_Server::READABLE,
110 'callback' => array( $this, 'get_items' ),
111 'permission_callback' => array( $this, 'manage_permissions_check' ),
112 ),
113 array(
114 'methods' => WP_REST_Server::CREATABLE,
115 'callback' => array( $this, 'create_item' ),
116 'permission_callback' => array( $this, 'manage_permissions_check' ),
117 ),
118 )
119 );
120
121 register_rest_route(
122 $this->namespace,
123 '/' . $this->rest_base . '/(?P<id>[\d]+)',
124 array(
125 array(
126 'methods' => WP_REST_Server::READABLE,
127 'callback' => array( $this, 'get_item' ),
128 'permission_callback' => array( $this, 'manage_permissions_check' ),
129 ),
130 array(
131 'methods' => WP_REST_Server::DELETABLE,
132 'callback' => array( $this, 'delete_item' ),
133 'permission_callback' => array( $this, 'manage_permissions_check' ),
134 ),
135 )
136 );
137
138 register_rest_route(
139 $this->namespace,
140 '/' . $this->rest_base . '/(?P<id>[\d]+)/reprint',
141 array(
142 array(
143 'methods' => WP_REST_Server::CREATABLE,
144 'callback' => array( $this, 'reprint_item' ),
145 'permission_callback' => array( $this, 'manage_permissions_check' ),
146 ),
147 )
148 );
149
150 register_rest_route(
151 $this->namespace,
152 '/' . $this->rest_base . '/queue',
153 array(
154 array(
155 'methods' => WP_REST_Server::READABLE,
156 'callback' => array( $this, 'get_queue' ),
157 'permission_callback' => array( $this, 'manage_permissions_check' ),
158 ),
159 )
160 );
161
162 register_rest_route(
163 $this->namespace,
164 '/' . $this->rest_base . '/queue/cancel',
165 array(
166 array(
167 'methods' => WP_REST_Server::CREATABLE,
168 'callback' => array( $this, 'cancel_queue' ),
169 'permission_callback' => array( $this, 'manage_permissions_check' ),
170 ),
171 )
172 );
173
174 register_rest_route(
175 $this->namespace,
176 '/' . $this->rest_base . '/queue/delete',
177 array(
178 array(
179 'methods' => WP_REST_Server::CREATABLE,
180 'callback' => array( $this, 'delete_queue' ),
181 'permission_callback' => array( $this, 'manage_permissions_check' ),
182 ),
183 )
184 );
185
186 register_rest_route(
187 $this->namespace,
188 '/' . $this->rest_base . '/test',
189 array(
190 'methods' => WP_REST_Server::CREATABLE,
191 'callback' => array( $this, 'test_print' ),
192 'permission_callback' => array( $this, 'manage_permissions_check' ),
193 )
194 );
195
196 register_rest_route(
197 $this->namespace,
198 '/' . $this->rest_base . '/relay-verification',
199 array(
200 'methods' => WP_REST_Server::READABLE,
201 'callback' => array( $this, 'relay_verification' ),
202 'permission_callback' => '__return_true',
203 )
204 );
205
206 register_rest_route(
207 $this->namespace,
208 '/' . $this->rest_base . '/relay/register',
209 array(
210 'methods' => WP_REST_Server::CREATABLE,
211 'callback' => array( $this, 'relay_register' ),
212 'permission_callback' => array( $this, 'relay_manage_permissions_check' ),
213 )
214 );
215
216 register_rest_route(
217 $this->namespace,
218 '/' . $this->rest_base . '/cloudprnt',
219 array(
220 array(
221 'methods' => array( 'POST', 'GET', 'DELETE' ),
222 'callback' => array( $this, 'cloudprnt' ),
223 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
224 ),
225 )
226 );
227
228 // Path-credential form: Star printers URL-encode the configured query
229 // string on the wire (& becomes %26), so printer_id/pt can never
230 // arrive as query parameters — but the path is transmitted verbatim.
231 register_rest_route(
232 $this->namespace,
233 '/' . $this->rest_base . '/cloudprnt/(?P<printer_id>[^/]+)/(?P<pt>[^/]+)',
234 array(
235 array(
236 'methods' => array( 'POST', 'GET', 'DELETE' ),
237 'callback' => array( $this, 'cloudprnt' ),
238 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
239 ),
240 )
241 );
242
243 register_rest_route(
244 $this->namespace,
245 '/' . $this->rest_base . '/epson-sdp',
246 array(
247 array(
248 'methods' => WP_REST_Server::CREATABLE,
249 'callback' => array( $this, 'epson_sdp' ),
250 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
251 ),
252 )
253 );
254
255 register_rest_route(
256 $this->namespace,
257 '/' . $this->rest_base . '/epson-sdp/(?P<printer_id>[^/]+)/(?P<pt>[^/]+)',
258 array(
259 array(
260 'methods' => WP_REST_Server::CREATABLE,
261 'callback' => array( $this, 'epson_sdp' ),
262 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
263 ),
264 )
265 );
266
267 register_rest_route(
268 $this->namespace,
269 '/printnode/printers',
270 array(
271 array(
272 'methods' => WP_REST_Server::CREATABLE,
273 'callback' => array( $this, 'printnode_printers' ),
274 'permission_callback' => array( $this, 'manage_permissions_check' ),
275 ),
276 )
277 );
278
279 register_rest_route(
280 $this->namespace,
281 '/star-online/devices',
282 array(
283 array(
284 'methods' => WP_REST_Server::CREATABLE,
285 'callback' => array( $this, 'star_online_devices' ),
286 'permission_callback' => array( $this, 'manage_permissions_check' ),
287 ),
288 )
289 );
290 }
291
292 /**
293 * Proxy the PrintNode account's printer list for the add-printer wizard.
294 *
295 * The API key is supplied in the POST body (never the URL/query, so it does
296 * not leak through logs or history) and is used only for this request; it is
297 * never returned. Only id/name/state are surfaced to the client.
298 *
299 * @param WP_REST_Request $request Request.
300 *
301 * @return \WP_REST_Response|WP_Error
302 */
303 public function printnode_printers( $request ) {
304 // The API key is a secret: read it from the request body only, never the
305 // query string, so it can't leak through server logs or browser history.
306 // get_param() merges query + body, so it is deliberately avoided here.
307 $query = $request->get_query_params();
308 if ( isset( $query['api_key'] ) ) {
309 return new WP_Error(
310 'wcpos_printnode_api_key_in_query',
311 __( 'The PrintNode API key must be sent in the request body, not the query string.', 'woocommerce-pos' ),
312 array( 'status' => 400 )
313 );
314 }
315
316 // JSON bodies land in the JSON param set, form-encoded bodies in POST;
317 // read both (cast handles the null-on-absent case) and never the query set.
318 $json = (array) $request->get_json_params();
319 $body = (array) $request->get_body_params();
320 $api_key = (string) ( $json['api_key'] ?? $body['api_key'] ?? '' );
321 if ( '' === $api_key ) {
322 return new WP_Error(
323 'wcpos_printnode_missing_api_key',
324 __( 'A PrintNode API key is required.', 'woocommerce-pos' ),
325 array( 'status' => 400 )
326 );
327 }
328
329 $result = ( new PrintNode_Client( $api_key ) )->printers();
330 if ( is_wp_error( $result ) ) {
331 // A rejected key is a client input error (the value just typed into
332 // the wizard) → 400 so the UI can prompt for a correct key. Any other
333 // PrintNode failure is an upstream/transport error → 502 (matching
334 // test_print_printnode()).
335 $status = 'wcpos_printnode_unauthorized' === $result->get_error_code() ? 400 : 502;
336
337 return new WP_Error(
338 'wcpos_printnode_printers_failed',
339 $result->get_error_message(),
340 array( 'status' => $status )
341 );
342 }
343
344 $printers = array();
345 foreach ( (array) $result as $printer ) {
346 if ( ! is_array( $printer ) || ! isset( $printer['id'] ) ) {
347 continue;
348 }
349 $printers[] = array(
350 'id' => (int) $printer['id'],
351 'name' => (string) ( $printer['name'] ?? '' ),
352 'state' => (string) ( $printer['state'] ?? '' ),
353 );
354 }
355
356 return new WP_REST_Response( array( 'printers' => $printers ), 200 );
357 }
358
359 /**
360 * Proxy the stario.online device list for the add-printer wizard.
361 *
362 * @param WP_REST_Request $request Request.
363 *
364 * @return \WP_REST_Response|WP_Error
365 */
366 public function star_online_devices( $request ) {
367 $query = $request->get_query_params();
368 if ( isset( $query['api_key'] ) ) {
369 return new WP_Error(
370 'wcpos_star_online_api_key_in_query',
371 __( 'The Star Online API key must be sent in the request body, not the query string.', 'woocommerce-pos' ),
372 array( 'status' => 400 )
373 );
374 }
375
376 $json = (array) $request->get_json_params();
377 $body = (array) $request->get_body_params();
378 $api_key = (string) ( $json['api_key'] ?? $body['api_key'] ?? '' );
379 $url = (string) ( $json['cloudprnt_url'] ?? $body['cloudprnt_url'] ?? '' );
380
381 $api_base = Star_Online_Client::api_base_from_cloudprnt_url( $url );
382 $group = Star_Online_Client::group_from_cloudprnt_url( $url );
383 if ( '' === $api_key || null === $api_base || '' === $group ) {
384 return new WP_Error(
385 'wcpos_star_online_invalid_request',
386 __( 'A Star Online API key and a valid stario.online CloudPRNT URL are required.', 'woocommerce-pos' ),
387 array( 'status' => 400 )
388 );
389 }
390
391 $result = ( new Star_Online_Client( $api_base, $api_key ) )->devices( $group );
392 if ( is_wp_error( $result ) ) {
393 return $result;
394 }
395
396 $devices = array();
397 foreach ( $result as $device ) {
398 if ( ! \is_array( $device ) || empty( $device['AccessIdentifier'] ) ) {
399 continue;
400 }
401 $state = 'unknown';
402 $status = isset( $device['Status'] ) && \is_array( $device['Status'] ) ? $device['Status'] : array();
403 if ( array_key_exists( 'Online', $status ) ) {
404 $state = $status['Online'] ? 'online' : 'offline';
405 }
406 $devices[] = array(
407 'id' => (string) $device['AccessIdentifier'],
408 'name' => (string) ( $device['ClientType'] ?? $device['AccessIdentifier'] ),
409 'state' => $state,
410 );
411 }
412
413 return new WP_REST_Response( array( 'devices' => $devices ), 200 );
414 }
415
416
417 /**
418 * Sanitize a job filter that may arrive as a scalar or as a list.
419 *
420 * These routes declare no arg schema, so a caller can send `status=failed`
421 * or `status[]=pending&status[]=failed`. filters_to_meta_query() turns a
422 * list into an IN clause, so flattening one to a string here would silently
423 * narrow the query (and warn on the array-to-string cast).
424 *
425 * @param mixed $value Raw request parameter.
426 *
427 * @return array|string
428 */
429 private function sanitize_filter( $value ) {
430 if ( \is_array( $value ) ) {
431 return array_map(
432 function ( $item ): string {
433 return sanitize_text_field( \is_scalar( $item ) ? (string) $item : '' );
434 },
435 $value
436 );
437 }
438
439 return sanitize_text_field( \is_scalar( $value ) ? (string) $value : '' );
440 }
441
442 /**
443 * List print jobs.
444 *
445 * @param WP_REST_Request $request Request.
446 *
447 * @return \WP_REST_Response
448 */
449 public function get_items( $request ) {
450 return rest_ensure_response(
451 $this->jobs->query(
452 array(
453 'printer_id' => $this->sanitize_filter( $request->get_param( 'printer_id' ) ),
454 'status' => $this->sanitize_filter( $request->get_param( 'status' ) ),
455 )
456 )
457 );
458 }
459
460 /**
461 * Get a print job.
462 *
463 * @param WP_REST_Request $request Request.
464 *
465 * @return \WP_REST_Response|WP_Error
466 */
467 public function get_item( $request ) {
468 $job = $this->jobs->get( (int) $request->get_param( 'id' ) );
469 if ( null === $job ) {
470 return new WP_Error(
471 'wcpos_print_job_not_found',
472 __( 'Print job not found.', 'woocommerce-pos' ),
473 array( 'status' => 404 )
474 );
475 }
476
477 return rest_ensure_response( $job );
478 }
479
480 /**
481 * Cancel a print job.
482 *
483 * @param WP_REST_Request $request Request.
484 *
485 * @return \WP_REST_Response|WP_Error
486 */
487 public function delete_item( $request ) {
488 $id = (int) $request->get_param( 'id' );
489 $job = $this->jobs->get( $id );
490 if ( null === $job ) {
491 return new WP_Error(
492 'wcpos_print_job_not_found',
493 __( 'Print job not found.', 'woocommerce-pos' ),
494 array( 'status' => 404 )
495 );
496 }
497
498 // Without force this route cancels: it takes a waiting job out of the
499 // running but leaves the row as history. With force the row goes for
500 // good, which is the only way to clear a terminal job before its
501 // retention window expires.
502 if ( rest_sanitize_boolean( $request->get_param( 'force' ) ) ) {
503 if ( ! $this->jobs->delete( $id ) ) {
504 return new WP_Error(
505 'wcpos_print_job_not_deleted',
506 __( 'The print job could not be deleted.', 'woocommerce-pos' ),
507 array( 'status' => 500 )
508 );
509 }
510
511 return rest_ensure_response(
512 array(
513 'deleted' => true,
514 'previous' => $job,
515 )
516 );
517 }
518
519 if ( ! $this->jobs->cancel_if_waiting( $id ) ) {
520 return new WP_Error(
521 'wcpos_print_job_not_cancellable',
522 __( 'Only pending or claimed print jobs can be cancelled.', 'woocommerce-pos' ),
523 array( 'status' => 409 )
524 );
525 }
526
527 return rest_ensure_response( $this->jobs->get( $id ) );
528 }
529
530 /**
531 * The admin queue view: paginated jobs (payloads stripped), status counts,
532 * and per-printer backlog with last-seen data for staleness banners.
533 *
534 * @param WP_REST_Request $request Request.
535 *
536 * @return \WP_REST_Response
537 */
538 public function get_queue( $request ) {
539 $per_page = (int) $request->get_param( 'per_page' );
540 $per_page = min( 100, max( 1, 0 === $per_page ? 20 : $per_page ) );
541 $page = max( 1, (int) $request->get_param( 'page' ) );
542
543 $status = $this->sanitize_filter( $request->get_param( 'status' ) );
544 $exclude_retried = 'active' === $status;
545 if ( 'active' === $status ) {
546 // The default queue view: everything not yet terminal-successful.
547 $status = array(
548 Print_Job_Service::STATUS_PENDING,
549 Print_Job_Service::STATUS_CLAIMED,
550 Print_Job_Service::STATUS_FAILED,
551 );
552 }
553 $filters = array(
554 'printer_id' => $this->sanitize_filter( $request->get_param( 'printer_id' ) ),
555 'status' => $status,
556 'exclude_retried' => $exclude_retried,
557 );
558
559 $jobs = array_map(
560 function ( array $job ): array {
561 $order = $job['order_id'] ? wc_get_order( $job['order_id'] ) : false;
562 if ( $order ) {
563 $job['order_number'] = (string) $order->get_order_number();
564 $job['order_edit_url'] = $order->get_edit_order_url();
565 }
566
567 return $job;
568 },
569 $this->jobs->query_rows(
570 array_merge(
571 $filters,
572 array(
573 'limit' => $per_page,
574 'page' => $page,
575 // Newest first: the job an admin opens the queue to check
576 // on is the one that just fired, not the oldest survivor.
577 'order' => 'DESC',
578 )
579 )
580 )
581 );
582
583 // One grouped query covers all status counts and every printer's
584 // backlog — the view refreshes every 30 s, so summary cost must not
585 // scale with printer count.
586 $summary = $this->jobs->status_summary();
587
588 $counts = array();
589 foreach ( array(
590 Print_Job_Service::STATUS_PENDING,
591 Print_Job_Service::STATUS_CLAIMED,
592 Print_Job_Service::STATUS_PRINTED,
593 Print_Job_Service::STATUS_FAILED,
594 Print_Job_Service::STATUS_CANCELLED,
595 ) as $status ) {
596 $counts[ $status ] = 0;
597 foreach ( $summary as $per_status ) {
598 $counts[ $status ] += isset( $per_status[ $status ] ) ? $per_status[ $status ]['count'] : 0;
599 }
600 }
601 $counts['failed_unresolved'] = 0;
602 foreach ( $summary as $per_status ) {
603 if ( isset( $per_status[ Print_Job_Service::STATUS_FAILED ] ) ) {
604 $counts['failed_unresolved'] += $per_status[ Print_Job_Service::STATUS_FAILED ]['unresolved_count'];
605 }
606 }
607
608 $printers = array();
609 foreach ( $this->registry->get_printers() as $printer ) {
610 $printer_id = (string) ( $printer['id'] ?? '' );
611 if ( '' === $printer_id ) {
612 continue;
613 }
614 // Waiting = pending + claimed: a printer that fetched a job and
615 // then died leaves it claimed forever with zero pending — that
616 // backlog must still trip the stale banner.
617 $waiting = 0;
618 $oldest = '';
619 foreach ( array( Print_Job_Service::STATUS_PENDING, Print_Job_Service::STATUS_CLAIMED ) as $status ) {
620 if ( ! isset( $summary[ $printer_id ][ $status ] ) ) {
621 continue;
622 }
623 $waiting += $summary[ $printer_id ][ $status ]['count'];
624 $created = $summary[ $printer_id ][ $status ]['oldest_gmt'];
625 if ( '' !== $created && ( '' === $oldest || $created < $oldest ) ) {
626 $oldest = $created;
627 }
628 }
629 $printers[] = array(
630 'printer_id' => $printer_id,
631 'name' => (string) ( $printer['name'] ?? $printer_id ),
632 // Push providers (PrintNode, Star Online) never poll, so
633 // last-seen staleness is meaningless for them — the UI must
634 // not show a "never fetched" banner. A missing provider
635 // defaults to star-cloudprnt exactly like the print path, so
636 // legacy rows without the field keep their stale warnings.
637 'polling' => Provider::is_polling(
638 Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null )
639 ),
640 'pending' => $waiting,
641 'oldest_pending_gmt' => $oldest,
642 'last_seen' => $this->registry->get_seen( $printer_id ),
643 );
644 }
645
646 return rest_ensure_response(
647 array(
648 'jobs' => $jobs,
649 'total' => $this->jobs->count( $filters ),
650 'page' => $page,
651 'per_page' => $per_page,
652 'summary' => array(
653 'counts' => $counts,
654 'printers' => $printers,
655 ),
656 )
657 );
658 }
659
660 /**
661 * Bulk-cancel waiting jobs by explicit ids or for a whole printer.
662 *
663 * @param WP_REST_Request $request Request.
664 *
665 * @return \WP_REST_Response
666 */
667 public function cancel_queue( $request ) {
668 $ids = $request->get_param( 'ids' );
669 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
670
671 $cancelled = $this->jobs->cancel_waiting(
672 array(
673 'ids' => \is_array( $ids ) ? $ids : array(),
674 'printer_id' => $printer_id,
675 )
676 );
677
678 return rest_ensure_response( array( 'cancelled' => $cancelled ) );
679 }
680
681 /**
682 * Permanently delete queue rows.
683 *
684 * Unlike cancel_queue(), this removes history: any status may be deleted,
685 * because the point is clearing a queue the admin no longer wants to look
686 * at rather than stopping work. Waiting jobs are cancelled on the way out
687 * so nothing is left half-claimed.
688 *
689 * @param WP_REST_Request $request Request.
690 *
691 * @return \WP_REST_Response|WP_Error
692 */
693 public function delete_queue( $request ) {
694 $ids = $request->get_param( 'ids' );
695 if ( ! \is_array( $ids ) || empty( $ids ) ) {
696 return new WP_Error(
697 'wcpos_print_job_no_ids',
698 __( 'No print jobs were selected.', 'woocommerce-pos' ),
699 array( 'status' => 400 )
700 );
701 }
702 foreach ( $ids as $id ) {
703 if ( ( ! \is_int( $id ) && ! \is_string( $id ) ) || ! ctype_digit( (string) $id ) || (int) $id < 1 ) {
704 return new WP_Error(
705 'wcpos_print_job_invalid_ids',
706 __( 'One or more selected print jobs are invalid.', 'woocommerce-pos' ),
707 array( 'status' => 400 )
708 );
709 }
710 }
711
712 $deleted = 0;
713 foreach ( array_map( 'intval', $ids ) as $id ) {
714 if ( $id > 0 && $this->jobs->delete( $id ) ) {
715 ++$deleted;
716 }
717 }
718
719 return rest_ensure_response( array( 'deleted' => $deleted ) );
720 }
721
722 /**
723 * Reprint a print job by copying it to a new pending job.
724 *
725 * @param WP_REST_Request $request Request.
726 *
727 * @return \WP_REST_Response|WP_Error
728 */
729 public function reprint_item( $request ) {
730 $source = $this->jobs->get( (int) $request->get_param( 'id' ) );
731 if ( null === $source ) {
732 return new WP_Error(
733 'wcpos_print_job_not_found',
734 __( 'Print job not found.', 'woocommerce-pos' ),
735 array( 'status' => 404 )
736 );
737 }
738 if ( $source['retried_to'] > 0 ) {
739 return new WP_Error(
740 'wcpos_print_job_already_retried',
741 __( 'This print job has already been retried.', 'woocommerce-pos' ),
742 array(
743 'status' => 409,
744 'retried_to' => $source['retried_to'],
745 )
746 );
747 }
748 if ( '' === $source['payload'] && '' === $source['template_id'] ) {
749 // A stripped raw job has nothing left to print — refuse loudly
750 // rather than queue a blank receipt.
751 return new WP_Error(
752 'wcpos_print_job_source_expired',
753 __( 'This job\'s stored receipt has been cleaned up and it has no template to re-render from.', 'woocommerce-pos' ),
754 array( 'status' => 410 )
755 );
756 }
757 $content_type = $source['content_type'];
758 $pn_kind = $source['pn_kind'];
759 if ( '' !== $source['template_id'] ) {
760 $template = Print_Job_Service::load_template( (string) $source['template_id'] );
761 if ( null === $template && $source['order_id'] > 0 ) {
762 // render_payload() takes its template branch on
763 // order_id + template_id and returns nothing when the
764 // template is gone — the stored payload is never reached.
765 // Queueing here would 201 a job that can only ever fail,
766 // so refuse for the same reason the stripped-payload guard
767 // above does.
768 return new WP_Error(
769 'wcpos_print_job_source_expired',
770 __( 'This job\'s template no longer exists, so it cannot be re-rendered.', 'woocommerce-pos' ),
771 array( 'status' => 410 )
772 );
773 }
774 $printer = $this->registry->get_printer( (string) $source['printer_id'] );
775 if ( null !== $printer ) {
776 // Refresh both halves of the pairing together. A legacy job can
777 // carry a media type from before the provider declared its own,
778 // but content_type and pn_kind must keep agreeing: reprinting a
779 // raw (escpos) PrintNode job through the printer-only resolver
780 // relabels it application/pdf in the queue view, even though
781 // submit still sends raw bytes off the stored pn_kind.
782 $resolver = new Print_Format_Resolver();
783 $fmt = null === $template ? array( 'kind' => '' ) : $resolver->resolve( $printer, $template );
784 if ( '' === (string) $fmt['kind'] ) {
785 // No loadable template, or one this printer can no longer
786 // render. Refresh from the provider's declared type only
787 // when no stored kind can contradict it; otherwise the
788 // source pairing is the best answer left.
789 if ( '' === $pn_kind ) {
790 $content_type = $resolver->content_type_for_printer( $printer );
791 }
792 } else {
793 $content_type = $fmt['content_type'];
794 $pn_kind = Provider::stores_job_kind( Provider::normalize( (string) ( $printer['provider'] ?? '' ) ) )
795 ? $fmt['kind']
796 : '';
797 }
798 }
799 }
800 $new_id = $this->jobs->create(
801 array(
802 'printer_id' => $source['printer_id'],
803 'content_type' => $content_type,
804 'payload' => $source['payload'],
805 'order_id' => $source['order_id'] ? $source['order_id'] : null,
806 'format' => $source['format'] ? $source['format'] : null,
807 // Template-backed jobs (auto-print) carry no stored payload —
808 // the render metadata must survive the copy or the reprint
809 // renders nothing.
810 'template_id' => '' !== $source['template_id'] ? $source['template_id'] : null,
811 'pn_kind' => '' !== $pn_kind ? $pn_kind : null,
812 'auto_open_drawer' => $source['auto_open_drawer'],
813 'drawer_connector' => $source['drawer_connector'],
814 )
815 );
816 if ( $new_id <= 0 ) {
817 return new WP_Error(
818 'wcpos_print_job_create_failed',
819 __( 'Print job could not be created.', 'woocommerce-pos' ),
820 array( 'status' => 500 )
821 );
822 }
823 if ( Print_Job_Service::STATUS_FAILED === $source['status'] && ! $this->jobs->mark_retried( (int) $source['id'], $new_id ) ) {
824 wp_delete_post( $new_id, true );
825
826 return new WP_Error(
827 'wcpos_print_job_retry_failed',
828 __( 'Print job retry could not be recorded.', 'woocommerce-pos' ),
829 array( 'status' => 500 )
830 );
831 }
832
833 // Push providers (PrintNode, Star Online) never poll the queue — their
834 // jobs only move when CRON_SUBMIT fires. Without this the replacement
835 // job stays pending forever and Retry silently does nothing.
836 $printer = $this->registry->get_printer( (string) $source['printer_id'] );
837 $provider = null !== $printer ? (string) ( $printer['provider'] ?? '' ) : '';
838 if ( Provider::requires_submit( $provider ) ) {
839 wp_schedule_single_event( time(), Cloud_Print_Trigger_Service::CRON_SUBMIT, array( $new_id ) );
840 }
841
842 $response = rest_ensure_response( $this->jobs->get( $new_id ) );
843 $response->set_status( 201 );
844
845 return $response;
846 }
847
848
849 /**
850 * Star CloudPRNT poll/fetch/confirm endpoint.
851 *
852 * @param WP_REST_Request $request Request.
853 *
854 * @return \WP_REST_Response|WP_Error
855 */
856 public function cloudprnt( $request ) {
857 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
858 $this->registry->record_seen( $printer_id );
859 $this->jobs->release_stale_claims( $printer_id );
860
861 if ( 'POST' === $request->get_method() ) {
862 return $this->cloudprnt_poll( $request, $printer_id );
863 }
864
865 $job = $this->get_cloud_job_for_request( $request, $printer_id );
866 if ( is_wp_error( $job ) ) {
867 return $job;
868 }
869
870 if ( 'DELETE' === $request->get_method() ) {
871 $code = sanitize_text_field( (string) $request->get_param( 'code' ) );
872 $status = '' === $code || '000' === $code || 1 === preg_match( '/^2\d{2,3}(?:\s|$)/', $code ) ? Print_Job_Service::STATUS_PRINTED : Print_Job_Service::STATUS_FAILED;
873 $this->jobs->record_printer_result( (int) $job['id'], Print_Job_Service::STATUS_PRINTED === $status );
874
875 if ( Print_Job_Service::STATUS_FAILED === $status ) {
876 $this->log_printer_failure( $request, $printer_id, $code, (int) $job['id'] );
877 }
878
879 return rest_ensure_response( array( 'ok' => true ) );
880 }
881
882 return $this->cloudprnt_fetch( $request, $printer_id, $job );
883 }
884
885 /**
886 * Answer a CloudPRNT poll: offer a job, and ask what the printer can decode.
887 *
888 * The poll body is the printer's half of the conversation. `printingInProgress`
889 * says a job is still on the paper, and the spec is explicit that the server
890 * must not offer another one until it clears — doing so risks the printer
891 * dropping the second job. `clientAction` carries the printer's answers to
892 * questions asked in an earlier poll response, which is the only way the
893 * protocol exposes what formats the hardware can decode.
894 *
895 * @param WP_REST_Request $request Request.
896 * @param string $printer_id Printer ID.
897 *
898 * @return \WP_REST_Response
899 */
900 private function cloudprnt_poll( WP_REST_Request $request, string $printer_id ) {
901 $poll = Cloud_Print_Poll_Request::from_body( (string) $request->get_body(), $request->get_json_params() );
902 $this->registry->record_capabilities( $printer_id, $poll->answers(), $poll->status_code() );
903
904 $response = array( 'jobReady' => false );
905
906 if ( ! $poll->printing_in_progress() && ! $this->jobs->find_active_claim( $printer_id ) ) {
907 $job = $this->jobs->next_pending( $printer_id );
908 if ( null !== $job ) {
909 // The offer is a list: the printer picks its preferred decodable
910 // entry and names it in the fetch's `?type`. Nothing decodable in
911 // the list means no GET at all, just a 510 confirmation — so the
912 // list is filtered by what this printer said it can decode.
913 // `mediaType` (singular) is kept for older firmware.
914 $media_types = $this->media_types_for_job( $job, $printer_id );
915 $response = array(
916 'jobReady' => true,
917 'jobToken' => (string) $job['id'],
918 'mediaType' => $media_types[0],
919 'mediaTypes' => array_values( $media_types ),
920 );
921 }
922 }
923
924 if ( $this->registry->should_request_capabilities( $printer_id ) ) {
925 $response['clientAction'] = array(
926 array( 'request' => 'ClientType' ),
927 array( 'request' => 'Encodings' ),
928 );
929 $this->registry->record_capability_request( $printer_id );
930 }
931
932 return rest_ensure_response( $response );
933 }
934
935 /**
936 * Serve a CloudPRNT job in the media type the printer asked for.
937 *
938 * @param WP_REST_Request $request Request.
939 * @param string $printer_id Printer ID.
940 * @param array $job Job array.
941 *
942 * @return \WP_REST_Response|WP_Error
943 */
944 private function cloudprnt_fetch( WP_REST_Request $request, string $printer_id, array $job ) {
945 // The fetch GET names the printer's chosen media type. A type the server
946 // cannot produce is answered with 415 (per the CloudPRNT spec) and the job
947 // is left unclaimed. What is servable is deliberately wider than what the
948 // poll advertised: the printer naming a type is a stronger signal than our
949 // cached capability answer, so a capability update landing between the two
950 // requests must not reject a format we had just offered. Firmware that
951 // omits the parameter gets our best offer for this printer instead. The
952 // logged value is length-capped: printers poll every few seconds, so a
953 // wedged loop must not flood the log with unbounded input.
954 $servable = ( new Cloud_Print_Media_Types() )->servable_for_job( $job, $this->registry->get_printer( $printer_id ) );
955 $requested = sanitize_text_field( (string) $request->get_param( 'type' ) );
956 $chosen = '' === $requested
957 ? $this->media_types_for_job( $job, $printer_id )[0]
958 : Cloud_Print_Media_Types::match( $requested, $servable );
959
960 if ( '' === $chosen ) {
961 Logger::warning(
962 sprintf(
963 '%s: printer "%s" requested media type "%s" for print job %d, which the server can only serve as %s.',
964 $request->get_route(),
965 $printer_id,
966 substr( $requested, 0, 100 ),
967 (int) $job['id'],
968 implode( ', ', $servable )
969 )
970 );
971
972 return new WP_Error(
973 'wcpos_print_job_incompatible_media_type',
974 __( 'The print job is not available in the requested media type.', 'woocommerce-pos' ),
975 array( 'status' => 415 )
976 );
977 }
978
979 if ( ! $this->jobs->try_claim( (int) $job['id'] ) ) {
980 return rest_ensure_response( array( 'jobReady' => false ) );
981 }
982
983 $render = $this->jobs->render_job( $job, $chosen );
984 if ( '' === $render['body'] ) {
985 Logger::error(
986 sprintf(
987 '%s: print job %d rendered an empty payload for printer "%s".',
988 $request->get_route(),
989 (int) $job['id'],
990 $printer_id
991 )
992 );
993 }
994
995 return $this->serve_raw( $render['body'], $chosen, self::control_headers( $chosen, $render ) );
996 }
997
998 /**
999 * The media types a CloudPRNT job can be served in, best first.
1000 *
1001 * @param array $job Job array.
1002 * @param string $printer_id Printer ID.
1003 *
1004 * @return array<int, string>
1005 */
1006 private function media_types_for_job( array $job, string $printer_id ): array {
1007 $capabilities = $this->registry->get_capabilities( $printer_id );
1008
1009 return ( new Cloud_Print_Media_Types() )->for_job(
1010 $job,
1011 $this->registry->get_printer( $printer_id ),
1012 $capabilities['encodings']
1013 );
1014 }
1015
1016 /**
1017 * Peripheral-control headers for a job served in a command-free format.
1018 *
1019 * `text/plain` and images carry no cut or drawer commands, so CloudPRNT reads
1020 * them off the fetch response instead. Command formats express both in-band
1021 * and must not also be told to cut, or the receipt cuts twice.
1022 *
1023 * Both headers are always sent, `none` included. Omitting them leaves the
1024 * decision to the printer's own defaults, which cut plain-text jobs — so a
1025 * template that deliberately does not cut would cut anyway, and would behave
1026 * differently in text than in StarPRNT. Saying `none` out loud keeps the two
1027 * formats rendering the same receipt.
1028 *
1029 * @param string $media_type The media type being served.
1030 * @param array $render Render result from Print_Job_Service::render_job().
1031 *
1032 * @return array<string, string>
1033 */
1034 private static function control_headers( string $media_type, array $render ): array {
1035 if ( ! Cloud_Print_Media_Types::is_header_controlled( $media_type ) ) {
1036 return array();
1037 }
1038
1039 $headers = array(
1040 'X-Star-Cut' => null === $render['cut'] ? 'none' : (string) $render['cut'],
1041 'X-Star-CashDrawer' => null === $render['drawer'] ? 'none' : (string) $render['drawer'],
1042 );
1043
1044 // The raster is already two-colour, so the printer's Floyd-Steinberg
1045 // default would dither an image that has nothing left to dither —
1046 // softening crisp black-on-white text into stipple.
1047 if ( Cloud_Print_Media_Types::PNG === Cloud_Print_Media_Types::normalize( $media_type ) ) {
1048 $headers['X-Star-ImageDitherPattern'] = 'none';
1049 }
1050
1051 return $headers;
1052 }
1053
1054 /**
1055 * Permission check for printer-token routes.
1056 *
1057 * @param WP_REST_Request $request Request.
1058 *
1059 * @return bool|WP_Error
1060 */
1061 public function printer_token_permissions_check( $request ) {
1062 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
1063 $token = (string) $request->get_param( 'pt' );
1064
1065 if ( ! $this->registry->verify_token( $printer_id, $token ) ) {
1066 Logger::warning(
1067 sprintf(
1068 '%s: authentication failed for printer "%s".',
1069 $request->get_route(),
1070 $printer_id
1071 )
1072 );
1073
1074 return new WP_Error(
1075 'wcpos_print_job_invalid_token',
1076 __( 'Invalid printer token.', 'woocommerce-pos' ),
1077 array( 'status' => 401 )
1078 );
1079 }
1080
1081 return true;
1082 }
1083
1084 /**
1085 * Resolve and authorize a CloudPRNT job token.
1086 *
1087 * @param WP_REST_Request $request Request.
1088 * @param string $printer_id Printer ID.
1089 *
1090 * @return array|WP_Error
1091 */
1092 private function get_cloud_job_for_request( WP_REST_Request $request, string $printer_id ) {
1093 $job_id = (int) $request->get_param( 'token' );
1094 $job = $this->jobs->get( $job_id );
1095 if ( null === $job || $printer_id !== $job['printer_id'] ) {
1096 Logger::warning(
1097 sprintf(
1098 '%s: print job "%d" was not found for printer "%s".',
1099 $request->get_route(),
1100 $job_id,
1101 $printer_id
1102 )
1103 );
1104
1105 return new WP_Error(
1106 'wcpos_print_job_not_found',
1107 __( 'Print job not found.', 'woocommerce-pos' ),
1108 array( 'status' => 404 )
1109 );
1110 }
1111
1112 return $job;
1113 }
1114
1115 /**
1116 * Log a printer-reported failure without request credentials or payloads.
1117 *
1118 * @param WP_REST_Request $request Request.
1119 * @param string $printer_id Printer ID.
1120 * @param string $code Failure code.
1121 * @param int $job_id Print job ID.
1122 * @param int|null $status Epson ePOS response status bitmask, when the result carried one.
1123 */
1124 private function log_printer_failure( WP_REST_Request $request, string $printer_id, string $code, int $job_id, ?int $status = null ): void {
1125 // One detail string serves both the stored reason and the log line, and it
1126 // keeps the raw hex even when no bit decodes, so an unmapped or
1127 // model-specific status can still be read back from a screenshot.
1128 $flags = null === $status ? '' : implode( ', ', self::describe_epson_status( $status ) );
1129 $detail = null === $status ? '' : sprintf( ' (0x%08X%s)', $status, '' === $flags ? '' : ': ' . $flags );
1130 $reason = $code . $detail;
1131
1132 // Persist it too, not just log it. Push providers already record their
1133 // submission error against the job; polling printers report theirs here,
1134 // and without this the queue can only ever say "Failed" while the reason
1135 // (EX_TIMEOUT, a Star status code) is buried in the log. The decoded
1136 // Epson status bits are what turn "EX_TIMEOUT" into "cover open".
1137 if ( '' !== $reason ) {
1138 update_post_meta( $job_id, Print_Job_Service::META_ERROR, sanitize_text_field( $reason ) );
1139 }
1140
1141 Logger::error(
1142 sprintf(
1143 '%s: printer "%s" reported failure code "%s"%s for print job %d.',
1144 $request->get_route(),
1145 $printer_id,
1146 $code,
1147 $detail,
1148 $job_id
1149 )
1150 );
1151 }
1152
1153 /**
1154 * Decode an Epson ePOS-Print response status bitmask.
1155 *
1156 * @param int $status Decimal ASB status bitmask.
1157 *
1158 * @return array<int, string>
1159 */
1160 private static function describe_epson_status( int $status ): array {
1161 // Epson ePOS-Print XML User's Manual, response `status` table (ASB bits).
1162 // Fault bits only: informational ones (print complete, drawer pin, feed
1163 // button, panel switch, buzzer) say nothing about why a print failed.
1164 $labels = array(
1165 0x00000001 => __( 'no response from printer', 'woocommerce-pos' ),
1166 0x00000008 => __( 'offline', 'woocommerce-pos' ),
1167 0x00000020 => __( 'cover open', 'woocommerce-pos' ),
1168 0x00000100 => __( 'waiting for online recovery', 'woocommerce-pos' ),
1169 0x00000400 => __( 'mechanical error', 'woocommerce-pos' ),
1170 0x00000800 => __( 'autocutter error', 'woocommerce-pos' ),
1171 0x00002000 => __( 'unrecoverable error', 'woocommerce-pos' ),
1172 0x00004000 => __( 'auto-recoverable error', 'woocommerce-pos' ),
1173 0x00020000 => __( 'paper near end', 'woocommerce-pos' ),
1174 0x00080000 => __( 'paper end', 'woocommerce-pos' ),
1175 0x80000000 => __( 'spooler stopped', 'woocommerce-pos' ),
1176 );
1177
1178 $descriptions = array();
1179 foreach ( $labels as $bit => $label ) {
1180 if ( 0 !== ( $status & $bit ) ) {
1181 $descriptions[] = $label;
1182 }
1183 }
1184
1185 return $descriptions;
1186 }
1187
1188 /**
1189 * Serve raw bytes from a REST callback.
1190 *
1191 * @param string $body Response body.
1192 * @param string $content_type Content type.
1193 * @param array<string, string> $headers Extra response headers.
1194 *
1195 * @return \WP_REST_Response
1196 */
1197 private function serve_raw( string $body, string $content_type, array $headers = array() ) {
1198 return Raw_Response::serve( $body, $content_type, $headers );
1199 }
1200
1201
1202 /**
1203 * Epson Server Direct Print poll/result endpoint.
1204 *
1205 * @param WP_REST_Request $request Request.
1206 *
1207 * @return \WP_REST_Response
1208 */
1209 public function epson_sdp( $request ) {
1210 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
1211 $this->registry->record_seen( $printer_id );
1212 $raw_body = (string) $request->get_body();
1213 $soap = 'text/xml; charset=utf-8';
1214 $ack = '<response success="true" code="" status=""/>';
1215
1216 $this->jobs->release_stale_claims( $printer_id );
1217
1218 // Server Direct Print multiplexes three different request types onto the
1219 // one configured URL, as URL-encoded form data distinguished by
1220 // ConnectionType (User's Manual Rev.K, ch.3 and the Test_print.php
1221 // reference implementation):
1222 //
1223 // GetRequest — poll for a job
1224 // SetResponse — printing result; the XML rides in the ResponseFile field
1225 // SetStatus — status notification; the XML rides in the Status field
1226 //
1227 // Answering a status notification with print data hands the job to a
1228 // request that discards it, so the printer never prints and the job stays
1229 // claimed. Dispatching on ConnectionType is what keeps the job on the
1230 // GetRequest that is actually asking for one.
1231 $connection_type = (string) $request->get_param( 'ConnectionType' );
1232
1233 // Result XML is a form field, so in the raw body it is percent-encoded
1234 // (`<response` arrives as `%3Cresponse`) and a raw-body substring test can
1235 // never match it. The raw-body branch is kept only for a caller that posts
1236 // the bare XML, which the printer never does.
1237 $result_xml = (string) $request->get_param( 'ResponseFile' );
1238 if ( '' === $result_xml && false !== strpos( $raw_body, '<response' ) ) {
1239 $result_xml = $raw_body;
1240 }
1241
1242 // A print result is recognised by its ePOS <response> element, whichever
1243 // way it arrived — the bare-XML caller sends no ConnectionType, so the
1244 // type is deliberately not consulted here. The printer also posts an
1245 // empty <PrintResponseInfo/> after every idle poll; reading that as a
1246 // result marked the in-flight job failed ("unknown"). It is acked below
1247 // and never dispatched on.
1248 if ( false !== strpos( $result_xml, '<response' ) ) {
1249 $claim = $this->jobs->find_active_claim( $printer_id );
1250 if ( null === $claim ) {
1251 // A result that arrives after the claim timed out belongs to the job
1252 // that was failed as unconfirmed — record it there instead of
1253 // dropping it, so a printer that merely reported late shows the truth.
1254 $claim = $this->jobs->find_unconfirmed( $printer_id );
1255 } else {
1256 // A result carries no job token — printjobid is SDP 2.00 only — so it
1257 // can only be attributed to the active claim. A printer holding an
1258 // unsent result retries that POST before polling for more work, so a
1259 // result should not arrive while an earlier job is still awaiting one.
1260 // Log it if it ever does: the attribution below would be the wrong job.
1261 $unconfirmed = $this->jobs->find_unconfirmed( $printer_id );
1262 if ( null !== $unconfirmed ) {
1263 Logger::warning(
1264 sprintf(
1265 'Printer "%s": result recorded against claimed job %d while unconfirmed job %d is still awaiting one.',
1266 $printer_id,
1267 (int) $claim['id'],
1268 (int) $unconfirmed['id']
1269 )
1270 );
1271 }
1272 }
1273 if ( null !== $claim ) {
1274 $ok = false !== strpos( $result_xml, 'success="true"' );
1275 $this->jobs->record_printer_result( (int) $claim['id'], $ok );
1276
1277 if ( ! $ok ) {
1278 $code = 'unknown';
1279 if ( 1 === preg_match( '/\bcode="([^"]*)"/', $result_xml, $matches ) ) {
1280 $code = sanitize_text_field( $matches[1] );
1281 }
1282
1283 $status = null;
1284 if ( 1 === preg_match( '/\bstatus="(\d+)"/', $result_xml, $matches ) ) {
1285 // The ASB status is unsigned 32-bit; on a 32-bit PHP build a value
1286 // with bit 31 set does not fit and (int) would saturate to
1287 // 0x7FFFFFFF, decoding every label. Such a value is left undecoded
1288 // rather than misdecoded.
1289 $status = (float) $matches[1] <= PHP_INT_MAX ? (int) $matches[1] : null;
1290 }
1291
1292 $this->log_printer_failure( $request, $printer_id, $code, (int) $claim['id'], $status );
1293 }
1294 }
1295
1296 return $this->serve_raw( $ack, $soap );
1297 }
1298
1299 // A status notification or a result post — typed, or recognisable only by
1300 // its ResponseFile — is not asking for work. Only the GetRequest lane below
1301 // (and a legacy untyped poll) may be handed a job.
1302 if ( 'SetStatus' === $connection_type || 'SetResponse' === $connection_type || '' !== $result_xml ) {
1303 return $this->serve_raw( $ack, $soap );
1304 }
1305
1306 if ( null !== $this->jobs->find_active_claim( $printer_id ) ) {
1307 return $this->serve_raw( $ack, $soap );
1308 }
1309
1310 $job = $this->jobs->next_pending( $printer_id );
1311 if ( null === $job ) {
1312 return $this->serve_raw( $ack, $soap );
1313 }
1314
1315 if ( ! $this->jobs->try_claim( (int) $job['id'] ) ) {
1316 return $this->serve_raw( $ack, $soap );
1317 }
1318 $epos = $this->jobs->render_payload( $job );
1319
1320 // An empty render means the job produced nothing printable — most often
1321 // a template whose engine this provider cannot render (Server Direct
1322 // Print only speaks the thermal pipeline's ePOS-Print XML). Dispatching
1323 // it anyway sends <PrintData></PrintData>: the printer parses that
1324 // happily, prints nothing, and posts back success="true", so the job is
1325 // recorded as Printed. Fail the job here instead, so the queue shows the
1326 // truth and the log names the printer.
1327 if ( '' === $epos ) {
1328 Logger::error(
1329 sprintf(
1330 '%s: print job %d rendered an empty payload for printer "%s"; nothing was sent to the printer.',
1331 $request->get_route(),
1332 (int) $job['id'],
1333 $printer_id
1334 )
1335 );
1336 update_post_meta( (int) $job['id'], Print_Job_Service::META_ERROR, 'empty_rendered_payload' );
1337 $this->jobs->set_status( (int) $job['id'], Print_Job_Service::STATUS_FAILED );
1338
1339 return $this->serve_raw( $ack, $soap );
1340 }
1341
1342 // Server Direct Print expects the print data wrapped in
1343 // PrintRequestInfo > ePOSPrint > PrintData — NOT the SOAP envelope used
1344 // by the direct ePOS-Print web service, which is a different protocol.
1345 // A printer that receives an unrecognised wrapper discards it silently:
1346 // it neither prints nor posts a result, so the job sits claimed forever.
1347 // Version 1.00 is the only version every SDP printer family supports
1348 // (Server Direct Print User's Manual Rev.K, "Response (Print request)");
1349 // 2.00+ adds printjobid but is limited to TM-i/TM-DT/TM-T88VI.
1350 $envelope = '<?xml version="1.0" encoding="utf-8"?>';
1351 $envelope .= '<PrintRequestInfo Version="1.00"><ePOSPrint>';
1352 $envelope .= '<Parameter><devid>local_printer</devid><timeout>' . self::EPSON_SDP_PRINT_TIMEOUT_MS . '</timeout></Parameter>';
1353 $envelope .= '<PrintData>' . $epos . '</PrintData>';
1354 $envelope .= '</ePOSPrint></PrintRequestInfo>';
1355
1356 return $this->serve_raw( $envelope, $soap );
1357 }
1358
1359 /**
1360 * Enqueue a print job (raw payload or order-based).
1361 *
1362 * @param WP_REST_Request $request Request.
1363 *
1364 * @return \WP_REST_Response|WP_Error
1365 */
1366 public function create_item( $request ) {
1367 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
1368 if ( '' === $printer_id ) {
1369 return new WP_Error(
1370 'wcpos_print_job_missing_printer',
1371 __( 'A printer_id is required.', 'woocommerce-pos' ),
1372 array( 'status' => 400 )
1373 );
1374 }
1375
1376 $payload = (string) $request->get_param( 'payload' );
1377 $format = (string) $request->get_param( 'format' );
1378 $template_id = sanitize_text_field( (string) $request->get_param( 'template_id' ) );
1379 $order_id = (int) $request->get_param( 'order_id' );
1380 $drawer_options = $this->drawer_options_from_request( $request );
1381
1382 $printer = $this->registry->get_printer( $printer_id );
1383 $is_template_job = 0 !== $order_id && '' !== $template_id;
1384 $validation = $this->validate_job_for_printer( $printer, $payload, $format, $is_template_job );
1385 if ( is_wp_error( $validation ) ) {
1386 return $validation;
1387 }
1388
1389 $provider = null !== $printer ? (string) ( $printer['provider'] ?? '' ) : '';
1390
1391 // PrintNode never polls, so a raw payload could never be delivered — a
1392 // PrintNode job must be order-based (rendered + submitted out-of-band).
1393 if ( 'printnode' === $provider && ( 0 === $order_id || '' === $template_id ) ) {
1394 return new WP_Error(
1395 'wcpos_print_job_printnode_requires_template',
1396 __( 'PrintNode print jobs require an order and a template.', 'woocommerce-pos' ),
1397 array( 'status' => 400 )
1398 );
1399 }
1400
1401 // Order-based job: render server-side from the order + template, deriving
1402 // the wire format from the printer's provider (shared with the auto-print
1403 // trigger). Star/Epson are fetched on poll; PrintNode is submitted.
1404 if ( 0 !== $order_id && '' !== $template_id ) {
1405 if ( null === $printer ) {
1406 // Without a known printer there is no provider to render for, and
1407 // the job could never be polled/submitted — fail loudly rather
1408 // than enqueue a job that silently never prints.
1409 return new WP_Error(
1410 'wcpos_print_job_unknown_printer',
1411 __( 'Unknown printer.', 'woocommerce-pos' ),
1412 array( 'status' => 404 )
1413 );
1414 }
1415
1416 return $this->create_order_job( $printer_id, $printer, $order_id, $template_id, $drawer_options );
1417 }
1418
1419 $id = $this->jobs->create(
1420 array(
1421 'printer_id' => $printer_id,
1422 'content_type' => (string) $request->get_param( 'content_type' ),
1423 'payload' => $payload,
1424 'order_id' => $order_id,
1425 'format' => $format,
1426 )
1427 );
1428 if ( $id <= 0 ) {
1429 return new WP_Error(
1430 'wcpos_print_job_create_failed',
1431 __( 'Print job could not be created.', 'woocommerce-pos' ),
1432 array( 'status' => 500 )
1433 );
1434 }
1435
1436 $response = rest_ensure_response( $this->jobs->get( $id ) );
1437 $response->set_status( 201 );
1438
1439 return $response;
1440 }
1441
1442 /**
1443 * Enqueue an order-based job, deriving the wire format from the printer's
1444 * provider via the shared trigger-service helper.
1445 *
1446 * @param string $printer_id Registered printer id.
1447 * @param array $printer Registered printer config.
1448 * @param int $order_id Order id to render.
1449 * @param string $template_id Template id (numeric) or virtual slug.
1450 * @param array $drawer_options Drawer options.
1451 *
1452 * @return \WP_REST_Response|WP_Error
1453 */
1454 private function create_order_job( string $printer_id, array $printer, int $order_id, string $template_id, array $drawer_options = array() ) {
1455 if ( ! wc_get_order( $order_id ) ) {
1456 // Surface the bad order up front rather than enqueue a job that
1457 // render_payload() can only ever resolve to an empty (never-printing) payload.
1458 return new WP_Error(
1459 'wcpos_print_job_unknown_order',
1460 __( 'Unknown order.', 'woocommerce-pos' ),
1461 array( 'status' => 404 )
1462 );
1463 }
1464
1465 $template = Print_Job_Service::load_template( $template_id );
1466 if ( null === $template ) {
1467 return new WP_Error(
1468 'wcpos_print_job_unknown_template',
1469 __( 'Unknown template.', 'woocommerce-pos' ),
1470 array( 'status' => 400 )
1471 );
1472 }
1473
1474 $id = Cloud_Print_Trigger_Service::enqueue_order_job(
1475 $this->jobs,
1476 $printer_id,
1477 $printer,
1478 $order_id,
1479 $template_id,
1480 $template,
1481 $drawer_options
1482 );
1483 if ( $id <= 0 ) {
1484 return new WP_Error(
1485 'wcpos_print_job_template_not_printable',
1486 __( 'The selected template cannot be printed on this printer.', 'woocommerce-pos' ),
1487 array( 'status' => 400 )
1488 );
1489 }
1490
1491 $response = rest_ensure_response( $this->jobs->get( $id ) );
1492 $response->set_status( 201 );
1493
1494 return $response;
1495 }
1496
1497 /**
1498 * Enqueue a diagnostic test print for a registered printer.
1499 *
1500 * @param WP_REST_Request $request Request.
1501 *
1502 * @return \WP_REST_Response|WP_Error
1503 */
1504 public function test_print( $request ) {
1505 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
1506 $printer = $this->registry->get_printer( $printer_id );
1507 if ( null === $printer ) {
1508 return new WP_Error(
1509 'wcpos_print_job_unknown_printer',
1510 __( 'Unknown printer.', 'woocommerce-pos' ),
1511 array( 'status' => 404 )
1512 );
1513 }
1514
1515 // Legacy printer rows saved before the provider field existed must test
1516 // as the default provider, not fall through to the no-diagnostic error.
1517 $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
1518
1519 if ( 'printnode' === $provider ) {
1520 return $this->test_print_printnode( $printer );
1521 }
1522
1523 if ( 'star-online' === $provider ) {
1524 return $this->test_print_star_online( $printer_id, $printer );
1525 }
1526
1527 try {
1528 $diag = ( new Cloud_Print_Diagnostic() )->build( $provider, (string) $printer['name'] );
1529 } catch ( \RuntimeException $e ) {
1530 return new WP_Error(
1531 'wcpos_print_job_no_diagnostic',
1532 __( 'Test print is not available for this printer yet.', 'woocommerce-pos' ),
1533 array( 'status' => 400 )
1534 );
1535 }
1536
1537 $id = $this->jobs->create(
1538 array(
1539 'printer_id' => $printer_id,
1540 'content_type' => $diag['content_type'],
1541 'payload' => $diag['payload'],
1542 )
1543 );
1544 if ( $id <= 0 ) {
1545 return new WP_Error(
1546 'wcpos_print_job_create_failed',
1547 __( 'Print job could not be created.', 'woocommerce-pos' ),
1548 array( 'status' => 500 )
1549 );
1550 }
1551
1552 $response = rest_ensure_response( $this->jobs->get( $id ) );
1553 $response->set_status( 201 );
1554
1555 return $response;
1556 }
1557
1558 /**
1559 * Queue a Star Markup test receipt and submit it through the push pipeline.
1560 *
1561 * @param string $printer_id Registered printer id.
1562 * @param array $printer Registered star-online printer.
1563 *
1564 * @return \WP_REST_Response|WP_Error
1565 */
1566 private function test_print_star_online( string $printer_id, array $printer ) {
1567 $markup = ( new Cloud_Print_Diagnostic() )->star_markup( (string) $printer['name'] );
1568
1569 $id = $this->jobs->create(
1570 array(
1571 'printer_id' => $printer_id,
1572 'content_type' => 'text/vnd.star.markup',
1573 'payload' => base64_encode( $markup ),
1574 )
1575 );
1576 if ( $id <= 0 ) {
1577 return new WP_Error(
1578 'wcpos_print_job_create_failed',
1579 __( 'Print job could not be created.', 'woocommerce-pos' ),
1580 array( 'status' => 500 )
1581 );
1582 }
1583
1584 wp_schedule_single_event( time(), Cloud_Print_Trigger_Service::CRON_SUBMIT, array( $id ) );
1585 ( new \WCPOS\WooCommercePOS\Services\Cloud_Print_Submit_Service() )->submit( $id );
1586
1587 $response = rest_ensure_response( $this->jobs->get( $id ) );
1588 $response->set_status( 201 );
1589
1590 return $response;
1591 }
1592
1593 /**
1594 * Submit a diagnostic PDF to a PrintNode printer.
1595 *
1596 * @param array $printer Registered PrintNode printer.
1597 *
1598 * @return \WP_REST_Response|WP_Error
1599 */
1600 private function test_print_printnode( array $printer ) {
1601 $api_key = (string) ( $printer['printnode_api_key'] ?? '' );
1602 $pn_printer_id = (int) ( $printer['printnode_printer_id'] ?? 0 );
1603 if ( '' === $api_key || 0 === $pn_printer_id ) {
1604 return new WP_Error(
1605 'wcpos_print_job_printnode_unconfigured',
1606 __( 'This PrintNode printer is missing its API key or printer id.', 'woocommerce-pos' ),
1607 array( 'status' => 400 )
1608 );
1609 }
1610
1611 try {
1612 $pdf = ( new Cloud_Print_Diagnostic() )->build_pdf( (string) $printer['name'] );
1613 } catch ( \Throwable $e ) {
1614 // Defense in depth: a Dompdf/font-cache/temp-dir failure must not
1615 // surface as an uncaught 500. Mirror the render_payload() guard.
1616 Logger::log( 'Cloud print: PrintNode diagnostic PDF render failed: ' . $e->getMessage() );
1617
1618 return new WP_Error(
1619 'wcpos_print_job_diagnostic_failed',
1620 __( 'Could not generate the test print.', 'woocommerce-pos' ),
1621 array( 'status' => 500 )
1622 );
1623 }
1624
1625 $result = ( new PrintNode_Client( $api_key ) )->submit_job(
1626 $pn_printer_id,
1627 'WCPOS Test Print',
1628 'pdf_base64',
1629 base64_encode( $pdf )
1630 );
1631
1632 if ( is_wp_error( $result ) ) {
1633 return new WP_Error(
1634 'wcpos_print_job_printnode_failed',
1635 $result->get_error_message(),
1636 array( 'status' => 502 )
1637 );
1638 }
1639
1640 return new WP_REST_Response(
1641 array(
1642 'submitted' => true,
1643 'external_provider' => 'printnode',
1644 'external_job_id' => (string) $result['id'],
1645 'external_state' => 'submitted',
1646 ),
1647 201
1648 );
1649 }
1650
1651 /**
1652 * Extract sanitized cash-drawer options from a REST request.
1653 *
1654 * @param WP_REST_Request $request Request.
1655 *
1656 * @return array{auto_open_drawer:bool, drawer_connector:string}
1657 */
1658 private function drawer_options_from_request( WP_REST_Request $request ): array {
1659 $auto = $request->get_param( 'autoOpenDrawer' );
1660 if ( null === $auto ) {
1661 $auto = $request->get_param( 'auto_open_drawer' );
1662 }
1663
1664 $connector = $request->get_param( 'drawerConnector' );
1665 if ( null === $connector ) {
1666 $connector = $request->get_param( 'drawer_connector' );
1667 }
1668
1669 return array(
1670 'auto_open_drawer' => rest_sanitize_boolean( $auto ),
1671 'drawer_connector' => Print_Job_Service::normalize_drawer_connector( (string) $connector ),
1672 );
1673 }
1674
1675 /**
1676 * Validate a job against the target printer's provider.
1677 *
1678 * @param array|null $printer Registered printer, or null when unknown.
1679 * @param string $payload Base64 payload (raw jobs).
1680 * @param string $format Render format (order-based jobs).
1681 * @param bool $is_template_job Whether this is an order/template job.
1682 *
1683 * @return true|WP_Error
1684 */
1685 private function validate_job_for_printer( ?array $printer, string $payload, string $format, bool $is_template_job ) {
1686 if ( null === $printer ) {
1687 return true;
1688 }
1689 $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
1690
1691 if ( 'epos-xml' === Provider::wire_format( $provider, 'thermal' ) ) {
1692 if ( '' !== $payload ) {
1693 return new WP_Error(
1694 'wcpos_print_job_incompatible',
1695 __( 'Epson Server Direct Print accepts order-based ePOS-Print jobs only, not raw payloads.', 'woocommerce-pos' ),
1696 array( 'status' => 400 )
1697 );
1698 }
1699 if ( '' !== $format && 'epos-xml' !== $format ) {
1700 return new WP_Error(
1701 'wcpos_print_job_incompatible',
1702 __( 'Epson Server Direct Print requires the epos-xml format.', 'woocommerce-pos' ),
1703 array( 'status' => 400 )
1704 );
1705 }
1706
1707 return true;
1708 }
1709
1710 if ( 'epos-xml' === $format ) {
1711 return new WP_Error(
1712 'wcpos_print_job_incompatible',
1713 __( 'Star CloudPRNT does not accept the epos-xml format.', 'woocommerce-pos' ),
1714 array( 'status' => 400 )
1715 );
1716 }
1717
1718 // The fixed-layout 'escpos' adapter emits a language StarPRNT-native
1719 // printers cannot decode, and the fixed-layout 'starprnt' adapter is a
1720 // placeholder that emits marker text, not wire bytes. Fail these jobs
1721 // loudly instead of queueing bytes the printer will reject.
1722 if ( ! $is_template_job && 'star-cloudprnt' === $provider && in_array( $format, array( 'escpos', 'starprnt' ), true ) ) {
1723 return new WP_Error(
1724 'wcpos_print_job_incompatible',
1725 __( 'Star CloudPRNT printers require order-based template jobs or a raw payload.', 'woocommerce-pos' ),
1726 array( 'status' => 400 )
1727 );
1728 }
1729
1730 return true;
1731 }
1732
1733 /**
1734 * Serve the pending relay verification token (public; consent callback).
1735 *
1736 * @return \WP_REST_Response|WP_Error
1737 */
1738 public function relay_verification() {
1739 $token = Cloud_Print_Relay_Service::pending_verification_token();
1740 if ( null === $token ) {
1741 return new WP_Error(
1742 'wcpos_relay_no_pending_verification',
1743 __( 'No relay verification is pending.', 'woocommerce-pos' ),
1744 array( 'status' => 404 )
1745 );
1746 }
1747
1748 return rest_ensure_response( array( 'token' => $token ) );
1749 }
1750
1751 /**
1752 * Register this site with the WCPOS Cloud Print relay.
1753 *
1754 * @return \WP_REST_Response|WP_Error
1755 */
1756 public function relay_register() {
1757 $result = Cloud_Print_Relay_Service::register_site();
1758
1759 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
1760 }
1761
1762 /**
1763 * Permission check for relay registration routes.
1764 *
1765 * Registering rotates the site's relay credentials, so it needs the
1766 * settings-management capability, not the cashier-level print capability.
1767 */
1768 public function relay_manage_permissions_check(): bool {
1769 return current_user_can( 'manage_woocommerce_pos' );
1770 }
1771
1772 /**
1773 * Check permissions for cashier-level print job actions.
1774 *
1775 * @param WP_REST_Request $request Request.
1776 *
1777 * @return bool|WP_Error
1778 */
1779 public function manage_permissions_check( $request ) {
1780 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
1781 return new WP_Error(
1782 'wcpos_rest_insufficient_permissions',
1783 __( 'Sorry, you cannot manage print jobs.', 'woocommerce-pos' ),
1784 array( 'status' => rest_authorization_required_code() )
1785 );
1786 }
1787
1788 return true;
1789 }
1790 }
1791