event_id, '_seatt_booking_type', true ) ?: 'paid'; if ( 'free' === $booking_type ) { SEATT_DB::mark_booking_paid( $booking->id, '', 'free' ); return array( 'method' => 'free', 'message' => __( 'This is a free event — your booking is already confirmed.', 'seathive' ), ); } $gateway = get_option( 'seatt_payment_gateway', 'manual' ); switch ( $gateway ) { case 'xendit': return self::create_xendit_payment( $booking ); case 'stripe': return self::create_stripe_payment( $booking ); case 'doku': return self::create_doku_payment( $booking ); case 'paypal': return self::create_paypal_payment( $booking ); case 'mollie': return self::create_mollie_payment( $booking ); default: return array( 'method' => 'manual', 'message' => __( 'Pay in cash — your booking will be confirmed once the organizer receives your payment.', 'seathive' ), ); } } /* ========================================================== * XENDIT INVOICE * ========================================================== */ private static function create_xendit_payment( $booking ) { $secret_key = get_option( 'seatt_xendit_secret_key' ); if ( ! $secret_key ) { return array( 'method' => 'xendit', 'error' => __( 'Xendit secret key is not set.', 'seathive' ) ); } $external_id = 'ESM-' . $booking->id . '-' . time(); $body = array( 'external_id' => $external_id, 'amount' => (int) round( $booking->total ), 'description' => sprintf( /* translators: %d: number */ __( 'Booking #%d', 'seathive' ), $booking->id ), 'payer_email' => '', // optional — fill in if you collect buyer email 'customer' => array( 'given_names' => $booking->buyer_name ?: 'Guest', 'mobile_number' => $booking->buyer_phone ?: '', ), 'success_redirect_url' => home_url( '/' ), 'failure_redirect_url' => self::cancel_url_for_booking( $booking ), ); $response = wp_remote_post( 'https://api.xendit.co/v2/invoices', array( 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => 'Basic ' . base64_encode( $secret_key . ':' ), ), 'body' => wp_json_encode( $body ), 'timeout' => 20, ) ); if ( is_wp_error( $response ) ) { return array( 'method' => 'xendit', 'error' => $response->get_error_message() ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $data['invoice_url'] ) ) { return array( 'method' => 'xendit', 'error' => $data['message'] ?? __( 'Unknown Xendit error.', 'seathive' ) ); } self::remember_gateway_order_id( $booking->id, $external_id ); return array( 'method' => 'xendit', 'invoice_url' => $data['invoice_url'], 'invoice_id' => $data['id'] ?? '', ); } /* ========================================================== * STRIPE (Checkout Sessions) * ========================================================== */ /** Stripe's own list of currencies that DON'T use a minor unit * (no cents) — reuses the plugin-wide seatt_zero_decimal_currencies() * (event-seating-manager.php) rather than keeping its own separate * copy, so this and the price-DISPLAY formatting (seatt_format_price()) * can never quietly drift out of sync with each other. Every * currency NOT on that list needs its amount multiplied by 100 * before being sent, since Stripe always wants the smallest unit * (cents, pence, etc.), not the "whole" amount — getting this wrong * either overcharges by 100x or undercharges to almost nothing. */ private static function stripe_zero_decimal_currencies() { // Stripe's OWN list (stripe.com/docs/currencies#zero-decimal). It is // not the same as the display list: IDR is shown without decimals // but Stripe expects it in sen (×100) — sending 250000 for // Rp 250.000 would have charged only Rp 2.500. return array( 'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf' ); } private static function stripe_amount( $total, $currency_code ) { $currency_lc = strtolower( $currency_code ); if ( in_array( $currency_lc, self::stripe_zero_decimal_currencies(), true ) ) { return (int) round( $total ); } return (int) round( $total * 100 ); } private static function create_stripe_payment( $booking ) { $secret_key = get_option( 'seatt_stripe_secret_key' ); if ( ! $secret_key ) { return array( 'method' => 'stripe', 'error' => __( 'Stripe secret key is not set.', 'seathive' ) ); } $reference_id = 'ESM-' . $booking->id . '-' . time(); $currency_code = get_option( 'seatt_currency_code', 'IDR' ); $event = get_post( $booking->event_id ); $event_name = $event ? $event->post_title : __( 'Event', 'seathive' ); // Stripe's API is form-encoded (application/x-www-form-urlencoded), // not JSON — one of the few gateways here that isn't. Nested // fields use PHP-style bracket notation in the field NAME itself // (line_items[0][price_data][...]), which http_build_query() // produces automatically from a nested PHP array. $body = array( 'mode' => 'payment', 'client_reference_id' => $reference_id, 'success_url' => add_query_arg( 'seatt_stripe_session', '{CHECKOUT_SESSION_ID}', home_url( '/' ) ), 'cancel_url' => self::cancel_url_for_booking( $booking ), 'line_items' => array( array( 'quantity' => 1, 'price_data' => array( 'currency' => strtolower( $currency_code ), 'unit_amount' => self::stripe_amount( $booking->total, $currency_code ), 'product_data' => array( 'name' => sprintf( /* translators: %1$s: value, %2$d: number */ __( '%1$s — Booking #%2$d', 'seathive' ), $event_name, $booking->id ), ), ), ), ), 'metadata' => array( 'seatt_booking_id' => $booking->id ), ); if ( $booking->buyer_email && is_email( $booking->buyer_email ) ) { $body['customer_email'] = $booking->buyer_email; } $response = wp_remote_post( 'https://api.stripe.com/v1/checkout/sessions', array( 'headers' => array( 'Authorization' => 'Bearer ' . $secret_key, 'Content-Type' => 'application/x-www-form-urlencoded', ), 'body' => http_build_query( $body ), 'timeout' => 20, ) ); if ( is_wp_error( $response ) ) { return array( 'method' => 'stripe', 'error' => $response->get_error_message() ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $data['url'] ) ) { return array( 'method' => 'stripe', 'error' => $data['error']['message'] ?? __( 'Unknown Stripe error.', 'seathive' ) ); } self::remember_gateway_order_id( $booking->id, $reference_id ); // Also stash by Stripe's own session id, since the webhook event // for Checkout Sessions carries that id front-and-center — this // avoids parsing it back out of client_reference_id every time. update_option( 'seatt_order_map_' . $data['id'], $booking->id, false ); return array( 'method' => 'stripe', 'redirect_url' => $data['url'], 'session_id' => $data['id'], ); } /* ========================================================== * MOLLIE (Payments API v2, hosted checkout) * ========================================================== */ /** Currencies Mollie accepts. IDR is NOT one of them. */ const MOLLIE_CURRENCIES = array( 'AED', 'AUD', 'BGN', 'BRL', 'CAD', 'CHF', 'CZK', 'DKK', 'EUR', 'GBP', 'HKD', 'HUF', 'ILS', 'ISK', 'JPY', 'MXN', 'MYR', 'NOK', 'NZD', 'PHP', 'PLN', 'RON', 'RUB', 'SEK', 'SGD', 'THB', 'TWD', 'USD', 'ZAR' ); public static function mollie_supports( $code ) { return in_array( strtoupper( (string) $code ), self::MOLLIE_CURRENCIES, true ); } /** * Currency + amount string Mollie is charged in. Like PayPal: a store * priced in a currency Mollie doesn't take (e.g. IDR) sets a Mollie * charge currency and exchange rate; bookings/receipts stay in the * store currency. Returns array( currency, value ) or WP_Error. */ public static function mollie_charge( $total ) { $store = strtoupper( get_option( 'seatt_currency_code', 'IDR' ) ); $charge = strtoupper( (string) get_option( 'seatt_mollie_currency', '' ) ) ?: $store; if ( ! self::mollie_supports( $charge ) ) { return new WP_Error( 'seatt_mollie_currency', sprintf( /* translators: %s: value */ __( 'Mollie does not accept %s. In Settings → Payment → Mollie, choose a currency Mollie supports (e.g. EUR) and enter the exchange rate.', 'seathive' ), $charge ) ); } $amount = (float) $total; if ( $charge !== $store ) { $rate = (float) get_option( 'seatt_mollie_rate', 0 ); // 1 {charge} = {rate} {store} if ( $rate <= 0 ) { return new WP_Error( 'seatt_mollie_rate', sprintf( /* translators: %1$s: value, %2$s: value */ __( 'Enter the exchange rate for Mollie (1 %1$s = how many %2$s) in Settings → Payment → Mollie.', 'seathive' ), $charge, $store ) ); } $amount = $amount / $rate; } // Mollie wants the exact decimals of the currency, as a string. $value = in_array( $charge, array( 'JPY', 'ISK' ), true ) ? (string) max( 1, (int) round( $amount ) ) : number_format( max( 0.01, round( $amount, 2 ) ), 2, '.', '' ); return array( $charge, $value ); } private static function mollie_request( $method, $path, $body = null ) { $key = trim( (string) get_option( 'seatt_mollie_api_key' ) ); if ( '' === $key ) { return new WP_Error( 'seatt_mollie_key', __( 'Mollie API key is not set.', 'seathive' ) ); } $args = array( 'method' => $method, 'timeout' => 20, 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Content-Type' => 'application/json' ), ); if ( null !== $body ) { $args['body'] = wp_json_encode( $body ); } $res = wp_remote_request( 'https://api.mollie.com/v2/' . ltrim( $path, '/' ), $args ); if ( is_wp_error( $res ) ) { return $res; } $data = json_decode( wp_remote_retrieve_body( $res ), true ); $code = (int) wp_remote_retrieve_response_code( $res ); if ( $code >= 400 || ! is_array( $data ) ) { // Mollie errors: {"status":422,"title":"Unprocessable Entity","detail":"…","field":"amount.value"} $msg = is_array( $data ) ? trim( ( $data['detail'] ?? $data['title'] ?? '' ) . ( ! empty( $data['field'] ) ? ' (' . $data['field'] . ')' : '' ) ) : ''; return new WP_Error( 'seatt_mollie_http', $msg ? $msg : sprintf( /* translators: %d: number */ __( 'Mollie returned HTTP %d.', 'seathive' ), $code ) ); } return $data; } private static function create_mollie_payment( $booking ) { $charge = self::mollie_charge( $booking->total ); if ( is_wp_error( $charge ) ) { return array( 'method' => 'mollie', 'error' => $charge->get_error_message() ); } list( $currency, $value ) = $charge; $reference = 'ESM-' . $booking->id . '-' . time(); $event = get_post( $booking->event_id ); $data = self::mollie_request( 'POST', 'payments', array( 'amount' => array( 'currency' => $currency, 'value' => $value ), 'description' => mb_substr( sprintf( /* translators: %1$s: value, %2$d: number */ __( '%1$s — Booking #%2$d', 'seathive' ), $event ? $event->post_title : __( 'Event', 'seathive' ), $booking->id ), 0, 250 ), 'redirectUrl' => self::find_confirmation_url( $booking ), 'cancelUrl' => self::cancel_url_for_booking( $booking ), 'webhookUrl' => rest_url( 'seatt/v1/payment-webhook' ), 'metadata' => array( 'seatt_booking_id' => (int) $booking->id, 'reference' => $reference ), ) ); if ( is_wp_error( $data ) ) { return array( 'method' => 'mollie', 'error' => $data->get_error_message() ); } $checkout = $data['_links']['checkout']['href'] ?? ''; if ( empty( $data['id'] ) || ! $checkout ) { return array( 'method' => 'mollie', 'error' => __( 'Mollie did not return a checkout link.', 'seathive' ) ); } self::remember_gateway_order_id( $booking->id, $reference ); update_option( 'seatt_order_map_' . $data['id'], $booking->id, false ); $ids = (array) get_option( 'seatt_mollie_ids_' . $booking->id, array() ); $ids[] = $data['id']; update_option( 'seatt_mollie_ids_' . $booking->id, array_slice( array_values( array_unique( $ids ) ), -5 ), false ); return array( 'method' => 'mollie', 'redirect_url' => $checkout, 'payment_id' => $data['id'] ); } /** * Mollie webhooks carry ONLY the payment id (id=tr_…) and are not * signed — so the id is never trusted by itself: the payment is * fetched back from Mollie's API with our key, and only Mollie's * answer decides whether the booking is paid. */ private static function apply_mollie_payment( $payment_id ) { $booking_id = intval( get_option( 'seatt_order_map_' . $payment_id ) ); if ( ! $booking_id ) { return 'unknown'; } $p = self::mollie_request( 'GET', 'payments/' . rawurlencode( $payment_id ) ); if ( is_wp_error( $p ) ) { return 'error'; } $meta_booking = intval( $p['metadata']['seatt_booking_id'] ?? ( $p['metadata']['esm_booking_id'] ?? 0 ) ); // esm_: payments started before the rename if ( $meta_booking && $meta_booking !== $booking_id ) { return 'mismatch'; } $status = (string) ( $p['status'] ?? '' ); $booking = SEATT_DB::get_booking( $booking_id ); if ( $booking ) { self::log_webhook( $booking->event_id, $booking_id, 'Mollie ' . $payment_id . ' → ' . $status ); } if ( 'paid' === $status ) { SEATT_DB::mark_booking_paid( $booking_id, $payment_id, 'mollie' ); return 'paid'; } return $status ?: 'pending'; } private static function handle_mollie_webhook( $payment_id ) { $r = self::apply_mollie_payment( $payment_id ); if ( 'unknown' === $r ) { return new WP_Error( 'seatt_unknown_booking', 'Unknown payment.', array( 'status' => 404 ) ); } if ( 'error' === $r ) { // Non-2xx makes Mollie retry later. return new WP_Error( 'seatt_mollie_unreachable', 'Could not verify the payment with Mollie.', array( 'status' => 503 ) ); } return rest_ensure_response( array( 'received' => true ) ); } /* ========================================================== * DOKU (Checkout / non-SNAP) * ========================================================== */ private static function doku_base_url() { $production = (bool) get_option( 'seatt_doku_production' ); return $production ? 'https://api.doku.com' : 'https://api-sandbox.doku.com'; } /** * DOKU signs by hashing a fixed list of header values plus a digest * of the body, one "Name:value" per line joined with \n and NO * trailing newline, HMAC-SHA256'd with the merchant secret key and * base64-encoded, then prefixed with "HMACSHA256=". * * Two details bite if you get them wrong, and both fail as a bare * 401 with no explanation of which part was off: * - Digest is base64 of the RAW SHA-256 bytes of the exact body * string that gets sent — re-encoding the array afterwards would * produce different whitespace and a different hash. * - Request-Target is the PATH only, no scheme or host. * * $digest is omitted entirely (not sent empty) for GET requests and * for any request without a body, which is what DOKU expects. */ private static function doku_signature( $client_id, $request_id, $timestamp, $target, $digest, $secret ) { $lines = array( 'Client-Id:' . $client_id, 'Request-Id:' . $request_id, 'Request-Timestamp:' . $timestamp, 'Request-Target:' . $target, ); if ( '' !== $digest && null !== $digest ) { $lines[] = 'Digest:' . $digest; } $raw = implode( "\n", $lines ); return 'HMACSHA256=' . base64_encode( hash_hmac( 'sha256', $raw, $secret, true ) ); } /** base64( raw SHA-256( $body ) ) — DOKU's Digest component. */ private static function doku_digest( $body ) { return base64_encode( hash( 'sha256', $body, true ) ); } /** * The path DOKU will use as Request-Target when it calls US back. * Derived from the same rest_url() the Settings screen shows the * merchant, so a site in a subdirectory (/blog/wp-json/...) signs * against the path DOKU actually hit rather than a guessed one. */ private static function doku_notification_path() { $path = wp_parse_url( rest_url( 'seatt/v1/payment-webhook' ), PHP_URL_PATH ); return $path ? $path : '/wp-json/seatt/v1/payment-webhook'; } private static function create_doku_payment( $booking ) { $client_id = get_option( 'seatt_doku_client_id' ); $secret = get_option( 'seatt_doku_secret_key' ); if ( ! $client_id || ! $secret ) { return array( 'method' => 'doku', 'error' => __( 'DOKU Client ID and Secret Key are not set.', 'seathive' ) ); } // DOKU Checkout settles in IDR and takes the amount with no // decimal component at all. Selling in another currency would // silently charge the buyer a completely different number, so // refuse rather than send an amount that means something else. $currency_code = strtoupper( get_option( 'seatt_currency_code', 'IDR' ) ); if ( 'IDR' !== $currency_code ) { return array( 'method' => 'doku', 'error' => sprintf( /* translators: %s: the currency code currently configured */ __( 'DOKU Checkout only accepts IDR, but this store is set to %s. Change the currency in Store Setting, or pick a different gateway.', 'seathive' ), $currency_code ), ); } $invoice_number = 'ESM-' . $booking->id . '-' . time(); $request_id = wp_generate_uuid4(); // ISO-8601 in UTC with a literal Z — DOKU rejects an offset form // like +07:00, and rejects timestamps more than a few minutes // out, so this must be real UTC and not local time. $timestamp = gmdate( 'Y-m-d\TH:i:s\Z' ); $target = '/checkout/v1/payment'; $event = get_post( $booking->event_id ); $event_name = $event ? $event->post_title : __( 'Event', 'seathive' ); $payload = array( 'order' => array( 'amount' => (int) round( $booking->total ), 'invoice_number' => $invoice_number, 'currency' => 'IDR', 'callback_url' => self::find_confirmation_url( $booking ), 'auto_redirect' => true, 'line_items' => array( array( 'id' => 'booking-' . $booking->id, 'name' => $event_name, 'quantity' => 1, 'price' => (int) round( $booking->total ), ), ), ), 'payment' => array( 'payment_due_date' => 60, ), ); $customer = array(); if ( $booking->buyer_name ) { $customer['name'] = $booking->buyer_name; } if ( $booking->buyer_email && is_email( $booking->buyer_email ) ) { $customer['email'] = $booking->buyer_email; } if ( $customer ) { $payload['customer'] = $customer; } // Encoded ONCE and reused for both the digest and the request // body — re-encoding would risk a different byte sequence and // therefore a signature DOKU cannot reproduce. $body = wp_json_encode( $payload ); $digest = self::doku_digest( $body ); $response = wp_remote_post( self::doku_base_url() . $target, array( 'headers' => array( 'Client-Id' => $client_id, 'Request-Id' => $request_id, 'Request-Timestamp' => $timestamp, 'Signature' => self::doku_signature( $client_id, $request_id, $timestamp, $target, $digest, $secret ), 'Content-Type' => 'application/json', ), 'body' => $body, 'timeout' => 20, ) ); if ( is_wp_error( $response ) ) { return array( 'method' => 'doku', 'error' => $response->get_error_message() ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $data['response']['payment']['url'] ) ) { // DOKU returns its complaints as a list under error_messages // rather than a single message field. $errors = $data['error_messages'] ?? ( $data['message'] ?? array() ); $msg = is_array( $errors ) ? implode( ' ', $errors ) : (string) $errors; return array( 'method' => 'doku', 'error' => $msg ?: __( 'Unknown DOKU error.', 'seathive' ) ); } self::remember_gateway_order_id( $booking->id, $invoice_number ); return array( 'method' => 'doku', 'redirect_url' => $data['response']['payment']['url'], 'session_id' => $data['response']['order']['session_id'] ?? '', ); } /** * DOKU notification. It signs the callback exactly the way we sign * our own requests, except Request-Target is the path of OUR * notification URL rather than a DOKU endpoint. */ private static function handle_doku_webhook( $req, $client_id_header ) { $secret = get_option( 'seatt_doku_secret_key' ); $client_id = get_option( 'seatt_doku_client_id' ); if ( ! $secret || ! $client_id ) { return new WP_Error( 'seatt_doku_not_configured', 'DOKU credentials are not set.', array( 'status' => 500 ) ); } if ( ! hash_equals( (string) $client_id, (string) $client_id_header ) ) { return new WP_Error( 'seatt_bad_signature', 'Unexpected DOKU Client-Id.', array( 'status' => 403 ) ); } $request_id = (string) $req->get_header( 'request-id' ); $timestamp = (string) $req->get_header( 'request-timestamp' ); $signature = (string) $req->get_header( 'signature' ); $raw_body = $req->get_body(); // DOKU signs against the path IT called, which is whatever the // merchant typed into DOKU Back Office. That is usually exactly // rest_url()'s path, but a trailing slash, or a host that // rewrites the URL, is enough to make a single hard-coded path // fail every notification with a 403 — silently, from our side, // which is exactly how a paid booking ends up stuck on // "Pending". Any of these candidate paths is still a path on // THIS site, signed with OUR secret, so accepting whichever one // matches doesn't weaken the check. $digest = self::doku_digest( $raw_body ); $configured = self::doku_notification_path(); $candidates = array( $configured, rtrim( $configured, '/' ) . '/', rtrim( $configured, '/' ) ); if ( ! empty( $_SERVER['REQUEST_URI'] ) ) { $req_path = wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ); if ( $req_path ) { $candidates[] = $req_path; } } $valid = false; foreach ( array_unique( $candidates ) as $path ) { $expected = self::doku_signature( $client_id, $request_id, $timestamp, $path, $digest, $secret ); if ( $signature && hash_equals( $expected, $signature ) ) { $valid = true; break; } } if ( ! $valid ) { self::log_webhook( 0, 0, 'DOKU notification rejected — signature mismatch (check the Secret Key and Notification URL in DOKU Back Office)' ); return new WP_Error( 'seatt_bad_signature', 'Invalid DOKU signature.', array( 'status' => 403 ) ); } // Replay protection. The signature proves DOKU sent it, but not // that it was sent NOW: a captured notification could be posted // again. Reject anything stamped more than a day away from our // clock (generous — DOKU retries failed deliveries for hours), // and anything whose Request-Id we've already processed. $ts = strtotime( (string) $timestamp ); if ( ! $ts || abs( time() - $ts ) > DAY_IN_SECONDS ) { self::log_webhook( 0, 0, 'DOKU notification rejected — Request-Timestamp outside the allowed window' ); return new WP_Error( 'seatt_stale', 'Stale DOKU notification.', array( 'status' => 400 ) ); } $seen_key = 'seatt_doku_seen_' . md5( (string) $request_id ); if ( get_transient( $seen_key ) ) { return rest_ensure_response( array( 'received' => true, 'duplicate' => true ) ); } $body = json_decode( $raw_body, true ); $invoice_number = $body['order']['invoice_number'] ?? ''; $booking_id = self::booking_id_from_order_id( $invoice_number ); if ( ! $booking_id ) { self::log_webhook( 0, 0, 'DOKU notification for unknown invoice ' . sanitize_text_field( $invoice_number ) ); return new WP_Error( 'seatt_unknown_booking', 'Booking not found for invoice_number.', array( 'status' => 404 ) ); } $status = strtoupper( (string) ( $body['transaction']['status'] ?? '' ) ); $booking = SEATT_DB::get_booking( $booking_id ); self::log_webhook( $booking ? $booking->event_id : 0, $booking_id, 'DOKU ' . ( $status ?: 'UNKNOWN' ) . ' — ' . sanitize_text_field( $invoice_number ) ); self::apply_doku_status( $booking_id, $status, $body, $invoice_number, true ); set_transient( $seen_key, 1, 2 * DAY_IN_SECONDS ); // only after it was actually processed return rest_ensure_response( array( 'received' => true ) ); } /** * One place that turns a DOKU transaction status into a booking * change, shared by the webhook and the active status check so the * two can never disagree about what SUCCESS means. * * $allow_cancel is false for the status CHECK: a booking can carry * several invoices (every "Complete Payment" retry opens a new one), * and an older one reporting EXPIRED says nothing about the newer * one the buyer actually paid. Only DOKU's own push for a specific * invoice is trusted to cancel. */ private static function apply_doku_status( $booking_id, $status, $body, $invoice_number, $allow_cancel ) { if ( 'SUCCESS' === $status ) { $ref = $body['transaction']['original_request_id'] ?? ''; SEATT_DB::mark_booking_paid( $booking_id, $ref ? $ref : $invoice_number, 'doku' ); return 'paid'; } if ( $allow_cancel && in_array( $status, array( 'FAILED', 'EXPIRED' ), true ) ) { $booking = SEATT_DB::get_booking( $booking_id ); if ( $booking && 'pending_payment' === $booking->status ) { SEATT_DB::cancel_booking( $booking_id ); } return 'cancelled'; } return 'pending'; } /** Records what a gateway told us (or why we refused it) in the * Activity Log. Without this, a notification that failed signature * verification left no trace anywhere on the site — the only * symptom was a paid booking that never left "Pending". */ private static function log_webhook( $event_id, $booking_id, $details ) { SEATT_DB::log_activity( intval( $event_id ), $booking_id ? intval( $booking_id ) : null, null, 'payment_webhook', $details ); } /* ========================================================== * ACTIVE PAYMENT STATUS CHECK * * Webhooks alone are not enough: DOKU only sends one if a * Notification URL is configured in its Back Office, a firewall or * security plugin can block it, and a signature mismatch rejects it * — and in every one of those cases the buyer has paid but the * booking sits on "Pending" forever. So the plugin also ASKS DOKU * (Check Status API) whenever someone is looking at a pending * booking: the buyer's receipt page, the admin Bookings screen, * and a background cron sweep. * ========================================================== */ /** Every gateway order id ever generated for this booking, newest * first — each "Complete Payment" retry creates a new one and the * buyer may have paid any of them. */ public static function order_ids_for_booking( $booking_id ) { global $wpdb; $prefix = 'seatt_order_map_ESM-' . intval( $booking_id ) . '-'; $names = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like( $prefix ) . '%' ) ); $ids = array(); foreach ( (array) $names as $name ) { $order_id = substr( $name, strlen( 'seatt_order_map_' ) ); if ( preg_match( '/^ESM-' . intval( $booking_id ) . '-(\d+)$/', $order_id, $m ) ) { $ids[ $order_id ] = intval( $m[1] ); } } arsort( $ids ); return array_keys( $ids ); } /** GET /orders/v1/status/{invoice}. Signed like our POSTs but with * no Digest, per DOKU's rule for GET requests. */ private static function doku_check_status( $invoice_number ) { $client_id = get_option( 'seatt_doku_client_id' ); $secret = get_option( 'seatt_doku_secret_key' ); if ( ! $client_id || ! $secret ) { return new WP_Error( 'seatt_doku_not_configured', 'DOKU credentials are not set.' ); } $target = '/orders/v1/status/' . rawurlencode( $invoice_number ); $request_id = wp_generate_uuid4(); $timestamp = gmdate( 'Y-m-d\TH:i:s\Z' ); $response = wp_remote_get( self::doku_base_url() . $target, array( 'headers' => array( 'Client-Id' => $client_id, 'Request-Id' => $request_id, 'Request-Timestamp' => $timestamp, 'Signature' => self::doku_signature( $client_id, $request_id, $timestamp, $target, '', $secret ), ), 'timeout' => 15, ) ); if ( is_wp_error( $response ) ) { return $response; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) ) { return new WP_Error( 'seatt_doku_bad_response', 'Unreadable DOKU status response (HTTP ' . wp_remote_retrieve_response_code( $response ) . ').' ); } return $data; } /** * Asks the gateway whether a pending booking has actually been paid * and, if so, marks it paid (which releases the email + QR exactly * as the webhook would). * * Throttled per booking so a receipt page left open, or a busy * admin screen, can't hammer the gateway; $min_interval = 0 forces * an immediate check (the admin "Check payment" button). * * Returns 'paid', 'pending', 'cancelled', 'throttled' or 'unsupported'. */ public static function sync_booking_payment( $booking, $min_interval = 30 ) { if ( is_numeric( $booking ) ) { $booking = SEATT_DB::get_booking( intval( $booking ) ); } if ( ! $booking ) { return 'unsupported'; } if ( 'pending_payment' !== $booking->status ) { return 'paid' === $booking->status ? 'paid' : 'cancelled'; } $gw = get_option( 'seatt_payment_gateway', 'manual' ); if ( ! in_array( $gw, array( 'doku', 'mollie' ), true ) ) { return 'unsupported'; } $lock = 'seatt_pay_sync_' . intval( $booking->id ); if ( $min_interval > 0 && get_transient( $lock ) ) { return 'throttled'; } set_transient( $lock, 1, max( 5, intval( $min_interval ) ) ); if ( 'mollie' === $gw ) { foreach ( array_reverse( (array) get_option( 'seatt_mollie_ids_' . $booking->id, array() ) ) as $pid ) { if ( 'paid' === self::apply_mollie_payment( $pid ) ) { return 'paid'; } } return 'pending'; } // Newest few only — each is one HTTP round trip. foreach ( array_slice( self::order_ids_for_booking( $booking->id ), 0, 5 ) as $invoice ) { $data = self::doku_check_status( $invoice ); if ( is_wp_error( $data ) ) { continue; } $status = strtoupper( (string) ( $data['transaction']['status'] ?? '' ) ); if ( 'SUCCESS' === $status ) { self::log_webhook( $booking->event_id, $booking->id, 'DOKU SUCCESS (status check) — ' . $invoice ); return self::apply_doku_status( $booking->id, $status, $data, $invoice, false ); } } return 'pending'; } /** Background sweep: recent pending bookings get checked even if * nobody opens a page. Runs on WP-Cron, so on a low-traffic site it * fires on the next visit rather than exactly every 5 minutes — * which is why the receipt page and admin screen check too. */ public static function cron_sync_pending() { self::expire_unconfirmed_cash(); if ( ! in_array( get_option( 'seatt_payment_gateway', 'manual' ), array( 'doku', 'mollie' ), true ) ) { return; } global $wpdb; $since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - 3 * DAY_IN_SECONDS ); $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE status = \'pending_payment\' AND created_at >= %s ORDER BY id DESC LIMIT 20', $wpdb->seatt_bookings, $since ) ); foreach ( (array) $ids as $id ) { self::sync_booking_payment( intval( $id ), 60 ); } } /** * With the Cash gateway a new booking locks its seats immediately * (the buyer pays at the desk). Left alone, anyone could lock the * whole venue with fake bookings they never pay for. Unconfirmed * cash bookings older than the configured number of hours are * cancelled and their seats go back on sale. 0 = never expire. */ public static function expire_unconfirmed_cash() { if ( 'manual' !== get_option( 'seatt_payment_gateway', 'manual' ) ) { return; } $hours = (int) get_option( 'seatt_cash_expiry_hours', 24 ); if ( $hours <= 0 ) { return; } global $wpdb; $cutoff = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - $hours * HOUR_IN_SECONDS ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, event_id FROM %i WHERE status = \'pending_payment\' AND created_at < %s ORDER BY id LIMIT 50', $wpdb->seatt_bookings, $cutoff ) ); foreach ( (array) $rows as $r ) { SEATT_DB::cancel_booking( $r->id ); SEATT_DB::log_activity( $r->event_id, $r->id, null, 'auto_cancelled', sprintf( 'Cash payment not confirmed within %d hours', $hours ) ); } } public static function add_cron_schedule( $schedules ) { if ( ! isset( $schedules['seatt_five_minutes'] ) ) { $schedules['seatt_five_minutes'] = array( 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => 'Every 5 minutes (SeatWP)', ); } return $schedules; } /** Wired from seatt_run(). Schedules itself if missing, so existing * installs pick the sweep up on update without a reactivation. */ public static function init() { add_filter( 'cron_schedules', array( __CLASS__, 'add_cron_schedule' ) ); add_action( 'seatt_sync_pending_payments', array( __CLASS__, 'cron_sync_pending' ) ); add_action( 'init', function () { if ( ! wp_next_scheduled( 'seatt_sync_pending_payments' ) ) { wp_schedule_event( time() + 60, 'seatt_five_minutes', 'seatt_sync_pending_payments' ); } } ); } /* ========================================================== * PAYPAL (Orders API v2) * ========================================================== */ private static function paypal_base_url() { $production = (bool) get_option( 'seatt_paypal_production' ); return $production ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com'; } /** PayPal's Orders API is plain OAuth2 client-credentials — trade * the Client ID/Secret for a short-lived Bearer token good for a * few hours, then use that for the actual order calls below. No * long-term token caching here (a transient would be a reasonable * later optimization) since a booking flow is infrequent enough * that fetching a fresh token per checkout is not a meaningful cost. */ private static function paypal_access_token() { $client_id = get_option( 'seatt_paypal_client_id' ); $client_secret = get_option( 'seatt_paypal_client_secret' ); if ( ! $client_id || ! $client_secret ) { return new WP_Error( 'seatt_paypal_not_configured', __( 'PayPal Client ID or Secret is not set.', 'seathive' ) ); } $response = wp_remote_post( self::paypal_base_url() . '/v1/oauth2/token', array( 'headers' => array( 'Authorization' => 'Basic ' . base64_encode( $client_id . ':' . $client_secret ), 'Content-Type' => 'application/x-www-form-urlencoded', ), 'body' => array( 'grant_type' => 'client_credentials' ), 'timeout' => 20, ) ); if ( is_wp_error( $response ) ) { return $response; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $data['access_token'] ) ) { return new WP_Error( 'seatt_paypal_auth_failed', __( 'Could not authenticate with PayPal.', 'seathive' ) ); } return $data['access_token']; } /** PayPal wants a plain decimal string ("10.00", not cents) for * every currency, zero-decimal ones included ("100" for JPY/IDR, * with no fractional part) — the opposite convention from Stripe * above, which is exactly the kind of gateway-specific quirk that's * easy to mix up if these two were sharing one helper. */ private static function paypal_amount( $total, $currency_code ) { // PayPal's own zero-decimal list (differs from Stripe's). if ( in_array( strtoupper( $currency_code ), array( 'HUF', 'JPY', 'TWD' ), true ) ) { return (string) (int) round( $total ); } return number_format( (float) $total, 2, '.', '' ); } /** Currencies PayPal Checkout accepts. IDR is NOT one of them. */ const PAYPAL_CURRENCIES = array( 'AUD', 'BRL', 'CAD', 'CNY', 'CZK', 'DKK', 'EUR', 'HKD', 'HUF', 'ILS', 'JPY', 'MYR', 'MXN', 'TWD', 'NZD', 'NOK', 'PHP', 'PLN', 'GBP', 'SGD', 'SEK', 'CHF', 'THB', 'USD' ); public static function paypal_supports( $code ) { return in_array( strtoupper( (string) $code ), self::PAYPAL_CURRENCIES, true ); } /** * Which currency and amount PayPal is actually charged in. * * PayPal rejects IDR outright ("UNPROCESSABLE_ENTITY … failed * business validation") — so a store priced in Rupiah can set a * PayPal charge currency (e.g. USD) and an exchange rate in the * PayPal settings; the order is converted only for PayPal, while * the booking, receipt and reports stay in the store currency. * Returns array( currency, amount_string ) or WP_Error. */ public static function paypal_charge( $total ) { $store = strtoupper( get_option( 'seatt_currency_code', 'IDR' ) ); $charge = strtoupper( (string) get_option( 'seatt_paypal_currency', '' ) ) ?: $store; if ( ! self::paypal_supports( $charge ) ) { return new WP_Error( 'seatt_paypal_currency', sprintf( /* translators: %s: value */ __( 'PayPal does not accept %s. In Settings → Payment → PayPal, choose a currency PayPal supports (e.g. USD) and enter the exchange rate.', 'seathive' ), $charge ) ); } if ( $charge === $store ) { return array( $charge, self::paypal_amount( $total, $charge ) ); } $rate = (float) get_option( 'seatt_paypal_rate', 0 ); // 1 {charge} = {rate} {store} if ( $rate <= 0 ) { return new WP_Error( 'seatt_paypal_rate', sprintf( /* translators: %1$s: value, %2$s: value */ __( 'Enter the exchange rate for PayPal (1 %1$s = how many %2$s) in Settings → Payment → PayPal.', 'seathive' ), $charge, $store ) ); } $converted = (float) $total / $rate; if ( $converted < 0.01 ) { $converted = 0.01; } return array( $charge, self::paypal_amount( $converted, $charge ) ); } private static function create_paypal_payment( $booking ) { $access_token = self::paypal_access_token(); if ( is_wp_error( $access_token ) ) { return array( 'method' => 'paypal', 'error' => $access_token->get_error_message() ); } $reference_id = 'ESM-' . $booking->id . '-' . time(); $charge = self::paypal_charge( $booking->total ); if ( is_wp_error( $charge ) ) { return array( 'method' => 'paypal', 'error' => $charge->get_error_message() ); } list( $currency_code, $amount ) = $charge; $body = array( 'intent' => 'CAPTURE', 'purchase_units' => array( array( 'reference_id' => $reference_id, 'amount' => array( 'currency_code' => $currency_code, 'value' => $amount, ), 'description' => sprintf( /* translators: %d: number */ __( 'Booking #%d', 'seathive' ), $booking->id ), ), ), // PayPal calls the buyer back here itself once they approve // or cancel on PayPal's own site — this plugin then CAPTURES // the order server-side from that return request (see // handle_paypal_return() below) rather than relying on a // webhook, since verifying a PayPal webhook signature needs // an extra round-trip API call of its own and the // return-and-capture flow is PayPal's own standard pattern // for exactly this kind of simple one-off checkout. 'application_context' => array( 'return_url' => rest_url( 'seatt/v1/paypal-return' ), 'cancel_url' => self::cancel_url_for_booking( $booking ), 'user_action' => 'PAY_NOW', ), ); $response = wp_remote_post( self::paypal_base_url() . '/v2/checkout/orders', array( 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), 'timeout' => 20, ) ); if ( is_wp_error( $response ) ) { return array( 'method' => 'paypal', 'error' => $response->get_error_message() ); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); $approve_url = ''; foreach ( (array) ( $data['links'] ?? array() ) as $link ) { if ( 'approve' === ( $link['rel'] ?? '' ) ) { $approve_url = $link['href']; break; } } if ( ! $approve_url ) { // PayPal's top-level "message" is generic; the real reason // (CURRENCY_NOT_SUPPORTED, DECIMAL_PRECISION, …) is in details. $msg = $data['message'] ?? __( 'Unknown PayPal error.', 'seathive' ); if ( ! empty( $data['details'][0] ) ) { $d = $data['details'][0]; $msg = trim( ( $d['issue'] ?? '' ) . ': ' . ( $d['description'] ?? $msg ), ': ' ); } return array( 'method' => 'paypal', 'error' => $msg ); } self::remember_gateway_order_id( $booking->id, $reference_id ); // PayPal's return request only carries ITS OWN order id (as // "token"), not our reference_id — stash the mapping under that // too so handle_paypal_return() can look the booking back up. update_option( 'seatt_order_map_' . $data['id'], $booking->id, false ); return array( 'method' => 'paypal', 'redirect_url' => $approve_url, 'order_id' => $data['id'], ); } /** * PayPal redirects the buyer straight back to this endpoint itself * (no separate webhook involved — see the comment on * application_context above) with ?token={order_id}&PayerID=... once * they approve on PayPal's site. Capturing here, server-side, is * what actually moves the money; the mere existence of this request * only means the buyer clicked through, not that payment succeeded. */ public static function handle_paypal_return( WP_REST_Request $req ) { $order_id = $req->get_param( 'token' ); if ( ! $order_id ) { return self::redirect_response( home_url( '/' ) ); } $booking_id = self::booking_id_from_order_id( $order_id ); if ( ! $booking_id ) { return self::redirect_response( home_url( '/' ) ); } $access_token = self::paypal_access_token(); if ( is_wp_error( $access_token ) ) { return self::redirect_response( home_url( '/' ) ); } $response = wp_remote_post( self::paypal_base_url() . '/v2/checkout/orders/' . rawurlencode( $order_id ) . '/capture', array( 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ), 'timeout' => 20, ) ); $booking = SEATT_DB::get_booking( $booking_id ); $redirect_to = self::find_confirmation_url( $booking ) ?: home_url( '/' ); if ( ! is_wp_error( $response ) ) { $data = json_decode( wp_remote_retrieve_body( $response ), true ); $status = $data['status'] ?? ''; if ( 'COMPLETED' === $status ) { $capture_id = $data['purchase_units'][0]['payments']['captures'][0]['id'] ?? $order_id; SEATT_DB::mark_booking_paid( $booking_id, $capture_id, 'paypal' ); } } return self::redirect_response( $redirect_to ); } /** A REST route callback normally returns a value to be JSON- * encoded, not a redirect — this small helper does the actual * headers-and-exit dance for the one route (PayPal's return URL) * that needs to send the browser somewhere instead. */ private static function redirect_response( $url ) { wp_safe_redirect( $url ); exit; } /** * Sends the buyer to a payment page on the GATEWAY's own domain. * * wp_safe_redirect() refuses any host but this site's and quietly * falls back to wp-admin — so "Complete Payment" landed the buyer on * the WordPress dashboard instead of the checkout page, for every * gateway, with no error anywhere to explain it. The initial * checkout never hit this because booking.js sets window.location * from the REST response itself; only this server-side retry did. * * The fix keeps the protection rather than swapping in a bare * wp_redirect(): the host is whitelisted for this one request only, * and only the host of a URL that our own gateway code just * received. An attacker-supplied ?redirect= is still refused. */ private static function redirect_to_gateway( $url ) { $host = wp_parse_url( $url, PHP_URL_HOST ); $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); // Payment pages are https without exception; anything else here // means a malformed or tampered URL, and the receipt page is a // safer place to land than following it. if ( ! $host || 'https' !== $scheme ) { return self::redirect_response( home_url( '/' ) ); } $allow = function ( $hosts ) use ( $host ) { $hosts[] = $host; return $hosts; }; add_filter( 'allowed_redirect_hosts', $allow ); wp_safe_redirect( $url ); remove_filter( 'allowed_redirect_hosts', $allow ); exit; } /** * Fixes a real gap on the confirmation/receipt page: a booking left * "Pending payment" (because the buyer's popup blocker silently ate * the payment tab, they accidentally closed it, or they just never * got there) had NO way for the buyer themselves to get back to * paying — only a passive warning message, with no link or button * anywhere. This generates a FRESH payment session/order with * whichever gateway is currently active and sends the buyer there, * rather than trying to reuse or store the original one — Stripe * Checkout Sessions and PayPal Orders both naturally expire on * their own timeline regardless of this plugin's own seat-hold * timer, so a brand new one is the reliable choice, not a * complication. */ public static function handle_retry_payment( WP_REST_Request $req ) { $booking_id = intval( $req->get_param( 'booking_id' ) ); if ( is_wp_error( SEATT_Security::throttle( 'retry', 10, 10 * MINUTE_IN_SECONDS ) ) ) { return self::redirect_response( home_url( '/' ) ); } // The link now carries the receipt key (rk) instead of the raw // check-in token — that token is the QR ticket itself and // shouldn't sit in browser history or leak through a Referer // header to the payment page. Old links with ?token= still work. $rk = (string) $req->get_param( 'rk' ); $booking = null; if ( '' !== $rk ) { $candidate = SEATT_DB::get_booking( $booking_id ); if ( $candidate && SEATT_DB::receipt_key_valid( $candidate, $rk ) ) { $booking = $candidate; } } else { $booking = SEATT_DB::get_booking_by_checkin_token( $booking_id, (string) $req->get_param( 'token' ) ); } if ( ! $booking || 'paid' === $booking->status || 'cancelled' === $booking->status ) { // Nothing sensible to retry — either the token's wrong, this // booking already went through, or it's been cancelled // outright. Same fallback destination as everywhere else in // this class when there's nowhere better and safer to send // someone. return self::redirect_response( self::find_confirmation_url( $booking ) ?: home_url( '/' ) ); } // The buyer may come back hours or days later, long after the // seat hold ran out and someone else bought the seat. Opening a // fresh payment page anyway is how one seat got paid for twice // (#33 and #35 on A7). Re-claim the seats first; if any is gone, // don't take the money. if ( ! SEATT_DB::reclaim_booking_seats( $booking ) ) { SEATT_DB::cancel_booking( $booking->id ); $fail_url = self::find_confirmation_url( $booking ) ?: home_url( '/' ); return self::redirect_response( add_query_arg( 'seatt_payment_error', rawurlencode( __( 'Sorry — the seat(s) in this booking have since been taken by someone else, so this booking was cancelled and no payment was taken. Please make a new booking.', 'seathive' ) ), $fail_url ) ); } $result = self::create_payment( $booking ); $redirect_url = $result['redirect_url'] ?? $result['invoice_url'] ?? ''; if ( $redirect_url ) { return self::redirect_to_gateway( $redirect_url ); } // The gateway call itself failed (bad API keys, gateway outage, // etc.) — this used to silently redirect back to the receipt // page with NO indication anything went wrong, which is exactly // what made a real configuration problem (e.g. missing/incorrect // PayPal credentials) look identical to "the button doesn't do // anything" from the buyer's side. The gateway's own error // message now rides along as a query arg so the confirmation // page can actually show it. $error_message = $result['error'] ?? __( 'Payment could not be started.', 'seathive' ); $fail_url = self::find_confirmation_url( $booking ) ?: home_url( '/' ); return self::redirect_response( add_query_arg( 'seatt_payment_error', rawurlencode( $error_message ), $fail_url ) ); } /** Same lookup as find_confirmation_url_base_only() below, but WITH * the seatt_booking_id query arg attached — used wherever landing back * on the FULL receipt view (not just the bare seat-picker) is what's * wanted, e.g. after a failed retry attempt above. * * IMPORTANT: this must be seatt_booking_id, NOT seatt_checkin_id/ * seatt_checkin_token — those two params are a COMPLETELY DIFFERENT * feature (SEATT_Public::render_checkin(), the staff-only "confirm * this guest's arrival" screen a door usher sees after scanning a * ticket's QR code). Landing a BUYER on that URL doesn't show them * their receipt at all — it shows "Staff Login Required" instead, * since that screen assumes whoever opened it is logged-in door * staff, not the ticket holder. Real bug, not a hypothetical: this * function originally used the checkin params (copied from a * pre-existing, equally wrong helper in SEATT_Notifications), and a * buyer clicking "Complete Payment" after their gateway session * expired hit exactly this "Staff Login Required" wall instead of * ever reaching a payment page. */ private static function find_confirmation_url( $booking ) { if ( ! $booking ) { return ''; } $base = self::find_confirmation_url_base_only( $booking ); if ( ! $base ) { return ''; } return add_query_arg( SEATT_DB::receipt_args( $booking ), $base ); } /** * Points a gateway's own "cancel"/"failure" redirect at a URL that * actually DOES something, instead of just dropping the buyer back * on the homepage with their booking silently stuck. Every gateway * here creates the booking (and moves its seats from 'held' to * 'sold') BEFORE the buyer ever leaves for the gateway's own site — * so if they cancel or the payment fails there, without this, those * seats stayed marked 'sold' forever even though no money ever * actually changed hands, silently blocking anyone (including the * same buyer) from ever booking them again. This is exactly the bug * behind the "seats no longer held by this session" error a buyer * would otherwise see if they tried the same seats a second time * after backing out of a first attempt. * * checkin_token in the URL is what stops a stranger from cancelling * someone else's booking just by guessing/incrementing a booking id * — handle_payment_cancelled() below verifies it matches before * touching anything, the same way the check-in flow already does. */ private static function cancel_url_for_booking( $booking ) { return add_query_arg( array( 'booking_id' => $booking->id, 'token' => $booking->checkin_token ), rest_url( 'seatt/v1/payment-cancelled' ) ); } /** * Where a gateway's cancel/failure redirect above actually lands. * Releases the booking's seats/zone quantities back to available * (via SEATT_DB::cancel_booking(), the same function the "EXPIRED" * webhook branches already use) and sends the buyer back to the * event's own booking page to try again, rather than the homepage. * * Deliberately only cancels a booking that's NOT already paid — a * gateway calling this after ALSO having sent a success webhook * (a real ordering hazard with async delivery, not just a * theoretical one) must never undo a completed sale. */ public static function handle_payment_cancelled( WP_REST_Request $req ) { $booking_id = intval( $req->get_param( 'booking_id' ) ); $token = (string) $req->get_param( 'token' ); $booking = SEATT_DB::get_booking_by_checkin_token( $booking_id, $token ); $redirect_to = home_url( '/' ); if ( $booking ) { if ( 'paid' !== $booking->status ) { SEATT_DB::cancel_booking( $booking_id ); } $redirect_to = self::find_confirmation_url_base_only( $booking ) ?: home_url( '/' ); } return self::redirect_response( $redirect_to ); } /** Same lookup as find_confirmation_url() above, but WITHOUT the * seatt_checkin_id/seatt_checkin_token query args — those args make * that page render a PAID receipt view, which is exactly wrong * here: this buyer's payment did NOT go through, so they need to * land back on the plain seat-picker to try again, not a receipt * for a booking that no longer holds anything. */ private static function find_confirmation_url_base_only( $booking ) { return seatt_event_booking_url( $booking->event_id ); } /* ========================================================== * Shared helpers * ========================================================== */ /** * Every gateway generates its own order_id/external_id/referenceId/ * session_id (which embeds the booking id already), but we also keep * a lookup option in case a gateway ever returns just its own opaque * id (Stripe's Checkout Session id and PayPal's Order id both get an * extra entry stashed under their own value for exactly this reason * — see create_stripe_payment()/create_paypal_payment()). Simple * options-based map is fine at usher-desk scale; move to a real * table if volume grows. */ private static function remember_gateway_order_id( $booking_id, $gateway_order_id ) { update_option( 'seatt_order_map_' . $gateway_order_id, $booking_id, false ); } private static function booking_id_from_order_id( $gateway_order_id ) { // Our own order_id/external_id/referenceId format is always // ESM-{booking_id}-{time}. if ( preg_match( '/^ESM-(\d+)-\d+$/', $gateway_order_id, $m ) ) { return intval( $m[1] ); } return intval( get_option( 'seatt_order_map_' . $gateway_order_id ) ); } /** * Handles Xendit AND Stripe webhook payloads on the same endpoint * (seatt/v1/payment-webhook) — distinguished by shape: Xendit sends its * callback token in an x-callback-token header; Stripe signs its * payload in a Stripe-Signature header instead; DOKU identifies * itself with a Client-Id header. PayPal doesn't use this endpoint * at all — see handle_paypal_return() above for why. */ public static function handle_webhook( WP_REST_Request $req ) { $stripe_sig_header = $req->get_header( 'stripe-signature' ); if ( $stripe_sig_header ) { return self::handle_stripe_webhook( $req, $stripe_sig_header ); } // DOKU is identified by Client-Id, which no other gateway here // sends. Checked before Xendit's token because DOKU also signs // with a header called Signature, and dispatching on the wrong // one would fail verification rather than fall through cleanly. $doku_client_id = $req->get_header( 'client-id' ); if ( $doku_client_id !== null && '' !== $doku_client_id ) { return self::handle_doku_webhook( $req, $doku_client_id ); } // Mollie: form-encoded body with just id=tr_… (no signature — see // apply_mollie_payment() for how it is verified). $mollie_id = (string) $req->get_param( 'id' ); if ( preg_match( '/^tr_[A-Za-z0-9]+$/', $mollie_id ) ) { return self::handle_mollie_webhook( $mollie_id ); } $body = $req->get_json_params(); $xendit_token_header = $req->get_header( 'x-callback-token' ); if ( $xendit_token_header !== null ) { return self::handle_xendit_webhook( $req, $body, $xendit_token_header ); } return new WP_Error( 'seatt_unrecognized_webhook', 'Unrecognized payload.', array( 'status' => 400 ) ); } private static function handle_xendit_webhook( $req, $body, $token_header ) { $expected_token = get_option( 'seatt_xendit_callback_token' ); if ( ! $expected_token || ! hash_equals( $expected_token, (string) $token_header ) ) { return new WP_Error( 'seatt_bad_signature', 'Invalid Xendit callback token.', array( 'status' => 403 ) ); } $external_id = $body['external_id'] ?? ''; $booking_id = self::booking_id_from_order_id( $external_id ); if ( ! $booking_id ) { return new WP_Error( 'seatt_unknown_booking', 'Booking not found for external_id.', array( 'status' => 404 ) ); } $status = $body['status'] ?? ''; if ( 'PAID' === $status || 'SETTLED' === $status ) { SEATT_DB::mark_booking_paid( $booking_id, $body['id'] ?? '', $body['payment_channel'] ?? 'xendit' ); } elseif ( in_array( $status, array( 'EXPIRED' ), true ) ) { SEATT_DB::cancel_booking( $booking_id ); } return rest_ensure_response( array( 'received' => true ) ); } /** * Stripe signs every webhook as "t={timestamp},v1={hmac}" in the * Stripe-Signature header — the HMAC is SHA-256 of "{t}.{raw body}", * keyed with the webhook's own signing secret (found on the Stripe * Dashboard when the webhook endpoint is created, separate from the * API secret key). Verified by hand here rather than via Stripe's PHP * SDK, consistent with the rest of this class calling every gateway * directly over wp_remote_post instead of taking on an SDK dependency. */ private static function handle_stripe_webhook( $req, $sig_header ) { $webhook_secret = get_option( 'seatt_stripe_webhook_secret' ); if ( ! $webhook_secret ) { return new WP_Error( 'seatt_stripe_not_configured', 'Stripe webhook secret is not set.', array( 'status' => 500 ) ); } $timestamp = ''; $signature = ''; foreach ( explode( ',', $sig_header ) as $part ) { $pair = explode( '=', trim( $part ), 2 ); if ( 't' === ( $pair[0] ?? '' ) ) $timestamp = $pair[1]; if ( 'v1' === ( $pair[0] ?? '' ) ) $signature = $pair[1]; } $raw_body = $req->get_body(); $expected = hash_hmac( 'sha256', $timestamp . '.' . $raw_body, $webhook_secret ); if ( ! $timestamp || ! $signature || ! hash_equals( $expected, $signature ) ) { return new WP_Error( 'seatt_bad_signature', 'Invalid Stripe signature.', array( 'status' => 403 ) ); } $event = json_decode( $raw_body, true ); if ( 'checkout.session.completed' !== ( $event['type'] ?? '' ) ) { // Stripe sends many event types to the same endpoint — anything // other than a completed Checkout Session is simply not this // plugin's concern, and gets acknowledged (not errored) so // Stripe doesn't keep retrying delivery of an event that was // never going to be acted on anyway. return rest_ensure_response( array( 'received' => true ) ); } $session = $event['data']['object'] ?? array(); $reference_id = $session['client_reference_id'] ?? ''; $session_id = $session['id'] ?? ''; $booking_id = self::booking_id_from_order_id( $reference_id ) ?: self::booking_id_from_order_id( $session_id ); if ( ! $booking_id ) { return new WP_Error( 'seatt_unknown_booking', 'Booking not found for this Checkout Session.', array( 'status' => 404 ) ); } if ( 'paid' === ( $session['payment_status'] ?? '' ) ) { SEATT_DB::mark_booking_paid( $booking_id, $session['payment_intent'] ?? $session_id, 'stripe' ); } return rest_ensure_response( array( 'received' => true ) ); } }