quantity]. Returns lines, subtotal, discount, total, requires_approval. */ function quote_order(array $event, array $qty, string $code, string $email, bool $lock = false): array { $offer = []; foreach (event_offer($event) as $c) $offer[(int)$c['id']] = $c; // One line per category: the same category sent twice ("5" and "05", say) must not be checked twice on its own. $norm = []; foreach ($qty as $k => $v) { $k = (string)$k; if (!preg_match('/^[1-9][0-9]{0,9}$/', $k)) fail('One of the selected tickets is no longer available.'); $n = is_scalar($v) && preg_match('/^\s*[0-9]{1,6}\s*$/', (string)$v) ? (int)$v : 0; if ($n > 0) $norm[(int)$k] = ($norm[(int)$k] ?? 0) + $n; } $qty = $norm; if (!$qty) fail('Choose at least one ticket.'); $lines = []; $count = 0; $approval = false; foreach ($qty as $catId => $q) { $c = $offer[(int)$catId] ?? null; if (!$c) fail('One of the selected tickets is no longer available.'); if ($c['state'] === 'sold_out') fail($c['name'] . ' is sold out.'); if ($c['state'] !== 'on_sale') fail($c['name'] . ' is not on sale right now.'); if ($q > $c['available']) fail('Only ' . plural($c['available'], 'ticket') . ' left for ' . $c['name'] . '.'); if ($q < (int)$c['min_per_order']) fail($c['name'] . ': minimum ' . (int)$c['min_per_order'] . ' per booking.'); if ($c['max_per_order'] !== null && $q > (int)$c['max_per_order']) fail($c['name'] . ': maximum ' . (int)$c['max_per_order'] . ' per booking.'); $lines[] = ['category_id' => (int)$c['id'], 'tier_id' => (int)$c['tier']['id'], 'qty' => $q, 'unit_price' => (int)$c['tier']['price'], 'name' => $c['name'], 'tier_name' => $c['tier']['name']]; $count += $q; $approval = $approval || (bool)apply_filters('gatecraft_category_needs_approval', false, $c); } if ($event['max_per_order'] !== null && $count > (int)$event['max_per_order']) fail('You can buy up to ' . (int)$event['max_per_order'] . ' tickets per booking.'); $cap = event_cap($event); if ($cap !== null) { $left = max(0, $cap - held_counts((int)$event['id'])['total']); if ($count > $left) fail($left > 0 ? 'Only ' . plural($left, 'ticket') . ' left for this event.' : 'This event is sold out.'); } $subtotal = array_sum(array_map(fn($l) => $l['qty'] * $l['unit_price'], $lines)); $disc = discount_choose((int)$event['id'], $lines, $code, strtolower($email), $lock); return [ 'lines' => $lines, 'count' => $count, 'subtotal' => $subtotal, 'discount' => $disc['discount'], 'discount_amount' => $disc['amount'], 'code_error' => $disc['code_error'], 'requires_approval' => $approval, ] + (function (int $net) { $t = tax_compute($net); return ['net' => $net, 'total' => $t['total'], 'tax' => $t]; })(max(0, $subtotal - $disc['amount'])); } function validate_buyer(array $b): array { $name = mb_substr(trim((string)($b['name'] ?? '')), 0, 120); $email = strtolower(trim((string)($b['email'] ?? ''))); $phone = trim((string)($b['phone'] ?? '')); if ($name === '' || preg_match('/[\x00-\x1f<>]/', $name)) fail('Please enter your full name.'); // Names appear in emails we send, so keep links and addresses out of them. if (preg_match('#://|www\.|@|\.(com|net|org|io|ru|cn|xyz|top|link)\b#i', $name)) fail('Please enter your name only.'); if (strlen($email) > 190 || !filter_var($email, FILTER_VALIDATE_EMAIL)) fail('Please enter a valid email address.'); if (setting('phone_mode') === 'hidden') $phone = ''; if ($phone === '' && setting('phone_mode') === 'required') fail('Please enter your phone number.'); if ($phone !== '' && !preg_match('/^[0-9+()\-\s]{6,40}$/', $phone)) fail('Please enter a valid phone number.'); return ['name' => $name, 'email' => $email, 'phone' => $phone ?: null]; } function new_reference(): string { do { $r = random_code(8); } while (qv('SELECT 1 FROM {orders} WHERE reference = ?', [$r])); return $r; } /** Creates the order and reserves stock. Returns the order row. */ function create_order(int $eventId, array $qty, array $buyerIn, array $answersIn, string $code, ?array $user, string $payWith = '', bool $agreed = true): array { $buyer = validate_buyer($buyerIn) + ['billing' => (array)apply_filters('gatecraft_buyer_billing', ['company' => null, 'tax_id' => null, 'address' => null], $buyerIn)]; if ($user) $buyer['email'] = strtolower($user['email']); // signed-in buyers use their verified email if (setting_on('require_terms') && !$agreed) fail('Please agree to the terms to continue.'); $catIds = array_map('intval', array_keys(array_filter(array_map('intval', $qty), fn($q) => $q > 0))); // Checkout questions come from add-ons. They validate the input and return [['q' => label, 'a' => answer], ...]. $answers = (array)apply_filters('gatecraft_checkout_answers', [], $eventId, $answersIn, $catIds); $evRow = q1('SELECT * FROM {events} WHERE id = ?', [$eventId]); $options = $evRow ? event_payment_options($evRow) : []; $opt = $options[$payWith] ?? (count($options) === 1 ? reset($options) : null); $mode = $opt ? ($opt['key'] === 'stripe' ? 'stripe' : 'offline') : 'offline'; $ipHash = PHP_SAPI === 'cli' ? null : substr(hash_hmac('sha256', 'order-ip|' . ip_bucket(client_ip()), app_key()), 0, 16); $orderId = tx(function () use ($eventId, $qty, $buyer, $answers, $code, $user, $mode, $opt, $options, $ipHash) { // Locking the event row serialises checkouts per event, so capacity checks cannot race. $event = q1('SELECT * FROM {events} WHERE id = ? FOR UPDATE', [$eventId]); if (!$event || $event['status'] !== 'published') fail('This event is not available.'); if ($event['starts_at'] <= now()) fail('Ticket sales for this event have closed.'); // Limit open (unpaid or unapproved) orders per email, so one person cannot hold the whole event. $open = (int)qv("SELECT COUNT(*) FROM {orders} WHERE event_id = ? AND buyer_email = ? AND status IN ('awaiting_approval','pending_payment') AND expires_at > ?", [$eventId, $buyer['email'], now()]); if ($open >= 2) fail('You already have open bookings for this event. Complete or cancel them first, or wait for them to expire.'); // And per network address (hashed, never stored as an address), so changing the email does not let one person hold every seat. $openByIp = (int)qv("SELECT COUNT(*) FROM {orders} WHERE event_id = ? AND ip_hash = ? AND status IN ('awaiting_approval','pending_payment') AND expires_at > ?", [$eventId, $ipHash, now()]); if ($openByIp >= 5) fail('There are already several unpaid bookings for this event from your connection. Complete one of them, or wait for them to expire.'); $quote = quote_order($event, $qty, $code, $buyer['email'], true); if ($quote['code_error']) fail($quote['code_error']); // A paid order needs a payment method. A free one, or an approval request for free tickets, does not. if ($quote['total'] > 0 && !$opt) fail($options ? 'Choose how you want to pay.' : 'Payment is not available for this event yet. Please contact the organiser.'); if ($quote['requires_approval']) { $status = 'awaiting_approval'; $expires = ldate('Y-m-d H:i:s', lstrtotime('+' . setting_int('approval_hold_days', 1, 60) . ' days')); $method = $quote['total'] === 0 ? 'free' : $mode; } elseif ($quote['total'] === 0) { $status = 'pending_payment'; // flipped to paid right after commit $expires = ldate('Y-m-d H:i:s', lstrtotime('+1 hour')); $method = 'free'; } else { $status = 'pending_payment'; $hold = $mode === 'stripe' ? '+' . setting_int('hold_minutes', 10, 120) . ' minutes' : '+' . setting_int('offline_hold_hours', 1, 336) . ' hours'; $expires = ldate('Y-m-d H:i:s', lstrtotime($hold)); $method = $mode; } $tax = $quote['tax']; q('INSERT INTO {orders} (reference, public_token, event_id, user_id, buyer_name, buyer_email, buyer_phone, subtotal, discount_amount, total, currency, discount_id, status, answers, expires_at, payment_method, pay_option, pay_instructions, ip_hash, tax_mode, tax_rate, tax_label, tax_amount, buyer_company, buyer_tax_id, buyer_address, language, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', [ new_reference(), bin2hex(random_bytes(32)), $eventId, $user['id'] ?? null, $buyer['name'], $buyer['email'], $buyer['phone'], $quote['subtotal'], $quote['discount_amount'], $quote['total'], cfg('currency'), $quote['discount']['id'] ?? null, $status, $answers ? wp_json_encode($answers, JSON_UNESCAPED_UNICODE) : null, $expires, $method, $method === 'offline' ? $opt['name'] : null, $method === 'offline' ? $opt['instructions'] : null, $ipHash, $tax['mode'] === 'none' ? null : $tax['mode'], $tax['rate'], $tax['label'], $tax['amount'], $buyer['billing']['company'], $buyer['billing']['tax_id'], $buyer['billing']['address'], current_language(), now(), ]); $id = (int)db_insert_id(); foreach ($quote['lines'] as $l) q('INSERT INTO {order_items} (order_id, category_id, tier_id, qty, unit_price) VALUES (?,?,?,?,?)', [$id, $l['category_id'], $l['tier_id'], $l['qty'], $l['unit_price']]); return $id; }); $o = get_order($orderId); if ($o['status'] === 'awaiting_approval') { email_order($orderId, 'approval_pending'); notify_staff('approval', 'Approval needed: ' . $o['reference'], $o['buyer_name'] . ' requested tickets for ' . $o['event_title'], '/admin/approvals'); check_capacity_alert((int)$o['event_id']); } elseif ($o['payment_method'] === 'free') { // Free tickets go out once the buyer opens a link sent to the address, so a script cannot claim every free place // with made-up addresses. A signed-in buyer booking with their own address has shown it is theirs already. if (free_booking_needs_confirm($o, $user)) email_order($orderId, 'confirm_email'); else mark_paid($orderId, 'free'); } elseif ($o['payment_method'] === 'offline') { email_order($orderId, 'payment_pending'); notify_staff('bank', 'Payment to confirm: ' . ($o['pay_option'] ?: 'offline') . ', booking ' . $o['reference'], $o['buyer_name'] . ', ' . money((int)$o['total']) . ', ' . $o['event_title'], '/admin/orders/' . $orderId); check_capacity_alert((int)$o['event_id']); } return get_order($orderId); } function free_booking_needs_confirm(array $o, ?array $user): bool { if (!setting_on('free_email_confirm')) return false; return !($user && strtolower((string)$user['email']) === strtolower((string)$o['buyer_email'])); } /** The secret part of the link that confirms a free booking. Not derivable from the booking page address. */ function free_confirm_code(array $o): string { return substr(hash_hmac('sha256', 'free-confirm|' . $o['id'] . '|' . $o['public_token'], app_key()), 0, 32); } function free_confirm_url(array $o): string { return url('/order/' . $o['public_token'] . '/confirm', ['c' => free_confirm_code($o)]); } /** True while a free booking waits for its email link. */ function awaiting_email_confirm(array $o): bool { return $o['status'] === 'pending_payment' && $o['payment_method'] === 'free'; } function get_order(int $id): ?array { return q1('SELECT o.*, e.title AS event_title, e.slug AS event_slug, e.starts_at, e.ends_at, e.doors_at, e.venue_id, v.name AS venue_name, v.address AS venue_address, v.city AS venue_city, v.map_url AS venue_map FROM {orders} o JOIN {events} e ON e.id = o.event_id LEFT JOIN {venues} v ON v.id = e.venue_id WHERE o.id = ?', [$id]); } function get_order_by_token(string $token): ?array { if (!preg_match('/^[a-f0-9]{64}$/', $token)) return null; $id = qv('SELECT id FROM {orders} WHERE public_token = ?', [$token]); return $id ? get_order((int)$id) : null; } function order_items(int $orderId): array { return q('SELECT oi.*, c.name AS category_name, t.name AS tier_name FROM {order_items} oi JOIN {ticket_categories} c ON c.id = oi.category_id JOIN {ticket_tiers} t ON t.id = oi.tier_id WHERE oi.order_id = ? ORDER BY oi.id', [$orderId])->fetchAll(); } function order_tickets(int $orderId): array { return q('SELECT tk.*, c.name AS category_name, t.name AS tier_name FROM {tickets} tk JOIN {ticket_categories} c ON c.id = tk.category_id JOIN {ticket_tiers} t ON t.id = tk.tier_id WHERE tk.order_id = ? ORDER BY tk.id', [$orderId])->fetchAll(); } function order_link(array $o): string { return url('/order/' . $o['public_token']); } /** True if the items of an order still fit, ignoring this order's own hold. Call inside a transaction holding the event lock. */ function order_still_fits(array $order): bool { $event = q1('SELECT * FROM {events} WHERE id = ?', [$order['event_id']]); $held = held_counts((int)$order['event_id']); $self = q('SELECT category_id, tier_id, qty FROM {order_items} WHERE order_id = ?', [$order['id']])->fetchAll(); $selfHeld = $order['status'] === 'paid' || $order['expires_at'] > now(); $cap = event_cap($event); $total = array_sum(array_column($self, 'qty')); foreach ($self as $it) { $cat = q1('SELECT capacity FROM {ticket_categories} WHERE id = ?', [$it['category_id']]); $tier = q1('SELECT quantity FROM {ticket_tiers} WHERE id = ?', [$it['tier_id']]); $catHeld = ($held['cat'][$it['category_id']] ?? 0) - ($selfHeld ? $it['qty'] : 0); $tierHeld = ($held['tier'][$it['tier_id']] ?? 0) - ($selfHeld ? $it['qty'] : 0); if ($catHeld + $it['qty'] > (int)$cat['capacity']) return false; if ($tier['quantity'] !== null && $tierHeld + $it['qty'] > (int)$tier['quantity']) return false; } if ($cap !== null && $held['total'] - ($selfHeld ? $total : 0) + $total > $cap) return false; return true; } /** Idempotent: confirms the order, issues one ticket per seat, emails the buyer. */ function mark_paid(int $orderId, string $method, ?int $actorId = null, bool $force = false): bool { $result = tx(function () use ($orderId, $method, $force) { $o = q1('SELECT * FROM {orders} WHERE id = ? FOR UPDATE', [$orderId]); if (!$o || $o['status'] === 'paid') return false; $allowed = $o['status'] === 'pending_payment' || ($o['status'] === 'expired' && in_array($method, ['stripe', 'offline'], true)); if (!$allowed) fail('This booking cannot be marked as paid from its current status.'); q('SELECT id FROM {events} WHERE id = ? FOR UPDATE', [$o['event_id']]); $overflow = false; if ($o['expires_at'] <= now() || $o['status'] === 'expired') { if (!order_still_fits($o)) { // Stripe already took the money: confirm and warn staff. Manual confirmations must not oversell. if ($method !== 'stripe' && !$force) fail('The hold on this booking expired and there is no longer enough capacity. Increase capacity first.'); $overflow = true; } } q("UPDATE {orders} SET status = 'paid', paid_at = ?, payment_method = ?, expires_at = NULL WHERE id = ?", [now(), $method, $orderId]); do_action('gatecraft_order_paid_tx', $orderId); // inside the transaction, e.g. for an invoice number issue_tickets($orderId); return ['overflow' => $overflow, 'ref' => $o['reference']]; }); if ($result === false) return false; if ($actorId !== null) audit('order.mark_paid', 'order', $orderId, ['method' => $method]); contact_refresh((string)qv('SELECT buyer_email FROM {orders} WHERE id = ?', [$orderId])); do_action('gatecraft_order_paid', $orderId); email_order($orderId, 'tickets'); if ($result['overflow']) notify_staff('payment', 'Capacity exceeded by booking ' . $result['ref'], 'Payment arrived after the hold expired. Check capacity for this event.', '/admin/orders/' . $orderId); elseif ($method !== 'free') notify_staff('paid', 'Paid booking ' . $result['ref'], 'Payment received (' . ($method === 'stripe' ? 'card' : 'confirmed by staff') . ').', '/admin/orders/' . $orderId); check_capacity_alert((int)qv('SELECT event_id FROM {orders} WHERE id = ?', [$orderId])); return true; } /** One ticket per seat of an order. Call inside the transaction that confirms it. */ function issue_tickets(int $orderId): void { $items = q('SELECT category_id, tier_id, qty FROM {order_items} WHERE order_id = ?', [$orderId])->fetchAll(); foreach ($items as $it) { for ($i = 0; $i < (int)$it['qty']; $i++) { for ($try = 0; ; $try++) { try { q('INSERT INTO {tickets} (order_id, category_id, tier_id, code, created_at) VALUES (?,?,?,?,?)', [$orderId, $it['category_id'], $it['tier_id'], random_code(16), now()]); break; } catch (DbError $ex) { if ($try >= 3 || $ex->getCode() !== 1062) throw $ex; } // 1062: duplicate code, try another } } } } function cancel_order(int $orderId, string $reason = '', bool $notify = true): void { tx(function () use ($orderId, $reason) { $o = q1('SELECT * FROM {orders} WHERE id = ? FOR UPDATE', [$orderId]); if (!$o || !in_array($o['status'], ['awaiting_approval', 'pending_payment', 'paid'], true)) fail('This booking cannot be cancelled.'); // A card payment taken through Stripe has to go back to the buyer, so it is refunded rather than cancelled. if ($o['status'] === 'paid' && $o['payment_method'] === 'stripe' && (int)$o['total'] > 0) fail('This booking was paid by card. Use Refund so the money goes back to the buyer.'); q("UPDATE {orders} SET status = 'cancelled', decision_note = ?, closed_at = ? WHERE id = ?", [mb_substr($reason, 0, 500) ?: null, now(), $orderId]); q("UPDATE {tickets} SET status = 'void' WHERE order_id = ?", [$orderId]); }); audit('order.cancel', 'order', $orderId, ['reason' => $reason]); contact_refresh((string)qv('SELECT buyer_email FROM {orders} WHERE id = ?', [$orderId])); if ($notify) email_order($orderId, 'cancelled'); } function refund_order(int $orderId, string $reason = ''): void { $o = get_order($orderId); if (!$o || $o['status'] !== 'paid') fail('Only confirmed bookings can be refunded.'); if ($o['payment_method'] === 'stripe' && (int)$o['total'] > 0) stripe_refund($o); // throws on failure, before we change anything tx(function () use ($orderId, $reason) { $upd = q("UPDATE {orders} SET status = 'refunded', decision_note = ?, closed_at = ? WHERE id = ? AND status = 'paid'", [mb_substr($reason, 0, 500) ?: null, now(), $orderId]); if ($upd->rowCount() !== 1) fail('Booking changed while refunding. Reload and check.'); q("UPDATE {tickets} SET status = 'void' WHERE order_id = ?", [$orderId]); }); audit('order.refund', 'order', $orderId, ['reason' => $reason]); contact_refresh((string)qv('SELECT buyer_email FROM {orders} WHERE id = ?', [$orderId])); email_order($orderId, 'refunded'); } /** Moves stale holds to expired. Safe to run often. */ function expire_orders(): int { $ids = q("SELECT id, status FROM {orders} WHERE status IN ('awaiting_approval','pending_payment') AND expires_at <= ? LIMIT 500", [now()])->fetchAll(); $n = 0; foreach ($ids as $r) { $upd = q("UPDATE {orders} SET status = 'expired', closed_at = ? WHERE id = ? AND status = ? AND expires_at <= ?", [now(), $r['id'], $r['status'], now()]); if ($upd->rowCount() === 1) { $n++; if ($r['status'] === 'pending_payment' && qv('SELECT payment_method FROM {orders} WHERE id = ?', [$r['id']]) !== 'free') email_order((int)$r['id'], 'expired'); elseif ($r['status'] === 'awaiting_approval') email_order((int)$r['id'], 'expired'); } } return $n; } function answers_list(?string $json): array { $a = $json ? json_decode($json, true) : []; return is_array($a) ? $a : []; }