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

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