diff --git a/api/admin/quickTasks/create.php b/api/admin/quickTasks/create.php new file mode 100644 index 0000000..e09a792 --- /dev/null +++ b/api/admin/quickTasks/create.php @@ -0,0 +1,52 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} +if ($templateID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'QuickTaskTemplateID is required']); +} + +try { + $template = queryOne(" + SELECT Title, Details, TaskCategoryID AS CategoryID + FROM QuickTaskTemplates + WHERE ID = ? AND BusinessID = ? AND IsActive = 1 + ", [$templateID, $businessID]); + + if (!$template) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Template not found']); + } + + queryTimed(" + INSERT INTO Tasks (BusinessID, CategoryID, TaskTypeID, Title, Details, CreatedOn, ClaimedByUserID) + VALUES (?, ?, 0, ?, ?, NOW(), 0) + ", [ + $businessID, + $template['CategoryID'], + $template['Title'], + $template['Details'], + ]); + + $taskID = (int) lastInsertId(); + + jsonResponse([ + 'OK' => true, + 'TASK_ID' => $taskID, + 'MESSAGE' => 'Task created', + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/admin/quickTasks/delete.php b/api/admin/quickTasks/delete.php new file mode 100644 index 0000000..6ec093d --- /dev/null +++ b/api/admin/quickTasks/delete.php @@ -0,0 +1,33 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} +if ($templateID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'QuickTaskTemplateID is required']); +} + +try { + $existing = queryOne("SELECT ID FROM QuickTaskTemplates WHERE ID = ? AND BusinessID = ?", [$templateID, $businessID]); + if (!$existing) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Template not found']); + } + + queryTimed("UPDATE QuickTaskTemplates SET IsActive = 0 WHERE ID = ?", [$templateID]); + + jsonResponse(['OK' => true, 'MESSAGE' => 'Template deleted']); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/admin/quickTasks/list.php b/api/admin/quickTasks/list.php new file mode 100644 index 0000000..19852b0 --- /dev/null +++ b/api/admin/quickTasks/list.php @@ -0,0 +1,63 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $rows = queryTimed(" + SELECT + qt.ID, + qt.Name, + qt.TaskCategoryID AS CategoryID, + qt.Title, + qt.Details, + qt.Icon, + qt.Color, + qt.SortOrder, + qt.IsActive, + tc.Name AS CategoryName, + tc.Color AS CategoryColor + FROM QuickTaskTemplates qt + LEFT JOIN TaskCategories tc ON qt.TaskCategoryID = tc.ID + WHERE qt.BusinessID = ? + AND qt.IsActive = 1 + ORDER BY qt.SortOrder, qt.ID + ", [$businessID]); + + $templates = []; + foreach ($rows as $row) { + $templates[] = [ + 'QuickTaskTemplateID' => (int) $row['ID'], + 'Name' => $row['Name'], + 'CategoryID' => $row['CategoryID'] ?? '', + 'Title' => $row['Title'], + 'Details' => $row['Details'] ?? '', + 'Icon' => $row['Icon'] ?? 'add_box', + 'Color' => $row['Color'] ?? '#6366f1', + 'SortOrder' => (int) $row['SortOrder'], + 'IsActive' => (int) $row['IsActive'], + 'CategoryName' => $row['CategoryName'] ?? '', + 'CategoryColor' => $row['CategoryColor'] ?? '', + ]; + } + + jsonResponse([ + 'OK' => true, + 'TEMPLATES' => $templates, + 'COUNT' => count($templates), + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/admin/quickTasks/save.php b/api/admin/quickTasks/save.php new file mode 100644 index 0000000..a70911a --- /dev/null +++ b/api/admin/quickTasks/save.php @@ -0,0 +1,72 @@ + 0) ? (int) $data['CategoryID'] : null; +$icon = !empty(trim($data['Icon'] ?? '')) ? trim($data['Icon']) : 'add_box'; +$color = !empty(trim($data['Color'] ?? '')) ? trim($data['Color']) : '#6366f1'; + +if ($businessID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} +if ($name === '') { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'Name is required']); +} +if ($title === '') { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'Title is required']); +} +if ($categoryID === null) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'Please select a category']); +} + +try { + if ($templateID > 0) { + // UPDATE + $existing = queryOne("SELECT ID FROM QuickTaskTemplates WHERE ID = ? AND BusinessID = ?", [$templateID, $businessID]); + if (!$existing) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Template not found']); + } + + queryTimed(" + UPDATE QuickTaskTemplates SET + Name = ?, Title = ?, Details = ?, TaskCategoryID = ?, Icon = ?, Color = ? + WHERE ID = ? + ", [$name, $title, $details ?: null, $categoryID, $icon, $color, $templateID]); + + jsonResponse([ + 'OK' => true, + 'TEMPLATE_ID' => $templateID, + 'MESSAGE' => 'Template updated', + ]); + } else { + // INSERT + $nextSort = queryOne("SELECT COALESCE(MAX(SortOrder), 0) + 1 AS nextSort FROM QuickTaskTemplates WHERE BusinessID = ?", [$businessID]); + + queryTimed(" + INSERT INTO QuickTaskTemplates (BusinessID, Name, Title, Details, TaskCategoryID, Icon, Color, SortOrder) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ", [$businessID, $name, $title, $details ?: null, $categoryID, $icon, $color, (int) $nextSort['nextSort']]); + + $newID = (int) lastInsertId(); + + jsonResponse([ + 'OK' => true, + 'TEMPLATE_ID' => $newID, + 'MESSAGE' => 'Template created', + ]); + } + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/admin/scheduledTasks/_cronUtils.php b/api/admin/scheduledTasks/_cronUtils.php new file mode 100644 index 0000000..ecb030f --- /dev/null +++ b/api/admin/scheduledTasks/_cronUtils.php @@ -0,0 +1,59 @@ +modify('+1 minute'); + $check->setTime((int) $check->format('H'), (int) $check->format('i'), 0); + + $maxIterations = 400 * 24 * 60; + for ($i = 0; $i < $maxIterations; $i++) { + $m = (int) $check->format('i'); + $h = (int) $check->format('G'); + $d = (int) $check->format('j'); + $mo = (int) $check->format('n'); + $dow = (int) $check->format('w'); // 0=Sunday + + $matchMinute = ($cronMinute === '*' || (is_numeric($cronMinute) && $m === (int) $cronMinute)); + $matchHour = ($cronHour === '*' || (is_numeric($cronHour) && $h === (int) $cronHour)); + $matchDay = ($cronDay === '*' || (is_numeric($cronDay) && $d === (int) $cronDay)); + $matchMonth = ($cronMonth === '*' || (is_numeric($cronMonth) && $mo === (int) $cronMonth)); + + $matchWeekday = ($cronWeekday === '*'); + if (!$matchWeekday) { + if (str_contains($cronWeekday, '-')) { + $range = explode('-', $cronWeekday); + if (count($range) === 2 && is_numeric($range[0]) && is_numeric($range[1])) { + $matchWeekday = ($dow >= (int) $range[0] && $dow <= (int) $range[1]); + } + } elseif (is_numeric($cronWeekday)) { + $matchWeekday = ($dow === (int) $cronWeekday); + } + } + + if ($matchMinute && $matchHour && $matchDay && $matchMonth && $matchWeekday) { + return $check; + } + + $check->modify('+1 minute'); + } + + // Fallback + return new DateTime('+1 day', new DateTimeZone('UTC')); +} diff --git a/api/admin/scheduledTasks/delete.php b/api/admin/scheduledTasks/delete.php new file mode 100644 index 0000000..5db7990 --- /dev/null +++ b/api/admin/scheduledTasks/delete.php @@ -0,0 +1,33 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} +if ($taskID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'ScheduledTaskID is required']); +} + +try { + $existing = queryOne("SELECT ID FROM ScheduledTaskDefinitions WHERE ID = ? AND BusinessID = ?", [$taskID, $businessID]); + if (!$existing) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Scheduled task not found']); + } + + queryTimed("DELETE FROM ScheduledTaskDefinitions WHERE ID = ?", [$taskID]); + + jsonResponse(['OK' => true, 'MESSAGE' => 'Scheduled task deleted']); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/admin/scheduledTasks/list.php b/api/admin/scheduledTasks/list.php new file mode 100644 index 0000000..7b011d5 --- /dev/null +++ b/api/admin/scheduledTasks/list.php @@ -0,0 +1,68 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} + +try { + $rows = queryTimed(" + SELECT + st.ID, + st.Name, + st.TaskCategoryID AS CategoryID, + st.Title, + st.Details, + st.CronExpression, + COALESCE(st.ScheduleType, 'cron') AS ScheduleType, + st.IntervalMinutes, + st.IsActive, + st.LastRunOn, + st.NextRunOn, + st.CreatedOn, + tc.Name AS CategoryName, + tc.Color AS CategoryColor + FROM ScheduledTaskDefinitions st + LEFT JOIN TaskCategories tc ON st.TaskCategoryID = tc.ID + WHERE st.BusinessID = ? + ORDER BY st.IsActive DESC, st.Name + ", [$businessID]); + + $scheduledTasks = []; + foreach ($rows as $row) { + $scheduledTasks[] = [ + 'ScheduledTaskID' => (int) $row['ID'], + 'Name' => $row['Name'], + 'CategoryID' => $row['CategoryID'] ?? '', + 'Title' => $row['Title'], + 'Details' => $row['Details'] ?? '', + 'CronExpression' => $row['CronExpression'], + 'ScheduleType' => $row['ScheduleType'], + 'IntervalMinutes' => $row['IntervalMinutes'] ?? '', + 'IsActive' => (bool) $row['IsActive'], + 'LastRunOn' => $row['LastRunOn'] ?? '', + 'NextRunOn' => $row['NextRunOn'] ?? '', + 'CreatedOn' => $row['CreatedOn'], + 'CategoryName' => $row['CategoryName'] ?? '', + 'CategoryColor' => $row['CategoryColor'] ?? '', + ]; + } + + jsonResponse([ + 'OK' => true, + 'SCHEDULED_TASKS' => $scheduledTasks, + 'COUNT' => count($scheduledTasks), + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/admin/scheduledTasks/run.php b/api/admin/scheduledTasks/run.php new file mode 100644 index 0000000..3ce6bf0 --- /dev/null +++ b/api/admin/scheduledTasks/run.php @@ -0,0 +1,47 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} +if ($scheduledTaskID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'ScheduledTaskID is required']); +} + +try { + $def = queryOne(" + SELECT Title, Details, TaskCategoryID AS CategoryID + FROM ScheduledTaskDefinitions + WHERE ID = ? AND BusinessID = ? + ", [$scheduledTaskID, $businessID]); + + if (!$def) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Scheduled task not found']); + } + + queryTimed(" + INSERT INTO Tasks (BusinessID, CategoryID, TaskTypeID, Title, Details, CreatedOn, ClaimedByUserID) + VALUES (?, ?, 0, ?, ?, NOW(), 0) + ", [$businessID, $def['CategoryID'], $def['Title'], $def['Details']]); + + $taskID = (int) lastInsertId(); + + jsonResponse([ + 'OK' => true, + 'TASK_ID' => $taskID, + 'MESSAGE' => 'Task created from scheduled task (manual trigger)', + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/admin/scheduledTasks/runDue.php b/api/admin/scheduledTasks/runDue.php new file mode 100644 index 0000000..7e183d1 --- /dev/null +++ b/api/admin/scheduledTasks/runDue.php @@ -0,0 +1,79 @@ + 0) { + $nextRunStr = null; // Paused until task completion + } elseif ($st === 'interval' && $interval > 0) { + $nextRun = new DateTime("+{$interval} minutes", new DateTimeZone('UTC')); + $nextRunStr = $nextRun->format('Y-m-d H:i:s'); + } else { + $nextRun = calculateNextRun($task['CronExpression']); + $nextRunStr = $nextRun->format('Y-m-d H:i:s'); + } + + queryTimed(" + UPDATE ScheduledTaskDefinitions SET LastRunOn = NOW(), NextRunOn = ? + WHERE ID = ? + ", [$nextRunStr, $task['ScheduledTaskID']]); + + $createdTasks[] = [ + 'ScheduledTaskID' => (int) $task['ScheduledTaskID'], + 'TaskID' => $newTaskID, + 'BusinessID' => (int) $task['BusinessID'], + 'Title' => $task['Title'], + ]; + } + + jsonResponse([ + 'OK' => true, + 'MESSAGE' => 'Processed ' . count($createdTasks) . ' scheduled task(s)', + 'CREATED_TASKS' => $createdTasks, + 'CHECKED_COUNT' => count($dueTasks), + 'RAN_AT' => gmdate('Y-m-d H:i:s'), + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/admin/scheduledTasks/save.php b/api/admin/scheduledTasks/save.php new file mode 100644 index 0000000..e650ff3 --- /dev/null +++ b/api/admin/scheduledTasks/save.php @@ -0,0 +1,127 @@ + 0) ? (int) $data['CategoryID'] : null; +$cronExpression = trim($data['CronExpression'] ?? ''); +$isActive = isset($data['IsActive']) ? ($data['IsActive'] ? 1 : 0) : 1; +$scheduleType = trim($data['ScheduleType'] ?? 'cron'); +$intervalMinutes = (isset($data['IntervalMinutes']) && is_numeric($data['IntervalMinutes'])) ? (int) $data['IntervalMinutes'] : null; + +if ($businessID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} +if ($taskName === '') { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'Name is required']); +} +if ($taskTitle === '') { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'Title is required']); +} + +try { + // Determine next run based on schedule type + if ($scheduleType === 'interval' || $scheduleType === 'interval_after_completion') { + if ($intervalMinutes === null || $intervalMinutes < 1) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'IntervalMinutes is required for interval scheduling (minimum 1)']); + } + if ($cronExpression === '') { + $cronExpression = '* * * * *'; + } + $nextRunOn = ($taskID === 0) + ? new DateTime('now', new DateTimeZone('UTC')) + : new DateTime("+{$intervalMinutes} minutes", new DateTimeZone('UTC')); + } else { + if ($cronExpression === '') { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'CronExpression is required']); + } + $cronParts = preg_split('/\s+/', $cronExpression); + if (count($cronParts) !== 5) { + apiAbort(['OK' => false, 'ERROR' => 'invalid_cron', 'MESSAGE' => 'Cron expression must have 5 parts: minute hour day month weekday']); + } + $nextRunOn = calculateNextRun($cronExpression); + } + + $nextRunStr = $nextRunOn->format('Y-m-d H:i:s'); + + if ($taskID > 0) { + // UPDATE + $existing = queryOne("SELECT ID FROM ScheduledTaskDefinitions WHERE ID = ? AND BusinessID = ?", [$taskID, $businessID]); + if (!$existing) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Scheduled task not found']); + } + + queryTimed(" + UPDATE ScheduledTaskDefinitions SET + Name = ?, Title = ?, Details = ?, TaskCategoryID = ?, + CronExpression = ?, ScheduleType = ?, IntervalMinutes = ?, + IsActive = ?, NextRunOn = ? + WHERE ID = ? + ", [$taskName, $taskTitle, $taskDetails ?: null, $categoryID, $cronExpression, $scheduleType, $intervalMinutes, $isActive, $nextRunStr, $taskID]); + + jsonResponse([ + 'OK' => true, + 'SCHEDULED_TASK_ID' => $taskID, + 'NEXT_RUN' => $nextRunStr, + 'MESSAGE' => 'Scheduled task updated', + ]); + } else { + // INSERT + queryTimed(" + INSERT INTO ScheduledTaskDefinitions ( + BusinessID, Name, Title, Details, TaskCategoryID, + CronExpression, ScheduleType, IntervalMinutes, IsActive, NextRunOn + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ", [$businessID, $taskName, $taskTitle, $taskDetails ?: null, $categoryID, $cronExpression, $scheduleType, $intervalMinutes, $isActive, $nextRunStr]); + + $newScheduledTaskID = (int) lastInsertId(); + + // Create the first task immediately + queryTimed(" + INSERT INTO Tasks (BusinessID, CategoryID, TaskTypeID, Title, Details, CreatedOn, ClaimedByUserID) + VALUES (?, ?, 0, ?, ?, NOW(), 0) + ", [$businessID, $categoryID, $taskTitle, $taskDetails ?: null]); + + $firstTaskID = (int) lastInsertId(); + + // Calculate actual next run (after the immediate one) + if ($scheduleType === 'interval_after_completion') { + $actualNextRunStr = null; // Don't schedule until task completion + } elseif ($scheduleType === 'interval') { + $actualNextRun = new DateTime("+{$intervalMinutes} minutes", new DateTimeZone('UTC')); + $actualNextRunStr = $actualNextRun->format('Y-m-d H:i:s'); + } else { + $actualNextRun = calculateNextRun($cronExpression); + $actualNextRunStr = $actualNextRun->format('Y-m-d H:i:s'); + } + + queryTimed(" + UPDATE ScheduledTaskDefinitions SET LastRunOn = NOW(), NextRunOn = ? + WHERE ID = ? + ", [$actualNextRunStr, $newScheduledTaskID]); + + jsonResponse([ + 'OK' => true, + 'SCHEDULED_TASK_ID' => $newScheduledTaskID, + 'TASK_ID' => $firstTaskID, + 'NEXT_RUN' => $actualNextRunStr ?? '', + 'MESSAGE' => 'Scheduled task created and first task added', + ]); + } + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/api/admin/scheduledTasks/toggle.php b/api/admin/scheduledTasks/toggle.php new file mode 100644 index 0000000..b459c17 --- /dev/null +++ b/api/admin/scheduledTasks/toggle.php @@ -0,0 +1,60 @@ + false, 'ERROR' => 'missing_params', 'MESSAGE' => 'BusinessID is required']); +} +if ($taskID <= 0) { + apiAbort(['OK' => false, 'ERROR' => 'missing_params', 'MESSAGE' => 'ScheduledTaskID is required']); +} + +try { + $existing = queryOne(" + SELECT ID, CronExpression, COALESCE(ScheduleType, 'cron') AS ScheduleType, IntervalMinutes + FROM ScheduledTaskDefinitions + WHERE ID = ? AND BusinessID = ? + ", [$taskID, $businessID]); + + if (!$existing) { + apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Scheduled task not found']); + } + + if ($isActive) { + // Recalculate next run time based on schedule type + $st = $existing['ScheduleType']; + $interval = (int) ($existing['IntervalMinutes'] ?? 0); + + if (($st === 'interval' || $st === 'interval_after_completion') && $interval > 0) { + $nextRun = new DateTime("+{$interval} minutes", new DateTimeZone('UTC')); + } else { + $nextRun = calculateNextRun($existing['CronExpression']); + } + + queryTimed("UPDATE ScheduledTaskDefinitions SET IsActive = 1, NextRunOn = ? WHERE ID = ?", [ + $nextRun->format('Y-m-d H:i:s'), + $taskID, + ]); + } else { + queryTimed("UPDATE ScheduledTaskDefinitions SET IsActive = 0 WHERE ID = ?", [$taskID]); + } + + jsonResponse([ + 'OK' => true, + 'MESSAGE' => $isActive ? 'Scheduled task enabled' : 'Scheduled task disabled', + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/cron/expireStaleChats.php b/cron/expireStaleChats.php new file mode 100644 index 0000000..46d8371 --- /dev/null +++ b/cron/expireStaleChats.php @@ -0,0 +1,53 @@ +getTimestamp() - $lastMsg->getTimestamp()) / 60; + if ($diffMinutes > 20) { + $shouldExpire = true; + } + } + + if ($shouldExpire) { + queryTimed("UPDATE Tasks SET CompletedOn = NOW() WHERE ID = ?", [$chat['ID']]); + $expiredCount++; + $expiredIds[] = (int) $chat['ID']; + } + } + + jsonResponse([ + 'OK' => true, + 'MESSAGE' => "Expired {$expiredCount} stale chat(s)", + 'EXPIRED_TASK_IDS' => $expiredIds, + 'CHECKED_COUNT' => count($staleChats), + ]); + +} catch (Exception $e) { + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/cron/expireTabs.php b/cron/expireTabs.php new file mode 100644 index 0000000..d034962 --- /dev/null +++ b/cron/expireTabs.php @@ -0,0 +1,193 @@ +30 min old) + */ + +try { + $config = getStripeConfig(); + $stripeSecretKey = $config['secretKey'] ?? ''; + if ($stripeSecretKey === '') { + error_log('[tab_cron] FATAL: stripeSecretKey not available. Aborting.'); + jsonResponse(['OK' => false, 'ERROR' => 'no_stripe_key']); + } + + $expiredCount = 0; + $capturedCount = 0; + $cancelledCount = 0; + $presenceCleaned = 0; + + // 1. Find open tabs that have been idle beyond their business lock duration + $idleTabs = queryTimed(" + SELECT t.ID, t.StripePaymentIntentID, t.AuthAmountCents, t.RunningTotalCents, + t.OwnerUserID, t.BusinessID, + b.SessionLockMinutes, b.PayfritFee, b.StripeAccountID, b.StripeOnboardingComplete + FROM Tabs t + JOIN Businesses b ON b.ID = t.BusinessID + WHERE t.StatusID = 1 + AND t.LastActivityOn < DATE_SUB(NOW(), INTERVAL COALESCE(b.SessionLockMinutes, 30) MINUTE) + "); + + foreach ($idleTabs as $tab) { + try { + // Re-verify tab is still open + $recheck = queryOne("SELECT StatusID FROM Tabs WHERE ID = ? LIMIT 1", [$tab['ID']]); + if (!$recheck || (int) $recheck['StatusID'] !== 1) { + error_log("[tab_cron] Tab #{$tab['ID']} no longer open, skipping."); + continue; + } + + // Check PI state on Stripe + $piData = stripeRequest('GET', "https://api.stripe.com/v1/payment_intents/{$tab['StripePaymentIntentID']}"); + + if (empty($piData['status'])) { + error_log("[tab_cron] Tab #{$tab['ID']} cannot read PI status. Skipping."); + continue; + } + + $piStatus = $piData['status']; + error_log("[tab_cron] Tab #{$tab['ID']} PI status: {$piStatus}"); + + if ($piStatus === 'canceled') { + queryTimed(" + UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled', + PaymentError = 'PI already cancelled on Stripe' + WHERE ID = ? AND StatusID = 1 + ", [$tab['ID']]); + $cancelledCount++; + + } elseif ($piStatus !== 'requires_capture') { + // Cancel the PI since it's not in a capturable state + stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/{$tab['StripePaymentIntentID']}/cancel"); + + queryTimed(" + UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled', + PaymentError = ? + WHERE ID = ? AND StatusID = 1 + ", ["PI never confirmed (status: {$piStatus})", $tab['ID']]); + $cancelledCount++; + + } else { + // PI is requires_capture — capture or cancel + $qOrders = queryOne(" + SELECT COALESCE(SUM(SubtotalCents), 0) AS TotalSubtotal, + COALESCE(SUM(TaxCents), 0) AS TotalTax, + COUNT(*) AS OrderCount + FROM TabOrders + WHERE TabID = ? AND ApprovalStatus = 'approved' + ", [$tab['ID']]); + + if ((int) $qOrders['OrderCount'] === 0 || (int) $qOrders['TotalSubtotal'] === 0) { + // No orders — cancel PI and release hold + $cancelData = stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/{$tab['StripePaymentIntentID']}/cancel"); + + if (($cancelData['status'] ?? '') === 'canceled') { + queryTimed(" + UPDATE Tabs SET StatusID = 5, ClosedOn = NOW(), PaymentStatus = 'cancelled' + WHERE ID = ? AND StatusID = 1 + ", [$tab['ID']]); + $cancelledCount++; + } else { + $errMsg = $cancelData['error']['message'] ?? 'Cancel failed'; + queryTimed("UPDATE Tabs SET PaymentStatus = 'cancel_failed', PaymentError = ? WHERE ID = ?", [$errMsg, $tab['ID']]); + error_log("[tab_cron] Tab #{$tab['ID']} cancel FAILED: {$errMsg}"); + continue; + } + + } else { + // Has orders — capture with 0% tip (auto-close) + $payfritFee = is_numeric($tab['PayfritFee']) ? (float) $tab['PayfritFee'] : 0.05; + $totalSubtotal = (int) $qOrders['TotalSubtotal']; + $totalTax = (int) $qOrders['TotalTax']; + $platformFee = (int) round($totalSubtotal * $payfritFee); + $totalBeforeCard = $totalSubtotal + $totalTax + $platformFee; + $cardFeeCents = (int) round(($totalBeforeCard + 30) / (1 - 0.029)) - $totalBeforeCard; + $captureCents = $totalBeforeCard + $cardFeeCents; + $applicationFeeCents = $platformFee * 2; + + // Cap at authorized amount + if ($captureCents > (int) $tab['AuthAmountCents']) { + $captureCents = (int) $tab['AuthAmountCents']; + if (($totalBeforeCard + $cardFeeCents) > 0) { + $applicationFeeCents = (int) round($applicationFeeCents * ($captureCents / ($totalBeforeCard + $cardFeeCents))); + } + } + + // Mark as closing (prevents race) + $closing = queryTimed("UPDATE Tabs SET StatusID = 2 WHERE ID = ? AND StatusID = 1", [$tab['ID']]); + if ($closing->rowCount() === 0) { + error_log("[tab_cron] Tab #{$tab['ID']} already being closed. Skipping."); + continue; + } + + $captureParams = [ + 'amount_to_capture' => $captureCents, + 'metadata[type]' => 'tab_auto_close', + 'metadata[tab_id]' => $tab['ID'], + ]; + if (!empty(trim($tab['StripeAccountID'] ?? '')) && (int) ($tab['StripeOnboardingComplete'] ?? 0) === 1) { + $captureParams['application_fee_amount'] = $applicationFeeCents; + } + + $captureData = stripeRequest('POST', "https://api.stripe.com/v1/payment_intents/{$tab['StripePaymentIntentID']}/capture", $captureParams); + + if (($captureData['status'] ?? '') === 'succeeded') { + queryTimed(" + UPDATE Tabs SET StatusID = 3, ClosedOn = NOW(), PaymentStatus = 'captured', + CapturedOn = NOW(), FinalCaptureCents = ?, TipAmountCents = 0 + WHERE ID = ? + ", [$captureCents, $tab['ID']]); + + queryTimed(" + UPDATE Orders SET PaymentStatus = 'paid', PaymentCompletedOn = NOW() + WHERE ID IN (SELECT OrderID FROM TabOrders WHERE TabID = ? AND ApprovalStatus = 'approved') + ", [$tab['ID']]); + + $capturedCount++; + error_log("[tab_cron] Tab #{$tab['ID']} auto-closed. Captured {$captureCents} cents (fee={$applicationFeeCents})."); + } else { + $errMsg = $captureData['error']['message'] ?? 'Capture failed'; + queryTimed("UPDATE Tabs SET StatusID = 1, PaymentStatus = 'capture_failed', PaymentError = ? WHERE ID = ?", [$errMsg, $tab['ID']]); + error_log("[tab_cron] Tab #{$tab['ID']} capture FAILED: {$errMsg}. Tab reverted to open."); + continue; + } + } + } + + // Release all members + queryTimed("UPDATE TabMembers SET StatusID = 3, LeftOn = NOW() WHERE TabID = ? AND StatusID = 1", [$tab['ID']]); + + // Reject pending orders + queryTimed("UPDATE TabOrders SET ApprovalStatus = 'rejected' WHERE TabID = ? AND ApprovalStatus = 'pending'", [$tab['ID']]); + + $expiredCount++; + + } catch (Exception $tabErr) { + error_log("[tab_cron] Error expiring tab #{$tab['ID']}: {$tabErr->getMessage()}"); + } + } + + // 2. Clean stale presence records (>30 min) + $cleanStmt = queryTimed("DELETE FROM UserPresence WHERE LastSeenOn < DATE_SUB(NOW(), INTERVAL 30 MINUTE)"); + $presenceCleaned = $cleanStmt->rowCount(); + + jsonResponse([ + 'OK' => true, + 'MESSAGE' => 'Tab cron complete', + 'EXPIRED_TABS' => $expiredCount, + 'CAPTURED_TABS' => $capturedCount, + 'CANCELLED_TABS' => $cancelledCount, + 'PRESENCE_CLEANED' => $presenceCleaned, + ]); + +} catch (Exception $e) { + error_log("[tab_cron] Cron error: {$e->getMessage()}"); + jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => $e->getMessage()]); +} diff --git a/receipt/index.php b/receipt/index.php new file mode 100644 index 0000000..3a3b242 --- /dev/null +++ b/receipt/index.php @@ -0,0 +1,388 @@ + +
The receipt link may be invalid or expired.
The receipt link may be invalid or expired.
| Item | Qty | Price | Total |
|---|