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
← All changes | app/Services/Integrations/FluentCart/FluentCart.php +284 -153 2.3.2 → 2.4.5 View file →
@@ -1,18 +1,21 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Services\Integrations\FluentCart;
4 +use FluentCart\App\Helpers\Helper as CartHelper;
5 +use FluentCart\App\Models\Customer as CartCustomer;
4 6 use FluentCart\App\Models\Order;
7 +use FluentCart\App\Services\URL;
5 8 use FluentSupport\App\Services\Helper;
6 -use FluentSupport\Framework\Support\Arr;
7 -use FluentSupport\App\Models\Customer;
9 +use FluentSupport\App\Services\ProfileInfoService;
8 10
9 11 class FluentCart
10 12 {
13 + const WIDGET_KEY = 'fluent_cart_purchases';
14 +
11 15 public function boot()
12 16 {
13 -// add_filter('fluent_support/customer_extra_widgets', array($this, 'getFluentCartPurchaseWidgets'), 120, 2);
14 - // add_filter('fluent_support/customer_extra_widgets', array($this, 'getFluentCartProLicenseWidget'), 125, 2);
17 + add_filter('fluent_support/customer_extra_widgets', array($this, 'getPurchaseWidgets'), 120, 3);
15 18
16 19 if (
17 20 !apply_filters('fluent_support/disable_fc_menu', false) &&
18 21 Helper::getBusinessSettings('ticket_link_portal') === 'fluent_cart'
@@ -20,217 +23,345 @@
20 23 $this->renderCustomerPortalInFluentCartDashboard();
21 24 }
22 25 }
23 26
24 - public function getFluentCartPurchaseWidgets($widgets, $customer)
27 + public function getPurchaseWidgets($widgets, $customer, $context = null)
25 28 {
26 - $wpUserId = $customer->user_id;
29 + if (!ProfileInfoService::wantsWidget($context, self::WIDGET_KEY)) {
30 + return $widgets;
31 + }
27 32
28 - // Get all order items for this customer, grouped by product
29 - $orderItems = \FluentCart\App\Models\OrderItem::whereHas('order.customer', function ($query) use ($wpUserId) {
30 - $query->where('user_id', $wpUserId);
31 - })
32 - ->with([
33 - 'order' => function ($query) {
34 - $query->select(['id', 'status', 'created_at', 'total_amount', 'currency', 'invoice_no']);
35 - }
36 - ])
37 - ->select(['id', 'order_id', 'post_id', 'post_title', 'title', 'payment_type', 'unit_price', 'subtotal', 'quantity'])
38 - ->orderByDesc('id')
39 - ->get();
33 + $cartCustomer = $this->findCustomer($customer);
40 34
41 - if ($orderItems->isEmpty()) {
35 + if (!$cartCustomer) {
42 36 return $widgets;
43 37 }
44 38
45 - // Create individual product entries for each order item (no grouping)
46 - $formattedProducts = [];
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();
47 45
48 - foreach ($orderItems as $item) {
49 - $licenseType = $this->getLicenseType($item);
46 + if ($orders->isEmpty()) {
47 + return $widgets;
48 + }
50 49
51 - $formattedProducts[] = [
52 - 'product_name' => $item->post_title,
53 - 'variation_name' => $item->title !== $item->post_title ? $item->title : null,
54 - 'license_type' => $licenseType,
55 - 'price' => $item->unit_price,
56 - 'currency' => $item->order->currency,
57 - 'formatted_price' => $item->formatted_total,
58 - 'status' => $this->getProductStatus($item),
59 - 'order' => [
60 - 'id' => $item->order->id,
61 - 'invoice_no' => $item->order->invoice_no,
62 - 'status' => $item->order->status,
63 - 'date' => $item->order->created_at->format('Y-m-d H:i:s'),
64 - 'currency' => $item->order->currency,
65 - 'total' => number_format($item->order->total_amount / 100, 2, '.', ''),
66 - 'quantity' => $item->quantity
67 - ]
50 + if (ProfileInfoService::isMcpWidgetRequest($context)) {
51 + $widgets[self::WIDGET_KEY] = [
52 + 'title' => __('FluentCart Purchases', 'fluent-support'),
53 + 'mcp' => $this->getMcpData($orders)
68 54 ];
69 - }
70 55
71 - // Sort products by most recent order date
72 - usort($formattedProducts, function ($a, $b) {
73 - return strtotime($b['order']['date']) - strtotime($a['order']['date']);
74 - });
75 -
76 - // Get FluentCart customer ID
77 - $fluentCartCustomer = \FluentCart\App\Models\Customer::where('user_id', $wpUserId)->first();
78 - if (!$fluentCartCustomer) {
79 56 return $widgets;
80 57 }
81 58
82 - $widgets['fct_purchases'] = [
83 - 'title' => __('FluentCart Purchases', 'fluent-support'),
84 - 'products' => $formattedProducts,
85 - 'fluent_cart_customer_id' => $fluentCartCustomer->id,
86 - 'summary' => [
87 - 'lifetime_value' => \FluentCart\App\Helpers\Helper::toDecimal($fluentCartCustomer->ltv),
88 - 'currency' => Arr::get($formattedProducts, '0.currency', \FluentCart\Api\CurrencySettings::get('currency')),
89 - 'total_purchases' => $fluentCartCustomer->purchase_count,
90 - 'first_purchase' => $fluentCartCustomer->first_purchase_date ? gmdate('F j, Y', strtotime($fluentCartCustomer->first_purchase_date)) : null,
91 - 'last_purchase' => $fluentCartCustomer->last_purchase_date ? gmdate('F j, Y', strtotime($fluentCartCustomer->last_purchase_date)) : null,
92 - ]
93 - ];
59 + $widgets[self::WIDGET_KEY] = array_merge(
60 + ['title' => __('FluentCart Purchases', 'fluent-support')],
61 + $this->getWidgetData($cartCustomer, $orders)
62 + );
94 63
95 64 return $widgets;
96 65 }
97 66
98 - // public function getFluentCartProLicenseWidget($widgets, $customer)
99 - // {
100 - // // Add customer's product licenses if available
101 - // $licenses = $this->getCustomerProductLicenses($customer);
102 -
103 - // if ($licenses) {
104 - // $widgets['fct_license'] = [
105 - // 'header' => __('FluentCart Product Licenses', 'fluent-support'),
106 - // 'licenses' => $licenses
107 - // ];
108 - // }
109 -
110 - // return $widgets;
111 - // }
112 -
113 67 /**
114 - * Get customer's product licenses from FluentCart Pro
68 + * Stats and recent purchases, drawn by the support site's sidebar as tiles and a list.
115 69 *
116 - * @param Customer $customer
117 - * @return array|null Structured license data or null if not available
70 + * @param CartCustomer $cartCustomer
71 + * @param \FluentCart\Framework\Support\Collection $orders Newest first.
72 + * @return array
118 73 */
119 - private function getCustomerProductLicenses($customer)
74 + private function getWidgetData($cartCustomer, $orders)
120 75 {
121 - // Check if FluentCart Pro License model is available
122 - if (!class_exists('\FluentCartPro\App\Modules\Licensing\Models\License')) {
123 - return null;
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 + ];
124 94 }
125 95
126 - try {
127 - $wpUserId = $customer->user_id;
128 - if (!$wpUserId) {
129 - return null;
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']++;
130 118 }
119 + }
131 120
132 - // Get FluentCart customer ID
133 - $fluentCartCustomer = \FluentCart\App\Models\Customer::where('user_id', $wpUserId)->first();
134 - if (!$fluentCartCustomer) {
135 - return null;
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;
136 125 }
137 126
138 - // Get licenses for this customer
139 - $licenses = \FluentCartPro\App\Modules\Licensing\Models\License::where('customer_id', $fluentCartCustomer->id)
140 - ->with(['product'])
141 - ->orderByDesc('created_at')
142 - ->get()
143 - ->toArray();
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;
144 130
145 - if (empty($licenses)) {
146 - return null;
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;
147 142 }
148 143
149 - $formattedLicenses = [];
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 + }
150 156
151 - foreach ($licenses as $license) {
152 - // Get product name from the data structure
153 - $productName = 'Unknown Product';
154 - if (isset($license['product']['post_title'])) {
155 - $productName = $license['product']['post_title'];
157 + foreach ($order->order_items as $orderItem) {
158 + if ($orderItem->payment_type == 'signup_fee') {
159 + continue;
156 160 }
157 161
162 + $variation = $orderItem->title !== $orderItem->post_title ? (string)$orderItem->title : '';
158 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 + }
159 182
160 - $status = $license['status'];
161 - $expirationDate = $license['expiration_date'];
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 + });
162 187
163 - // Format expiration date for tooltip
164 - $expirationTooltip = '';
165 - if ($expirationDate) {
166 - $expirationTooltip = 'Expires: ' . gmdate('M d, Y', strtotime($expirationDate));
167 - } else {
168 - $expirationTooltip = 'Expires: Lifetime';
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;
169 245 }
170 246
171 - $formattedLicenses[] = [
172 - 'id' => $license['id'],
173 - 'product_name' => $productName,
174 - 'status' => $status,
175 - 'expiration_tooltip' => $expirationTooltip,
176 - 'expiration_date' => $expirationDate
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),
177 253 ];
178 254 }
179 255
180 - return $formattedLicenses;
256 + $subscriptions = [];
257 + foreach ($order->subscriptions as $subscription) {
258 + if ($subscription->status === 'active') {
259 + $activeSubscriptions++;
260 + }
181 261
182 - } catch (\Exception $e) {
183 - return null;
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 + ];
184 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;
185 300 }
186 301
187 302 /**
188 - * Determine the license type based on order item payment type
303 + * FluentCart Pro licenses, without the keys.
189 304 *
190 - * @param \FluentCart\App\Models\OrderItem $item
191 - * @return string|null
305 + * @param int $customerId
306 + * @return array
192 307 */
193 - private function getLicenseType($item)
308 + private function getMcpLicenses($customerId)
194 309 {
195 - switch ($item->payment_type) {
196 - case 'subscription':
197 - // Return 'Subscription' for internal logic but don't display it as license type
198 - return 'Subscription';
199 - case 'payment':
200 - default:
201 - // Check if it's a lifetime license based on product name or other criteria
202 - if (stripos($item->post_title, 'lifetime') !== false || stripos($item->title, 'lifetime') !== false) {
203 - return 'Lifetime License';
204 - }
205 - // Don't show license type for regular one-time purchases
206 - return null;
310 + if (!class_exists('\FluentCartPro\App\Modules\Licensing\Models\License')) {
311 + return [];
207 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;
208 337 }
209 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 +
210 345 /**
211 - * Determine the product status based on order status and payment type
346 + * A GMT MySQL datetime as ISO 8601 UTC, or null.
212 347 *
213 - * @param \FluentCart\App\Models\OrderItem $item
214 - * @return string
348 + * @param string|\DateTimeInterface|null $date
349 + * @return string|null
215 350 */
216 - private function getProductStatus($item)
351 + private static function gmtDateIso($date)
217 352 {
218 - $orderStatus = $item->order->status;
353 + if ($date instanceof \DateTimeInterface) {
354 + $date = $date->format('Y-m-d H:i:s');
355 + }
219 356
220 - // Return 'canceled' for multiple statuses
221 - if (in_array($orderStatus, ['canceled', 'failed'])) {
222 - return 'canceled';
357 + if (!$date || strpos((string)$date, '0000-00-00') === 0) {
358 + return null;
223 359 }
224 360
225 - // Return default statuses
226 - $statusMap = [
227 - 'completed' => 'completed',
228 - 'processing' => 'processing',
229 - 'on-hold' => 'on-hold'
230 - ];
361 + $timestamp = strtotime($date . ' UTC');
231 362
232 - return $statusMap[$orderStatus] ?? 'pending'; // Default to 'pending' if not found
363 + return $timestamp ? gmdate('c', $timestamp) : null;
233 364 }
234 365
235 366 private function renderCustomerPortalInFluentCartDashboard()
236 367 {