'POST', 'callback' => array( $this, 'portal_login' ), 'permission_callback' => '__return_true', ) ); // Public — anyone can ask to be notified about an event, same // trust level as creating a booking itself (both just take a // name/contact info from an anonymous visitor). register_rest_route( self::NS, '/waitlist', array( 'methods' => 'POST', 'callback' => array( $this, 'join_waitlist' ), 'permission_callback' => '__return_true', ) ); // GA zones — same public/no-login pattern as seats above. register_rest_route( self::NS, '/zones/hold', array( 'methods' => 'POST', 'callback' => array( $this, 'hold_zone' ), 'permission_callback' => '__return_true', ) ); register_rest_route( self::NS, '/zones/release', array( 'methods' => 'POST', 'callback' => array( $this, 'release_zone' ), 'permission_callback' => '__return_true', ) ); register_rest_route( self::NS, '/bookings', array( 'methods' => 'POST', 'callback' => array( $this, 'create_booking' ), 'permission_callback' => '__return_true', ) ); register_rest_route( self::NS, '/bookings/(?P\d+)/pay', array( 'methods' => 'POST', 'callback' => array( $this, 'pay_booking' ), 'permission_callback' => array( $this, 'can_sell_seats' ), ) ); // Event-day check-in — staff-only (confirms someone's arrival), // but the token in the request body is what's actually // unguessable/secret, not the login itself. // Offline scanner: every valid ticket of one event, with the // check-in token only as a SHA-256 hash — the device can verify a // scanned QR without ever holding the tokens themselves. register_rest_route( self::NS, '/events/(?P\d+)/offline-manifest', array( 'methods' => 'GET', 'callback' => array( $this, 'offline_manifest' ), 'permission_callback' => array( $this, 'can_checkin' ), ) ); register_rest_route( self::NS, '/bookings/(?P\d+)/checkin', array( 'methods' => 'POST', 'callback' => array( $this, 'checkin_booking' ), 'permission_callback' => array( $this, 'can_checkin' ), ) ); // Send a test receipt, to prove the mail settings work before an // event rather than after a buyer fails to get their ticket. register_rest_route( self::NS, '/smtp-test', array( 'methods' => 'POST', 'callback' => array( $this, 'smtp_test' ), 'permission_callback' => function () { // Settings-level access only: this sends mail from the // site and reports back the mail server's own errors, // which can disclose the host and account name. return current_user_can( 'manage_options' ); }, ) ); // Who has arrived so far, for the list under the scanner. Read // only, and behind the same capability as check-in itself. register_rest_route( self::NS, '/events/(?P\d+)/checked-in', array( 'methods' => 'GET', 'callback' => array( $this, 'get_checked_in' ), 'permission_callback' => array( $this, 'can_checkin' ), ) ); // Manual fallback from the admin Confirmation screen — no QR // token needed since the admin session itself is the auth. register_rest_route( self::NS, '/bookings/(?P\d+)/checkin-manual', array( 'methods' => 'POST', 'callback' => array( $this, 'checkin_booking_manual' ), 'permission_callback' => array( $this, 'can_checkin' ), ) ); // Used by the confirmation page to show what was booked. Ushers // need this (their own view after checkout); the buyer's // confirmation page load is unauthenticated, so this is // deliberately public — it only ever returns booking/seat details, // never anything sensitive like payment credentials. register_rest_route( self::NS, '/bookings/(?P\d+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_booking' ), 'permission_callback' => '__return_true', ), // Staff-only: permanently remove a booking (any status, // including 'paid') and free its seats back to available. array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_booking' ), 'permission_callback' => array( $this, 'can_sell_seats' ), ), ) ); // Receipt-page poller: "has this booking been paid yet?". Public // on purpose (the receipt page itself is public) and returns only // the status word — no buyer details, no check-in token. Asking // also triggers an active gateway status check, throttled per // booking inside sync_booking_payment(). register_rest_route( self::NS, '/bookings/(?P\d+)/payment-status', array( 'methods' => 'GET', 'callback' => array( $this, 'booking_payment_status' ), 'permission_callback' => '__return_true', ) ); // Price preview for the cart (early bird, promo, fee, tax). register_rest_route( self::NS, '/quote', array( 'methods' => 'POST', 'callback' => array( $this, 'quote' ), 'permission_callback' => '__return_true', ) ); // Staff: complimentary (Rp 0) ticket for a sponsor/guest. register_rest_route( self::NS, '/bookings/comp', array( 'methods' => 'POST', 'callback' => array( $this, 'create_comp_booking' ), 'permission_callback' => array( $this, 'can_sell_seats' ), ) ); // Staff "Resend receipt" — re-sends the paid receipt email (with // QR) to the buyer, e.g. for bookings paid before the email fix. register_rest_route( self::NS, '/bookings/(?P\d+)/resend-receipt', array( 'methods' => 'POST', 'callback' => array( $this, 'resend_receipt' ), 'permission_callback' => array( $this, 'can_sell_seats' ), ) ); // Staff "Check payment" button on the Bookings screen — forces an // immediate status check with the gateway, no throttle. register_rest_route( self::NS, '/bookings/(?P\d+)/sync-payment', array( 'methods' => 'POST', 'callback' => array( $this, 'sync_booking_payment' ), 'permission_callback' => array( $this, 'can_sell_seats' ), ) ); // Payment gateway webhook — verified by signature, not WP auth. register_rest_route( self::NS, '/payment-webhook', array( 'methods' => 'POST', 'callback' => array( $this, 'payment_webhook' ), 'permission_callback' => '__return_true', ) ); // PayPal redirects the buyer's own BROWSER here (a plain GET, not // a server-to-server webhook) once they approve on PayPal's site // — see SEATT_Payment::handle_paypal_return() for why this gateway // doesn't use the webhook endpoint above the way Xendit/Stripe do. register_rest_route( self::NS, '/paypal-return', array( 'methods' => 'GET', 'callback' => array( 'SEATT_Payment', 'handle_paypal_return' ), 'permission_callback' => '__return_true', ) ); // Where every gateway's own cancel/failure redirect points — see // SEATT_Payment::cancel_url_for_booking() for why this exists: a // booking's seats move to 'sold' the instant it's created, BEFORE // the buyer ever leaves for the gateway's site, so a cancelled/ // failed payment needs this to actually release them again // rather than leaving them silently stuck. register_rest_route( self::NS, '/payment-cancelled', array( 'methods' => 'GET', 'callback' => array( 'SEATT_Payment', 'handle_payment_cancelled' ), 'permission_callback' => '__return_true', ) ); // The "Complete Payment" button on a still-pending booking's // receipt page — see SEATT_Payment::handle_retry_payment() for why // this generates a brand new gateway session rather than trying // to reuse/store the original one. register_rest_route( self::NS, '/retry-payment', array( 'methods' => 'GET', 'callback' => array( 'SEATT_Payment', 'handle_retry_payment' ), 'permission_callback' => '__return_true', ) ); } public function can_manage_charts() { return current_user_can( 'seatt_manage_charts' ); } /** * Verifies username/password via WordPress's OWN wp_signon() (the * exact same check wp-login.php itself uses — no shortcuts on * security here) and, on success, sets the real WP auth cookies * exactly like a normal login would. The only difference from a * normal login is that the caller (the portal page's own JS) decides * what to show for success/failure, instead of wp-login.php doing it * with its own themed page. */ /** * Brute-force protection for the custom login endpoint above — a * plugin like Limit Login Attempts protects the REAL wp-login.php, * but has no way to know this REST endpoint exists, so it needed its * own lockout here. Tracked with plain WP transients (no new DB * table needed): after MAX_ATTEMPTS wrong passwords for the same * username+IP pair within the tracking window, further attempts are * refused outright — without even touching wp_signon() — until the * lockout expires. */ const LOGIN_MAX_ATTEMPTS = 5; const LOGIN_LOCKOUT_SECS = 15 * MINUTE_IN_SECONDS; /** Best-effort real client IP — deliberately just REMOTE_ADDR, not * any X-Forwarded-For/X-Real-IP header. Those headers are trivial * for the CLIENT itself to fake (curl -H "X-Forwarded-For: ...") * unless the host's proxy is known to overwrite rather than append * to them, which isn't something this plugin can assume about every * hosting setup it might run on — trusting them blindly would let an * attacker rotate through fake IPs and defeat this lockout entirely. */ private function get_client_ip() { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; } private function login_lockout_key( $username ) { return 'seatt_login_fail_' . md5( $this->get_client_ip() . '|' . strtolower( $username ) ); } public function portal_login( WP_REST_Request $req ) { $body = $req->get_json_params(); $username = isset( $body['username'] ) ? sanitize_user( $body['username'] ) : ''; $password = isset( $body['password'] ) ? (string) $body['password'] : ''; if ( '' === $username || '' === $password ) { return new WP_Error( 'seatt_login_failed', __( 'Please enter both a username and password.', 'seathive' ), array( 'status' => 400 ) ); } $lockout_key = $this->login_lockout_key( $username ); $attempts = (int) get_transient( $lockout_key ); if ( $attempts >= self::LOGIN_MAX_ATTEMPTS ) { return new WP_Error( 'seatt_login_locked', __( 'Too many failed login attempts. Please wait 15 minutes and try again.', 'seathive' ), array( 'status' => 429 ) ); } $user = wp_signon( array( 'user_login' => $username, 'user_password' => $password, 'remember' => true, ), is_ssl() ); if ( is_wp_error( $user ) ) { // A fresh transient starts its own countdown; get_transient() // itself returns false (cast to 0 above) once the PREVIOUS // one expired, so this naturally resets after a clean window // rather than needing separate cleanup logic. set_transient( $lockout_key, $attempts + 1, self::LOGIN_LOCKOUT_SECS ); // wp_signon()'s own messages mention wp-login.php/lost-password // URLs by name — deliberately replaced with a generic message // instead, since this portal has no such pages of its own to // link to and doing so would just be a dead end for someone // using a portal-only account. return new WP_Error( 'seatt_login_failed', __( 'Incorrect username or password.', 'seathive' ), array( 'status' => 401 ) ); } // A genuine success clears the slate — an admin who fat-fingered // their password a couple of times before getting it right // shouldn't stay one step away from a lockout for the next 15 // minutes just because of that. delete_transient( $lockout_key ); return rest_ensure_response( array( 'success' => true ) ); } /** * A simple rate limit re-using the SAME transient mechanism as the * portal login lockout above — a public, unauthenticated form with * no CAPTCHA is otherwise an easy target for a script to flood with * junk entries. */ public function join_waitlist( WP_REST_Request $req ) { $body = $req->get_json_params(); $event_id = isset( $body['event_id'] ) ? intval( $body['event_id'] ) : 0; $name = isset( $body['name'] ) ? sanitize_text_field( $body['name'] ) : ''; $email = isset( $body['email'] ) ? sanitize_email( $body['email'] ) : ''; $phone = isset( $body['phone'] ) ? sanitize_text_field( $body['phone'] ) : ''; $quantity = isset( $body['quantity'] ) ? max( 1, intval( $body['quantity'] ) ) : 1; $note = isset( $body['note'] ) ? sanitize_text_field( $body['note'] ) : ''; if ( ! $event_id || ! get_post( $event_id ) ) { return new WP_Error( 'seatt_bad_request', __( 'Unknown event.', 'seathive' ), array( 'status' => 400 ) ); } if ( '' === $name || ! is_email( $email ) ) { return new WP_Error( 'seatt_bad_request', __( 'Please enter your name and a valid email address.', 'seathive' ), array( 'status' => 400 ) ); } $rate_key = 'seatt_waitlist_rl_' . md5( $this->get_client_ip() ); if ( (int) get_transient( $rate_key ) >= 5 ) { return new WP_Error( 'seatt_rate_limited', __( 'Too many requests — please try again in a few minutes.', 'seathive' ), array( 'status' => 429 ) ); } set_transient( $rate_key, (int) get_transient( $rate_key ) + 1, 10 * MINUTE_IN_SECONDS ); SEATT_DB::add_to_waitlist( $event_id, $name, $email, $phone, $quantity, $note ); return rest_ensure_response( array( 'success' => true ) ); } public function can_sell_seats() { return current_user_can( 'seatt_sell_seats' ); } /** Check-in accepts any of THREE capabilities — the full Usher one, * the narrow QR-only Check-in Portal one, or the new Confirmation * Staff one — so all three can actually check a guest in from * wherever their own page/portal already lets them see the booking, * without any of them gaining the OTHERS' access (e.g. Confirmation * Staff still can't sell/hold seats or see the Bookings page). */ /** * The arrivals list shown beneath the QR scanner. * * Deliberately narrow: names, seat labels and arrival time only. * Staff working the door need to recognise a guest and see that the * scan registered — they do not need buyer emails, phone numbers or * amounts paid, and this portal is often open on a shared device at * an entrance where anyone nearby can read the screen. * * Newest first, since the useful question at a door is almost always * "did that last scan go through?". */ public function get_checked_in( WP_REST_Request $req ) { $event_id = intval( $req->get_param( 'event_id' ) ); if ( ! $event_id ) { return new WP_Error( 'seatt_bad_event', 'Missing event id.', array( 'status' => 400 ) ); } // Check-in Staff accounts are pinned to the events ticked on // their profile; without this, one door's tablet could pull the // guest list for an event it has nothing to do with. if ( ! $this->can_checkin_event( $event_id ) ) { return new WP_Error( 'seatt_forbidden_event', 'This account is not assigned to that event.', array( 'status' => 403 ) ); } $rows = array(); foreach ( SEATT_DB::get_attendance_for_event( $event_id ) as $b ) { if ( empty( $b->checked_in_at ) ) { continue; } $seats = array(); $attendees = array(); foreach ( (array) $b->seats as $s ) { $seats[] = seatt_seat_label( $s->row_name, $s->seat_label ); // The name printed on the ticket is the ATTENDEE's, which // is often not the buyer's — one person books six seats // for friends. Searching the door list for the name a // guest gives you found nothing, because only the buyer // was sent. Skip attendees who just repeat the buyer so // the row doesn't say the same name twice. $attendee = trim( (string) $s->attendee_name ); if ( '' !== $attendee && 0 !== strcasecmp( $attendee, (string) $b->buyer_name ) ) { $attendees[] = $attendee; } } foreach ( (array) $b->zones as $z ) { $seats[] = $z->zone_name . ' ×' . intval( $z->quantity ); foreach ( (array) ( $z->holders ?? array() ) as $h ) { if ( '' !== trim( (string) $h->name ) && 0 !== strcasecmp( trim( (string) $h->name ), (string) $b->buyer_name ) ) { $attendees[] = trim( (string) $h->name ); } } } $rows[] = array( 'booking_id' => intval( $b->id ), 'name' => $b->buyer_name, 'attendees' => array_values( array_unique( $attendees ) ), 'seats' => $seats, 'checked_in_at' => $b->checked_in_at, 'time' => mysql2date( 'H:i', $b->checked_in_at ), ); } // get_attendance_for_event() orders by booking date; the door // cares about arrival order instead. usort( $rows, function ( $a, $b ) { return strcmp( $b['checked_in_at'], $a['checked_in_at'] ); } ); return rest_ensure_response( array( 'total' => count( $rows ), 'arrived' => $rows, ) ); } /** * Is this account allowed to work the door for $event_id? * * Sellers and admins are unrestricted. A Check-in Staff account is * pinned to the events ticked on its profile, so a tablet issued for * one event cannot be pointed at another one's guests. */ private function can_checkin_event( $event_id ) { if ( current_user_can( 'seatt_sell_seats' ) || current_user_can( 'manage_options' ) ) { return true; } $assigned = array_filter( array_map( 'intval', (array) get_user_meta( get_current_user_id(), 'seatt_assigned_event_ids', true ) ) ); return in_array( intval( $event_id ), $assigned, true ); } public function smtp_test( WP_REST_Request $req ) { $to = sanitize_email( (string) $req->get_param( 'to' ) ); if ( ! $to ) { $to = wp_get_current_user()->user_email; } $result = SEATT_SMTP::send_test( $to ); if ( is_wp_error( $result ) ) { return new WP_Error( 'seatt_smtp_failed', $result->get_error_message(), array( 'status' => 400 ) ); } return rest_ensure_response( array( 'sent' => true, 'to' => $to, ) ); } public function can_checkin() { return current_user_can( 'seatt_sell_seats' ) || current_user_can( 'seatt_checkin_only' ) || current_user_can( 'seatt_view_confirmation' ); } /* ------------------------------------------------------------- */ /** * Belt-and-suspenders against any caching plugin/CDN/proxy serving a * stale response after a save — these two endpoints must always * reflect the current DB state, never a cached one. */ private function no_cache_headers() { nocache_headers(); header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' ); } /** Sets (replaces) how many tickets this session currently wants * from one zone — send quantity 0 to clear it. Mirrors hold_seats() * but for a QUANTITY rather than a list of specific IDs, since a * zone ticket has no individual identity to hold. */ public function hold_zone( WP_REST_Request $req ) { $body = $req->get_json_params(); $zone_id = intval( $body['zone_id'] ?? 0 ); $quantity = intval( $body['quantity'] ?? 0 ); $session_id = sanitize_text_field( $body['session_id'] ?? '' ); if ( ! $zone_id || ! $session_id ) { return new WP_Error( 'seatt_bad_request', __( 'zone_id and session_id are required.', 'seathive' ), array( 'status' => 400 ) ); } if ( ! SEATT_Security::valid_session_id( $session_id ) ) { return new WP_Error( 'seatt_bad_session', __( 'Please reload the page and try again.', 'seathive' ), array( 'status' => 400 ) ); } if ( $err = SEATT_Security::throttle( 'hold', 40, MINUTE_IN_SECONDS ) ) { return $err; } global $wpdb; if ( SEATT_Event_Cancel::is_cancelled( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT event_id FROM %i WHERE id = %d', $wpdb->seatt_zones, $zone_id ) ) ) ) { return new WP_Error( 'seatt_event_cancelled', __( 'This event has been cancelled — tickets are no longer sold.', 'seathive' ), array( 'status' => 410 ) ); } if ( ! SEATT_Security::is_staff() && $quantity > 0 ) { $ev = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT event_id FROM %i WHERE id = %d', $wpdb->seatt_zones, $zone_id ) ); if ( SEATT_DB::cart_size( $session_id, $zone_id ) + $quantity > SEATT_Security::max_for_event( $ev ) ) { return new WP_Error( 'seatt_order_limit', SEATT_Security::limit_message( $ev ), array( 'status' => 400 ) ); } } // Extra rules (ticket-only mode: sales end, max per ticket type). $zone_row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $wpdb->seatt_zones, $zone_id ) ); $allowed = apply_filters( 'seatt_zone_hold_allowed', true, $zone_row, $quantity ); if ( is_wp_error( $allowed ) ) { return $allowed; } $result = SEATT_DB::hold_zone_tickets( $zone_id, $quantity, $session_id ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( array( 'success' => true ) ); } public function release_zone( WP_REST_Request $req ) { $body = $req->get_json_params(); $zone_id = intval( $body['zone_id'] ?? 0 ); $session_id = sanitize_text_field( $body['session_id'] ?? '' ); $ok = SEATT_DB::release_zone_hold( $zone_id, $session_id ); return rest_ensure_response( array( 'success' => (bool) $ok ) ); } public function create_booking( WP_REST_Request $req ) { $body = $req->get_json_params(); $event_id = intval( $body['event_id'] ?? 0 ); $session_id = sanitize_text_field( $body['session_id'] ?? '' ); $buyer_name = sanitize_text_field( $body['buyer_name'] ?? '' ); $buyer_phone = sanitize_text_field( $body['buyer_phone'] ?? '' ); // Digits only. Enforced here as well as in the browser, because // the field is posted to a public REST route that anyone can call // directly — a client-side pattern alone stops honest typos, not // junk in the database that door staff then can't dial. // Formatting characters people naturally type are tolerated and // stripped rather than rejected: +62, spaces, dashes, brackets. $phone_digits = preg_replace( '/[^0-9]/', '', $buyer_phone ); $phone_clean = preg_replace( '/[\s\-\(\)\.\+]/', '', $buyer_phone ); if ( '' === $phone_digits ) { return new WP_Error( 'seatt_phone_required', __( 'Please enter a phone number.', 'seathive' ), array( 'status' => 400 ) ); } if ( $phone_clean !== $phone_digits ) { return new WP_Error( 'seatt_phone_invalid', __( 'Phone number must contain digits only.', 'seathive' ), array( 'status' => 400 ) ); } $buyer_phone = $phone_digits; $buyer_email = sanitize_email( $body['buyer_email'] ?? '' ); $attendees = $body['attendees'] ?? array(); $zone_items = $body['zone_items'] ?? array(); // Honeypot: a field real buyers never see or fill. Bots that // fill every input give themselves away. if ( '' !== trim( (string) ( $body['website'] ?? '' ) ) ) { return new WP_Error( 'seatt_bad_request', __( 'Could not process this booking.', 'seathive' ), array( 'status' => 400 ) ); } if ( ! SEATT_Security::valid_session_id( $session_id ) ) { return new WP_Error( 'seatt_bad_session', __( 'Please reload the page and try again.', 'seathive' ), array( 'status' => 400 ) ); } if ( SEATT_Event_Cancel::is_cancelled( $event_id ) ) { return new WP_Error( 'seatt_event_cancelled', __( 'This event has been cancelled — tickets are no longer sold.', 'seathive' ), array( 'status' => 410 ) ); } if ( $err = SEATT_Security::throttle( 'book', 8, 10 * MINUTE_IN_SECONDS ) ) { return $err; } if ( ! SEATT_Security::is_staff() ) { $qty = count( (array) $attendees ); foreach ( (array) $zone_items as $zi ) { $qty += max( 0, intval( $zi['quantity'] ?? 0 ) ); } $max = SEATT_Security::max_for_event( $event_id ); if ( $qty > $max ) { return new WP_Error( 'seatt_order_limit', SEATT_Security::limit_message( $event_id ), array( 'status' => 400 ) ); } // Per-person limit: count what this email/phone already booked // for the event, so repeat checkouts can't exceed it. if ( 'person' === SEATT_Security::limit_scope( $event_id ) ) { $already = SEATT_Security::booked_by_person( $event_id, $body['buyer_email'] ?? '', $body['buyer_phone'] ?? '' ); if ( $already + $qty > $max ) { return new WP_Error( 'seatt_person_limit', $already ? sprintf( /* translators: %1$d: number, %2$d: number */ __( 'You have already booked %1$d seat(s) for this event. The maximum is %2$d per person.', 'seathive' ), $already, $max ) : SEATT_Security::limit_message( $event_id ), array( 'status' => 400 ) ); } } } // A cart can now be seats-only, zone-tickets-only, or both — only // require that AT LEAST ONE of the two is present. if ( ! $event_id || ! $session_id || ( empty( $attendees ) && empty( $zone_items ) ) ) { return new WP_Error( 'seatt_bad_request', __( 'event_id, session_id and at least one seat or zone ticket are required.', 'seathive' ), array( 'status' => 400 ) ); } // Ticket holders: one ticket → the buyer; more → name + email per // ticket (date of birth optional), for seats and quantity tickets. $prepared = SEATT_Attendees::prepare( (array) $attendees, (array) $zone_items, array( 'name' => $buyer_name, 'email' => $buyer_email ) ); if ( is_wp_error( $prepared ) ) { return $prepared; } list( $attendees, $zone_items ) = $prepared; foreach ( $attendees as $a ) { if ( empty( $a['seat_id'] ) || empty( $a['attendee_name'] ) ) { return new WP_Error( 'seatt_bad_request', __( 'Every seat needs an attendee name.', 'seathive' ), array( 'status' => 400 ) ); } } // Checkout questions (Event Settings → Checkout questions). $answers = class_exists( 'SEATT_Checkout_Fields' ) ? SEATT_Checkout_Fields::validate( $event_id, $body['custom_fields'] ?? array(), SEATT_Security::is_staff() ) : array(); if ( is_wp_error( $answers ) ) { return $answers; } $user_id = get_current_user_id() ?: null; $booking_id = SEATT_DB::create_booking( $event_id, $session_id, $attendees, $buyer_name, $buyer_phone, $buyer_email, $user_id, $zone_items, sanitize_text_field( $body['promo_code'] ?? '' ) ); if ( is_wp_error( $booking_id ) ) { return $booking_id; } $booking = SEATT_DB::get_booking( $booking_id ); $duplicate = SEATT_DB::$last_was_duplicate; if ( $duplicate ) { // The same order again (double click, retried request, second // tab): hand back the order that already exists and the same // payment page — never a second order or a second invoice. $payment = get_transient( 'seatt_pay_' . (int) $booking_id ); if ( ! is_array( $payment ) ) { $payment = 'paid' === $booking->status ? array( 'method' => 'paid', 'message' => __( 'This order is already confirmed.', 'seathive' ) ) : SEATT_Payment::create_payment( $booking ); } SEATT_DB::log_activity( $booking->event_id, $booking->id, get_current_user_id() ?: null, 'duplicate_order_blocked' ); return rest_ensure_response( array( 'success' => true, 'duplicate' => true, 'booking_id' => (int) $booking_id, 'receipt_key' => SEATT_DB::receipt_key( $booking ), 'total' => $booking->total, 'payment' => $payment, ) ); } if ( $answers ) { SEATT_Checkout_Fields::store( $booking_id, $event_id, $answers ); $booking = SEATT_DB::get_booking( $booking_id ); } // Kick off payment (stub — see class-esm-payment.php). $payment = SEATT_Payment::create_payment( $booking ); if ( empty( $payment['error'] ) ) { set_transient( 'seatt_pay_' . (int) $booking_id, $payment, HOUR_IN_SECONDS ); } // A gateway refusal used to vanish: the buyer saw a short // message, the booking sat at pending_payment, and nothing // anywhere recorded WHY. With no trace there is no way to tell a // wrong secret key from a blocked outbound connection from a // rejected amount — the three need completely different fixes. if ( ! empty( $payment['error'] ) ) { SEATT_DB::log_activity( $booking->event_id, $booking->id, get_current_user_id() ?: null, 'payment_failed', sprintf( '%s: %s', $payment['method'] ?? 'gateway', $payment['error'] ) ); } return rest_ensure_response( array( 'success' => true, 'booking_id' => $booking_id, 'receipt_key' => SEATT_DB::receipt_key( $booking ), 'total' => $booking->total, 'payment' => $payment, ) ); } public function pay_booking( WP_REST_Request $req ) { // Manual "mark as paid" endpoint for cash-on-the-spot sales by an // usher (no online gateway involved). Real gateway payments should // come through payment_webhook() instead. $booking_id = intval( $req['id'] ); $booking = SEATT_DB::get_booking( $booking_id ); if ( ! $booking ) { return new WP_Error( 'seatt_not_found', __( 'Booking not found.', 'seathive' ), array( 'status' => 404 ) ); } if ( 'paid' === $booking->status ) { return rest_ensure_response( array( 'success' => true, 'already' => true ) ); } // A cancelled booking already gave its seats back — confirming it // now could sell a seat that has since gone to someone else. if ( 'pending_payment' !== $booking->status ) { return new WP_Error( 'seatt_not_pending', __( 'Only unpaid (pending) bookings can be confirmed.', 'seathive' ), array( 'status' => 409 ) ); } SEATT_DB::mark_booking_paid( $booking_id, 'cash-' . get_current_user_id(), 'cash' ); return rest_ensure_response( array( 'success' => true ) ); } public function offline_manifest( WP_REST_Request $req ) { global $wpdb; $this->no_cache_headers(); $event_id = intval( $req['id'] ); if ( ! $this->can_checkin_event( $event_id ) ) { return new WP_Error( 'seatt_forbidden_event', __( 'This account is not assigned to that event.', 'seathive' ), array( 'status' => 403 ) ); } $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, buyer_name, checkin_token, checked_in_at FROM %i WHERE event_id = %d AND status = \'paid\' AND checkin_token IS NOT NULL', $wpdb->seatt_bookings, $event_id ) ); $tickets = array(); if ( class_exists( 'SEATT_Ticket_Codes' ) ) { SEATT_Ticket_Codes::ensure_event( $event_id ); } foreach ( $rows as $r ) { $with = SEATT_DB::get_booking_with_seats( $r->id ); $seats = array(); foreach ( (array) ( $with['seats'] ?? array() ) as $s ) { $seats[] = array( 'label' => seatt_seat_label( $s->row_name, $s->seat_label ), 'category' => SEATT_Categories::name( $event_id, $s->category ), 'attendee_name' => $s->attendee_name ); } foreach ( (array) ( $with['zones'] ?? array() ) as $z ) { $seats[] = array( 'label' => $z->zone_name . ' ×' . (int) $z->quantity, 'category' => ucfirst( $z->category ?? '' ), 'attendee_name' => '' ); } // Each ticket's own QR, verified on the phone the same way. $own = array(); if ( class_exists( 'SEATT_Ticket_Codes' ) ) { foreach ( SEATT_Ticket_Codes::rows( $r->id ) as $t ) { $own[ (string) $t->id ] = array( 'h' => hash( 'sha256', (string) $t->token ), 'name' => $t->holder_name, 'label' => $t->label, 'checked' => (bool) $t->checked_in_at ); } } $tickets[ (string) $r->id ] = array( 'h' => hash( 'sha256', (string) $r->checkin_token ), 'name' => $r->buyer_name, 'seats' => $seats, 'checked' => (bool) $r->checked_in_at && ( ! $own || ! in_array( false, wp_list_pluck( $own, 'checked' ), true ) ), 't' => (object) $own, ); } return rest_ensure_response( array( 'event_id' => $event_id, 'title' => get_the_title( $event_id ), 'generated' => current_time( 'mysql' ), 'tickets' => $tickets, ) ); } public function checkin_booking( WP_REST_Request $req ) { $booking_id = intval( $req['id'] ); $body = $req->get_json_params(); $token = sanitize_text_field( $body['token'] ?? '' ); // A ticket's own QR carries its ticket id (seatt_t): it admits that // one person. Without it, the booking's (group) QR admits everyone. $ticket_id = intval( $body['ticket_id'] ?? 0 ); $ticket = null; if ( $ticket_id && class_exists( 'SEATT_Ticket_Codes' ) ) { $ticket = SEATT_Ticket_Codes::find( $booking_id, $ticket_id, $token ); $booking = $ticket ? SEATT_DB::get_booking( $booking_id ) : null; } else { $booking = SEATT_DB::get_booking_by_checkin_token( $booking_id, $token ); } if ( ! $booking ) { return new WP_Error( 'seatt_bad_token', __( 'This QR code doesn\'t match a valid booking.', 'seathive' ), array( 'status' => 404 ) ); } // Every refused scan is logged, so the Live dashboard can show // "Refused at the door" and why. // A phone camera keeps seeing the same QR for seconds: the same // refusal is logged once per minute, not once per frame (that // flooded "Latest arrivals" with one guest's name). $refuse = function ( $reason ) use ( $booking, &$ticket ) { global $wpdb; if ( $ticket ) { $reason .= '|t:' . (int) $ticket->id; } $recent = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE booking_id = %d AND action = \'checkin_refused\' AND details = %s AND created_at >= %s', $wpdb->seatt_activity_log, $booking->id, $reason, gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - MINUTE_IN_SECONDS ) ) ); if ( ! $recent ) { SEATT_DB::log_activity( $booking->event_id, $booking->id, get_current_user_id() ?: null, 'checkin_refused', $reason ); } }; if ( 'cancelled' === $booking->status || 'refunded' === $booking->status ) { $refuse( 'refunded' === $booking->status ? 'refunded' : 'cancelled' ); return new WP_Error( 'seatt_cancelled', __( 'This booking was cancelled — cannot check in.', 'seathive' ), array( 'status' => 409 ) ); } // Refunded tickets used to scan green (only "cancelled" was // blocked), and so did unpaid ones. if ( 'paid' !== $booking->status ) { $refuse( 'unpaid' ); return new WP_Error( 'seatt_unpaid', __( 'This booking has not been paid yet — cannot check in.', 'seathive' ), array( 'status' => 409 ) ); } // The door being worked, as chosen in the scanner's Event picker. // Without this comparison a ticket for ANY event was accepted at // ANY door: the token identifies a booking, and nothing tied that // booking to the event actually taking place. Someone holding // last month's ticket, or a ticket for the other hall tonight, // scanned green — and was recorded as arrived on the wrong // event's attendance list, corrupting both. $scanned_event_id = intval( $req->get_param( 'event_id' ) ); if ( $scanned_event_id && intval( $booking->event_id ) !== $scanned_event_id ) { SEATT_DB::log_activity( $scanned_event_id, $booking->id, get_current_user_id() ?: null, 'checkin_refused', 'wrong_event' ); $booked_event = get_post( $booking->event_id ); return new WP_Error( 'seatt_wrong_event', sprintf( /* translators: %s: name of the event the ticket is actually for */ __( 'This ticket is for a different event (%s).', 'seathive' ), $booked_event ? $booked_event->post_title : __( 'unknown event', 'seathive' ) ), array( 'status' => 409 ) ); } if ( ! $this->can_checkin_event( $booking->event_id ) ) { return new WP_Error( 'seatt_forbidden_event', __( 'This account is not assigned to that event.', 'seathive' ), array( 'status' => 403 ) ); } $user_id = get_current_user_id() ?: null; // Checked BEFORE calling check_in_booking() (which is written to // be idempotent — re-scanning an already-checked-in booking just // returns its original timestamp, doesn't error or overwrite it) // so the scanner can tell the difference and show "already // checked in" instead of just re-showing the same success card. if ( $ticket ) { $already_checked_in = ! empty( $ticket->checked_in_at ); $already_at = $ticket->checked_in_at; } else { // Group QR: "already" only once every ticket of the booking is in. $prog = class_exists( 'SEATT_Ticket_Codes' ) ? ( SEATT_Ticket_Codes::ensure( $booking_id ) ? SEATT_Ticket_Codes::progress( $booking_id ) : array( 'arrived' => 0, 'total' => 0 ) ) : array( 'arrived' => 0, 'total' => 0 ); $already_checked_in = ! empty( $booking->checked_in_at ) && ( ! $prog['total'] || $prog['arrived'] >= $prog['total'] ); $already_at = $booking->checked_in_at; } if ( $already_checked_in ) { $refuse( 'already:' . mysql2date( 'H:i', $already_at ) ); } // Offline scanner sync: honour the real scan time (sanity-bounded). $at = null; $scanned = (string) $req->get_param( 'scanned_at' ); if ( '' !== $scanned && ( $ts = strtotime( $scanned ) ) ) { // $ts is a real (UTC) timestamp from the device's ISO time; // checked_in_at is stored in site-local time like the rest. if ( $ts <= time() + 300 && $ts >= time() - 2 * DAY_IN_SECONDS ) { $at = get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $ts ) ); } } if ( $ticket ) { if ( ! $already_checked_in ) { SEATT_Ticket_Codes::check_in( $ticket->id, $user_id, $at ); } // The booking itself counts as "arrived" from its first ticket on. $result = SEATT_DB::check_in_booking( $booking_id, $user_id, $at ? 'qr-offline' : 'qr-ticket', $at, false ); $result = $already_checked_in ? $already_at : ( $at ?: current_time( 'mysql' ) ); } else { $result = SEATT_DB::check_in_booking( $booking_id, $user_id, $at ? 'qr-offline' : 'qr', $at ); } if ( is_wp_error( $result ) ) { return $result; } $with_seats = SEATT_DB::get_booking_with_seats( $booking_id ); $seat_labels = array(); $seat_details = array(); foreach ( ( $with_seats['seats'] ?? array() ) as $s ) { $label = seatt_seat_label( $s->row_name, $s->seat_label ); $seat_labels[] = $label; $seat_details[] = array( 'label' => $label, 'attendee_name' => $s->attendee_name, 'category' => SEATT_Categories::name( $booking->event_id, $s->category ), ); } $extra = array(); if ( $ticket ) { // One person: show their name and their seat only. foreach ( SEATT_Ticket_Codes::rows( $booking_id ) as $t ) { if ( (int) $t->id === (int) $ticket->id ) { $seat_labels = array( $t->label ); $seat_details = array( array( 'label' => $t->label, 'attendee_name' => $t->holder_name, 'category' => $t->seat_id ? SEATT_Categories::name( $booking->event_id, (string) $t->seat_category ) : (string) $t->zone_name ) ); $extra = array( 'ticket' => array( 'id' => (int) $t->id, 'label' => $t->label, 'holder' => $t->holder_name, 'no' => (int) $t->ticket_no ) ); } } } if ( class_exists( 'SEATT_Ticket_Codes' ) ) { $extra['progress'] = SEATT_Ticket_Codes::progress( $booking_id ); } return rest_ensure_response( array( 'success' => true, 'already_checked_in' => $already_checked_in, 'checked_in_at' => $result, 'buyer_name' => $ticket ? ( $extra['ticket']['holder'] ?? $booking->buyer_name ) : $booking->buyer_name, 'seats' => $seat_labels, 'seat_details' => $seat_details, ) + $extra ); } public function checkin_booking_manual( WP_REST_Request $req ) { $booking_id = intval( $req['id'] ); $booking = SEATT_DB::get_booking( $booking_id ); if ( ! $booking ) { return new WP_Error( 'seatt_not_found', __( 'Booking not found.', 'seathive' ), array( 'status' => 404 ) ); } if ( in_array( $booking->status, array( 'cancelled', 'refunded' ), true ) ) { return new WP_Error( 'seatt_cancelled', __( 'This booking was cancelled — cannot check in.', 'seathive' ), array( 'status' => 409 ) ); } // Same restriction as the scanner: an account pinned to one event // must not be able to check people in on another, even though // this route takes a booking id straight from the admin list. if ( ! $this->can_checkin_event( $booking->event_id ) ) { return new WP_Error( 'seatt_forbidden_event', __( 'This account is not assigned to that event.', 'seathive' ), array( 'status' => 403 ) ); } $user_id = get_current_user_id() ?: null; $result = SEATT_DB::check_in_booking( $booking_id, $user_id, 'manual' ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( array( 'success' => true, 'checked_in_at' => $result ) ); } public function booking_payment_status( WP_REST_Request $req ) { $this->no_cache_headers(); // Each call can trigger an outbound DOKU request; the per-booking // throttle alone doesn't stop someone walking ids 1..N. if ( $err = SEATT_Security::throttle( 'paystatus', 30, MINUTE_IN_SECONDS ) ) { return $err; } $booking = SEATT_DB::get_booking( intval( $req['id'] ) ); if ( ! $booking ) { return new WP_Error( 'seatt_not_found', __( 'Booking not found.', 'seathive' ), array( 'status' => 404 ) ); } if ( 'pending_payment' === $booking->status ) { SEATT_Payment::sync_booking_payment( $booking, 20 ); $booking = SEATT_DB::get_booking( $booking->id ); } return rest_ensure_response( array( 'status' => $booking ? $booking->status : 'unknown' ) ); } /** * Cart preview. Prices come from the DB by seat/zone id — the client * only says WHICH seats, never what they cost. */ public function quote( WP_REST_Request $req ) { if ( $err = SEATT_Security::throttle( 'quote', 60, MINUTE_IN_SECONDS ) ) { return $err; } global $wpdb; $b = (array) $req->get_json_params(); $event_id = intval( $b['event_id'] ?? 0 ); $chart = SEATT_DB::get_chart_for_event( $event_id ); $lines = array(); $seat_ids = array_slice( array_values( array_filter( array_map( 'intval', (array) ( $b['seat_ids'] ?? array() ) ) ) ), 0, 200 ); if ( $chart && $seat_ids ) { $ph = implode( ',', array_fill( 0, count( $seat_ids ), '%d' ) ); // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the interpolated parts are lists of %d/%s placeholders or WHERE fragments built with $wpdb->prepare() $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT category, price FROM %i WHERE chart_id = %d AND id IN ( ' . $ph . ' )', array_merge( array( $wpdb->seatt_seats, $chart->id ), $seat_ids ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter foreach ( $rows as $r ) { $lines[] = array( 'category' => $r->category, 'price' => (float) $r->price, 'qty' => 1 ); } } foreach ( array_slice( (array) ( $b['zone_items'] ?? array() ), 0, 50 ) as $zi ) { $z = $wpdb->get_row( $wpdb->prepare( 'SELECT category, price FROM %i WHERE id = %d AND event_id = %d AND is_active = 1', $wpdb->seatt_zones, intval( $zi['zone_id'] ?? 0 ), $event_id ) ); if ( $z ) { $lines[] = array( 'category' => $z->category, 'price' => (float) $z->price, 'qty' => max( 0, min( 100, intval( $zi['quantity'] ?? 0 ) ) ) ); } } return rest_ensure_response( SEATT_Pricing::quote( $event_id, $lines, sanitize_text_field( $b['promo_code'] ?? '' ), sanitize_email( $b['email'] ?? '' ) ) ); } public function create_comp_booking( WP_REST_Request $req ) { $b = (array) $req->get_json_params(); $event_id = intval( $b['event_id'] ?? 0 ); $name = sanitize_text_field( $b['guest_name'] ?? '' ); $email = sanitize_email( $b['guest_email'] ?? '' ); $phone = sanitize_text_field( $b['guest_phone'] ?? '' ); $labels = array_filter( array_map( 'trim', preg_split( '/[,;\n]+/', (string) ( $b['seats'] ?? '' ) ) ) ); if ( ! $event_id || '' === $name ) { return new WP_Error( 'seatt_bad_request', __( 'Choose an event and enter the guest\'s name.', 'seathive' ), array( 'status' => 400 ) ); } // Required: the e-ticket is emailed, and the door team may need to call. if ( ! $email || ! is_email( $email ) ) { return new WP_Error( 'seatt_bad_request', __( 'Enter a valid email — the e-ticket is sent there.', 'seathive' ), array( 'status' => 400 ) ); } if ( strlen( preg_replace( '/\D/', '', $phone ) ) < 8 ) { return new WP_Error( 'seatt_bad_request', __( 'Enter a valid phone number.', 'seathive' ), array( 'status' => 400 ) ); } $mode = 'onsite' === ( $b['payment'] ?? '' ) ? 'onsite' : 'comp'; // Tickets-only events (or quantity tickets): ticket type + quantity // instead of seat labels. $items = array(); foreach ( (array) ( $b['tickets'] ?? array() ) as $zid => $qty ) { if ( (int) $qty > 0 ) { $items[ (int) $zid ] = min( 50, (int) $qty ); } } if ( $items ) { $id = SEATT_DB::create_comp_ticket_booking( $event_id, $items, $name, $phone, $email, get_current_user_id() ?: null, $mode ); } elseif ( ! $labels ) { return new WP_Error( 'seatt_bad_request', __( 'Enter the seat(s) or choose a ticket type and quantity.', 'seathive' ), array( 'status' => 400 ) ); } else { $id = SEATT_DB::create_comp_booking( $event_id, $labels, $name, $phone, $email, get_current_user_id() ?: null, $mode ); } if ( is_wp_error( $id ) ) { $id->add_data( array( 'status' => 409 ) ); return $id; } return rest_ensure_response( array( 'booking_id' => $id ) ); } public function resend_receipt( WP_REST_Request $req ) { $booking = SEATT_DB::get_booking( intval( $req['id'] ) ); if ( ! $booking ) { return new WP_Error( 'seatt_not_found', __( 'Booking not found.', 'seathive' ), array( 'status' => 404 ) ); } if ( 'paid' !== $booking->status ) { return new WP_Error( 'seatt_not_paid', __( 'Only paid bookings have a receipt to send.', 'seathive' ), array( 'status' => 409 ) ); } if ( empty( $booking->buyer_email ) || ! is_email( $booking->buyer_email ) ) { return new WP_Error( 'seatt_no_email', __( 'This booking has no email address.', 'seathive' ), array( 'status' => 409 ) ); } $ok = SEATT_Notifications::$instance ? SEATT_Notifications::$instance->send_confirmation_email( $booking->id ) : false; if ( ! $ok ) { return new WP_Error( 'seatt_mail_failed', __( 'The email could not be sent — check Settings → Email (SMTP) and the Activity Log for the reason.', 'seathive' ), array( 'status' => 500 ) ); } return rest_ensure_response( array( 'sent' => true, 'to' => $booking->buyer_email ) ); } public function sync_booking_payment( WP_REST_Request $req ) { $booking = SEATT_DB::get_booking( intval( $req['id'] ) ); if ( ! $booking ) { return new WP_Error( 'seatt_not_found', __( 'Booking not found.', 'seathive' ), array( 'status' => 404 ) ); } $result = SEATT_Payment::sync_booking_payment( $booking, 0 ); $booking = SEATT_DB::get_booking( $booking->id ); return rest_ensure_response( array( 'result' => $result, 'status' => $booking ? $booking->status : 'unknown', ) ); } public function payment_webhook( WP_REST_Request $req ) { return SEATT_Payment::handle_webhook( $req ); } public function get_booking( WP_REST_Request $req ) { $this->no_cache_headers(); $booking_id = intval( $req['id'] ); $data = SEATT_DB::get_booking_with_seats( $booking_id ); if ( ! $data ) { return new WP_Error( 'seatt_not_found', __( 'Booking not found.', 'seathive' ), array( 'status' => 404 ) ); } // This route was fully public and returned the raw booking row — // buyer name/email/phone AND checkin_token, the secret baked into // the entry QR code. Anyone could walk the ids and mint a valid // ticket QR for every paid booking. Now it needs staff access or // the receipt key, and the check-in token only goes to staff. $is_staff = current_user_can( 'seatt_sell_seats' ) || current_user_can( 'manage_options' ); if ( ! $is_staff && ! SEATT_DB::receipt_key_valid( $data['booking'], (string) $req->get_param( 'rk' ) ) ) { return new WP_Error( 'seatt_forbidden', __( 'Not allowed to view this booking.', 'seathive' ), array( 'status' => 403 ) ); } if ( ! $is_staff ) { $data['booking'] = clone $data['booking']; unset( $data['booking']->checkin_token, $data['booking']->session_id ); } return rest_ensure_response( $data ); } public function delete_booking( WP_REST_Request $req ) { $booking_id = intval( $req['id'] ); $booking = SEATT_DB::get_booking( $booking_id ); if ( ! $booking ) { return new WP_Error( 'seatt_not_found', __( 'Booking not found.', 'seathive' ), array( 'status' => 404 ) ); } // Unpaid (pending) and failed (cancelled) bookings can be deleted. // A PAID booking is a settled sale — money was taken, a receipt // and QR ticket went out — so it can't be deleted any more. // Enforced here server-side, not just hidden in the UI, since // this is the actual safeguard. if ( 'paid' === $booking->status ) { return new WP_Error( 'seatt_not_deletable', __( 'Paid bookings can\'t be deleted.', 'seathive' ), array( 'status' => 403 ) ); } // Deletions used to leave no trace at all, which made "where did // booking #33 go?" unanswerable. Logged BEFORE the row is gone. $seat_labels = array(); $with = SEATT_DB::get_booking_with_seats( $booking_id ); foreach ( (array) ( $with['seats'] ?? array() ) as $s ) { $seat_labels[] = seatt_seat_label( $s->row_name, $s->seat_label ); } SEATT_DB::log_activity( $booking->event_id, $booking_id, get_current_user_id() ?: null, 'booking_deleted', sprintf( '#%d %s (%s)%s', $booking_id, $booking->buyer_name, $booking->status, $seat_labels ? ' — ' . implode( ', ', $seat_labels ) : '' ) ); SEATT_DB::delete_booking( $booking_id ); return rest_ensure_response( array( 'success' => true ) ); } }