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

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