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

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

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