PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
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 / Preview_Receipt_Builder.php

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

1,268 lines 39.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Preview receipt data builder service.
4 *
5 * Generates realistic sample receipt data for template gallery/editor
6 * previews using the store's actual WooCommerce settings.
7 *
8 * @package WCPOS\WooCommercePOS\Services
9 */
10
11 namespace WCPOS\WooCommercePOS\Services;
12
13 use WCPOS\WooCommercePOS\Abstracts\Store;
14
15 /**
16 * Preview_Receipt_Builder class.
17 *
18 * Builds a sample receipt payload that mirrors the real Receipt_Data_Builder
19 * output but uses the store's catalog products, tax rates, and settings
20 * instead of data from an actual order.
21 */
22 class Preview_Receipt_Builder {
23
24 /**
25 * Shared store resolver.
26 *
27 * @var Receipt_Store_Resolver
28 */
29 private Receipt_Store_Resolver $store_resolver;
30
31 /**
32 * Fallback tax rate percentage when no WooCommerce tax rates are configured.
33 *
34 * @var float
35 */
36 const FALLBACK_TAX_RATE = 10.0;
37
38 /**
39 * Quantities assigned to each line item (up to 3 products).
40 *
41 * @var int[]
42 */
43 const LINE_QUANTITIES = array( 2, 1, 1 );
44
45 /**
46 * Minimum number of line items in the preview.
47 *
48 * @var int
49 */
50 const MIN_LINES = 2;
51
52 /**
53 * Sample customer data keyed by ISO 3166-1 alpha-2 country code.
54 *
55 * Each entry provides first_name, last_name, address_1, address_2, email,
56 * and phone fields. City, state, postcode, and country are filled from the
57 * store's WooCommerce settings at runtime. The 'US' entry is the fallback
58 * used for any country not listed here.
59 *
60 * @var array<string, array<string, string>>
61 */
62 const SAMPLE_CUSTOMERS = array(
63 'US' => array(
64 'first_name' => 'Sarah',
65 'last_name' => 'Johnson',
66 'address_1' => '456 Oak Avenue',
67 'address_2' => 'Suite 200',
68 'email' => 'sarah.johnson@example.com',
69 'phone' => '(555) 987-6543',
70 ),
71 'GB' => array(
72 'first_name' => 'James',
73 'last_name' => 'Smith',
74 'address_1' => '12 Baker Street',
75 'address_2' => 'Flat 3',
76 'email' => 'james.smith@example.co.uk',
77 'phone' => '07700 900123',
78 ),
79 'DE' => array(
80 'first_name' => 'Anna',
81 'last_name' => 'Mueller',
82 'address_1' => 'Hauptstraße 42',
83 'address_2' => '',
84 'email' => 'anna.mueller@example.de',
85 'phone' => '030 12345678',
86 ),
87 'FR' => array(
88 'first_name' => 'Marie',
89 'last_name' => 'Dupont',
90 'address_1' => '15 Rue de Rivoli',
91 'address_2' => 'Apt 4B',
92 'email' => 'marie.dupont@example.fr',
93 'phone' => '01 23 45 67 89',
94 ),
95 'AU' => array(
96 'first_name' => 'Liam',
97 'last_name' => 'Wilson',
98 'address_1' => '88 George Street',
99 'address_2' => '',
100 'email' => 'liam.wilson@example.com.au',
101 'phone' => '02 9876 5432',
102 ),
103 'JP' => array(
104 'first_name' => 'Yuki',
105 'last_name' => 'Tanaka',
106 'address_1' => '1-2-3 Shibuya',
107 'address_2' => '',
108 'email' => 'yuki.tanaka@example.jp',
109 'phone' => '03-1234-5678',
110 ),
111 'NL' => array(
112 'first_name' => 'Lars',
113 'last_name' => 'de Vries',
114 'address_1' => 'Keizersgracht 123',
115 'address_2' => '',
116 'email' => 'lars.devries@example.nl',
117 'phone' => '020 123 4567',
118 ),
119 'ES' => array(
120 'first_name' => 'Carmen',
121 'last_name' => 'García',
122 'address_1' => 'Calle Mayor 7',
123 'address_2' => '',
124 'email' => 'carmen.garcia@example.es',
125 'phone' => '91 234 56 78',
126 ),
127 'IT' => array(
128 'first_name' => 'Marco',
129 'last_name' => 'Rossi',
130 'address_1' => 'Via Roma 55',
131 'address_2' => '',
132 'email' => 'marco.rossi@example.it',
133 'phone' => '06 1234 5678',
134 ),
135 'CA' => array(
136 'first_name' => 'Emma',
137 'last_name' => 'Brown',
138 'address_1' => '200 Bay Street',
139 'address_2' => 'Unit 1500',
140 'email' => 'emma.brown@example.ca',
141 'phone' => '(416) 555-0123',
142 ),
143 'NZ' => array(
144 'first_name' => 'Oliver',
145 'last_name' => 'Taylor',
146 'address_1' => '42 Lambton Quay',
147 'address_2' => '',
148 'email' => 'oliver.taylor@example.co.nz',
149 'phone' => '04 123 4567',
150 ),
151 'IN' => array(
152 'first_name' => 'Priya',
153 'last_name' => 'Sharma',
154 'address_1' => '14 MG Road',
155 'address_2' => '',
156 'email' => 'priya.sharma@example.in',
157 'phone' => '98765 43210',
158 ),
159 'BR' => array(
160 'first_name' => 'Ana',
161 'last_name' => 'Silva',
162 'address_1' => 'Rua Augusta 1200',
163 'address_2' => 'Apto 42',
164 'email' => 'ana.silva@example.com.br',
165 'phone' => '(11) 98765-4321',
166 ),
167 'MX' => array(
168 'first_name' => 'Sofía',
169 'last_name' => 'Hernández',
170 'address_1' => 'Av. Reforma 222',
171 'address_2' => 'Piso 3',
172 'email' => 'sofia.hernandez@example.com.mx',
173 'phone' => '55 1234 5678',
174 ),
175 'SE' => array(
176 'first_name' => 'Erik',
177 'last_name' => 'Lindqvist',
178 'address_1' => 'Drottninggatan 50',
179 'address_2' => '',
180 'email' => 'erik.lindqvist@example.se',
181 'phone' => '08-123 456 78',
182 ),
183 'NO' => array(
184 'first_name' => 'Ingrid',
185 'last_name' => 'Hansen',
186 'address_1' => 'Karl Johans gate 10',
187 'address_2' => '',
188 'email' => 'ingrid.hansen@example.no',
189 'phone' => '22 12 34 56',
190 ),
191 'DK' => array(
192 'first_name' => 'Mads',
193 'last_name' => 'Nielsen',
194 'address_1' => 'Strøget 28',
195 'address_2' => '',
196 'email' => 'mads.nielsen@example.dk',
197 'phone' => '33 12 34 56',
198 ),
199 'FI' => array(
200 'first_name' => 'Aino',
201 'last_name' => 'Virtanen',
202 'address_1' => 'Mannerheimintie 15',
203 'address_2' => '',
204 'email' => 'aino.virtanen@example.fi',
205 'phone' => '09 123 4567',
206 ),
207 'BE' => array(
208 'first_name' => 'Lucas',
209 'last_name' => 'Peeters',
210 'address_1' => 'Meir 48',
211 'address_2' => '',
212 'email' => 'lucas.peeters@example.be',
213 'phone' => '03 123 45 67',
214 ),
215 'AT' => array(
216 'first_name' => 'Katharina',
217 'last_name' => 'Gruber',
218 'address_1' => 'Kärntner Straße 21',
219 'address_2' => '',
220 'email' => 'katharina.gruber@example.at',
221 'phone' => '01 234 5678',
222 ),
223 'CH' => array(
224 'first_name' => 'Lukas',
225 'last_name' => 'Meier',
226 'address_1' => 'Bahnhofstrasse 30',
227 'address_2' => '',
228 'email' => 'lukas.meier@example.ch',
229 'phone' => '044 123 45 67',
230 ),
231 'PT' => array(
232 'first_name' => 'Maria',
233 'last_name' => 'Santos',
234 'address_1' => 'Rua Augusta 85',
235 'address_2' => '',
236 'email' => 'maria.santos@example.pt',
237 'phone' => '21 123 4567',
238 ),
239 'PL' => array(
240 'first_name' => 'Katarzyna',
241 'last_name' => 'Nowak',
242 'address_1' => 'ul. Marszałkowska 12',
243 'address_2' => '',
244 'email' => 'katarzyna.nowak@example.pl',
245 'phone' => '22 123 45 67',
246 ),
247 'CZ' => array(
248 'first_name' => 'Tereza',
249 'last_name' => 'Nováková',
250 'address_1' => 'Václavské náměstí 30',
251 'address_2' => '',
252 'email' => 'tereza.novakova@example.cz',
253 'phone' => '221 234 567',
254 ),
255 'IE' => array(
256 'first_name' => 'Aoife',
257 'last_name' => 'Murphy',
258 'address_1' => '22 Grafton Street',
259 'address_2' => '',
260 'email' => 'aoife.murphy@example.ie',
261 'phone' => '01 234 5678',
262 ),
263 'ZA' => array(
264 'first_name' => 'Thandiwe',
265 'last_name' => 'Ndlovu',
266 'address_1' => '100 Long Street',
267 'address_2' => '',
268 'email' => 'thandiwe.ndlovu@example.co.za',
269 'phone' => '021 123 4567',
270 ),
271 'SG' => array(
272 'first_name' => 'Wei Lin',
273 'last_name' => 'Tan',
274 'address_1' => '1 Raffles Place',
275 'address_2' => '#08-01',
276 'email' => 'weilin.tan@example.sg',
277 'phone' => '6123 4567',
278 ),
279 'HK' => array(
280 'first_name' => 'Wing',
281 'last_name' => 'Chan',
282 'address_1' => '88 Queens Road Central',
283 'address_2' => 'Flat 12A',
284 'email' => 'wing.chan@example.hk',
285 'phone' => '2123 4567',
286 ),
287 'KR' => array(
288 'first_name' => 'Jimin',
289 'last_name' => 'Park',
290 'address_1' => '25 Gangnam-daero',
291 'address_2' => '',
292 'email' => 'jimin.park@example.kr',
293 'phone' => '02-1234-5678',
294 ),
295 'MY' => array(
296 'first_name' => 'Nurul',
297 'last_name' => 'Ahmad',
298 'address_1' => 'Jalan Bukit Bintang 55',
299 'address_2' => '',
300 'email' => 'nurul.ahmad@example.my',
301 'phone' => '03-1234 5678',
302 ),
303 'TH' => array(
304 'first_name' => 'Siriporn',
305 'last_name' => 'Suksawat',
306 'address_1' => '99 Sukhumvit Road',
307 'address_2' => '',
308 'email' => 'siriporn.s@example.co.th',
309 'phone' => '02 123 4567',
310 ),
311 'PH' => array(
312 'first_name' => 'Maria',
313 'last_name' => 'Reyes',
314 'address_1' => '123 Ayala Avenue',
315 'address_2' => '',
316 'email' => 'maria.reyes@example.ph',
317 'phone' => '02 8123 4567',
318 ),
319 'AE' => array(
320 'first_name' => 'Fatima',
321 'last_name' => 'Al Maktoum',
322 'address_1' => 'Sheikh Zayed Road',
323 'address_2' => 'Tower 1, Office 501',
324 'email' => 'fatima.m@example.ae',
325 'phone' => '04 123 4567',
326 ),
327 'SA' => array(
328 'first_name' => 'Nora',
329 'last_name' => 'Al-Rashid',
330 'address_1' => 'King Fahd Road',
331 'address_2' => '',
332 'email' => 'nora.alrashid@example.sa',
333 'phone' => '011 234 5678',
334 ),
335 'IL' => array(
336 'first_name' => 'Noa',
337 'last_name' => 'Cohen',
338 'address_1' => 'Rothschild Blvd 40',
339 'address_2' => '',
340 'email' => 'noa.cohen@example.co.il',
341 'phone' => '03-123-4567',
342 ),
343 'TR' => array(
344 'first_name' => 'Elif',
345 'last_name' => 'Yılmaz',
346 'address_1' => 'İstiklal Caddesi 45',
347 'address_2' => '',
348 'email' => 'elif.yilmaz@example.com.tr',
349 'phone' => '0212 123 4567',
350 ),
351 'RU' => array(
352 'first_name' => 'Anastasia',
353 'last_name' => 'Ivanova',
354 'address_1' => 'Tverskaya Ulitsa 12',
355 'address_2' => 'Kv. 5',
356 'email' => 'anastasia.ivanova@example.ru',
357 'phone' => '+7 495 123-45-67',
358 ),
359 'GR' => array(
360 'first_name' => 'Eleni',
361 'last_name' => 'Papadopoulos',
362 'address_1' => 'Ermou 25',
363 'address_2' => '',
364 'email' => 'eleni.p@example.gr',
365 'phone' => '210 123 4567',
366 ),
367 'RO' => array(
368 'first_name' => 'Andreea',
369 'last_name' => 'Popescu',
370 'address_1' => 'Calea Victoriei 100',
371 'address_2' => '',
372 'email' => 'andreea.popescu@example.ro',
373 'phone' => '021 123 4567',
374 ),
375 'HU' => array(
376 'first_name' => 'Anna',
377 'last_name' => 'Szabó',
378 'address_1' => 'Andrássy út 60',
379 'address_2' => '',
380 'email' => 'anna.szabo@example.hu',
381 'phone' => '1 234 5678',
382 ),
383 'ID' => array(
384 'first_name' => 'Siti',
385 'last_name' => 'Rahayu',
386 'address_1' => 'Jl. Sudirman No. 52',
387 'address_2' => '',
388 'email' => 'siti.rahayu@example.co.id',
389 'phone' => '021-1234567',
390 ),
391 'TW' => array(
392 'first_name' => 'Mei-Ling',
393 'last_name' => 'Chen',
394 'address_1' => 'Zhongxiao East Road Sec 4, No 12',
395 'address_2' => '3F',
396 'email' => 'meiling.chen@example.tw',
397 'phone' => '02-2771-1234',
398 ),
399 'CN' => array(
400 'first_name' => 'Xiao',
401 'last_name' => 'Wang',
402 'address_1' => 'Nanjing Road 200',
403 'address_2' => '',
404 'email' => 'xiao.wang@example.cn',
405 'phone' => '021-6234-5678',
406 ),
407 'VN' => array(
408 'first_name' => 'Linh',
409 'last_name' => 'Nguyen',
410 'address_1' => '45 Le Loi Street',
411 'address_2' => '',
412 'email' => 'linh.nguyen@example.vn',
413 'phone' => '028 1234 5678',
414 ),
415 'AR' => array(
416 'first_name' => 'Valentina',
417 'last_name' => 'Fernández',
418 'address_1' => 'Av. Corrientes 1234',
419 'address_2' => 'Piso 5',
420 'email' => 'valentina.f@example.com.ar',
421 'phone' => '11 1234-5678',
422 ),
423 'CO' => array(
424 'first_name' => 'Camila',
425 'last_name' => 'Rodríguez',
426 'address_1' => 'Carrera 7 No. 71-52',
427 'address_2' => '',
428 'email' => 'camila.r@example.co',
429 'phone' => '601 234 5678',
430 ),
431 'CL' => array(
432 'first_name' => 'Catalina',
433 'last_name' => 'Muñoz',
434 'address_1' => 'Av. Providencia 1200',
435 'address_2' => 'Of. 301',
436 'email' => 'catalina.munoz@example.cl',
437 'phone' => '2 2345 6789',
438 ),
439 );
440
441 /**
442 * Fallback product definitions when the catalog is empty.
443 *
444 * @var array[]
445 */
446 const FALLBACK_PRODUCTS = array(
447 array(
448 'name' => 'Premium Widget',
449 'price' => 29.99,
450 'regular_price' => 34.99,
451 'sku' => 'WIDGET-001',
452 'meta' => array(
453 array(
454 'key' => 'Size',
455 'value' => 'Large',
456 ),
457 array(
458 'key' => 'Color',
459 'value' => 'Midnight Blue',
460 ),
461 ),
462 'attributes' => array(
463 array(
464 'key' => 'Size',
465 'value' => 'Large',
466 ),
467 array(
468 'key' => 'Color',
469 'value' => 'Midnight Blue',
470 ),
471 ),
472 ),
473 array(
474 'name' => 'Standard Gadget',
475 'price' => 15.50,
476 'regular_price' => 15.50,
477 'sku' => 'GADGET-002',
478 'meta' => array(
479 array(
480 'key' => 'Material',
481 'value' => 'Stainless Steel',
482 ),
483 ),
484 'attributes' => array(
485 array(
486 'key' => 'Material',
487 'value' => 'Stainless Steel',
488 ),
489 ),
490 ),
491 array(
492 'name' => 'Deluxe Component',
493 'price' => 42.00,
494 'regular_price' => 42.00,
495 'sku' => 'COMP-003',
496 'meta' => array(),
497 'attributes' => array(),
498 ),
499 );
500
501 /**
502 * Build a preview receipt payload.
503 *
504 * Returns an array matching the receipt data schema with all sections
505 * populated using the store's real settings, products, and tax rates.
506 *
507 * @param object|null $pos_store POS store object. Falls back to default store.
508 *
509 * @return array Complete receipt data array.
510 */
511 public function build( $pos_store = null ): array {
512 $resolved_store = null === $pos_store ? wcpos_get_store() : $pos_store;
513 if ( ! \is_object( $resolved_store ) ) {
514 $resolved_store = wcpos_get_store();
515 }
516 $this->store_resolver = new Receipt_Store_Resolver( \is_object( $resolved_store ) ? $resolved_store : new Store() );
517 $currency = $this->resolve_currency();
518 $display_incl = 'incl' === $this->store_resolver->resolve_store_option_string(
519 'get_tax_display_cart',
520 get_option( 'woocommerce_tax_display_cart', 'excl' )
521 );
522 $tax_enabled = 'yes' === $this->store_resolver->resolve_store_option_string(
523 'get_calc_taxes',
524 get_option( 'woocommerce_calc_taxes', 'no' )
525 );
526 $tax_config = $this->get_tax_config( $tax_enabled );
527 $tax_rate = $tax_config['rate'];
528 $tax_label = $tax_config['label'];
529 $tax_code = $tax_config['code'];
530
531 $raw_products = $this->get_products();
532 $prices_include_tax = 'yes' === $this->store_resolver->resolve_store_option_string(
533 'get_prices_include_tax',
534 wc_prices_include_tax() ? 'yes' : 'no'
535 );
536 $dp = $this->store_resolver->resolve_price_num_decimals();
537
538 // Build line items.
539 $lines = array();
540 $lines_total_excl = 0.0;
541 $lines_total_incl = 0.0;
542
543 foreach ( $raw_products as $index => $product ) {
544 $qty = self::LINE_QUANTITIES[ $index ] ?? 1;
545 $base_price = (float) $product['price'];
546 $regular_base_price = (float) $product['regular_price'];
547
548 if ( $prices_include_tax ) {
549 $unit_incl = $base_price;
550 $unit_excl = $base_price / ( 1 + $tax_rate / 100 );
551 $regular_unit_incl = $regular_base_price;
552 $regular_unit_excl = $regular_base_price / ( 1 + $tax_rate / 100 );
553 } else {
554 $unit_excl = $base_price;
555 $unit_incl = $base_price * ( 1 + $tax_rate / 100 );
556 $regular_unit_excl = $regular_base_price;
557 $regular_unit_incl = $regular_base_price * ( 1 + $tax_rate / 100 );
558 }
559
560 $selling_unit_incl = round( $unit_incl, $dp );
561 $selling_unit_excl = round( $unit_excl, $dp );
562 $regular_unit_incl = round( $regular_unit_incl, $dp );
563 $regular_unit_excl = round( $regular_unit_excl, $dp );
564 $unit_savings_incl = max( 0.0, $regular_unit_incl - $selling_unit_incl );
565 $unit_savings_excl = max( 0.0, $regular_unit_excl - $selling_unit_excl );
566 $line_total_incl = round( $selling_unit_incl * $qty, $dp );
567 $line_total_excl = round( $selling_unit_excl * $qty, $dp );
568 $line_regular_incl = round( $regular_unit_incl * $qty, $dp );
569 $line_regular_excl = round( $regular_unit_excl * $qty, $dp );
570 $line_savings_incl = round( $unit_savings_incl * $qty, $dp );
571 $line_savings_excl = round( $unit_savings_excl * $qty, $dp );
572
573 $unit_price_rounded = $display_incl ? $selling_unit_incl : $selling_unit_excl;
574
575 $lines[] = array(
576 'key' => (string) ( $index + 1 ),
577 'sku' => $product['sku'],
578 'name' => $product['name'],
579 'qty' => (float) $qty,
580 'qty_refunded' => 0.0,
581 'regular_price' => $display_incl ? $regular_unit_incl : $regular_unit_excl,
582 'regular_price_incl' => $regular_unit_incl,
583 'regular_price_excl' => $regular_unit_excl,
584 'selling_price' => $unit_price_rounded,
585 'selling_price_incl' => $selling_unit_incl,
586 'selling_price_excl' => $selling_unit_excl,
587 'unit_savings' => $display_incl ? $unit_savings_incl : $unit_savings_excl,
588 'unit_savings_incl' => $unit_savings_incl,
589 'unit_savings_excl' => $unit_savings_excl,
590 'line_regular_total' => $display_incl ? $line_regular_incl : $line_regular_excl,
591 'line_regular_total_incl' => $line_regular_incl,
592 'line_regular_total_excl' => $line_regular_excl,
593 'line_selling_total' => $display_incl ? $line_total_incl : $line_total_excl,
594 'line_selling_total_incl' => $line_total_incl,
595 'line_selling_total_excl' => $line_total_excl,
596 'line_savings' => $display_incl ? $line_savings_incl : $line_savings_excl,
597 'line_savings_incl' => $line_savings_incl,
598 'line_savings_excl' => $line_savings_excl,
599 'savings_in_discounts' => false,
600 'unit_subtotal' => $unit_price_rounded,
601 'unit_subtotal_incl' => $selling_unit_incl,
602 'unit_subtotal_excl' => $selling_unit_excl,
603 'unit_price' => $unit_price_rounded,
604 'unit_price_incl' => $selling_unit_incl,
605 'unit_price_excl' => $selling_unit_excl,
606 'line_subtotal' => $display_incl ? $line_total_incl : $line_total_excl,
607 'line_subtotal_incl' => $line_total_incl,
608 'line_subtotal_excl' => $line_total_excl,
609 'discounts' => 0.0,
610 'discounts_incl' => 0.0,
611 'discounts_excl' => 0.0,
612 'line_total' => $display_incl ? $line_total_incl : $line_total_excl,
613 'line_total_incl' => $line_total_incl,
614 'line_total_excl' => $line_total_excl,
615 'total_refunded' => 0.0,
616 'taxes' => array(),
617 'meta' => $product['meta'] ?? array(),
618 'attributes' => $product['attributes'] ?? array(),
619 );
620
621 $lines_total_excl += $line_total_excl;
622 $lines_total_incl += $line_total_incl;
623 }
624
625 // Fee (excl tax).
626 $fee_excl = 2.50;
627 $fee_tax = round( $fee_excl * $tax_rate / 100, $dp );
628 $fee_incl = $fee_excl + $fee_tax;
629 $fee_label = /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Gift Wrapping', 'woocommerce-pos' );
630
631 // Shipping (excl tax).
632 $shipping_excl = 10.00;
633 $shipping_tax = round( $shipping_excl * $tax_rate / 100, $dp );
634 $shipping_incl = $shipping_excl + $shipping_tax;
635 $shipping_label = /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Flat Rate Shipping', 'woocommerce-pos' );
636
637 // Discount: 10% of line items excl total.
638 $discount_rate = 10.0;
639 $discount_excl = round( $lines_total_excl * $discount_rate / 100, $dp );
640 $discount_tax = round( $discount_excl * $tax_rate / 100, $dp );
641 $discount_incl = $discount_excl + $discount_tax;
642 $discount_label = strtr(
643 /* translators: %s: discount percentage */
644 __( 'Summer Sale (%s%%)', 'woocommerce-pos' ),
645 array(
646 '%1$s' => (string) (int) $discount_rate,
647 '%s' => (string) (int) $discount_rate,
648 '%%' => '%',
649 )
650 );
651
652 // Distribute discount proportionally across line items with remainder correction.
653 $sum_discount_excl = 0.0;
654 $sum_discount_incl = 0.0;
655 $last_index = count( $lines ) - 1;
656
657 foreach ( $lines as $i => &$line ) {
658 if ( $lines_total_excl > 0 ) {
659 $share = $line['line_subtotal_excl'] / $lines_total_excl;
660 } else {
661 $share = 1.0 / count( $lines );
662 }
663
664 if ( $i < $last_index ) {
665 $line['discounts_excl'] = round( $discount_excl * $share, $dp );
666 $line['discounts_incl'] = round( $discount_incl * $share, $dp );
667 $sum_discount_excl += $line['discounts_excl'];
668 $sum_discount_incl += $line['discounts_incl'];
669 } else {
670 // Assign remainder to last item so distributed totals match exactly.
671 $line['discounts_excl'] = round( $discount_excl - $sum_discount_excl, $dp );
672 $line['discounts_incl'] = round( $discount_incl - $sum_discount_incl, $dp );
673 }
674
675 $line['discounts'] = $display_incl ? $line['discounts_incl'] : $line['discounts_excl'];
676 $line['line_total_excl'] = round( $line['line_subtotal_excl'] - $line['discounts_excl'], $dp );
677 $line['line_total_incl'] = round( $line['line_subtotal_incl'] - $line['discounts_incl'], $dp );
678 $line['line_total'] = $display_incl ? $line['line_total_incl'] : $line['line_total_excl'];
679 $line['unit_price_incl'] = round( $line['line_total_incl'] / $line['qty'], $dp );
680 $line['unit_price_excl'] = round( $line['line_total_excl'] / $line['qty'], $dp );
681 $line['unit_price'] = $display_incl ? $line['unit_price_incl'] : $line['unit_price_excl'];
682 }
683 unset( $line );
684
685 // Totals.
686 $subtotal_excl = $lines_total_excl;
687 $subtotal_incl = $lines_total_incl;
688
689 // Item count summaries — matches the real builder's totals.total_qty
690 // / totals.line_count so preview mirrors the live receipt schema.
691 $total_qty = (float) array_sum( array_column( $lines, 'qty' ) );
692 $line_count = \count( $lines );
693
694 // Taxable base: line items - discount + shipping + fee (all excl).
695 $taxable_excl = $subtotal_excl - $discount_excl + $shipping_excl + $fee_excl;
696 $total_tax = round( $taxable_excl * $tax_rate / 100, $dp );
697
698 $total_excl = $subtotal_excl - $discount_excl + $shipping_excl + $fee_excl;
699 $total_incl = $total_excl + $total_tax;
700
701 // Payment: cash rounded up to nearest 5.
702 $tendered = (float) ( ceil( $total_incl / 5 ) * 5 );
703 $change_total = round( $tendered - $total_incl, $dp );
704
705 $created_timestamp = strtotime( '2024-01-15 10:30:00 UTC' );
706 $paid_timestamp = strtotime( '2024-01-15 10:35:00 UTC' );
707 $completed_timestamp = strtotime( '2024-01-15 10:42:00 UTC' );
708 $date_timezone = $this->store_resolver->resolve_store_timezone();
709 $date_locale = $this->store_resolver->resolve_locale();
710
711 $order = array(
712 'id' => 1234,
713 'number' => '1234',
714 'currency' => $currency,
715 'customer_note' => __( 'Please gift wrap this order. Thank you!', 'woocommerce-pos' ),
716 'wc_status' => 'completed',
717 'status_label' => wc_get_order_status_name( 'completed' ),
718 'created_via' => 'woocommerce-pos',
719 'created' => Receipt_Date_Formatter::from_timestamp( $created_timestamp, $date_timezone, $date_locale ),
720 'paid' => Receipt_Date_Formatter::from_timestamp( $paid_timestamp, $date_timezone, $date_locale ),
721 'completed' => Receipt_Date_Formatter::from_timestamp( $completed_timestamp, $date_timezone, $date_locale ),
722 'printed' => Receipt_Date_Formatter::from_timestamp( time(), $date_timezone, $date_locale ),
723 'needs_payment' => false,
724 'payment_url' => '',
725 );
726
727 $store = $this->get_store_info();
728
729 $cashier = $this->get_cashier();
730
731 $customer = $this->get_customer();
732
733 $fees = array(
734 array(
735 'label' => $fee_label,
736 'total' => $display_incl ? $fee_incl : $fee_excl,
737 'total_incl' => $fee_incl,
738 'total_excl' => $fee_excl,
739 'taxes' => array(),
740 'meta' => array(),
741 ),
742 );
743
744 $shipping = array(
745 array(
746 'label' => $shipping_label,
747 'method_id' => 'flat_rate',
748 'total' => $display_incl ? $shipping_incl : $shipping_excl,
749 'total_incl' => $shipping_incl,
750 'total_excl' => $shipping_excl,
751 'taxes' => array(),
752 'meta' => array(),
753 ),
754 );
755
756 $discounts = array(
757 array(
758 'label' => $discount_label,
759 'code' => 'SUMMER10',
760 'discount_type' => 'fixed_cart',
761 'total' => $display_incl ? $discount_incl : $discount_excl,
762 'total_incl' => $discount_incl,
763 'total_excl' => $discount_excl,
764 ),
765 );
766
767 $sale_savings_total_incl = (float) array_sum( array_column( $lines, 'line_savings_incl' ) );
768 $sale_savings_total_excl = (float) array_sum( array_column( $lines, 'line_savings_excl' ) );
769 $total_saved_incl = $discount_incl + $sale_savings_total_incl;
770 $total_saved_excl = $discount_excl + $sale_savings_total_excl;
771
772 $totals = array(
773 'subtotal' => $display_incl ? $subtotal_incl : $subtotal_excl,
774 'subtotal_incl' => $subtotal_incl,
775 'subtotal_excl' => $subtotal_excl,
776 'discount_total' => $display_incl ? $discount_incl : $discount_excl,
777 'discount_total_incl' => $discount_incl,
778 'discount_total_excl' => $discount_excl,
779 'sale_savings_total' => $display_incl ? $sale_savings_total_incl : $sale_savings_total_excl,
780 'sale_savings_total_incl' => $sale_savings_total_incl,
781 'sale_savings_total_excl' => $sale_savings_total_excl,
782 'total_saved' => $display_incl ? $total_saved_incl : $total_saved_excl,
783 'total_saved_incl' => $total_saved_incl,
784 'total_saved_excl' => $total_saved_excl,
785 'total_saved_complete' => true,
786 'tax_total' => $total_tax,
787 'total' => $display_incl ? $total_incl : $total_excl,
788 'total_incl' => $total_incl,
789 'total_excl' => $total_excl,
790 'paid_total' => $total_incl,
791 'change_total' => $change_total,
792 'refund_total' => 0.0,
793 'net_total' => 0.0,
794 'total_qty' => $total_qty,
795 'line_count' => $line_count,
796 );
797
798 $taxable_amount_incl = $taxable_excl + $total_tax;
799
800 if ( $tax_rate > 0 ) {
801 $tax_summary = array(
802 array(
803 'code' => $tax_code,
804 'rate' => $tax_rate,
805 'label' => $tax_label,
806 'compound' => false,
807 'taxable_amount_excl' => $taxable_excl,
808 'tax_amount' => $total_tax,
809 'taxable_amount_incl' => $taxable_amount_incl,
810 ),
811 );
812 } else {
813 $tax_summary = array();
814 }
815
816 $payments = array(
817 array(
818 'method_id' => 'pos_cash',
819 'method_title' => /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Cash', 'woocommerce-pos' ),
820 'amount' => $total_incl,
821 'transaction_id' => '',
822 'tendered' => $tendered,
823 'change' => $change_total,
824 ),
825 );
826
827 $refunds = array();
828
829 $presentation_hints = $this->store_resolver->build_presentation_hints( $currency, $prices_include_tax );
830 $tax = $this->store_resolver->build_tax_section();
831
832 $fiscal = Receipt_Payload_Assembler::fiscal(
833 array(
834 'immutable_id' => '12345:42',
835 'receipt_number' => '00042',
836 'sequence' => 42,
837 'hash' => 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2',
838 'qr_payload' => 'https://example.com/verify?id=SAMPLE-001',
839 'tax_agency_code' => 'SAMPLE',
840 'signed_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
841 'signature_excerpt' => 'A1B2',
842 'document_label' => /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Tax Receipt', 'woocommerce-pos' ),
843 'is_reprint' => false,
844 'reprint_count' => 0,
845 'extra_fields' => array(
846 array(
847 'label' => /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Tax ID', 'woocommerce-pos' ),
848 'value' => 'XX-1234567',
849 ),
850 array(
851 'label' => /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Auth Code', 'woocommerce-pos' ),
852 'value' => 'ABC-789',
853 ),
854 ),
855 )
856 );
857
858 return Receipt_Payload_Assembler::assemble(
859 array(
860 'order' => $order,
861 'store' => $store,
862 'cashier' => $cashier,
863 'customer' => $customer,
864 'lines' => $lines,
865 'fees' => $fees,
866 'shipping' => $shipping,
867 'discounts' => $discounts,
868 'totals' => $totals,
869 'tax' => $tax,
870 'tax_summary' => $tax_summary,
871 'payments' => $payments,
872 'refunds' => $refunds,
873 'fiscal' => $fiscal,
874 'presentation_hints' => $presentation_hints,
875 )
876 );
877 }
878
879 /**
880 * Get store information from the POS store object.
881 *
882 * @return array Store data with name, address, branding, and policy fields.
883 */
884 private function get_store_info(): array {
885 return $this->store_resolver->build_store_section(
886 array(
887 // Sample content stands in wherever the store has nothing configured,
888 // so preview templates always have every section to lay out.
889 'opening_hours' => array(
890 '0' => array( '09:00', '17:00' ),
891 '1' => array( '09:00', '17:00' ),
892 '2' => array( '09:00', '17:00' ),
893 '3' => array( '09:00', '17:00' ),
894 '4' => array( '09:00', '17:00' ),
895 '5' => array( '10:00', '16:00' ),
896 '6' => array(),
897 ),
898 'personal_notes' => /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Thank you for shopping with us! We appreciate your business.', 'woocommerce-pos' ),
899 'policies_and_conditions' => /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Returns accepted within 30 days with original receipt. Items must be unused and in original packaging.', 'woocommerce-pos' ),
900 'footer_imprint' => /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Thank you for your purchase!', 'woocommerce-pos' ),
901 )
902 );
903 }
904
905
906 /**
907 * Resolve the currency code for the preview, preferring the store's
908 * configured currency over the global WooCommerce default. Sample-data
909 * previews should reflect what the chosen store will charge.
910 *
911 * @return string ISO 4217 currency code (e.g. "USD", "EUR").
912 */
913 private function resolve_currency(): string {
914 return $this->store_resolver->resolve_store_option_string(
915 'get_currency',
916 get_option( 'woocommerce_currency', 'USD' )
917 );
918 }
919
920
921 /**
922 * Get cashier data from the current logged-in user.
923 *
924 * @return array Cashier data with id and name.
925 */
926 private function get_cashier(): array {
927 $current_user = wp_get_current_user();
928
929 if ( $current_user->exists() ) {
930 return array(
931 'id' => $current_user->ID,
932 'name' => $current_user->display_name,
933 );
934 }
935
936 return array(
937 'id' => 0,
938 'name' => 'Jane Smith',
939 );
940 }
941
942 /**
943 * Build a locale-aware sample customer using the store's country settings.
944 *
945 * Reads woocommerce_default_country (format "CC" or "CC:SS") to select a
946 * matching entry from SAMPLE_CUSTOMERS, falling back to the US entry for
947 * unknown countries. City, state, postcode, and country are filled from
948 * the store's WooCommerce settings so the preview reflects real store data.
949 *
950 * @return array Customer data with id, name, billing_address, shipping_address, and tax_id.
951 */
952 private function get_customer(): array {
953 $raw = get_option( 'woocommerce_default_country', 'US' );
954 $parts = explode( ':', $raw );
955 $country = $parts[0] ?? 'US';
956 $state = $parts[1] ?? '';
957
958 $sample = self::SAMPLE_CUSTOMERS[ $country ] ?? self::SAMPLE_CUSTOMERS['US'];
959
960 $city = get_option( 'woocommerce_store_city', '' );
961 $postcode = get_option( 'woocommerce_store_postcode', '' );
962
963 $billing = array(
964 'first_name' => $sample['first_name'],
965 'last_name' => $sample['last_name'],
966 'company' => '',
967 'address_1' => $sample['address_1'],
968 'address_2' => $sample['address_2'],
969 'city' => $city,
970 'state' => $state,
971 'postcode' => $postcode,
972 'country' => $country,
973 'email' => $sample['email'],
974 'phone' => $sample['phone'],
975 );
976
977 $shipping = array(
978 'first_name' => $sample['first_name'],
979 'last_name' => $sample['last_name'],
980 'company' => '',
981 'address_1' => $sample['address_1'],
982 'address_2' => $sample['address_2'],
983 'city' => $city,
984 'state' => $state,
985 'postcode' => $postcode,
986 'country' => $country,
987 );
988
989 $tax_ids = self::sample_tax_ids_for_country( $country, $this->store_resolver->resolve_locale() );
990
991 return array(
992 'id' => 42,
993 'name' => $sample['first_name'] . ' ' . $sample['last_name'],
994 'billing_address' => $billing,
995 'shipping_address' => $shipping,
996 'tax_ids' => $tax_ids,
997 );
998 }
999
1000 /**
1001 * Sample tax ID list for the given country, used by the receipt preview.
1002 *
1003 * Values are realistic-shape but fictitious. Returns an empty array for
1004 * countries without a representative sample.
1005 *
1006 * @param string $country ISO 3166-1 alpha-2 country code.
1007 * @param string $locale Receipt locale.
1008 *
1009 * @return array<int,array<string,mixed>>
1010 */
1011 private static function sample_tax_ids_for_country( string $country, string $locale = '' ): array {
1012 $country = strtoupper( $country );
1013
1014 $samples = array(
1015 'GB' => array(
1016 array(
1017 'type' => Tax_Id_Types::TYPE_GB_VAT,
1018 'value' => 'GB123456789',
1019 'country' => 'GB',
1020 ),
1021 ),
1022 'DE' => array(
1023 array(
1024 'type' => Tax_Id_Types::TYPE_EU_VAT,
1025 'value' => 'DE123456789',
1026 'country' => 'DE',
1027 ),
1028 ),
1029 'FR' => array(
1030 array(
1031 'type' => Tax_Id_Types::TYPE_EU_VAT,
1032 'value' => 'FR12345678901',
1033 'country' => 'FR',
1034 ),
1035 ),
1036 'NL' => array(
1037 array(
1038 'type' => Tax_Id_Types::TYPE_EU_VAT,
1039 'value' => 'NL123456789B01',
1040 'country' => 'NL',
1041 ),
1042 ),
1043 'ES' => array(
1044 array(
1045 'type' => Tax_Id_Types::TYPE_ES_NIF,
1046 'value' => 'B12345678',
1047 'country' => 'ES',
1048 ),
1049 ),
1050 'IT' => array(
1051 array(
1052 'type' => Tax_Id_Types::TYPE_IT_PIVA,
1053 'value' => '12345678901',
1054 'country' => 'IT',
1055 ),
1056 ),
1057 'AU' => array(
1058 array(
1059 'type' => Tax_Id_Types::TYPE_AU_ABN,
1060 'value' => '53004085616',
1061 'country' => 'AU',
1062 ),
1063 ),
1064 'CA' => array(
1065 array(
1066 'type' => Tax_Id_Types::TYPE_CA_GST_HST,
1067 'value' => '123456789RT0001',
1068 'country' => 'CA',
1069 ),
1070 ),
1071 'IN' => array(
1072 array(
1073 'type' => Tax_Id_Types::TYPE_IN_GST,
1074 'value' => '29ABCDE1234F1Z5',
1075 'country' => 'IN',
1076 ),
1077 ),
1078 'BR' => array(
1079 array(
1080 'type' => Tax_Id_Types::TYPE_BR_CPF,
1081 'value' => '12345678909',
1082 'country' => 'BR',
1083 ),
1084 ),
1085 'US' => array(
1086 array(
1087 'type' => Tax_Id_Types::TYPE_US_EIN,
1088 'value' => '12-3456789',
1089 'country' => 'US',
1090 ),
1091 ),
1092 );
1093
1094 $tax_ids = $samples[ $country ] ?? array();
1095 $labels = Receipt_I18n_Labels::get_labels( $locale );
1096
1097 return array_map(
1098 static function ( array $tax_id ) use ( $labels ): array {
1099 $type = (string) $tax_id['type'];
1100 $tax_id['label'] = $labels[ 'customer_tax_id_label_' . $type ] ?? $labels['customer_tax_id_label_other'];
1101
1102 return $tax_id;
1103 },
1104 $tax_ids
1105 );
1106 }
1107
1108 /**
1109 * Get product data for line items.
1110 *
1111 * Queries up to 3 published simple/variable products from the catalog.
1112 * Falls back to hardcoded product definitions if the catalog is empty.
1113 * Pads the result to at least MIN_LINES items.
1114 *
1115 * @return array[] Array of product arrays with name, price, and sku keys.
1116 */
1117 private function get_products(): array {
1118 $products = wc_get_products(
1119 array(
1120 'status' => 'publish',
1121 'type' => array( 'simple', 'variable' ),
1122 'limit' => 3,
1123 'return' => 'objects',
1124 )
1125 );
1126
1127 $result = array();
1128
1129 if ( ! empty( $products ) ) {
1130 foreach ( $products as $product ) {
1131 $meta = array();
1132
1133 if ( $product->is_type( 'variable' ) ) {
1134 // Use the first visible variation's attributes (excludes hidden/disabled).
1135 $children = $product->get_visible_children();
1136 if ( ! empty( $children ) ) {
1137 $variation = wc_get_product( $children[0] );
1138 if ( $variation instanceof \WC_Product_Variation ) {
1139 $product = $variation;
1140 foreach ( $variation->get_variation_attributes() as $attr_key => $attr_value ) {
1141 if ( '' !== $attr_value ) {
1142 $taxonomy = str_replace( 'attribute_', '', $attr_key );
1143 $label = wc_attribute_label( $taxonomy, $variation );
1144 $value = $variation->get_attribute( $taxonomy );
1145 $meta[] = array(
1146 'key' => wp_strip_all_tags( $label ),
1147 'value' => wp_strip_all_tags( '' !== $value ? $value : $attr_value ),
1148 );
1149 }
1150 }
1151 }
1152 }
1153 }
1154
1155 $price = (float) $product->get_price();
1156 if ( $price <= 0 ) {
1157 $price = 19.99;
1158 }
1159 $regular_price = (float) $product->get_regular_price();
1160 if ( $regular_price <= 0 ) {
1161 $regular_price = $price;
1162 }
1163
1164 $result[] = array(
1165 'name' => $product->get_name(),
1166 'price' => $price,
1167 'regular_price' => $regular_price,
1168 'sku' => $product->get_sku() ? $product->get_sku() : '',
1169 'meta' => $meta,
1170 'attributes' => $meta,
1171 );
1172 }
1173 }
1174
1175 // Fall back to hardcoded products if catalog is empty.
1176 if ( empty( $result ) ) {
1177 $result = self::FALLBACK_PRODUCTS;
1178 }
1179
1180 // Pad to at least MIN_LINES items.
1181 $fallback_index = 0;
1182 $fallback_count = count( self::FALLBACK_PRODUCTS );
1183 $result_count = count( $result );
1184 while ( $result_count < self::MIN_LINES ) {
1185 $result[] = self::FALLBACK_PRODUCTS[ $fallback_index % $fallback_count ];
1186 ++$fallback_index;
1187 ++$result_count;
1188 }
1189
1190 $result = array_slice( $result, 0, 3 );
1191
1192 // Preview data intentionally demonstrates the savings fields even when
1193 // the store's sampled catalog products are all at regular price.
1194 if ( isset( $result[0] ) && (float) $result[0]['regular_price'] <= (float) $result[0]['price'] ) {
1195 $result[0]['regular_price'] = (float) $result[0]['price'] + 5.0;
1196 }
1197
1198 return $result;
1199 }
1200
1201 /**
1202 * Get tax configuration from WooCommerce tax rates.
1203 *
1204 * Uses WC_Tax::find_rates() with the store's base location to find
1205 * the primary tax rate. Falls back to a default rate if no rates
1206 * are configured.
1207 *
1208 * @param bool $tax_enabled Whether taxes are enabled for this store.
1209 *
1210 * @return array Tax config with rate (float), label (string), and code (string).
1211 */
1212
1213 /**
1214 * Get tax configuration from WooCommerce tax rates.
1215 *
1216 * Uses WC_Tax::find_rates() with the store's base location to find
1217 * the primary tax rate. Falls back to a default rate if no rates
1218 * are configured.
1219 *
1220 * @param bool $tax_enabled Whether taxes are enabled for this store.
1221 *
1222 * @return array Tax config with rate (float), label (string), and code (string).
1223 */
1224 private function get_tax_config( bool $tax_enabled ): array {
1225 if ( ! $tax_enabled ) {
1226 return array(
1227 'rate' => 0.0,
1228 'label' => '',
1229 'code' => '',
1230 );
1231 }
1232
1233 $default = array(
1234 'rate' => self::FALLBACK_TAX_RATE,
1235 'label' => /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Tax', 'woocommerce-pos' ),
1236 'code' => '1',
1237 );
1238
1239 $raw = get_option( 'woocommerce_default_country', '' );
1240 $parts = explode( ':', $raw );
1241 $country = $parts[0] ?? '';
1242 $state = $parts[1] ?? '';
1243
1244 $rates = \WC_Tax::find_rates(
1245 array(
1246 'country' => $country,
1247 'state' => $state,
1248 'postcode' => get_option( 'woocommerce_store_postcode', '' ),
1249 'city' => get_option( 'woocommerce_store_city', '' ),
1250 'tax_class' => '',
1251 )
1252 );
1253
1254 if ( ! empty( $rates ) ) {
1255 $first_rate = reset( $rates );
1256 $rate_id = key( $rates );
1257
1258 return array(
1259 'rate' => (float) $first_rate['rate'],
1260 'label' => $first_rate['label'] ? $first_rate['label'] : /* translators: Sample receipt label or value used in receipt template previews. */ __( 'Tax', 'woocommerce-pos' ),
1261 'code' => (string) $rate_id,
1262 );
1263 }
1264
1265 return $default;
1266 }
1267 }
1268