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:
John Mizerek 2026-03-14 15:57:25 -07:00
parent bd913bb46d
commit 4d806d4e1e
14 changed files with 1327 additions and 0 deletions

View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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'));
}

View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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()]);
}

View 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
View 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
View 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
View 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;