PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.9.0 1.8.0 1.7.1 1.7.0 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 All 53 releases
fluent-cart / app / Services / ShortCodeParser / Parsers / OrderParser.php

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

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