PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
fluent-support / app / Services / Integrations / FluentCart / FluentCart.php

FluentCart.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Services/Integrations/FluentCart/FluentCart.php

381 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Services\Integrations\FluentCart;
4 use FluentCart\App\Helpers\Helper as CartHelper;
5 use FluentCart\App\Models\Customer as CartCustomer;
6 use FluentCart\App\Models\Order;
7 use FluentCart\App\Services\URL;
8 use FluentSupport\App\Services\Helper;
9 use FluentSupport\App\Services\ProfileInfoService;
10
11 class FluentCart
12 {
13 const WIDGET_KEY = 'fluent_cart_purchases';
14
15 public function boot()
16 {
17 add_filter('fluent_support/customer_extra_widgets', array($this, 'getPurchaseWidgets'), 120, 3);
18
19 if (
20 !apply_filters('fluent_support/disable_fc_menu', false) &&
21 Helper::getBusinessSettings('ticket_link_portal') === 'fluent_cart'
22 ) {
23 $this->renderCustomerPortalInFluentCartDashboard();
24 }
25 }
26
27 public function getPurchaseWidgets($widgets, $customer, $context = null)
28 {
29 if (!ProfileInfoService::wantsWidget($context, self::WIDGET_KEY)) {
30 return $widgets;
31 }
32
33 $cartCustomer = $this->findCustomer($customer);
34
35 if (!$cartCustomer) {
36 return $widgets;
37 }
38
39 // The totals come from the customer record; the list needs only the latest orders.
40 $orders = Order::query()->where('customer_id', $cartCustomer->id)
41 ->orderBy('id', 'DESC')
42 ->with(['order_items', 'subscriptions'])
43 ->limit(50)
44 ->get();
45
46 if ($orders->isEmpty()) {
47 return $widgets;
48 }
49
50 if (ProfileInfoService::isMcpWidgetRequest($context)) {
51 $widgets[self::WIDGET_KEY] = [
52 'title' => __('FluentCart Purchases', 'fluent-support'),
53 'mcp' => $this->getMcpData($orders)
54 ];
55
56 return $widgets;
57 }
58
59 $widgets[self::WIDGET_KEY] = array_merge(
60 ['title' => __('FluentCart Purchases', 'fluent-support')],
61 $this->getWidgetData($cartCustomer, $orders)
62 );
63
64 return $widgets;
65 }
66
67 /**
68 * Stats and recent purchases, drawn by the support site's sidebar as tiles and a list.
69 *
70 * @param CartCustomer $cartCustomer
71 * @param \FluentCart\Framework\Support\Collection $orders Newest first.
72 * @return array
73 */
74 private function getWidgetData($cartCustomer, $orders)
75 {
76 $stats = [
77 [
78 'label' => __('Lifetime Value', 'fluent-support'),
79 'value' => self::formatAmount($cartCustomer->ltv, $orders->first()->currency),
80 'url' => URL::getDashboardUrl('customers/' . $cartCustomer->id . '/view'),
81 ],
82 [
83 'label' => __('Paid Purchases', 'fluent-support'),
84 'value' => (string)(int)$cartCustomer->purchase_count,
85 ],
86 ];
87
88 if ($cartCustomer->first_purchase_date) {
89 $stats[] = [
90 'label' => __('First Paid Order', 'fluent-support'),
91 'value' => self::gmtDateIso($cartCustomer->first_purchase_date),
92 'type' => 'date',
93 ];
94 }
95
96 if ($cartCustomer->last_purchase_date) {
97 $stats[] = [
98 'label' => __('Last Paid Order', 'fluent-support'),
99 'value' => self::gmtDateIso($cartCustomer->last_purchase_date),
100 'type' => 'date',
101 ];
102 }
103
104 $orderIds = $orders->pluck('id')->toArray();
105
106 // Paid renewals repeat their subscription's first order every term: fold them into it.
107 // Unpaid ones (pending, scheduled, failed) stay as their own rows with a badge.
108 $renewals = [];
109 foreach ($orders as $order) {
110 $isPaid = in_array($order->payment_status, ['paid', 'partially_refunded'], true);
111
112 if ($order->type === 'renewal' && $isPaid && in_array($order->parent_id, $orderIds)) {
113 if (!isset($renewals[$order->parent_id])) {
114 // Orders are newest first, so the first renewal seen is the latest.
115 $renewals[$order->parent_id] = ['count' => 0, 'latest' => $order];
116 }
117 $renewals[$order->parent_id]['count']++;
118 }
119 }
120
121 $items = [];
122 foreach ($orders as $order) {
123 if ($order->type === 'renewal' && isset($renewals[$order->parent_id]) && in_array($order->payment_status, ['paid', 'partially_refunded'], true)) {
124 continue;
125 }
126
127 $subscription = $order->subscriptions->first();
128 $renewal = isset($renewals[$order->id]) ? $renewals[$order->id] : null;
129 $lastPaidAt = $renewal ? $renewal['latest']->created_at : $order->created_at;
130
131 if ($subscription) {
132 $badge = [
133 'text' => (string)$subscription->status,
134 'type' => $subscription->status === 'active' ? 'success' : 'warning',
135 ];
136 } elseif ($order->total_refund) {
137 $badge = ['text' => __('Refunded', 'fluent-support'), 'type' => 'danger'];
138 } elseif ($order->payment_status !== 'paid') {
139 $badge = ['text' => (string)$order->payment_status, 'type' => 'warning'];
140 } else {
141 $badge = null;
142 }
143
144 $details = [];
145 if ($renewal) {
146 /* translators: %d: number of renewal payments */
147 $details[] = sprintf(_n('%d renewal', '%d renewals', $renewal['count'], 'fluent-support'), $renewal['count']);
148 }
149 if ($subscription && $subscription->status === 'active' && $subscription->next_billing_date) {
150 /* translators: %s: date */
151 $details[] = sprintf(__('Next renewal %s', 'fluent-support'), wp_date(get_option('date_format'), strtotime($subscription->next_billing_date . ' UTC')));
152 } elseif ($subscription && $subscription->expire_at && strtotime($subscription->expire_at . ' UTC') < time()) {
153 /* translators: %s: date */
154 $details[] = sprintf(__('Expired %s', 'fluent-support'), wp_date(get_option('date_format'), strtotime($subscription->expire_at . ' UTC')));
155 }
156
157 foreach ($order->order_items as $orderItem) {
158 if ($orderItem->payment_type == 'signup_fee') {
159 continue;
160 }
161
162 $variation = $orderItem->title !== $orderItem->post_title ? (string)$orderItem->title : '';
163
164 $items[] = [
165 'title' => (string)$orderItem->post_title,
166 'subtitle' => $variation
167 ? sprintf(
168 /* translators: 1: product variation, 2: order number */
169 __('%1$s · #%2$s', 'fluent-support'),
170 $variation,
171 $order->invoice_no
172 )
173 : '#' . $order->invoice_no,
174 'detail' => implode(' · ', $details),
175 'meta' => self::gmtDateIso($lastPaidAt),
176 'meta_type' => 'date',
177 'url' => URL::getDashboardUrl('orders/' . $order->id . '/view'),
178 'badge' => $badge,
179 ];
180 }
181 }
182
183 // Latest payment first, so a subscription that just renewed sits at the top.
184 usort($items, function ($a, $b) {
185 return strcmp((string)$b['meta'], (string)$a['meta']);
186 });
187
188 return [
189 'stats' => $stats,
190 'sections' => [
191 [
192 'label' => __('Recent Purchases', 'fluent-support'),
193 'items' => array_slice($items, 0, 10),
194 ],
195 ],
196 ];
197 }
198
199 /**
200 * Cents to a plain-text amount with the currency sign.
201 *
202 * @param int $cents
203 * @param string $currency
204 * @return string
205 */
206 private static function formatAmount($cents, $currency)
207 {
208 return html_entity_decode(wp_strip_all_tags((string)CartHelper::toDecimal((int)$cents, true, $currency ?: null)), ENT_QUOTES, 'UTF-8');
209 }
210
211 private function findCustomer($customer)
212 {
213 if (!empty($customer->email)) {
214 $cartCustomer = CartCustomer::query()->where('email', $customer->email)->first();
215 if ($cartCustomer) {
216 return $cartCustomer;
217 }
218 }
219
220 if (!empty($customer->user_id)) {
221 return CartCustomer::query()->where('user_id', $customer->user_id)->first();
222 }
223
224 return null;
225 }
226
227 /**
228 * Plain purchase data for MCP agents. Amounts are in the currency's major unit;
229 * dates are ISO 8601 UTC (FluentCart stores GMT).
230 *
231 * @param \FluentCart\Framework\Support\Collection $orders
232 * @return array
233 */
234 private function getMcpData($orders)
235 {
236 $mcpOrders = [];
237 $activeSubscriptions = 0;
238
239 // The server keeps 50 list items; send the latest 50.
240 foreach ($orders->take(50) as $order) {
241 $items = [];
242 foreach ($order->order_items as $orderItem) {
243 if ($orderItem->payment_type == 'signup_fee') {
244 continue;
245 }
246
247 $items[] = [
248 'product_id' => (int)$orderItem->post_id,
249 'name' => (string)$orderItem->post_title,
250 'variation' => (string)$orderItem->title,
251 'qty' => (int)$orderItem->quantity,
252 'total' => $this->toAmount($orderItem->line_total),
253 ];
254 }
255
256 $subscriptions = [];
257 foreach ($order->subscriptions as $subscription) {
258 if ($subscription->status === 'active') {
259 $activeSubscriptions++;
260 }
261
262 $subscriptions[] = [
263 'id' => (int)$subscription->id,
264 'name' => (string)$subscription->item_name,
265 'status' => (string)$subscription->status,
266 'billing_interval' => (string)$subscription->billing_interval,
267 'next_billing_date' => self::gmtDateIso($subscription->next_billing_date),
268 'expire_at' => self::gmtDateIso($subscription->expire_at),
269 ];
270 }
271
272 $mcpOrders[] = [
273 'id' => (int)$order->id,
274 'number' => (string)$order->invoice_no,
275 'status' => (string)$order->status,
276 'payment_status' => (string)$order->payment_status,
277 'total' => $this->toAmount($order->total_amount),
278 'paid' => $this->toAmount($order->total_paid),
279 'refunded' => $this->toAmount($order->total_refund),
280 'currency' => (string)$order->currency,
281 'date' => self::gmtDateIso($order->created_at),
282 'url' => URL::getDashboardUrl('orders/' . $order->id . '/view'),
283 'items' => $items,
284 'subscriptions' => $subscriptions,
285 ];
286 }
287
288 $data = [
289 'total_orders' => Order::query()->where('customer_id', $orders->first()->customer_id)->count(),
290 'active_subscriptions' => $activeSubscriptions,
291 'orders' => $mcpOrders,
292 ];
293
294 $licenses = $this->getMcpLicenses($orders->first()->customer_id);
295 if ($licenses) {
296 $data['licenses'] = $licenses;
297 }
298
299 return $data;
300 }
301
302 /**
303 * FluentCart Pro licenses, without the keys.
304 *
305 * @param int $customerId
306 * @return array
307 */
308 private function getMcpLicenses($customerId)
309 {
310 if (!class_exists('\FluentCartPro\App\Modules\Licensing\Models\License')) {
311 return [];
312 }
313
314 $licenses = \FluentCartPro\App\Modules\Licensing\Models\License::query()
315 ->where('customer_id', $customerId)
316 ->with(['product', 'variation'])
317 ->orderBy('id', 'DESC')
318 ->limit(50)
319 ->get();
320
321 $formatted = [];
322 foreach ($licenses as $license) {
323 $formatted[] = [
324 'id' => (int)$license->id,
325 'product' => $license->product ? (string)$license->product->post_title : '',
326 'variation' => $license->variation ? (string)$license->variation->variation_title : '',
327 'status' => (string)$license->status,
328 'is_expired' => $license->isExpired(),
329 'expiration_date' => self::gmtDateIso($license->expiration_date),
330 'activation_count' => (int)$license->activation_count,
331 'limit' => $license->limit ? (int)$license->limit : 'unlimited',
332 'order_id' => (int)$license->order_id,
333 ];
334 }
335
336 return $formatted;
337 }
338
339 // FluentCart stores amounts in cents, zero-decimal currencies too (see CartHelper::toDecimal()).
340 private function toAmount($cents)
341 {
342 return round((float)$cents / 100, CartHelper::shopConfig('is_zero_decimal') ? 0 : 2);
343 }
344
345 /**
346 * A GMT MySQL datetime as ISO 8601 UTC, or null.
347 *
348 * @param string|\DateTimeInterface|null $date
349 * @return string|null
350 */
351 private static function gmtDateIso($date)
352 {
353 if ($date instanceof \DateTimeInterface) {
354 $date = $date->format('Y-m-d H:i:s');
355 }
356
357 if (!$date || strpos((string)$date, '0000-00-00') === 0) {
358 return null;
359 }
360
361 $timestamp = strtotime($date . ' UTC');
362
363 return $timestamp ? gmdate('c', $timestamp) : null;
364 }
365
366 private function renderCustomerPortalInFluentCartDashboard()
367 {
368 if (!function_exists('fluent_cart_api') || Helper::getBusinessSettings('ticket_link_portal') !== 'fluent_cart' ) {
369 return;
370 }
371
372 fluent_cart_api()->addCustomerDashboardEndpoint('fluent-support', [
373 'title' => __('Support', 'fluent-support'),
374 'render_callback' => function () {
375 echo do_shortcode('[fluent_support_portal]');
376 },
377 'priority' => 'high'
378 ]);
379 }
380 }
381