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

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

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