PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
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 / Templates / Receipt.php

Receipt.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.16, at includes/Templates/Receipt.php

554 lines 17.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Receipt template handler.
4 *
5 * @author Paul Kilmurray <paul@kilbot.com>
6 *
7 * @see http://wcpos.com
8 * @package WCPOS\WooCommercePOS
9 */
10
11 namespace WCPOS\WooCommercePOS\Templates;
12
13 use Exception;
14 use WCPOS\WooCommercePOS\Logger;
15 use WCPOS\WooCommercePOS\Services\Receipt_Data_Builder;
16 use WCPOS\WooCommercePOS\Services\Receipt_Renderer_Factory;
17 use WCPOS\WooCommercePOS\Services\Template_Pdf_Service;
18 use WCPOS\WooCommercePOS\Templates as TemplatesManager;
19 use WCPOS\WooCommercePOS\Templates\Thermal\Html_Thermal_Emitter;
20 use WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer;
21
22 /**
23 * Receipt class.
24 */
25 class Receipt {
26 /**
27 * The order ID.
28 *
29 * @var int
30 */
31 private $order_id;
32
33 /**
34 * Flag to track if we're rendering a template.
35 *
36 * @var bool
37 */
38 private static $rendering = false;
39
40 /**
41 * Constructor.
42 *
43 * @param int $order_id The order ID.
44 */
45 public function __construct( int $order_id ) {
46 $this->order_id = $order_id;
47
48 add_filter( 'show_admin_bar', '__return_false' );
49 add_action( 'woocommerce_pos_receipt_head', array( $this, 'receipt_head' ) );
50 }
51
52 /**
53 * Adds a script to the head of the WordPress template when the
54 * 'woocommerce_pos_receipt_head' action is triggered. The script listens for
55 * a 'message' event with a specific action ('wcpos-print-receipt') and, upon
56 * receiving such an event, triggers the browser's print functionality.
57 *
58 * Usage: Call `do_action( 'woocommerce_pos_receipt_head' );` at the desired
59 * location in your template file to include the script.
60 */
61 public function receipt_head(): void {
62 ?>
63 <script>
64 window.addEventListener("message", ({data}) => {
65 if (data.action && data.action === "wcpos-print-receipt") {
66 window.print();
67 }
68 }, false);
69 </script>
70 <?php
71 }
72
73
74 /**
75 * Get the receipt template.
76 *
77 * @return void
78 */
79 public function get_template(): void {
80 try {
81 $order = wc_get_order( $this->order_id );
82
83 // Validate order key for security. Missing orders share the permission
84 // message so unauthenticated requests cannot enumerate order IDs.
85 $order_key = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : '';
86 if ( ! $order || empty( $order_key ) || ! hash_equals( $order->get_order_key(), $order_key ) ) {
87 wp_die( esc_html__( 'You do not have permission to view this receipt.', 'woocommerce-pos' ) );
88 }
89
90 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
91 $format = isset( $_GET['format'] ) ? sanitize_text_field( wp_unslash( $_GET['format'] ) ) : '';
92 if ( 'pdf' === $format ) {
93 $this->render_pdf( $order );
94 }
95
96 /*
97 * Fires before rendering the receipt template.
98 *
99 * @param int $order_id Order ID.
100 * @param WC_Abstract_Order $order Order object.
101 *
102 * @since 1.8.0
103 *
104 * @hook woocommerce_pos_before_template_render
105 */
106 do_action( 'woocommerce_pos_before_template_render', $this->order_id, $order );
107
108 /**
109 * Check for custom template first.
110 */
111 $custom_template = $this->get_custom_template();
112 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
113 $is_preview = isset( $_GET['wcpos_preview_template'] ) && current_user_can( 'manage_woocommerce_pos' );
114 $receipt_data = $this->get_receipt_data( $order, $is_preview ? 'preview' : 'live' );
115
116 // Start output buffering and register shutdown handler for fatal errors.
117 self::$rendering = true;
118 register_shutdown_function( array( __CLASS__, 'handle_shutdown' ) );
119 ob_start();
120
121 if ( $custom_template ) {
122 $this->render_custom_template( $custom_template, $order, $receipt_data );
123 } else {
124 /**
125 * Put WC_Order into the global scope so that the template can access it.
126 */
127 $path = $this->get_template_path( 'receipt.php' );
128 include $path;
129 }
130
131 // If we got here, template rendered successfully.
132 self::$rendering = false;
133 ob_end_flush();
134
135 /*
136 * Fires after rendering the receipt template.
137 *
138 * @param int $order_id Order ID.
139 * @param WC_Abstract_Order $order Order object.
140 *
141 * @since 1.8.0
142 *
143 * @hook woocommerce_pos_after_template_render
144 */
145 do_action( 'woocommerce_pos_after_template_render', $this->order_id, $order );
146
147 exit;
148 } catch ( Exception $e ) {
149 self::$rendering = false;
150 if ( ob_get_level() ) {
151 ob_end_clean();
152 }
153 wc_print_notice( $e->getMessage(), 'error' );
154 }
155 }
156
157 /**
158 * Render and serve a custom receipt template as a PDF download.
159 *
160 * @param \WC_Abstract_Order $order Order object.
161 *
162 * @return void
163 */
164 private function render_pdf( \WC_Abstract_Order $order ): void {
165 /*
166 * Filters the receipt template used for storefront PDF downloads.
167 *
168 * Receives the same template array resolved for the HTML receipt surface
169 * (including the woocommerce_pos_active_receipt_template filter and the
170 * ?template= query param), so both surfaces stay in sync by default.
171 *
172 * @param null|array $template Resolved template data or null.
173 * @param WC_Abstract_Order $order Order object.
174 *
175 * @returns null|array Template data or null.
176 *
177 * @since 1.9.11
178 *
179 * @hook woocommerce_pos_storefront_receipt_template
180 */
181 $template = apply_filters( 'woocommerce_pos_storefront_receipt_template', $this->get_custom_template(), $order );
182 if ( ! \is_array( $template ) || empty( $template ) ) {
183 wp_die(
184 esc_html__( 'No receipt template is configured.', 'woocommerce-pos' ),
185 '',
186 array( 'response' => 404 )
187 );
188 }
189
190 try {
191 $pdf = ( new Template_Pdf_Service() )->render( $template, $order );
192 } catch ( \Throwable $e ) {
193 Logger::log( sprintf( 'Storefront receipt PDF render failed for order %d: %s', $order->get_id(), $e->getMessage() ) );
194 wp_die(
195 esc_html__( 'Could not generate the receipt PDF.', 'woocommerce-pos' ),
196 '',
197 array( 'response' => 500 )
198 );
199 }
200 if ( '' === $pdf ) {
201 Logger::log( sprintf( 'Storefront receipt PDF render failed for order %d: renderer returned no data.', $order->get_id() ) );
202 wp_die(
203 esc_html__( 'Could not generate the receipt PDF.', 'woocommerce-pos' ),
204 '',
205 array( 'response' => 500 )
206 );
207 }
208
209 // Discard any open output buffers (e.g. zlib compression) so the
210 // Content-Length header matches the bytes actually sent.
211 while ( ob_get_level() ) {
212 if ( ! ob_end_clean() ) {
213 wp_die(
214 esc_html__( 'Could not generate the receipt PDF.', 'woocommerce-pos' ),
215 '',
216 array( 'response' => 500 )
217 );
218 }
219 }
220
221 $order_number = sanitize_file_name( (string) $order->get_order_number() );
222 header( 'Content-Type: application/pdf' );
223 header( 'Content-Disposition: attachment; filename="receipt-' . $order_number . '.pdf"' );
224
225 // A remaining (non-removable) output buffer may transform the body on
226 // flush, e.g. ob_gzhandler, making the raw PDF byte count wrong. Only
227 // declare Content-Length when the output stream is unbuffered; browsers
228 // fall back to reading until the response ends.
229 if ( 0 === ob_get_level() ) {
230 header( 'Content-Length: ' . \strlen( $pdf ) );
231 }
232 header( 'Cache-Control: no-store' );
233 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
234 echo $pdf;
235 exit;
236 }
237
238 /**
239 * Render a custom template for the browser print surface.
240 *
241 * This route serves the HTML the POS prints via window.print(); PDFs render
242 * through Template_Pdf_Service and never pass here. Logicless output is
243 * wrapped with a print-only grayscale filter so physical printouts stay
244 * black-and-white while on-screen and PDF output keep their colour. Browsers
245 * that ignore the filter still print acceptably because template colours are
246 * print-safe (dark accent ink, near-white fills). Legacy PHP templates emit a
247 * full HTML document and own their print styling, so they render untouched.
248 *
249 * @param array $custom_template Template metadata/content.
250 * @param \WC_Abstract_Order $order Order object.
251 * @param array $receipt_data Canonical receipt payload.
252 */
253 private function render_custom_template( array $custom_template, \WC_Abstract_Order $order, array $receipt_data ): void {
254 $template_engine = $this->get_template_engine( $custom_template );
255 if ( 'thermal' === $template_engine ) {
256 $ast = ( new Thermal_Renderer() )->build_ast( $custom_template, $order, $receipt_data );
257 $thermal_html = ( new Html_Thermal_Emitter() )->emit( $ast );
258 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The emitter escapes every dynamic value.
259 echo $thermal_html;
260
261 return;
262 }
263
264 $renderer = ( new Receipt_Renderer_Factory() )->create( $template_engine );
265
266 if ( 'logicless' !== $template_engine ) {
267 $renderer->render( $custom_template, $order, $receipt_data );
268
269 return;
270 }
271
272 echo '<style>@media print { .wcpos-receipt-print-root { -webkit-filter: grayscale(1); filter: grayscale(1); } }</style>';
273 echo '<div class="wcpos-receipt-print-root">';
274 $renderer->render( $custom_template, $order, $receipt_data );
275 echo '</div>';
276 }
277
278 /**
279 * Get template engine type from metadata.
280 *
281 * @param array $template Template metadata.
282 *
283 * @return string
284 */
285 private function get_template_engine( array $template ): string {
286 $engine = isset( $template['engine'] ) ? sanitize_text_field( $template['engine'] ) : 'legacy-php';
287
288 return in_array( $engine, array( 'logicless', 'thermal', 'legacy-php' ), true ) ? $engine : 'legacy-php';
289 }
290
291 /**
292 * Shutdown handler to catch fatal errors during template rendering.
293 *
294 * @return void
295 */
296 public static function handle_shutdown(): void {
297 if ( ! self::$rendering ) {
298 return;
299 }
300
301 $error = error_get_last();
302
303 // Check if there was a fatal error.
304 if ( $error && \in_array( $error['type'], array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR ), true ) ) {
305 // Clean any partial output.
306 if ( ob_get_level() ) {
307 ob_end_clean();
308 }
309
310 // Display a user-friendly error page.
311 self::display_error_page( $error );
312 }
313 }
314
315 /**
316 * Display a user-friendly error page.
317 *
318 * @param array $error Error details from error_get_last().
319 *
320 * @return void
321 */
322 private static function display_error_page( array $error ): void {
323 $error_type = self::get_error_type_name( $error['type'] );
324
325 // Only show detailed error info to administrators.
326 $show_details = current_user_can( 'manage_options' ) || ( \defined( 'WP_DEBUG' ) && WP_DEBUG );
327
328 ?>
329 <!DOCTYPE html>
330 <html <?php language_attributes(); ?>>
331 <head>
332 <meta charset="<?php bloginfo( 'charset' ); ?>">
333 <meta name="viewport" content="width=device-width, initial-scale=1">
334 <title><?php /* translators: Short WCPOS UI label; keep concise. */ esc_html_e( 'Receipt Error', 'woocommerce-pos' ); ?></title>
335 <style>
336 * { box-sizing: border-box; margin: 0; padding: 0; }
337 body {
338 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
339 background: #f0f0f1;
340 color: #1d2327;
341 padding: 20px;
342 line-height: 1.6;
343 }
344 .error-container {
345 max-width: 600px;
346 margin: 40px auto;
347 background: #fff;
348 border-left: 4px solid #d63638;
349 box-shadow: 0 1px 1px rgba(0,0,0,.04);
350 padding: 24px;
351 }
352 h1 {
353 color: #d63638;
354 font-size: 1.3em;
355 margin-bottom: 16px;
356 display: flex;
357 align-items: center;
358 gap: 10px;
359 }
360 h1::before {
361 content: "⚠️";
362 }
363 p { margin-bottom: 12px; }
364 .error-details {
365 background: #f6f7f7;
366 border: 1px solid #dcdcde;
367 padding: 16px;
368 margin-top: 16px;
369 font-family: Consolas, Monaco, monospace;
370 font-size: 13px;
371 overflow-x: auto;
372 word-break: break-word;
373 }
374 .error-details strong { color: #d63638; }
375 .suggestions {
376 margin-top: 20px;
377 padding: 16px;
378 background: #fcf9e8;
379 border: 1px solid #dba617;
380 }
381 .suggestions h2 {
382 font-size: 1em;
383 margin-bottom: 10px;
384 }
385 .suggestions ul {
386 margin-left: 20px;
387 }
388 .suggestions li {
389 margin-bottom: 6px;
390 }
391 </style>
392 </head>
393 <body>
394 <div class="error-container">
395 <h1><?php /* translators: Short WCPOS UI label; keep concise. */ esc_html_e( 'Receipt Template Error', 'woocommerce-pos' ); ?></h1>
396 <p><?php esc_html_e( 'There was a problem rendering the receipt template. This is usually caused by a syntax error or undefined variable in the template code.', 'woocommerce-pos' ); ?></p>
397
398 <?php if ( $show_details ) { ?>
399 <div class="error-details">
400 <strong><?php echo esc_html( $error_type ); ?>:</strong><br>
401 <?php echo esc_html( $error['message'] ); ?><br><br>
402 <strong><?php /* translators: Short WCPOS UI label; keep concise. */ esc_html_e( 'File:', 'woocommerce-pos' ); ?></strong> <?php echo esc_html( $error['file'] ); ?><br>
403 <strong><?php /* translators: Short WCPOS UI label; keep concise. */ esc_html_e( 'Line:', 'woocommerce-pos' ); ?></strong> <?php echo esc_html( $error['line'] ); ?>
404 </div>
405
406 <div class="suggestions">
407 <h2><?php /* translators: Short WCPOS UI label; keep concise. */ esc_html_e( 'Suggestions:', 'woocommerce-pos' ); ?></h2>
408 <ul>
409 <li><?php /* translators: Help text shown on the receipt template fatal error page. */ esc_html_e( 'Check the template file for syntax errors (missing semicolons, brackets, etc.)', 'woocommerce-pos' ); ?></li>
410 <li><?php /* translators: Help text shown on the receipt template fatal error page. */ esc_html_e( 'Ensure all variables used in the template are defined', 'woocommerce-pos' ); ?></li>
411 <li><?php esc_html_e( 'Verify that any custom functions or classes exist', 'woocommerce-pos' ); ?></li>
412 <li><?php esc_html_e( 'Try resetting to the default receipt template', 'woocommerce-pos' ); ?></li>
413 </ul>
414 </div>
415 <?php } else { ?>
416 <p><?php esc_html_e( 'Please contact the site administrator for assistance.', 'woocommerce-pos' ); ?></p>
417 <?php } ?>
418 </div>
419 </body>
420 </html>
421 <?php
422 }
423
424 /**
425 * Get human-readable error type name.
426 *
427 * @param int $type Error type constant.
428 *
429 * @return string Human-readable error type.
430 */
431 private static function get_error_type_name( int $type ): string {
432 $types = array(
433 E_ERROR => 'Fatal Error',
434 E_PARSE => 'Parse Error',
435 E_CORE_ERROR => 'Core Error',
436 E_COMPILE_ERROR => 'Compile Error',
437 );
438
439 return $types[ $type ] ?? 'Error';
440 }
441
442 /**
443 * Get the template path.
444 *
445 * @param string $file_name The template file name.
446 *
447 * @return null|mixed
448 */
449 private function get_template_path( string $file_name ) {
450 /*
451 * Filters the path to the receipt template file.
452 *
453 * @param {string} $path Full server path to the template file.
454 *
455 * @returns {string} $path Full server path to the template file.
456 *
457 * @since 1.0.0
458 *
459 * @hook woocommerce_pos_print_receipt_path
460 */
461 return apply_filters( 'woocommerce_pos_print_receipt_path', woocommerce_pos_locate_template( $file_name ) );
462 }
463
464 /**
465 * Get receipt data payload for the selected mode.
466 *
467 * @param \WC_Abstract_Order $order Order object.
468 * @param string $mode Receipt mode.
469 *
470 * @return array
471 */
472 private function get_receipt_data( \WC_Abstract_Order $order, string $mode ): array {
473 $mode = 'fiscal' === $mode ? 'live' : $mode;
474 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
475 $store_id = 'preview' === $mode && isset( $_GET['store_id'] ) ? (int) $_GET['store_id'] : 0;
476 $pos_store = $store_id > 0 ? wcpos_get_store( $store_id ) : null;
477 if ( $store_id > 0 && ! \is_object( $pos_store ) ) {
478 $pos_store = null;
479 }
480
481 return ( new Receipt_Data_Builder() )->build( $order, $mode, $pos_store );
482 }
483
484 /**
485 * Get the active custom receipt template.
486 *
487 * @return null|array Custom template data or null if not found.
488 */
489 private function get_custom_template(): ?array {
490 /**
491 * Filters the active receipt template.
492 *
493 * @param null|array $template Active template data or null.
494 *
495 * @returns array|null Active template data or null.
496 *
497 * @since 1.8.0
498 *
499 * @hook woocommerce_pos_active_receipt_template
500 */
501 $template = apply_filters( 'woocommerce_pos_active_receipt_template', null );
502
503 if ( $template ) {
504 return $template;
505 }
506
507 // Check for preview template parameter (used in admin preview).
508 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
509 if ( isset( $_GET['wcpos_preview_template'] ) && current_user_can( 'manage_woocommerce_pos' ) ) {
510 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
511 $preview_id = sanitize_text_field( wp_unslash( $_GET['wcpos_preview_template'] ) );
512
513 if ( is_numeric( $preview_id ) ) {
514 // Database template.
515 return TemplatesManager::get_template( (int) $preview_id );
516 }
517
518 // Virtual template (theme/plugin-pro/plugin-core).
519 $template = TemplatesManager::get_virtual_template( $preview_id, 'receipt' );
520 if ( $template ) {
521 return $template;
522 }
523
524 // Gallery template (e.g. "standard-receipt").
525 return TemplatesManager::get_gallery_template_by_key( $preview_id );
526 }
527
528 // Check for template selection parameter (used by POS app to switch templates).
529 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
530 if ( isset( $_GET['template'] ) ) {
531 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
532 $template_id = sanitize_text_field( wp_unslash( $_GET['template'] ) );
533
534 if ( is_numeric( $template_id ) ) {
535 $post_id = (int) $template_id;
536 $template = 'publish' === get_post_status( $post_id ) ? TemplatesManager::get_template( $post_id ) : null;
537 } else {
538 $template = TemplatesManager::get_virtual_template( $template_id, 'receipt' );
539 if ( ! $template ) {
540 $template = TemplatesManager::get_gallery_template_by_key( $template_id );
541 }
542 }
543
544 // Only allow published receipt templates.
545 if ( $template && 'receipt' === ( $template['type'] ?? '' ) ) {
546 return $template;
547 }
548 }
549
550 // Get active receipt template (can be virtual or from database).
551 return TemplatesManager::get_active_template( 'receipt' );
552 }
553 }
554