PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
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 1.3.22 1.3.21 1.3.20 All 50 releases
fluent-cart / app / Services / ShortCodeParser / Parsers / OrderParser.php

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

996 lines 37.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 'review_url' => 'getReviewUrl',
60 'is_digital' => 'getIsDigital',
61 'store_vat_display' => 'getStoreVatDisplay',
62 'store_company_name' => 'getStoreCompanyName',
63 'store_company_display' => 'getStoreCompanyDisplay',
64 'store_legal_registration_id' => 'getStoreLegalRegistrationId',
65 'store_legal_registration_display' => 'getStoreLegalRegistrationDisplay',
66 'store_seller_vat_id' => 'getStoreSellerVatId',
67 'store_seller_vat_display' => 'getStoreSellerVatDisplay',
68 'store_seller_tax_id' => 'getStoreSellerTaxId',
69 'store_tax_display' => 'getStoreTaxDisplay',
70 'buyer_vat_display' => 'getBuyerVatDisplay',
71 'buyer_company_name' => 'getBuyerCompanyName',
72 'buyer_legal_registration_id' => 'getBuyerLegalRegistrationId',
73 'buyer_reverse_charge_declaration' => 'getBuyerReverseChargeDeclaration',
74 'tax_breakdown' => 'getTaxBreakdown',
75 'fee_lines' => 'getFeeLines',
76 ];
77
78 protected array $attributeMap = [
79 'id' => 'order.id',
80 'status' => 'order.status',
81 'created_at' => 'order.created_at',
82 'updated_at' => 'order.updated_at',
83 ];
84
85 protected array $centColumns = [
86 'total_amount',
87 'subtotal',
88 'discount_tax',
89 'manual_discount_total',
90 'coupon_discount_total',
91 'shipping_tax',
92 'shipping_total',
93 'fee_total',
94 'tax_total',
95 'total_paid',
96 'total_refund'
97 ];
98
99 public function parse($accessor = '', $code = '', $transformer = null): ?string
100 {
101
102 if ($this->shouldParseAddress($accessor)) {
103 return $this->parseAddressFields($accessor);
104 }
105
106 if (in_array($accessor, ['updated_at', 'created_at'])) {
107 $date = Arr::get($this->data, $this->attributeMap[$accessor]);
108 $timestamp = DateTime::anyTimeToGmt($date)->getTimestamp();
109
110 // Through DateFormatter so the store's format and timezone settings
111 // reach this too; orderTz is only consulted on the Browser source.
112 $date = DateFormatter::format($timestamp, false, $this->orderTz);
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 /**
366 * The public "review your order" page for this order.
367 *
368 * Carries the order uuid, which is what lets the link work for a
369 * guest-checkout buyer with no account to sign into — so, unlike
370 * getCustomerOrderLink(), it is never wrapped in wp_login_url().
371 *
372 * Drop {{order.review_url}} into any order email template to invite a
373 * review. Best paired with a notification that fires after fulfilment;
374 * the page only resolves once the order has actually completed.
375 */
376 public function getReviewUrl($accessor = null, $code = null)
377 {
378 $orderHash = (string) Arr::get($this->order, 'uuid', '');
379
380 if ($orderHash !== '') {
381 return Order::reviewUrlForHash($orderHash);
382 }
383
384 $orderId = Arr::get($this->order, 'id');
385
386 if (empty($orderId)) {
387 return $code;
388 }
389
390 $order = Order::query()->find($orderId);
391
392 return $order instanceof Order ? $order->getOrderReviewUrl() : $code;
393 }
394
395 public function getPaymentLink($accessor = null, $code = null)
396 {
397 if (empty($this->order)) {
398 return $code;
399 }
400
401 return PaymentHelper::getCustomPaymentLink(Arr::get($this->order, 'uuid'));
402 }
403
404 public function getAdminOrderLink($accessor, $code = null)
405 {
406 if (empty($this->order)) {
407 return $code;
408 }
409 return admin_url('admin.php?page=fluent-cart#/orders/' . Arr::get($this->order, 'id') . '/view');
410 }
411
412 public function getAdminOrderAnchorLink($accessor, $code = null, $conditions = [])
413 {
414 $defaultValue = Arr::get($conditions, 'default_value');
415 if (empty($this->order)) {
416 return $code;
417 }
418
419 $url = admin_url('admin.php?page=fluent-cart#/orders/' . Arr::get($this->order, 'id') . '/view');
420
421 if (!empty($defaultValue)) {
422 return "<a style='color: #017EF3; text-decoration: none;' href='" . $url . "'>" . $defaultValue . "</a>";
423 }
424
425 return $url;
426 }
427
428 public function getCustomerOrderLink($accessor, $code = null)
429 {
430 if (empty($this->order)) {
431 return $code;
432 }
433
434 $customerProfilePage = $this->storeSettings->getCustomerProfilePage();
435 $orderLink = $customerProfilePage . '#/order/' . Arr::get($this->order, 'uuid');
436
437 return is_user_logged_in() ? $orderLink : wp_login_url($orderLink);
438 }
439
440 public function getTotalAmount()
441 {
442 $total = ($this->order['total_amount'] / 100);
443 $currency_sign = $this->order['currency'];
444 return $total . $currency_sign;
445 }
446
447 public function getDownloads()
448 {
449 $order = $this->order;
450
451 $downloads = $order->getDownloads();
452 if ($downloads) {
453 return (string)\FluentCart\App\App::make('view')->make('emails.parts.downloads', [
454 'order' => $order,
455 'heading' => '',
456 'downloadItems' => $downloads
457 ]);
458 }
459
460 return '';
461
462 }
463
464 public function getLicenses()
465 {
466 $order = $this->order;
467 $licenses = $order->getLicenses();
468 if ($licenses && $licenses->count() > 0) {
469 return (string)\FluentCart\App\App::make('view')->make('emails.parts.licenses', [
470 'licenses' => $licenses,
471 'heading' => __('Licenses', 'fluent-cart'),
472 'show_notice' => false
473 ]);
474 }
475
476 return '';
477 }
478
479 public function getLicenseCount(): string
480 {
481 return (string)$this->licenses->count();
482 }
483
484 public function getIsDigital(): string
485 {
486 if (!$this->order) {
487 return 'no';
488 }
489
490 $fulfillmentType = Arr::get($this->order, 'fulfillment_type');
491
492 return $fulfillmentType === 'digital' ? 'yes' : 'no';
493 }
494
495 public function getItemCount(): string
496 {
497 $orderItems = $this->order ? $this->order->order_items : null;
498
499 if ($orderItems) {
500 return (string)$orderItems->count();
501 }
502
503 return '0';
504 }
505
506 public function getPaymentMethodTitle(): string
507 {
508 if (!$this->order) {
509 return '';
510 }
511
512 $title = (string) Arr::get($this->order, 'payment_method_title', '');
513 if ($title !== '') {
514 return $title;
515 }
516
517 $slug = (string) Arr::get($this->order, 'payment_method', '');
518 if ($slug === '') {
519 return '';
520 }
521
522 if (class_exists(GatewayManager::class)) {
523 $gateway = GatewayManager::getInstance($slug);
524 if ($gateway) {
525 $gatewayTitle = (string) $gateway->getMeta('title');
526 if ($gatewayTitle !== '') {
527 return $gatewayTitle;
528 }
529 }
530 }
531
532 return ucwords(str_replace(['_', '-'], ' ', $slug));
533 }
534
535 public function getFeeLines(): string
536 {
537 if (!$this->order) {
538 return '';
539 }
540
541 $currency = (string) Arr::get($this->order, 'currency', '');
542 $feeItems = $this->order->feeItems()->get();
543
544 if ($feeItems->isEmpty()) {
545 return '';
546 }
547
548 $rowStyle = 'padding:3px 0;font-size:11px;font-weight:600;color:#525866;';
549 $valueStyle = 'text-align:right;';
550 $rows = '';
551
552 foreach ($feeItems as $feeItem) {
553 $rows .= '<tr>'
554 . '<td style="' . $rowStyle . '">' . esc_html((string) $feeItem->title) . '</td>'
555 . '<td style="' . $rowStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml((int) $feeItem->subtotal, $currency) . '</td>'
556 . '</tr>';
557 }
558
559 return $rows;
560 }
561
562 public function getTaxBreakdown(): string
563 {
564 if (!$this->order) {
565 return '';
566 }
567
568 $currency = (string) Arr::get($this->order, 'currency', '');
569 $summary = TaxSummaryHelper::computeTaxSummary($this->order);
570
571 if (!$summary['shouldRender']) {
572 return '';
573 }
574
575 $headingStyle = 'padding:8px 0 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:#94a3b8;';
576 $mutedStyle = 'padding:3px 0;font-size:11px;color:#94a3b8;';
577 $normalStyle = 'padding:3px 0;font-size:11px;font-weight:600;color:#525866;';
578 $totalStyle = 'padding:6px 0 3px;font-size:11px;font-weight:700;color:#0E121B;border-top:1px solid #e2e8f0;';
579 $valueStyle = 'text-align:right;';
580
581 $rows = '';
582
583 $foldedRateLines = Arr::get($summary, 'foldedRateLines', []);
584 $isRcOrder = !empty($summary['isReverseCharge']);
585 $rcReversedTotal = isset($summary['reversedTaxTotal']) ? (int) $summary['reversedTaxTotal'] : 0;
586 $rcReversedValue = $rcReversedTotal > 0
587 ? esc_html(Helper::toDecimal($rcReversedTotal))
588 : esc_html__('Charge reversed', 'fluent-cart');
589 $includedInPrices = (int) Arr::get($summary, 'includedInPrices', 0);
590 $opFeeRows = Arr::get($summary, 'feeTaxLineRows', []);
591 $taxRateLines = Arr::get($summary, 'taxRateLines', []);
592 $productTaxRowCount = !empty($taxRateLines)
593 ? count($taxRateLines)
594 : (int) ($summary['inclusiveTax'] > 0) + (int) ($summary['exclusiveTax'] > 0);
595 $rowCount = $productTaxRowCount + count($opFeeRows) + (int) ($summary['shippingTax'] > 0);
596 $shippingTaxLines = Arr::get($summary, 'shippingTaxLines', []);
597 $shouldShowBreakdown = !empty($taxRateLines)
598 || !empty($shippingTaxLines)
599 || $rowCount >= 2
600 || ($rowCount === 1 && !($summary['payableTax'] > 0 || $summary['inclusiveTax'] > 0 || (int) Arr::get($summary, 'inclusiveFeeTax', 0) > 0));
601
602 $isSimplifiedMode = ($summary['displayMode'] ?? '') === 'simplified';
603
604 if ($isSimplifiedMode && !empty($summary['simpleLine'])) {
605 $rows .= '<tr>'
606 . '<td style="' . $normalStyle . '">' . esc_html((string) $summary['simpleLine']['label']) . '</td>'
607 . '<td style="' . $normalStyle . $valueStyle . '">' . esc_html((string) $summary['simpleLine']['value']) . '</td>'
608 . '</tr>';
609
610 // Simplified mode: single tax line only, skip the detailed breakdown below.
611 return $this->wrapTaxBreakdownBox($rows);
612 }
613
614 if (!empty($foldedRateLines)) {
615 $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;';
616 $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;';
617 $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;';
618
619 $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;">'
620 . esc_html__('Tax breakdown by rate', 'fluent-cart')
621 . '</td></tr>';
622
623 $nestedTable = '<table width="100%" cellpadding="0" cellspacing="0" style="border:none;border-collapse:collapse;table-layout:fixed;">';
624 $nestedTable .= '<tr>'
625 . '<td style="' . $colHeadStyle . '">' . esc_html__('Rate', 'fluent-cart') . '</td>'
626 . '<td style="' . $colHeadBaseStyle . '">' . esc_html__('Taxable base', 'fluent-cart') . '</td>'
627 . '<td style="' . $colHeadTaxStyle . '">' . esc_html__('Tax', 'fluent-cart') . '</td>'
628 . '</tr>';
629
630 foreach ($foldedRateLines as $foldedRow) {
631 $foldedStyle = !empty($foldedRow['inclusive']) ? $mutedStyle : $normalStyle;
632 $foldedStyleR = $foldedStyle . $valueStyle;
633 $nestedTable .= '<tr>'
634 . '<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>'
635 . '<td style="width:24%;' . $foldedStyleR . 'padding-right:8px;white-space:nowrap;vertical-align:top;">' . CurrencySettings::getPriceHtml((int) $foldedRow['base'], $currency) . '</td>'
636 . '<td style="width:18%;' . $foldedStyleR . 'white-space:nowrap;vertical-align:top;">' . CurrencySettings::getPriceHtml((int) $foldedRow['tax'], $currency) . '</td>'
637 . '</tr>';
638 }
639 $nestedTable .= '</table>';
640
641 $rows .= '<tr><td colspan="2" style="padding:2px 0 0 0;">' . $nestedTable . '</td></tr>';
642
643 if ($isRcOrder) {
644 $rcReversedTotalValue = $rcReversedTotal > 0
645 ? CurrencySettings::getPriceHtml($rcReversedTotal, $currency)
646 : esc_html__('Charge reversed', 'fluent-cart');
647 $rows .= '<tr>'
648 . '<td style="' . $totalStyle . '">' . esc_html__('VAT reversed', 'fluent-cart') . '</td>'
649 . '<td style="' . $totalStyle . $valueStyle . '">' . $rcReversedTotalValue . '</td>'
650 . '</tr>';
651 return $this->wrapTaxBreakdownBox($rows);
652 }
653
654 $rows .= '<tr>'
655 . '<td style="' . $totalStyle . '">' . esc_html__('Total tax', 'fluent-cart') . '</td>'
656 . '<td style="' . $totalStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml((int) $summary['totalOrderTax'], $currency) . '</td>'
657 . '</tr>';
658
659 if ($includedInPrices > 0) {
660 $rows .= '<tr>'
661 . '<td style="' . $mutedStyle . '">' . esc_html__('of which included in prices', 'fluent-cart') . '</td>'
662 . '<td style="' . $mutedStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($includedInPrices, $currency) . '</td>'
663 . '</tr>';
664 }
665
666 if ($summary['payableTax'] > 0 && $includedInPrices > 0) {
667 $payableStyle = $totalStyle;
668 $rows .= '<tr>'
669 . '<td style="' . $payableStyle . '">' . esc_html__('Payable now (added)', 'fluent-cart') . '</td>'
670 . '<td style="' . $payableStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml((int) $summary['payableTax'], $currency) . '</td>'
671 . '</tr>';
672 }
673
674 return $this->wrapTaxBreakdownBox($rows);
675 }
676
677 if ($isRcOrder) {
678 $rows .= '<tr><td colspan="2" style="' . $headingStyle . '">'
679 . esc_html__('TAX SUMMARY', 'fluent-cart')
680 . '</td></tr>';
681 $rows .= '<tr>'
682 . '<td style="' . $totalStyle . '">' . esc_html__('Tax reversed', 'fluent-cart') . '</td>'
683 . '<td style="' . $totalStyle . $valueStyle . '">' . $rcReversedValue . '</td>'
684 . '</tr>';
685 return $this->wrapTaxBreakdownBox($rows);
686 }
687
688 $rows .= '<tr><td colspan="2" style="' . $headingStyle . '">'
689 . esc_html__('TAX SUMMARY', 'fluent-cart')
690 . '</td></tr>';
691
692 if (!empty($taxRateLines) && $shouldShowBreakdown) {
693 foreach ($taxRateLines as $taxLine) {
694 $taxLineStyle = !empty($taxLine['inclusive']) ? $mutedStyle : $normalStyle;
695 $rows .= '<tr>'
696 . '<td style="' . $taxLineStyle . '">' . esc_html($taxLine['label']) . '</td>'
697 . '<td style="' . $taxLineStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($taxLine['order_tax'], $currency) . '</td>'
698 . '</tr>';
699 }
700 }
701
702 if (empty($taxRateLines) && $summary['inclusiveTax'] > 0 && $shouldShowBreakdown) {
703 $rows .= '<tr>'
704 . '<td style="' . $mutedStyle . '">' . esc_html__('Included in item prices', 'fluent-cart') . '</td>'
705 . '<td style="' . $mutedStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['inclusiveTax'], $currency) . '</td>'
706 . '</tr>';
707 }
708
709 if (empty($taxRateLines) && $summary['exclusiveTax'] > 0 && $shouldShowBreakdown) {
710 $rows .= '<tr>'
711 . '<td style="' . $normalStyle . '">' . esc_html__('Added on products', 'fluent-cart') . '</td>'
712 . '<td style="' . $normalStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['exclusiveTax'], $currency) . '</td>'
713 . '</tr>';
714 }
715
716 if ($shouldShowBreakdown) {
717 foreach ($opFeeRows as $feeRow) {
718 $feeLineStyle = $feeRow['inclusive'] ? $mutedStyle : $normalStyle;
719 $rows .= '<tr>'
720 . '<td style="' . $feeLineStyle . '">' . esc_html($feeRow['display_label']) . '</td>'
721 . '<td style="' . $feeLineStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($feeRow['tax_amount'], $currency) . '</td>'
722 . '</tr>';
723 }
724 }
725
726 if ($summary['shippingTax'] > 0 && $shouldShowBreakdown) {
727 $isShippingInclusive = TaxSummaryHelper::isShippingTaxInclusive($this->order);
728 $shippingRowStyle = $isShippingInclusive ? $mutedStyle : $normalStyle;
729 if (!empty($shippingTaxLines)) {
730 foreach ($shippingTaxLines as $shippingTaxLine) {
731 $rows .= '<tr>'
732 . '<td style="' . $shippingRowStyle . '">' . esc_html($shippingTaxLine['label']) . '</td>'
733 . '<td style="' . $shippingRowStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($shippingTaxLine['shipping_tax'], $currency) . '</td>'
734 . '</tr>';
735 }
736 } else {
737 $shippingRowLabel = $isShippingInclusive
738 ? esc_html__('Included in shipping prices', 'fluent-cart')
739 : esc_html__('Added on shipping', 'fluent-cart');
740 $rows .= '<tr>'
741 . '<td style="' . $shippingRowStyle . '">' . $shippingRowLabel . '</td>'
742 . '<td style="' . $shippingRowStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['shippingTax'], $currency) . '</td>'
743 . '</tr>';
744 }
745 }
746
747 if ($summary['payableTax'] > 0) {
748 $rows .= '<tr>'
749 . '<td style="' . $totalStyle . '">' . esc_html__('Total payable tax', 'fluent-cart') . '</td>'
750 . '<td style="' . $totalStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['payableTax'], $currency) . '</td>'
751 . '</tr>';
752 }
753
754 if ($summary['inclusiveTax'] > 0 || !empty($summary['inclusiveFeeTax'])) {
755 $rows .= '<tr>'
756 . '<td style="' . $mutedStyle . '">' . esc_html__('Total tax in this order', 'fluent-cart') . '</td>'
757 . '<td style="' . $mutedStyle . $valueStyle . '">' . CurrencySettings::getPriceHtml($summary['totalOrderTax'], $currency) . '</td>'
758 . '</tr>';
759 }
760
761 return $this->wrapTaxBreakdownBox($rows);
762 }
763
764 /**
765 * Wrap the accumulated tax-breakdown rows in their own grey rounded box,
766 * right-aligned to a fixed 400px column.
767 *
768 * The tax breakdown is the only thing that lives inside the grey box; the
769 * surrounding Subtotal/Discount/Shipping/Fees rows (above) and Refund/Total/
770 * Payment rows (below) are rendered as plain rows by the summary table and
771 * stay outside this wrapper. A table-based 400px right-aligned wrapper is used
772 * (not a div) so PDF renderers (mPDF/Dompdf) and email clients honour the
773 * width/alignment, matching the boxed treatment on the thank-you/customer
774 * surfaces. Consumed by the PDF receipt templates via {{order.tax_breakdown}}.
775 *
776 * @param string $rows Inner <tr> rows for the tax breakdown.
777 * @return string A single <tr><td colspan="2"> cell carrying the grey box.
778 */
779 private function wrapTaxBreakdownBox(string $rows): string
780 {
781 if ($rows === '') {
782 return '';
783 }
784
785 $boxStyle = 'background-color:rgb(249,250,251);padding:16px;border-radius:8px;';
786
787 $nestedTable = '<table width="100%" cellpadding="0" cellspacing="0" style="width:100%;border:none;border-collapse:collapse;">'
788 . $rows
789 . '</table>';
790
791 return '<tr><td colspan="2" style="padding:8px 0 0 0;">'
792 . '<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;">'
793 . '<tr><td style="' . $boxStyle . '">' . $nestedTable . '</td></tr>'
794 . '</table>'
795 . '</td></tr>';
796 }
797
798 private function resolveTaxRateLabel($orderTaxRate): string
799 {
800 $taxRate = $orderTaxRate->tax_rate ?? null;
801 if ($taxRate) {
802 $name = (string) Arr::get($taxRate, 'name', '');
803 $rate = Arr::get($taxRate, 'rate');
804 if ($name !== '' && is_numeric($rate)) {
805 $rate = (string) $rate;
806
807 if (strpos($rate, '.') !== false) {
808 $rate = rtrim(rtrim($rate, '0'), '.');
809 }
810
811 return $name . ' (' . $rate . '%)';
812 }
813 if ($name !== '') {
814 return $name;
815 }
816 }
817
818 return __('Tax', 'fluent-cart');
819 }
820
821 public function getSubscriptions()
822 {
823 return '';
824 }
825
826 /**
827 * Returns formatted store VAT display string, e.g. "VAT: NL123456789B01".
828 * Returns empty string if no store VAT is configured for this order.
829 */
830 public function getStoreVatDisplay(): string
831 {
832 if (!$this->order) {
833 return '';
834 }
835
836 $orderTaxRate = $this->order->orderTaxRates ? $this->order->orderTaxRates->first() : null;
837
838 if (!$orderTaxRate) {
839 return '';
840 }
841
842 $storeVatNumber = Arr::get($orderTaxRate->meta ?? [], 'store_vat_number', '');
843
844 if (empty($storeVatNumber)) {
845 return '';
846 }
847
848 $taxCountry = Arr::get($orderTaxRate->meta ?? [], 'tax_country', '');
849 $label = \FluentCart\App\Modules\Tax\TaxModule::getCountryTaxTitle($taxCountry);
850
851 return esc_html($label) . ': ' . esc_html($storeVatNumber);
852 }
853
854 /**
855 * Returns formatted buyer VAT display string, e.g. "VAT/Tax ID: XX123456".
856 * Checks business_info first, then falls back to legacy VAT storage.
857 */
858 public function getBuyerVatDisplay(): string
859 {
860 if (!$this->order) {
861 return '';
862 }
863
864 $vatNumber = $this->order->getCustomerTaxNumber();
865 if (!empty($vatNumber)) {
866 $label = __('VAT/Tax ID', 'fluent-cart');
867 return esc_html($label) . ': ' . esc_html($vatNumber);
868 }
869
870 return '';
871 }
872
873 /**
874 * Returns buyer company name from billing address meta or VAT reverse charge data.
875 */
876 public function getBuyerCompanyName(): string
877 {
878 if (!$this->order) {
879 return '';
880 }
881
882 // Check billing address meta first
883 if ($this->order->billing_address) {
884 $companyName = Arr::get($this->order->billing_address->meta ?? [], 'other_data.company_name', '');
885 if (!empty($companyName)) {
886 return esc_html($companyName);
887 }
888 }
889
890 return esc_html($this->order->getCustomerTaxName());
891 }
892
893 public function getBuyerLegalRegistrationId(): string
894 {
895 if (!$this->order) {
896 return '';
897 }
898
899 if ($this->order->billing_address) {
900 $regId = Arr::get($this->order->billing_address->meta ?? [], 'other_data.legal_registration_id', '');
901 if (!empty($regId)) {
902 return esc_html($regId);
903 }
904 }
905
906 return esc_html(Arr::get($this->order->getBusinessInfo(), 'legal_registration_id', ''));
907 }
908
909 public function getBuyerReverseChargeDeclaration(): string
910 {
911 if (!$this->order) {
912 return '';
913 }
914
915 return esc_html(Arr::get($this->order->getBusinessInfo(), 'reverse_charge_declaration', ''));
916 }
917
918 // -------------------------------------------------------------------------
919 // Store business info — reads from snapshotted fct_order_meta[store_business_info]
920 // with live StoreSettings fallback for orders placed before this feature.
921 // -------------------------------------------------------------------------
922
923 private function getStoreBusinessField(string $field): string
924 {
925 if (!$this->order) {
926 return '';
927 }
928 $snapshot = $this->order->getMeta('store_business_info', false);
929 if ($snapshot !== false && isset($snapshot[$field])) {
930 return (string) $snapshot[$field];
931 }
932 return (string) $this->storeSettings->get($field, '');
933 }
934
935 public function getStoreCompanyName(): string
936 {
937 return esc_html($this->getStoreBusinessField('company_name'));
938 }
939
940 public function getStoreCompanyDisplay(): string
941 {
942 $val = $this->getStoreBusinessField('company_name');
943 if (empty($val)) {
944 return '';
945 }
946 /* translators: %1$s: store company name */
947 return esc_html(sprintf(__('Company: %1$s', 'fluent-cart'), $val));
948 }
949
950 public function getStoreLegalRegistrationId(): string
951 {
952 return esc_html($this->getStoreBusinessField('legal_registration_id'));
953 }
954
955 public function getStoreLegalRegistrationDisplay(): string
956 {
957 $val = $this->getStoreBusinessField('legal_registration_id');
958 if (empty($val)) {
959 return '';
960 }
961 /* translators: %1$s: store legal registration ID */
962 return esc_html(sprintf(__('Reg. ID: %1$s', 'fluent-cart'), $val));
963 }
964
965 public function getStoreSellerVatId(): string
966 {
967 return esc_html($this->getStoreBusinessField('seller_vat_id'));
968 }
969
970 public function getStoreSellerVatDisplay(): string
971 {
972 $val = $this->getStoreBusinessField('seller_vat_id');
973 if (empty($val)) {
974 return '';
975 }
976 /* translators: %1$s: store seller VAT ID */
977 return esc_html(sprintf(__('VAT ID: %1$s', 'fluent-cart'), $val));
978 }
979
980 public function getStoreSellerTaxId(): string
981 {
982 return esc_html($this->getStoreBusinessField('seller_tax_id'));
983 }
984
985 public function getStoreTaxDisplay(): string
986 {
987 $val = $this->getStoreBusinessField('seller_tax_id');
988 if (empty($val)) {
989 return '';
990 }
991 /* translators: %1$s: store seller tax ID */
992 return esc_html(sprintf(__('Tax ID: %1$s', 'fluent-cart'), $val));
993 }
994
995 }
996