PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.8
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.8
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Services / Report / ReportHelper.php

ReportHelper.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.8, at app/Services/Report/ReportHelper.php

1,568 lines 63.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Services\Report;
4
5 use FluentForm\App\Helpers\Helper;
6 use FluentForm\App\Models\EntryDetails;
7 use FluentForm\App\Models\Form;
8 use FluentForm\App\Models\FormAnalytics;
9 use FluentForm\App\Models\Log;
10 use FluentForm\App\Models\Submission;
11 use FluentForm\App\Modules\Form\FormFieldsParser;
12 use FluentForm\App\Modules\Payments\PaymentHelper;
13 use FluentForm\App\Services\Manager\FormManagerService;
14 use FluentForm\App\Services\Submission\SubmissionService;
15 use FluentForm\Framework\Helpers\ArrayHelper as Arr;
16
17 class ReportHelper
18 {
19 private static function scopeQueryToAllowedForms($query, $formId = null, $column = 'form_id')
20 {
21 if ($formId) {
22 return $query->where($column, $formId);
23 }
24
25 if (false !== ($allowedFormIds = FormManagerService::getUserAllowedFormsScope())) {
26 return $query->whereIn($column, $allowedFormIds ?: [0]);
27 }
28
29 return $query;
30 }
31
32 public static function generateReport($form, $statuses = ['read', 'unread', 'unapproved', 'approved', 'declined', 'unconfirmed', 'confirmed'])
33 {
34 $formInputs = FormFieldsParser::getEntryInputs($form, ['admin_label', 'element', 'options']);
35 $inputLabels = FormFieldsParser::getAdminLabels($form, $formInputs);
36
37 $elements = [];
38 foreach ($formInputs as $inputName => $input) {
39 $elements[$inputName] = $input['element'];
40 if ('select_country' == $input['element']) {
41 $formInputs[$inputName]['options'] = getFluentFormCountryList();
42 }
43 }
44
45 $reportableInputs = Helper::getReportableInputs();
46 $formReportableInputs = array_intersect($reportableInputs, array_values($elements));
47 $reportableInputs = Helper::getSubFieldReportableInputs();
48 $formSubFieldInputs = array_intersect($reportableInputs, array_values($elements));
49
50
51 if (!$formReportableInputs && !$formSubFieldInputs) {
52 return [
53 'report_items' => (object)[],
54 'total_entries' => static::getEntryCounts($form->id, $statuses),
55 'browsers' => static::getBrowserCounts($form->id, $statuses),
56 'devices' => static::getDeviceCounts($form->id, $statuses),
57 ];
58 }
59
60 $inputs = [];
61 $subfieldInputs = [];
62 foreach ($elements as $elementKey => $element) {
63 if (in_array($element, $formReportableInputs)) {
64 $inputs[$elementKey] = $element;
65 }
66 if (in_array($element, $formSubFieldInputs)) {
67 $subfieldInputs[$elementKey] = $element;
68 }
69 }
70
71 $reports = static::getInputReport($form->id, array_keys($inputs), $statuses);
72
73 $subFieldReports = static::getSubFieldInputReport($form->id, array_keys($subfieldInputs), $statuses);
74 $reports = array_merge($reports, $subFieldReports);
75 foreach ($reports as $reportKey => $report) {
76 $reports[$reportKey]['label'] = $inputLabels[$reportKey];
77 $reports[$reportKey]['element'] = Arr::get($inputs, $reportKey, []);
78 $reports[$reportKey]['options'] = $formInputs[$reportKey]['options'];
79
80 // Per-element override hook. Safe with no listener (default
81 // payload is returned unchanged). Pro hooks the ranking
82 // variant; third-party fields can extend the same pattern.
83 $element = $reports[$reportKey]['element'];
84 if (is_string($element) && $element !== '') {
85 $reports[$reportKey] = apply_filters(
86 'fluentform/reports/format_field_' . $element,
87 $reports[$reportKey],
88 array_merge($formInputs[$reportKey], ['name' => $reportKey]),
89 $form->id,
90 $statuses
91 );
92 }
93 }
94
95 return [
96 'report_items' => $reports,
97 'total_entries' => static::getEntryCounts($form->id, $statuses),
98 'browsers' => static::getBrowserCounts($form->id, $statuses),
99 'devices' => static::getDeviceCounts($form->id, $statuses),
100 ];
101 }
102
103 public static function getInputReport(
104 $formId,
105 $fieldNames,
106 $statuses = ['read', 'unread', 'unapproved', 'approved', 'declined', 'unconfirmed', 'confirmed']
107 ) {
108 if (!$fieldNames) {
109 return [];
110 }
111
112 $reports = EntryDetails::select(['field_name', 'sub_field_name', 'field_value'])
113 ->where('form_id', $formId)
114 ->whereIn('field_name', $fieldNames)
115 ->when(
116 is_array($statuses) && (count($statuses) > 0),
117 function ($q) use ($statuses) {
118 return $q->whereHas('submission', function ($q) use ($statuses) {
119 return $q->whereIn('status', $statuses);
120 });
121 })
122 ->selectRaw('COUNT(field_name) AS total_count')
123 ->groupBy(['field_name', 'field_value'])
124 ->get();
125
126 $formattedReports = [];
127 foreach ($reports as $report) {
128 $formattedReports[$report->field_name]['reports'][] = [
129 'value' => Helper::safeUnserialize($report->field_value),
130 'count' => $report->total_count,
131 'sub_field' => $report->sub_field_name,
132 ];
133
134 $formattedReports[$report->field_name]['total_entry'] = static::getEntryTotal($report->field_name, $formId,
135 $statuses);
136 }
137 if ($formattedReports) {
138 //sync with form field order
139 $formattedReports = array_replace(array_intersect_key(array_flip($fieldNames), $formattedReports),
140 $formattedReports);
141 }
142 return $formattedReports;
143 }
144
145 public static function getSubFieldInputReport($formId, $fieldNames, $statuses)
146 {
147 if (!$fieldNames) {
148 return [];
149 }
150
151 $reports = EntryDetails::select(['field_name', 'sub_field_name', 'field_value'])
152 ->selectRaw('COUNT(field_name) AS total_count')
153 ->where('form_id', $formId)
154 ->whereIn('field_name', $fieldNames)
155 ->when(
156 is_array($statuses) && (count($statuses) > 0),
157 function ($q) use ($statuses) {
158 return $q->whereHas('submission', function ($q) use ($statuses) {
159 return $q->whereIn('status', $statuses);
160 });
161 })
162 ->groupBy(['field_name', 'field_value', 'sub_field_name'])
163 ->get()->toArray();
164 return static::getFormattedReportsForSubInputs($reports, $formId, $statuses);
165 }
166
167 protected static function getFormattedReportsForSubInputs($reports, $formId, $statuses)
168 {
169 if (!count($reports)) {
170 return [];
171 }
172 $formattedReports = [];
173 foreach ($reports as $report) {
174 static::setReportForSubInput((array)$report, $formattedReports);
175 }
176 foreach ($formattedReports as $fieldName => $val) {
177 $formattedReports[$fieldName]['total_entry'] = static::getEntryTotal(
178 Arr::get($report, 'field_name'),
179 $formId,
180 $statuses
181 );
182 $formattedReports[$fieldName]['reports'] = array_values(
183 $formattedReports[$fieldName]['reports']
184 );
185 }
186 return $formattedReports;
187 }
188
189 protected static function setReportForSubInput($report, &$formattedReports)
190 {
191 $filedValue = Helper::safeUnserialize(Arr::get($report, 'field_value'));
192
193 if (is_array($filedValue)) {
194 foreach ($filedValue as $fVal) {
195 static::setReportForSubInput(
196 array_merge($report, ['field_value' => $fVal]),
197 $formattedReports
198 );
199 }
200 } else {
201 $value = Arr::get($report, 'sub_field_name') . ' : ' . $filedValue;
202 $count = Arr::get($formattedReports, $report['field_name'] . '.reports.' . $value . '.count');
203 $count = $count ? $count + Arr::get($report, 'total_count') : Arr::get($report, 'total_count');
204
205 $formattedReports[$report['field_name']]['reports'][$value] = [
206 'value' => $value,
207 'count' => $count,
208 'sub_field' => $report['sub_field_name'],
209 ];
210 }
211 }
212
213 public static function getEntryTotal($fieldName, $formId, $statuses = false)
214 {
215 return EntryDetails::select('id')->where('form_id', $formId)
216 ->where('field_name', $fieldName)
217 ->when(
218 is_array($statuses) && (count($statuses) > 0),
219 function ($q) use ($statuses) {
220 return $q->whereHas('submission', function ($q) use ($statuses) {
221 return $q->whereIn('status', $statuses);
222 });
223 }
224 )
225 ->distinct(['field_name', 'submission_id'])
226 ->count();
227 }
228
229 private static function getEntryCounts($formId, $statuses = false)
230 {
231 return Submission::where('form_id', $formId)
232 ->when(
233 is_array($statuses) && (count($statuses) > 0),
234 function ($q) use ($statuses) {
235 return $q->whereIn('status', $statuses);
236 })
237 ->when(!$statuses, function ($q) {
238 return $q->where('status', '!=', 'trashed');
239 })->count();
240 }
241
242 public static function getBrowserCounts($formId, $statuses = false)
243 {
244 return static::getCounts($formId, 'browser', $statuses);
245 }
246
247 public static function getDeviceCounts($formId, $statuses = false)
248 {
249 return static::getCounts($formId, 'device', $statuses);
250 }
251
252 private static function getCounts($formId, $for, $statuses)
253 {
254 $deviceCounts = Submission::select([
255 "$for",
256 ])
257 ->selectRaw('COUNT(id) as total_count')
258 ->where('form_id', $formId)
259 ->when(
260 is_array($statuses) && (count($statuses) > 0),
261 function ($q) use ($statuses) {
262 return $q->whereIn('status', $statuses);
263 })
264 ->when(!$statuses, function ($q) {
265 return $q->where('status', '!=', 'trashed');
266 })
267 ->groupBy("$for")->get();
268
269 $formattedData = [];
270 foreach ($deviceCounts as $deviceCount) {
271 $formattedData[$deviceCount->{$for}] = $deviceCount->total_count;
272 }
273 return $formattedData;
274 }
275
276 public static function maybeMigrateData($formId)
277 {
278 if ('yes' == Helper::getFormMeta($formId, 'report_data_migrated')) {
279 return true;
280 }
281
282 // Check if migration is already in progress (with 5-minute staleness timeout)
283 $migrationStatus = Helper::getFormMeta($formId, 'report_data_migration_status');
284 if ($migrationStatus && strpos($migrationStatus, 'processing:') === 0) {
285 $startedAt = (int) substr($migrationStatus, 11);
286 if ((time() - $startedAt) < 300) {
287 return true;
288 }
289 }
290
291 Helper::setFormMeta($formId, 'report_data_migration_status', 'processing:' . time());
292
293 // Dispatch a non-blocking background request
294 $args = [
295 'timeout' => 0.1,
296 'blocking' => false,
297 'body' => [
298 'action' => 'fluentform_report_data_migrate',
299 'form_id' => $formId,
300 'nonce' => wp_create_nonce('fluentform_report_data_migrate'),
301 ],
302 'cookies' => is_user_logged_in() ? wp_unslash($_COOKIE) : [],
303 'sslverify' => apply_filters('fluentform/https_local_ssl_verify', false),
304 ];
305
306 wp_remote_post(esc_url_raw(Helper::getAjaxUrl()), $args);
307
308 return true;
309 }
310
311 /**
312 * Ensure submission_id index exists on entry_details table for efficient anti-join queries.
313 */
314 private static $indexEnsured = false;
315
316 private static function ensureEntryDetailsIndex()
317 {
318 if (static::$indexEnsured) {
319 return;
320 }
321
322 global $wpdb;
323 $table = $wpdb->prefix . 'fluentform_entry_details';
324
325 // Check if index already exists
326 $indexExists = $wpdb->get_var($wpdb->prepare(
327 "SELECT COUNT(1) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = 'idx_submission_id'",
328 $table
329 ));
330
331 if (!$indexExists) {
332 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is hardcoded prefix + table name, not user input
333 $wpdb->query("ALTER TABLE `{$table}` ADD INDEX `idx_submission_id` (`submission_id`)");
334 }
335
336 static::$indexEnsured = true;
337 }
338
339 public static function runMigrationBatch($formId)
340 {
341 if ('yes' == Helper::getFormMeta($formId, 'report_data_migrated')) {
342 return;
343 }
344
345 // Acquire a MySQL advisory lock to prevent concurrent batch execution per form.
346 // GET_LOCK returns 1 if acquired, 0 if timed out, NULL on error.
347 global $wpdb;
348 $lockName = 'ff_report_migrate_' . intval($formId);
349 $gotLock = $wpdb->get_var($wpdb->prepare("SELECT GET_LOCK(%s, 0)", $lockName));
350
351 if (!$gotLock) {
352 return; // Another worker holds the lock for this form
353 }
354
355 try {
356 // Ensure index exists for efficient doesntHave('entryDetails') queries
357 static::ensureEntryDetailsIndex();
358
359 $batchSize = apply_filters('fluentform/report_migration_batch_size', 200);
360 $maxBatches = apply_filters('fluentform/report_migration_max_batches', 50);
361 $submissionService = new SubmissionService();
362 $lastId = (int) Helper::getFormMeta($formId, 'report_data_migration_last_id');
363
364 for ($batch = 0; $batch < $maxBatches; $batch++) {
365 $unmigratedData = Submission::select(['id', 'response'])
366 ->where('form_id', $formId)
367 ->where('id', '>', $lastId)
368 ->doesntHave('entryDetails')
369 ->orderBy('id', 'ASC')
370 ->limit($batchSize)
371 ->get();
372
373 if (!$unmigratedData || count($unmigratedData) === 0) {
374 break;
375 }
376
377 foreach ($unmigratedData as $datum) {
378 $lastId = $datum->id;
379 $value = json_decode($datum->response, true);
380 if (is_array($value) && !empty($value)) {
381 $submissionService->recordEntryDetails($datum->id, $formId, $value);
382 }
383
384 // If no entry_details were created (empty/invalid response or all values filtered),
385 // insert a sentinel so doesntHave('entryDetails') excludes this row
386 $hasDetails = EntryDetails::where('submission_id', $datum->id)->exists();
387 if (!$hasDetails) {
388 EntryDetails::insert([
389 'form_id' => $formId,
390 'submission_id' => $datum->id,
391 'field_name' => '_migration_skipped',
392 'sub_field_name' => '',
393 'field_value' => '',
394 ]);
395 }
396 }
397
398 Helper::setFormMeta($formId, 'report_data_migration_last_id', $lastId);
399
400 if ($unmigratedData->count() < $batchSize) {
401 break;
402 }
403 }
404
405 // Only mark complete if no unmigrated rows remain
406 $remaining = Submission::where('form_id', $formId)
407 ->doesntHave('entryDetails')
408 ->count();
409
410 if ($remaining === 0) {
411 Helper::setFormMeta($formId, 'report_data_migrated', 'yes');
412 Helper::setFormMeta($formId, 'report_data_migration_last_id', 0);
413 }
414
415 Helper::setFormMeta($formId, 'report_data_migration_status', 'completed');
416 } finally {
417 $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName));
418 }
419 }
420
421 /**
422 * Get overview chart data
423 */
424 public static function getOverviewChartData($startDate, $endDate, $formId, $view)
425 {
426 // Process and fix date ranges if needed
427 list($startDate, $endDate) = self::processDateRange($startDate, $endDate);
428
429 // Calculate date difference to determine grouping
430 $startDateTime = new \DateTime($startDate);
431 $endDateTime = new \DateTime($endDate);
432 $interval = $startDateTime->diff($endDateTime);
433 $daysInterval = $interval->days + 1;
434
435 // Determine grouping mode based on date range
436 $groupingMode = self::getGroupingMode($daysInterval);
437 $data = self::getAggregatedData($startDate, $endDate, $groupingMode, $view, $formId);
438 // Get date labels based on grouping mode
439 $dateLabels = self::getDateLabels($startDateTime, $endDateTime, $groupingMode);
440 // Format the data for the chart
441 $chartData = self::formatDataForChart($dateLabels, $data, $formId);
442 // Get views data based on the ip
443 $views = self::getFormViews($startDate, $endDate, $groupingMode, $formId);
444 if ($views) {
445 $chartData['values']['views'] = array_values(self::fillMissingData($dateLabels['dates'], $views));
446 }
447 return $chartData;
448 }
449
450
451
452
453
454 public static function getFormStats($startDate, $endDate, $formId)
455 {
456 // Process and fix date ranges if needed
457 list($startDate, $endDate) = self::processDateRange($startDate, $endDate);
458
459 // Calculate the date range duration to determine previous period
460 $startDateTime = new \DateTime($startDate);
461 $endDateTime = new \DateTime($endDate);
462 $interval = $startDateTime->diff($endDateTime);
463 $daysDifference = $interval->days;
464
465 // Calculate previous period dates (same duration, shifted back)
466 $previousEndDateTime = clone $startDateTime;
467 $previousEndDateTime->modify('-1 day');
468 $previousStartDateTime = clone $previousEndDateTime;
469 $previousStartDateTime->modify("-{$daysDifference} days");
470
471 $previousStartDate = $previousStartDateTime->format('Y-m-d H:i:s');
472 $previousEndDate = $previousEndDateTime->format('Y-m-d H:i:s');
473
474 // Get submission counts
475 $periodSubmissionsQuery = Submission::whereBetween('created_at', [$startDate, $endDate]);
476 self::scopeQueryToAllowedForms($periodSubmissionsQuery, $formId);
477 $periodSubmissions = $periodSubmissionsQuery->count();
478
479 $previousPeriodSubmissionsQuery = Submission::whereBetween('created_at', [$previousStartDate, $previousEndDate]);
480 self::scopeQueryToAllowedForms($previousPeriodSubmissionsQuery, $formId);
481 $previousPeriodSubmissions = $previousPeriodSubmissionsQuery->count();
482
483 // Get submission status counts (grouped)
484 $statusCountsQuery = Submission::whereBetween('created_at', [$startDate, $endDate])
485 ->selectRaw('status, COUNT(*) as count')
486 ->groupBy('status');
487 self::scopeQueryToAllowedForms($statusCountsQuery, $formId);
488 $statusCounts = $statusCountsQuery->pluck('count', 'status');
489
490 $unreadSubmissions = intval(Arr::get($statusCounts, 'unread', 0));
491 $readSubmissions = intval(Arr::get($statusCounts, 'read', 0));
492 $periodSpamSubmissions = intval(Arr::get($statusCounts, 'spam', 0));
493
494
495 $previousStatusCountsQuery = Submission::whereBetween('created_at', [$previousStartDate, $previousEndDate])
496 ->selectRaw('status, COUNT(*) as count')
497 ->groupBy('status');
498 self::scopeQueryToAllowedForms($previousStatusCountsQuery, $formId);
499 $previousStatusCounts = $previousStatusCountsQuery->pluck('count', 'status');
500
501 $previousSpamSubmissions = intval(Arr::get($previousStatusCounts, 'spam', 0));
502
503 // Get active integrations count from wp_options
504 $modulesStatus = get_option('fluentform_global_modules_status');
505 $activeIntegrations = count(array_filter($modulesStatus, function ($status) {
506 return $status === 'yes' || $status == 1 || $status == 'true';
507 }));
508
509 // Calculate period growth percentage
510 $growthPercentage = 0;
511 if ($previousPeriodSubmissions > 0) {
512 $growthPercentage = round((($periodSubmissions - $previousPeriodSubmissions) / $previousPeriodSubmissions) * 100,
513 1);
514 } elseif ($periodSubmissions > 0) {
515 $growthPercentage = 100;
516 }
517
518 $growthText = $growthPercentage > 0 ? '+' . $growthPercentage . '%' : $growthPercentage . '%';
519 $growthType = $growthPercentage > 0 ? 'up' : ($growthPercentage < 0 ? 'down' : 'neutral');
520
521 // calculate spam percentage
522 $spamPercentage = 0;
523 if ($previousSpamSubmissions > 0) {
524 $spamPercentage = round((($periodSpamSubmissions - $previousSpamSubmissions) / $previousSpamSubmissions) * 100,
525 1);
526 } elseif ($periodSpamSubmissions > 0) {
527 $spamPercentage = 100;
528 }
529 $spamText = $spamPercentage > 0 ? '+' . $spamPercentage . '%' : $spamPercentage . '%';
530 $spamType = $spamPercentage > 0 ? 'down' : ($spamPercentage < 0 ? 'up' : 'neutral'); // Refunds going up is bad
531
532 // Active forms
533 $periodActiveFormsQuery = Form::where('status', 'published')->whereBetween('created_at', [$startDate, $endDate]);
534 self::scopeQueryToAllowedForms($periodActiveFormsQuery, $formId, 'id');
535 $periodActiveFormsCount = $periodActiveFormsQuery->count();
536
537 $previousActiveFormsQuery = Form::where('status', 'published')->whereBetween('created_at', [$previousStartDate, $previousEndDate]);
538 self::scopeQueryToAllowedForms($previousActiveFormsQuery, $formId, 'id');
539 $previousActiveFormsCount = $previousActiveFormsQuery->count();
540 $activeFormsPercentage = 0;
541 if ($previousActiveFormsCount > 0) {
542 $activeFormsPercentage = round((($periodActiveFormsCount - $previousActiveFormsCount) / $previousActiveFormsCount) * 100,
543 1);
544 } elseif ($periodActiveFormsCount > 0) {
545 $activeFormsPercentage = 100;
546 }
547 $activeFormsText = $activeFormsPercentage > 0 ? '+' . $activeFormsPercentage . '%' : $activeFormsPercentage . '%';
548 $activeFormsType = $activeFormsPercentage > 0 ? 'up' : ($activeFormsPercentage < 0 ? 'down' : 'neutral');
549
550 $readRate = $periodSubmissions > 0 ? round(($readSubmissions / $periodSubmissions) * 100, 1) : 0;
551
552 $stats = [
553 'period' => $daysDifference . ' days',
554 'total_submissions' => [
555 'value' => $periodSubmissions,
556 'period_value' => $periodSubmissions,
557 'change' => $growthText,
558 'change_type' => $growthType
559 ],
560 'spam_submissions' => [
561 'value' => $periodSpamSubmissions,
562 'period_value' => $previousSpamSubmissions,
563 'change' => $spamText,
564 'change_type' => $spamType
565 ],
566 'active_integrations' => [
567 'value' => $activeIntegrations,
568 ],
569 'unread_submissions' => [
570 'value' => $unreadSubmissions,
571 ],
572 'read_submissions' => [
573 'value' => $readSubmissions,
574 ],
575 'active_forms' => [
576 'value' => $periodActiveFormsCount,
577 'change' => $activeFormsText,
578 'change_type' => $activeFormsType
579 ],
580 'read_submission_rate' => [
581 'value' => $readRate,
582 ]
583 ];
584
585 // Add payment statistics if payment module is enabled
586 $paymentSettings = get_option('__fluentform_payment_module_settings');
587 if ($paymentSettings && Arr::get($paymentSettings, 'status') === 'yes') {
588 // Get payment statistics
589 $paymentStats = self::getPaymentStats($startDate, $endDate, $previousStartDate, $previousEndDate, $formId);
590 $stats = array_merge($stats, $paymentStats);
591 }
592
593 return $stats;
594 }
595
596
597 /**
598 * Initialize heatmap data structure based on aggregation type
599 */
600 protected static function initializeHeatmapData($aggregationType)
601 {
602 if ($aggregationType === 'day_of_week') {
603 $dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
604 $heatmapData = [];
605 foreach ($dayNames as $day) {
606 $heatmapData[$day] = array_fill(0, 24, 0); // 24 time slots (0-23 hours), all initialized to 0
607 }
608 return $heatmapData;
609 }
610
611 return [];
612 }
613
614 /**
615 * Get submission data for heatmap with appropriate grouping
616 * Optimized version with better query performance
617 */
618 protected static function getHeatmapSubmissionData($startDate, $endDate, $formId, $aggregationType)
619 {
620 if ($aggregationType === 'day_of_week') {
621 $query = Submission::selectRaw('
622 DAYOFWEEK(created_at) as day_of_week,
623 HOUR(created_at) as submission_hour,
624 COUNT(*) as count
625 ')
626 ->whereBetween('created_at', [$startDate, $endDate])
627 ->whereNotIn('status', ['trashed', 'spam'])
628 ->groupBy('day_of_week', 'submission_hour')
629 ->orderBy('day_of_week')
630 ->orderBy('submission_hour');
631
632 self::scopeQueryToAllowedForms($query, $formId);
633
634 return $query->get();
635 }
636
637 return collect([]);
638 }
639
640
641 public static function getApiLogs($startDate, $endDate, $formId = null)
642 {
643 // Process date range
644 list($startDate, $endDate) = self::processDateRange($startDate, $endDate);
645
646 // Calculate date difference to determine grouping
647 $startDateTime = new \DateTime($startDate);
648 $endDateTime = new \DateTime($endDate);
649 $interval = $startDateTime->diff($endDateTime);
650 $daysInterval = $interval->days + 1;
651
652 // Determine grouping mode based on date range
653 $groupingMode = self::getGroupingMode($daysInterval);
654
655 // Define the date format based on grouping mode
656 if ($groupingMode === 'day') {
657 $dateFormat = "DATE(created_at)";
658 } elseif ($groupingMode === '3days') {
659 $dateFormat = "DATE(created_at)";
660 } elseif ($groupingMode === 'week') {
661 $dateFormat = "DATE(DATE_ADD(created_at, INTERVAL(-WEEKDAY(created_at)) DAY))";
662 } else { // month
663 $dateFormat = "DATE_FORMAT(created_at, '%Y-%m-01')";
664 }
665
666 // Components to exclude
667 $excludedComponents = [
668 'postFeeds',
669 'AdminApproval',
670 'Payment',
671 'EntryEditor',
672 'DoubleOptin',
673 'Subscription',
674 'UserRegistration',
675 'Akismet Integration',
676 'CleanTalk API Integration'
677 ];
678
679 // Get logs grouped by date and status using Eloquent, excluding specific components
680 $logsQuery = Log::whereBetween('created_at', [$startDate, $endDate]);
681
682 // Exclude components - handle both NULL and specific values
683 $logsQuery->where(function ($query) use ($excludedComponents) {
684 $query->whereNull('component')
685 ->orWhereNotIn('component', $excludedComponents);
686 });
687
688 self::scopeQueryToAllowedForms($logsQuery, $formId, 'parent_source_id');
689
690 $results = $logsQuery->selectRaw($dateFormat . ' as log_date')
691 ->selectRaw('status')
692 ->selectRaw('COUNT(*) as count')
693 ->groupBy('log_date', 'status')
694 ->orderBy('log_date')
695 ->get();
696
697 // Get total counts by status (also excluding the specific components)
698 $totalsQuery = Log::whereBetween('created_at', [$startDate, $endDate]);
699
700 $totalsQuery->where(function ($query) use ($excludedComponents) {
701 $query->whereNull('component')
702 ->orWhereNotIn('component', $excludedComponents);
703 });
704
705 self::scopeQueryToAllowedForms($totalsQuery, $formId, 'parent_source_id');
706
707 $totalsResults = $totalsQuery->selectRaw('status')
708 ->selectRaw('COUNT(*) as count')
709 ->groupBy('status')
710 ->get();
711
712 $totals = [
713 'success' => 0,
714 'pending' => 0,
715 'failed' => 0
716 ];
717
718 foreach ($totalsResults as $total) {
719 $status = strtolower($total->status);
720 if (isset($totals[$status])) {
721 $totals[$status] = (int)$total->count;
722 }
723 }
724
725 // Get date labels and prepare data
726 $dateLabels = self::getDateLabels($startDateTime, $endDateTime, $groupingMode);
727 $dates = $dateLabels['dates'];
728 $formattedLabels = $dateLabels['labels'];
729
730 // Initialize data structure - always with all dates, even if no data exists
731 $seriesData = [
732 'success' => array_fill_keys($dates, 0),
733 'pending' => array_fill_keys($dates, 0),
734 'failed' => array_fill_keys($dates, 0)
735 ];
736
737 // Fill in data from results when available
738 foreach ($results as $row) {
739 $date = $row->log_date;
740 $status = strtolower($row->status);
741 $count = (int)$row->count;
742
743 // Map status to our categories
744 if ($status === 'success' || $status === 'pending' || $status === 'failed') {
745 if (isset($seriesData[$status][$date])) {
746 $seriesData[$status][$date] = $count;
747 }
748 }
749 }
750
751 return [
752 'logs_data' => [
753 'categories' => $formattedLabels,
754 'series' => $seriesData
755 ],
756 'totals' => $totals,
757 'start_date' => $startDate,
758 'end_date' => $endDate
759 ];
760 }
761
762 /**
763 * Get top performing forms by entries, views, or payments
764 */
765 public static function getTopPerformingForms($startDate, $endDate, $metric = 'entries', $formIds = false)
766 {
767 list($startDate, $endDate) = self::processDateRange($startDate, $endDate);
768 global $wpdb;
769 $prefix = $wpdb->prefix;
770 $formResults = [];
771 $disableMessage = '';
772 $hasFormScope = false !== $formIds;
773 $formIds = $hasFormScope
774 ? array_values(array_filter(array_map('intval', (array) $formIds)))
775 : [];
776
777 switch ($metric) {
778 case 'entries':
779 // Use Form model with Submission relationship
780 $results = Form::select(['id', 'title'])
781 ->withCount([
782 'submissions' => function ($q) use ($startDate, $endDate) {
783 $q->whereBetween('created_at', [$startDate, $endDate]);
784 $q->whereNotIn('status', ['trashed', 'spam']);
785 }
786 ]);
787
788 if ($hasFormScope) {
789 $results->whereIn('id', $formIds ?: [0]);
790 }
791
792 $results = $results
793 ->orderBy('submissions_count', 'DESC')
794 ->limit(5)
795 ->get();
796
797 // Map the results to standard format
798 foreach ($results as $form) {
799 $formResults[] = (object)[
800 'id' => $form->id,
801 'title' => $form->title,
802 'value' => $form->submissions_count
803 ];
804 }
805 break;
806
807 case 'payments':
808 // Check if payment module is enabled
809 $paymentSettings = get_option('__fluentform_payment_module_settings');
810 if ($paymentSettings && Arr::get($paymentSettings, 'status')) {
811 $results = wpFluent()->table('fluentform_forms')
812 ->select([
813 'fluentform_forms.id',
814 'fluentform_forms.title',
815 wpFluent()->raw("COALESCE(SUM({$prefix}fluentform_transactions.payment_total), 0) as raw_value")
816 ])
817 ->leftJoin('fluentform_transactions', 'fluentform_forms.id', '=',
818 'fluentform_transactions.form_id')
819 ->whereBetween('fluentform_transactions.created_at', [$startDate, $endDate])
820 ->where('fluentform_transactions.status', 'paid');
821
822 if ($hasFormScope) {
823 $results->whereIn('fluentform_forms.id', $formIds ?: [0]);
824 }
825
826 $results = $results
827 ->groupBy('fluentform_forms.id')
828 ->orderBy('raw_value', 'DESC')
829 ->limit(5)
830 ->get();
831
832 // Convert cents to dollars in PHP for better precision
833 foreach ($results as $form) {
834 $form->value = round((float)$form->raw_value / 100, 2);
835 }
836
837 $formResults = $results;
838 } else {
839 $disableMessage = __('Payment module is disabled. Please enable it to view top performing form by payments.', 'fluentform');
840 }
841 break;
842
843 case 'views':
844 // Count unique views by IP from analytics table if analytics enabled
845 if (!apply_filters('fluentform/disabled_analytics', true)) {
846 $results = wpFluent()->table('fluentform_forms')
847 ->select([
848 'fluentform_forms.id',
849 'fluentform_forms.title',
850 wpFluent()->raw("COUNT(DISTINCT {$prefix}fluentform_form_analytics.ip) as value")
851 ])
852 ->leftJoin('fluentform_form_analytics', 'fluentform_forms.id', '=',
853 'fluentform_form_analytics.form_id')
854 ->whereBetween('fluentform_form_analytics.created_at', [$startDate, $endDate]);
855
856 if ($hasFormScope) {
857 $results->whereIn('fluentform_forms.id', $formIds ?: [0]);
858 }
859
860 $results = $results
861 ->groupBy('fluentform_forms.id')
862 ->orderBy('value', 'DESC')
863 ->limit(5)
864 ->get();
865
866 $formResults = $results;
867 } else {
868 $disableMessage = __('Analytics is disabled. Please enable it to view top performing form by views.', 'fluentform');
869 }
870 break;
871 }
872
873 // Common formatting for all results
874 $topForms = [];
875 foreach ($formResults as $form) {
876 if ((float)$form->value > 0) {
877 $topForms[] = [
878 'id' => $form->id,
879 'title' => $form->title ?: 'Untitled Form',
880 'value' => (float)$form->value
881 ];
882 }
883 }
884 return [
885 'disable_message' => $disableMessage,
886 'data' => array_reverse($topForms)
887 ];
888 }
889
890 /**
891 * Get form views date chunks
892 */
893 private static function getFormViews($startDate, $endDate, $groupingMode, $formId)
894 {
895 if (apply_filters('fluentform/disabled_analytics', true)) {
896 return [];
897 }
898
899 // 1. Get UNIQUE VIEWS by IP address
900 $viewsQuery = FormAnalytics::whereBetween('created_at', [$startDate, $endDate])
901 ->whereNotNull('ip');
902 self::scopeQueryToAllowedForms($viewsQuery, $formId);
903
904 // Group by date and IP to count unique visitors
905 if ($groupingMode === 'day') {
906 $viewsQuery->selectRaw('DATE(created_at) as date_group, COUNT(DISTINCT ip) as unique_count');
907 } elseif ($groupingMode === '3days') {
908 // Get min date for reference
909 $minDateQuery = FormAnalytics::whereBetween('created_at', [$startDate, $endDate])
910 ->selectRaw('MIN(DATE(created_at)) as min_date');
911 self::scopeQueryToAllowedForms($minDateQuery, $formId);
912 $minDateRecord = $minDateQuery->first();
913
914 if ($minDateRecord && $minDateRecord->min_date) {
915 $minDate = $minDateRecord->min_date;
916 $viewsQuery->selectRaw("FLOOR(DATEDIFF(DATE(created_at), ?) / 3) as group_num", [$minDate])
917 ->selectRaw('MIN(DATE(created_at)) as date_group')
918 ->selectRaw('COUNT(DISTINCT ip) as unique_count')
919 ->groupBy('group_num');
920 } else {
921 $viewsQuery->selectRaw('DATE(created_at) as date_group, COUNT(DISTINCT ip) as unique_count')
922 ->groupBy('date_group');
923 }
924 } elseif ($groupingMode === 'week') {
925 $viewsQuery->selectRaw("DATE(DATE_ADD(created_at, INTERVAL(-WEEKDAY(created_at)) DAY)) as date_group, COUNT(DISTINCT ip) as unique_count");
926 } else { // month
927 $viewsQuery->selectRaw("DATE_FORMAT(created_at, '%Y-%m-01') as date_group, COUNT(DISTINCT ip) as unique_count");
928 }
929
930 if ($groupingMode !== '3days' || !(isset($minDateRecord) && $minDateRecord->min_date)) {
931 $viewsQuery->groupBy('date_group');
932 }
933
934 $results = $viewsQuery->orderBy('date_group')->get();
935 $views = [];
936 foreach ($results as $result) {
937 $views[$result->date_group] = $result->unique_count;
938 }
939 return $views;
940 }
941
942 /**
943 * Process date range
944 */
945 public static function processDateRange($startDate, $endDate)
946 {
947 // Validate date formats
948 if (!strtotime($startDate) || !strtotime($endDate)) {
949 return [];
950 }
951
952 // Sanity check - ensure start date is before end date
953 $startDateTime = new \DateTime($startDate);
954 $endDateTime = new \DateTime($endDate);
955
956 // If start date is after end date, swap them
957 if ($startDateTime > $endDateTime) {
958 $temp = $startDate;
959 $startDate = $endDate;
960 $endDate = $temp;
961 }
962
963 return [$startDate, $endDate];
964 }
965
966 /**
967 * Determine grouping mode based on date range
968 */
969 private static function getGroupingMode($daysInterval)
970 {
971 if ($daysInterval <= 7) {
972 return 'day'; // 1-7 days: group by day
973 } elseif ($daysInterval <= 31) {
974 return '3days'; // 8-31 days: group by 3 days
975 } elseif ($daysInterval <= 92) {
976 return 'week'; // Group by week for 1-3 months
977 } else {
978 return 'month'; // 3+ months: group by month
979 }
980 }
981
982 /**
983 * Get aggregated data based on grouping mode
984 */
985 private static function getAggregatedData($startDate, $endDate, $groupingMode, $view, $formId)
986 {
987 $baseQuery = Submission::whereBetween('created_at', [$startDate, $endDate]);
988 self::scopeQueryToAllowedForms($baseQuery, $formId);
989
990 if ($view === 'revenue') {
991 // Clone the base query for each payment status
992 $paidQuery = clone $baseQuery;
993 $pendingQuery = clone $baseQuery;
994 $refundedQuery = clone $baseQuery;
995
996 // Get paid payments
997 $paidQuery->whereNotNull('payment_total')
998 ->where(function ($query) {
999 $query->where('payment_status', 'paid');
1000 })
1001 ->selectRaw('ROUND(SUM(payment_total) / 100, 2) as count');
1002
1003 // Get pending payments
1004 $pendingQuery->whereNotNull('payment_total')
1005 ->where('payment_status', 'pending')
1006 ->selectRaw('ROUND(SUM(payment_total) / 100, 2) as count');
1007
1008 // Get refunded payments
1009 $refundedQuery->whereNotNull('payment_total')
1010 ->where('payment_status', 'refunded')
1011 ->selectRaw('ROUND(SUM(payment_total) / 100, 2) as count');
1012
1013 // Apply grouping based on mode to all three queries
1014 if ($groupingMode === 'day') {
1015 $paidQuery->selectRaw('DATE(created_at) as date_group')->groupBy('date_group');
1016 $pendingQuery->selectRaw('DATE(created_at) as date_group')->groupBy('date_group');
1017 $refundedQuery->selectRaw('DATE(created_at) as date_group')->groupBy('date_group');
1018 } elseif ($groupingMode === '3days') {
1019 // Get minimum date for reference
1020 $minDateQuery = Submission::whereBetween('created_at', [$startDate, $endDate])
1021 ->selectRaw('MIN(DATE(created_at)) as min_date');
1022 self::scopeQueryToAllowedForms($minDateQuery, $formId);
1023 $minDateRecord = $minDateQuery->first();
1024
1025 if ($minDateRecord && $minDateRecord->min_date) {
1026 $minDate = $minDateRecord->min_date;
1027
1028 $paidQuery->selectRaw("MIN(DATE(created_at)) as date_group")
1029 ->selectRaw("FLOOR(DATEDIFF(DATE(created_at), ?) / 3) as group_num", [$minDate])
1030 ->groupBy('group_num');
1031
1032 $pendingQuery->selectRaw("MIN(DATE(created_at)) as date_group")
1033 ->selectRaw("FLOOR(DATEDIFF(DATE(created_at), ?) / 3) as group_num", [$minDate])
1034 ->groupBy('group_num');
1035
1036 $refundedQuery->selectRaw("MIN(DATE(created_at)) as date_group")
1037 ->selectRaw("FLOOR(DATEDIFF(DATE(created_at), ?) / 3) as group_num", [$minDate])
1038 ->groupBy('group_num');
1039 } else {
1040 $paidQuery->selectRaw('DATE(created_at) as date_group')->groupBy('date_group');
1041 $pendingQuery->selectRaw('DATE(created_at) as date_group')->groupBy('date_group');
1042 $refundedQuery->selectRaw('DATE(created_at) as date_group')->groupBy('date_group');
1043 }
1044 } elseif ($groupingMode === 'week') {
1045 $paidQuery->selectRaw("DATE(DATE_ADD(created_at, INTERVAL(-WEEKDAY(created_at)) DAY)) as date_group")->groupBy('date_group');
1046 $pendingQuery->selectRaw("DATE(DATE_ADD(created_at, INTERVAL(-WEEKDAY(created_at)) DAY)) as date_group")->groupBy('date_group');
1047 $refundedQuery->selectRaw("DATE(DATE_ADD(created_at, INTERVAL(-WEEKDAY(created_at)) DAY)) as date_group")->groupBy('date_group');
1048 } else {
1049 $paidQuery->selectRaw("DATE_FORMAT(created_at, '%Y-%m-01') as date_group")->groupBy('date_group');
1050 $pendingQuery->selectRaw("DATE_FORMAT(created_at, '%Y-%m-01') as date_group")->groupBy('date_group');
1051 $refundedQuery->selectRaw("DATE_FORMAT(created_at, '%Y-%m-01') as date_group")->groupBy('date_group');
1052 }
1053
1054 // Execute the queries
1055 $paidResults = $paidQuery->orderBy('date_group')->get();
1056 $pendingResults = $pendingQuery->orderBy('date_group')->get();
1057 $refundedResults = $refundedQuery->orderBy('date_group')->get();
1058
1059
1060 // Format the data
1061 $paidData = $revenuePayments = [];
1062 foreach ($paidResults as $result) {
1063 $paidData[$result->date_group] = $result->count;
1064 $revenuePayments[$result->date_group] = $result->count;
1065 }
1066
1067 $pendingData = [];
1068 foreach ($pendingResults as $result) {
1069 $pendingData[$result->date_group] = $result->count;
1070 }
1071
1072 $refundedData = [];
1073 foreach ($refundedResults as $result) {
1074 $refundedData[$result->date_group] = $result->count;
1075 if (isset($revenuePayments[$result->date_group])) {
1076 $revenuePayments[$result->date_group] -= $result->count;
1077 }
1078 }
1079
1080 // Return all three datasets
1081 return [
1082 'paid' => $paidData,
1083 'pending' => $pendingData,
1084 'refunded' => $refundedData,
1085 'payments' => $revenuePayments
1086 ];
1087 } else {
1088 $query = $baseQuery->selectRaw('COUNT(*) as count')->selectRaw('status');
1089 $query->groupBy('status');
1090
1091 // Apply grouping based on mode
1092 if ($groupingMode === 'day') {
1093 $query->selectRaw('DATE(created_at) as date_group')
1094 ->groupBy('date_group');
1095 } elseif ($groupingMode === '3days') {
1096 $minDateQuery = Submission::whereBetween('created_at', [$startDate, $endDate])
1097 ->selectRaw('MIN(DATE(created_at)) as min_date');
1098 self::scopeQueryToAllowedForms($minDateQuery, $formId);
1099 $minDateRecord = $minDateQuery->first();
1100
1101 if ($minDateRecord && $minDateRecord->min_date) {
1102 $query->selectRaw("MIN(DATE(created_at)) as date_group")
1103 ->selectRaw("FLOOR(DATEDIFF(DATE(created_at), ?) / 3) as group_num", [$minDateRecord->min_date])
1104 ->groupBy('group_num');
1105 } else {
1106 $query->selectRaw('DATE(created_at) as date_group')
1107 ->groupBy('date_group');
1108 }
1109 } elseif ($groupingMode === 'week') {
1110 $query->selectRaw("DATE(DATE_ADD(created_at, INTERVAL(-WEEKDAY(created_at)) DAY)) as date_group")
1111 ->groupBy('date_group');
1112 } else {
1113 $query->selectRaw("DATE_FORMAT(created_at, '%Y-%m-01') as date_group")
1114 ->groupBy('date_group');
1115 }
1116
1117 $results = $query->orderBy('date_group')->get();
1118 $total = $read = $unread = $spam = $trashed = [];
1119 foreach ($results as $result) {
1120 if ($result->status === 'read') {
1121 $read[$result->date_group] = $result->count;
1122 }
1123 if ($result->status === 'unread') {
1124 $unread[$result->date_group] = $result->count;
1125 }
1126 if ($result->status === 'spam') {
1127 $spam[$result->date_group] = $result->count;
1128 }
1129 if ($result->status === 'trashed') {
1130 $trashed[$result->date_group] = $result->count;
1131 }
1132 $total[$result->date_group] = isset($total[$result->date_group]) ? $total[$result->date_group] + $result->count : $result->count;
1133 }
1134 // Return all four datasets
1135 return [
1136 'submissions' => $total,
1137 'read' => $read,
1138 'unread' => $unread,
1139 'spam' => $spam,
1140 'trashed' => $trashed
1141 ];
1142 }
1143 }
1144
1145 /**
1146 * Generate date labels based on grouping mode
1147 */
1148 private static function getDateLabels(\DateTime $startDate, \DateTime $endDate, $groupingMode)
1149 {
1150 $dates = [];
1151 $labels = [];
1152 $current = clone $startDate;
1153
1154 if ($groupingMode === 'day') {
1155 // Generate daily labels
1156 while ($current <= $endDate) {
1157 $dateKey = $current->format('Y-m-d');
1158 $dates[] = $dateKey;
1159 $labels[] = $current->format('M d');
1160 $current->modify('+1 day');
1161 }
1162 } elseif ($groupingMode === '3days') {
1163 // Generate labels for every 3 days
1164 $dayIndex = 0;
1165 $groupStartDate = clone $current;
1166
1167 while ($current <= $endDate) {
1168 if ($dayIndex % 3 === 0 && $dayIndex > 0) {
1169 $previousDate = clone $current;
1170 $previousDate->modify('-1 day');
1171
1172 $dateKey = $groupStartDate->format('Y-m-d');
1173 $dates[] = $dateKey;
1174 $labels[] = $groupStartDate->format('M d');
1175
1176 $groupStartDate = clone $current;
1177 }
1178
1179 $current->modify('+1 day');
1180 $dayIndex++;
1181 }
1182
1183 // Add the last group if needed
1184 if ($groupStartDate <= $endDate) {
1185 $dateKey = $groupStartDate->format('Y-m-d');
1186 $dates[] = $dateKey;
1187 $labels[] = $groupStartDate->format('M d');
1188 }
1189 } elseif ($groupingMode === 'week') {
1190 // Generate weekly labels
1191 while ($current <= $endDate) {
1192 // Use simple approach to get Monday (start of week)
1193 $dayOfWeek = (int)$current->format('N'); // 1 (Monday) through 7 (Sunday)
1194 $daysToSubtract = $dayOfWeek - 1;
1195
1196 $weekStart = clone $current;
1197 if ($daysToSubtract > 0) {
1198 $weekStart->modify("-{$daysToSubtract} days");
1199 }
1200
1201 // Calculate end of week (Sunday)
1202 $weekEnd = clone $weekStart;
1203 $weekEnd->modify('+6 days');
1204
1205 // If weekend exceeds the range end, cap it
1206 if ($weekEnd > $endDate) {
1207 $weekEnd = clone $endDate;
1208 }
1209
1210 $dateKey = $weekStart->format('Y-m-d');
1211 $dates[] = $dateKey;
1212 $labels[] = $weekStart->format('M d');
1213
1214 // Move to next week
1215 $current->modify('+7 days');
1216 }
1217 } else {
1218 // Generate monthly labels
1219 while ($current <= $endDate) {
1220 $dateKey = $current->format('Y-m-01');
1221 $dates[] = $dateKey;
1222 $labels[] = $current->format('M Y');
1223
1224 // Manually move to first day of next month
1225 $year = (int)$current->format('Y');
1226 $month = (int)$current->format('m');
1227
1228 // Move to next month
1229 $month++;
1230 if ($month > 12) {
1231 $month = 1;
1232 $year++;
1233 }
1234
1235 // Set to first day of next month
1236 $current = new \DateTime("$year-$month-01");
1237 }
1238 }
1239
1240 return ['dates' => $dates, 'labels' => $labels];
1241 }
1242
1243 public static function getPaymentsByType($startDate, $endDate, $type, $formId = 0)
1244 {
1245 $paymentSettings = get_option('__fluentform_payment_module_settings');
1246 if (!$paymentSettings || !Arr::isTrue($paymentSettings, 'status')) {
1247 return []; // Return empty if payment module is disabled
1248 }
1249 list($startDate, $endDate) = self::processDateRange($startDate, $endDate);
1250
1251 // Base query for transactions
1252 $query = \FluentForm\App\Models\Transaction::whereBetween('created_at', [$startDate, $endDate]);
1253
1254 // Filter by transaction type if specified
1255 if ($type === 'subscription') {
1256 $query->whereIn('transaction_type', ['subscription', 'subscription_signup_fee']);
1257 } elseif ($type === 'onetime') {
1258 $query->where('transaction_type', 'onetime');
1259 }
1260
1261 self::scopeQueryToAllowedForms($query, $formId);
1262
1263 // Get payments grouped by status
1264 $payments = $query->select('status')
1265 ->selectRaw('SUM(payment_total) as total_amount')
1266 ->selectRaw('COUNT(*) as count')
1267 ->groupBy('status')
1268 ->get();
1269
1270 // Get the total payment amount
1271 $totalAmount = 0;
1272 foreach ($payments as $payment) {
1273 $totalAmount += $payment->total_amount;
1274 }
1275
1276 $formattedData = [];
1277 foreach ($payments as $payment) {
1278 $status = strtolower($payment->status);
1279 $amount = $payment->total_amount / 100; // Convert from cents to dollars
1280 $percentage = $totalAmount > 0 ? round(($payment->total_amount / $totalAmount) * 100, 2) : 0;
1281
1282 $formattedData[$status] = [
1283 'amount' => $amount,
1284 'percentage' => $percentage,
1285 'count' => $payment->count
1286 ];
1287 }
1288
1289 // Calculate weekly average paid amount
1290 $daysInRange = self::getDateDifference($startDate, $endDate);
1291 $weeksInRange = max(1, round($daysInRange / 7, 1));
1292
1293 $paidAmount = 0;
1294 foreach ($formattedData as $status => $data) {
1295 if ($status === 'paid') {
1296 $paidAmount = $data['amount'];
1297 break;
1298 }
1299 }
1300
1301 $weeklyAverage = $paidAmount / $weeksInRange;
1302
1303 return [
1304 'currency_symbol' => Arr::get(PaymentHelper::getCurrencyConfig($formId), 'currency_sign', '$'),
1305 'payment_statuses' => $formattedData,
1306 'total_amount' => $totalAmount / 100, // Convert from cents to dollars
1307 'weekly_average' => round($weeklyAverage, 2)
1308 ];
1309 }
1310
1311 /**
1312 * Format payment method name for display
1313 */
1314 protected static function formatPaymentMethodName($paymentMethod)
1315 {
1316 $methodNames = [
1317 'stripe' => 'Stripe',
1318 'paypal' => 'PayPal',
1319 'razorpay' => 'Razorpay',
1320 'paystack' => 'Paystack',
1321 'mollie' => 'Mollie',
1322 'square' => 'Square',
1323 'paddle' => 'Paddle',
1324 'test' => 'Offline/Test',
1325 'offline' => 'Offline'
1326 ];
1327
1328 return $methodNames[$paymentMethod] ?? ucfirst($paymentMethod);
1329 }
1330
1331 /**
1332 * Format data for the chart
1333 */
1334 private static function formatDataForChart($dateLabels, $data, $formId)
1335 {
1336 $dates = $dateLabels['dates'];
1337 $labels = $dateLabels['labels'];
1338
1339 if (is_array($data) && isset($data['paid'])) {
1340 $paidValues = self::fillMissingData($dates, $data['paid']);
1341 $pendingValues = self::fillMissingData($dates, $data['pending']);
1342 $refundedValues = self::fillMissingData($dates, $data['refunded']);
1343 $paymentsValues = self::fillMissingData($dates, $data['payments']);
1344 $currencyConfig = PaymentHelper::getCurrencyConfig($formId);
1345
1346 return [
1347 'dates' => $labels,
1348 'currency_sign' => Arr::get($currencyConfig, 'currency_sign', '$'),
1349 'currency' => Arr::get($currencyConfig, 'currency', 'USD'),
1350 'values' => [
1351 'paid' => array_values($paidValues),
1352 'pending' => array_values($pendingValues),
1353 'refunded' => array_values($refundedValues),
1354 'payments' => array_values($paymentsValues)
1355 ]
1356 ];
1357 } else {
1358 return [
1359 'dates' => $labels,
1360 'values' => [
1361 'submissions' => array_values(self::fillMissingData($dates, $data['submissions'])),
1362 'read' => array_values(self::fillMissingData($dates, $data['read'])),
1363 'unread' => array_values(self::fillMissingData($dates, $data['unread'])),
1364 'spam' => array_values(self::fillMissingData($dates, $data['spam'])),
1365 'trashed' => array_values(self::fillMissingData($dates, $data['trashed']))
1366 ]
1367 ];
1368 }
1369 }
1370
1371 /**
1372 * Fill in missing data based on date intervals
1373 *
1374 * @param array $allDates Array of interval start dates
1375 * @param array $data Associative array of date => value pairs
1376 *
1377 * @return array Result with interval start dates mapped to summed values
1378 */
1379 private static function fillMissingData($allDates, $data)
1380 {
1381 $result = [];
1382
1383 // Pre-convert dates to timestamps for faster comparison
1384 $dataTimestamps = [];
1385 foreach ($data as $date => $value) {
1386 $dataTimestamps[strtotime($date)] = $value;
1387 }
1388
1389 $allDatesCount = count($allDates);
1390 for ($i = 0; $i < $allDatesCount; $i++) {
1391 $startTimestamp = strtotime($allDates[$i]);
1392
1393 // Calculate end timestamp of interval (exclusive)
1394 $endTimestamp = isset($allDates[$i + 1])
1395 ? strtotime($allDates[$i + 1])
1396 : $startTimestamp + (3 * 24 * 60 * 60); // 3 days in seconds
1397
1398 $sum = 0;
1399 $hasData = false;
1400
1401 // Check each timestamp in the data array
1402 foreach ($dataTimestamps as $timestamp => $value) {
1403 if ($timestamp >= $startTimestamp && $timestamp < $endTimestamp) {
1404 $sum += $value;
1405 $hasData = true;
1406 }
1407 }
1408
1409 $result[$allDates[$i]] = $hasData ? $sum : 0;
1410 }
1411
1412 return $result;
1413 }
1414
1415 private static function getPaymentStats($startDate, $endDate, $previousStartDate, $previousEndDate, $formId)
1416 {
1417 // Get total payments (paid status) for current period
1418 $currentPaymentsQuery = wpFluent()
1419 ->table('fluentform_transactions')
1420 ->whereBetween('created_at', [$startDate, $endDate])
1421 ->where('status', 'paid');
1422 self::scopeQueryToAllowedForms($currentPaymentsQuery, $formId);
1423 $currentPayments = $currentPaymentsQuery->sum('payment_total');
1424
1425 // Get total payments for previous period
1426 $previousPaymentsQuery = wpFluent()
1427 ->table('fluentform_transactions')
1428 ->whereBetween('created_at', [$previousStartDate, $previousEndDate])
1429 ->where('status', 'paid');
1430 self::scopeQueryToAllowedForms($previousPaymentsQuery, $formId);
1431 $previousPayments = $previousPaymentsQuery->sum('payment_total');
1432
1433 // Get pending payments for current period
1434 $currentPendingQuery = wpFluent()
1435 ->table('fluentform_transactions')
1436 ->whereBetween('created_at', [$startDate, $endDate])
1437 ->where('status', 'pending');
1438 self::scopeQueryToAllowedForms($currentPendingQuery, $formId);
1439 $currentPending = $currentPendingQuery->sum('payment_total');
1440
1441 // Get pending payments for previous period
1442 $previousPendingQuery = wpFluent()
1443 ->table('fluentform_transactions')
1444 ->whereBetween('created_at', [$previousStartDate, $previousEndDate])
1445 ->where('status', 'pending');
1446 self::scopeQueryToAllowedForms($previousPendingQuery, $formId);
1447 $previousPending = $previousPendingQuery->sum('payment_total');
1448
1449 // Get total refunds for current period
1450 $currentRefundsQuery = wpFluent()
1451 ->table('fluentform_transactions')
1452 ->whereBetween('created_at', [$startDate, $endDate])
1453 ->where('status', 'refunded');
1454 self::scopeQueryToAllowedForms($currentRefundsQuery, $formId);
1455 $currentRefunds = $currentRefundsQuery->sum('payment_total');
1456
1457 // Get total refunds for previous period
1458 $previousRefundsQuery = wpFluent()
1459 ->table('fluentform_transactions')
1460 ->whereBetween('created_at', [$previousStartDate, $previousEndDate])
1461 ->where('status', 'refunded');
1462 self::scopeQueryToAllowedForms($previousRefundsQuery, $formId);
1463 $previousRefunds = $previousRefundsQuery->sum('payment_total');
1464
1465 // Convert from cents to dollars
1466 $currentPayments = $currentPayments ? $currentPayments / 100 : 0;
1467 $previousPayments = $previousPayments ? $previousPayments / 100 : 0;
1468 $currentPending = $currentPending ? $currentPending / 100 : 0;
1469 $previousPending = $previousPending ? $previousPending / 100 : 0;
1470 $currentRefunds = $currentRefunds ? $currentRefunds / 100 : 0;
1471 $previousRefunds = $previousRefunds ? $previousRefunds / 100 : 0;
1472
1473 // Calculate payment growth percentage
1474 $paymentGrowthPercentage = 0;
1475 if ($previousPayments > 0) {
1476 $paymentGrowthPercentage = round((($currentPayments - $previousPayments) / $previousPayments) * 100, 1);
1477 } elseif ($currentPayments > 0) {
1478 $paymentGrowthPercentage = 100;
1479 }
1480
1481 $paymentGrowthText = $paymentGrowthPercentage > 0 ? '+' . $paymentGrowthPercentage . '%' : $paymentGrowthPercentage . '%';
1482 $paymentGrowthType = $paymentGrowthPercentage > 0 ? 'up' : ($paymentGrowthPercentage < 0 ? 'down' : 'neutral');
1483
1484 // Calculate refund growth percentage
1485 $refundGrowthPercentage = 0;
1486 if ($previousRefunds > 0) {
1487 $refundGrowthPercentage = round((($currentRefunds - $previousRefunds) / $previousRefunds) * 100, 1);
1488 } elseif ($currentRefunds > 0) {
1489 $refundGrowthPercentage = 100;
1490 }
1491
1492 $refundGrowthText = $refundGrowthPercentage > 0 ? '+' . $refundGrowthPercentage . '%' : $refundGrowthPercentage . '%';
1493 $refundGrowthType = $refundGrowthPercentage > 0 ? 'down' : ($refundGrowthPercentage < 0 ? 'up' : 'neutral'); // Refunds going up is bad
1494
1495 // Calculate pending growth percentage
1496 $pendingGrowthPercentage = 0;
1497 if ($previousPending > 0) {
1498 $pendingGrowthPercentage = round((($currentPending - $previousPending) / $previousPending) * 100, 1);
1499 } elseif ($currentPending > 0) {
1500 $pendingGrowthPercentage = 100;
1501 }
1502
1503 $pendingGrowthText = $pendingGrowthPercentage > 0 ? '+' . $pendingGrowthPercentage . '%' : $pendingGrowthPercentage . '%';
1504 $pendingGrowthType = $pendingGrowthPercentage > 0 ? 'up' : ($pendingGrowthPercentage < 0 ? 'down' : 'neutral');
1505
1506 // Calculate revenue percentage
1507 $totalRevenue = $currentPayments - $currentRefunds;
1508 $previousRevenue = $previousPayments - $previousRefunds;
1509 $revenuePercentage = 0;
1510 if ($previousRevenue > 0) {
1511 $revenuePercentage = round((($totalRevenue - $previousRevenue) / $previousRevenue) * 100, 1);
1512 } elseif ($totalRevenue > 0) {
1513 $revenuePercentage = 100;
1514 }
1515 $revenueText = $revenuePercentage > 0 ? '+' . $revenuePercentage . '%' : $revenuePercentage . '%';
1516 $revenueType = $revenuePercentage > 0 ? 'up' : ($revenuePercentage < 0 ? 'down' : 'neutral');
1517
1518 // Get default currency from payment settings
1519 $paymentSettings = PaymentHelper::getPaymentSettings();
1520 $currency = Arr::get($paymentSettings, 'currency', 'USD');
1521 $currencySymbol = PaymentHelper::getCurrencySymbol($currency);
1522
1523 return [
1524 'total_payments' => [
1525 'value' => number_format($currentPayments, 2),
1526 'raw_value' => $currentPayments,
1527 'currency' => $currency,
1528 'currency_symbol' => $currencySymbol,
1529 'change' => $paymentGrowthText,
1530 'change_type' => $paymentGrowthType
1531 ],
1532 'pending_payments' => [
1533 'value' => number_format($currentPending, 2),
1534 'raw_value' => $currentPending,
1535 'currency' => $currency,
1536 'currency_symbol' => $currencySymbol,
1537 'change' => $pendingGrowthText,
1538 'change_type' => $pendingGrowthType
1539 ],
1540 'total_refunds' => [
1541 'value' => number_format($currentRefunds, 2),
1542 'raw_value' => $currentRefunds,
1543 'currency' => $currency,
1544 'currency_symbol' => $currencySymbol,
1545 'change' => $refundGrowthText,
1546 'change_type' => $refundGrowthType
1547 ],
1548 'total_revenue' => [
1549 'value' => number_format($totalRevenue, 2),
1550 'raw_value' => $totalRevenue,
1551 'change' => $revenueText,
1552 'change_type' => $revenueType,
1553 'currency' => $currency,
1554 'currency_symbol' => $currencySymbol
1555 ]
1556 ];
1557 }
1558
1559
1560 protected static function getDateDifference($startDate, $endDate)
1561 {
1562 $start = new \DateTime($startDate);
1563 $end = new \DateTime($endDate);
1564 $interval = $start->diff($end);
1565 return $interval->days + 1;
1566 }
1567 }
1568