PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.3
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.3
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.3, at includes/Services/Receipt_Data_Builder.php

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