?) AS pending_payment,
SUM(o.status IN ('cancelled','rejected','expired','refunded')) AS closed_orders,
COUNT(*) AS all_orders
FROM {orders} o WHERE $w", array_merge([now()], $a));
$t = q1("SELECT COUNT(*) AS sold, SUM(tk.checked_in_at IS NOT NULL) AS checked_in, SUM(o.payment_method = 'comp') AS comp, SUM(o.payment_method IN ('cash','door_card')) AS door FROM {tickets} tk JOIN {orders} o ON o.id = tk.order_id
WHERE tk.status = 'valid' AND o.status = 'paid' AND $w", $a);
$r = array_map('intval', $r);
$r['tickets'] = (int)$t['sold'];
$r['checked_in'] = (int)$t['checked_in'];
$r['comp_tickets'] = (int)$t['comp'];
$r['door_tickets'] = (int)$t['door'];
$r['avg_order'] = $r['paid_orders'] ? intdiv($r['revenue'], $r['paid_orders']) : 0;
return $r;
}
function report_where(?int $eventId, ?string $from, ?string $to, string $alias = 'o'): array
{
$w = ['1=1']; $a = [];
if ($eventId) { $w[] = "$alias.event_id = ?"; $a[] = $eventId; }
if ($from) { $w[] = "$alias.created_at >= ?"; $a[] = $from . ' 00:00:00'; }
if ($to) { $w[] = "$alias.created_at <= ?"; $a[] = $to . ' 23:59:59'; }
return [implode(' AND ', $w), $a];
}
/** Sold, revenue and capacity by category and tier for one event. */
function report_by_ticket_type(int $eventId): array
{
return q("SELECT c.id AS category_id, c.name AS category, c.capacity,
(SELECT t.price FROM {ticket_tiers} t WHERE t.category_id = c.id ORDER BY t.is_active DESC, t.sort_order, t.id LIMIT 1) AS price,
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN oi.qty END),0) AS sold,
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN oi.qty * oi.unit_price END),0) AS gross,
COALESCE(SUM(CASE WHEN o.status IN ('awaiting_approval','pending_payment') AND o.expires_at > ? THEN oi.qty END),0) AS held
FROM {ticket_categories} c
LEFT JOIN {order_items} oi ON oi.category_id = c.id LEFT JOIN {orders} o ON o.id = oi.order_id
WHERE c.event_id = ? GROUP BY c.id ORDER BY c.sort_order, c.id", [now(), $eventId])->fetchAll();
}
/** Daily paid tickets and revenue for the last $days days. */
function report_daily(?int $eventId, int $days = 30): array
{
$start = ldate('Y-m-d', lstrtotime('-' . ($days - 1) . ' days'));
$args = [$start . ' 00:00:00'];
$ev = '';
if ($eventId) { $ev = ' AND o.event_id = ?'; $args[] = $eventId; }
$rows = q("SELECT DATE(o.paid_at) AS d, SUM(o.total) AS revenue, SUM((SELECT SUM(qty) FROM {order_items} WHERE order_id = o.id)) AS tickets
FROM {orders} o WHERE o.status = 'paid' AND o.paid_at >= ?$ev GROUP BY DATE(o.paid_at)", $args)->fetchAll();
$map = [];
foreach ($rows as $r) $map[$r['d']] = ['revenue' => (int)$r['revenue'], 'tickets' => (int)$r['tickets']];
$out = [];
for ($i = 0; $i < $days; $i++) {
$d = ldate('Y-m-d', lstrtotime($start . " +$i days"));
$out[] = ['date' => $d] + ($map[$d] ?? ['revenue' => 0, 'tickets' => 0]);
}
return $out;
}
function report_discounts(?int $eventId): array
{
$args = [];
$w = "o.status = 'paid'";
if ($eventId) { $w .= ' AND o.event_id = ?'; $args[] = $eventId; }
return q("SELECT d.id, d.name, d.code, d.auto_apply, COUNT(o.id) AS uses, COALESCE(SUM(o.discount_amount),0) AS amount
FROM {discounts} d JOIN {orders} o ON o.discount_id = d.id WHERE $w GROUP BY d.id ORDER BY amount DESC", $args)->fetchAll();
}
/**
* Accessible SVG bar chart. Horizontal positions are percentages and vertical ones pixels, so the chart fills any
* width without stretching its text. Values are drawn with presentation attributes so the strict CSP stays intact.
*/
function bar_chart_svg(array $points, string $valueKey, string $title, callable $fmt): string
{
$h = 220; $top = 22; $bottom = 26; $plot = $h - $top - $bottom;
$max = max(1, ...array_map(fn($p) => (int)$p[$valueKey], $points));
$n = max(1, count($points));
$slot = 100 / $n; $gap = min(0.6, $slot * 0.2);
$pc = fn(float $v) => round($v, 3) . '%';
$out = '';
$real = max(0, ...array_map(fn($p) => (int)$p[$valueKey], $points));
if ($real === 0) $out .= 'Nothing in this period yet';
foreach ($real === 0 ? [] : ($max >= 2 ? [1, 0.5] : [1]) as $f) {
$y = $top + $plot - (int)round($plot * $f);
$out .= '';
$out .= '' . e($fmt((int)round($max * $f))) . '';
}
$out .= '';
foreach (array_values($points) as $i => $p) {
$v = (int)$p[$valueKey];
$bh = $v ? max(3, (int)round($plot * $v / $max)) : 2;
$x = $i * $slot + $gap / 2;
$label = ldate_h('j M Y', lstrtotime($p['date'])) . ': ' . $fmt($v);
$out .= '' . e($label) . '';
$last = $i === $n - 1;
if ($n <= 31 ? ($last || ($i % 5 === 0 && $n - 1 - $i >= 3)) : ($last || $i % (int)ceil($n / 6) === 0)) {
$anchor = $i === 0 ? 'start' : ($last ? 'end' : 'middle');
$tx = $i === 0 ? $x : ($last ? $x + $slot - $gap : $x + ($slot - $gap) / 2);
$out .= '' . e(ldate_h('j M', lstrtotime($p['date']))) . '';
}
}
return '';
}
/** Rows for the attendee export (one per ticket). */
function export_attendees(int $eventId): array
{
$questions = q('SELECT label FROM {event_questions} WHERE event_id = ? ORDER BY sort_order, id', [$eventId])->fetchAll(Result::COLUMN);
$rows = [array_merge(['Ticket code', 'Category', 'Attendee', 'Email', 'Phone', 'Booking', 'Paid at', 'Checked in at', 'Ticket status'], $questions)];
$st = q("SELECT tk.code, tk.status AS tstatus, tk.checked_in_at, c.name AS category, t.name AS tier, o.buyer_name, o.buyer_email, o.buyer_phone, o.reference, o.paid_at, o.answers
FROM {tickets} tk JOIN {orders} o ON o.id = tk.order_id JOIN {ticket_categories} c ON c.id = tk.category_id JOIN {ticket_tiers} t ON t.id = tk.tier_id
WHERE o.event_id = ? ORDER BY o.id, tk.id", [$eventId]);
foreach ($st as $r) {
$ans = [];
foreach (answers_list($r['answers']) as $x) $ans[$x['q']] = $x['a'];
$rows[] = array_merge([format_code($r['code']), $r['category'], $r['buyer_name'], $r['buyer_email'], (string)$r['buyer_phone'], $r['reference'],
(string)$r['paid_at'], (string)$r['checked_in_at'], $r['tstatus']], array_map(fn($q) => $ans[$q] ?? '', $questions));
}
return $rows;
}
function export_orders(array $filters): array
{
[$w, $a] = orders_filter_sql($filters);
$rows = [['Reference', 'Event', 'Buyer', 'Email', 'Phone', 'Status', 'Tickets', 'Subtotal', 'Discount', 'Tax', 'Total', 'Discount code', 'Payment', 'Created', 'Paid', 'Invoice', 'Company', 'Tax number']];
$st = q("SELECT o.*, e.title, d.name AS dname, d.code AS dcode, (SELECT SUM(qty) FROM {order_items} WHERE order_id = o.id) AS qty
FROM {orders} o JOIN {events} e ON e.id = o.event_id LEFT JOIN {discounts} d ON d.id = o.discount_id WHERE $w ORDER BY o.id DESC", $a);
foreach ($st as $r) {
$rows[] = [$r['reference'], $r['title'], $r['buyer_name'], $r['buyer_email'], (string)$r['buyer_phone'], status_label($r['status']), (int)$r['qty'],
money_num((int)$r['subtotal']), money_num((int)$r['discount_amount']), money_num((int)$r['tax_amount']), money_num((int)$r['total']), (string)($r['dcode'] ?: $r['dname']),
(string)$r['payment_method'], $r['created_at'], (string)$r['paid_at'], (string)$r['invoice_number'], (string)$r['buyer_company'], (string)$r['buyer_tax_id']];
}
return $rows;
}
function export_sales_workbook(?int $eventId, ?string $from, ?string $to): array
{
$s = report_summary($eventId, $from, $to);
$cur = cfg('currency');
$summary = [['Metric', 'Value'],
['Revenue (' . $cur . ')', money_num($s['revenue'])], ['Gross before discounts (' . $cur . ')', money_num($s['gross'])],
['Discounts given (' . $cur . ')', money_num($s['discounts'])], ['Tax collected (' . $cur . ')', money_num($s['tax'])], ['Refunded (' . $cur . ')', money_num($s['refunded'])],
['Confirmed bookings', $s['paid_orders']], ['Tickets issued', $s['tickets']], ['Checked in', $s['checked_in']],
['Awaiting approval', $s['awaiting_approval']], ['Awaiting payment', $s['pending_payment']], ['Average booking (' . $cur . ')', money_num($s['avg_order'])]];
$sheets = ['Summary' => $summary];
if ($eventId) {
$types = [['Category', 'Price', 'Sold', 'Held', 'Capacity', 'Gross']];
foreach (report_by_ticket_type($eventId) as $r) {
$types[] = [$r['category'], money_num((int)$r['price']), (int)$r['sold'], (int)$r['held'], (int)$r['capacity'], money_num((int)$r['gross'])];
}
$sheets['By category'] = $types;
}
$daily = [['Date', 'Tickets', 'Revenue']];
foreach (report_daily($eventId, 90) as $d) $daily[] = [$d['date'], $d['tickets'], money_num($d['revenue'])];
$sheets['Daily (90 days)'] = $daily;
$disc = [['Discount', 'Code', 'Automatic', 'Uses', 'Amount']];
foreach (report_discounts($eventId) as $d) $disc[] = [$d['name'], (string)$d['code'], $d['auto_apply'] ? 'Yes' : 'No', (int)$d['uses'], money_num((int)$d['amount'])];
$sheets['Discounts'] = $disc;
return $sheets;
}
/** Shared filter logic for the orders list and export. */
function orders_filter_sql(array $f): array
{
$w = ['1=1']; $a = [];
if (!empty($f['event'])) { $w[] = 'o.event_id = ?'; $a[] = (int)$f['event']; }
if (!empty($f['status']) && in_array($f['status'], ['awaiting_approval', 'pending_payment', 'paid', 'rejected', 'cancelled', 'expired', 'refunded'], true)) {
$w[] = 'o.status = ?'; $a[] = $f['status'];
}
if (!empty($f['type'])) {
$types = ['online' => "o.payment_method IN ('stripe','offline','free')", 'comp' => "o.payment_method = 'comp'", 'door' => "o.payment_method IN ('cash','door_card')", 'imported' => "o.payment_method = 'imported'"];
if (isset($types[$f['type']])) $w[] = $types[$f['type']];
}
if (!empty($f['q'])) {
$w[] = '(o.reference = ? OR o.buyer_email LIKE ? OR o.buyer_name LIKE ?)';
$like = '%' . addcslashes((string)$f['q'], '%_\\') . '%';
array_push($a, strtoupper((string)$f['q']), $like, $like);
}
if (!empty($f['from'])) { $w[] = 'o.created_at >= ?'; $a[] = $f['from'] . ' 00:00:00'; }
if (!empty($f['to'])) { $w[] = 'o.created_at <= ?'; $a[] = $f['to'] . ' 23:59:59'; }
return [implode(' AND ', $w), $a];
}