restaurant_id. That lets a voucher purchase offer the SAME active gateways * as a booking WITHOUT touching the booking payment flow (isolated pipeline). */ trait Alexr_ajax_voucher_actions { /** * rr_voucher_create — create a pending_payment voucher order (amount computed * server-side from the GiftCard product) and return the `payment` payload * (same shape the booking widget consumes) with the tenant's active gateways. */ public function voucher_create() { $restaurantId = intval($_REQUEST['restaurantId'] ?? 0); if (!$this->verify_nonce($restaurantId)) { wp_send_json_error(['message' => __eva('Invalid request')]); return; } try { $voucher = \Alexr\Models\Voucher::createOrder([ 'restaurant_id' => $restaurantId, 'product_id' => $_REQUEST['product_id'] ?? null, 'amount' => $_REQUEST['amount'] ?? null, 'buyer_name' => $_REQUEST['buyer_name'] ?? '', 'buyer_email' => $_REQUEST['buyer_email'] ?? '', 'buyer_phone' => $_REQUEST['buyer_phone'] ?? '', 'recipient_name' => $_REQUEST['recipient_name'] ?? '', 'recipient_email' => $_REQUEST['recipient_email'] ?? '', 'message' => $_REQUEST['message'] ?? '', 'delivery_date' => $_REQUEST['delivery_date'] ?? null, 'language' => $_REQUEST['language'] ?? '', ]); } catch (\InvalidArgumentException $e) { wp_send_json_error(['message' => $e->getMessage()]); return; } $payment = $this->getVoucherPaymentArgs($voucher); if (!$payment) { wp_send_json_error(['message' => __eva('No payment method is available')]); return; } wp_send_json_success([ 'voucher' => [ 'uuid' => $voucher->uuid, 'code' => $voucher->code, 'amount' => 0.01 * intval($voucher->initial_amount), ], 'payment' => $payment, ]); } /** * Build the `payment` payload for a voucher purchase: same structure as the * booking flow's gatewayPaymentDetails(), reusing the per-gateway arg builders * (they only read ->restaurant_id, so a light reference keeps us decoupled * from Booking). Stores an anti-replay token on the voucher for the return * page. `is_capture_card` is always false — a voucher is a direct charge, * never a no-show pre-authorisation. WooCommerce is intentionally excluded: * its flow builds a WC product/cart from a booking and is not a simple charge. * * @return array|false */ public function getVoucherPaymentArgs($voucher) { $restaurant = $voucher->restaurant; if (!$restaurant) return false; $amount = 0.01 * intval($voucher->initial_amount); // in currency units if ($amount <= 0) return false; // Anti-replay token for the return URL, stored on the voucher. $settings = is_array($voucher->settings) ? $voucher->settings : []; $settings['gateway_token'] = \Evavel\Support\Str::upper(\Evavel\Support\Str::random(12)); $voucher->settings = $settings; $voucher->save(); // The builders only need ->restaurant_id. $ref = (object) ['restaurant_id' => $voucher->restaurant_id]; $payment_gateways = []; $args = $this->getStripePaymentArgs($ref); if ($args) $payment_gateways['stripe'] = $args; $args = $this->getRedsysPaymentArgs($ref, $amount); if ($args) $payment_gateways['redsys'] = $args; $args = $this->getPaypalPaymentArgs($ref); if ($args) $payment_gateways['paypal'] = $args; $args = $this->getMercadopagoPaymentArgs($ref); if ($args) $payment_gateways['mercadopago'] = $args; $args = $this->getMolliePaymentArgs($ref); if ($args) $payment_gateways['mollie'] = $args; $args = $this->getSquarePaymentArgs($ref); if ($args) $payment_gateways['square'] = $args; $args = $this->getXenditPaymentArgs($ref); if ($args) $payment_gateways['xendit'] = $args; $args = $this->getUpaymentsPaymentArgs($ref); if ($args) $payment_gateways['upayments'] = $args; return [ 'payment_gateways' => $payment_gateways, 'required' => true, 'is_capture_card' => false, 'amount' => $amount, 'currency' => strtolower($restaurant->currency), 'currency_symbol' => \Alexr\Enums\CurrencyType::symbolFor($restaurant->currency), 'message' => '', // Target of the gateway redirect: the ?myvoucher return page. 'return_url' => add_query_arg( ['payment' => 'confirm'], alexr_view_voucher_url($voucher->uuid) ), ]; } /** * rr_voucher_pay_stripe_create — create a Stripe PaymentIntent for a voucher * purchase and return its clientSecret. The amount/currency are taken from the * VOUCHER (server-authoritative — never $_REQUEST). Persists a Payment row * linked by voucher_id (booking_id stays NULL). Idempotent: reuses an existing * pending Stripe payment for the voucher. The browser confirms with * confirmParams.return_url = the ?myvoucher page, where the intent is * re-verified server-side before the voucher is issued. */ public function voucher_pay_stripe_create() { $restaurantId = intval($_REQUEST['restaurantId'] ?? 0); $uuid = sanitize_text_field($_REQUEST['voucher_uuid'] ?? ''); if (!$this->verify_nonce($restaurantId)) { wp_send_json_error(['error' => __eva('Invalid nonce. Try to refresh the page.')]); return; } if (!defined('ALEXR_PRO_VERSION') || !class_exists('\Alexr\Payments\GatewayStripe')) { wp_send_json_error(['error' => __eva('Stripe is not available')]); return; } $voucher = \Alexr\Models\Voucher::where('uuid', $uuid) ->where('restaurant_id', $restaurantId) ->first(); if (!$voucher) { wp_send_json_error(['error' => __eva('Invalid voucher')]); return; } if ($voucher->status !== \Alexr\Enums\VoucherStatus::PENDING_PAYMENT) { wp_send_json_error(['error' => __eva('This voucher has already been paid')]); return; } // Idempotent: reuse an existing pending intent for this voucher. $existing = \Alexr\Models\Payment::where('voucher_id', $voucher->id) ->where('payment_type', 'stripe') ->last(); if ($existing && $existing->stripeClientSecret) { wp_send_json_success(['clientSecret' => $existing->stripeClientSecret, 'status' => $existing->status]); return; } try { $gateway = new \Alexr\Payments\GatewayStripe($restaurantId); if (!$gateway->enabled) { wp_send_json_error(['error' => __eva('Stripe is not enabled')]); return; } // Server-authoritative amount + currency from the voucher. $amountCents = intval($voucher->initial_amount); $currency = strtolower($voucher->currency ?: ($voucher->restaurant ? $voucher->restaurant->currency : 'eur')); \Stripe\Stripe::setApiKey($gateway->getPrivateKey()); $intent = \Stripe\PaymentIntent::create([ 'amount' => $amountCents, 'currency' => $currency, 'automatic_payment_methods' => ['enabled' => true], ]); $payment = \Alexr\Models\Payment::create([ evavel_tenant_field() => $restaurantId, 'booking_id' => null, 'voucher_id' => $voucher->id, 'payment_type' => 'stripe', 'amount' => $amountCents, 'currency' => $currency, 'stripe_payment_intent_id' => $intent->id, 'stripe_payment_status' => $intent->status, 'status' => \Alexr\Enums\PaymentStatus::PENDING, 'settings' => ['stripe_client_secret' => $intent->client_secret], ]); wp_send_json_success(['clientSecret' => $intent->client_secret, 'status' => $payment->status]); } catch (\Exception $e) { wp_send_json_error(['error' => $e->getMessage()]); } } // ── Redirect gateways (Mollie / Square / Mercadopago / …) ──────────────── // Reuse the SAME drivers as bookings through their optional $return_url param // and a duck-typed voucher payable (the drivers only read ->uuid/->first_name/ // ->last_name/->email/->phone/->gateway_token/->restaurant_id). The Payment is // persisted here with voucher_id (booking_id NULL), never via the driver's own // create_payment (which assumes booking_id and trusts $_REQUEST['amount']). public function voucher_pay_mollie_create() { $ctx = $this->voucherPaymentContext('\Alexr\Payments\GatewayMollie'); if (!$ctx) return; list($voucher, $gateway, $amount, $currency, $payable, $returnUrl) = $ctx; $gp = $gateway->create_gateway_payment($payable, $amount, $returnUrl); if (!$gp || empty($gp['success'])) { wp_send_json_error(['error' => isset($gp['error']) ? $gp['error'] : __eva('Error creating gateway payment')]); return; } $this->persistVoucherPayment($voucher, 'mollie', intval($amount * 100), $currency, ['mollie' => $gp['data']]); wp_send_json_success(['redirect' => $gp['redirect']]); } public function voucher_pay_square_create() { $ctx = $this->voucherPaymentContext('\Alexr\Payments\GatewaySquare'); if (!$ctx) return; list($voucher, $gateway, $amount, $currency, $payable, $returnUrl) = $ctx; $gp = $gateway->create_gateway_payment($payable, $amount, $returnUrl); if (!$gp || empty($gp['success'])) { wp_send_json_error(['error' => __eva('Error creating gateway payment')]); return; } $data = $gp['data']; $this->persistVoucherPayment($voucher, 'square', intval($amount * 100), $currency, ['square' => $data]); wp_send_json_success(['redirect' => $data['payment_link']['long_url']]); } public function voucher_pay_mercadopago_create() { $ctx = $this->voucherPaymentContext('\Alexr\Payments\GatewayMercadopago'); if (!$ctx) return; list($voucher, $gateway, $amount, $currency, $payable, $returnUrl) = $ctx; $gp = $gateway->create_gateway_payment($payable, $amount, $returnUrl); if (!$gp || empty($gp['success'])) { wp_send_json_error(['error' => isset($gp['error']) ? $gp['error'] : __eva('Error creating gateway payment')]); return; } $this->persistVoucherPayment($voucher, 'mercadopago', intval($amount * 100), $currency, ['mercadopago' => $gp['data']]); wp_send_json_success(['redirect' => $gp['redirect']]); } public function voucher_pay_xendit_create() { $ctx = $this->voucherPaymentContext('\Alexr\Payments\GatewayXendit'); if (!$ctx) return; list($voucher, $gateway, $amount, $currency, $payable, $returnUrl) = $ctx; $amountCents = intval($amount * 100); // restaurant currency $exchange = floatval(str_replace(',', '.', $gateway->currency_exchange)); if (!$exchange || $exchange == 0) $exchange = 1.0; $xenditAmount = intval($amountCents * $exchange); // cents, gateway currency try { $invoice = $gateway->createInvoice($payable, $xenditAmount / 100, $gateway->currency, $returnUrl); if (!$invoice || empty($invoice['invoice_url'])) { wp_send_json_error(['error' => __eva('Error creating gateway payment')]); return; } $payment = $this->persistVoucherPayment( $voucher, 'xendit', $xenditAmount, strtolower($gateway->currency), ['invoice' => $invoice, 'type' => 'direct_payment'], [ 'xendit_invoice_id' => $invoice['id'] ?? null, 'xendit_external_id' => $invoice['external_id'] ?? null, 'xendit_invoice_status' => $invoice['status'] ?? null, ] ); wp_send_json_success([ 'invoice_url' => $invoice['invoice_url'], 'invoice_id' => $invoice['id'] ?? null, 'payment_id' => $payment->id, ]); } catch (\Exception $e) { wp_send_json_error(['error' => $e->getMessage()]); } } public function voucher_pay_upayments_create() { $ctx = $this->voucherPaymentContext('\Alexr\Payments\GatewayUpayments'); if (!$ctx) return; list($voucher, $gateway, $amount, $currency, $payable, $returnUrl) = $ctx; $amountCents = intval($amount * 100); $exchange = floatval(str_replace(',', '.', $gateway->currency_exchange)); if (!$exchange || $exchange == 0) $exchange = 1.0; $upaymentsAmount = intval($amountCents * $exchange); // cents (createCharge divides /100) // Upayments' browser return trusts its `result` param (no server re-verify), // so carry our anti-replay token through the return URL. $returnUrl = add_query_arg(['token' => $payable->gateway_token], $returnUrl); try { $charge = $gateway->createCharge($payable, $upaymentsAmount, $gateway->currency, $returnUrl); $payment_url = $charge['data']['link'] ?? $charge['data']['url'] ?? $charge['data']['paymentURL'] ?? $charge['data']['payment_url'] ?? null; if (!$payment_url) { wp_send_json_error(['error' => __eva('Error creating gateway payment')]); return; } $payment = $this->persistVoucherPayment( $voucher, 'upayments', $upaymentsAmount, strtolower($gateway->currency), ['charge' => $charge, 'type' => 'direct_payment'], [ 'upayments_order_id' => $charge['order']['id'] ?? null, 'upayments_reference_id' => $charge['reference']['id'] ?? null, 'upayments_status' => 'PENDING', ] ); wp_send_json_success([ 'payment_url' => $payment_url, 'payment_id' => $payment->id, 'order_id' => $charge['order']['id'] ?? null, 'reference_id' => $charge['reference']['id'] ?? null, ]); } catch (\Exception $e) { wp_send_json_error(['error' => $e->getMessage()]); } } public function voucher_pay_redsys_create() { $ctx = $this->voucherPaymentContext('\Alexr\Payments\GatewayRedsys'); if (!$ctx) return; list($voucher, $gateway, $amount, $currency, $payable, $returnUrl) = $ctx; // Redsys returns signed params the browser auto-posts to `action`. $params = $gateway->get_redsys_args($payable, $amount, null, false, $returnUrl); $rawVars = $gateway->get_raw_vars(); $action = $gateway->get_url(); $this->persistVoucherPayment($voucher, 'redsys', intval($amount * 100), $currency, [ 'redsys' => ['vars' => $rawVars, 'params' => $params, 'action' => $action], ]); wp_send_json_success(['params' => $params, 'action' => $action]); } /** * PayPal is captured client-side (PayPal JS SDK) and the captured order is * validated here against the PayPal API. Because capture already happened, the * voucher is issued in THIS handler (no redirect processor). The amount is * server-authoritative from the voucher, never the client. */ public function voucher_pay_paypal_create() { $restaurantId = intval($_REQUEST['restaurantId'] ?? 0); $uuid = sanitize_text_field($_REQUEST['voucher_uuid'] ?? ''); if (!$this->verify_nonce($restaurantId)) { wp_send_json_error(['error' => __eva('Invalid nonce. Try to refresh the page.')]); return; } if (!defined('ALEXR_PRO_VERSION') || !class_exists('\Alexr\Payments\GatewayPaypal')) { wp_send_json_error(['error' => __eva('PayPal is not available')]); return; } $voucher = \Alexr\Models\Voucher::where('uuid', $uuid) ->where('restaurant_id', $restaurantId) ->first(); if (!$voucher) { wp_send_json_error(['error' => __eva('Invalid voucher')]); return; } if ($voucher->status !== \Alexr\Enums\VoucherStatus::PENDING_PAYMENT) { // Already issued — idempotent success (the PayPal button may re-fire). wp_send_json_success(['status' => $voucher->status, 'redirect' => alexr_view_voucher_url($voucher->uuid)]); return; } $gateway = new \Alexr\Payments\GatewayPaypal($restaurantId); if (!$gateway->enabled) { wp_send_json_error(['error' => __eva('PayPal is not enabled')]); return; } $orderData = sanitize_text_field($_REQUEST['orderData'] ?? ''); $orderData = json_decode(str_replace('\"', '"', $orderData), true); $orderValidate = null; if (is_array($orderData) && isset($orderData['id'])) { $orderValidate = $gateway->validate($orderData['id']); } $completed = is_array($orderData) && isset($orderData['status']) && $orderData['status'] === 'COMPLETED' && is_array($orderValidate) && isset($orderValidate['status']) && $orderValidate['status'] === 'COMPLETED'; $amountCents = intval($voucher->initial_amount); $currency = strtolower($voucher->currency ?: ($voucher->restaurant ? $voucher->restaurant->currency : 'eur')); $payment = $this->persistVoucherPayment( $voucher, 'paypal', $amountCents, $currency, ['paypal' => $orderData, 'validate' => $orderValidate], ['status' => $completed ? \Alexr\Enums\PaymentStatus::SUCCEEDED : \Alexr\Enums\PaymentStatus::PENDING] ); if ($completed) { if ($voucher->markPaid($payment)) { do_action('alexr_voucher_issued', $voucher); } } wp_send_json_success([ 'status' => $payment->status, 'redirect' => alexr_view_voucher_url($voucher->uuid), ]); } /** * Shared setup for a redirect-gateway voucher payment: validate nonce + PRO + * gateway, resolve the pending voucher, and build the payable + return URL. * Returns [voucher, gateway, amount(units), currency, payable, returnUrl] or * null after sending a JSON error. */ protected function voucherPaymentContext($gatewayClass) { $restaurantId = intval($_REQUEST['restaurantId'] ?? 0); $uuid = sanitize_text_field($_REQUEST['voucher_uuid'] ?? ''); if (!$this->verify_nonce($restaurantId)) { wp_send_json_error(['error' => __eva('Invalid nonce. Try to refresh the page.')]); return null; } if (!defined('ALEXR_PRO_VERSION') || !class_exists($gatewayClass)) { wp_send_json_error(['error' => __eva('This payment method is not available')]); return null; } $voucher = \Alexr\Models\Voucher::where('uuid', $uuid) ->where('restaurant_id', $restaurantId) ->first(); if (!$voucher) { wp_send_json_error(['error' => __eva('Invalid voucher')]); return null; } if ($voucher->status !== \Alexr\Enums\VoucherStatus::PENDING_PAYMENT) { wp_send_json_error(['error' => __eva('This voucher has already been paid')]); return null; } $gateway = new $gatewayClass($restaurantId); if (!$gateway->enabled) { wp_send_json_error(['error' => __eva('This payment method is not enabled')]); return null; } $amount = 0.01 * intval($voucher->initial_amount); // currency units $currency = strtolower($voucher->currency ?: ($voucher->restaurant ? $voucher->restaurant->currency : 'eur')); $payable = $this->voucherPayable($voucher); $returnUrl = alexr_view_voucher_url($voucher->uuid); return [$voucher, $gateway, $amount, $currency, $payable, $returnUrl]; } /** * A duck-typed stand-in for a Booking that the gateway drivers can read from * (they only touch these properties). Keeps the voucher flow decoupled. */ protected function voucherPayable($voucher) { $name = trim((string) $voucher->buyer_name); $first = $name; $last = ''; if (strpos($name, ' ') !== false) { $parts = explode(' ', $name, 2); $first = $parts[0]; $last = $parts[1]; } $settings = is_array($voucher->settings) ? $voucher->settings : []; $token = !empty($settings['gateway_token']) ? $settings['gateway_token'] : ''; return (object) [ 'id' => $voucher->id, 'uuid' => $voucher->uuid, 'restaurant_id' => $voucher->restaurant_id, 'first_name' => $first, 'last_name' => $last, 'email' => $voucher->buyer_email, 'phone' => $voucher->buyer_phone, 'gateway_token' => $token, 'shift_event_name' => __eva('Gift voucher'), 'date' => '', ]; } protected function persistVoucherPayment($voucher, $type, $amountCents, $currency, $settings, $extra = []) { return \Alexr\Models\Payment::create(array_merge([ evavel_tenant_field() => $voucher->restaurant_id, 'booking_id' => null, 'voucher_id' => $voucher->id, 'payment_type' => $type, 'amount' => $amountCents, 'currency' => $currency, 'status' => \Alexr\Enums\PaymentStatus::PENDING, 'settings' => $settings, ], $extra)); } }