PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 trunk 1.2.0 All 47 releases
fluent-cart / app / Services / ShortCodeParser / Parsers / OrderParser.php

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

966 lines 36.0 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 $foldedRateLines = Arr::get($summary, 'foldedRateLines', []);
554 $isRcOrder = !empty($summary['isReverseCharge']);
555 $rcReversedTotal = isset($summary['reversedTaxTotal']) ? (int) $summary['reversedTaxTotal'] : 0;
556 $rcReversedValue = $rcReversedTotal > 0
557 ? esc_html(Helper::toDecimal($rcReversedTotal))
558 : esc_html__('Charge reversed', 'fluent-cart');
559 $includedInPrices = (int) Arr::get($summary, 'includedInPrices', 0);
560 $opFeeRows = Arr::get($summary, 'feeTaxLineRows', []);
561 $taxRateLines = Arr::get($summary, 'taxRateLines', []);
562 $productTaxRowCount = !empty($taxRateLines)
563 ? count($taxRateLines)
564 : (int) ($summary['inclusiveTax'] > 0) + (int) ($summary['exclusiveTax'] > 0);
565 $rowCount = $productTaxRowCount + count($opFeeRows) + (int) ($summary['shippingTax'] > 0);
566 $shippingTaxLines = Arr::get($summary, 'shippingTaxLines', []);
567 $shouldShowBreakdown = !empty($taxRateLines)
568 || !empty($shippingTaxLines)
569 || $rowCount >= 2
570 || ($rowCount === 1 && !($summary['payableTax'] > 0 || $summary['inclusiveTax'] > 0 || (int) Arr::get($summary, 'inclusiveFeeTax', 0) > 0));
571
572 $isSimplifiedMode = ($summary['displayMode'] ?? '') === 'simplified';
573
574 if ($isSimplifiedMode && !empty($summary['simpleLine'])) {
575 $rows .= '<tr>'
576 . '<td style="' . $normalStyle . '">' . esc_html((string) $summary['simpleLine']['label']) . '</td>'
577 . '<td style="' . $normalStyle . $valueStyle . '">' . esc_html((string) $summary['simpleLine']['value']) . '</td>'
578 . '</tr>';
579
580 // Simplified mode: single tax line only, skip the detailed breakdown below.
581 return $this->wrapTaxBreakdownBox($rows);
582 }
583
584 if (!empty($foldedRateLines)) {
585 $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;';
586 $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;';
587 $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;';
588
589 $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;">'
590 . esc_html__('Tax breakdown by rate', 'fluent-cart')
591 . '</td></tr>';
592
593 $nestedTable = '<table width="100%" cellpadding="0" cellspacing="0" style="border:none;border-collapse:collapse;table-layout:fixed;">';
594 $nestedTable .= '<tr>'
595 . '<td style="' . $colHeadStyle . '">' . esc_html__('Rate', 'fluent-cart') . '</td>'
596 . '<td style="' . $colHeadBaseStyle . '">' . esc_html__('Taxable base', 'fluent-cart') . '</td>'
597 . '<td style="' . $colHeadTaxStyle . '">' . esc_html__('Tax', 'fluent-cart') . '</td>'
598 . '</tr>';
599
600 foreach ($foldedRateLines as $foldedRow) {
601 $foldedStyle = !empty($foldedRow['inclusive']) ? $mutedStyle : $normalStyle;
602 $foldedStyleR = $foldedStyle . $valueStyle;
603 $nestedTable .= '<tr>'
604 . '<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>'
605 . '<td style="width:24%;' . $foldedStyleR . 'padding-right:8px;white-space:nowrap;vertical-align:top;">' . CurrencySettings::getPriceHtml((int) $foldedRow['base'], $currency) . '</td>'
606 . '<td style="width:18%;' . $foldedStyleR . 'white-space:nowrap;vertical-align:top;">' . CurrencySettings::getPriceHtml((int) $foldedRow['tax'], $currency) . '</td>'
607 . '</tr>';
608 }
609 $nestedTable .= '</table>';
610
611 $rows .= '<tr><td colspan="2" style="padding:2px 0 0 0;">' . $nestedTable . '</td></tr>';
612
613 if ($isRcOrder) {
614 $rcReversedTotalValue = $rcReversedTotal > 0
615 ? CurrencySettings::getPriceHtml($rcReversedTotal, $currency)
616 : esc_html__('Charge reversed', 'fluent-cart');
617 $rows .= '<tr>'
618 . '<td style="' . $totalStyle . '">' . esc_html__('VAT reversed', 'fluent-cart') . '</td>'
619 . '<td style="' . $totalStyle . $valueStyle . '">' . $rcReversedTotalValue . '</td>'
620 . '</tr>';
621 return $this->wrapTaxBreakdownBox($rows);
622 }
623
624 $rows .= '<tr>'
625 . '<td style="' . $totalStyle . '">' . esc_html__('Total tax', 'fluent-cart') . '</td>'
626 . '<td style="' . $totalStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml((int) $summary['totalOrderTax'], $currency) . '</td>'
627 . '</tr>';
628
629 if ($includedInPrices > 0) {
630 $rows .= '<tr>'
631 . '<td style="' . $mutedStyle . '">' . esc_html__('of which included in prices', 'fluent-cart') . '</td>'
632 . '<td style="' . $mutedStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($includedInPrices, $currency) . '</td>'
633 . '</tr>';
634 }
635
636 if ($summary['payableTax'] > 0 && $includedInPrices > 0) {
637 $payableStyle = $totalStyle;
638 $rows .= '<tr>'
639 . '<td style="' . $payableStyle . '">' . esc_html__('Payable now (added)', 'fluent-cart') . '</td>'
640 . '<td style="' . $payableStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml((int) $summary['payableTax'], $currency) . '</td>'
641 . '</tr>';
642 }
643
644 return $this->wrapTaxBreakdownBox($rows);
645 }
646
647 if ($isRcOrder) {
648 $rows .= '<tr><td colspan="2" style="' . $headingStyle . '">'
649 . esc_html__('TAX SUMMARY', 'fluent-cart')
650 . '</td></tr>';
651 $rows .= '<tr>'
652 . '<td style="' . $totalStyle . '">' . esc_html__('Tax reversed', 'fluent-cart') . '</td>'
653 . '<td style="' . $totalStyle . $valueStyle . '">' . $rcReversedValue . '</td>'
654 . '</tr>';
655 return $this->wrapTaxBreakdownBox($rows);
656 }
657
658 $rows .= '<tr><td colspan="2" style="' . $headingStyle . '">'
659 . esc_html__('TAX SUMMARY', 'fluent-cart')
660 . '</td></tr>';
661
662 if (!empty($taxRateLines) && $shouldShowBreakdown) {
663 foreach ($taxRateLines as $taxLine) {
664 $taxLineStyle = !empty($taxLine['inclusive']) ? $mutedStyle : $normalStyle;
665 $rows .= '<tr>'
666 . '<td style="' . $taxLineStyle . '">' . esc_html($taxLine['label']) . '</td>'
667 . '<td style="' . $taxLineStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($taxLine['order_tax'], $currency) . '</td>'
668 . '</tr>';
669 }
670 }
671
672 if (empty($taxRateLines) && $summary['inclusiveTax'] > 0 && $shouldShowBreakdown) {
673 $rows .= '<tr>'
674 . '<td style="' . $mutedStyle . '">' . esc_html__('Included in item prices', 'fluent-cart') . '</td>'
675 . '<td style="' . $mutedStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['inclusiveTax'], $currency) . '</td>'
676 . '</tr>';
677 }
678
679 if (empty($taxRateLines) && $summary['exclusiveTax'] > 0 && $shouldShowBreakdown) {
680 $rows .= '<tr>'
681 . '<td style="' . $normalStyle . '">' . esc_html__('Added on products', 'fluent-cart') . '</td>'
682 . '<td style="' . $normalStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['exclusiveTax'], $currency) . '</td>'
683 . '</tr>';
684 }
685
686 if ($shouldShowBreakdown) {
687 foreach ($opFeeRows as $feeRow) {
688 $feeLineStyle = $feeRow['inclusive'] ? $mutedStyle : $normalStyle;
689 $rows .= '<tr>'
690 . '<td style="' . $feeLineStyle . '">' . esc_html($feeRow['display_label']) . '</td>'
691 . '<td style="' . $feeLineStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($feeRow['tax_amount'], $currency) . '</td>'
692 . '</tr>';
693 }
694 }
695
696 if ($summary['shippingTax'] > 0 && $shouldShowBreakdown) {
697 $isShippingInclusive = TaxSummaryHelper::isShippingTaxInclusive($this->order);
698 $shippingRowStyle = $isShippingInclusive ? $mutedStyle : $normalStyle;
699 if (!empty($shippingTaxLines)) {
700 foreach ($shippingTaxLines as $shippingTaxLine) {
701 $rows .= '<tr>'
702 . '<td style="' . $shippingRowStyle . '">' . esc_html($shippingTaxLine['label']) . '</td>'
703 . '<td style="' . $shippingRowStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($shippingTaxLine['shipping_tax'], $currency) . '</td>'
704 . '</tr>';
705 }
706 } else {
707 $shippingRowLabel = $isShippingInclusive
708 ? esc_html__('Included in shipping prices', 'fluent-cart')
709 : esc_html__('Added on shipping', 'fluent-cart');
710 $rows .= '<tr>'
711 . '<td style="' . $shippingRowStyle . '">' . $shippingRowLabel . '</td>'
712 . '<td style="' . $shippingRowStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['shippingTax'], $currency) . '</td>'
713 . '</tr>';
714 }
715 }
716
717 if ($summary['payableTax'] > 0) {
718 $rows .= '<tr>'
719 . '<td style="' . $totalStyle . '">' . esc_html__('Total payable tax', 'fluent-cart') . '</td>'
720 . '<td style="' . $totalStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['payableTax'], $currency) . '</td>'
721 . '</tr>';
722 }
723
724 if ($summary['inclusiveTax'] > 0 || !empty($summary['inclusiveFeeTax'])) {
725 $rows .= '<tr>'
726 . '<td style="' . $mutedStyle . '">' . esc_html__('Total tax in this order', 'fluent-cart') . '</td>'
727 . '<td style="' . $mutedStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['totalOrderTax'], $currency) . '</td>'
728 . '</tr>';
729 }
730
731 return $this->wrapTaxBreakdownBox($rows);
732 }
733
734 /**
735 * Wrap the accumulated tax-breakdown rows in their own grey rounded box,
736 * right-aligned to a fixed 400px column.
737 *
738 * The tax breakdown is the only thing that lives inside the grey box; the
739 * surrounding Subtotal/Discount/Shipping/Fees rows (above) and Refund/Total/
740 * Payment rows (below) are rendered as plain rows by the summary table and
741 * stay outside this wrapper. A table-based 400px right-aligned wrapper is used
742 * (not a div) so PDF renderers (mPDF/Dompdf) and email clients honour the
743 * width/alignment, matching the boxed treatment on the thank-you/customer
744 * surfaces. Consumed by the PDF receipt templates via {{order.tax_breakdown}}.
745 *
746 * @param string $rows Inner <tr> rows for the tax breakdown.
747 * @return string A single <tr><td colspan="2"> cell carrying the grey box.
748 */
749 private function wrapTaxBreakdownBox(string $rows): string
750 {
751 if ($rows === '') {
752 return '';
753 }
754
755 $boxStyle = 'background-color:rgb(249,250,251);padding:16px;border-radius:8px;';
756
757 $nestedTable = '<table width="100%" cellpadding="0" cellspacing="0" style="width:100%;border:none;border-collapse:collapse;">'
758 . $rows
759 . '</table>';
760
761 return '<tr><td colspan="2" style="padding:8px 0 0 0;">'
762 . '<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;">'
763 . '<tr><td style="' . $boxStyle . '">' . $nestedTable . '</td></tr>'
764 . '</table>'
765 . '</td></tr>';
766 }
767
768 private function resolveTaxRateLabel($orderTaxRate): string
769 {
770 $taxRate = $orderTaxRate->tax_rate ?? null;
771 if ($taxRate) {
772 $name = (string) Arr::get($taxRate, 'name', '');
773 $rate = Arr::get($taxRate, 'rate');
774 if ($name !== '' && is_numeric($rate)) {
775 $rate = (string) $rate;
776
777 if (strpos($rate, '.') !== false) {
778 $rate = rtrim(rtrim($rate, '0'), '.');
779 }
780
781 return $name . ' (' . $rate . '%)';
782 }
783 if ($name !== '') {
784 return $name;
785 }
786 }
787
788 return __('Tax', 'fluent-cart');
789 }
790
791 public function getSubscriptions()
792 {
793 return '';
794 }
795
796 /**
797 * Returns formatted store VAT display string, e.g. "VAT: NL123456789B01".
798 * Returns empty string if no store VAT is configured for this order.
799 */
800 public function getStoreVatDisplay(): string
801 {
802 if (!$this->order) {
803 return '';
804 }
805
806 $orderTaxRate = $this->order->orderTaxRates ? $this->order->orderTaxRates->first() : null;
807
808 if (!$orderTaxRate) {
809 return '';
810 }
811
812 $storeVatNumber = Arr::get($orderTaxRate->meta ?? [], 'store_vat_number', '');
813
814 if (empty($storeVatNumber)) {
815 return '';
816 }
817
818 $taxCountry = Arr::get($orderTaxRate->meta ?? [], 'tax_country', '');
819 $label = \FluentCart\App\Modules\Tax\TaxModule::getCountryTaxTitle($taxCountry);
820
821 return esc_html($label) . ': ' . esc_html($storeVatNumber);
822 }
823
824 /**
825 * Returns formatted buyer VAT display string, e.g. "VAT/Tax ID: XX123456".
826 * Checks business_info first, then falls back to legacy VAT storage.
827 */
828 public function getBuyerVatDisplay(): string
829 {
830 if (!$this->order) {
831 return '';
832 }
833
834 $vatNumber = $this->order->getCustomerTaxNumber();
835 if (!empty($vatNumber)) {
836 $label = __('VAT/Tax ID', 'fluent-cart');
837 return esc_html($label) . ': ' . esc_html($vatNumber);
838 }
839
840 return '';
841 }
842
843 /**
844 * Returns buyer company name from billing address meta or VAT reverse charge data.
845 */
846 public function getBuyerCompanyName(): string
847 {
848 if (!$this->order) {
849 return '';
850 }
851
852 // Check billing address meta first
853 if ($this->order->billing_address) {
854 $companyName = Arr::get($this->order->billing_address->meta ?? [], 'other_data.company_name', '');
855 if (!empty($companyName)) {
856 return esc_html($companyName);
857 }
858 }
859
860 return esc_html($this->order->getCustomerTaxName());
861 }
862
863 public function getBuyerLegalRegistrationId(): string
864 {
865 if (!$this->order) {
866 return '';
867 }
868
869 if ($this->order->billing_address) {
870 $regId = Arr::get($this->order->billing_address->meta ?? [], 'other_data.legal_registration_id', '');
871 if (!empty($regId)) {
872 return esc_html($regId);
873 }
874 }
875
876 return esc_html(Arr::get($this->order->getBusinessInfo(), 'legal_registration_id', ''));
877 }
878
879 public function getBuyerReverseChargeDeclaration(): string
880 {
881 if (!$this->order) {
882 return '';
883 }
884
885 return esc_html(Arr::get($this->order->getBusinessInfo(), 'reverse_charge_declaration', ''));
886 }
887
888 // -------------------------------------------------------------------------
889 // Store business info — reads from snapshotted fct_order_meta[store_business_info]
890 // with live StoreSettings fallback for orders placed before this feature.
891 // -------------------------------------------------------------------------
892
893 private function getStoreBusinessField(string $field): string
894 {
895 if (!$this->order) {
896 return '';
897 }
898 $snapshot = $this->order->getMeta('store_business_info', false);
899 if ($snapshot !== false && isset($snapshot[$field])) {
900 return (string) $snapshot[$field];
901 }
902 return (string) $this->storeSettings->get($field, '');
903 }
904
905 public function getStoreCompanyName(): string
906 {
907 return esc_html($this->getStoreBusinessField('company_name'));
908 }
909
910 public function getStoreCompanyDisplay(): string
911 {
912 $val = $this->getStoreBusinessField('company_name');
913 if (empty($val)) {
914 return '';
915 }
916 /* translators: %1$s: store company name */
917 return esc_html(sprintf(__('Company: %1$s', 'fluent-cart'), $val));
918 }
919
920 public function getStoreLegalRegistrationId(): string
921 {
922 return esc_html($this->getStoreBusinessField('legal_registration_id'));
923 }
924
925 public function getStoreLegalRegistrationDisplay(): string
926 {
927 $val = $this->getStoreBusinessField('legal_registration_id');
928 if (empty($val)) {
929 return '';
930 }
931 /* translators: %1$s: store legal registration ID */
932 return esc_html(sprintf(__('Reg. ID: %1$s', 'fluent-cart'), $val));
933 }
934
935 public function getStoreSellerVatId(): string
936 {
937 return esc_html($this->getStoreBusinessField('seller_vat_id'));
938 }
939
940 public function getStoreSellerVatDisplay(): string
941 {
942 $val = $this->getStoreBusinessField('seller_vat_id');
943 if (empty($val)) {
944 return '';
945 }
946 /* translators: %1$s: store seller VAT ID */
947 return esc_html(sprintf(__('VAT ID: %1$s', 'fluent-cart'), $val));
948 }
949
950 public function getStoreSellerTaxId(): string
951 {
952 return esc_html($this->getStoreBusinessField('seller_tax_id'));
953 }
954
955 public function getStoreTaxDisplay(): string
956 {
957 $val = $this->getStoreBusinessField('seller_tax_id');
958 if (empty($val)) {
959 return '';
960 }
961 /* translators: %1$s: store seller tax ID */
962 return esc_html(sprintf(__('Tax ID: %1$s', 'fluent-cart'), $val));
963 }
964
965 }
966