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 <noreply@anthropic.com>
This commit is contained in:
parent
bd913bb46d
commit
4d806d4e1e
14 changed files with 1327 additions and 0 deletions
52
api/admin/quickTasks/create.php
Normal file
52
api/admin/quickTasks/create.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Create a task from a quick task template (instant creation).
|
||||
* POST: { BusinessID, QuickTaskTemplateID }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? headerValue('X-Business-ID') ?? 0);
|
||||
$templateID = (int) ($data['QuickTaskTemplateID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
33
api/admin/quickTasks/delete.php
Normal file
33
api/admin/quickTasks/delete.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Delete (soft) a quick task template.
|
||||
* POST: { BusinessID, QuickTaskTemplateID }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? headerValue('X-Business-ID') ?? 0);
|
||||
$templateID = (int) ($data['QuickTaskTemplateID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
63
api/admin/quickTasks/list.php
Normal file
63
api/admin/quickTasks/list.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Returns quick task templates for a business.
|
||||
* GET/POST: { BusinessID }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? headerValue('X-Business-ID') ?? $_GET['BusinessID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
72
api/admin/quickTasks/save.php
Normal file
72
api/admin/quickTasks/save.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Create or update a quick task template.
|
||||
* POST: { BusinessID, QuickTaskTemplateID? (for update), Name, Title, Details, CategoryID, Icon, Color }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? headerValue('X-Business-ID') ?? 0);
|
||||
$templateID = (int) ($data['QuickTaskTemplateID'] ?? 0);
|
||||
$name = trim($data['Name'] ?? '');
|
||||
$title = trim($data['Title'] ?? '');
|
||||
$details = trim($data['Details'] ?? '');
|
||||
$categoryID = (isset($data['CategoryID']) && is_numeric($data['CategoryID']) && $data['CategoryID'] > 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()]);
|
||||
}
|
||||
59
api/admin/scheduledTasks/_cronUtils.php
Normal file
59
api/admin/scheduledTasks/_cronUtils.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
/**
|
||||
* Shared cron expression utilities for scheduled tasks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a 5-part cron expression and calculate the next run time from now.
|
||||
* Supports: minute hour day month weekday
|
||||
* Weekday ranges like 1-5 (Mon-Fri) are supported.
|
||||
* Returns a DateTime in UTC.
|
||||
*/
|
||||
function calculateNextRun(string $cronExpression): DateTime {
|
||||
$parts = preg_split('/\s+/', trim($cronExpression));
|
||||
if (count($parts) !== 5) {
|
||||
return new DateTime('+1 day', new DateTimeZone('UTC'));
|
||||
}
|
||||
|
||||
[$cronMinute, $cronHour, $cronDay, $cronMonth, $cronWeekday] = $parts;
|
||||
|
||||
// Start from next minute
|
||||
$check = new DateTime('now', new DateTimeZone('UTC'));
|
||||
$check->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'));
|
||||
}
|
||||
33
api/admin/scheduledTasks/delete.php
Normal file
33
api/admin/scheduledTasks/delete.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Delete a scheduled task definition (hard delete).
|
||||
* POST: { BusinessID, ScheduledTaskID }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? headerValue('X-Business-ID') ?? 0);
|
||||
$taskID = (int) ($data['ScheduledTaskID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
68
api/admin/scheduledTasks/list.php
Normal file
68
api/admin/scheduledTasks/list.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Returns scheduled task definitions for a business.
|
||||
* GET/POST: { BusinessID }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? headerValue('X-Business-ID') ?? $_GET['BusinessID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
47
api/admin/scheduledTasks/run.php
Normal file
47
api/admin/scheduledTasks/run.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Manually trigger a scheduled task (creates a task without updating next run time).
|
||||
* POST: { BusinessID, ScheduledTaskID }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? headerValue('X-Business-ID') ?? 0);
|
||||
$scheduledTaskID = (int) ($data['ScheduledTaskID'] ?? 0);
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
79
api/admin/scheduledTasks/runDue.php
Normal file
79
api/admin/scheduledTasks/runDue.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
require_once __DIR__ . '/_cronUtils.php';
|
||||
// No runAuth() — this is a cron/public endpoint
|
||||
|
||||
/**
|
||||
* Process all due scheduled tasks.
|
||||
* Called by cron every minute. Creates Tasks entries for any due ScheduledTaskDefinitions.
|
||||
*/
|
||||
|
||||
try {
|
||||
$dueTasks = queryTimed("
|
||||
SELECT
|
||||
ID AS ScheduledTaskID,
|
||||
BusinessID,
|
||||
TaskCategoryID AS CategoryID,
|
||||
Title,
|
||||
Details,
|
||||
CronExpression,
|
||||
COALESCE(ScheduleType, 'cron') AS ScheduleType,
|
||||
IntervalMinutes
|
||||
FROM ScheduledTaskDefinitions
|
||||
WHERE IsActive = 1
|
||||
AND NextRunOn <= NOW()
|
||||
");
|
||||
|
||||
$createdTasks = [];
|
||||
|
||||
foreach ($dueTasks as $task) {
|
||||
queryTimed("
|
||||
INSERT INTO Tasks (BusinessID, CategoryID, TaskTypeID, Title, Details, CreatedOn, ClaimedByUserID)
|
||||
VALUES (?, ?, 0, ?, ?, NOW(), 0)
|
||||
", [
|
||||
$task['BusinessID'],
|
||||
$task['CategoryID'],
|
||||
$task['Title'],
|
||||
$task['Details'],
|
||||
]);
|
||||
|
||||
$newTaskID = (int) lastInsertId();
|
||||
|
||||
// Calculate next run based on schedule type
|
||||
$st = $task['ScheduleType'];
|
||||
$interval = (int) ($task['IntervalMinutes'] ?? 0);
|
||||
|
||||
if ($st === 'interval_after_completion' && $interval > 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()]);
|
||||
}
|
||||
127
api/admin/scheduledTasks/save.php
Normal file
127
api/admin/scheduledTasks/save.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
require_once __DIR__ . '/_cronUtils.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Create or update a scheduled task definition.
|
||||
* POST: { BusinessID, ScheduledTaskID? (for update), Name, Title, Details, CategoryID,
|
||||
* CronExpression, IsActive, ScheduleType ('cron'|'interval'|'interval_after_completion'),
|
||||
* IntervalMinutes (for interval types) }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? headerValue('X-Business-ID') ?? 0);
|
||||
$taskID = (int) ($data['ScheduledTaskID'] ?? 0);
|
||||
$taskName = trim($data['Name'] ?? '');
|
||||
$taskTitle = trim($data['Title'] ?? '');
|
||||
$taskDetails = trim($data['Details'] ?? '');
|
||||
$categoryID = (isset($data['CategoryID']) && is_numeric($data['CategoryID']) && $data['CategoryID'] > 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()]);
|
||||
}
|
||||
60
api/admin/scheduledTasks/toggle.php
Normal file
60
api/admin/scheduledTasks/toggle.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../../helpers.php';
|
||||
require_once __DIR__ . '/_cronUtils.php';
|
||||
runAuth();
|
||||
|
||||
/**
|
||||
* Enable or disable a scheduled task.
|
||||
* POST: { BusinessID, ScheduledTaskID, IsActive }
|
||||
*/
|
||||
|
||||
$data = readJsonBody();
|
||||
$businessID = (int) ($data['BusinessID'] ?? headerValue('X-Business-ID') ?? 0);
|
||||
$taskID = (int) ($data['ScheduledTaskID'] ?? 0);
|
||||
$isActive = isset($data['IsActive']) ? ($data['IsActive'] ? 1 : 0) : 0;
|
||||
|
||||
if ($businessID <= 0) {
|
||||
apiAbort(['OK' => 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()]);
|
||||
}
|
||||
53
cron/expireStaleChats.php
Normal file
53
cron/expireStaleChats.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../api/helpers.php';
|
||||
// No runAuth() — cron/public endpoint
|
||||
|
||||
/**
|
||||
* Expire stale chats (older than 20 minutes with no recent activity).
|
||||
* Called every minute by cron.
|
||||
*/
|
||||
|
||||
try {
|
||||
$staleChats = queryTimed("
|
||||
SELECT t.ID, t.CreatedOn,
|
||||
(SELECT MAX(cm.CreatedOn) FROM ChatMessages cm WHERE cm.TaskID = t.ID) AS LastMessageOn
|
||||
FROM Tasks t
|
||||
WHERE t.TaskTypeID = 2
|
||||
AND t.CompletedOn IS NULL
|
||||
AND t.CreatedOn < DATE_SUB(NOW(), INTERVAL 20 MINUTE)
|
||||
");
|
||||
|
||||
$expiredCount = 0;
|
||||
$expiredIds = [];
|
||||
|
||||
foreach ($staleChats as $chat) {
|
||||
$shouldExpire = false;
|
||||
|
||||
if (empty($chat['LastMessageOn'])) {
|
||||
$shouldExpire = true;
|
||||
} else {
|
||||
$lastMsg = new DateTime($chat['LastMessageOn'], new DateTimeZone('UTC'));
|
||||
$now = new DateTime('now', new DateTimeZone('UTC'));
|
||||
$diffMinutes = ($now->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()]);
|
||||
}
|
||||
193
cron/expireTabs.php
Normal file
193
cron/expireTabs.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../api/helpers.php';
|
||||
require_once __DIR__ . '/../api/config/stripe.php';
|
||||
// No runAuth() — cron/public endpoint
|
||||
|
||||
/**
|
||||
* Scheduled task to handle tab expiry and cleanup.
|
||||
* Run every 5 minutes.
|
||||
*
|
||||
* 1. Expire idle tabs (no activity beyond business SessionLockMinutes)
|
||||
* 2. Clean up stale presence records (>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()]);
|
||||
}
|
||||
388
receipt/index.php
Normal file
388
receipt/index.php
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
<?php
|
||||
/**
|
||||
* Public receipt page — no auth required.
|
||||
* Secured by random v4 UUID unguessability.
|
||||
* URL: /receipt/index.php?UUID={orderUuid}
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../api/helpers.php';
|
||||
// No runAuth() — public page
|
||||
|
||||
$uuid = trim($_GET['UUID'] ?? '');
|
||||
$isAdminView = (int) ($_GET['is_admin_view'] ?? 0);
|
||||
|
||||
if ($uuid === '') {
|
||||
echo <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>Receipt Not Found</title>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5;color:#333}
|
||||
.msg{text-align:center;padding:40px}.msg h2{margin-bottom:8px}</style>
|
||||
</head><body><div class="msg"><h2>Receipt not found</h2><p>The receipt link may be invalid or expired.</p></div></body></html>
|
||||
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 <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>Receipt Not Found</title>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5;color:#333}
|
||||
.msg{text-align:center;padding:40px}.msg h2{margin-bottom:8px}</style>
|
||||
</head><body><div class="msg"><h2>Receipt not found</h2><p>The receipt link may be invalid or expired.</p></div></body></html>
|
||||
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 .= '<tr>';
|
||||
$itemRows .= '<td>' . esc($parent['Name']) . '</td>';
|
||||
$itemRows .= '<td class="qty">' . $qty . '</td>';
|
||||
$itemRows .= '<td class="amt">' . dollars($price) . '</td>';
|
||||
$itemRows .= '<td class="amt">' . dollars($lineTotal) . '</td>';
|
||||
$itemRows .= '</tr>';
|
||||
|
||||
// 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 .= '<tr class="modifier">';
|
||||
$itemRows .= '<td colspan="3">' . esc($modParentName) . ': ' . esc($child['Name']) . ' (' . dollars((float) $child['Price']) . ')</td>';
|
||||
$itemRows .= '<td class="amt">' . dollars($modTotal) . '</td>';
|
||||
$itemRows .= '</tr>';
|
||||
}
|
||||
|
||||
if (!empty(trim($parent['Remark'] ?? ''))) {
|
||||
$itemRows .= '<tr class="remark"><td colspan="4">' . esc($parent['Remark']) . '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// 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 .= '<div class="meta-row"><span>Date</span><span>' . $createdOn->format('M j, Y') . '</span></div>';
|
||||
$metaHtml .= '<div class="meta-row"><span>Time</span><span>' . $createdOn->format('g:i A') . '</span></div>';
|
||||
}
|
||||
$metaHtml .= '<div class="meta-row"><span>Order</span><span>#' . (int) $order['ID'] . '</span></div>';
|
||||
|
||||
$remarksHtml = '';
|
||||
if (!empty(trim($order['Remarks'] ?? ''))) {
|
||||
$remarksHtml = '<div class="order-remarks">' . esc($order['Remarks']) . '</div>';
|
||||
}
|
||||
|
||||
$totalsHtml = '<div class="total-row"><span>Subtotal</span><span>' . dollars($cartGrandTotal) . '</span></div>';
|
||||
|
||||
if ($taxAmount > 0) {
|
||||
$totalsHtml .= '<div class="total-row"><span>Tax</span><span>' . dollars($taxAmount) . '</span></div>';
|
||||
}
|
||||
if ($serviceFeeDisplay > 0) {
|
||||
$totalsHtml .= '<div class="total-row"><span>Service fee</span><span>' . dollars($serviceFeeDisplay) . '</span></div>';
|
||||
}
|
||||
if ($deliveryFee > 0) {
|
||||
$totalsHtml .= '<div class="total-row"><span>Delivery fee</span><span>' . dollars($deliveryFee) . '</span></div>';
|
||||
}
|
||||
if ($receiptTip > 0) {
|
||||
$totalsHtml .= '<div class="total-row"><span>Tip</span><span>' . dollars($receiptTip) . '</span></div>';
|
||||
}
|
||||
if ($cardFee > 0) {
|
||||
$totalsHtml .= '<div class="total-row"><span>Processing fee</span><span>' . dollars($cardFee) . '</span></div>';
|
||||
}
|
||||
|
||||
$totalsHtml .= '<div class="total-row grand-total"><span>Total</span><span>' . dollars($orderGrandTotal) . '</span></div>';
|
||||
|
||||
if ($balanceApplied > 0) {
|
||||
$totalsHtml .= '<div class="total-row" style="font-size:13px;color:#666;padding-top:8px"><span>Paid with balance</span><span>' . dollars($balanceApplied) . '</span></div>';
|
||||
$amountCharged = $orderGrandTotal - $balanceApplied;
|
||||
if ($amountCharged > 0) {
|
||||
$chargeLabel = $isCashOrder ? 'Cash due' : 'Charged to card';
|
||||
$totalsHtml .= '<div class="total-row" style="font-size:13px;color:#666"><span>' . $chargeLabel . '</span><span>' . dollars($amountCharged) . '</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
echo <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Receipt - {$businessName}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
.receipt {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.receipt-header {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
padding: 28px 24px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.receipt-header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.receipt-header .order-type {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.receipt-meta {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px dashed #ddd;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
.receipt-meta .meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.items {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
.items table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.items th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #999;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0 0 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.items th:last-child,
|
||||
.items td.amt {
|
||||
text-align: right;
|
||||
}
|
||||
.items td {
|
||||
padding: 10px 0;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
vertical-align: top;
|
||||
}
|
||||
.items td.qty {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
width: 40px;
|
||||
}
|
||||
.items .modifier td {
|
||||
padding: 2px 0 2px 16px;
|
||||
font-size: 13px;
|
||||
color: #777;
|
||||
border-bottom: none;
|
||||
}
|
||||
.items .remark td {
|
||||
padding: 2px 0 8px 16px;
|
||||
font-size: 13px;
|
||||
color: #c0392b;
|
||||
border-bottom: none;
|
||||
font-style: italic;
|
||||
}
|
||||
.totals {
|
||||
padding: 16px 24px 24px;
|
||||
border-top: 1px dashed #ddd;
|
||||
}
|
||||
.totals .total-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
.totals .grand-total {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
border-top: 2px solid #1a1a2e;
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.receipt-footer {
|
||||
text-align: center;
|
||||
padding: 16px 24px 24px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
.order-remarks {
|
||||
padding: 8px 24px;
|
||||
font-size: 13px;
|
||||
color: #c0392b;
|
||||
font-style: italic;
|
||||
border-bottom: 1px dashed #ddd;
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; padding: 0; }
|
||||
.receipt { box-shadow: none; border-radius: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="receipt">
|
||||
<div class="receipt-header">
|
||||
<h1>{$businessName}</h1>
|
||||
<div class="order-type">{$orderTypeDisplay}</div>
|
||||
</div>
|
||||
|
||||
<div class="receipt-meta">
|
||||
{$metaHtml}
|
||||
</div>
|
||||
|
||||
{$remarksHtml}
|
||||
|
||||
<div class="items">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Qty</th><th>Price</th><th>Total</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{$itemRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="totals">
|
||||
{$totalsHtml}
|
||||
</div>
|
||||
|
||||
<div class="receipt-footer">
|
||||
Powered by Payfrit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
Loading…
Add table
Reference in a new issue