Plugin Directory

Changeset 3488279


Ignore:
Timestamp:
03/22/2026 04:01:56 PM (13 days ago)
Author:
patsatech
Message:
  • Hardened IPN/callback handling (validate crypt, decode before logging, safer VendorTxCode parsing, correct error when entry is missing).
  • Fixed duplicate-payment guard when redirecting to Opayo (avoid blocking legitimate submissions).
  • Restored Apply 3D Secure global setting and send Apply3DSecure in the Crypt payload.
  • Fixed mislabeled transaction type setting; use version_compare for OpenSSL vs mcrypt.
  • Added filter gform_sagepay_form_gateway_register_url to override test/live registration URLs if Opayo changes endpoints.
  • Synced plugin version constant with readme; trimmed noisy debug logging on redirect.
  • Default Form registration URLs now use Elavon Opayo hosts (sandbox.opayo.eu.elavon.com / live.opayo.eu.elavon.com) instead of legacy test.sagepay.com / live.sagepay.com. Same path: /gateway/service/vspform-register.vsp. Use filter gform_sagepay_form_gateway_register_url to point at legacy or alternate URLs if required.
  • Fixed Opayo Form Crypt encryption for PHP OpenSSL: removed double padding (manual PKCS5 plus OpenSSL PKCS7), which commonly caused error 5080 “Form transaction registration failed”. Encryption now matches Opayo’s documented approach (16-byte key/IV from password, uppercase hex).
  • Amount sent as formatted decimal (e.g. 10.00). Sanitize Crypt field values (control characters). Safer VendorData mapping; optional ReferrerID via filter gform_sagepay_form_referrer_id.
  • Resolve Opayo Vendor from add-on settings, legacy gf_sagepay_form_configured option, or wrongly nested upgrade data; filter gform_sagepay_form_vendor_name.
  • Block checkout if vendor name is still missing (clear error instead of posting blank Vendor to Opayo).
  • On the bridge page, reinject Vendor from saved settings when the query string omits it; restore hidden fields and auto-submit (removed debug text inputs / disabled submit).
  • Upgrade copy_settings now merges legacy settings flat instead of nesting them under gf_sagepay_form_configured.
  • Default payment handoff uses a short URL + server-side transient, then POSTs to Opayo (avoids huge Crypt in the query string, which is often truncated by servers/proxies and causes 5080).
  • Strip & and = from values inside the encrypted Crypt payload (they break Opayo’s name=value parsing).
  • Filters: gform_sagepay_form_use_transient_bridge (default true), gform_sagepay_form_bridge_params.
