From 4d806d4e1e9d1da0cd9f921058500333b286ce51 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Sat, 14 Mar 2026 15:57:25 -0700 Subject: [PATCH] Port admin, cron, and receipt endpoints from CFML to PHP - admin/quickTasks: list, create, save, delete - admin/scheduledTasks: list, save, delete, toggle, run, runDue - cron: expireStaleChats, expireTabs - receipt: public order receipt page (no auth, UUID-secured) Co-Authored-By: Claude Opus 4.6 --- api/admin/quickTasks/create.php | 52 ++++ api/admin/quickTasks/delete.php | 33 ++ api/admin/quickTasks/list.php | 63 ++++ api/admin/quickTasks/save.php | 72 +++++ api/admin/scheduledTasks/_cronUtils.php | 59 ++++ api/admin/scheduledTasks/delete.php | 33 ++ api/admin/scheduledTasks/list.php | 68 +++++ api/admin/scheduledTasks/run.php | 47 +++ api/admin/scheduledTasks/runDue.php | 79 +++++ api/admin/scheduledTasks/save.php | 127 ++++++++ api/admin/scheduledTasks/toggle.php | 60 ++++ cron/expireStaleChats.php | 53 ++++ cron/expireTabs.php | 193 ++++++++++++ receipt/index.php | 388 ++++++++++++++++++++++++ 14 files changed, 1327 insertions(+) create mode 100644 api/admin/quickTasks/create.php create mode 100644 api/admin/quickTasks/delete.php create mode 100644 api/admin/quickTasks/list.php create mode 100644 api/admin/quickTasks/save.php create mode 100644 api/admin/scheduledTasks/_cronUtils.php create mode 100644 api/admin/scheduledTasks/delete.php create mode 100644 api/admin/scheduledTasks/list.php create mode 100644 api/admin/scheduledTasks/run.php create mode 100644 api/admin/scheduledTasks/runDue.php create mode 100644 api/admin/scheduledTasks/save.php create mode 100644 api/admin/scheduledTasks/toggle.php create mode 100644 cron/expireStaleChats.php create mode 100644 cron/expireTabs.php create mode 100644 receipt/index.php 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 @@ + +Receipt Not Found + + +

Receipt not found

The receipt link may be invalid or expired.

