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

Receipt_Data_Builder.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.18, at includes/Services/Receipt_Data_Builder.php

986 lines 33.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Receipt data builder service.
4 *
5 * @package WCPOS\WooCommercePOS\Services
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 use DateTimeZone;
11 use WCPOS\WooCommercePOS\Abstracts\Store;
12 use WC_Abstract_Order;
13
14 /**
15 * Receipt_Data_Builder class.
16 */
17 class Receipt_Data_Builder {
18 /**
19 * Build a canonical receipt payload.
20 *
21 * @param WC_Abstract_Order $order Receipt order.
22 * @param string $mode Receipt mode ('live', 'fiscal', 'preview'), passed unchanged to the receipt data filter.
23 * @param object|null $pos_store POS store object. Falls back to order meta or default.
24 *
25 * @return array
26 */
27 public function build( WC_Abstract_Order $order, string $mode = 'live', $pos_store = null ): array {
28 $wc_status = method_exists( $order, 'get_status' ) ? (string) $order->get_status() : '';
29 $status_label = '';
30 if ( '' !== $wc_status && function_exists( 'wc_get_order_status_name' ) ) {
31 $status_label = (string) wc_get_order_status_name( $wc_status );
32 }
33
34 $order_store_id = (int) $order->get_meta( '_pos_store' );
35 $missing_order_store_id = 0;
36 if ( null === $pos_store ) {
37 $pos_store = $order_store_id > 0 ? wcpos_get_store(
38 $order_store_id,
39 array(
40 'status' => array( 'publish', 'trash' ),
41 )
42 ) : wcpos_get_store();
43
44 if ( $order_store_id > 0 && ! \is_object( $pos_store ) ) {
45 $missing_order_store_id = $order_store_id;
46 }
47 }
48 if ( ! \is_object( $pos_store ) && 0 === $missing_order_store_id ) {
49 $pos_store = wcpos_get_store();
50 }
51 if ( ! \is_object( $pos_store ) ) {
52 $pos_store = $missing_order_store_id > 0 ? new \stdClass() : new Store();
53 }
54
55 $store_resolver = new Receipt_Store_Resolver( $pos_store );
56 $date_timezone = $store_resolver->resolve_store_timezone();
57 $date_locale = $store_resolver->resolve_locale();
58 $order_data = array(
59 'id' => $order->get_id(),
60 'number' => (string) $order->get_order_number(),
61 'currency' => (string) $order->get_currency(),
62 'customer_note' => (string) $order->get_customer_note(),
63 'wc_status' => $wc_status,
64 'status_label' => $status_label,
65 'created_via' => method_exists( $order, 'get_created_via' ) ? (string) $order->get_created_via() : '',
66 'created' => $this->format_wc_datetime_in_timezone( $order->get_date_created(), $date_timezone, $date_locale ),
67 'paid' => $this->format_wc_datetime_in_timezone( $order->get_date_paid(), $date_timezone, $date_locale ),
68 'completed' => $this->format_wc_datetime_in_timezone( $order->get_date_completed(), $date_timezone, $date_locale ),
69 // Render-time timestamp: refreshed on every build() call so reprints
70 // show the actual print time, not a value persisted to the database.
71 'printed' => Receipt_Date_Formatter::from_timestamp( time(), $date_timezone, $date_locale ),
72 // Payment fields — templates can render a "How to pay" section guarded
73 // by {{#order.needs_payment}}…{{/order.needs_payment}}. payment_url is
74 // always populated (WC's order-pay endpoint accepts the key regardless
75 // of status); the boolean controls whether to show it.
76 'needs_payment' => method_exists( $order, 'needs_payment' ) ? (bool) $order->needs_payment() : false,
77 'payment_url' => method_exists( $order, 'get_checkout_payment_url' ) ? (string) $order->get_checkout_payment_url() : '',
78 );
79
80 $display_incl = 'incl' === $store_resolver->resolve_store_option_string(
81 'get_tax_display_cart',
82 get_option( 'woocommerce_tax_display_cart', 'excl' )
83 );
84 $presentation_hints = $store_resolver->build_presentation_hints( (string) $order->get_currency() );
85 $tax = $store_resolver->build_tax_section();
86
87 // $missing_order_store_id > 0 only ever happens alongside the bare \stdClass
88 // assigned above, so no getter resolves and every fallback below is taken.
89 // That is what keeps a deleted store's receipt showing the recorded store ID
90 // rather than silently borrowing the current store's name and address.
91 $store_fallbacks = array();
92 if ( $missing_order_store_id > 0 ) {
93 $store_fallbacks['id'] = $missing_order_store_id;
94 // translators: %d: Historical POS store ID that no longer exists.
95 $store_fallbacks['name'] = sprintf( __( 'Store #%d', 'woocommerce-pos' ), $missing_order_store_id );
96 }
97
98 $store = $store_resolver->build_store_section( $store_fallbacks );
99
100 $cashier = array(
101 'id' => (int) $order->get_meta( '_pos_user' ),
102 'name' => '',
103 );
104 if ( $cashier['id'] > 0 ) {
105 $user = get_user_by( 'id', $cashier['id'] );
106 if ( $user ) {
107 $cashier['name'] = $user->display_name;
108 }
109 }
110
111 $customer_id = $order->get_customer_id();
112 $customer_name = trim( $order->get_formatted_billing_full_name() );
113
114 if ( ! $customer_id && '' === $customer_name ) {
115 $customer_name = /* translators: Short WCPOS UI label; keep concise. */ __( 'Guest', 'woocommerce-pos' );
116 }
117
118 $tax_ids = ( new Tax_Id_Reader() )->read_for_order( $order );
119 $tax_ids = Receipt_Sections::label_tax_ids( $tax_ids, 'customer', $presentation_hints['locale'] ?? '' );
120
121 $customer = array(
122 'id' => $customer_id ? $customer_id : null,
123 'name' => $customer_name,
124 'billing_address' => $order->get_address( 'billing' ),
125 'shipping_address' => $order->get_address( 'shipping' ),
126 // Structured TaxId[] — read fallback across the legacy meta-key inventory.
127 'tax_ids' => $tax_ids,
128 );
129
130 $lines = array();
131 foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) {
132 if ( ! $item instanceof \WC_Order_Item_Product ) {
133 continue;
134 }
135
136 $line_total_excl = (float) $item->get_total();
137 $line_tax_total = (float) $item->get_total_tax();
138 $line_total_incl = $line_total_excl + $line_tax_total;
139
140 $line_subtotal_excl = (float) $item->get_subtotal();
141 $line_subtotal_tax = (float) $item->get_subtotal_tax();
142 $line_subtotal_incl = $line_subtotal_excl + $line_subtotal_tax;
143
144 $qty = (float) $item->get_quantity();
145 if ( $qty <= 0 ) {
146 $qty = 0.0;
147 }
148 $calc_dp = wc_get_price_decimals();
149 $unit_price_incl = $qty > 0 ? round( $line_total_incl / $qty, $calc_dp ) : 0.0;
150 $unit_price_excl = $qty > 0 ? round( $line_total_excl / $qty, $calc_dp ) : 0.0;
151 $unit_subtotal_incl = $qty > 0 ? round( $line_subtotal_incl / $qty, $calc_dp ) : 0.0;
152 $unit_subtotal_excl = $qty > 0 ? round( $line_subtotal_excl / $qty, $calc_dp ) : 0.0;
153
154 $discounts_incl = max( 0, $line_subtotal_incl - $line_total_incl );
155 $discounts_excl = max( 0, $line_subtotal_excl - $line_total_excl );
156
157 $qty_refunded = method_exists( $order, 'get_qty_refunded_for_item' )
158 ? abs( (float) $order->get_qty_refunded_for_item( $item_id ) )
159 : 0.0;
160 $total_refunded = method_exists( $order, 'get_total_refunded_for_item' )
161 ? abs( (float) $order->get_total_refunded_for_item( $item_id ) )
162 : 0.0;
163 $price_convenience = $this->get_line_price_convenience_fields(
164 $item,
165 $order,
166 $qty,
167 $unit_subtotal_incl,
168 $unit_subtotal_excl,
169 $line_subtotal_incl,
170 $line_subtotal_excl,
171 $line_total_incl,
172 $line_total_excl
173 );
174
175 $line = array(
176 'key' => (string) $item_id,
177 'sku' => $item->get_product() ? $item->get_product()->get_sku() : '',
178 'name' => $item->get_name(),
179 'qty' => $qty,
180 'qty_refunded' => $qty_refunded,
181 'unit_subtotal' => array(
182 'incl' => $unit_subtotal_incl,
183 'excl' => $unit_subtotal_excl,
184 ),
185 'unit_price' => array(
186 'incl' => $unit_price_incl,
187 'excl' => $unit_price_excl,
188 ),
189 'line_subtotal' => array(
190 'incl' => $line_subtotal_incl,
191 'excl' => $line_subtotal_excl,
192 ),
193 'discounts' => array(
194 'incl' => $discounts_incl,
195 'excl' => $discounts_excl,
196 ),
197 'line_total' => array(
198 'incl' => $line_total_incl,
199 'excl' => $line_total_excl,
200 ),
201 'total_refunded' => $total_refunded,
202 'taxes' => $this->get_line_taxes( $item ),
203 'meta' => $this->get_item_meta_pairs( $item ),
204 'attributes' => $this->get_product_attribute_pairs( $item ),
205 );
206 $lines[] = Receipt_Sections::line( array_merge( $line, $price_convenience ), $display_incl );
207 }
208
209 $shipping = array();
210 foreach ( $order->get_items( 'shipping' ) as $shipping_item ) {
211 if ( ! $shipping_item instanceof \WC_Order_Item_Shipping ) {
212 continue;
213 }
214 $ship_total_excl = (float) $shipping_item->get_total();
215 $ship_total_tax = (float) $shipping_item->get_total_tax();
216 $ship_total_incl = $ship_total_excl + $ship_total_tax;
217 $shipping[] = Receipt_Sections::shipping(
218 $shipping_item->get_name(),
219 (string) $shipping_item->get_method_id(),
220 array(
221 'incl' => $ship_total_incl,
222 'excl' => $ship_total_excl,
223 ),
224 $this->get_item_taxes( $shipping_item ),
225 $this->get_item_meta_pairs( $shipping_item ),
226 $display_incl
227 );
228 }
229
230 $fees = array();
231 foreach ( $order->get_fees() as $fee ) {
232 $fee_total_excl = (float) $fee->get_total();
233 $fee_total_tax = (float) $fee->get_total_tax();
234 $fee_total_incl = $fee_total_excl + $fee_total_tax;
235 $fees[] = Receipt_Sections::fee(
236 $fee->get_name(),
237 array(
238 'incl' => $fee_total_incl,
239 'excl' => $fee_total_excl,
240 ),
241 $this->get_item_taxes( $fee ),
242 $this->get_item_meta_pairs( $fee ),
243 $display_incl
244 );
245 }
246
247 $discounts = array();
248 foreach ( $order->get_items( 'coupon' ) as $coupon_item ) {
249 if ( ! $coupon_item instanceof \WC_Order_Item_Coupon ) {
250 continue;
251 }
252 $coupon = $this->get_order_coupon( $coupon_item );
253 $coupon_excl = (float) $coupon_item->get_discount();
254 $coupon_tax = (float) $coupon_item->get_discount_tax();
255 $coupon_incl = $coupon_excl + $coupon_tax;
256 $discounts[] = Receipt_Sections::discount(
257 $this->get_coupon_label( $coupon_item, $coupon ),
258 $coupon_item->get_code(),
259 $coupon ? (string) $coupon->get_discount_type() : '',
260 array(
261 'incl' => $coupon_incl,
262 'excl' => $coupon_excl,
263 ),
264 $display_incl
265 );
266 }
267
268 $discount_total_excl = (float) $order->get_discount_total();
269 $discount_total_tax = (float) $order->get_discount_tax();
270 $discount_total_incl = $discount_total_excl + $discount_total_tax;
271
272 $tax_total = (float) $order->get_total_tax();
273 $total = (float) $order->get_total();
274 $refund_total = method_exists( $order, 'get_total_refunded' )
275 ? abs( (float) $order->get_total_refunded() )
276 : 0.0;
277 $totals = Receipt_Sections::totals(
278 $lines,
279 array(
280 'discount_total' => array(
281 'incl' => $discount_total_incl,
282 'excl' => $discount_total_excl,
283 ),
284 'tax_total' => $tax_total,
285 'total' => $total,
286 'paid_total' => $total,
287 'change_total' => (float) $order->get_meta( '_pos_cash_change' ),
288 'refund_total' => $refund_total,
289 ),
290 $display_incl
291 );
292
293 $payments = array(
294 Receipt_Sections::payment(
295 $order->get_payment_method(),
296 $order->get_payment_method_title(),
297 $total,
298 (string) $order->get_transaction_id(),
299 (float) $order->get_meta( '_pos_cash_amount_tendered' ),
300 (float) $order->get_meta( '_pos_cash_change' )
301 ),
302 );
303
304 $tax_summary = $this->get_tax_summary( $order );
305
306 $fiscal = Receipt_Payload_Assembler::fiscal(
307 array(
308 'immutable_id' => '',
309 'receipt_number' => '',
310 'sequence' => null,
311 'hash' => '',
312 'qr_payload' => '',
313 'tax_agency_code' => '',
314 'signed_at' => '',
315 'signature_excerpt' => '',
316 'document_label' => '',
317 'is_reprint' => false,
318 'reprint_count' => 0,
319 'extra_fields' => array(),
320 )
321 );
322
323 $data = Receipt_Payload_Assembler::assemble(
324 array(
325 'order' => $order_data,
326 'store' => $store,
327 'cashier' => $cashier,
328 'customer' => $customer,
329 'lines' => $lines,
330 'fees' => $fees,
331 'shipping' => $shipping,
332 'discounts' => $discounts,
333 'totals' => $totals,
334 'tax' => $tax,
335 'tax_summary' => $tax_summary,
336 'payments' => $payments,
337 'refunds' => $this->get_refunds( $order, $display_incl, $date_timezone, $date_locale ),
338 'fiscal' => $fiscal,
339 'presentation_hints' => $presentation_hints,
340 )
341 );
342
343 /**
344 * Filters the canonical receipt data before it is rendered or snapshotted.
345 *
346 * Runs for every receipt this builder produces: live receipts, fiscal
347 * snapshots captured at payment time, PDF downloads and legacy PHP
348 * templates. Extensions can add their own keys to any section (for
349 * example a flag on a `discounts[]` row) or adjust labels. Keys defined
350 * by Receipt_Data_Schema should keep their documented types.
351 *
352 * Sample previews for the template editor and gallery also run through this filter,
353 * with mode `preview` and an unsaved order whose id is 0; a plugin that needs a
354 * persisted order should return `$data` unchanged when `$order->get_id()` is 0.
355 *
356 * @param array $data Receipt data (see Receipt_Data_Schema).
357 * @param WC_Abstract_Order $order Order the receipt is for.
358 * @param string $mode Receipt mode: 'live', 'fiscal' or 'preview', passed unchanged.
359 *
360 * @since 1.10.8
361 *
362 * @hook woocommerce_pos_receipt_data
363 */
364 return (array) apply_filters( 'woocommerce_pos_receipt_data', $data, $order, $mode );
365 }
366
367
368 /**
369 * Read the recorded POS prices needed for historical receipt savings.
370 *
371 * @param \WC_Order_Item_Product $item Order item.
372 *
373 * @return array{price:float,regular_price:float,tax_status:string}|null
374 */
375 private function get_pos_price_data( \WC_Order_Item_Product $item ): ?array {
376 $raw = $item->get_meta( '_woocommerce_pos_data', true );
377 $data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $raw );
378 if (
379 ! \is_array( $data )
380 || ! isset( $data['price'], $data['regular_price'], $data['tax_status'] )
381 || ! is_numeric( $data['price'] )
382 || ! is_numeric( $data['regular_price'] )
383 || ! \in_array( $data['tax_status'], array( 'none', 'taxable', 'shipping' ), true )
384 ) {
385 return null;
386 }
387
388 return array(
389 'price' => (float) $data['price'],
390 'regular_price' => (float) $data['regular_price'],
391 'tax_status' => (string) $data['tax_status'],
392 );
393 }
394
395 /**
396 * Convert a recorded price into tax-inclusive/exclusive historical bases.
397 *
398 * @param float $value Recorded price in the order's entered-price basis.
399 * @param string $tax_status Recorded product tax status.
400 * @param bool $prices_include_tax Whether recorded prices include tax.
401 * @param float $subtotal_incl Stored line subtotal including tax.
402 * @param float $subtotal_excl Stored line subtotal excluding tax.
403 *
404 * @return array{incl:?float,excl:?float}
405 */
406 private function convert_recorded_price_bases(
407 float $value,
408 string $tax_status,
409 bool $prices_include_tax,
410 float $subtotal_incl,
411 float $subtotal_excl
412 ): array {
413 if ( 'none' === $tax_status || 0.0 === $value ) {
414 return array(
415 'incl' => $value,
416 'excl' => $value,
417 );
418 }
419
420 if ( $prices_include_tax ) {
421 return array(
422 'incl' => $value,
423 'excl' => 0.0 !== $subtotal_incl ? $value * $subtotal_excl / $subtotal_incl : null,
424 );
425 }
426
427 return array(
428 'incl' => 0.0 !== $subtotal_excl ? $value * $subtotal_incl / $subtotal_excl : null,
429 'excl' => $value,
430 );
431 }
432
433 /**
434 * Derive recorded prices and savings without changing WooCommerce discounts.
435 *
436 * @param \WC_Order_Item_Product $item Order item.
437 * @param WC_Abstract_Order $order Receipt order.
438 * @param float $qty Item quantity.
439 * @param float $unit_subtotal_incl Stored unit subtotal including tax.
440 * @param float $unit_subtotal_excl Stored unit subtotal excluding tax.
441 * @param float $subtotal_incl Stored line subtotal including tax.
442 * @param float $subtotal_excl Stored line subtotal excluding tax.
443 * @param float $total_incl Stored line total including tax.
444 * @param float $total_excl Stored line total excluding tax.
445 *
446 * @return array<string,array|bool>
447 */
448 private function get_line_price_convenience_fields(
449 \WC_Order_Item_Product $item,
450 WC_Abstract_Order $order,
451 float $qty,
452 float $unit_subtotal_incl,
453 float $unit_subtotal_excl,
454 float $subtotal_incl,
455 float $subtotal_excl,
456 float $total_incl,
457 float $total_excl
458 ): array {
459 $pos_data = $this->get_pos_price_data( $item );
460 $prices_include_tax = method_exists( $order, 'get_prices_include_tax' ) && $order->get_prices_include_tax();
461 $selling = array(
462 'incl' => $unit_subtotal_incl,
463 'excl' => $unit_subtotal_excl,
464 );
465 $regular = array(
466 'incl' => null,
467 'excl' => null,
468 );
469
470 if ( null !== $pos_data ) {
471 $recorded_selling = $this->convert_recorded_price_bases(
472 $pos_data['price'],
473 $pos_data['tax_status'],
474 $prices_include_tax,
475 $subtotal_incl,
476 $subtotal_excl
477 );
478 $regular = $this->convert_recorded_price_bases(
479 $pos_data['regular_price'],
480 $pos_data['tax_status'],
481 $prices_include_tax,
482 $subtotal_incl,
483 $subtotal_excl
484 );
485
486 foreach ( array( 'incl', 'excl' ) as $basis ) {
487 if ( null !== $recorded_selling[ $basis ] ) {
488 $selling[ $basis ] = $recorded_selling[ $basis ];
489 }
490 }
491 }
492
493 $unit_savings = array(
494 'incl' => null !== $regular['incl'] ? max( 0.0, $regular['incl'] - $selling['incl'] ) : null,
495 'excl' => null !== $regular['excl'] ? max( 0.0, $regular['excl'] - $selling['excl'] ) : null,
496 );
497 $multiply = static function ( ?float $value ) use ( $qty ): ?float {
498 return null === $value ? null : $value * $qty;
499 };
500 $line_regular = array(
501 'incl' => $multiply( $regular['incl'] ),
502 'excl' => $multiply( $regular['excl'] ),
503 );
504 $line_selling = array(
505 'incl' => $multiply( $selling['incl'] ),
506 'excl' => $multiply( $selling['excl'] ),
507 );
508 $line_savings = array(
509 'incl' => $multiply( $unit_savings['incl'] ),
510 'excl' => $multiply( $unit_savings['excl'] ),
511 );
512
513 $savings_in_discounts = false;
514 $stored = array(
515 'incl' => array(
516 'subtotal' => $subtotal_incl,
517 'total' => $total_incl,
518 ),
519 'excl' => array(
520 'subtotal' => $subtotal_excl,
521 'total' => $total_excl,
522 ),
523 );
524 // Compare on the recorded-price basis first: the opposite basis is float-derived
525 // from stored line ratios, and WooCommerce's own tax rounding can shift it by a
526 // sub-cent. Price-decimal precision tolerates that noise; rounding precision does not.
527 $precision = wc_get_price_decimals();
528 $basis_order = $prices_include_tax ? array( 'incl', 'excl' ) : array( 'excl', 'incl' );
529 foreach ( $basis_order as $basis ) {
530 if ( null === $line_savings[ $basis ] || $line_savings[ $basis ] <= 0.0 ) {
531 continue;
532 }
533
534 $distance_to_regular = round( abs( $stored[ $basis ]['subtotal'] - $line_regular[ $basis ] ), $precision );
535 $distance_to_selling = round( abs( $stored[ $basis ]['subtotal'] - $line_selling[ $basis ] ), $precision );
536 $stored_discount = round( max( 0.0, $stored[ $basis ]['subtotal'] - $stored[ $basis ]['total'] ), $precision );
537 $recorded_savings = round( $line_savings[ $basis ], $precision );
538
539 $savings_in_discounts = $distance_to_regular < $distance_to_selling
540 && $stored_discount >= $recorded_savings;
541 break;
542 }
543
544 return array(
545 'regular_price' => $regular,
546 'selling_price' => $selling,
547 'unit_savings' => $unit_savings,
548 'line_regular_total' => $line_regular,
549 'line_selling_total' => $line_selling,
550 'line_savings' => $line_savings,
551 'savings_in_discounts' => $savings_in_discounts,
552 );
553 }
554
555 /**
556 * Load the WooCommerce coupon behind an order coupon line, if it still exists.
557 *
558 * Order coupon lines only store the code; the coupon post may have been
559 * deleted since the order was placed, in which case templates fall back to
560 * the code alone.
561 *
562 * @param \WC_Order_Item_Coupon $coupon_item Coupon order item.
563 * @return \WC_Coupon|null
564 */
565 private function get_order_coupon( \WC_Order_Item_Coupon $coupon_item ): ?\WC_Coupon {
566 $code = (string) $coupon_item->get_code();
567 if ( '' === $code ) {
568 return null;
569 }
570
571 try {
572 $coupon = new \WC_Coupon( $code );
573 } catch ( \Exception $exception ) {
574 return null;
575 }
576
577 return $coupon->get_id() ? $coupon : null;
578 }
579
580 /**
581 * Resolve an optional human-facing coupon label for a receipt discount row.
582 *
583 * Coupons are identified by code; the code is already exposed as
584 * `discounts[].code`. Prefer a distinct, user-authored description, but
585 * fall back to the code so templates that render `label` always have text.
586 *
587 * @param \WC_Order_Item_Coupon $coupon_item Coupon order item.
588 * @param \WC_Coupon|null $coupon Coupon behind the line, when it still exists.
589 * @return string
590 */
591 private function get_coupon_label( \WC_Order_Item_Coupon $coupon_item, ?\WC_Coupon $coupon ): string {
592 $code = (string) $coupon_item->get_code();
593 if ( null === $coupon ) {
594 return $code;
595 }
596
597 $label = trim( wp_strip_all_tags( (string) $coupon->get_description() ) );
598
599 return '' !== $label && 0 !== strcasecmp( $label, $code ) ? $label : $code;
600 }
601
602 /**
603 * Build tax summary.
604 *
605 * @param WC_Abstract_Order $order Order object.
606 *
607 * @return array
608 */
609 private function get_tax_summary( WC_Abstract_Order $order ): array {
610 $summary = array();
611
612 $taxable_bases = $this->get_taxable_bases_by_rate_id( $order );
613
614 foreach ( $order->get_items( 'tax' ) as $tax_item ) {
615 $tax_amount = (float) $tax_item->get_tax_total() + (float) $tax_item->get_shipping_tax_total();
616 $rate = (float) $tax_item->get_rate_percent();
617 $rate_id = (string) $tax_item->get_rate_id();
618 $taxable_excl = $taxable_bases[ $rate_id ] ?? null;
619
620 $summary[] = Receipt_Sections::tax_summary_entry(
621 $rate_id,
622 $rate,
623 $tax_item->get_label( $order ),
624 method_exists( $tax_item, 'is_compound' ) ? (bool) $tax_item->is_compound() : false,
625 $taxable_excl,
626 $tax_amount
627 );
628 }
629
630 return $summary;
631 }
632
633
634 /**
635 * Sum post-discount pre-tax item totals by tax rate id.
636 *
637 * A line taxed by multiple rates contributes its full net total to each
638 * applicable rate. Compound rates intentionally use the pure pre-tax net
639 * base for the v1 contract.
640 *
641 * @param WC_Abstract_Order $order Order object.
642 *
643 * @return array<string,float>
644 */
645 private function get_taxable_bases_by_rate_id( WC_Abstract_Order $order ): array {
646 $bases = array();
647
648 foreach ( array( 'line_item', 'fee', 'shipping' ) as $item_type ) {
649 foreach ( $order->get_items( $item_type ) as $item ) {
650 if ( ! method_exists( $item, 'get_taxes' ) || ! method_exists( $item, 'get_total' ) ) {
651 continue;
652 }
653
654 $raw_taxes = $item->get_taxes();
655 $totals = isset( $raw_taxes['total'] ) && is_array( $raw_taxes['total'] ) ? $raw_taxes['total'] : array();
656 $base = (float) $item->get_total();
657
658 foreach ( $totals as $rate_id => $tax_amount ) {
659 if ( '' === (string) $rate_id || '' === (string) $tax_amount ) {
660 continue;
661 }
662
663 $key = (string) $rate_id;
664 if ( ! array_key_exists( $key, $bases ) ) {
665 $bases[ $key ] = 0.0;
666 }
667
668 $bases[ $key ] += $base;
669 }
670 }
671 }
672
673 return $bases;
674 }
675
676
677 /**
678 * Format a WooCommerce date in a resolved receipt timezone.
679 *
680 * @param \WC_DateTime|null $date WooCommerce date.
681 * @param DateTimeZone $timezone Receipt timezone.
682 * @param string $locale Receipt locale.
683 *
684 * @return array<string,string>
685 */
686 private function format_wc_datetime_in_timezone( $date, DateTimeZone $timezone, string $locale = '' ): array {
687 if ( ! $date ) {
688 return Receipt_Date_Formatter::empty();
689 }
690
691 return Receipt_Date_Formatter::from_timestamp( $date->getTimestamp(), $timezone, $locale );
692 }
693
694
695 /**
696 * Build line tax rows.
697 *
698 * @param \WC_Order_Item_Product $item Order line item.
699 *
700 * @return array
701 */
702 private function get_line_taxes( $item ): array {
703 return $this->get_item_taxes( $item );
704 }
705
706 /**
707 * Build tax rows for any order item that exposes get_taxes().
708 *
709 * Resolves human-readable label and percent rate via WC_Tax when possible,
710 * falling back to the rate id string and null rate.
711 *
712 * @param object $item Order item.
713 *
714 * @return array
715 */
716 private function get_item_taxes( $item ): array {
717 $taxes = array();
718
719 if ( ! method_exists( $item, 'get_taxes' ) ) {
720 return $taxes;
721 }
722
723 $raw = $item->get_taxes();
724 if ( ! \is_array( $raw ) ) {
725 return $taxes;
726 }
727
728 $totals = isset( $raw['total'] ) && \is_array( $raw['total'] ) ? $raw['total'] : array();
729
730 foreach ( $totals as $tax_rate_id => $tax_amount ) {
731 if ( ! $tax_amount ) {
732 continue;
733 }
734
735 $rate = null;
736 $label = (string) $tax_rate_id;
737
738 if ( class_exists( '\WC_Tax' ) ) {
739 // _get_tax_rate() is internal to WooCommerce; keep the fallback label/rate if it changes.
740 try {
741 $rate_data = \WC_Tax::_get_tax_rate( (int) $tax_rate_id, OBJECT );
742 if ( \is_object( $rate_data ) ) {
743 if ( isset( $rate_data->tax_rate ) && '' !== $rate_data->tax_rate ) {
744 $rate = (float) $rate_data->tax_rate;
745 }
746 $resolved_label = \WC_Tax::get_rate_label( $rate_data );
747 if ( \is_string( $resolved_label ) && '' !== $resolved_label ) {
748 $label = $resolved_label;
749 }
750 }
751 } catch ( \Throwable $exception ) {
752 $rate = null;
753 $label = (string) $tax_rate_id;
754 }
755 }
756
757 $taxes[] = array(
758 'code' => (string) $tax_rate_id,
759 'rate' => $rate,
760 'label' => $label,
761 'amount' => (float) $tax_amount,
762 );
763 }
764
765 return $taxes;
766 }
767
768 /**
769 * Extract formatted meta pairs from an order item.
770 *
771 * @param object $item Order item.
772 *
773 * @return array
774 */
775 private function get_item_meta_pairs( $item ): array {
776 $pairs = array();
777
778 if ( ! method_exists( $item, 'get_formatted_meta_data' ) ) {
779 return $pairs;
780 }
781
782 $formatted_meta = $item->get_formatted_meta_data( '_', true );
783 if ( ! \is_array( $formatted_meta ) ) {
784 return $pairs;
785 }
786
787 foreach ( $formatted_meta as $meta_entry ) {
788 if ( isset( $meta_entry->key ) && '_' === substr( (string) $meta_entry->key, 0, 1 ) ) {
789 continue;
790 }
791
792 $pairs[] = array(
793 'key' => wp_strip_all_tags( $meta_entry->display_key ),
794 'value' => wp_strip_all_tags( $meta_entry->display_value ),
795 );
796 }
797
798 return $pairs;
799 }
800
801 /**
802 * Extract product attributes without order-item add-on metadata.
803 *
804 * @param \WC_Order_Item_Product $item Order product item.
805 *
806 * @return array
807 */
808 private function get_product_attribute_pairs( \WC_Order_Item_Product $item ): array {
809 $product = $item->get_product();
810 $pairs = array();
811
812 if ( ! $product instanceof \WC_Product ) {
813 return $pairs;
814 }
815
816 if ( $product instanceof \WC_Product_Variation ) {
817 return Receipt_Sections::variation_attribute_pairs( $product );
818 }
819
820 foreach ( $product->get_attributes() as $attribute ) {
821 if ( ! $attribute instanceof \WC_Product_Attribute || ! $attribute->get_visible() ) {
822 continue;
823 }
824
825 $values = $attribute->is_taxonomy()
826 ? wc_get_product_terms( $product->get_id(), $attribute->get_name(), array( 'fields' => 'names' ) )
827 : $attribute->get_options();
828 $values = array_filter( array_map( 'wp_strip_all_tags', array_map( 'strval', $values ) ) );
829
830 if ( empty( $values ) ) {
831 continue;
832 }
833
834 $pairs[] = array(
835 'key' => wp_strip_all_tags( wc_attribute_label( $attribute->get_name(), $product ) ),
836 'value' => implode( ', ', $values ),
837 );
838 }
839
840 return $pairs;
841 }
842
843 /**
844 * Build refunds[] block from $order->get_refunds().
845 *
846 * @param WC_Abstract_Order $order Order object.
847 * @param bool $display_incl Whether totals should be tax-inclusive (matches shop tax display).
848 * @param DateTimeZone $date_timezone Receipt timezone.
849 * @param string $date_locale Receipt locale.
850 *
851 * @return array
852 */
853 private function get_refunds( WC_Abstract_Order $order, bool $display_incl, DateTimeZone $date_timezone, string $date_locale = '' ): array {
854 $refunds = array();
855
856 if ( ! method_exists( $order, 'get_refunds' ) ) {
857 return $refunds;
858 }
859
860 foreach ( $order->get_refunds() as $refund ) {
861 if ( ! $refund instanceof \WC_Order_Refund ) {
862 continue;
863 }
864
865 $refunded_by_id = (int) $refund->get_refunded_by();
866 $refunded_by_name = '';
867 if ( $refunded_by_id > 0 ) {
868 $user = get_user_by( 'id', $refunded_by_id );
869 if ( $user ) {
870 $refunded_by_name = (string) $user->display_name;
871 }
872 }
873
874 $refund_lines = array();
875 foreach ( $refund->get_items( 'line_item' ) as $refund_item ) {
876 if ( ! $refund_item instanceof \WC_Order_Item_Product ) {
877 continue;
878 }
879 $line_total_excl = abs( (float) $refund_item->get_total() );
880 $line_total_tax = abs( (float) $refund_item->get_total_tax() );
881 $line_total_incl = $line_total_excl + $line_total_tax;
882 $refund_lines[] = array(
883 'name' => (string) $refund_item->get_name(),
884 'sku' => $refund_item->get_product() ? (string) $refund_item->get_product()->get_sku() : '',
885 'qty' => abs( (float) $refund_item->get_quantity() ),
886 'total' => $display_incl ? $line_total_incl : $line_total_excl,
887 'total_incl' => $line_total_incl,
888 'total_excl' => $line_total_excl,
889 'taxes' => array_map(
890 static function ( array $tax ): array {
891 $tax['amount'] = abs( (float) $tax['amount'] );
892 return $tax;
893 },
894 $this->get_item_taxes( $refund_item )
895 ),
896 );
897 }
898
899 $refund_fees = array();
900 foreach ( $refund->get_items( 'fee' ) as $refund_fee ) {
901 if ( ! $refund_fee instanceof \WC_Order_Item_Fee ) {
902 continue;
903 }
904 $fee_total_excl = abs( (float) $refund_fee->get_total() );
905 $fee_total_tax = abs( (float) $refund_fee->get_total_tax() );
906 $fee_total_incl = $fee_total_excl + $fee_total_tax;
907 $refund_fees[] = array(
908 'label' => (string) $refund_fee->get_name(),
909 'total' => $display_incl ? $fee_total_incl : $fee_total_excl,
910 'total_incl' => $fee_total_incl,
911 'total_excl' => $fee_total_excl,
912 'taxes' => array_map(
913 static function ( array $tax ): array {
914 $tax['amount'] = abs( (float) $tax['amount'] );
915 return $tax;
916 },
917 $this->get_item_taxes( $refund_fee )
918 ),
919 );
920 }
921
922 $refund_shipping = array();
923 foreach ( $refund->get_items( 'shipping' ) as $refund_ship ) {
924 if ( ! $refund_ship instanceof \WC_Order_Item_Shipping ) {
925 continue;
926 }
927 $ship_total_excl = abs( (float) $refund_ship->get_total() );
928 $ship_total_tax = abs( (float) $refund_ship->get_total_tax() );
929 $ship_total_incl = $ship_total_excl + $ship_total_tax;
930 $refund_shipping[] = array(
931 'label' => (string) $refund_ship->get_name(),
932 'method_id' => method_exists( $refund_ship, 'get_method_id' ) ? (string) $refund_ship->get_method_id() : '',
933 'total' => $display_incl ? $ship_total_incl : $ship_total_excl,
934 'total_incl' => $ship_total_incl,
935 'total_excl' => $ship_total_excl,
936 'taxes' => array_map(
937 static function ( array $tax ): array {
938 $tax['amount'] = abs( (float) $tax['amount'] );
939 return $tax;
940 },
941 $this->get_item_taxes( $refund_ship )
942 ),
943 );
944 }
945
946 $pos_destination = (string) $refund->get_meta( '_pos_refund_destination' );
947 $pos_mode = (string) $refund->get_meta( '_pos_refund_mode' );
948 $pos_gateway_id = (string) $refund->get_meta( '_pos_refund_gateway_id' );
949 $pos_gateway_title = (string) $refund->get_meta( '_pos_refund_gateway_title' );
950 if ( '' === $pos_gateway_title && '' !== $pos_gateway_id && function_exists( 'WC' ) ) {
951 // Resolve via the WC()->payment_gateways() method (which returns
952 // WC_Payment_Gateways::instance() lazily) instead of the
953 // WC()->payment_gateways property — the property can legitimately
954 // be null mid-bootstrap or in some test environments.
955 $gateways = WC()->payment_gateways()->payment_gateways();
956 if ( isset( $gateways[ $pos_gateway_id ] ) && method_exists( $gateways[ $pos_gateway_id ], 'get_title' ) ) {
957 $pos_gateway_title = (string) $gateways[ $pos_gateway_id ]->get_title();
958 }
959 }
960
961 $refunds[] = array(
962 'id' => (int) $refund->get_id(),
963 'date' => $this->format_wc_datetime_in_timezone( $refund->get_date_created(), $date_timezone, $date_locale ),
964 'amount' => abs( (float) $refund->get_amount() ),
965 'subtotal' => method_exists( $refund, 'get_subtotal' ) ? abs( (float) $refund->get_subtotal() ) : 0.0,
966 'tax_total' => method_exists( $refund, 'get_total_tax' ) ? abs( (float) $refund->get_total_tax() ) : 0.0,
967 'shipping_total' => method_exists( $refund, 'get_shipping_total' ) ? abs( (float) $refund->get_shipping_total() ) : 0.0,
968 'shipping_tax' => method_exists( $refund, 'get_shipping_tax' ) ? abs( (float) $refund->get_shipping_tax() ) : 0.0,
969 'reason' => (string) $refund->get_reason(),
970 'refunded_by_id' => $refunded_by_id > 0 ? $refunded_by_id : null,
971 'refunded_by_name' => $refunded_by_name,
972 'refunded_payment' => method_exists( $refund, 'get_refunded_payment' ) ? (bool) $refund->get_refunded_payment() : false,
973 'destination' => $pos_destination,
974 'gateway_id' => $pos_gateway_id,
975 'gateway_title' => $pos_gateway_title,
976 'processing_mode' => $pos_mode,
977 'lines' => $refund_lines,
978 'fees' => $refund_fees,
979 'shipping' => $refund_shipping,
980 );
981 }
982
983 return $refunds;
984 }
985 }
986