Location:
sagepay-form-payment-gateway-for-gravity-forms/trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • sagepay-form-payment-gateway-for-gravity-forms/trunk/class-gf-sagepay-form.php

    r2888615 r3488279  
    9999                        'class'    => 'medium',
    100100                        'required' => true,
    101                         'tooltip'  => '<h6>' . __( 'Encryption Password', 'gravityforms-sagepay-form' ) . '</h6>' . __( 'Please enter your encryption password provided by Opayo Form.', 'gravityforms-sagepay-form' ),
     101                        'tooltip'  => '<h6>' . __( 'Encryption Password', 'gravityforms-sagepay-form' ) . '</h6>' . __( 'Enter the Form integration encryption password from MyOpayo (Administrator → Password details). It must match the environment (test/live). Opayo typically uses a 16-character password; shorter values are null-padded for AES as per Opayo’s PHP examples.', 'gravityforms-sagepay-form' ),
    102102                    ),
    103103                    array(
     
    143143                        'required' => true,
    144144                        'tooltip'  => '<h6>' . __( 'Customer E-Mail Message', 'gravityforms-sagepay-form' ) . '</h6>' . __( 'A message to the customer which is inserted into the successful transaction e-mails only.', 'gravityforms-sagepay-form' ),
    145                     ),/*
     145                    ),
    146146                    array(
    147147                        'name'          => 'apply3d',
     
    164164                        'horizontal'    => true,
    165165                        'default_value' => '1',
    166                         'tooltip'       => '<h6>' . __( 'Apply 3D Secure', 'gravityforms-sagepay-form' ) . '</h6>' . __( 'Select whether you would like to do 3D Secure Check on Transactions.', 'gravityforms-sagepay-form' ),
    167                     ),*/
     166                        'tooltip'       => '<h6>' . __( 'Apply 3D Secure', 'gravityforms-sagepay-form' ) . '</h6>' . __( 'When enabled, Opayo applies its default 3D Secure rules. When disabled, 3D Secure authentication is not applied (Opayo Apply3DSecure = 2).', 'gravityforms-sagepay-form' ),
     167                    ),
    168168                    array(
    169169                        'name'          => 'trans_type',
    170                         'label'         => __( 'Send E-Mail', 'gravityforms-sagepay-form' ),
     170                        'label'         => __( 'Transaction type', 'gravityforms-sagepay-form' ),
    171171                        'type'          => 'radio',
    172172                        'choices'       => array(
     
    191191                        'horizontal'    => true,
    192192                        'default_value' => 'PAYMENT',
    193                         'tooltip'       => '<h6>' . __( 'Send E-Mails', 'gravityforms-sagepay-form' ) . '</h6>' . __( 'Select Who to send e-mails to.', 'gravityforms-sagepay-form' ),
     193                        'tooltip'       => '<h6>' . __( 'Transaction type', 'gravityforms-sagepay-form' ) . '</h6>' . __( 'PAYMENT captures funds immediately. DEFERRED authorises for later capture. AUTHENTICATE only validates the card.', 'gravityforms-sagepay-form' ),
    194194                    ),
    195195                    array(
     
    278278        // -------------------------------------------------------------------------------------------------
    279279
    280         // --add Page Style, Continue Button Label, Cancel URL
     280        // --add Page Style, Continue Button Label (Success/Cancel URLs use entry source_url + default GF confirmation)
    281281        $fields = array(
    282282            /*
     
    297297                'tooltip'  => '<h6>' . __( 'Continue Button Label', 'gravityforms-sagepay-form' ) . '</h6>' . __( 'Enter the text that should appear on the continue button once payment has been completed via SagePay Form.', 'gravityforms-sagepay-form' )
    298298            ),*/
    299             array(
    300                 'name'     => 'cancelUrl',
    301                 'label'    => __( 'Cancel URL', 'gravityforms-sagepay-form' ),
    302                 'type'     => 'text',
    303                 'class'    => 'medium',
    304                 'required' => false,
    305                 'tooltip'  => '<h6>' . __( 'Cancel URL', 'gravityforms-sagepay-form' ) . '</h6>' . __( 'Enter the URL the user should be sent to should they cancel before completing their Opayo Form payment or payment fails.', 'gravityforms-sagepay-form' ),
    306             ),
    307             array(
    308                 'name'     => 'successUrl',
    309                 'label'    => __( 'Success URL', 'gravityforms-sagepay-form' ),
    310                 'type'     => 'text',
    311                 'class'    => 'medium',
    312                 'required' => false,
    313                 'tooltip'  => '<h6>' . __( 'Success URL', 'gravityforms-sagepay-form' ) . '</h6>' . __( 'Enter the URL the user should be sent to after completing their Opayo Form payment.', 'gravityforms-sagepay-form' ),
    314             ), /*
     299            /*
    315300            array(
    316301                'name'    => 'options',
     
    693678    public function redirect_url( $feed, $submission_data, $form, $entry ) {
    694679
    695 
    696         $this->log_debug( '' );
    697 
    698         $this->log_debug( '' );
    699 
    700         $this->log_debug( '================================================================================================================' );
    701 
    702         $this->log_debug( '================================================================================================================' );
    703 
    704         $this->log_debug( 'Form Submission Data : {<pre>' . print_r( $entry, true ) . '</pre>}' );
     680        $this->log_debug( 'redirect_url: preparing Opayo Form redirect for entry #' . $entry['id'] );
    705681
    706682        // Don't process redirect url if request is a Opayo Form return
     
    711687        $settings = $this->get_plugin_settings();
    712688
     689        $trans_type_setting = rgar( $settings, 'trans_type', 'PAYMENT' );
     690
    713691        $entry_check = GFAPI::get_entry( $entry['id'] );
    714 
    715         if( ( $entry_check['payment_status'] != '' || $entry_check['payment_status'] != 'Processing' ) && !empty($entry_check['transaction_id']) ){
    716 
    717             wp_die('<pre>Payment Failed please try again after sometime. Or contact support entry ID:'.$entry['id'].'</pre>');
    718             exit;
    719 
     692        if ( is_wp_error( $entry_check ) ) {
     693            $this->log_error( 'redirect_url: could not load entry #' . $entry['id'] . ' — ' . $entry_check->get_error_message() );
     694            return '';
     695        }
     696
     697        $pay_status     = rgar( $entry_check, 'payment_status', '' );
     698        $transaction_id = rgar( $entry_check, 'transaction_id', '' );
     699        $final_statuses = array( 'Paid', 'Approved', 'Active' );
     700
     701        if ( in_array( $pay_status, $final_statuses, true ) ) {
     702            wp_die(
     703                esc_html(
     704                    sprintf(
     705                        /* translators: %d: entry ID */
     706                        __( 'This entry is already paid. Reference: %d', 'gravityforms-sagepay-form' ),
     707                        (int) $entry['id']
     708                    )
     709                ),
     710                esc_html__( 'Payment', 'gravityforms-sagepay-form' ),
     711                array( 'response' => 400 )
     712            );
     713        }
     714
     715        // Block duplicate in-flight payment if a transaction id already exists while still "Processing"
     716        if ( $pay_status === 'Processing' && $transaction_id !== '' && $transaction_id !== null ) {
     717            wp_die(
     718                esc_html(
     719                    sprintf(
     720                        /* translators: %d: entry ID */
     721                        __( 'A payment is already in progress for this entry. Please wait or contact support. Reference: %d', 'gravityforms-sagepay-form' ),
     722                        (int) $entry['id']
     723                    )
     724                ),
     725                esc_html__( 'Payment', 'gravityforms-sagepay-form' ),
     726                array( 'response' => 409 )
     727            );
    720728        }
    721729
     
    723731        GFAPI::update_entry_property( $entry['id'], 'payment_status', 'Processing' );
    724732
    725         if ( $settings['trans_type'] == 'PAYMENT' ) {
     733        if ( $trans_type_setting === 'PAYMENT' ) {
    726734            $trans_type = 'PAYMENT';
    727         } elseif ( $settings['trans_type'] == 'DEFERRED' ) {
     735        } elseif ( $trans_type_setting === 'DEFERRED' ) {
    728736            $trans_type = 'DEFERRED';
    729737        } else {
     
    743751        $customer_fields = $this->customer_query_string( $feed, $entry );
    744752
    745         $return_url = $this->return_url( $form['id'], $entry['id'] );
    746 
    747         // Cancel URL
    748         $cancel_url = ! empty( $feed['meta']['cancelUrl'] ) ? $feed['meta']['cancelUrl'] : home_url();
    749 
    750753        // URL that will listen to notifications from Opayo Form
    751754        $ipn_url = add_query_arg( 'page', 'gf_sagepay_form_ipn', home_url( '/' ) );
    752755
    753         $vendor_name   = $settings['vendor_name'];
    754         $mode          = $settings['mode'];
    755         //$apply3d       = $settings['apply3d'];
    756         $email_message = $settings['email_message'];
    757         $send_emails   = $settings['send_emails'];
    758         $vendor_email  = $settings['vendor_email'];
    759 
    760         $custom_field = $entry['id'] . '|' . wp_hash( $entry['id'] );
    761 
     756        $failure_return_url = $this->get_opayo_cancel_return_url( $form, $entry );
     757
     758        $vendor_name = $this->resolve_plugin_vendor_name( $settings );
     759        if ( $vendor_name === '' ) {
     760            $this->log_error( 'Opayo Form: Vendor Name is empty. Set it under Forms → Settings → Opayo Form.' );
     761            wp_die(
     762                esc_html__( 'The payment gateway is not fully configured (missing Opayo vendor name). Please contact the site administrator.', 'gravityforms-sagepay-form' ),
     763                esc_html__( 'Payment unavailable', 'gravityforms-sagepay-form' ),
     764                array( 'response' => 503 )
     765            );
     766        }
     767
     768        $apply3d       = rgar( $settings, 'apply3d', '1' );
     769        $email_message = rgar( $settings, 'email_message', '' );
     770        $send_emails   = rgar( $settings, 'send_emails', '2' );
     771        $vendor_email  = rgar( $settings, 'vendor_email', '' );
     772
     773        $payment_amount = false;
    762774        switch ( $feed['meta']['transactionType'] ) {
    763775            case 'product':
     
    774786                break;
    775787            */
     788        }
     789
     790        if ( $payment_amount === false || $payment_amount === '' || (float) $payment_amount <= 0 ) {
     791            $this->log_debug( 'redirect_url: invalid or zero payment amount; not sending to Opayo.' );
     792            return '';
    776793        }
    777794
     
    795812        //$country = GFCommon::get_country_code( $submission_data['country'] );
    796813
    797         $sagepay_arg['VendorData'] = $entry[ $feed['meta']['billingInformation_vendor_data'] ];
    798         $sagepay_arg['ReferrerID'] = 'CC923B06-40D5-4713-85C1-700D690550BF';
    799         $sagepay_arg['Amount']     = $payment_amount;
     814        $vendor_data_field_id = rgar( $feed['meta'], 'billingInformation_vendor_data' );
     815        $vendor_data          = ( $vendor_data_field_id !== '' && $vendor_data_field_id !== null ) ? rgar( $entry, (string) $vendor_data_field_id, '' ) : '';
     816        if ( $vendor_data !== '' ) {
     817            $sagepay_arg['VendorData'] = $this->sanitize_opayo_crypt_field_value( $vendor_data );
     818        }
     819
     820        $referrer_id = apply_filters( 'gform_sagepay_form_referrer_id', 'CC923B06-40D5-4713-85C1-700D690550BF', $form, $entry, $feed );
     821        if ( $referrer_id !== '' && $referrer_id !== null ) {
     822            $sagepay_arg['ReferrerID'] = $this->sanitize_opayo_crypt_field_value( (string) $referrer_id );
     823        }
     824
     825        $sagepay_arg['Amount'] = $this->format_opayo_amount( $payment_amount );
    800826
    801827        $sagepay_arg['CustomerName']  = substr( $first_name . ' ' . $last_name, 0, 100 );
     
    830856        $sagepay_arg['DeliveryPhone']    = substr( $phone, 0, 20 );
    831857
    832         $sagepay_arg['FailureURL']   = $ipn_url;
    833         $sagepay_arg['SuccessURL']   = $ipn_url;
     858        $sagepay_arg['FailureURL'] = $failure_return_url;
     859        $sagepay_arg['SuccessURL'] = $ipn_url;
    834860        $sagepay_arg['Description']  = sprintf( __( 'Order #%s', 'gravityforms' ), ltrim( $invoice, '#' ) );
    835861        $sagepay_arg['Currency']     = $currency;
    836862        $sagepay_arg['VendorTxCode'] = $custom_field;
    837863        $sagepay_arg['VendorEMail']  = $vendor_email;
    838         $sagepay_arg['SendEMail']    = $send_emails;
    839         /*
    840         if ( $apply3d != 0 ) {
    841             $sagepay_arg['Apply3DSecure'] = 0;$apply3d;
    842         }*/
    843         $sagepay_arg['Website'] = get_bloginfo( 'name' );
     864        $sagepay_arg['SendEMail'] = $send_emails;
     865        // Opayo: 0 = apply 3DS per account/rules, 2 = do not apply 3DS
     866        $sagepay_arg['Apply3DSecure'] = ( $apply3d === '1' || $apply3d === 1 || $apply3d === true ) ? 0 : 2;
     867        $sagepay_arg['Website']       = get_bloginfo( 'name' );
    844868        if ( $country == 'US' ) {
    845869                $sagepay_arg['eMailMessage'] = $email_message;
    846870        }
    847871
    848         $post_values = '';
     872        $crypt_url_keys = array( 'FailureURL', 'SuccessURL' );
     873        $post_values    = '';
    849874        foreach ( $sagepay_arg as $key => $value ) {
    850             $post_values .= "$key=" . trim( $value ) . '&';
     875            $raw = trim( (string) $value );
     876            $val = in_array( $key, $crypt_url_keys, true ) ? $this->sanitize_opayo_crypt_url_value( $raw ) : $this->sanitize_opayo_crypt_field_value( $raw );
     877            $post_values .= $key . '=' . $val . '&';
    851878        }
    852879        $post_values = substr( $post_values, 0, -1 );
     
    855882        $params['TxType']      = $trans_type;
    856883        $params['Vendor']      = $vendor_name;
    857         $params['Crypt']           = $this->encryptAndEncode( $post_values );
    858 
    859         $query_string = http_build_query( $params );
    860 
    861         $query_string = apply_filters( "gform_sagepay_form_query_{$form['id']}", apply_filters( 'gform_sagepay_form_query', $query_string, $form, $entry, $feed ), $form, $entry, $feed );
    862 
    863         if ( ! $query_string ) {
    864             $this->log_debug( 'NOT sending to Opayo Form: The price is either zero or the gform_sagepay_form_query filter was used to remove the querystring that is sent to Opayo Form.' );
    865 
     884        $params['Crypt']       = $this->encryptAndEncode( $post_values );
     885
     886        if ( strlen( $params['Crypt'] ) < 32 ) {
     887            $this->log_error( 'Opayo Form: Crypt payload is invalid or encryption failed; not redirecting.' );
    866888            return '';
    867889        }
    868890
    869         $url = $ipn_url . '&' . $query_string;
     891        $use_transient_bridge = apply_filters( 'gform_sagepay_form_use_transient_bridge', true, $params, $form, $entry, $feed );
     892
     893        if ( $use_transient_bridge ) {
     894            $params = apply_filters( 'gform_sagepay_form_bridge_params', $params, $form, $entry, $feed );
     895            if ( empty( $params['Crypt'] ) || empty( $params['Vendor'] ) ) {
     896                $this->log_error( 'Opayo Form: bridge params invalid after gform_sagepay_form_bridge_params filter.' );
     897                return '';
     898            }
     899            $token = wp_generate_password( 32, false, false );
     900            set_transient( $this->get_opayo_bridge_transient_key( $token ), $params, 15 * MINUTE_IN_SECONDS );
     901            $url = add_query_arg(
     902                array(
     903                    'page'     => 'gf_sagepay_form_ipn',
     904                    'opayo_br' => $token,
     905                ),
     906                home_url( '/' )
     907            );
     908        } else {
     909            $query_string = http_build_query( $params );
     910
     911            $query_string = apply_filters( "gform_sagepay_form_query_{$form['id']}", apply_filters( 'gform_sagepay_form_query', $query_string, $form, $entry, $feed ), $form, $entry, $feed );
     912
     913            if ( ! $query_string ) {
     914                $this->log_debug( 'NOT sending to Opayo Form: The price is either zero or the gform_sagepay_form_query filter was used to remove the querystring that is sent to Opayo Form.' );
     915
     916                return '';
     917            }
     918
     919            $url = $ipn_url . '&' . $query_string;
     920        }
    870921
    871922        $url = apply_filters( "gform_sagepay_form_request_{$form['id']}", apply_filters( 'gform_sagepay_form_request', $url, $form, $entry, $feed ), $form, $entry, $feed );
     
    878929    }
    879930
     931    /**
     932     * Opayo Form AES-128-CBC: use a 16-byte key and IV derived from the Form encryption password (pad with nulls or truncate).
     933     * OpenSSL applies PKCS#7 padding; do not pre-pad the plaintext (manual PKCS5 + OpenSSL caused double padding and 5080 errors).
     934     */
     935    private function get_opayo_form_aes_key( $password ) {
     936        $password = (string) $password;
     937        return substr( str_pad( $password, 16, "\0" ), 0, 16 );
     938    }
     939
     940    /**
     941     * Strip characters that break the Crypt name=value& string (newlines, control chars).
     942     */
     943    private function sanitize_opayo_crypt_field_value( $value ) {
     944        $value = preg_replace( '/[\x00-\x1F\x7F]/', ' ', (string) $value );
     945        // Plaintext is parsed as name=value pairs; & and = inside values break Opayo's decoder → 5080.
     946        $value = str_replace( array( '&', '=' ), ' ', $value );
     947        return trim( preg_replace( '/\s+/', ' ', $value ) );
     948    }
     949
     950    /**
     951     * URLs inside Crypt must keep query strings intact (&, =).
     952     */
     953    private function sanitize_opayo_crypt_url_value( $value ) {
     954        $value = preg_replace( '/[\x00-\x1F\x7F]/', ' ', (string) $value );
     955        return trim( $value );
     956    }
     957
     958    /**
     959     * Page the customer submitted from (Gravity Forms entry source_url); used for Opayo cancel / failure redirect.
     960     */
     961    private function get_opayo_cancel_return_url( $form, $entry ) {
     962        $url = '';
     963        if ( is_array( $entry ) ) {
     964            $url = trim( (string) rgar( $entry, 'source_url', '' ) );
     965        }
     966        $parsed = is_string( $url ) && $url !== '' ? wp_parse_url( $url ) : array();
     967        if ( ! empty( $parsed['scheme'] ) && in_array( strtolower( $parsed['scheme'] ), array( 'http', 'https' ), true ) ) {
     968            return apply_filters( 'gform_sagepay_form_cancel_return_url', $url, $form, $entry );
     969        }
     970
     971        return apply_filters( 'gform_sagepay_form_cancel_return_url', home_url( '/' ), $form, $entry );
     972    }
     973
     974    /**
     975     * After Opayo returns, send the customer to the form page with gf_sagepay_form_return so GFFormDisplay::handle_confirmation runs (default GF confirmation / redirect).
     976     */
     977    private function get_opayo_success_return_url( $form, $entry ) {
     978        $base    = $this->get_opayo_cancel_return_url( $form, $entry );
     979        $form_id = (int) rgar( $entry, 'form_id' );
     980        $lead_id = (int) rgar( $entry, 'id' );
     981        if ( $form_id < 1 || $lead_id < 1 ) {
     982            return apply_filters( 'gform_sagepay_form_success_return_url', $base, $form, $entry );
     983        }
     984        $ids_query  = "ids={$form_id}|{$lead_id}";
     985        $ids_query .= '&hash=' . wp_hash( $ids_query );
     986        $url        = add_query_arg( 'gf_sagepay_form_return', base64_encode( $ids_query ), $base );
     987
     988        return apply_filters( 'gform_sagepay_form_success_return_url', $url, $form, $entry );
     989    }
     990
     991    /**
     992     * Transient key for server-side bridge (avoids huge Crypt in GET, which is often truncated → 5080).
     993     */
     994    private function get_opayo_bridge_transient_key( $token ) {
     995        return 'gfspf_opayo_' . $token;
     996    }
     997
     998    private function get_opayo_form_register_url( $mode ) {
     999        if ( $mode !== 'live' ) {
     1000            $url = 'https://sandbox.opayo.eu.elavon.com/gateway/service/vspform-register.vsp';
     1001        } else {
     1002            $url = 'https://live.opayo.eu.elavon.com/gateway/service/vspform-register.vsp';
     1003        }
     1004        return apply_filters( 'gform_sagepay_form_gateway_register_url', $url, $mode );
     1005    }
     1006
     1007    /**
     1008     * Output auto-POST HTML to Opayo vspform-register.
     1009     *
     1010     * @param array  $fields       Assoc: VPSProtocol, TxType, Vendor, Crypt.
     1011     * @param string $register_url Gateway URL.
     1012     */
     1013    private function output_opayo_register_post_document( $fields, $register_url ) {
     1014        if ( ! is_array( $fields ) ) {
     1015            $fields = array();
     1016        }
     1017        $html_inputs = array();
     1018        foreach ( $fields as $key => $value ) {
     1019            if ( is_array( $value ) ) {
     1020                continue;
     1021            }
     1022            $html_inputs[] = '<input type="hidden" name="' . esc_attr( (string) $key ) . '" value="' . esc_attr( (string) $value ) . '" />';
     1023        }
     1024
     1025        echo '<!DOCTYPE html>
     1026<html>
     1027<head>
     1028<meta charset="utf-8" />
     1029<script>
     1030window.onload = function() { document.getElementById("sagepay_payment_form").submit(); };
     1031</script>
     1032</head>
     1033<body>
     1034<form action="' . esc_url( $register_url ) . '" method="post" name="sagepay_payment_form" id="sagepay_payment_form">
     1035' . implode( '', $html_inputs ) . '
     1036<noscript><p><input type="submit" value="' . esc_attr__( 'Continue to payment', 'gravityforms-sagepay-form' ) . '" /></p></noscript>
     1037<p>' . esc_html__( 'Please wait while you are redirected to the payment page.', 'gravityforms-sagepay-form' ) . '</p>
     1038</form>
     1039</body>
     1040</html>';
     1041    }
     1042
     1043    /**
     1044     * Opayo expects amount as d.cc (e.g. 10.00), no thousands separators.
     1045     */
     1046    private function format_opayo_amount( $amount ) {
     1047        return number_format( (float) $amount, 2, '.', '' );
     1048    }
     1049
     1050    /**
     1051     * Resolve Opayo vendor name from add-on settings, legacy options, or bad upgrade shapes.
     1052     */
     1053    private function resolve_plugin_vendor_name( $settings ) {
     1054        if ( ! is_array( $settings ) ) {
     1055            $settings = array();
     1056        }
     1057
     1058        $candidates = array();
     1059        $candidates[] = rgar( $settings, 'vendor_name', '' );
     1060
     1061        $nested = rgar( $settings, 'gf_sagepay_form_configured' );
     1062        if ( is_array( $nested ) ) {
     1063            $candidates[] = rgar( $nested, 'vendor_name', '' );
     1064            $candidates[] = rgar( $nested, 'vendor', '' );
     1065        }
     1066
     1067        $legacy = get_option( 'gf_sagepay_form_configured', null );
     1068        if ( is_array( $legacy ) ) {
     1069            $candidates[] = rgar( $legacy, 'vendor_name', '' );
     1070            $candidates[] = rgar( $legacy, 'vendor', '' );
     1071        }
     1072
     1073        foreach ( $candidates as $v ) {
     1074            if ( ! is_string( $v ) ) {
     1075                continue;
     1076            }
     1077            $v = trim( $v );
     1078            if ( $v !== '' ) {
     1079                return apply_filters( 'gform_sagepay_form_vendor_name', $v, $settings );
     1080            }
     1081        }
     1082
     1083        return apply_filters( 'gform_sagepay_form_vendor_name', '', $settings );
     1084    }
     1085
    8801086    public function encryptAndEncode( $strIn ) {
    8811087
    8821088        $settings   = $this->get_plugin_settings();
    883         $vendorpass = $settings['vendor_password'];
    884         $strIn      = self::pkcs5_pad( $strIn, 16 );
    885 
    886         if ( PHP_VERSION >= 7 ) {
    887             return '@' . bin2hex( openssl_encrypt( $strIn, 'AES-128-CBC', $vendorpass, OPENSSL_RAW_DATA, $vendorpass ) );
    888         } else {
    889             return '@' . bin2hex( mcrypt_encrypt( MCRYPT_RIJNDAEL_128, $vendorpass, $strIn, MCRYPT_MODE_CBC, $vendorpass ) );
    890         }
     1089        $vendorpass = rgar( $settings, 'vendor_password', '' );
     1090        $key        = $this->get_opayo_form_aes_key( $vendorpass );
     1091
     1092        if ( version_compare( PHP_VERSION, '7.0.0', '>=' ) ) {
     1093            $crypt = openssl_encrypt( (string) $strIn, 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $key );
     1094            if ( false === $crypt ) {
     1095                $this->log_error( 'Opayo Form: openssl_encrypt failed. Check the Form encryption password in settings and OpenSSL support.' );
     1096                return '@';
     1097            }
     1098            return '@' . strtoupper( bin2hex( $crypt ) );
     1099        }
     1100
     1101        $strIn_padded = self::pkcs5_pad( (string) $strIn, 16 );
     1102        return '@' . strtoupper( bin2hex( mcrypt_encrypt( MCRYPT_RIJNDAEL_128, $key, $strIn_padded, MCRYPT_MODE_CBC, $key ) ) );
    8911103    }
    8921104
    8931105    public function decodeAndDecrypt( $strIn ) {
    8941106        $settings   = $this->get_plugin_settings();
    895         $vendorpass = $settings['vendor_password'];
    896         $strIn      = substr( $strIn, 1 );
    897         $strIn      = pack( 'H*', $strIn );
    898 
    899         if ( PHP_VERSION >= 7 ) {
    900             return openssl_decrypt( $strIn, 'AES-128-CBC', $vendorpass, OPENSSL_RAW_DATA, $vendorpass );
    901         } else {
    902             return mcrypt_decrypt( MCRYPT_RIJNDAEL_128, $vendorpass, $strIn, MCRYPT_MODE_CBC, $vendorpass );
    903         }
     1107        $vendorpass = rgar( $settings, 'vendor_password', '' );
     1108        $key = $this->get_opayo_form_aes_key( $vendorpass );
     1109        $hex = substr( $strIn, 1 );
     1110        if ( '' === $hex || 0 !== strlen( $hex ) % 2 ) {
     1111            return '';
     1112        }
     1113        $binary = function_exists( 'hex2bin' ) ? hex2bin( $hex ) : pack( 'H*', $hex );
     1114        if ( false === $binary ) {
     1115            return '';
     1116        }
     1117
     1118        if ( version_compare( PHP_VERSION, '7.0.0', '>=' ) ) {
     1119            $plain = openssl_decrypt( $binary, 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $key );
     1120            return ( false !== $plain && null !== $plain ) ? $plain : '';
     1121        }
     1122
     1123        return mcrypt_decrypt( MCRYPT_RIJNDAEL_128, $key, $binary, MCRYPT_MODE_CBC, $key );
    9041124    }
    9051125
     
    9121132    public function decode( $strIn ) {
    9131133        $decodedString = self::decodeAndDecrypt( $strIn );
     1134        if ( ! is_string( $decodedString ) || $decodedString === '' ) {
     1135            return array();
     1136        }
     1137        $sagePayResponse = array();
    9141138        parse_str( $decodedString, $sagePayResponse );
    9151139        return $sagePayResponse;
     
    14001624
    14011625
    1402     // ------- PROCESSING WORLDPAY IPN (Callback) -----------//
     1626    // ------- Opayo Form IPN (Callback) -----------//
    14031627
    14041628    public function callback() {
     
    14081632        }
    14091633
     1634        $bridge_token = isset( $_GET['opayo_br'] ) ? sanitize_text_field( wp_unslash( $_GET['opayo_br'] ) ) : '';
     1635        if ( $bridge_token !== '' && preg_match( '/^[A-Za-z0-9]{20,48}$/', $bridge_token ) ) {
     1636            $settings  = $this->get_plugin_settings();
     1637            $mode      = rgar( $settings, 'mode', 'test' );
     1638            $trans_key = $this->get_opayo_bridge_transient_key( $bridge_token );
     1639            $params    = get_transient( $trans_key );
     1640            delete_transient( $trans_key );
     1641
     1642            if ( false === $params || ! is_array( $params ) || empty( $params['Crypt'] ) ) {
     1643                wp_die(
     1644                    esc_html__( 'This payment session has expired or is invalid. Please submit the form again.', 'gravityforms-sagepay-form' ),
     1645                    esc_html__( 'Payment session expired', 'gravityforms-sagepay-form' ),
     1646                    array( 'response' => 410 )
     1647                );
     1648            }
     1649
     1650            $this->output_opayo_register_post_document( $params, $this->get_opayo_form_register_url( $mode ) );
     1651            exit;
     1652        }
     1653
    14101654        if ( isset( $_GET['VPSProtocol'] ) ) {
    1411             unset( $_GET['page'] );
    1412 
    14131655            $settings = $this->get_plugin_settings();
    1414 
    1415             // Getting Url (Live or Sandbox)
    1416             if ( $settings['mode'] != 'live' ) {
    1417                 $redirect_url = 'https://test.sagepay.com/gateway/service/vspform-register.vsp';
    1418             } else {
    1419                 $redirect_url = 'https://live.sagepay.com/gateway/service/vspform-register.vsp';
    1420             }
    1421 
    1422             $sagepay_arg_array = array();
    1423 
    1424             foreach ( $_GET as $key => $value ) {
    1425                 $sagepay_arg_array[] = '<input type="hidden" name="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" />';
    1426             }
    1427 
    1428                echo '<!DOCTYPE html>
    1429                         <html>
    1430                         <head>
    1431                         <script>
    1432                             window.onload = function(e){
    1433                                  document.getElementById("sagepay_payment_form").submit();
    1434                             }
    1435                         </script>
    1436                         </head>
    1437                         <body>
    1438                             <form action="' . $redirect_url . '" method="post" name="sagepay_payment_form" target="_self"  id="sagepay_payment_form" >
    1439                                 ' . implode( '', $sagepay_arg_array ) . '
    1440                                 <b> Please wait while you are being redirected.</b>
    1441                             </form>
    1442                         </body>
    1443                         </html>';
    1444 
     1656            $mode     = rgar( $settings, 'mode', 'test' );
     1657
     1658            $redirect_url = $this->get_opayo_form_register_url( $mode );
     1659
     1660            $bridge = wp_unslash( $_GET );
     1661            unset( $bridge['page'] );
     1662
     1663            $resolved_vendor = $this->resolve_plugin_vendor_name( $settings );
     1664            if ( ( ! isset( $bridge['Vendor'] ) || '' === (string) $bridge['Vendor'] ) && $resolved_vendor !== '' ) {
     1665                $bridge['Vendor'] = $resolved_vendor;
     1666            }
     1667            if ( $resolved_vendor === '' && ( ! isset( $bridge['Vendor'] ) || '' === (string) $bridge['Vendor'] ) ) {
     1668                wp_die(
     1669                    esc_html__( 'Payment gateway error: Opayo vendor name is not configured.', 'gravityforms-sagepay-form' ),
     1670                    esc_html__( 'Payment unavailable', 'gravityforms-sagepay-form' ),
     1671                    array( 'response' => 503 )
     1672                );
     1673            }
     1674
     1675            $this->output_opayo_register_post_document( $bridge, $redirect_url );
    14451676            exit;
    14461677
     
    14491680        $this->log_debug( 'IPN request received. Starting to process...' );
    14501681
    1451         $this->log_debug( 'IPN Response : '.print_r( $_REQUEST, true ) );
    1452 
    1453         $this->log_debug( print_r( $transaction_response, true ) );
    1454 
    1455         $transaction_response = $this->decode( str_replace( ' ', '+', $_REQUEST['crypt'] ) );
    1456 
    1457         $this->log_debug( print_r( $transaction_response, true ) );
    1458 
    1459         list($entry_id, $hash) = explode( '-', $transaction_response['VendorTxCode'] );
    1460 
    1461         $hash = wp_hash( $entry_id );
     1682        if ( rgempty( 'crypt', $_REQUEST ) ) {
     1683            $this->log_error( 'IPN missing crypt parameter. Aborting.' );
     1684            return false;
     1685        }
     1686
     1687        $crypt_raw            = wp_unslash( $_REQUEST['crypt'] );
     1688        $transaction_response = $this->decode( str_replace( ' ', '+', $crypt_raw ) );
     1689
     1690        if ( empty( $transaction_response ) || ! is_array( $transaction_response ) ) {
     1691            $this->log_error( 'IPN could not decode crypt payload. Aborting.' );
     1692            return false;
     1693        }
     1694
     1695        $this->log_debug( 'IPN decoded response: ' . print_r( $transaction_response, true ) );
     1696
     1697        if ( empty( $transaction_response['VendorTxCode'] ) ) {
     1698            $this->log_error( 'IPN missing VendorTxCode after decode. Aborting.' );
     1699            return false;
     1700        }
     1701
     1702        $vendor_tx = (string) $transaction_response['VendorTxCode'];
     1703        $dash_pos  = strpos( $vendor_tx, '-' );
     1704        $entry_id  = $dash_pos !== false ? substr( $vendor_tx, 0, $dash_pos ) : $vendor_tx;
     1705        $entry_id  = trim( $entry_id );
     1706
     1707        if ( $entry_id === '' || ! ctype_digit( $entry_id ) ) {
     1708            $this->log_error( 'IPN VendorTxCode does not start with a numeric entry ID. Aborting.' );
     1709            return false;
     1710        }
    14621711
    14631712        // ------ Getting entry related to this IPN ----------------------------------------------//
    1464         $entry = $this->get_entry( $entry_id . '|' . $hash );
     1713        $entry = $this->get_entry( $entry_id . '|' . wp_hash( $entry_id ) );
    14651714
    14661715        // Ignore orphan IPN messages (ones without an entry)
    14671716        if ( ! $entry ) {
    1468             $this->log_error( 'Entry could not be found. Entry ID: ' . $entry['id'] . '. Aborting.' );
     1717            $this->log_error( 'Entry could not be found. Entry ID: ' . $entry_id . '. Aborting.' );
    14691718
    14701719            return false;
     
    16121861                $this->complete_payment( $entry, $action );
    16131862
    1614                 $redirect_url = ! empty( $config['meta']['successUrl'] ) ? $config['meta']['successUrl'] : home_url();
    1615                 wp_redirect( $redirect_url );
     1863                $form         = GFAPI::get_form( rgar( $entry, 'form_id' ) );
     1864                $redirect_url = $this->get_opayo_success_return_url( is_array( $form ) ? $form : array(), $entry );
     1865                wp_safe_redirect( $redirect_url );
    16161866                exit;
    16171867
     
    16291879                $action['note']           = sprintf( __( 'Payment is pending. Amount: %1$s. Transaction Id: %2$s. Reason: %3$s', 'gravityforms-sagepay-form' ), $amount_formatted, $action['transaction_id'], $this->get_pending_reason( $pending_reason ) );
    16301880
    1631                 $redirect_url = ! empty( $config['meta']['successUrl'] ) ? $config['meta']['successUrl'] : home_url();
    1632                 wp_redirect( $redirect_url );
     1881                $form         = GFAPI::get_form( rgar( $entry, 'form_id' ) );
     1882                $redirect_url = $this->get_opayo_success_return_url( is_array( $form ) ? $form : array(), $entry );
     1883                wp_safe_redirect( $redirect_url );
    16331884                exit;
    16341885
     
    16591910                }
    16601911
    1661                 $redirect_url = ! empty( $config['meta']['cancelUrl'] ) ? $config['meta']['cancelUrl'] : home_url();
    1662                 wp_redirect( $redirect_url );
     1912                $form         = GFAPI::get_form( rgar( $entry, 'form_id' ) );
     1913                $redirect_url = $this->get_opayo_cancel_return_url( is_array( $form ) ? $form : array(), $entry );
     1914                wp_safe_redirect( $redirect_url );
    16631915                exit;
    16641916
     
    16791931
    16801932        // Getting entry associated with this IPN message (entry id is sent in the 'custom' field)
    1681         list( $entry_id, $hash ) = explode( '|', $custom_field );
     1933        $parts = explode( '|', $custom_field, 2 );
     1934        if ( count( $parts ) < 2 ) {
     1935            $this->log_error( "IPN custom field malformed (expected entry_id|hash): {$custom_field}. Aborting." );
     1936            return false;
     1937        }
     1938        list( $entry_id, $hash ) = $parts;
    16821939        $hash_matches            = wp_hash( $entry_id ) == $hash;
    16831940
     
    22562513
    22572514    public function copy_settings() {
    2258         // copy plugin settings
    22592515        $old_settings = get_option( 'gf_sagepay_form_configured' );
    2260         $new_settings = array( 'gf_sagepay_form_configured' => $old_settings );
    2261         $this->update_plugin_settings( $new_settings );
     2516        if ( ! is_array( $old_settings ) ) {
     2517            return;
     2518        }
     2519        $current = $this->get_plugin_settings();
     2520        if ( ! is_array( $current ) ) {
     2521            $current = array();
     2522        }
     2523        // Merge legacy flat settings into the add-on option (do not nest under gf_sagepay_form_configured).
     2524        $this->update_plugin_settings( array_merge( $old_settings, $current ) );
    22622525    }
    22632526
  • sagepay-form-payment-gateway-for-gravity-forms/trunk/readme.txt

    r2888620 r3488279  
    11=== Opayo Form Payment Gateway for Gravity Forms ===
    22Contributors: patsatech
    3 Tags: ecommerce, payment gateway, wordpress, gravity forms,Opayo server,Opayo go
    4 Requires at least: 3.5
    5 Tested up to: 6.1.1
    6 Stable tag: 1.1.9
     3Tags: ecommerce, payment gateway, gravity forms,Opayo server,Opayo go
     4Requires at least: 4.5
     5Tested up to: 6.9.4
     6Stable tag: 1.2.0
    77License: GPLv2 or later
    88
    9 Opayo Server Gateway for accepting payments on your Gravity Forms Store.
     9Accept card payments in Gravity Forms using Opayo Form (hosted checkout by Elavon)—customers pay on Opayo’s pages, not on your server.
    1010
    1111== Description ==
    1212
    13 The Sage Pay Payment system provides a secure, simple means of authorizing credit and debit card transactions from your website.
     13This add-on connects **Gravity Forms** to **Opayo Form** (formerly Sage Pay Form), Elavon’s hosted payment integration. Shoppers are sent to Opayo to enter card details; card data never touches your WordPress site, which keeps your PCI scope lower than with on-page card fields.
    1414
    15 The Sage Pay system provides a straightforward payment interface for the customer, and takes complete responsibility for the online transaction, including the collection and encrypted storage of credit and debit card details, eliminating the security implications of holding such sensitive information on your own servers.
     15**You need**
    1616
    17 So this plugin helps you to accept payments with Gravity Forms using Opayo Accounts.
    18 Send us your ideas and feedback here: https://www.patsatech.com/contact-us
     17* WordPress 4.5 or higher and a supported PHP version (7.0+ recommended).
     18* **Gravity Forms** with a license that includes payment add-ons (this plugin extends the Gravity Forms payment framework).
     19* An **Opayo** account with **Form** integration enabled, plus your vendor name and Form encryption password from **MyOpayo**.
     20
     21**What it supports**
     22
     23* Product and donation feeds, billing field mapping, and delayed notifications or post creation until payment completes (where you configure it in the feed).
     24* Opayo **VPS Protocol 4.00**, test and live modes, and current Elavon gateway hosts (`sandbox.opayo.eu.elavon.com` / `live.opayo.eu.elavon.com`), with a filter to override URLs if your integration pack specifies different endpoints.
     25* Reliable handoff to Opayo using a short on-site URL plus a transient (avoids oversized query strings that can cause registration errors).
     26* After payment, customers can be returned to the form’s page so Gravity Forms can run the normal **confirmation** (message or redirect), using the entry’s source URL when available.
     27
     28**Support and feedback**
     29
     30Questions or ideas: [contact PatSaTECH](https://www.patsatech.com/contact-us).
    1931
    2032== Installation ==
    2133
    22 1. Download and unzip the latest release zip file.
    23 2. If you use the WordPress plugin uploader to install this plugin skip to step 4.
    24 3. Upload the entire plugin directory to your `/wp-content/plugins/` directory.
    25 4. Activate the plugin through the 'Plugins' menu in WordPress Administration.
     341. **Install Gravity Forms** (if it is not already installed) and ensure your license supports payment add-ons.
     35
     362. **Install this plugin**
     37   * In WordPress: **Plugins → Add New → Upload Plugin**, choose the ZIP, then **Install Now**, **Activate**; or
     38   * Upload the plugin folder to `wp-content/plugins/` via FTP/SFTP, then activate under **Plugins**.
     39
     403. **Configure Opayo (global)** 
     41   Go to **Forms → Settings → Opayo Form** (or the Opayo Form item under Gravity Forms settings). Enter **Vendor name**, **Encryption password**, **Vendor email**, choose **Test** or **Live**, set **Transaction type** (e.g. PAYMENT), and save. Use the credentials from **MyOpayo** for the same environment (test vs live).
     42
     434. **Add a payment feed to a form** 
     44   Open your form → **Settings → Opayo Form** → add a feed. Map billing fields, choose products or donations, and adjust notifications/posts options as needed. There is no separate “Cancel URL” or “Success URL” field: cancel uses the entry **source URL** (the page where the form was submitted), and success returns through Gravity Forms’ normal confirmation flow.
     45
     465. **Test** 
     47   Use Opayo **test/sandbox** mode and a test card until you are satisfied, then switch to **Live** and verify with a small real transaction.
     48
     49For gateway-specific rules, limits, and 3D Secure behaviour, refer to **Opayo / Elavon** documentation and your MyOpayo account.
    2650
    2751== Changelog ==
     
    6084= 1.1.9 =
    6185* Updated to change branding from Sagepaay to Opayo.
     86
     87= 1.2.0 =
     88* Hardened IPN/callback handling (validate crypt, decode before logging, safer VendorTxCode parsing, correct error when entry is missing).
     89* Fixed duplicate-payment guard when redirecting to Opayo (avoid blocking legitimate submissions).
     90* Restored Apply 3D Secure global setting and send Apply3DSecure in the Crypt payload.
     91* Fixed mislabeled transaction type setting; use version_compare for OpenSSL vs mcrypt.
     92* Added filter gform_sagepay_form_gateway_register_url to override test/live registration URLs if Opayo changes endpoints.
     93* Synced plugin version constant with readme; trimmed noisy debug logging on redirect.
     94* Default Form registration URLs now use Elavon Opayo hosts (sandbox.opayo.eu.elavon.com / live.opayo.eu.elavon.com) instead of legacy test.sagepay.com / live.sagepay.com. Same path: /gateway/service/vspform-register.vsp. Use filter gform_sagepay_form_gateway_register_url to point at legacy or alternate URLs if required.
     95* Fixed Opayo Form Crypt encryption for PHP OpenSSL: removed double padding (manual PKCS5 plus OpenSSL PKCS7), which commonly caused error 5080 “Form transaction registration failed”. Encryption now matches Opayo’s documented approach (16-byte key/IV from password, uppercase hex).
     96* Amount sent as formatted decimal (e.g. 10.00). Sanitize Crypt field values (control characters). Safer VendorData mapping; optional ReferrerID via filter gform_sagepay_form_referrer_id.
     97* Resolve Opayo Vendor from add-on settings, legacy gf_sagepay_form_configured option, or wrongly nested upgrade data; filter gform_sagepay_form_vendor_name.
     98* Block checkout if vendor name is still missing (clear error instead of posting blank Vendor to Opayo).
     99* On the bridge page, reinject Vendor from saved settings when the query string omits it; restore hidden fields and auto-submit (removed debug text inputs / disabled submit).
     100* Upgrade copy_settings now merges legacy settings flat instead of nesting them under gf_sagepay_form_configured.
     101* Default payment handoff uses a short URL + server-side transient, then POSTs to Opayo (avoids huge Crypt in the query string, which is often truncated by servers/proxies and causes 5080).
     102* Strip & and = from values inside the encrypted Crypt payload (they break Opayo’s name=value parsing).
     103* Filters: gform_sagepay_form_use_transient_bridge (default true), gform_sagepay_form_bridge_params.
  • sagepay-form-payment-gateway-for-gravity-forms/trunk/sagepay-form.php

    r2888615 r3488279  
    44 * Plugin URI: http://www.patsatech.com/
    55 * Description: Integrates Gravity Forms with Opayo Form Method, enabling end users to purchase goods and services through Gravity Forms.
    6  * Version: 1.1.9
     6 * Version: 1.2.0
    77 * Author: PatSaTECH
    88 * Author URI: http://www.patsatech.com
    99 * Contributors: patsatech
    1010 * Requires at least: 4.5
    11  * Tested up to: 6.1.1
     11 * Tested up to: 6.9.4
    1212 *
    1313 * Text Domain: gravityforms-sagepay-form
     
    1818 */
    1919
    20 define( 'GF_SAGEPAY_FORM_VERSION', '1.1.7' );
     20define( 'GF_SAGEPAY_FORM_VERSION', '1.2.0' );
    2121
    2222add_action( 'gform_loaded', array( 'GF_SagePay_Form_Bootstrap', 'load' ), 5 );
Note: See TracChangeset for help on using the changeset viewer.