diff --git a/commerce_paypal.module b/commerce_paypal.module index 8767833..30efea0 100644 --- a/commerce_paypal.module +++ b/commerce_paypal.module @@ -471,6 +471,7 @@ function commerce_paypal_currencies($method_id) { case 'paypal_ppa': return drupal_map_assoc(array('AUD', 'CAD', 'EUR', 'GBP', 'JPY', 'USD')); case 'paypal_wps': + case 'paypal_apc': return drupal_map_assoc(array('AUD', 'BRL', 'CAD', 'CHF', 'CZK', 'DKK', 'EUR', 'GBP', 'HKD', 'HUF', 'ILS', 'JPY', 'MXN', 'MYR', 'NOK', 'NZD', 'PHP', 'PLN', 'SEK', 'SGD', 'THB', 'TRY', 'TWD', 'USD')); } } diff --git a/modules/adaptive/README.md b/modules/adaptive/README.md new file mode 100644 index 0000000..7ee1d2d --- /dev/null +++ b/modules/adaptive/README.md @@ -0,0 +1,27 @@ +PayPal Adaptive Payments +======================== + +This module implements the [PayPal Adaptive Payments API][api] which allows for +marketplace-style split transactions of up to 9 receivers. Transactions can be +split or parallel and have different options for distributing fees and handling +payments. + +On its own, this module will effectively act like a standard PayPal module and +will not specify or create multiple transactions per order. In order to add +extra receivers to an order is to use the alter hook defined in the API. You +can find more information about that in the `commerce_paypal_adaptive.api.php` +file in this directory. + + +Setting Up +---------- + +You will need to [create Signature API credentials][credentials] in order to +use this module. Enter these API credentials into the payment configuration +form and save it. You will also need to at least one PayPal address that can +receive payments. + + + + [api]: https://developer.paypal.com/docs/classic/products/adaptive-payments/ + [credentials]: https://developer.paypal.com/docs/classic/api/apiCredentials/#creating-an-api-signature diff --git a/modules/adaptive/commerce_paypal_adaptive.api.php b/modules/adaptive/commerce_paypal_adaptive.api.php new file mode 100644 index 0000000..0a2af76 --- /dev/null +++ b/modules/adaptive/commerce_paypal_adaptive.api.php @@ -0,0 +1,62 @@ + 'merchant@example.com', + 'amount' => '5', + ); +} diff --git a/modules/adaptive/commerce_paypal_adaptive.info b/modules/adaptive/commerce_paypal_adaptive.info new file mode 100644 index 0000000..a61b28d --- /dev/null +++ b/modules/adaptive/commerce_paypal_adaptive.info @@ -0,0 +1,9 @@ +name = PayPal Adaptive Payments +description = Implements PayPal Adaptive Payments for use with Commerce Marketplace. +package = Commerce (PayPal) +dependencies[] = commerce +dependencies[] = commerce_ui +dependencies[] = commerce_payment +dependencies[] = commerce_order +dependencies[] = commerce_paypal +core = 7.x diff --git a/modules/adaptive/commerce_paypal_adaptive.install b/modules/adaptive/commerce_paypal_adaptive.install new file mode 100644 index 0000000..23917bf --- /dev/null +++ b/modules/adaptive/commerce_paypal_adaptive.install @@ -0,0 +1,6 @@ + 'commerce_paypal_adaptive_chained', + 'title' => t('PayPal Adaptive (Chained)'), + 'short_title' => t('PayPal'), + 'description' => t('PayPal Adaptive Payments (Chained)'), + 'terminal' => FALSE, + 'offsite' => TRUE, + 'offsite_autoredirect' => TRUE, + + // Because the order form generation code does not have access to a payment + // method info array, we set the bn directly there instead of making use of + // this buttonsource variable. It's here for consistency with other payment + // methods in this package. + 'buttonsource' => 'CommerceGuys_Cart_APC', + ); + + return $payment_methods; +} + +/** + * Returns the default settings for the PayPal WPS payment method. + */ +function commerce_paypal_adaptive_default_settings() { + $default_currency = commerce_default_currency(); + + return array( + 'api_username' => '', + 'api_password' => '', + 'api_signature' => '', + 'currency_code' => in_array($default_currency, array_keys(commerce_paypal_currencies('paypal_wps'))) ? $default_currency : 'USD', + 'allow_supported_currencies' => FALSE, + 'language' => 'US', + 'server' => 'sandbox', + 'action_type' => 'PAY', + 'fees_payer' => 'SECONDARYONLY', + 'ipn_logging' => 'notification', + 'primary_receiver' => '', + 'ipn_create_billing_profile' => FALSE, + 'show_payment_instructions' => FALSE, + ); +} + +/** + * Payment method callback: settings form. + */ +function commerce_paypal_adaptive_chained_settings_form($settings = array()) { + $form = array(); + + // Merge default settings into the stored settings array. + $settings = (array) $settings + commerce_paypal_adaptive_default_settings(); + + $form['api_username'] = array( + '#type' => 'textfield', + '#title' => t('API username'), + '#default_value' => $settings['api_username'], + ); + $form['api_password'] = array( + '#type' => 'textfield', + '#title' => t('API password'), + '#default_value' => $settings['api_password'], + ); + $form['api_signature'] = array( + '#type' => 'textfield', + '#title' => t('Signature'), + '#default_value' => $settings['api_signature'], + ); + $form['currency_code'] = array( + '#type' => 'select', + '#title' => t('Default currency'), + '#description' => t('Transactions in other currencies will be converted to this currency, so multi-currency sites must be configured to use appropriate conversion rates.'), + '#options' => commerce_paypal_currencies('paypal_apc'), + '#default_value' => $settings['currency_code'], + ); + $form['allow_supported_currencies'] = array( + '#type' => 'checkbox', + '#title' => t('Allow transactions to use any currency in the options list above.'), + '#description' => t('Transactions in unsupported currencies will still be converted into the default currency.'), + '#default_value' => $settings['allow_supported_currencies'], + ); + $form['server'] = array( + '#type' => 'radios', + '#title' => t('PayPal server'), + '#options' => array( + 'sandbox' => ('Sandbox - use for testing, requires a PayPal Sandbox account'), + 'live' => ('Live - use for processing real transactions'), + ), + '#default_value' => $settings['server'], + ); + $form['action_type'] = array( + '#type' => 'radios', + '#title' => t('Payment action'), + '#options' => array( + 'PAY' => t('Pay - capture and pay all receivers of a multi-receiver payment'), + 'PAY_PRIMARY' => t('Pay Primary - pays the primary receiver of a chained payment'), + ), + '#default_value' => $settings['action_type'], + ); + $form['fees_payer'] = array( + '#type' => 'radios', + '#title' => t('Who pays the fees?'), + '#options' => array( + 'SENDER' => t('Sender pays the fees.'), + 'PRIMARYRECEIVER' => t('Primary receiver pays all fees.'), + 'EACHRECEIVER' => t('Each receiver pays their own fee.'), + 'SECONDARYONLY' => t('Secondary receivers pay all fees.'), + ), + '#default_value' => $settings['fees_payer'], + ); + $form['ipn_logging'] = array( + '#type' => 'radios', + '#title' => t('IPN logging'), + '#options' => array( + 'notification' => t('Log notifications during IPN validation and processing.'), + 'full_ipn' => t('Log notifications with the full IPN during validation and processing (used for debugging).'), + ), + '#default_value' => $settings['ipn_logging'], + ); + $form['primary_receiver'] = array( + '#type' => 'textfield', + '#title' => t('PayPal primary receiver e-mail addresses'), + '#description' => t('Enter the primary e-mail address for your PayPal account if different from the one entered above or a comma separated list of all valid e-mail addresses on the account.') . '
' . t('IPNs that originate from payments made to a PayPal account whose e-mail address is not in this list will not be processed.'), + '#default_value' => $settings['primary_receiver'], + ); + $form['ipn_create_billing_profile'] = array( + '#type' => 'checkbox', + '#title' => t('Create a billing profile based on name and country data in the IPN for any order that does not have one yet.'), + '#description' => t('This is most useful for sites that do not collect billing information locally but still want to have customer names on orders.'), + '#default_value' => $settings['ipn_create_billing_profile'], + ); + $form['show_payment_instructions'] = array( + '#type' => 'checkbox', + '#title' => t('Show a message on the checkout form when PayPal Adaptive Payments is selected telling the customer to "Continue with checkout to complete payment via PayPal."'), + '#default_value' => $settings['show_payment_instructions'], + ); + + return $form; +} + +/** + * Payment method callback. + * + * Adds a message to the submission form if enabled in the payment method + * settings. + */ +function commerce_paypal_adaptive_chained_submit_form($payment_method, $pane_values, $checkout_pane, $order) { + $form = array(); + + if (!empty($payment_method['settings']['show_payment_instructions'])) { + $form['paypal_apc_information'] = array( + '#markup' => '' . t('(Continue with checkout to complete payment via PayPal.)') . '', + ); + } + + return $form; +} + +/** + * Implements hook_form_FORM_ID_alter(). + */ +function commerce_paypal_adaptive_form_commerce_checkout_form_alter(&$form, &$form_state) { + // If this checkout form contains the payment method radios... + if (!empty($form['commerce_payment']['payment_method']['#options'])) { + // Loop over its options array looking for a PayPal WPS option. + foreach ($form['commerce_payment']['payment_method']['#options'] as $key => &$value) { + list($method_id, $rule_name) = explode('|', $key); + + // If we find PayPal WPS... + if ($method_id == 'paypal_apc') { + // Prepare the replacement radio button text with icons. + $icons = commerce_paypal_icons(); + $value = t('!logo PayPal - pay securely without sharing your financial information', array('!logo' => $icons['paypal'])); + $value .= '
' . t('Includes:') . '' . implode(' ', $icons) . '
'; + + // Add the CSS. + $form['commerce_payment']['payment_method']['#attached']['css'][] = drupal_get_path('module', 'commerce_paypal_adaptive') . '/theme/commerce_paypal_adaptive.theme.css'; + + break; + } + } + } +} + +/** + * Payment method callback: redirect form. + */ +function commerce_paypal_adaptive_chained_redirect_form($form, &$form_state, $order, $payment_method) { + // Return an error if the enabling action's settings haven't been configured. + if (empty($payment_method['settings']['primary_receiver'])) { + drupal_set_message(t('PayPal Adaptive is not configured for use. No PayPal e-mail address has been specified.'), 'error'); + return array(); + } + + $settings = array( + // Return to the previous page when payment is canceled. + 'cancel_return' => url('checkout/' . $order->order_id . '/payment/back/' . $order->data['payment_redirect_key'], array('absolute' => TRUE)), + // Return to the payment redirect page for processing successful payments. + 'return' => url('checkout/' . $order->order_id . '/payment/return/' . $order->data['payment_redirect_key'], array('absolute' => TRUE)), + // Specify the current payment method instance ID in the notify_url. + 'payment_method' => $payment_method['instance_id'], + ); + + return commerce_paypal_adaptive_chained_order_form($form, $form_state, $order, $payment_method['settings'] + $settings); +} + +/** + * Payment method callback: redirect form return validation. + */ +function commerce_paypal_adaptive_chained_redirect_form_validate($order, $payment_method) { + if (!empty($payment_method['settings']['ipn_logging']) && + $payment_method['settings']['ipn_logging'] == 'full_ipn') { + watchdog('commerce_paypal_wps', 'Customer returned from PayPal with the following POST data: !ipn_data', array('!ipn_data' => '
' . check_plain(print_r($_POST, TRUE)) . '
'), WATCHDOG_NOTICE); + } + + // This may be an unnecessary step, but if for some reason the user does end + // up returning at the success URL with a Failed payment, go back. + if (!empty($_POST['payment_status']) && $_POST['payment_status'] == 'Failed') { + return FALSE; + } +} + +/** + * Payment method callback: redirect form return submission. + */ +function commerce_paypal_adaptive_chained_redirect_form_submit($order, $payment_method) { + // Nothing here yet. +} + +/** + * Implements hook_commerce_paypal_ipn_process(). + */ +function commerce_paypal_adaptive_commerce_paypal_ipn_process($order, $payment_method, $ipn) { + // @todo Implement. +} + +/** + * Builds a Website Payments Standard form from an order object. + * + * @param object $order + * The fully loaded order being paid for. + * @param array $settings + * An array of settings used to build out the form, including: + * - server: which server to use, either sandbox or live + * - business: the PayPal e-mail address the payment submits to + * - cancel_return: the URL PayPal should send the user to on cancellation + * - return: the URL PayPal should send the user to on successful payment + * - currency_code: the PayPal currency code to use for this payment if the + * total for the order is in a non-PayPal supported currency + * - language: the PayPal language code to use on the payment form + * - action_type: the PayPal payment action to use: sale, authorization, + * or order + * - payment_method: optionally a payment method instance ID to include in the + * IPN notify_url. + * + * @return array + * A renderable form array. + */ +function commerce_paypal_adaptive_chained_order_form($form, &$form_state, $order, $settings) { + $wrapper = entity_metadata_wrapper('commerce_order', $order); + + // Determine the currency code to use to actually process the transaction, + // which will either be the default currency code or the currency code of the + // order if it's supported by PayPal if that option is enabled. + $currency_code = $settings['currency_code']; + $order_currency_code = $wrapper->commerce_order_total->currency_code->value(); + + if (!empty($settings['allow_supported_currencies']) && in_array($order_currency_code, array_keys(commerce_paypal_currencies('paypal_wps')))) { + $currency_code = $order_currency_code; + } + + $amount = $wrapper->commerce_order_total->amount->value(); + + // Ensure a default value for the payment_method setting. + $settings += array('payment_method' => ''); + + // Build the data array that will be translated into hidden form values. + $data = array( + // Specify the checkout experience to present to the user. + 'actionType' => $settings['action_type'], + // The path PayPal should send the IPN to. + 'ipnNotificationUrl' => commerce_paypal_ipn_url($settings['payment_method']), + // Return to the review page when payment is canceled. + 'cancelUrl' => $settings['cancel_return'], + // Return to the payment redirect page for processing successful payments. + 'returnUrl' => $settings['return'], + // Set the currency and language codes. + 'currencyCode' => $currency_code, + + // Primary Receiver information. + 'receiverList' => array( + 'receiver' => array( + array( + 'email' => $settings['primary_receiver'], + 'amount' => commerce_paypal_price_amount(commerce_currency_convert($amount, $order_currency_code, $currency_code), $currency_code), + 'primary' => 'true', + ), + ), + ), + + // Use the timestamp to generate a unique invoice number. + 'invoice' => commerce_paypal_ipn_invoice($order), + // Required. + 'requestEnvelope' => array( + 'errorLanguage' => 'en_US', + ), + // Shown on the checkout page. + 'memo' => t('Order #@order', array('@order' => $order->order_id)), + // Who is paying the fees? + 'feesPayer' => $settings['fees_payer'], + ); + + // Allow modules to alter parameters of the API request. + drupal_alter('commerce_paypal_apc_order_form_data', $data, $order, $settings); + + $response = commerce_paypal_adaptive_payrequest($data, $settings); + + if ($response !== FALSE) { + if ($response['responseEnvelope']['ack'] == 'Success') { + $form['#action'] = commerce_paypal_adaptive_redirect_url($response['payKey'], $settings); + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Proceed to PayPal'), + ); + } + else { + watchdog('commerce_paypal_adaptive', 'Error #!error_id creating transaction: !message
!error_object', array( + '!error_id' => $response['error'][0]['errorId'], + '!message' => $response['error'][0]['message'], + '!error_object' => '
' . print_r($response['error'][0], TRUE) . '
', + ), WATCHDOG_ERROR); + drupal_set_message(t('Unable to initialize transaction with PayPal.'), 'error'); + drupal_goto($settings['cancel_return']); + } + } + else { + drupal_set_message(t('Unable to initialize transaction with PayPal.'), 'error'); + drupal_goto($settings['cancel_return']); + } + + return $form; +} + +/** + * Returns the URL to the specified PayPal Adaptive server. + * + * @param string $server + * Either sandbox or live indicating which server to get the URL for. + * + * @return string + * The URL to use to submit requests to the PayPal Adaptive server. + */ +function commerce_paypal_adaptive_server_url($server) { + switch ($server) { + case 'sandbox': + return 'https://www.sandbox.paypal.com/cgi-bin/webscr'; + + case 'live': + return 'https://www.paypal.com/cgi-bin/webscr'; + } +} + +/** + * Perform a PayPal Adaptive Payments PayRequest Request. + * + * @param array $params + * The parameters for the request. See the PayPal API page (linked below) for + * more information. This structure will be converted to JSON and sent to the + * PayPal API. + * @param array $settings + * Payment method settings array. + * + * @return mixed + * A response object or FALSE if the request could not be completed. + * + * @see https://developer.paypal.com/docs/classic/api/adaptive-payments/Pay_API_Operation/ + */ +function commerce_paypal_adaptive_payrequest($params, $settings) { + if ($settings['ipn_logging'] == 'full_ipn') { + watchdog('commerce_paypal_adaptive', 'PayPal Adaptive PayRequest Request: !ipn_data', array('!ipn_data' => '
' . check_plain(print_r($params, TRUE)) . '
')); + } + + $response = commerce_paypal_adaptive_post($params, 'Pay', $settings); + + if ($settings['ipn_logging'] == 'full_ipn') { + watchdog('commerce_paypal_adaptive', 'PayPal Adaptive PayRequest Response: !ipn_data', array('!ipn_data' => '
' . check_plain(print_r($response, TRUE)) . '
')); + } + + return $response; +} + +/** + * Perform an API POST operation. + * + * @param array $post_data + * The data to post to the API. + * @param string $type + * The endpoint to post to. Usually 'Pay' or 'PaymentDetails'. + * @param array $settings + * Payment method settings array. + * + * @return mixed + * The decoded API response. + */ +function commerce_paypal_adaptive_post($post_data, $type, $settings) { + if ($type != 'Pay' && $type != 'PaymentDetails') { + drupal_set_message(t('Invalid PayPal Adaptive Payment request: @type.', array('@type' => $type)), 'error'); + watchdog('commerce_paypal_adaptive', 'Invalid PayPal Adaptive Payment request: @type.', array('@type' => $type), WATCHDOG_ERROR); + } + + if ($settings['server'] == 'sandbox') { + $url = 'https://svcs.sandbox.paypal.com/AdaptivePayments/'; + $application_id = 'APP-80W284485P519543T'; + } + else { + $url = 'https://svcs.paypal.com/AdaptivePayments/'; + $application_id = $settings['application_id']; + } + + $curl = curl_init($url . $type); + + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'POST'); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE); + curl_setopt($curl, CURLOPT_POSTFIELDS, drupal_json_encode($post_data)); + curl_setopt($curl, CURLOPT_HTTPHEADER, array( + 'X-PAYPAL-SECURITY-USERID: ' . $settings['api_username'], + 'X-PAYPAL-SECURITY-PASSWORD: ' . $settings['api_password'], + 'X-PAYPAL-SECURITY-SIGNATURE: ' . $settings['api_signature'], + 'X-PAYPAL-REQUEST-DATA-FORMAT: JSON', + 'X-PAYPAL-RESPONSE-DATA-FORMAT: JSON', + 'X-PAYPAL-APPLICATION-ID: ' . $application_id, + )); + + $result = curl_exec($curl); + + curl_close($curl); + + return drupal_json_decode($result); +} + +/** + * Generate the redirect URL for an Adaptive Payments payKey. + * + * @param string $pay_key + * The payKey returned by a successful PayRequest. + * @param array $settings + * Payment method settings array. + * + * @return string + * The URL that the customer will be redirected to in order to complete their + * transaction. + */ +function commerce_paypal_adaptive_redirect_url($pay_key, $settings) { + $server_url = commerce_paypal_adaptive_server_url($settings['server']); + $params = array( + 'cmd' => '_ap-payment', + 'paykey' => $pay_key, + ); + return $server_url . '?' . http_build_query($params); +}