PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.2
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Services / ShortCodeParser / Parsers / OrderParser.php

OrderParser.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.2, at app/Services/ShortCodeParser/Parsers/OrderParser.php

942 lines 34.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\ShortCodeParser\Parsers;
4
5 use FluentCart\Api\CurrencySettings;
6 use FluentCart\Api\StoreSettings;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Models\Order;
10 use FluentCart\App\Models\Subscription;
11 use FluentCart\App\Modules\PaymentMethods\Core\GatewayManager;
12 use FluentCart\App\Services\DateTime\DateTime;
13 use FluentCart\App\Services\Payments\PaymentHelper;
14 use FluentCart\App\Services\Payments\PaymentReceipt;
15 use FluentCart\App\Services\TemplateService;
16 use FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper;
17 use FluentCart\Framework\Support\Arr;
18 use FluentCart\Framework\Support\Str;
19 use FluentCartPro\App\Modules\Licensing\Models\License;
20
21 class OrderParser extends BaseParser
22 {
23 private StoreSettings $storeSettings;
24 private $order;
25 private $orderTz;
26
27 private $licenses;
28
29 private bool $licenseLoaded = false;
30
31 private $subscriptions;
32
33 private bool $subscriptionLoaded = false;
34
35 public function __construct($data)
36 {
37 $this->storeSettings = new StoreSettings();
38 $this->order = Arr::get($data, 'order');
39 $config = Arr::wrap(
40 Arr::get($this->order, 'config')
41 );
42 $rawTz = Arr::get($config, 'user_tz', 'UTC');
43 $this->orderTz = (@timezone_open($rawTz) !== false) ? $rawTz : 'UTC';
44 $orderId = Arr::get($this->order, 'id');
45
46
47 parent::__construct($data);
48 }
49
50 // protected array $methodMap = [
51 // 'customer_dashboard_link' => 'getCustomerDashboardLink',
52 // 'payment_summary' => 'getPaymentSummary',
53 // 'payment_receipt' => 'getPaymentReceipt',
54 // ];
55
56 protected array $methodMap = [
57 'item_count' => 'getItemCount',
58 'is_digital' => 'getIsDigital',
59 'store_vat_display' => 'getStoreVatDisplay',
60 'store_company_name' => 'getStoreCompanyName',
61 'store_company_display' => 'getStoreCompanyDisplay',
62 'store_legal_registration_id' => 'getStoreLegalRegistrationId',
63 'store_legal_registration_display' => 'getStoreLegalRegistrationDisplay',
64 'store_seller_vat_id' => 'getStoreSellerVatId',
65 'store_seller_vat_display' => 'getStoreSellerVatDisplay',
66 'store_seller_tax_id' => 'getStoreSellerTaxId',
67 'store_tax_display' => 'getStoreTaxDisplay',
68 'buyer_vat_display' => 'getBuyerVatDisplay',
69 'buyer_company_name' => 'getBuyerCompanyName',
70 'buyer_legal_registration_id' => 'getBuyerLegalRegistrationId',
71 'buyer_reverse_charge_declaration' => 'getBuyerReverseChargeDeclaration',
72 'tax_breakdown' => 'getTaxBreakdown',
73 'fee_lines' => 'getFeeLines',
74 ];
75
76 protected array $attributeMap = [
77 'id' => 'order.id',
78 'status' => 'order.status',
79 'created_at' => 'order.created_at',
80 'updated_at' => 'order.updated_at',
81 ];
82
83 protected array $centColumns = [
84 'total_amount',
85 'subtotal',
86 'discount_tax',
87 'manual_discount_total',
88 'coupon_discount_total',
89 'shipping_tax',
90 'shipping_total',
91 'fee_total',
92 'tax_total',
93 'total_paid',
94 'total_refund'
95 ];
96
97 public function parse($accessor = '', $code = '', $transformer = null): ?string
98 {
99
100 if ($this->shouldParseAddress($accessor)) {
101 return $this->parseAddressFields($accessor);
102 }
103
104 if (in_array($accessor, ['updated_at', 'created_at'])) {
105 $date = Arr::get($this->data, $this->attributeMap[$accessor]);
106 $timestamp = DateTime::anyTimeToGmt($date)->getTimestamp();
107
108 $date = wp_date(
109 get_option('date_format'),
110 $timestamp,
111 new \DateTimeZone($this->orderTz)
112 );
113
114 return Helper::translateNumber($date);
115 }
116
117 // Handle _formatted suffix for cent columns (e.g. total_amount_formatted)
118 $formattedSuffix = '_formatted';
119 if (Str::endsWith($accessor, $formattedSuffix)) {
120 $baseAccessor = substr($accessor, 0, -strlen($formattedSuffix));
121 if (in_array($baseAccessor, $this->centColumns)) {
122 $amount = Arr::get($this->order, $baseAccessor);
123 if (!is_numeric($amount)) {
124 return (string) $amount;
125 }
126 return CurrencySettings::getPriceHtml($amount, $this->order['currency']);
127 }
128 }
129
130 if (in_array($accessor, $this->centColumns)) {
131 $amount = Arr::get($this->order, $accessor);
132 if (!is_numeric($amount)) {
133 return (string) $amount;
134 }
135 return (string) ($amount / 100);
136 }
137
138 // $html parsers
139 $htmlParsers = [
140 'order.download_details',
141 'order.items_table',
142 'order.payment_summary',
143 'order.payment_receipt',
144 'order.subscription_details',
145 'order.license_details',
146 'order.address_details',
147 ];
148
149 if (in_array($code, $htmlParsers)) {
150
151 $order = $this->order;
152
153 if ($code == 'order.items_table') {
154 return \FluentCart\App\App::make('view')->make('emails.parts.items_table', [
155 'order' => $order,
156 'formattedItems' => $order->order_items,
157 'heading' => __('Order Summary', 'fluent-cart'),
158 ]);
159 }
160
161 if ($code === 'order.subscription_details') {
162 if ($order->subscriptions && $order->subscriptions->count() > 0) {
163 return \FluentCart\App\App::make('view')->make('invoice.parts.subscription_items', [
164 'subscriptions' => $order->subscriptions,
165 'order' => $order
166 ]);
167 }
168 return '';
169 }
170
171 if ($code === 'order.license_details') {
172 $licenses = $order->getLicenses();
173 if ($licenses && $licenses->count() > 0) {
174 return \FluentCart\App\App::make('view')->make('emails.parts.licenses', [
175 'licenses' => $licenses,
176 'heading' => _n('License', 'Licenses', $licenses->count(), 'fluent-cart'),
177 'show_notice' => false
178 ]);
179 }
180 return '';
181 }
182
183 if ($code === 'order.download_details') {
184 $downloads = $order->getDownloads();
185 if ($downloads) {
186 return \FluentCart\App\App::make('view')->make('emails.parts.downloads', [
187 'order' => $order,
188 'heading' => _n('Download', 'Downloads', count($downloads), 'fluent-cart'),
189 'downloadItems' => $downloads,
190 ]);
191 }
192 return '';
193 }
194
195 if ($code === 'order.address_details') {
196 return \FluentCart\App\App::make('view')->make('emails.parts.addresses', [
197 'order' => $order,
198 ]);
199 }
200
201 if ($code == 'order.payment_summary') {
202 return $this->getPaymentSummary();
203 }
204 if ($code == 'order.payment_receipt') {
205 return $this->getPaymentReceipt();
206 }
207 }
208
209
210 return $this->get($accessor, $code);
211 }
212
213 public function shouldParseAddress($accessor): bool
214 {
215 return Str::startsWith($accessor, 'billing.') || Str::startsWith($accessor, 'shipping.');
216 }
217
218 public function parseAddressFields($accessor)
219 {
220 list($addressType, $accessorsKey) = $this->resolveAddressFieldKeys($accessor);
221 return $this->getAddressData($addressType, $accessorsKey);
222 }
223
224 public function resolveAddressFieldKeys($accessor): array
225 {
226 $exploded = explode('.', $accessor);
227 $addressType = $exploded[0];
228 $accessorsKey = implode('.', array_slice($exploded, 1));
229 return [$addressType, $accessorsKey];
230 }
231
232 public function getAddressData($addressAccessor, $accessor = null)
233 {
234 $address = Arr::get($this->order, $addressAccessor . '_address');
235
236 if (empty($address)) {
237 return "";
238 }
239
240 $formattedFields = ['city', 'state', 'country'];
241 if (in_array($accessor, $formattedFields) && method_exists($address, 'getFormattedAddress')) {
242 $formatted = $address->getFormattedAddress();
243 return Arr::get($formatted, $accessor) ?: '';
244 }
245
246 return Arr::get($address, $accessor) ?: '';
247 }
248
249 public function getPaymentSummary()
250 {
251 $order = $this->order;
252
253 return \FluentCart\App\App::make('view')->make('emails.parts.items_table', [
254 'order' => $order,
255 'formattedItems' => $order->order_items,
256 'heading' => '',
257 ]);
258 }
259
260 public function getPaymentReceipt()
261 {
262 $order = $this->order;
263
264 ob_start();
265
266 \FluentCart\App\App::make('view')->render('emails.parts.items_table', [
267 'order' => $order,
268 'formattedItems' => $order->order_items,
269 'heading' => __('Order Summary', 'fluent-cart'),
270 ]);
271
272
273 if ($order->subscriptions && $order->subscriptions->count() > 0) {
274 \FluentCart\App\App::make('view')->render('invoice.parts.subscription_items', [
275 'subscriptions' => $order->subscriptions,
276 'order' => $order
277 ]);
278 }
279
280 $licenses = $order->getLicenses();
281 if ($licenses && $licenses->count() > 0) {
282 \FluentCart\App\App::make('view')->render('emails.parts.licenses', [
283 'licenses' => $licenses,
284 'heading' => __('Licenses', 'fluent-cart'),
285 'show_notice' => false
286 ]);
287 }
288
289 $downloads = $order->getDownloads();
290 if ($downloads) {
291 \FluentCart\App\App::make('view')->render('emails.parts.downloads', [
292 'order' => $order,
293 'heading' => __('Downloads', 'fluent-cart'),
294 'downloadItems' => $downloads,
295 ]);
296 }
297
298 echo '<hr />';
299
300 \FluentCart\App\App::make('view')->render('emails.parts.addresses', [
301 'order' => $order,
302 ]);
303
304 return ob_get_clean();
305
306
307 }
308
309 public function getDiscountTotal(): string
310 {
311 return (string) ($this->getDiscountTotalInCents() / 100);
312 }
313
314 public function getDiscountTotalFormatted(): string
315 {
316 return CurrencySettings::getPriceHtml($this->getDiscountTotalInCents(), $this->order['currency']);
317 }
318
319 private function getDiscountTotalInCents(): int
320 {
321 return (int) Arr::get($this->order, 'coupon_discount_total', 0)
322 + (int) Arr::get($this->order, 'manual_discount_total', 0);
323 }
324
325 public function getOrderRef(): string
326 {
327 $invoiceNo = Arr::get($this->order, 'invoice_no');
328
329 if (!empty($invoiceNo)) {
330 return (string) $invoiceNo;
331 }
332
333 return (string) Arr::get($this->order, 'id');
334 }
335
336 public function getCustomerDashboardAnchorLink($accessor, $code = null, $conditions = [])
337 {
338 $defaultValue = Arr::get($conditions, 'default_value') ?? Arr::get($this->order, 'invoice_no');
339 if (empty($this->order)) {
340 return $code;
341 }
342
343 $profilePage = $this->storeSettings->getCustomerProfilePage();
344
345
346 if (!empty($profilePage)) {
347 return "<a style='color: #017EF3; text-decoration: none;' href='" . "$profilePage#/order/" . Arr::get($this->order, 'uuid') . "'>" . $defaultValue . "</a>";
348 } else {
349 return Arr::get($this->order, 'invoice_no');
350 }
351
352 }
353
354 public function getCustomerDashboardLink($accessor, $code = null)
355 {
356 if (empty($this->order)) {
357 return $code;
358 }
359
360 $orderLink = TemplateService::getCustomerProfileUrl('order/' . Arr::get($this->order, 'uuid'));
361
362 return is_user_logged_in() ? $orderLink : wp_login_url($orderLink);
363 }
364
365 public function getPaymentLink($accessor = null, $code = null)
366 {
367 if (empty($this->order)) {
368 return $code;
369 }
370
371 return PaymentHelper::getCustomPaymentLink(Arr::get($this->order, 'uuid'));
372 }
373
374 public function getAdminOrderLink($accessor, $code = null)
375 {
376 if (empty($this->order)) {
377 return $code;
378 }
379 return admin_url('admin.php?page=fluent-cart#/orders/' . Arr::get($this->order, 'id') . '/view');
380 }
381
382 public function getAdminOrderAnchorLink($accessor, $code = null, $conditions = [])
383 {
384 $defaultValue = Arr::get($conditions, 'default_value');
385 if (empty($this->order)) {
386 return $code;
387 }
388
389 $url = admin_url('admin.php?page=fluent-cart#/orders/' . Arr::get($this->order, 'id') . '/view');
390
391 if (!empty($defaultValue)) {
392 return "<a style='color: #017EF3; text-decoration: none;' href='" . $url . "'>" . $defaultValue . "</a>";
393 }
394
395 return $url;
396 }
397
398 public function getCustomerOrderLink($accessor, $code = null)
399 {
400 if (empty($this->order)) {
401 return $code;
402 }
403
404 $customerProfilePage = $this->storeSettings->getCustomerProfilePage();
405 $orderLink = $customerProfilePage . '#/order/' . Arr::get($this->order, 'uuid');
406
407 return is_user_logged_in() ? $orderLink : wp_login_url($orderLink);
408 }
409
410 public function getTotalAmount()
411 {
412 $total = ($this->order['total_amount'] / 100);
413 $currency_sign = $this->order['currency'];
414 return $total . $currency_sign;
415 }
416
417 public function getDownloads()
418 {
419 $order = $this->order;
420
421 $downloads = $order->getDownloads();
422 if ($downloads) {
423 return (string)\FluentCart\App\App::make('view')->make('emails.parts.downloads', [
424 'order' => $order,
425 'heading' => '',
426 'downloadItems' => $downloads
427 ]);
428 }
429
430 return '';
431
432 }
433
434 public function getLicenses()
435 {
436 $order = $this->order;
437 $licenses = $order->getLicenses();
438 if ($licenses && $licenses->count() > 0) {
439 return (string)\FluentCart\App\App::make('view')->make('emails.parts.licenses', [
440 'licenses' => $licenses,
441 'heading' => __('Licenses', 'fluent-cart'),
442 'show_notice' => false
443 ]);
444 }
445
446 return '';
447 }
448
449 public function getLicenseCount(): string
450 {
451 return (string)$this->licenses->count();
452 }
453
454 public function getIsDigital(): string
455 {
456 if (!$this->order) {
457 return 'no';
458 }
459
460 $fulfillmentType = Arr::get($this->order, 'fulfillment_type');
461
462 return $fulfillmentType === 'digital' ? 'yes' : 'no';
463 }
464
465 public function getItemCount(): string
466 {
467 $orderItems = $this->order ? $this->order->order_items : null;
468
469 if ($orderItems) {
470 return (string)$orderItems->count();
471 }
472
473 return '0';
474 }
475
476 public function getPaymentMethodTitle(): string
477 {
478 if (!$this->order) {
479 return '';
480 }
481
482 $title = (string) Arr::get($this->order, 'payment_method_title', '');
483 if ($title !== '') {
484 return $title;
485 }
486
487 $slug = (string) Arr::get($this->order, 'payment_method', '');
488 if ($slug === '') {
489 return '';
490 }
491
492 if (class_exists(GatewayManager::class)) {
493 $gateway = GatewayManager::getInstance($slug);
494 if ($gateway) {
495 $gatewayTitle = (string) $gateway->getMeta('title');
496 if ($gatewayTitle !== '') {
497 return $gatewayTitle;
498 }
499 }
500 }
501
502 return ucwords(str_replace(['_', '-'], ' ', $slug));
503 }
504
505 public function getFeeLines(): string
506 {
507 if (!$this->order) {
508 return '';
509 }
510
511 $currency = (string) Arr::get($this->order, 'currency', '');
512 $feeItems = $this->order->feeItems()->get();
513
514 if ($feeItems->isEmpty()) {
515 return '';
516 }
517
518 $rowStyle = 'padding:3px 0;font-size:11px;font-weight:600;color:#525866;';
519 $valueStyle = 'text-align:right;';
520 $rows = '';
521
522 foreach ($feeItems as $feeItem) {
523 $rows .= '<tr>'
524 . '<td style="' . $rowStyle . '">' . esc_html((string) $feeItem->title) . '</td>'
525 . '<td style="' . $rowStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml((int) $feeItem->subtotal, $currency) . '</td>'
526 . '</tr>';
527 }
528
529 return $rows;
530 }
531
532 public function getTaxBreakdown(): string
533 {
534 if (!$this->order) {
535 return '';
536 }
537
538 $currency = (string) Arr::get($this->order, 'currency', '');
539 $summary = TaxSummaryHelper::computeTaxSummary($this->order);
540
541 if (!$summary['shouldRender']) {
542 return '';
543 }
544
545 $headingStyle = 'padding:8px 0 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:#94a3b8;';
546 $mutedStyle = 'padding:3px 0;font-size:11px;color:#94a3b8;';
547 $normalStyle = 'padding:3px 0;font-size:11px;font-weight:600;color:#525866;';
548 $totalStyle = 'padding:6px 0 3px;font-size:11px;font-weight:700;color:#0E121B;border-top:1px solid #e2e8f0;';
549 $valueStyle = 'text-align:right;';
550
551 $rows = '';
552
553 if ($summary['isReverseCharge']) {
554 $rcReversedTotal = isset($summary['reversedTaxTotal']) ? (int) $summary['reversedTaxTotal'] : 0;
555 $rcReversedValue = $rcReversedTotal > 0
556 ? esc_html(Helper::toDecimal($rcReversedTotal))
557 : esc_html__('Charge reversed', 'fluent-cart');
558 $rows .= '<tr><td colspan="2" style="' . $headingStyle . '">'
559 . esc_html__('TAX SUMMARY', 'fluent-cart')
560 . '</td></tr>';
561 $rows .= '<tr>'
562 . '<td style="' . $totalStyle . '">' . esc_html__('Tax reversed', 'fluent-cart') . '</td>'
563 . '<td style="' . $totalStyle . $valueStyle . '">' . $rcReversedValue . '</td>'
564 . '</tr>';
565 return $this->wrapTaxBreakdownBox($rows);
566 }
567
568 $foldedRateLines = Arr::get($summary, 'foldedRateLines', []);
569 $includedInPrices = (int) Arr::get($summary, 'includedInPrices', 0);
570 $opFeeRows = Arr::get($summary, 'feeTaxLineRows', []);
571 $taxRateLines = Arr::get($summary, 'taxRateLines', []);
572 $productTaxRowCount = !empty($taxRateLines)
573 ? count($taxRateLines)
574 : (int) ($summary['inclusiveTax'] > 0) + (int) ($summary['exclusiveTax'] > 0);
575 $rowCount = $productTaxRowCount + count($opFeeRows) + (int) ($summary['shippingTax'] > 0);
576 $shippingTaxLines = Arr::get($summary, 'shippingTaxLines', []);
577 $shouldShowBreakdown = !empty($taxRateLines)
578 || !empty($shippingTaxLines)
579 || $rowCount >= 2
580 || ($rowCount === 1 && !($summary['payableTax'] > 0 || $summary['inclusiveTax'] > 0 || (int) Arr::get($summary, 'inclusiveFeeTax', 0) > 0));
581
582 if (!empty($foldedRateLines)) {
583 $colHeadStyle = 'width:58%;padding:3px 8px 3px 0;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:#64748b;vertical-align:top;';
584 $colHeadBaseStyle = 'width:24%;padding:3px 8px 3px 0;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:#64748b;text-align:right;white-space:nowrap;vertical-align:top;';
585 $colHeadTaxStyle = 'width:18%;padding:3px 0;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:#64748b;text-align:right;white-space:nowrap;vertical-align:top;';
586
587 $rows .= '<tr><td colspan="2" style="padding:5px 0 2px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:#64748b;">'
588 . esc_html__('Tax breakdown by rate', 'fluent-cart')
589 . '</td></tr>';
590
591 $nestedTable = '<table width="100%" cellpadding="0" cellspacing="0" style="border:none;border-collapse:collapse;table-layout:fixed;">';
592 $nestedTable .= '<tr>'
593 . '<td style="' . $colHeadStyle . '">' . esc_html__('Rate', 'fluent-cart') . '</td>'
594 . '<td style="' . $colHeadBaseStyle . '">' . esc_html__('Taxable base', 'fluent-cart') . '</td>'
595 . '<td style="' . $colHeadTaxStyle . '">' . esc_html__('Tax', 'fluent-cart') . '</td>'
596 . '</tr>';
597
598 foreach ($foldedRateLines as $foldedRow) {
599 $foldedStyle = !empty($foldedRow['inclusive']) ? $mutedStyle : $normalStyle;
600 $foldedStyleR = $foldedStyle . $valueStyle;
601 $nestedTable .= '<tr>'
602 . '<td style="width:58%;' . $foldedStyle . 'padding-right:8px;white-space:normal;word-break:break-word;overflow-wrap:break-word;vertical-align:top;">' . esc_html((string) $foldedRow['label']) . '</td>'
603 . '<td style="width:24%;' . $foldedStyleR . 'padding-right:8px;white-space:nowrap;vertical-align:top;">' . CurrencySettings::getPriceHtml((int) $foldedRow['base'], $currency) . '</td>'
604 . '<td style="width:18%;' . $foldedStyleR . 'white-space:nowrap;vertical-align:top;">' . CurrencySettings::getPriceHtml((int) $foldedRow['tax'], $currency) . '</td>'
605 . '</tr>';
606 }
607 $nestedTable .= '</table>';
608
609 $rows .= '<tr><td colspan="2" style="padding:2px 0 0 0;">' . $nestedTable . '</td></tr>';
610
611 $rows .= '<tr>'
612 . '<td style="' . $totalStyle . '">' . esc_html__('Total tax', 'fluent-cart') . '</td>'
613 . '<td style="' . $totalStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml((int) $summary['totalOrderTax'], $currency) . '</td>'
614 . '</tr>';
615
616 if ($includedInPrices > 0) {
617 $rows .= '<tr>'
618 . '<td style="' . $mutedStyle . '">' . esc_html__('of which included in prices', 'fluent-cart') . '</td>'
619 . '<td style="' . $mutedStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($includedInPrices, $currency) . '</td>'
620 . '</tr>';
621 }
622
623 if ($summary['payableTax'] > 0 && $includedInPrices > 0) {
624 $payableStyle = $totalStyle;
625 $rows .= '<tr>'
626 . '<td style="' . $payableStyle . '">' . esc_html__('Payable now (added)', 'fluent-cart') . '</td>'
627 . '<td style="' . $payableStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml((int) $summary['payableTax'], $currency) . '</td>'
628 . '</tr>';
629 }
630
631 return $this->wrapTaxBreakdownBox($rows);
632 }
633
634 $rows .= '<tr><td colspan="2" style="' . $headingStyle . '">'
635 . esc_html__('TAX SUMMARY', 'fluent-cart')
636 . '</td></tr>';
637
638 if (!empty($taxRateLines) && $shouldShowBreakdown) {
639 foreach ($taxRateLines as $taxLine) {
640 $taxLineStyle = !empty($taxLine['inclusive']) ? $mutedStyle : $normalStyle;
641 $rows .= '<tr>'
642 . '<td style="' . $taxLineStyle . '">' . esc_html($taxLine['label']) . '</td>'
643 . '<td style="' . $taxLineStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($taxLine['order_tax'], $currency) . '</td>'
644 . '</tr>';
645 }
646 }
647
648 if (empty($taxRateLines) && $summary['inclusiveTax'] > 0 && $shouldShowBreakdown) {
649 $rows .= '<tr>'
650 . '<td style="' . $mutedStyle . '">' . esc_html__('Included in item prices', 'fluent-cart') . '</td>'
651 . '<td style="' . $mutedStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['inclusiveTax'], $currency) . '</td>'
652 . '</tr>';
653 }
654
655 if (empty($taxRateLines) && $summary['exclusiveTax'] > 0 && $shouldShowBreakdown) {
656 $rows .= '<tr>'
657 . '<td style="' . $normalStyle . '">' . esc_html__('Added on products', 'fluent-cart') . '</td>'
658 . '<td style="' . $normalStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['exclusiveTax'], $currency) . '</td>'
659 . '</tr>';
660 }
661
662 if ($shouldShowBreakdown) {
663 foreach ($opFeeRows as $feeRow) {
664 $feeLineStyle = $feeRow['inclusive'] ? $mutedStyle : $normalStyle;
665 $rows .= '<tr>'
666 . '<td style="' . $feeLineStyle . '">' . esc_html($feeRow['display_label']) . '</td>'
667 . '<td style="' . $feeLineStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($feeRow['tax_amount'], $currency) . '</td>'
668 . '</tr>';
669 }
670 }
671
672 if ($summary['shippingTax'] > 0 && $shouldShowBreakdown) {
673 $isShippingInclusive = TaxSummaryHelper::isShippingTaxInclusive($this->order);
674 $shippingRowStyle = $isShippingInclusive ? $mutedStyle : $normalStyle;
675 if (!empty($shippingTaxLines)) {
676 foreach ($shippingTaxLines as $shippingTaxLine) {
677 $rows .= '<tr>'
678 . '<td style="' . $shippingRowStyle . '">' . esc_html($shippingTaxLine['label']) . '</td>'
679 . '<td style="' . $shippingRowStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($shippingTaxLine['shipping_tax'], $currency) . '</td>'
680 . '</tr>';
681 }
682 } else {
683 $shippingRowLabel = $isShippingInclusive
684 ? esc_html__('Included in shipping prices', 'fluent-cart')
685 : esc_html__('Added on shipping', 'fluent-cart');
686 $rows .= '<tr>'
687 . '<td style="' . $shippingRowStyle . '">' . $shippingRowLabel . '</td>'
688 . '<td style="' . $shippingRowStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['shippingTax'], $currency) . '</td>'
689 . '</tr>';
690 }
691 }
692
693 if ($summary['payableTax'] > 0) {
694 $rows .= '<tr>'
695 . '<td style="' . $totalStyle . '">' . esc_html__('Total payable tax', 'fluent-cart') . '</td>'
696 . '<td style="' . $totalStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['payableTax'], $currency) . '</td>'
697 . '</tr>';
698 }
699
700 if ($summary['inclusiveTax'] > 0 || !empty($summary['inclusiveFeeTax'])) {
701 $rows .= '<tr>'
702 . '<td style="' . $mutedStyle . '">' . esc_html__('Total tax in this order', 'fluent-cart') . '</td>'
703 . '<td style="' . $mutedStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['totalOrderTax'], $currency) . '</td>'
704 . '</tr>';
705 }
706
707 return $this->wrapTaxBreakdownBox($rows);
708 }
709
710 /**
711 * Wrap the accumulated tax-breakdown rows in their own grey rounded box,
712 * right-aligned to a fixed 400px column.
713 *
714 * The tax breakdown is the only thing that lives inside the grey box; the
715 * surrounding Subtotal/Discount/Shipping/Fees rows (above) and Refund/Total/
716 * Payment rows (below) are rendered as plain rows by the summary table and
717 * stay outside this wrapper. A table-based 400px right-aligned wrapper is used
718 * (not a div) so PDF renderers (mPDF/Dompdf) and email clients honour the
719 * width/alignment, matching the boxed treatment on the thank-you/customer
720 * surfaces. Consumed by the PDF receipt templates via {{order.tax_breakdown}}.
721 *
722 * @param string $rows Inner <tr> rows for the tax breakdown.
723 * @return string A single <tr><td colspan="2"> cell carrying the grey box.
724 */
725 private function wrapTaxBreakdownBox(string $rows): string
726 {
727 if ($rows === '') {
728 return '';
729 }
730
731 $boxStyle = 'background-color:rgb(249,250,251);padding:16px;border-radius:8px;';
732
733 $nestedTable = '<table width="100%" cellpadding="0" cellspacing="0" style="width:100%;border:none;border-collapse:collapse;">'
734 . $rows
735 . '</table>';
736
737 return '<tr><td colspan="2" style="padding:8px 0 0 0;">'
738 . '<table align="right" width="400" cellpadding="0" cellspacing="0" style="width:400px;max-width:100%;border:none;border-collapse:collapse;margin:0 0 0 auto;">'
739 . '<tr><td style="' . $boxStyle . '">' . $nestedTable . '</td></tr>'
740 . '</table>'
741 . '</td></tr>';
742 }
743
744 private function resolveTaxRateLabel($orderTaxRate): string
745 {
746 $taxRate = $orderTaxRate->tax_rate ?? null;
747 if ($taxRate) {
748 $name = (string) Arr::get($taxRate, 'name', '');
749 $rate = Arr::get($taxRate, 'rate');
750 if ($name !== '' && is_numeric($rate)) {
751 $rate = (string) $rate;
752
753 if (strpos($rate, '.') !== false) {
754 $rate = rtrim(rtrim($rate, '0'), '.');
755 }
756
757 return $name . ' (' . $rate . '%)';
758 }
759 if ($name !== '') {
760 return $name;
761 }
762 }
763
764 return __('Tax', 'fluent-cart');
765 }
766
767 public function getSubscriptions()
768 {
769 return '';
770 }
771
772 /**
773 * Returns formatted store VAT display string, e.g. "VAT: NL123456789B01".
774 * Returns empty string if no store VAT is configured for this order.
775 */
776 public function getStoreVatDisplay(): string
777 {
778 if (!$this->order) {
779 return '';
780 }
781
782 $orderTaxRate = $this->order->orderTaxRates ? $this->order->orderTaxRates->first() : null;
783
784 if (!$orderTaxRate) {
785 return '';
786 }
787
788 $storeVatNumber = Arr::get($orderTaxRate->meta ?? [], 'store_vat_number', '');
789
790 if (empty($storeVatNumber)) {
791 return '';
792 }
793
794 $taxCountry = Arr::get($orderTaxRate->meta ?? [], 'tax_country', '');
795 $label = \FluentCart\App\Modules\Tax\TaxModule::getCountryTaxTitle($taxCountry);
796
797 return esc_html($label) . ': ' . esc_html($storeVatNumber);
798 }
799
800 /**
801 * Returns formatted buyer VAT display string, e.g. "VAT/Tax ID: XX123456".
802 * Checks business_info first, then falls back to legacy VAT storage.
803 */
804 public function getBuyerVatDisplay(): string
805 {
806 if (!$this->order) {
807 return '';
808 }
809
810 $vatNumber = $this->order->getCustomerTaxNumber();
811 if (!empty($vatNumber)) {
812 $label = __('VAT/Tax ID', 'fluent-cart');
813 return esc_html($label) . ': ' . esc_html($vatNumber);
814 }
815
816 return '';
817 }
818
819 /**
820 * Returns buyer company name from billing address meta or VAT reverse charge data.
821 */
822 public function getBuyerCompanyName(): string
823 {
824 if (!$this->order) {
825 return '';
826 }
827
828 // Check billing address meta first
829 if ($this->order->billing_address) {
830 $companyName = Arr::get($this->order->billing_address->meta ?? [], 'other_data.company_name', '');
831 if (!empty($companyName)) {
832 return esc_html($companyName);
833 }
834 }
835
836 return esc_html($this->order->getCustomerTaxName());
837 }
838
839 public function getBuyerLegalRegistrationId(): string
840 {
841 if (!$this->order) {
842 return '';
843 }
844
845 if ($this->order->billing_address) {
846 $regId = Arr::get($this->order->billing_address->meta ?? [], 'other_data.legal_registration_id', '');
847 if (!empty($regId)) {
848 return esc_html($regId);
849 }
850 }
851
852 return esc_html(Arr::get($this->order->getBusinessInfo(), 'legal_registration_id', ''));
853 }
854
855 public function getBuyerReverseChargeDeclaration(): string
856 {
857 if (!$this->order) {
858 return '';
859 }
860
861 return esc_html(Arr::get($this->order->getBusinessInfo(), 'reverse_charge_declaration', ''));
862 }
863
864 // -------------------------------------------------------------------------
865 // Store business info — reads from snapshotted fct_order_meta[store_business_info]
866 // with live StoreSettings fallback for orders placed before this feature.
867 // -------------------------------------------------------------------------
868
869 private function getStoreBusinessField(string $field): string
870 {
871 if (!$this->order) {
872 return '';
873 }
874 $snapshot = $this->order->getMeta('store_business_info', false);
875 if ($snapshot !== false && isset($snapshot[$field])) {
876 return (string) $snapshot[$field];
877 }
878 return (string) $this->storeSettings->get($field, '');
879 }
880
881 public function getStoreCompanyName(): string
882 {
883 return esc_html($this->getStoreBusinessField('company_name'));
884 }
885
886 public function getStoreCompanyDisplay(): string
887 {
888 $val = $this->getStoreBusinessField('company_name');
889 if (empty($val)) {
890 return '';
891 }
892 /* translators: %1$s: store company name */
893 return esc_html(sprintf(__('Company: %1$s', 'fluent-cart'), $val));
894 }
895
896 public function getStoreLegalRegistrationId(): string
897 {
898 return esc_html($this->getStoreBusinessField('legal_registration_id'));
899 }
900
901 public function getStoreLegalRegistrationDisplay(): string
902 {
903 $val = $this->getStoreBusinessField('legal_registration_id');
904 if (empty($val)) {
905 return '';
906 }
907 /* translators: %1$s: store legal registration ID */
908 return esc_html(sprintf(__('Reg. ID: %1$s', 'fluent-cart'), $val));
909 }
910
911 public function getStoreSellerVatId(): string
912 {
913 return esc_html($this->getStoreBusinessField('seller_vat_id'));
914 }
915
916 public function getStoreSellerVatDisplay(): string
917 {
918 $val = $this->getStoreBusinessField('seller_vat_id');
919 if (empty($val)) {
920 return '';
921 }
922 /* translators: %1$s: store seller VAT ID */
923 return esc_html(sprintf(__('VAT ID: %1$s', 'fluent-cart'), $val));
924 }
925
926 public function getStoreSellerTaxId(): string
927 {
928 return esc_html($this->getStoreBusinessField('seller_tax_id'));
929 }
930
931 public function getStoreTaxDisplay(): string
932 {
933 $val = $this->getStoreBusinessField('seller_tax_id');
934 if (empty($val)) {
935 return '';
936 }
937 /* translators: %1$s: store seller tax ID */
938 return esc_html(sprintf(__('Tax ID: %1$s', 'fluent-cart'), $val));
939 }
940
941 }
942