PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
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.0, at includes/API/V1/Print_Jobs_Controller.php

1,599 lines 51.3 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 . '/test',
177 array(
178 'methods' => WP_REST_Server::CREATABLE,
179 'callback' => array( $this, 'test_print' ),
180 'permission_callback' => array( $this, 'manage_permissions_check' ),
181 )
182 );
183
184 register_rest_route(
185 $this->namespace,
186 '/' . $this->rest_base . '/relay-verification',
187 array(
188 'methods' => WP_REST_Server::READABLE,
189 'callback' => array( $this, 'relay_verification' ),
190 'permission_callback' => '__return_true',
191 )
192 );
193
194 register_rest_route(
195 $this->namespace,
196 '/' . $this->rest_base . '/relay/register',
197 array(
198 'methods' => WP_REST_Server::CREATABLE,
199 'callback' => array( $this, 'relay_register' ),
200 'permission_callback' => array( $this, 'relay_manage_permissions_check' ),
201 )
202 );
203
204 register_rest_route(
205 $this->namespace,
206 '/' . $this->rest_base . '/cloudprnt',
207 array(
208 array(
209 'methods' => array( 'POST', 'GET', 'DELETE' ),
210 'callback' => array( $this, 'cloudprnt' ),
211 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
212 ),
213 )
214 );
215
216 // Path-credential form: Star printers URL-encode the configured query
217 // string on the wire (& becomes %26), so printer_id/pt can never
218 // arrive as query parameters — but the path is transmitted verbatim.
219 register_rest_route(
220 $this->namespace,
221 '/' . $this->rest_base . '/cloudprnt/(?P<printer_id>[^/]+)/(?P<pt>[^/]+)',
222 array(
223 array(
224 'methods' => array( 'POST', 'GET', 'DELETE' ),
225 'callback' => array( $this, 'cloudprnt' ),
226 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
227 ),
228 )
229 );
230
231 register_rest_route(
232 $this->namespace,
233 '/' . $this->rest_base . '/epson-sdp',
234 array(
235 array(
236 'methods' => WP_REST_Server::CREATABLE,
237 'callback' => array( $this, 'epson_sdp' ),
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/(?P<printer_id>[^/]+)/(?P<pt>[^/]+)',
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 '/printnode/printers',
258 array(
259 array(
260 'methods' => WP_REST_Server::CREATABLE,
261 'callback' => array( $this, 'printnode_printers' ),
262 'permission_callback' => array( $this, 'manage_permissions_check' ),
263 ),
264 )
265 );
266
267 register_rest_route(
268 $this->namespace,
269 '/star-online/devices',
270 array(
271 array(
272 'methods' => WP_REST_Server::CREATABLE,
273 'callback' => array( $this, 'star_online_devices' ),
274 'permission_callback' => array( $this, 'manage_permissions_check' ),
275 ),
276 )
277 );
278 }
279
280 /**
281 * Proxy the PrintNode account's printer list for the add-printer wizard.
282 *
283 * The API key is supplied in the POST body (never the URL/query, so it does
284 * not leak through logs or history) and is used only for this request; it is
285 * never returned. Only id/name/state are surfaced to the client.
286 *
287 * @param WP_REST_Request $request Request.
288 *
289 * @return \WP_REST_Response|WP_Error
290 */
291 public function printnode_printers( $request ) {
292 // The API key is a secret: read it from the request body only, never the
293 // query string, so it can't leak through server logs or browser history.
294 // get_param() merges query + body, so it is deliberately avoided here.
295 $query = $request->get_query_params();
296 if ( isset( $query['api_key'] ) ) {
297 return new WP_Error(
298 'wcpos_printnode_api_key_in_query',
299 __( 'The PrintNode API key must be sent in the request body, not the query string.', 'woocommerce-pos' ),
300 array( 'status' => 400 )
301 );
302 }
303
304 // JSON bodies land in the JSON param set, form-encoded bodies in POST;
305 // read both (cast handles the null-on-absent case) and never the query set.
306 $json = (array) $request->get_json_params();
307 $body = (array) $request->get_body_params();
308 $api_key = (string) ( $json['api_key'] ?? $body['api_key'] ?? '' );
309 if ( '' === $api_key ) {
310 return new WP_Error(
311 'wcpos_printnode_missing_api_key',
312 __( 'A PrintNode API key is required.', 'woocommerce-pos' ),
313 array( 'status' => 400 )
314 );
315 }
316
317 $result = ( new PrintNode_Client( $api_key ) )->printers();
318 if ( is_wp_error( $result ) ) {
319 // A rejected key is a client input error (the value just typed into
320 // the wizard) → 400 so the UI can prompt for a correct key. Any other
321 // PrintNode failure is an upstream/transport error → 502 (matching
322 // test_print_printnode()).
323 $status = 'wcpos_printnode_unauthorized' === $result->get_error_code() ? 400 : 502;
324
325 return new WP_Error(
326 'wcpos_printnode_printers_failed',
327 $result->get_error_message(),
328 array( 'status' => $status )
329 );
330 }
331
332 $printers = array();
333 foreach ( (array) $result as $printer ) {
334 if ( ! is_array( $printer ) || ! isset( $printer['id'] ) ) {
335 continue;
336 }
337 $printers[] = array(
338 'id' => (int) $printer['id'],
339 'name' => (string) ( $printer['name'] ?? '' ),
340 'state' => (string) ( $printer['state'] ?? '' ),
341 );
342 }
343
344 return new WP_REST_Response( array( 'printers' => $printers ), 200 );
345 }
346
347 /**
348 * Proxy the stario.online device list for the add-printer wizard.
349 *
350 * @param WP_REST_Request $request Request.
351 *
352 * @return \WP_REST_Response|WP_Error
353 */
354 public function star_online_devices( $request ) {
355 $query = $request->get_query_params();
356 if ( isset( $query['api_key'] ) ) {
357 return new WP_Error(
358 'wcpos_star_online_api_key_in_query',
359 __( 'The Star Online API key must be sent in the request body, not the query string.', 'woocommerce-pos' ),
360 array( 'status' => 400 )
361 );
362 }
363
364 $json = (array) $request->get_json_params();
365 $body = (array) $request->get_body_params();
366 $api_key = (string) ( $json['api_key'] ?? $body['api_key'] ?? '' );
367 $url = (string) ( $json['cloudprnt_url'] ?? $body['cloudprnt_url'] ?? '' );
368
369 $api_base = Star_Online_Client::api_base_from_cloudprnt_url( $url );
370 $group = Star_Online_Client::group_from_cloudprnt_url( $url );
371 if ( '' === $api_key || null === $api_base || '' === $group ) {
372 return new WP_Error(
373 'wcpos_star_online_invalid_request',
374 __( 'A Star Online API key and a valid stario.online CloudPRNT URL are required.', 'woocommerce-pos' ),
375 array( 'status' => 400 )
376 );
377 }
378
379 $result = ( new Star_Online_Client( $api_base, $api_key ) )->devices( $group );
380 if ( is_wp_error( $result ) ) {
381 return $result;
382 }
383
384 $devices = array();
385 foreach ( $result as $device ) {
386 if ( ! \is_array( $device ) || empty( $device['AccessIdentifier'] ) ) {
387 continue;
388 }
389 $state = 'unknown';
390 $status = isset( $device['Status'] ) && \is_array( $device['Status'] ) ? $device['Status'] : array();
391 if ( array_key_exists( 'Online', $status ) ) {
392 $state = $status['Online'] ? 'online' : 'offline';
393 }
394 $devices[] = array(
395 'id' => (string) $device['AccessIdentifier'],
396 'name' => (string) ( $device['ClientType'] ?? $device['AccessIdentifier'] ),
397 'state' => $state,
398 );
399 }
400
401 return new WP_REST_Response( array( 'devices' => $devices ), 200 );
402 }
403
404
405 /**
406 * Sanitize a job filter that may arrive as a scalar or as a list.
407 *
408 * These routes declare no arg schema, so a caller can send `status=failed`
409 * or `status[]=pending&status[]=failed`. filters_to_meta_query() turns a
410 * list into an IN clause, so flattening one to a string here would silently
411 * narrow the query (and warn on the array-to-string cast).
412 *
413 * @param mixed $value Raw request parameter.
414 *
415 * @return array|string
416 */
417 private function sanitize_filter( $value ) {
418 if ( \is_array( $value ) ) {
419 return array_map(
420 function ( $item ): string {
421 return sanitize_text_field( \is_scalar( $item ) ? (string) $item : '' );
422 },
423 $value
424 );
425 }
426
427 return sanitize_text_field( \is_scalar( $value ) ? (string) $value : '' );
428 }
429
430 /**
431 * List print jobs.
432 *
433 * @param WP_REST_Request $request Request.
434 *
435 * @return \WP_REST_Response
436 */
437 public function get_items( $request ) {
438 return rest_ensure_response(
439 $this->jobs->query(
440 array(
441 'printer_id' => $this->sanitize_filter( $request->get_param( 'printer_id' ) ),
442 'status' => $this->sanitize_filter( $request->get_param( 'status' ) ),
443 )
444 )
445 );
446 }
447
448 /**
449 * Get a print job.
450 *
451 * @param WP_REST_Request $request Request.
452 *
453 * @return \WP_REST_Response|WP_Error
454 */
455 public function get_item( $request ) {
456 $job = $this->jobs->get( (int) $request->get_param( 'id' ) );
457 if ( null === $job ) {
458 return new WP_Error(
459 'wcpos_print_job_not_found',
460 __( 'Print job not found.', 'woocommerce-pos' ),
461 array( 'status' => 404 )
462 );
463 }
464
465 return rest_ensure_response( $job );
466 }
467
468 /**
469 * Cancel a print job.
470 *
471 * @param WP_REST_Request $request Request.
472 *
473 * @return \WP_REST_Response|WP_Error
474 */
475 public function delete_item( $request ) {
476 $id = (int) $request->get_param( 'id' );
477 $job = $this->jobs->get( $id );
478 if ( null === $job ) {
479 return new WP_Error(
480 'wcpos_print_job_not_found',
481 __( 'Print job not found.', 'woocommerce-pos' ),
482 array( 'status' => 404 )
483 );
484 }
485
486 if ( ! $this->jobs->cancel_if_waiting( $id ) ) {
487 return new WP_Error(
488 'wcpos_print_job_not_cancellable',
489 __( 'Only pending or claimed print jobs can be cancelled.', 'woocommerce-pos' ),
490 array( 'status' => 409 )
491 );
492 }
493
494 return rest_ensure_response( $this->jobs->get( $id ) );
495 }
496
497 /**
498 * The admin queue view: paginated jobs (payloads stripped), status counts,
499 * and per-printer backlog with last-seen data for staleness banners.
500 *
501 * @param WP_REST_Request $request Request.
502 *
503 * @return \WP_REST_Response
504 */
505 public function get_queue( $request ) {
506 $per_page = (int) $request->get_param( 'per_page' );
507 $per_page = min( 100, max( 1, 0 === $per_page ? 20 : $per_page ) );
508 $page = max( 1, (int) $request->get_param( 'page' ) );
509
510 $status = $this->sanitize_filter( $request->get_param( 'status' ) );
511 $exclude_retried = 'active' === $status;
512 if ( 'active' === $status ) {
513 // The default queue view: everything not yet terminal-successful.
514 $status = array(
515 Print_Job_Service::STATUS_PENDING,
516 Print_Job_Service::STATUS_CLAIMED,
517 Print_Job_Service::STATUS_FAILED,
518 );
519 }
520 $filters = array(
521 'printer_id' => $this->sanitize_filter( $request->get_param( 'printer_id' ) ),
522 'status' => $status,
523 'exclude_retried' => $exclude_retried,
524 );
525
526 $jobs = array_map(
527 function ( array $job ): array {
528 $order = $job['order_id'] ? wc_get_order( $job['order_id'] ) : false;
529 if ( $order ) {
530 $job['order_number'] = (string) $order->get_order_number();
531 $job['order_edit_url'] = $order->get_edit_order_url();
532 }
533
534 return $job;
535 },
536 $this->jobs->query_rows(
537 array_merge(
538 $filters,
539 array(
540 'limit' => $per_page,
541 'page' => $page,
542 )
543 )
544 )
545 );
546
547 // One grouped query covers all status counts and every printer's
548 // backlog — the view refreshes every 30 s, so summary cost must not
549 // scale with printer count.
550 $summary = $this->jobs->status_summary();
551
552 $counts = array();
553 foreach ( array(
554 Print_Job_Service::STATUS_PENDING,
555 Print_Job_Service::STATUS_CLAIMED,
556 Print_Job_Service::STATUS_PRINTED,
557 Print_Job_Service::STATUS_FAILED,
558 Print_Job_Service::STATUS_CANCELLED,
559 ) as $status ) {
560 $counts[ $status ] = 0;
561 foreach ( $summary as $per_status ) {
562 $counts[ $status ] += isset( $per_status[ $status ] ) ? $per_status[ $status ]['count'] : 0;
563 }
564 }
565 $counts['failed_unresolved'] = 0;
566 foreach ( $summary as $per_status ) {
567 if ( isset( $per_status[ Print_Job_Service::STATUS_FAILED ] ) ) {
568 $counts['failed_unresolved'] += $per_status[ Print_Job_Service::STATUS_FAILED ]['unresolved_count'];
569 }
570 }
571
572 $printers = array();
573 foreach ( $this->registry->get_printers() as $printer ) {
574 $printer_id = (string) ( $printer['id'] ?? '' );
575 if ( '' === $printer_id ) {
576 continue;
577 }
578 // Waiting = pending + claimed: a printer that fetched a job and
579 // then died leaves it claimed forever with zero pending — that
580 // backlog must still trip the stale banner.
581 $waiting = 0;
582 $oldest = '';
583 foreach ( array( Print_Job_Service::STATUS_PENDING, Print_Job_Service::STATUS_CLAIMED ) as $status ) {
584 if ( ! isset( $summary[ $printer_id ][ $status ] ) ) {
585 continue;
586 }
587 $waiting += $summary[ $printer_id ][ $status ]['count'];
588 $created = $summary[ $printer_id ][ $status ]['oldest_gmt'];
589 if ( '' !== $created && ( '' === $oldest || $created < $oldest ) ) {
590 $oldest = $created;
591 }
592 }
593 $printers[] = array(
594 'printer_id' => $printer_id,
595 'name' => (string) ( $printer['name'] ?? $printer_id ),
596 // Push providers (PrintNode, Star Online) never poll, so
597 // last-seen staleness is meaningless for them — the UI must
598 // not show a "never fetched" banner. A missing provider
599 // defaults to star-cloudprnt exactly like the print path, so
600 // legacy rows without the field keep their stale warnings.
601 'polling' => Provider::is_polling(
602 Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null )
603 ),
604 'pending' => $waiting,
605 'oldest_pending_gmt' => $oldest,
606 'last_seen' => $this->registry->get_seen( $printer_id ),
607 );
608 }
609
610 return rest_ensure_response(
611 array(
612 'jobs' => $jobs,
613 'total' => $this->jobs->count( $filters ),
614 'page' => $page,
615 'per_page' => $per_page,
616 'summary' => array(
617 'counts' => $counts,
618 'printers' => $printers,
619 ),
620 )
621 );
622 }
623
624 /**
625 * Bulk-cancel waiting jobs by explicit ids or for a whole printer.
626 *
627 * @param WP_REST_Request $request Request.
628 *
629 * @return \WP_REST_Response
630 */
631 public function cancel_queue( $request ) {
632 $ids = $request->get_param( 'ids' );
633 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
634
635 $cancelled = $this->jobs->cancel_waiting(
636 array(
637 'ids' => \is_array( $ids ) ? $ids : array(),
638 'printer_id' => $printer_id,
639 )
640 );
641
642 return rest_ensure_response( array( 'cancelled' => $cancelled ) );
643 }
644
645 /**
646 * Reprint a print job by copying it to a new pending job.
647 *
648 * @param WP_REST_Request $request Request.
649 *
650 * @return \WP_REST_Response|WP_Error
651 */
652 public function reprint_item( $request ) {
653 $source = $this->jobs->get( (int) $request->get_param( 'id' ) );
654 if ( null === $source ) {
655 return new WP_Error(
656 'wcpos_print_job_not_found',
657 __( 'Print job not found.', 'woocommerce-pos' ),
658 array( 'status' => 404 )
659 );
660 }
661 if ( $source['retried_to'] > 0 ) {
662 return new WP_Error(
663 'wcpos_print_job_already_retried',
664 __( 'This print job has already been retried.', 'woocommerce-pos' ),
665 array(
666 'status' => 409,
667 'retried_to' => $source['retried_to'],
668 )
669 );
670 }
671 if ( '' === $source['payload'] && '' === $source['template_id'] ) {
672 // A stripped raw job has nothing left to print — refuse loudly
673 // rather than queue a blank receipt.
674 return new WP_Error(
675 'wcpos_print_job_source_expired',
676 __( 'This job\'s stored receipt has been cleaned up and it has no template to re-render from.', 'woocommerce-pos' ),
677 array( 'status' => 410 )
678 );
679 }
680 $content_type = $source['content_type'];
681 $pn_kind = $source['pn_kind'];
682 if ( '' !== $source['template_id'] ) {
683 $template = Print_Job_Service::load_template( (string) $source['template_id'] );
684 if ( null === $template && $source['order_id'] > 0 ) {
685 // render_payload() takes its template branch on
686 // order_id + template_id and returns nothing when the
687 // template is gone — the stored payload is never reached.
688 // Queueing here would 201 a job that can only ever fail,
689 // so refuse for the same reason the stripped-payload guard
690 // above does.
691 return new WP_Error(
692 'wcpos_print_job_source_expired',
693 __( 'This job\'s template no longer exists, so it cannot be re-rendered.', 'woocommerce-pos' ),
694 array( 'status' => 410 )
695 );
696 }
697 $printer = $this->registry->get_printer( (string) $source['printer_id'] );
698 if ( null !== $printer ) {
699 // Refresh both halves of the pairing together. A legacy job can
700 // carry a media type from before the provider declared its own,
701 // but content_type and pn_kind must keep agreeing: reprinting a
702 // raw (escpos) PrintNode job through the printer-only resolver
703 // relabels it application/pdf in the queue view, even though
704 // submit still sends raw bytes off the stored pn_kind.
705 $resolver = new Print_Format_Resolver();
706 $fmt = null === $template ? array( 'kind' => '' ) : $resolver->resolve( $printer, $template );
707 if ( '' === (string) $fmt['kind'] ) {
708 // No loadable template, or one this printer can no longer
709 // render. Refresh from the provider's declared type only
710 // when no stored kind can contradict it; otherwise the
711 // source pairing is the best answer left.
712 if ( '' === $pn_kind ) {
713 $content_type = $resolver->content_type_for_printer( $printer );
714 }
715 } else {
716 $content_type = $fmt['content_type'];
717 $pn_kind = Provider::stores_job_kind( Provider::normalize( (string) ( $printer['provider'] ?? '' ) ) )
718 ? $fmt['kind']
719 : '';
720 }
721 }
722 }
723 $new_id = $this->jobs->create(
724 array(
725 'printer_id' => $source['printer_id'],
726 'content_type' => $content_type,
727 'payload' => $source['payload'],
728 'order_id' => $source['order_id'] ? $source['order_id'] : null,
729 'format' => $source['format'] ? $source['format'] : null,
730 // Template-backed jobs (auto-print) carry no stored payload —
731 // the render metadata must survive the copy or the reprint
732 // renders nothing.
733 'template_id' => '' !== $source['template_id'] ? $source['template_id'] : null,
734 'pn_kind' => '' !== $pn_kind ? $pn_kind : null,
735 'auto_open_drawer' => $source['auto_open_drawer'],
736 'drawer_connector' => $source['drawer_connector'],
737 )
738 );
739 if ( $new_id <= 0 ) {
740 return new WP_Error(
741 'wcpos_print_job_create_failed',
742 __( 'Print job could not be created.', 'woocommerce-pos' ),
743 array( 'status' => 500 )
744 );
745 }
746 if ( Print_Job_Service::STATUS_FAILED === $source['status'] && ! $this->jobs->mark_retried( (int) $source['id'], $new_id ) ) {
747 wp_delete_post( $new_id, true );
748
749 return new WP_Error(
750 'wcpos_print_job_retry_failed',
751 __( 'Print job retry could not be recorded.', 'woocommerce-pos' ),
752 array( 'status' => 500 )
753 );
754 }
755
756 // Push providers (PrintNode, Star Online) never poll the queue — their
757 // jobs only move when CRON_SUBMIT fires. Without this the replacement
758 // job stays pending forever and Retry silently does nothing.
759 $printer = $this->registry->get_printer( (string) $source['printer_id'] );
760 $provider = null !== $printer ? (string) ( $printer['provider'] ?? '' ) : '';
761 if ( Provider::requires_submit( $provider ) ) {
762 wp_schedule_single_event( time(), Cloud_Print_Trigger_Service::CRON_SUBMIT, array( $new_id ) );
763 }
764
765 $response = rest_ensure_response( $this->jobs->get( $new_id ) );
766 $response->set_status( 201 );
767
768 return $response;
769 }
770
771
772 /**
773 * Star CloudPRNT poll/fetch/confirm endpoint.
774 *
775 * @param WP_REST_Request $request Request.
776 *
777 * @return \WP_REST_Response|WP_Error
778 */
779 public function cloudprnt( $request ) {
780 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
781 $this->registry->record_seen( $printer_id );
782 $this->jobs->release_stale_claims( $printer_id );
783
784 if ( 'POST' === $request->get_method() ) {
785 return $this->cloudprnt_poll( $request, $printer_id );
786 }
787
788 $job = $this->get_cloud_job_for_request( $request, $printer_id );
789 if ( is_wp_error( $job ) ) {
790 return $job;
791 }
792
793 if ( 'DELETE' === $request->get_method() ) {
794 $code = sanitize_text_field( (string) $request->get_param( 'code' ) );
795 $status = '' === $code || '000' === $code || 1 === preg_match( '/^2\d{2,3}(?:\s|$)/', $code ) ? Print_Job_Service::STATUS_PRINTED : Print_Job_Service::STATUS_FAILED;
796 $this->jobs->set_status( (int) $job['id'], $status );
797
798 if ( Print_Job_Service::STATUS_FAILED === $status ) {
799 $this->log_printer_failure( $request, $printer_id, $code, (int) $job['id'] );
800 }
801
802 return rest_ensure_response( array( 'ok' => true ) );
803 }
804
805 return $this->cloudprnt_fetch( $request, $printer_id, $job );
806 }
807
808 /**
809 * Answer a CloudPRNT poll: offer a job, and ask what the printer can decode.
810 *
811 * The poll body is the printer's half of the conversation. `printingInProgress`
812 * says a job is still on the paper, and the spec is explicit that the server
813 * must not offer another one until it clears — doing so risks the printer
814 * dropping the second job. `clientAction` carries the printer's answers to
815 * questions asked in an earlier poll response, which is the only way the
816 * protocol exposes what formats the hardware can decode.
817 *
818 * @param WP_REST_Request $request Request.
819 * @param string $printer_id Printer ID.
820 *
821 * @return \WP_REST_Response
822 */
823 private function cloudprnt_poll( WP_REST_Request $request, string $printer_id ) {
824 $poll = Cloud_Print_Poll_Request::from_body( (string) $request->get_body(), $request->get_json_params() );
825 $this->registry->record_capabilities( $printer_id, $poll->answers(), $poll->status_code() );
826
827 $response = array( 'jobReady' => false );
828
829 if ( ! $poll->printing_in_progress() && ! $this->jobs->find_active_claim( $printer_id ) ) {
830 $job = $this->jobs->next_pending( $printer_id );
831 if ( null !== $job ) {
832 // The offer is a list: the printer picks its preferred decodable
833 // entry and names it in the fetch's `?type`. Nothing decodable in
834 // the list means no GET at all, just a 510 confirmation — so the
835 // list is filtered by what this printer said it can decode.
836 // `mediaType` (singular) is kept for older firmware.
837 $media_types = $this->media_types_for_job( $job, $printer_id );
838 $response = array(
839 'jobReady' => true,
840 'jobToken' => (string) $job['id'],
841 'mediaType' => $media_types[0],
842 'mediaTypes' => array_values( $media_types ),
843 );
844 }
845 }
846
847 if ( $this->registry->should_request_capabilities( $printer_id ) ) {
848 $response['clientAction'] = array(
849 array( 'request' => 'ClientType' ),
850 array( 'request' => 'Encodings' ),
851 );
852 $this->registry->record_capability_request( $printer_id );
853 }
854
855 return rest_ensure_response( $response );
856 }
857
858 /**
859 * Serve a CloudPRNT job in the media type the printer asked for.
860 *
861 * @param WP_REST_Request $request Request.
862 * @param string $printer_id Printer ID.
863 * @param array $job Job array.
864 *
865 * @return \WP_REST_Response|WP_Error
866 */
867 private function cloudprnt_fetch( WP_REST_Request $request, string $printer_id, array $job ) {
868 // The fetch GET names the printer's chosen media type. A type the server
869 // cannot produce is answered with 415 (per the CloudPRNT spec) and the job
870 // is left unclaimed. What is servable is deliberately wider than what the
871 // poll advertised: the printer naming a type is a stronger signal than our
872 // cached capability answer, so a capability update landing between the two
873 // requests must not reject a format we had just offered. Firmware that
874 // omits the parameter gets our best offer for this printer instead. The
875 // logged value is length-capped: printers poll every few seconds, so a
876 // wedged loop must not flood the log with unbounded input.
877 $servable = ( new Cloud_Print_Media_Types() )->servable_for_job( $job, $this->registry->get_printer( $printer_id ) );
878 $requested = sanitize_text_field( (string) $request->get_param( 'type' ) );
879 $chosen = '' === $requested
880 ? $this->media_types_for_job( $job, $printer_id )[0]
881 : Cloud_Print_Media_Types::match( $requested, $servable );
882
883 if ( '' === $chosen ) {
884 Logger::warning(
885 sprintf(
886 '%s: printer "%s" requested media type "%s" for print job %d, which the server can only serve as %s.',
887 $request->get_route(),
888 $printer_id,
889 substr( $requested, 0, 100 ),
890 (int) $job['id'],
891 implode( ', ', $servable )
892 )
893 );
894
895 return new WP_Error(
896 'wcpos_print_job_incompatible_media_type',
897 __( 'The print job is not available in the requested media type.', 'woocommerce-pos' ),
898 array( 'status' => 415 )
899 );
900 }
901
902 if ( ! $this->jobs->try_claim( (int) $job['id'] ) ) {
903 return rest_ensure_response( array( 'jobReady' => false ) );
904 }
905
906 $render = $this->jobs->render_job( $job, $chosen );
907 if ( '' === $render['body'] ) {
908 Logger::error(
909 sprintf(
910 '%s: print job %d rendered an empty payload for printer "%s".',
911 $request->get_route(),
912 (int) $job['id'],
913 $printer_id
914 )
915 );
916 }
917
918 return $this->serve_raw( $render['body'], $chosen, self::control_headers( $chosen, $render ) );
919 }
920
921 /**
922 * The media types a CloudPRNT job can be served in, best first.
923 *
924 * @param array $job Job array.
925 * @param string $printer_id Printer ID.
926 *
927 * @return array<int, string>
928 */
929 private function media_types_for_job( array $job, string $printer_id ): array {
930 $capabilities = $this->registry->get_capabilities( $printer_id );
931
932 return ( new Cloud_Print_Media_Types() )->for_job(
933 $job,
934 $this->registry->get_printer( $printer_id ),
935 $capabilities['encodings']
936 );
937 }
938
939 /**
940 * Peripheral-control headers for a job served in a command-free format.
941 *
942 * `text/plain` and images carry no cut or drawer commands, so CloudPRNT reads
943 * them off the fetch response instead. Command formats express both in-band
944 * and must not also be told to cut, or the receipt cuts twice.
945 *
946 * Both headers are always sent, `none` included. Omitting them leaves the
947 * decision to the printer's own defaults, which cut plain-text jobs — so a
948 * template that deliberately does not cut would cut anyway, and would behave
949 * differently in text than in StarPRNT. Saying `none` out loud keeps the two
950 * formats rendering the same receipt.
951 *
952 * @param string $media_type The media type being served.
953 * @param array $render Render result from Print_Job_Service::render_job().
954 *
955 * @return array<string, string>
956 */
957 private static function control_headers( string $media_type, array $render ): array {
958 if ( ! Cloud_Print_Media_Types::is_header_controlled( $media_type ) ) {
959 return array();
960 }
961
962 $headers = array(
963 'X-Star-Cut' => null === $render['cut'] ? 'none' : (string) $render['cut'],
964 'X-Star-CashDrawer' => null === $render['drawer'] ? 'none' : (string) $render['drawer'],
965 );
966
967 // The raster is already two-colour, so the printer's Floyd-Steinberg
968 // default would dither an image that has nothing left to dither —
969 // softening crisp black-on-white text into stipple.
970 if ( Cloud_Print_Media_Types::PNG === Cloud_Print_Media_Types::normalize( $media_type ) ) {
971 $headers['X-Star-ImageDitherPattern'] = 'none';
972 }
973
974 return $headers;
975 }
976
977 /**
978 * Permission check for printer-token routes.
979 *
980 * @param WP_REST_Request $request Request.
981 *
982 * @return bool|WP_Error
983 */
984 public function printer_token_permissions_check( $request ) {
985 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
986 $token = (string) $request->get_param( 'pt' );
987
988 if ( ! $this->registry->verify_token( $printer_id, $token ) ) {
989 Logger::warning(
990 sprintf(
991 '%s: authentication failed for printer "%s".',
992 $request->get_route(),
993 $printer_id
994 )
995 );
996
997 return new WP_Error(
998 'wcpos_print_job_invalid_token',
999 __( 'Invalid printer token.', 'woocommerce-pos' ),
1000 array( 'status' => 401 )
1001 );
1002 }
1003
1004 return true;
1005 }
1006
1007 /**
1008 * Resolve and authorize a CloudPRNT job token.
1009 *
1010 * @param WP_REST_Request $request Request.
1011 * @param string $printer_id Printer ID.
1012 *
1013 * @return array|WP_Error
1014 */
1015 private function get_cloud_job_for_request( WP_REST_Request $request, string $printer_id ) {
1016 $job_id = (int) $request->get_param( 'token' );
1017 $job = $this->jobs->get( $job_id );
1018 if ( null === $job || $printer_id !== $job['printer_id'] ) {
1019 Logger::warning(
1020 sprintf(
1021 '%s: print job "%d" was not found for printer "%s".',
1022 $request->get_route(),
1023 $job_id,
1024 $printer_id
1025 )
1026 );
1027
1028 return new WP_Error(
1029 'wcpos_print_job_not_found',
1030 __( 'Print job not found.', 'woocommerce-pos' ),
1031 array( 'status' => 404 )
1032 );
1033 }
1034
1035 return $job;
1036 }
1037
1038 /**
1039 * Log a printer-reported failure without request credentials or payloads.
1040 *
1041 * @param WP_REST_Request $request Request.
1042 * @param string $printer_id Printer ID.
1043 * @param string $code Failure code.
1044 * @param int $job_id Print job ID.
1045 */
1046 private function log_printer_failure( WP_REST_Request $request, string $printer_id, string $code, int $job_id ): void {
1047 Logger::error(
1048 sprintf(
1049 '%s: printer "%s" reported failure code "%s" for print job %d.',
1050 $request->get_route(),
1051 $printer_id,
1052 $code,
1053 $job_id
1054 )
1055 );
1056 }
1057
1058 /**
1059 * Serve raw bytes from a REST callback.
1060 *
1061 * @param string $body Response body.
1062 * @param string $content_type Content type.
1063 * @param array<string, string> $headers Extra response headers.
1064 *
1065 * @return \WP_REST_Response
1066 */
1067 private function serve_raw( string $body, string $content_type, array $headers = array() ) {
1068 return Raw_Response::serve( $body, $content_type, $headers );
1069 }
1070
1071
1072 /**
1073 * Epson Server Direct Print poll/result endpoint.
1074 *
1075 * @param WP_REST_Request $request Request.
1076 *
1077 * @return \WP_REST_Response
1078 */
1079 public function epson_sdp( $request ) {
1080 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
1081 $this->registry->record_seen( $printer_id );
1082 $raw_body = (string) $request->get_body();
1083 $soap = 'text/xml; charset=utf-8';
1084 $ack = '<response success="true" code="" status=""/>';
1085
1086 $this->jobs->release_stale_claims( $printer_id );
1087
1088 // Server Direct Print multiplexes three different request types onto the
1089 // one configured URL, as URL-encoded form data distinguished by
1090 // ConnectionType (User's Manual Rev.K, ch.3 and the Test_print.php
1091 // reference implementation):
1092 //
1093 // GetRequest — poll for a job
1094 // SetResponse — printing result; the XML rides in the ResponseFile field
1095 // SetStatus — status notification; the XML rides in the Status field
1096 //
1097 // Answering a status notification with print data hands the job to a
1098 // request that discards it, so the printer never prints and the job stays
1099 // claimed. Dispatching on ConnectionType is what keeps the job on the
1100 // GetRequest that is actually asking for one.
1101 $connection_type = (string) $request->get_param( 'ConnectionType' );
1102
1103 // Result XML is a form field, so in the raw body it is percent-encoded
1104 // (`success="true"` arrives as `success%3D%22true%22`) and a raw-body
1105 // substring test can never match it. The raw-body branch is kept only for
1106 // a caller that posts the bare XML, which the printer never does.
1107 $result_xml = (string) $request->get_param( 'ResponseFile' );
1108 if ( '' === $result_xml && false !== strpos( $raw_body, 'success=' ) ) {
1109 $result_xml = $raw_body;
1110 }
1111
1112 if ( 'SetResponse' === $connection_type || '' !== $result_xml ) {
1113 $claim = $this->jobs->find_active_claim( $printer_id );
1114 if ( null !== $claim ) {
1115 $ok = false !== strpos( $result_xml, 'success="true"' );
1116 $this->jobs->set_status( (int) $claim['id'], $ok ? Print_Job_Service::STATUS_PRINTED : Print_Job_Service::STATUS_FAILED );
1117
1118 if ( ! $ok ) {
1119 $code = 'unknown';
1120 if ( 1 === preg_match( '/\bcode="([^"]*)"/', $result_xml, $matches ) ) {
1121 $code = sanitize_text_field( $matches[1] );
1122 }
1123
1124 $this->log_printer_failure( $request, $printer_id, $code, (int) $claim['id'] );
1125 }
1126 }
1127
1128 return $this->serve_raw( $ack, $soap );
1129 }
1130
1131 // A status notification is not asking for work.
1132 if ( 'SetStatus' === $connection_type ) {
1133 return $this->serve_raw( $ack, $soap );
1134 }
1135
1136 if ( null !== $this->jobs->find_active_claim( $printer_id ) ) {
1137 return $this->serve_raw( $ack, $soap );
1138 }
1139
1140 $job = $this->jobs->next_pending( $printer_id );
1141 if ( null === $job ) {
1142 return $this->serve_raw( $ack, $soap );
1143 }
1144
1145 if ( ! $this->jobs->try_claim( (int) $job['id'] ) ) {
1146 return $this->serve_raw( $ack, $soap );
1147 }
1148 $epos = $this->jobs->render_payload( $job );
1149
1150 // Server Direct Print expects the print data wrapped in
1151 // PrintRequestInfo > ePOSPrint > PrintData — NOT the SOAP envelope used
1152 // by the direct ePOS-Print web service, which is a different protocol.
1153 // A printer that receives an unrecognised wrapper discards it silently:
1154 // it neither prints nor posts a result, so the job sits claimed forever.
1155 // Version 1.00 is the only version every SDP printer family supports
1156 // (Server Direct Print User's Manual Rev.K, "Response (Print request)");
1157 // 2.00+ adds printjobid but is limited to TM-i/TM-DT/TM-T88VI.
1158 $envelope = '<?xml version="1.0" encoding="utf-8"?>';
1159 $envelope .= '<PrintRequestInfo Version="1.00"><ePOSPrint>';
1160 $envelope .= '<Parameter><devid>local_printer</devid><timeout>' . self::EPSON_SDP_PRINT_TIMEOUT_MS . '</timeout></Parameter>';
1161 $envelope .= '<PrintData>' . $epos . '</PrintData>';
1162 $envelope .= '</ePOSPrint></PrintRequestInfo>';
1163
1164 return $this->serve_raw( $envelope, $soap );
1165 }
1166
1167 /**
1168 * Enqueue a print job (raw payload or order-based).
1169 *
1170 * @param WP_REST_Request $request Request.
1171 *
1172 * @return \WP_REST_Response|WP_Error
1173 */
1174 public function create_item( $request ) {
1175 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
1176 if ( '' === $printer_id ) {
1177 return new WP_Error(
1178 'wcpos_print_job_missing_printer',
1179 __( 'A printer_id is required.', 'woocommerce-pos' ),
1180 array( 'status' => 400 )
1181 );
1182 }
1183
1184 $payload = (string) $request->get_param( 'payload' );
1185 $format = (string) $request->get_param( 'format' );
1186 $template_id = sanitize_text_field( (string) $request->get_param( 'template_id' ) );
1187 $order_id = (int) $request->get_param( 'order_id' );
1188 $drawer_options = $this->drawer_options_from_request( $request );
1189
1190 $printer = $this->registry->get_printer( $printer_id );
1191 $is_template_job = 0 !== $order_id && '' !== $template_id;
1192 $validation = $this->validate_job_for_printer( $printer, $payload, $format, $is_template_job );
1193 if ( is_wp_error( $validation ) ) {
1194 return $validation;
1195 }
1196
1197 $provider = null !== $printer ? (string) ( $printer['provider'] ?? '' ) : '';
1198
1199 // PrintNode never polls, so a raw payload could never be delivered — a
1200 // PrintNode job must be order-based (rendered + submitted out-of-band).
1201 if ( 'printnode' === $provider && ( 0 === $order_id || '' === $template_id ) ) {
1202 return new WP_Error(
1203 'wcpos_print_job_printnode_requires_template',
1204 __( 'PrintNode print jobs require an order and a template.', 'woocommerce-pos' ),
1205 array( 'status' => 400 )
1206 );
1207 }
1208
1209 // Order-based job: render server-side from the order + template, deriving
1210 // the wire format from the printer's provider (shared with the auto-print
1211 // trigger). Star/Epson are fetched on poll; PrintNode is submitted.
1212 if ( 0 !== $order_id && '' !== $template_id ) {
1213 if ( null === $printer ) {
1214 // Without a known printer there is no provider to render for, and
1215 // the job could never be polled/submitted — fail loudly rather
1216 // than enqueue a job that silently never prints.
1217 return new WP_Error(
1218 'wcpos_print_job_unknown_printer',
1219 __( 'Unknown printer.', 'woocommerce-pos' ),
1220 array( 'status' => 404 )
1221 );
1222 }
1223
1224 return $this->create_order_job( $printer_id, $printer, $order_id, $template_id, $drawer_options );
1225 }
1226
1227 $id = $this->jobs->create(
1228 array(
1229 'printer_id' => $printer_id,
1230 'content_type' => (string) $request->get_param( 'content_type' ),
1231 'payload' => $payload,
1232 'order_id' => $order_id,
1233 'format' => $format,
1234 )
1235 );
1236 if ( $id <= 0 ) {
1237 return new WP_Error(
1238 'wcpos_print_job_create_failed',
1239 __( 'Print job could not be created.', 'woocommerce-pos' ),
1240 array( 'status' => 500 )
1241 );
1242 }
1243
1244 $response = rest_ensure_response( $this->jobs->get( $id ) );
1245 $response->set_status( 201 );
1246
1247 return $response;
1248 }
1249
1250 /**
1251 * Enqueue an order-based job, deriving the wire format from the printer's
1252 * provider via the shared trigger-service helper.
1253 *
1254 * @param string $printer_id Registered printer id.
1255 * @param array $printer Registered printer config.
1256 * @param int $order_id Order id to render.
1257 * @param string $template_id Template id (numeric) or virtual slug.
1258 * @param array $drawer_options Drawer options.
1259 *
1260 * @return \WP_REST_Response|WP_Error
1261 */
1262 private function create_order_job( string $printer_id, array $printer, int $order_id, string $template_id, array $drawer_options = array() ) {
1263 if ( ! wc_get_order( $order_id ) ) {
1264 // Surface the bad order up front rather than enqueue a job that
1265 // render_payload() can only ever resolve to an empty (never-printing) payload.
1266 return new WP_Error(
1267 'wcpos_print_job_unknown_order',
1268 __( 'Unknown order.', 'woocommerce-pos' ),
1269 array( 'status' => 404 )
1270 );
1271 }
1272
1273 $template = Print_Job_Service::load_template( $template_id );
1274 if ( null === $template ) {
1275 return new WP_Error(
1276 'wcpos_print_job_unknown_template',
1277 __( 'Unknown template.', 'woocommerce-pos' ),
1278 array( 'status' => 400 )
1279 );
1280 }
1281
1282 $id = Cloud_Print_Trigger_Service::enqueue_order_job(
1283 $this->jobs,
1284 $printer_id,
1285 $printer,
1286 $order_id,
1287 $template_id,
1288 $template,
1289 $drawer_options
1290 );
1291 if ( $id <= 0 ) {
1292 return new WP_Error(
1293 'wcpos_print_job_template_not_printable',
1294 __( 'The selected template cannot be printed on this printer.', 'woocommerce-pos' ),
1295 array( 'status' => 400 )
1296 );
1297 }
1298
1299 $response = rest_ensure_response( $this->jobs->get( $id ) );
1300 $response->set_status( 201 );
1301
1302 return $response;
1303 }
1304
1305 /**
1306 * Enqueue a diagnostic test print for a registered printer.
1307 *
1308 * @param WP_REST_Request $request Request.
1309 *
1310 * @return \WP_REST_Response|WP_Error
1311 */
1312 public function test_print( $request ) {
1313 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
1314 $printer = $this->registry->get_printer( $printer_id );
1315 if ( null === $printer ) {
1316 return new WP_Error(
1317 'wcpos_print_job_unknown_printer',
1318 __( 'Unknown printer.', 'woocommerce-pos' ),
1319 array( 'status' => 404 )
1320 );
1321 }
1322
1323 // Legacy printer rows saved before the provider field existed must test
1324 // as the default provider, not fall through to the no-diagnostic error.
1325 $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
1326
1327 if ( 'printnode' === $provider ) {
1328 return $this->test_print_printnode( $printer );
1329 }
1330
1331 if ( 'star-online' === $provider ) {
1332 return $this->test_print_star_online( $printer_id, $printer );
1333 }
1334
1335 try {
1336 $diag = ( new Cloud_Print_Diagnostic() )->build( $provider, (string) $printer['name'] );
1337 } catch ( \RuntimeException $e ) {
1338 return new WP_Error(
1339 'wcpos_print_job_no_diagnostic',
1340 __( 'Test print is not available for this printer yet.', 'woocommerce-pos' ),
1341 array( 'status' => 400 )
1342 );
1343 }
1344
1345 $id = $this->jobs->create(
1346 array(
1347 'printer_id' => $printer_id,
1348 'content_type' => $diag['content_type'],
1349 'payload' => $diag['payload'],
1350 )
1351 );
1352 if ( $id <= 0 ) {
1353 return new WP_Error(
1354 'wcpos_print_job_create_failed',
1355 __( 'Print job could not be created.', 'woocommerce-pos' ),
1356 array( 'status' => 500 )
1357 );
1358 }
1359
1360 $response = rest_ensure_response( $this->jobs->get( $id ) );
1361 $response->set_status( 201 );
1362
1363 return $response;
1364 }
1365
1366 /**
1367 * Queue a Star Markup test receipt and submit it through the push pipeline.
1368 *
1369 * @param string $printer_id Registered printer id.
1370 * @param array $printer Registered star-online printer.
1371 *
1372 * @return \WP_REST_Response|WP_Error
1373 */
1374 private function test_print_star_online( string $printer_id, array $printer ) {
1375 $markup = ( new Cloud_Print_Diagnostic() )->star_markup( (string) $printer['name'] );
1376
1377 $id = $this->jobs->create(
1378 array(
1379 'printer_id' => $printer_id,
1380 'content_type' => 'text/vnd.star.markup',
1381 'payload' => base64_encode( $markup ),
1382 )
1383 );
1384 if ( $id <= 0 ) {
1385 return new WP_Error(
1386 'wcpos_print_job_create_failed',
1387 __( 'Print job could not be created.', 'woocommerce-pos' ),
1388 array( 'status' => 500 )
1389 );
1390 }
1391
1392 wp_schedule_single_event( time(), Cloud_Print_Trigger_Service::CRON_SUBMIT, array( $id ) );
1393 ( new \WCPOS\WooCommercePOS\Services\Cloud_Print_Submit_Service() )->submit( $id );
1394
1395 $response = rest_ensure_response( $this->jobs->get( $id ) );
1396 $response->set_status( 201 );
1397
1398 return $response;
1399 }
1400
1401 /**
1402 * Submit a diagnostic PDF to a PrintNode printer.
1403 *
1404 * @param array $printer Registered PrintNode printer.
1405 *
1406 * @return \WP_REST_Response|WP_Error
1407 */
1408 private function test_print_printnode( array $printer ) {
1409 $api_key = (string) ( $printer['printnode_api_key'] ?? '' );
1410 $pn_printer_id = (int) ( $printer['printnode_printer_id'] ?? 0 );
1411 if ( '' === $api_key || 0 === $pn_printer_id ) {
1412 return new WP_Error(
1413 'wcpos_print_job_printnode_unconfigured',
1414 __( 'This PrintNode printer is missing its API key or printer id.', 'woocommerce-pos' ),
1415 array( 'status' => 400 )
1416 );
1417 }
1418
1419 try {
1420 $pdf = ( new Cloud_Print_Diagnostic() )->build_pdf( (string) $printer['name'] );
1421 } catch ( \Throwable $e ) {
1422 // Defense in depth: a Dompdf/font-cache/temp-dir failure must not
1423 // surface as an uncaught 500. Mirror the render_payload() guard.
1424 Logger::log( 'Cloud print: PrintNode diagnostic PDF render failed: ' . $e->getMessage() );
1425
1426 return new WP_Error(
1427 'wcpos_print_job_diagnostic_failed',
1428 __( 'Could not generate the test print.', 'woocommerce-pos' ),
1429 array( 'status' => 500 )
1430 );
1431 }
1432
1433 $result = ( new PrintNode_Client( $api_key ) )->submit_job(
1434 $pn_printer_id,
1435 'WCPOS Test Print',
1436 'pdf_base64',
1437 base64_encode( $pdf )
1438 );
1439
1440 if ( is_wp_error( $result ) ) {
1441 return new WP_Error(
1442 'wcpos_print_job_printnode_failed',
1443 $result->get_error_message(),
1444 array( 'status' => 502 )
1445 );
1446 }
1447
1448 return new WP_REST_Response(
1449 array(
1450 'submitted' => true,
1451 'external_provider' => 'printnode',
1452 'external_job_id' => (string) $result['id'],
1453 'external_state' => 'submitted',
1454 ),
1455 201
1456 );
1457 }
1458
1459 /**
1460 * Extract sanitized cash-drawer options from a REST request.
1461 *
1462 * @param WP_REST_Request $request Request.
1463 *
1464 * @return array{auto_open_drawer:bool, drawer_connector:string}
1465 */
1466 private function drawer_options_from_request( WP_REST_Request $request ): array {
1467 $auto = $request->get_param( 'autoOpenDrawer' );
1468 if ( null === $auto ) {
1469 $auto = $request->get_param( 'auto_open_drawer' );
1470 }
1471
1472 $connector = $request->get_param( 'drawerConnector' );
1473 if ( null === $connector ) {
1474 $connector = $request->get_param( 'drawer_connector' );
1475 }
1476
1477 return array(
1478 'auto_open_drawer' => rest_sanitize_boolean( $auto ),
1479 'drawer_connector' => Print_Job_Service::normalize_drawer_connector( (string) $connector ),
1480 );
1481 }
1482
1483 /**
1484 * Validate a job against the target printer's provider.
1485 *
1486 * @param array|null $printer Registered printer, or null when unknown.
1487 * @param string $payload Base64 payload (raw jobs).
1488 * @param string $format Render format (order-based jobs).
1489 * @param bool $is_template_job Whether this is an order/template job.
1490 *
1491 * @return true|WP_Error
1492 */
1493 private function validate_job_for_printer( ?array $printer, string $payload, string $format, bool $is_template_job ) {
1494 if ( null === $printer ) {
1495 return true;
1496 }
1497 $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
1498
1499 if ( 'epos-xml' === Provider::wire_format( $provider, 'thermal' ) ) {
1500 if ( '' !== $payload ) {
1501 return new WP_Error(
1502 'wcpos_print_job_incompatible',
1503 __( 'Epson Server Direct Print accepts order-based ePOS-Print jobs only, not raw payloads.', 'woocommerce-pos' ),
1504 array( 'status' => 400 )
1505 );
1506 }
1507 if ( '' !== $format && 'epos-xml' !== $format ) {
1508 return new WP_Error(
1509 'wcpos_print_job_incompatible',
1510 __( 'Epson Server Direct Print requires the epos-xml format.', 'woocommerce-pos' ),
1511 array( 'status' => 400 )
1512 );
1513 }
1514
1515 return true;
1516 }
1517
1518 if ( 'epos-xml' === $format ) {
1519 return new WP_Error(
1520 'wcpos_print_job_incompatible',
1521 __( 'Star CloudPRNT does not accept the epos-xml format.', 'woocommerce-pos' ),
1522 array( 'status' => 400 )
1523 );
1524 }
1525
1526 // The fixed-layout 'escpos' adapter emits a language StarPRNT-native
1527 // printers cannot decode, and the fixed-layout 'starprnt' adapter is a
1528 // placeholder that emits marker text, not wire bytes. Fail these jobs
1529 // loudly instead of queueing bytes the printer will reject.
1530 if ( ! $is_template_job && 'star-cloudprnt' === $provider && in_array( $format, array( 'escpos', 'starprnt' ), true ) ) {
1531 return new WP_Error(
1532 'wcpos_print_job_incompatible',
1533 __( 'Star CloudPRNT printers require order-based template jobs or a raw payload.', 'woocommerce-pos' ),
1534 array( 'status' => 400 )
1535 );
1536 }
1537
1538 return true;
1539 }
1540
1541 /**
1542 * Serve the pending relay verification token (public; consent callback).
1543 *
1544 * @return \WP_REST_Response|WP_Error
1545 */
1546 public function relay_verification() {
1547 $token = Cloud_Print_Relay_Service::pending_verification_token();
1548 if ( null === $token ) {
1549 return new WP_Error(
1550 'wcpos_relay_no_pending_verification',
1551 __( 'No relay verification is pending.', 'woocommerce-pos' ),
1552 array( 'status' => 404 )
1553 );
1554 }
1555
1556 return rest_ensure_response( array( 'token' => $token ) );
1557 }
1558
1559 /**
1560 * Register this site with the WCPOS Cloud Print relay.
1561 *
1562 * @return \WP_REST_Response|WP_Error
1563 */
1564 public function relay_register() {
1565 $result = Cloud_Print_Relay_Service::register_site();
1566
1567 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
1568 }
1569
1570 /**
1571 * Permission check for relay registration routes.
1572 *
1573 * Registering rotates the site's relay credentials, so it needs the
1574 * settings-management capability, not the cashier-level print capability.
1575 */
1576 public function relay_manage_permissions_check(): bool {
1577 return current_user_can( 'manage_woocommerce_pos' );
1578 }
1579
1580 /**
1581 * Check permissions for cashier-level print job actions.
1582 *
1583 * @param WP_REST_Request $request Request.
1584 *
1585 * @return bool|WP_Error
1586 */
1587 public function manage_permissions_check( $request ) {
1588 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
1589 return new WP_Error(
1590 'wcpos_rest_insufficient_permissions',
1591 __( 'Sorry, you cannot manage print jobs.', 'woocommerce-pos' ),
1592 array( 'status' => rest_authorization_required_code() )
1593 );
1594 }
1595
1596 return true;
1597 }
1598 }
1599