first(); if ( ! $voucher ) { wp_redirect( '/' ); exit; } // Close the payment cycle if this is a gateway return. $this->processGatewayReturn( $voucher ); ob_start(); $this->renderConfirmation( $voucher ); die( ob_get_clean() ); } /** * Server-to-server notification for a voucher (PRO routes it here when the * `booking` uuid starts with 'gv'). Redsys posts the signed response params. */ public function gateway_notification( $gateway_param, $uuid ) { $voucher = \Alexr\Models\Voucher::where( 'uuid', sanitize_text_field( $uuid ) )->first(); if ( ! $voucher || $voucher->status !== \Alexr\Enums\VoucherStatus::PENDING_PAYMENT ) { return; } if ( ! alexr_gift_cards_enabled( $voucher->restaurant_id ) ) { return; } if ( $gateway_param === 'alexr_gateway_redsys' ) { $this->processRedsysSigned( $voucher, $_POST ); } } /** * Dispatch to the matching gateway return processor. Stripe returns with a * ?payment_intent; the redirect gateways come back with * ?alexr_gateway_{gw}=...&token=... (PayPal is issued in its ajax handler). */ protected function processGatewayReturn( $voucher ) { if ( $voucher->status !== \Alexr\Enums\VoucherStatus::PENDING_PAYMENT ) { return; // already issued / cancelled — nothing to process (idempotent) } if ( ! alexr_gift_cards_enabled( $voucher->restaurant_id ) ) { return; // addon not active: never issue } if ( isset( $_GET['payment_intent'] ) ) { $this->processStripe( $voucher, sanitize_text_field( $_GET['payment_intent'] ) ); return; } if ( isset( $_GET['alexr_gateway_mercadopago'] ) ) { $this->processMercadopago( $voucher ); return; } if ( isset( $_GET['alexr_gateway_mollie'] ) ) { $this->processMollie( $voucher ); return; } if ( isset( $_GET['alexr_gateway_square'] ) ) { $this->processSquare( $voucher ); return; } if ( isset( $_GET['alexr_gateway_xendit'] ) ) { $this->processXendit( $voucher ); return; } if ( isset( $_GET['alexr_gateway_upayments'] ) ) { $this->processUpayments( $voucher ); return; } if ( isset( $_GET['alexr_gateway_redsys'] ) ) { $this->processRedsys( $voucher ); return; } } /** * Stripe return: re-read the PaymentIntent from Stripe and require that it * (a) succeeded, (b) is the intent persisted for THIS voucher, and (c) carries * the voucher's amount and currency. Only then issue. Idempotent. */ protected function processStripe( $voucher, $intentId ) { if ( ! defined( 'ALEXR_PRO_VERSION' ) || ! class_exists( '\Alexr\Payments\GatewayStripe' ) ) { return; } // (b) the intent must be ours, for this voucher. $payment = \Alexr\Models\Payment::where( 'stripe_payment_intent_id', $intentId ) ->where( 'voucher_id', $voucher->id ) ->where( 'payment_type', 'stripe' ) ->first(); if ( ! $payment ) { return; } try { $gateway = new \Alexr\Payments\GatewayStripe( $voucher->restaurant_id ); \Stripe\Stripe::setApiKey( $gateway->getPrivateKey() ); $intent = \Stripe\PaymentIntent::retrieve( $intentId ); if ( ! $intent ) { return; } $payment->stripe_payment_status = $intent->status; if ( $intent->status !== 'succeeded' ) { // canceled / requires_payment_method → failed attempt, voucher stays pending if ( in_array( $intent->status, array( 'canceled' ), true ) ) { $payment->status = \Alexr\Enums\PaymentStatus::CANCELLED; } $payment->save(); return; } // (c) amount + currency must match what the voucher costs. $expectedCents = intval( $voucher->initial_amount ); $expectedCurrency = strtolower( $voucher->currency ?: ( $voucher->restaurant ? $voucher->restaurant->currency : '' ) ); $metaUuid = isset( $intent->metadata['voucher_uuid'] ) ? (string) $intent->metadata['voucher_uuid'] : ''; if ( intval( $intent->amount_received ) !== $expectedCents || strtolower( (string) $intent->currency ) !== $expectedCurrency || ( $metaUuid !== '' && $metaUuid !== $voucher->uuid ) ) { $this->storeCallback( $payment, 'verification_failed', array( 'amount_received' => $intent->amount_received, 'currency' => $intent->currency, 'metadata_uuid' => $metaUuid, ) ); $payment->save(); return; } $payment->status = \Alexr\Enums\PaymentStatus::SUCCEEDED; $payment->save(); $this->issue( $voucher, $payment ); } catch ( \Exception $e ) { // Leave the voucher pending; the buyer sees the pending message. } } /** * Mercadopago return: the back_url carries the Mercadopago payment id. We * re-read it from the API and require status=approved, external_reference = * voucher uuid, and amount + currency equal to the voucher's. The anti-replay * token is still a first gate. */ protected function processMercadopago( $voucher ) { if ( ! $this->tokenMatches( $voucher ) ) return; if ( ! class_exists( '\Alexr\Payments\GatewayMercadopago' ) ) return; $payment = $this->voucherPayment( $voucher, 'mercadopago' ); if ( ! $payment ) return; $status = sanitize_text_field( $_GET['alexr_gateway_mercadopago'] ); $mpId = sanitize_text_field( $_GET['payment_id'] ?? ( $_GET['collection_id'] ?? '' ) ); if ( $status === 'failure' ) { $payment->status = \Alexr\Enums\PaymentStatus::CANCELLED; $payment->save(); $this->clearToken( $voucher ); $this->cancel( $voucher ); return; } if ( ! $mpId ) return; // pending without an id: keep waiting try { $gateway = new \Alexr\Payments\GatewayMercadopago( $voucher->restaurant_id ); $mp = $gateway->get_pago( $mpId ); if ( ! $mp ) return; $mpStatus = strtolower( (string) ( $mp->status ?? '' ) ); $mpReference = (string) ( $mp->external_reference ?? '' ); $mpAmount = intval( round( floatval( $mp->transaction_amount ?? 0 ) * 100 ) ); $mpCurrency = strtoupper( (string) ( $mp->currency_id ?? '' ) ); $expectedCurrency = strtoupper( $gateway->currency ?: $voucher->currency ); $ok = $mpStatus === 'approved' && $mpReference === $voucher->uuid && $mpAmount === intval( $voucher->initial_amount ) && ( $mpCurrency === '' || $mpCurrency === $expectedCurrency ); $this->storeCallback( $payment, 'mercadopago_result', array( 'payment_id' => $mpId, 'status' => $mpStatus, 'amount' => $mpAmount, 'currency' => $mpCurrency, ) ); if ( $ok ) { $payment->status = \Alexr\Enums\PaymentStatus::SUCCEEDED; $payment->save(); $this->clearToken( $voucher ); $this->issue( $voucher, $payment ); } elseif ( in_array( $mpStatus, array( 'rejected', 'cancelled', 'refunded', 'charged_back' ), true ) ) { $payment->status = \Alexr\Enums\PaymentStatus::CANCELLED; $payment->save(); $this->clearToken( $voucher ); $this->cancel( $voucher ); } else { $payment->save(); // in_process / pending: keep waiting } } catch ( \Exception $e ) {} } /** * Mollie return: re-verifies against the Mollie API by passing the voucher's * Payment to the driver's get_gateway_payment (optional $payment param). */ protected function processMollie( $voucher ) { if ( ! $this->tokenMatches( $voucher ) ) return; if ( ! class_exists( '\Alexr\Payments\GatewayMollie' ) ) return; $payment = $this->voucherPayment( $voucher, 'mollie' ); if ( ! $payment ) return; try { $gateway = new \Alexr\Payments\GatewayMollie( $voucher->restaurant_id ); $gp = $gateway->get_gateway_payment( $voucher, $payment ); if ( ! $gp ) return; if ( $gp->isPaid() ) { $payment->status = \Alexr\Enums\PaymentStatus::SUCCEEDED; $this->storeCallback( $payment, 'payment', json_decode( json_encode( $gp ), true ) ); $payment->save(); $this->clearToken( $voucher ); $this->issue( $voucher, $payment ); } elseif ( $gp->isPending() ) { // leave pending } else { $payment->status = \Alexr\Enums\PaymentStatus::CANCELLED; $payment->save(); $this->clearToken( $voucher ); $this->cancel( $voucher ); } } catch ( \Exception $e ) {} } /** * Square return: re-verifies via the driver's get_gateway_payment (passing the * voucher's Payment) and checks the order status. */ protected function processSquare( $voucher ) { if ( ! $this->tokenMatches( $voucher ) ) return; if ( ! class_exists( '\Alexr\Payments\GatewaySquare' ) ) return; $payment = $this->voucherPayment( $voucher, 'square' ); if ( ! $payment ) return; try { $gateway = new \Alexr\Payments\GatewaySquare( $voucher->restaurant_id ); $gp = $gateway->get_gateway_payment( $voucher, $payment ); if ( ! $gp ) return; $status = isset( $gp['status'] ) ? $gp['status'] : ''; if ( $status === 'COMPLETED' || $status === 'PAID' ) { $payment->status = \Alexr\Enums\PaymentStatus::SUCCEEDED; $payment->save(); $this->clearToken( $voucher ); $this->issue( $voucher, $payment ); } else { $payment->status = \Alexr\Enums\PaymentStatus::CANCELLED; $payment->save(); $this->clearToken( $voucher ); $this->cancel( $voucher ); } } catch ( \Exception $e ) {} } /** * Xendit return: re-verifies against the Xendit API (getInvoice must report * PAID) — authoritative, so no token is needed. Finds the voucher's Payment by * the external_id echoed in the return URL. (The Xendit webhook also issues * the voucher — see ajax-xendit-actions.php — whichever arrives first wins.) */ protected function processXendit( $voucher ) { if ( ! class_exists( '\Alexr\Payments\GatewayXendit' ) ) return; $externalId = sanitize_text_field( $_GET['xendit_external_id'] ?? '' ); $status = strtolower( sanitize_text_field( $_GET['xendit_status'] ?? '' ) ); if ( ! $externalId ) return; $payment = \Alexr\Models\Payment::where( 'payment_type', 'xendit' ) ->where( 'xendit_external_id', $externalId ) ->where( 'voucher_id', $voucher->id ) ->first(); if ( ! $payment ) return; if ( $status === 'success' || $status === 'paid' ) { try { $gateway = new \Alexr\Payments\GatewayXendit( $voucher->restaurant_id ); $invoice = $gateway->getInvoice( $payment->xendit_invoice_id ); if ( $invoice && strtoupper( $invoice['status'] ?? '' ) === 'PAID' && intval( round( floatval( $invoice['amount'] ?? 0 ) * 100 ) ) === intval( $payment->amount ) ) { $payment->status = \Alexr\Enums\PaymentStatus::SUCCEEDED; $payment->xendit_invoice_status = 'PAID'; $this->storeCallback( $payment, 'callback', array( 'external_id' => $externalId, 'status' => $status ) ); $payment->save(); $this->issue( $voucher, $payment ); } } catch ( \Exception $e ) {} } elseif ( $status === 'failed' || $status === 'failure' ) { $payment->status = \Alexr\Enums\PaymentStatus::FAILED; $payment->xendit_invoice_status = 'FAILED'; $payment->save(); $this->cancel( $voucher ); } } /** * Upayments return: gated by our anti-replay token (carried in the return * URL given to the gateway) and, when Upayments echoes a payment_id, verified * through the API (check-payment-status). Without an API answer we fall back * to the token-gated `result` param, as the booking flow does. */ protected function processUpayments( $voucher ) { if ( ! $this->tokenMatches( $voucher ) ) return; $orderId = sanitize_text_field( $_GET['upayments_order_id'] ?? '' ); $result = strtoupper( sanitize_text_field( $_GET['result'] ?? '' ) ); $paymentId = sanitize_text_field( $_GET['payment_id'] ?? ( $_GET['paymentId'] ?? '' ) ); $payment = null; if ( $orderId ) { $payment = \Alexr\Models\Payment::where( 'payment_type', 'upayments' ) ->where( 'upayments_order_id', $orderId ) ->where( 'voucher_id', $voucher->id ) ->first(); } if ( ! $payment ) { $payment = \Alexr\Models\Payment::where( 'payment_type', 'upayments' ) ->where( 'voucher_id', $voucher->id ) ->last(); } if ( ! $payment ) return; // API verification (best effort: the response shape is searched for a result/status). $apiResult = ''; if ( $paymentId && class_exists( '\Alexr\Payments\GatewayUpayments' ) ) { try { $gateway = new \Alexr\Payments\GatewayUpayments( $voucher->restaurant_id ); $response = $gateway->checkPaymentStatus( $paymentId ); $apiResult = strtoupper( (string) $this->findKeyDeep( $response, array( 'result', 'status', 'payment_status' ) ) ); $this->storeCallback( $payment, 'check_payment_status', $response ); } catch ( \Exception $e ) {} } $effective = $apiResult !== '' ? $apiResult : $result; if ( in_array( $effective, array( 'CAPTURED', 'SUCCESS', 'PAID', 'TRUE' ), true ) ) { $payment->status = \Alexr\Enums\PaymentStatus::SUCCEEDED; $payment->upayments_status = $effective; if ( $paymentId ) $payment->upayments_payment_id = $paymentId; $this->storeCallback( $payment, 'callback', array( 'order_id' => $orderId, 'result' => $result, 'api_result' => $apiResult ) ); $payment->save(); $this->clearToken( $voucher ); $this->issue( $voucher, $payment ); } elseif ( in_array( $effective, array( 'NOT CAPTURED', 'NOT_CAPTURED', 'FAILED', 'FAILURE', 'CANCELED', 'CANCELLED', 'FALSE' ), true ) ) { $payment->status = \Alexr\Enums\PaymentStatus::FAILED; $payment->upayments_status = $effective; $payment->save(); $this->clearToken( $voucher ); $this->cancel( $voucher ); } } /** * Redsys browser return (URLOK/URLKO). If Redsys appended the signed response * parameters we verify them right here and issue; otherwise we only record the * ko and leave the voucher pending for the signed server notification. The * anti-replay token is a first gate but never sufficient on its own. */ protected function processRedsys( $voucher ) { if ( ! $this->tokenMatches( $voucher ) ) return; $result = sanitize_text_field( $_GET['alexr_gateway_redsys'] ?? '' ); if ( isset( $_GET['Ds_MerchantParameters'] ) && isset( $_GET['Ds_Signature'] ) ) { $this->processRedsysSigned( $voucher, $_GET ); return; } if ( $result === 'ko' ) { $payment = $this->voucherPayment( $voucher, 'redsys' ); if ( $payment ) { $payment->status = \Alexr\Enums\PaymentStatus::CANCELLED; $payment->save(); } $this->clearToken( $voucher ); $this->cancel( $voucher ); } // 'ok' without signed params: wait for the server notification. } /** * Verify a signed Redsys response (redirect params or server notification) * and issue / cancel accordingly. Checks signature, authorisation code, * amount, and that the order belongs to the voucher's Redsys payment. */ protected function processRedsysSigned( $voucher, $source ) { if ( ! class_exists( '\Alexr\Payments\GatewayRedsys' ) ) return; $params = isset( $source['Ds_MerchantParameters'] ) ? trim( wp_unslash( $source['Ds_MerchantParameters'] ) ) : ''; $signature = isset( $source['Ds_Signature'] ) ? trim( wp_unslash( $source['Ds_Signature'] ) ) : ''; if ( $params === '' || $signature === '' ) return; $payment = $this->voucherPayment( $voucher, 'redsys' ); if ( ! $payment ) return; try { $gateway = new \Alexr\Payments\GatewayRedsys( $voucher->restaurant_id ); $data = $gateway->verify_response( $params, $signature ); } catch ( \Exception $e ) { return; } if ( ! $data ) { $this->storeCallback( $payment, 'redsys_invalid_signature', array( 'at' => current_time( 'mysql' ) ) ); $payment->save(); return; } $this->storeCallback( $payment, 'redsys_response', $data ); // The response must be for the order we sent. $settings = is_array( $payment->settings ) ? $payment->settings : array(); $sentOrder = isset( $settings['redsys']['vars']['DS_MERCHANT_ORDER'] ) ? (string) $settings['redsys']['vars']['DS_MERCHANT_ORDER'] : ''; $gotOrder = (string) ( $data['Ds_Order'] ?? '' ); $gotAmount = intval( $data['Ds_Amount'] ?? -1 ); $authorised = \Alexr\Payments\GatewayRedsys::is_response_authorised( $data['Ds_Response'] ?? '' ); if ( $authorised && $gotAmount === intval( $payment->amount ) && ( $sentOrder === '' || $sentOrder === $gotOrder ) ) { $payment->status = \Alexr\Enums\PaymentStatus::SUCCEEDED; $payment->save(); $this->clearToken( $voucher ); $this->issue( $voucher, $payment ); } elseif ( ! $authorised ) { $payment->status = \Alexr\Enums\PaymentStatus::CANCELLED; $payment->save(); $this->clearToken( $voucher ); $this->cancel( $voucher ); } else { $payment->save(); // authorised but amount/order mismatch: keep pending for review } } protected function voucherPayment( $voucher, $type ) { return \Alexr\Models\Payment::where( 'voucher_id', $voucher->id ) ->where( 'payment_type', $type ) ->last(); } /** First value found for any of $keys in a (nested) array / object. */ protected function findKeyDeep( $data, array $keys ) { if ( is_object( $data ) ) $data = json_decode( json_encode( $data ), true ); if ( ! is_array( $data ) ) return ''; foreach ( $keys as $key ) { if ( isset( $data[ $key ] ) && is_scalar( $data[ $key ] ) ) { return (string) ( is_bool( $data[ $key ] ) ? ( $data[ $key ] ? 'TRUE' : 'FALSE' ) : $data[ $key ] ); } } foreach ( $data as $value ) { if ( is_array( $value ) || is_object( $value ) ) { $found = $this->findKeyDeep( $value, $keys ); if ( $found !== '' ) return $found; } } return ''; } /** * Anti-replay: the return URL carries &token=..., compared (constant-time) * against the token stashed on the voucher when the payment was created. */ protected function tokenMatches( $voucher ) { $token = isset( $_GET['token'] ) ? sanitize_text_field( $_GET['token'] ) : ''; $settings = is_array( $voucher->settings ) ? $voucher->settings : []; $stored = isset( $settings['gateway_token'] ) ? $settings['gateway_token'] : ''; return $token !== '' && $stored !== '' && hash_equals( (string) $stored, (string) $token ); } protected function clearToken( $voucher ) { $settings = is_array( $voucher->settings ) ? $voucher->settings : []; $settings['gateway_token'] = null; $voucher->settings = $settings; $voucher->save(); } protected function cancel( $voucher ) { $voucher->status = \Alexr\Enums\VoucherStatus::CANCELLED; $voucher->save(); } protected function storeCallback( $payment, $key, $value ) { $settings = is_array( $payment->settings ) ? $payment->settings : []; $settings[ $key ] = $value; $payment->settings = $settings; } /** * Mark the voucher paid/active and fire the issued hook (the delivery phase * listens to it to email the buyer / recipient). markPaid() flips the row * atomically, so concurrent returns can never issue twice. */ protected function issue( $voucher, $payment = null ) { $activated = $voucher->markPaid( $payment ); if ( $activated ) { do_action( 'alexr_voucher_issued', $voucher ); } } /** * Standalone confirmation + printable gift voucher page. Active vouchers show * a print-ready certificate (code, amount, to/from, message, expiry, terms); * pending (payment still being confirmed by the gateway) auto-refreshes a few * times; cancelled/expired show a short status message. */ protected function renderConfirmation( $voucher ) { // Translate into the voucher's language (the buyer's, not the site's). // NOTE: __eva() is only an extractor marker — it does NOT translate. $lang = $voucher->language ?: 'en'; $t = function ( $string ) use ( $lang ) { return function_exists( 'alexr_translate_x' ) ? alexr_translate_x( $string, $lang ) : $string; }; $restaurant = $voucher->restaurant; $symbol = \Alexr\Enums\CurrencyType::symbolFor( $voucher->currency ); $isActive = in_array( $voucher->status, array( \Alexr\Enums\VoucherStatus::ACTIVE, \Alexr\Enums\VoucherStatus::REDEEMED ), true ); $isPending = $voucher->status === \Alexr\Enums\VoucherStatus::PENDING_PAYMENT; $amount = $symbol . number_format( 0.01 * intval( $voucher->initial_amount ), 2 ); $settings = is_array( $voucher->settings ) ? $voucher->settings : []; $product_name = isset( $settings['product_name'] ) ? $settings['product_name'] : ''; // Fetch the product for its terms (not snapshotted on the voucher). $terms = ''; if ( $voucher->product_id ) { $product = \Alexr\Settings\GiftCard::find( $voucher->product_id ); if ( $product ) { $terms = (string) $product->terms; } } $expires = $voucher->expires_at ? date_i18n( get_option( 'date_format' ), strtotime( $voucher->expires_at ) ) : $t( __eva( 'No expiry' ) ); $title = $isActive ? $t( __eva( 'Your gift voucher is ready!' ) ) : ( $isPending ? $t( __eva( 'Completing your payment…' ) ) : $t( __eva( 'Gift voucher' ) ) ); // Pending: poll the page a few times while the gateway confirms (without // re-sending the gateway params, so nothing is re-processed). $refreshUrl = ''; if ( $isPending ) { $tries = intval( $_GET['r'] ?? 0 ); if ( $tries < 6 ) { $refreshUrl = add_query_arg( array( 'r' => $tries + 1 ), alexr_view_voucher_url( $voucher->uuid ) ); } } ?>
name : '' ); ?>
status ) ) ); ?>