restaurant_id. That lets a voucher purchase offer the SAME active gateways * as a booking WITHOUT touching the booking payment flow (isolated pipeline). * * Money: the voucher stores `initial_amount` in CENTS (int). Every gateway call * derives its amount from that integer (never from a float round-trip, never * from $_REQUEST). Every endpoint is HARD-gated by alexr_gift_cards_enabled(). */ 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; } if (!alexr_gift_cards_enabled($restaurantId)) { wp_send_json_error(['message' => __eva('Gift vouchers are not available')]); 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 || empty($payment['payment_gateways'])) { wp_send_json_error(['message' => __eva('No payment method is available')]); return; } wp_send_json_success([ 'voucher' => [ 'uuid' => $voucher->uuid, 'code' => $voucher->code, 'amount' => $this->voucherAmountUnits($voucher), ], '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; $amountCents = intval($voucher->initial_amount); if ($amountCents <= 0) return false; $amount = $this->voucherAmountUnits($voucher); // display / redirect drivers // 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; // PayPal is rendered client-side (JS SDK buttons): the widget needs the // public client-id + currency to load the SDK. The booking widget gets // them from an enqueued script; the voucher widget loads it on demand. if (isset($payment_gateways['paypal']) && class_exists('\Alexr\Payments\GatewayPaypal')) { $paypal = new \Alexr\Payments\GatewayPaypal($voucher->restaurant_id); $payment_gateways['paypal']['client_id'] = $paypal->api_id; $payment_gateways['paypal']['currency'] = $paypal->currency ?: strtoupper($restaurant->currency); $payment_gateways['paypal']['amount'] = $this->voucherGatewayAmount($voucher, $paypal->currency_exchange, 2); } 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 (id + amount + currency + metadata) before the * voucher is issued. Card only: asynchronous methods (SEPA, Bizum…) would come * back as `processing`, which we cannot close without a webhook. */ 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; } if (!alexr_gift_cards_enabled($restaurantId)) { wp_send_json_error(['error' => __eva('Gift vouchers are 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; } // Server-authoritative amount + currency from the voucher. $amountCents = intval($voucher->initial_amount); $currency = strtolower($voucher->currency ?: ($voucher->restaurant ? $voucher->restaurant->currency : 'eur')); // Idempotent: reuse an existing pending intent for this voucher (same amount). $existing = \Alexr\Models\Payment::where('voucher_id', $voucher->id) ->where('payment_type', 'stripe') ->last(); if ($existing && $existing->stripeClientSecret && intval($existing->amount) === $amountCents && $existing->status === \Alexr\Enums\PaymentStatus::PENDING) { 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; } \Stripe\Stripe::setApiKey($gateway->getPrivateKey()); $intent = \Stripe\PaymentIntent::create([ 'amount' => $amountCents, 'currency' => $currency, 'payment_method_types' => ['card'], 'description' => 'Gift voucher ' . $voucher->code, 'receipt_email' => $voucher->buyer_email ?: null, 'metadata' => [ 'alexr_type' => 'voucher', 'voucher_uuid' => $voucher->uuid, 'voucher_id' => $voucher->id, 'restaurant_id' => $restaurantId, ], ]); $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($voucher->initial_amount), $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($voucher->initial_amount), $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($voucher->initial_amount), $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; // Cents in the gateway currency (exchange rate configured on the gateway). $xenditAmount = $this->voucherGatewayAmount($voucher, $gateway->currency_exchange); 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([ 'redirect' => $invoice['invoice_url'], '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; $upaymentsAmount = $this->voucherGatewayAmount($voucher, $gateway->currency_exchange); // cents (createCharge divides /100) // Upayments' browser return is re-verified through the API when possible; // the anti-replay token travels in the return URL as a second gate. $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([ 'redirect' => $payment_url, '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($voucher->initial_amount), $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: status COMPLETED AND the captured * amount/currency must equal the voucher's (in the PayPal currency, after the * configured exchange rate). Because capture already happened, the voucher is * issued in THIS handler (no redirect processor). */ 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; } if (!alexr_gift_cards_enabled($restaurantId)) { wp_send_json_error(['error' => __eva('Gift vouchers are 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 = wp_unslash($_REQUEST['orderData'] ?? ''); $orderData = json_decode($orderData, true); if (!is_array($orderData) || empty($orderData['id'])) { wp_send_json_error(['error' => __eva('Invalid PayPal order')]); return; } // Authoritative copy of the order from the PayPal API (never the browser's). $orderValidate = $gateway->validate(sanitize_text_field($orderData['id'])); $expectedCurrency = strtoupper($gateway->currency ?: $voucher->currency); $expectedCents = $this->voucherGatewayAmount($voucher, $gateway->currency_exchange); $completed = is_array($orderValidate) && isset($orderValidate['status']) && $orderValidate['status'] === 'COMPLETED' && $this->paypalOrderMatches($orderValidate, $expectedCents, $expectedCurrency); $payment = $this->persistVoucherPayment( $voucher, 'paypal', $expectedCents, strtolower($expectedCurrency), ['paypal' => $orderData, 'validate' => $orderValidate], ['status' => $completed ? \Alexr\Enums\PaymentStatus::SUCCEEDED : \Alexr\Enums\PaymentStatus::FAILED] ); if (!$completed) { wp_send_json_error(['error' => __eva('The PayPal payment could not be verified')]); return; } if ($voucher->markPaid($payment)) { do_action('alexr_voucher_issued', $voucher); } wp_send_json_success([ 'status' => $payment->status, 'redirect' => alexr_view_voucher_url($voucher->uuid), ]); } /** * Does a PayPal order (as returned by the Orders API) carry a capture whose * amount + currency equal what the voucher costs? */ protected function paypalOrderMatches(array $order, $expectedCents, $expectedCurrency) { $units = $order['purchase_units'] ?? []; if (!is_array($units) || !count($units)) return false; $found = null; foreach ($units as $unit) { $captures = $unit['payments']['captures'] ?? []; foreach ($captures as $capture) { if (($capture['status'] ?? '') === 'COMPLETED' && isset($capture['amount']['value'])) { $found = $capture['amount']; break 2; } } if (!$found && isset($unit['amount']['value'])) { $found = $unit['amount']; } } if (!$found) return false; $cents = intval(round(floatval($found['value']) * 100)); $currency = strtoupper((string) ($found['currency_code'] ?? '')); return $cents === intval($expectedCents) && $currency === strtoupper($expectedCurrency); } /** * Shared setup for a redirect-gateway voucher payment: validate nonce + PRO + * addon + 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; } if (!alexr_gift_cards_enabled($restaurantId)) { wp_send_json_error(['error' => __eva('Gift vouchers are 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 = $this->voucherAmountUnits($voucher); // currency units (the drivers multiply by 100) $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. * The *_prefix / redsys_order_id members give voucher payments their OWN * identifier namespace at the gateways (Xendit external_id, Upayments order * id, Redsys order number) so they can never collide with booking ids. */ 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' => '', 'external_prefix' => 'voucher_', // Xendit external_id / Upayments order id 'redsys_order_id' => 'V' . $voucher->id, // Redsys DS_MERCHANT_ORDER base (alphanumeric tail) 'description' => 'Gift voucher ' . $voucher->code, ]; } 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' => intval($amountCents), 'currency' => $currency, 'status' => \Alexr\Enums\PaymentStatus::PENDING, 'settings' => $settings, ], $extra)); } /** Voucher price in currency units (float), from the integer cents. */ protected function voucherAmountUnits($voucher) { return round(intval($voucher->initial_amount) / 100, 2); } /** * Voucher price converted to a gateway currency with the gateway's exchange * rate. Returns integer cents by default, or a 2-decimal float when $decimals * is given. Rounds ONCE, from the integer cents (no float round-trips). */ protected function voucherGatewayAmount($voucher, $exchange, $decimals = null) { $rate = floatval(str_replace(',', '.', (string) $exchange)); if (!$rate || $rate <= 0) $rate = 1.0; $cents = intval(round(intval($voucher->initial_amount) * $rate)); return $decimals === null ? $cents : round($cents / 100, $decimals); } }