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