+HTML; + exit; +} + +$order = queryOne(" + SELECT O.OrderTypeID, O.BusinessID, O.Remarks, O.ID, O.BalanceApplied, O.PaymentID, O.TipAmount, + B.Name AS BusinessName, B.TaxRate, B.PayfritFee, + COALESCE(P.PaymentPaidInCash, 0) AS PaymentPaidInCash, + COALESCE(P.PaymentFromCreditCard, 0) AS PaymentFromCreditCard + FROM Orders O + JOIN Businesses B ON B.ID = O.BusinessID + LEFT JOIN Payments P ON P.PaymentID = O.PaymentID + WHERE O.UUID = ? +", [$uuid]); + +if (!$order) { + echo << +Receipt Not Found + + +

Receipt not found

The receipt link may be invalid or expired.

+HTML; + exit; +} + +$orderTask = queryOne("SELECT CreatedOn FROM Tasks WHERE OrderID = ? LIMIT 1", [(int) $order['ID']]); + +$orderType = queryOne("SELECT Name AS OrderTypeName FROM tt_OrderTypes WHERE ID = ?", [(int) $order['OrderTypeID']]); +$orderTypeName = $orderType['OrderTypeName'] ?? ''; + +$deliveryAddress = ''; +if ((int) $order['OrderTypeID'] === 3) { + $addr = queryOne(" + SELECT A.Line1 + FROM Addresses A + JOIN Orders O ON A.ID = O.AddressID + WHERE O.UUID = ? + ", [$uuid]); + $deliveryAddress = $addr['Line1'] ?? ''; +} + +$parentItems = queryTimed(" + SELECT OL.ID, OL.Quantity, OL.Remark, + I.ID AS ItemID, I.Name, I.Price, I.CategoryID + FROM OrderLineItems OL + JOIN Items I ON I.ID = OL.ItemID + JOIN Orders O ON O.ID = OL.OrderID + WHERE O.UUID = ? + AND OL.ParentOrderLineItemID = 0 + ORDER BY OL.AddedOn DESC +", [$uuid]); + +$cartGrandTotal = 0; +$payfritsCut = 0; + +// Helper +function esc(string $s): string { + return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); +} +function dollars(float $amount): string { + return '$' . number_format($amount, 2); +} + +// Build item rows +$itemRows = ''; +foreach ($parentItems as $parent) { + $price = (float) $parent['Price']; + $qty = (int) $parent['Quantity']; + + if ((int) $parent['CategoryID'] !== 31) { + $payfritsCut += $price * $qty * (float) $order['PayfritFee']; + } + + $lineTotal = $price * $qty; + $cartGrandTotal += $lineTotal; + + $itemRows .= ''; + $itemRows .= '' . esc($parent['Name']) . ''; + $itemRows .= '' . $qty . ''; + $itemRows .= '' . dollars($price) . ''; + $itemRows .= '' . dollars($lineTotal) . ''; + $itemRows .= ''; + + // Child/modifier items + $children = queryTimed(" + SELECT I.Name, I.Price, I.ParentItemID + FROM OrderLineItems OL + JOIN Items I ON I.ID = OL.ItemID + WHERE OL.ParentOrderLineItemID = ? + ORDER BY OL.AddedOn DESC + ", [(int) $parent['ID']]); + + foreach ($children as $child) { + $modParent = queryOne("SELECT Name FROM Items WHERE ID = ?", [(int) $child['ParentItemID']]); + $modParentName = $modParent['Name'] ?? ''; + $modTotal = (float) $child['Price'] * $qty; + $cartGrandTotal += $modTotal; + + $itemRows .= ''; + $itemRows .= '' . esc($modParentName) . ': ' . esc($child['Name']) . ' (' . dollars((float) $child['Price']) . ')'; + $itemRows .= '' . dollars($modTotal) . ''; + $itemRows .= ''; + } + + if (!empty(trim($parent['Remark'] ?? ''))) { + $itemRows .= '' . esc($parent['Remark']) . ''; + } +} + +// Calculate totals (matches createPaymentIntent logic — no intermediate rounding) +$taxAmountRaw = $cartGrandTotal * (float) $order['TaxRate']; +$payfritFeeRaw = $payfritsCut; + +// Delivery fee +$deliveryFee = 0; +if ((int) $order['OrderTypeID'] === 3) { + $delFee = queryOne(" + SELECT B.DeliveryFlatFee + FROM Businesses B + JOIN Orders O ON B.ID = O.BusinessID + WHERE O.ID = ? + ", [(int) $order['ID']]); + $deliveryFee = (float) ($delFee['DeliveryFlatFee'] ?? 0); +} + +$isCashOrder = (float) $order['PaymentPaidInCash'] > 0; +$isCardOrder = (float) $order['PaymentFromCreditCard'] > 0; + +$receiptTip = (float) ($order['TipAmount'] ?? 0); +$totalBeforeCardFee = $cartGrandTotal + $taxAmountRaw + $payfritFeeRaw + $deliveryFee + $receiptTip; + +if ($isCardOrder) { + $cardFeePercent = 0.029; + $cardFeeFixed = 0.30; + $totalCustomerPaysRaw = ($totalBeforeCardFee + $cardFeeFixed) / (1 - $cardFeePercent); + $orderGrandTotal = round($totalCustomerPaysRaw * 100) / 100; + $taxAmount = round($taxAmountRaw * 100) / 100; + $cardFee = $orderGrandTotal - $cartGrandTotal - $taxAmount - round($payfritFeeRaw * 100) / 100 - $deliveryFee - $receiptTip; + $cardFee = round($cardFee * 100) / 100; +} else { + $orderGrandTotal = round($totalBeforeCardFee * 100) / 100; + $taxAmount = round($taxAmountRaw * 100) / 100; + $cardFee = 0; +} + +$serviceFeeDisplay = round($payfritFeeRaw * 100) / 100; +$balanceApplied = (float) ($order['BalanceApplied'] ?? 0); + +// Build the page +$businessName = esc($order['BusinessName']); +$orderTypeDisplay = esc($orderTypeName); +if ((int) $order['OrderTypeID'] === 3 && $deliveryAddress !== '') { + $orderTypeDisplay .= ' to ' . esc($deliveryAddress); +} + +$metaHtml = ''; +if ($orderTask && $isAdminView === 0) { + $createdOn = new DateTime($orderTask['CreatedOn'], new DateTimeZone('UTC')); + $metaHtml .= '
Date' . $createdOn->format('M j, Y') . '
'; + $metaHtml .= '
Time' . $createdOn->format('g:i A') . '
'; +} +$metaHtml .= '
Order#' . (int) $order['ID'] . '
'; + +$remarksHtml = ''; +if (!empty(trim($order['Remarks'] ?? ''))) { + $remarksHtml = '
' . esc($order['Remarks']) . '
'; +} + +$totalsHtml = '
Subtotal' . dollars($cartGrandTotal) . '
'; + +if ($taxAmount > 0) { + $totalsHtml .= '
Tax' . dollars($taxAmount) . '
'; +} +if ($serviceFeeDisplay > 0) { + $totalsHtml .= '
Service fee' . dollars($serviceFeeDisplay) . '
'; +} +if ($deliveryFee > 0) { + $totalsHtml .= '
Delivery fee' . dollars($deliveryFee) . '
'; +} +if ($receiptTip > 0) { + $totalsHtml .= '
Tip' . dollars($receiptTip) . '
'; +} +if ($cardFee > 0) { + $totalsHtml .= '
Processing fee' . dollars($cardFee) . '
'; +} + +$totalsHtml .= '
Total' . dollars($orderGrandTotal) . '
'; + +if ($balanceApplied > 0) { + $totalsHtml .= '
Paid with balance' . dollars($balanceApplied) . '
'; + $amountCharged = $orderGrandTotal - $balanceApplied; + if ($amountCharged > 0) { + $chargeLabel = $isCashOrder ? 'Cash due' : 'Charged to card'; + $totalsHtml .= '
' . $chargeLabel . '' . dollars($amountCharged) . '
'; + } +} + +echo << + + + + + Receipt - {$businessName} + + + + +
+
+

{$businessName}

+
{$orderTypeDisplay}
+
+ +
+ {$metaHtml} +
+ + {$remarksHtml} + +
+ + + + + + {$itemRows} + +
ItemQtyPriceTotal
+
+ +
+ {$totalsHtml} +
+ + +
+ + + +HTML;