diff options
| -rw-r--r-- | OneClick.class.php | 1692 | ||||
| -rw-r--r-- | OneClick.php | 52 |
2 files changed, 1744 insertions, 0 deletions
diff --git a/OneClick.class.php b/OneClick.class.php new file mode 100644 index 0000000..55124f8 --- /dev/null +++ b/OneClick.class.php @@ -0,0 +1,1692 @@ +<?php + +/** + * This is a class that implements a "one-click" donation method. It currently + * works with PayPal and Google Checkout. While this class can be used in any + * manner one sees fit, it was designed to work with a small wrapper/router + * script that accepts incoming requests for donations or IPN/notifications + * from the payment processor and calls the appropriate member methods. + */ + +class OneClick { + + /** + * CONSTANTS + */ + + // Turn IPN debuggin on/off + const OC_DEBUG = TRUE; + + + /** + * VARIABLES + */ + + // This will host the POSTed or GET data. + public $_request; + + // Valid groups that users can join. This is just a safeguard to prevent + // users from joining arbitrary groups + public $_valid_groups = array( + 'CC Newsletter', + 'CC Events' + ); + + // Google Checkout processor name in CiviCRM db. + public $_gc_pp_name = 'Google Checkout API Access'; + + // IPN callback URL for PayPal + public $_paypal_notify_url = 'https://creativecommons.net/sites/default/modules/civicrm/bin/OneClick.php?oc_action=paypalipn'; + + // Base URL for PayPal request (minus the query string) + public $_paypal_base_url = 'https://www.paypal.com/cgi-bin/webscr'; + + // Email address of site's PayPal account + public $_paypal_business = 'paypal@creativecommons.org'; + + // Base URL for Google Checkout request + public $_gc_base_url = 'https://checkout.google.com/api/checkout/v2/merchantCheckout/Donations'; + + // Minimum contribution amount, anything lower will be kicked back. + public $_min_amount = '5'; + + // An array of URLs that should be notified when an incoming + // contribution (via IPN) is successfully processed. + public $_notify_urls = array(); + + // Default name of item as contributor sees it and as recorded in CiviCRM. + public $_item_name = 'Online Contribution: Support Creative Commons'; + + // Where contributor goes if they cancel contribution at payment processor + public $_cancel_return = 'https://creativecommons.net/donate'; + + // Where user goes when they click "Return to Merchant Site" + public $_return = 'https://creativecommons.net/thanks'; + + + // Class constructor + public function __construct($req) { + + $this->_request = $req; + + if ( ! empty($this->_request['source']) ) { + $this->_item_name = $this->_request['source']; + } + + if ( ! empty($this->_request['cancel_return']) ) { + $this->_cancel_return = $this->_request['cancel_return']; + } + + if ( ! empty($this->_request['return']) ) { + $this->_return = $this->_request['return']; + } + + if ( self::OC_DEBUG ) { + include_once 'CRM/Core/Error.php'; + } + + } + + + /** + * This function accepts various arguments, processes them appropriately + * and then ships the user off to the payment processor. + */ + function oc_donate() { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_donate()"); + } + + // If nojavascript is a query param then we'll need to manually assign + // the amount variable and also handle recurring donations. + if ( $this->_request['nojavascript'] == "true" ) { + if ( $this->_request['amount'] == "choose" ) { + $this->_request['amount'] = $this->_request['choose_amount']; + } + if ( $this->_request['split'] ) { + $this->_request['recur'] = $this->_request['split']; + $this->_request['amount'] = round($this->_request['amount']/12, 2); + } + } + + // We don't do anything if the amount is less than the minimum amount. + if ( floor($this->_request['amount']) < $this->_min_amount ) { + $err_msg = "The minimum donation is \${$this->_min_amount}."; + $err_msg = urlencode($err_msg); + header("Location: $this->_cancel_return?{$_SERVER['QUERY_STRING']}&error=$err_msg"); + exit; + } + + if ( $this->_request['pp'] == "paypal") { + $paypal_query = $this->oc_make_paypal_url(); + $paypal_url = "$this->_paypal_base_url?$paypal_query"; + header("Location: $paypal_url"); + exit; + } elseif ( $this->_request['pp'] == "gc" ) { + // Fetch the credential for GC, else fail + $payment_processor = $this->oc_get_paymentprocessor($this->_gc_pp_name, 'Google_Checkout'); + if ( ! $payment_processor ) { + $err_msg = "Failed to determine Google Checkout credentials."; + $err_msg = urlencode($err_msg); + header("Location: $this->_cancel_return?{$_SERVER['QUERY_STRING']}&error=$err_msg"); + exit; + } + // Retrieve the XML for this request + $gc_xml = $this->oc_make_gc_xml(); + // Actually make the request + $gc_url = $this->oc_gc_request($gc_xml, $payment_processor); + header("Location: $gc_url"); + exit; + } else { + $err_msg = "Unknown payment processor."; + $err_msg = urlencode($err_msg); + header("Location: $this->_cancel_return?{$_SERVER['QUERY_STRING']}&error=$err_msg"); + exit; + } + + } + + + /** + * This is a custom IPN/notification handler for the OneClick system. + */ + function oc_ipn($pp) { + + // Get request headers and raw POST data + $headers = apache_request_headers(); + $raw_post_data = file_get_contents("php://input"); + + if ( self::OC_DEBUG ) { + $datetime = date('c'); + $debug_output = "OneClick: Entering oc_ipn()\n"; + $debug_output = "IPN START $datetime\nHEADERS:\n"; + $debug_output .= print_r($headers, TRUE); + $debug_output .= "POST DATA:\n$raw_post_data\n"; + CRM_Core_Error::debug_log_message($debug_output); + } + + if ( $pp == 'google' ) { + + require_once 'Google/library/googleresponse.php'; + require_once 'Google/library/xml-processing/gc_xmlparser.php'; + + $payment_processor = $this->oc_get_paymentprocessor($this->_gc_pp_name, 'Google_Checkout'); + + $server_type = preg_match('/sandbox/', $this->_gc_base_url) ? 'sandbox' : ''; + $response = new GoogleResponse($payment_processor['user_name'], $payment_processor['password'], $raw_post_data, $server_type); + + // Do the notification's credentials match those in our DB? + if ( ! $status = $response->HttpAuthentication($headers) ) { + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Google Checkout IPN unauthorized."); + } + header("HTTP/1.1 401 Unauthorized"); + exit; + } + + // Retrieve the root element name and data from the notification XML + $xmlParser = new gc_XmlParser($raw_post_data); + // The root element of the POSTed XML + $root = $xmlParser->GetRoot(); + $data = $xmlParser->GetData(); + + if ( self::OC_DEBUG ) { + $debug_output = "PAYMENT PROCESSOR: Google Checkout\n"; + $debug_output .= "ROOT: $root\n"; + $debug_output .= "DATA:\n" . print_r($data, true); + CRM_Core_Error::debug_log_message($debug_output); + } + + // Check to see if this contribution already exists in the db, and + // if it does then this is just another notification for the same + // order, so we need to update the existing contribution rather + // than create a new one. + require_once 'CRM/Contribute/BAO/Contribution.php'; + $find_params = array('trxn_id' => $data[$root]['google-order-number']['VALUE']); + $existing_contr = CRM_Contribute_BAO_Contribution::retrieve( $find_params, + CRM_Core_DAO::$_nullArray, + CRM_Core_DAO::$_nullArray); + + switch ( $root ) { + case 'new-order-notification': + $params = $this->oc_map_ipn_fields('google', $data[$root], $existing_contr); + $contribution = $this->oc_record_contribution($params); + break; + case 'order-state-change-notification': + switch ( $data[$root]['new-financial-order-state']['VALUE'] ) { + case 'PAYMENT_DECLINED': + $status = 4; // Failed + break; + case 'CANCELLED': + case 'CANCELLED_BY_GOOGLE': + $status = 3; // Cancelled + break; + default: + $status = 0; + } + // The payment was cancelled or declined. + if ( $status ) { + $params = array( + 'transaction' => array( + 'id' => $existing_contr->id, + 'contact_id' => $existing_contr->contact_id, + 'contribution_status_id' => $status + ) + ); + $contribution = $this->oc_record_contribution($params); + $notify_callbacks = TRUE; + } else { + // Trigger an ACK to Google + $contribution = TRUE; + } + break; + case 'risk-information-notification': + // We don't need anything from this notification, so trigger an ACK + // to Google. + $contribution = TRUE; + break; + case 'cancelled-subscription-notification': + $params = array( + 'transaction' => array( + 'id' => $existing_contr->id, + 'contact_id' => $existing_contr->contact_id, + 'contribution_recur_id' => $existing_contr->contribution_recur_id, + 'is_recur' => '1' + ), + 'recur' => array( + 'contribution_status_id' => '3', + 'cancel_date' => $this->oc_iso8601_to_mysql($data[$root]['timestamp']['VALUE']), + ) + ); + $contribution = $this->oc_record_contribution($params); + $notify_callbacks = TRUE; + break; + case 'charge-amount-notification': + $params = array( + 'transaction' => array( + 'id' => $existing_contr->id, + 'contact_id' => $existing_contr->contact_id, + 'contribution_status_id' => 1 + ) + ); + $contribution = $this->oc_record_contribution($params); + if ( $contribution ) { + $notify_callbacks = TRUE; + } + break; + case 'chargeback-amount-notification': + // CiviCRM has no handling for the concept of a chargeback, + // so we just mark the contribution as cancelled. + case 'refund-amount-notification': + // CiviCRM has no "refunded" contribution status, so for + // these we just use 3, which is "cancelled". + $params = array( + 'transaction' => array( + 'id' => $existing_contr->id, + 'contact_id' => $existing_contr->contact_id, + 'contribution_status_id' => 3 + ) + ); + $contribution = $this->oc_record_contribution($params); + if ( $contribution ) { + $notify_callbacks = TRUE; + } + break; + } + + // If we couldn't process the contribution for some reason then exit here + // and don't ACK Google so that Google will keep trying to notify us. + if ( $contribution ) { + $ack = $response->SendAck($data[$root]['serial-number']); + if ( self::OC_DEBUG ) { + $debug_output = "OneClick:\nFORMATTED DATA: \n" . print_r($params, TRUE); + $debug_output .= "CONTRIBUTION: \n" . print_r($contribution, TRUE); + $debug_output .= "GOOGLE ACK: $ack\n"; + CRM_Core_Error::debug_log_message($debug_output); + } + } else { + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Google Checkout - failed to add contribution."); + } + header("HTTP/1.1 503 Service Unavailable"); + exit; + } + + } elseif ( $pp == 'paypal' ) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: PAYMENT PROCESSOR - PayPal"); + } + + // Verify this IPN with PayPal + $verification = 'cmd=_notify-validate&' . $raw_post_data; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_POST, TRUE); + curl_setopt($ch, CURLOPT_POSTFIELDS, $verification); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE); + curl_setopt($ch, CURLOPT_URL, $this->_paypal_base_url); + $response = curl_exec($ch); + $response_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: PayPal IPN Verification: $response"); + } + + // If verification checks out, try to process the IPN + if ( $response == 'VERIFIED' && $response_code == '200' ) { + //if ( $response != 'VERIFIED' && $response_code == '200' ) { + + // If this is a subscr IPN, then go ahead and look up any + // existing contributions that may be a part of this subscr + // because we'll need this data to determine a few things like + // whether to record a new contribution or to update an + // existing one, or whether to record a premium for this + // contribution. We don't need to worry about this for regular + // web_accept payments i.e. non-recurring. + if ( preg_match('/subscr_/', $this->_request['txn_type']) ) { + $subscr_query = " + SELECT c.id, c.contact_id, c.trxn_id, c.invoice_id, c.contribution_recur_id + FROM civicrm_contribution c LEFT JOIN civicrm_contribution_recur r + ON c.contribution_recur_id = r.id + WHERE r.processor_id = '{$this->_request['subscr_id']}' + ORDER BY c.id DESC + "; + $subscr_dao = CRM_Core_DAO::executeQuery( $subscr_query ); + $subscr_dao->fetch(); + } + + switch ( $this->_request['txn_type'] ) { + case 'subscr_signup': + case 'subscr_payment': + case 'web_accept': + $existing_contr = isset($subscr_dao) ? $subscr_dao : NULL; + $params = $this->oc_map_ipn_fields('paypal', $this->_request, $existing_contr); + $contribution = $this->oc_record_contribution($params); + if ( $contribution && $contribution->contribution_status_id == '1' ) { + // subscr_signup can create a contribution record, but + // since it comes in at the same time as the subscr_payment + // and isn't really a *true* payment notice, then only send + // notifications for subscr_payment and web_accept. + if ( ! $this->_request['txn_type'] == 'subscr_signup' ) { + $notify_callbacks = TRUE; + } + } + if ( self::OC_DEBUG ) { + $debug_output = "OneClick: FORMATTED DATA: \n" . print_r($params, TRUE); + $debug_output .= "CONTRIBUTION: \n" . print_r($contribution, TRUE); + CRM_Core_Error::debug_log_message($debug_output); + } + break; + case 'subscr_eot': + $recur_update = array( + 'contribution_status_id' => 1, + 'end_date' => date('YmdHis'), + ); + break; + case 'subscr_cancel': + $recur_update = array( + 'contribution_status_id' => 3, + 'cancel_date' => date('YmdHis'), + ); + break; + case 'subscr_failed': + // This IPN type comes in when a subscr payments fails + // for any given reason. PayPal will try to make the + // payment two more times, and after the 3rd failure + // will give up and send a subscr_eot. Even though + // CiviCRM doesn't expose it, just set that status to + // failed in the recur table. + $recur_update = array( + 'contribution_status_id' => 4, + 'modified_date' => date('YmdHis'), + ); + } + + if ( $recur_update ) { + $recur_update['id'] = $subscr_dao->contribution_recur_id; + // Even though $subscr_dao could represent more than one + // subscr payment we don't really care because + // $recur_update will only exist for IPN that have to do + // with the subscription, and not any one payment, so we + // just set $transaction['id'] to that of the first record, + // as nothing will be updated anyway, and if we don't then + // oc_record_contribution will try to create a new + // contribution record which we don't want. + $params = array( + 'transaction' => array( + 'id' => $subscr_dao->id, + 'contact_id' => $subscr_dao->contact_id, + 'contribution_recur_id' => $subscr_dao->contribution_recur_id, + 'is_recur' => '1' + ), + 'recur' => $recur_update + ); + $contribution = $this->oc_record_contribution($params); + } + + } else { + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: PayPal IPN unverified."); + } + exit; + } + + } else { + // We don't know what payment processor this is. + return FALSE; + } + + // Notify any URLs configured to receive notifications. A notification will go + // out when a contribution is successfully recorded, and one will also go out + // if the the payment processor later denies the charge or if the user cancels + // the order. + if ( $notify_callbacks ) { + $this->oc_notify_urls($contribution); + } + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: IPN END"); + } + + } + + + /** + * Sends a contributor a custom receipt. + */ + function oc_send_receipt ( $contribution, $receipt_id ) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_send_receipt()"); + } + + require_once 'CRM/Core/BAO/MessageTemplates.php'; + + $msg_params = array('id' => $receipt_id); + $template = CRM_Core_BAO_MessageTemplates::retrieve($msg_params, CRM_Core_DAO::$_nullArray); + + $query = "SELECT label FROM civicrm_option_value WHERE description LIKE '%OneClick Email Receipts%'"; + $mail_from = CRM_Core_DAO::singleValueQuery( $query, CRM_Core_DAO::$_nullArray ); + if ( ! $mail_from ) { + return FALSE; + } + + $email_params = array(); + $email_params['from'] = $mail_from; + $email_params['toName'] = "$contribution->first_name $contribution->last_name"; + $email_params['bcc'] = $mail_from; + $email_params['replyTo'] = $mail_from; + $email_params['subject'] = $template->msg_subject; + $email_params['text'] = $template->msg_text; + + // get the billing location type + $location_types =& CRM_Core_PseudoConstant::locationType( ); + $billing_location_type_id = array_search( 'Billing', $location_types ); + + require_once 'CRM/Contact/BAO/Contact/Location.php'; + list( $name, $email_params['toEmail'] ) = CRM_Contact_BAO_Contact_Location::getEmailDetails( $contribution->contact_id, FALSE, $billing_location_type_id ); + + // Make a pretty date + $unix_timestamp = strtotime($contribution->receive_date); + $contribution_date = date('l, F j, Y g:iA', $unix_timestamp); + + // Poor man's templating with preg_replace + $msg_tokens = array( + '%{amount}' => $contribution->total_amount, + '%{date}' => $contribution_date, + '%{trxn_id}' => $contribution->trxn_id, + '%{first_name}' => $contribution->first_name, + '%{last_name}' => $contribution->last_name + ); + foreach ( $msg_tokens as $msg_token => $token_value ) { + $email_params['text'] = preg_replace("/$msg_token/", $token_value, $email_params['text']); + } + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Sending receipt:\n{$email_params['text']}"); + } + + require_once 'CRM/Utils/Mail.php'; + CRM_Utils_Mail::send( $email_params ); + + // Now update receipt_date in the contribution + $rcpt_params = array( + 'receipt_date' => date('YmdHis') + ); + $rcpt_ids = array('contribution' => $contribution->id); + require_once 'CRM/Contribute/BAO/Contribution.php'; + CRM_Contribute_BAO_Contribution::create( $rcpt_params, $rcpt_ids ); + + } + + + /** + * Adds a premium to a contribution. + */ + function oc_set_premium( $contribution, $custom_data ) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_set_premium()"); + } + + // If the user wanted gifts then we need to create a new record + // in the civicrm_contribution_product table. Code from here: + // CRM/Contribute/Form/Contribution/Confirm.php:583-603 + $gift_params = array( + 'product_id' => $custom_data['premium'], + 'contribution_id' => $contribution->id, + 'product_option' => $custom_data['shirt_size'], + 'quantity' => 1 + ); + + //Fixed For CRM-3901 + require_once 'CRM/Contribute/DAO/ContributionProduct.php'; + $dao_contr_prod = & new CRM_Contribute_DAO_ContributionProduct(); + $dao_contr_prod->contribution_id = $contribution->id; + if ( $dao_contr_prod->find(true) ) { + $gift_params['id'] = $dao_contr_prod->id; + } + + require_once 'CRM/Contribute/BAO/Contribution.php'; + CRM_Contribute_BAO_Contribution::addPremium( $gift_params ); + + } + + + /** + * Creates a soft contribution if the contribution originated on a PCP. + */ + function oc_add_soft_contribution ( $contribution, $custom_data ) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_add_soft_contribution()"); + } + + $cs_params = array(); + $cs_params['contribution_id'] = $contribution->id; + $cs_params['contact_id'] = $contribution->contact_id; + $cs_params['amount'] = $contribution->total_amount; + $cs_params['pcp_id'] = $custom_data['pcpid']; + // Add user to Honor Roll unless they chose to opt-out. + if ( ! isset($custom_data['sloptout']) ) { + $cs_params['pcp_display_in_roll'] = '1'; + $cs_params['pcp_roll_nickname'] = "$contribution->first_name $contribution->last_name"; + } + + require_once 'CRM/Contribute/BAO/Contribution.php'; + CRM_Contribute_BAO_Contribution::addSoftContribution($cs_params); + + } + + + /** + * Adds user to any specified groups. + */ + function oc_set_groups ( $contribution, $custom_data ) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_set_groups()"); + } + + require_once 'CRM/Contact/BAO/Group.php'; + require_once 'CRM/Contact/BAO/GroupContact.php'; + foreach ( $custom_data['groups'] as $group ) { + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: User to be added to group '$group'"); + } + // CiviCRM replaces any spaces in group names with an underscore + $group_name = preg_replace('/ /', '_', $group); + $ml_params = array( + "name" => $group_name, + "is_active" => "1" + ); + $group_values = array(); + CRM_Contact_BAO_Group::retrieve( $ml_params, $group_values ); + $contact_ids = array($contribution->contact_id); + $group_id = $group_values['id']; + CRM_Contact_BAO_GroupContact::addContactsToGroup( $contact_ids, $group_id ); + } + + } + + + /** + * Sets a db custom value so contributor will show up on a public supporter list. + * WARNING: This function is CC-specific, but generally useful. + */ + function oc_set_supporter_list ( $contribution ) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_set_supporter_list()"); + } + + $query = "SELECT id FROM civicrm_custom_field WHERE label = 'OneClick Supporter List'"; + $custom_id = CRM_Core_DAO::singleValueQuery( $query, CRM_Core_DAO::$_nullArray ); + if ( ! $custom_id ) { + return FALSE; + } + + require_once 'CRM/Core/BAO/CustomValueTable.php'; + $sl_params = array( + 'entityID' => $contribution->id, + "custom_{$custom_id}" => "Yes" + ); + CRM_Core_BAO_CustomValueTable::setValues($sl_params); + + } + + /** + * Sets a contributor source, so we can more easily track mailing donation totals, etc + */ + function oc_set_contributor_source ( $contribution, $custom_data ) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_set_contributor_source()"); + } + + $query = "SELECT id FROM civicrm_custom_field WHERE label = 'Contributor Source'"; + $custom_id = CRM_Core_DAO::singleValueQuery( $query, CRM_Core_DAO::$_nullArray ); + if ( ! $custom_id ) { + return FALSE; + } + + require_once 'CRM/Core/BAO/CustomValueTable.php'; + $sl_params = array( + 'entityID' => $contribution->id, + "custom_$custom_id" => $custom_data['contrib_source'] + ); + CRM_Core_BAO_CustomValueTable::setValues($sl_params); + } + + /** + * Stuff custom data into serialized array. + */ + function oc_create_custom_data($additional_data = NULL) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_create_custom_data()"); + } + + $custom_data = array(); + + # Is this a contribution through a Personal Campaign Page? + if ( isset($this->_request['pcpid']) && is_numeric($this->_request['pcpid']) ) { + $custom_data['pcpid'] = trim($this->_request['pcpid']); + } + + # Does the user want to opt-out of public supporter lists? This includes + # both the public supporter page and also the "honor roll" list for PCPs. + if ( isset($this->_request['sloptout']) ) { + $custom_data['sloptout'] = '1'; + } + + // Does the user want to join some mail lists? + if ( isset($this->_request['groups']) ) { + $groups = split(':', urldecode($this->_request['groups'])); + // Make sure they are permitted groups + for ( $i = 0; $i < count($groups); $i++ ) { + if ( ! in_array($groups[$i], $this->_valid_groups) ) { + unset($groups[$i]); + } + $groups[$i] = trim($groups[$i]); + } + $custom_data['groups'] = $groups; + } + + // The contributor wants gifts + if ( isset($this->_request['premium']) && is_numeric($this->_request['premium']) ) { + $custom_data['shirt_size'] = $this->_request['size']; + + // This is the db ID of the premium + $custom_data['premium'] = $this->_request['premium']; + } + + // Send the contributor a receipt + if ( isset($this->_request['receipt']) && is_numeric($this->_request['receipt']) ) { + $custom_data['receipt'] = $this->_request['receipt']; + } + + // If this is a recurring contribution, the ID of a final/reminder receipt + if ( isset($this->_request['final_receipt']) && is_numeric($this->_request['final_receipt']) ) { + $custom_data['final_receipt'] = $this->_request['final_receipt']; + } + + // Is there a contributor source defined? + if ( isset($this->_request['contrib_source']) ) { + $custom_data['contrib_source'] = htmlspecialchars($this->_request['contrib_source']); + } + + // Generally all custom data is gleaned from request variables, but + // this function also accepts an argument with some additional data, + // when necessary. The argument must be an array. + if ( $additional_data && is_array($additional_data) ) { + foreach ( $additional_data as $key => $data ) { + $custom_data[$key] = $data; + } + } + + // Return serialized custom data + if ( $custom_data ) { + return serialize($custom_data); + } else { + return FALSE; + } + + } + + + /** + * Returns a query string suitable for PayPal. + */ + function oc_make_paypal_url() { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_make_paypal_url()"); + } + + // See this URL for more information on these fields + // https://www.paypal.com/IntegrationCenter/ic_std-variable-ref-buy-now.html + // Preset common/default PayPal variables: + $paypal_fields = array( + "business" => $this->_paypal_business, + "cancel_return" => $this->_cancel_return, + "notify_url" => $this->_paypal_notify_url, + "cmd" => "_xclick", + "currency_code" => "USD", + "invoice" => "", + "item_name" => $this->_item_name, + "no_note" => "1", + "no_shipping" => "1", + "quantity" => "1", + "return" => $this->_return, + "invoice" => $this->oc_create_invoice_id(), + "rm" => "2" + ); + + // Is this a recurring contribution of some sort? + // https://www.paypal.com/en_US/ebook/subscriptions/html.html + if ( isset($this->_request['recur']) ) { + switch ( $this->_request['recur'] ) { + case '1': + // Payments will recur for 12 months + $paypal_fields['srt'] = "12"; + case '2': + $paypal_fields['cmd'] = "_xclick-subscriptions"; + $paypal_fields['a3'] = round($this->_request['amount'], 2); + $paypal_fields['p3'] = "1"; + $paypal_fields['t3'] = "M"; + $paypal_fields['src'] = "1"; + $paypal_fields['sra'] = "1"; + break; + } + } else { + // Field "amount" is used in non-recurring payments + $paypal_fields['amount'] = round($this->_request['amount'], 2); + } + + // If the contributor wanted a premium then we pass this info on to PayPal. + // And we also tell PayPal that we need a shipping address. + // pretty and nice. + if ( isset($this->_request['premium']) && is_numeric($this->_request['premium']) ) { + $paypal_fields['on0'] = "Gift"; + $paypal_fields['os0'] = "Yes"; + $paypal_fields['on1'] = "Shirt size"; + $paypal_fields['os1'] = $this->_request['size']; + $paypal_fields['no_shipping'] = "2"; + } else { + $paypal_fields['on0'] = "Gift"; + $paypal_fields['os0'] = "No"; + } + + // Create a custom data field to be submitted to the payment processor. + if ( $custom_data = $this->oc_create_custom_data() ) { + $paypal_fields['custom'] = $custom_data; + } + + // Concatenate the field with & + $paypal_query_string = ''; + foreach ( $paypal_fields as $key => $value ) { + $value = urlencode($value); + $paypal_query_string .= "$key=$value&"; + } + $paypal_query_string = rtrim($paypal_query_string, '&'); + + return $paypal_query_string; + + } + + + /** + * Generate XML suitable to send to Google Checkout's API. + */ + function oc_make_gc_xml() { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_make_gc_xml()"); + } + + // Base XML template for Google API request + if ( isset($this->_request['recur']) ) { + // Template for subscription (recurring) payments + $template = <<<XML +<checkout-shopping-cart xmlns="http://checkout.google.com/schema/2"> + <shopping-cart> + <items> + <item> + <item-name></item-name> + <item-description></item-description> + <quantity>1</quantity> + <unit-price currency="USD"></unit-price> + <subscription type="google" period="MONTHLY"> + <payments> + <subscription-payment> + <maximum-charge currency="USD"></maximum-charge> + </subscription-payment> + </payments> + <recurrent-item> + <item-name></item-name> + <item-description></item-description> + <quantity>1</quantity> + <unit-price currency="USD"></unit-price> + <merchant-private-item-data> + <recurrent-custom-data></recurrent-custom-data> + </merchant-private-item-data> + </recurrent-item> + </subscription> + </item> + </items> + <merchant-private-data></merchant-private-data> + </shopping-cart> + <checkout-flow-support> + <merchant-checkout-flow-support> + <continue-shopping-url></continue-shopping-url> + <edit-cart-url></edit-cart-url> + </merchant-checkout-flow-support> + </checkout-flow-support> +</checkout-shopping-cart> +XML; + } else { + // Template for single-time donations + $template = <<<XML +<checkout-shopping-cart xmlns="http://checkout.google.com/schema/2"> + <shopping-cart> + <items> + <item> + <item-name></item-name> + <item-description></item-description> + <quantity>1</quantity> + <unit-price currency="USD"></unit-price> + </item> + </items> + <merchant-private-data></merchant-private-data> + </shopping-cart> + <checkout-flow-support> + <merchant-checkout-flow-support> + <continue-shopping-url></continue-shopping-url> + <edit-cart-url></edit-cart-url> + </merchant-checkout-flow-support> + </checkout-flow-support> +</checkout-shopping-cart> +XML; + } + + // A place to stick additional merchant-private-data ($custom_data). + $add_custom_data = array(); + + $gc_xml = new DomDocument('1.0'); + $gc_xml->loadXML($template); + $gc_xml->encoding = 'UTF-8'; + + $node_item_name = $gc_xml->createTextNode($this->_item_name); + $node_amount = $gc_xml->createTextNode( round($this->_request['amount'], 2) ); + + $gc_xml->getElementsByTagName('item-name')->item(0)->appendChild($node_item_name); + $gc_xml->getElementsByTagName('unit-price')->item(0)->appendChild($node_amount); + + if ( isset($this->_request['recur']) ) { + $node_recur_item_name = $gc_xml->createTextNode($this->_item_name); + $node_recur_amount = $gc_xml->createTextNode( round($this->_request['amount'], 2) ); + $node_maximum_charge = $gc_xml->createTextNode( round($this->_request['amount'], 2) ); + $gc_xml->getElementsByTagName('item-name')->item(1)->appendChild($node_recur_item_name); + $gc_xml->getElementsByTagName('unit-price')->item(1)->appendChild($node_recur_amount); + $gc_xml->getElementsByTagName('maximum-charge')->item(0)->appendChild($node_maximum_charge); + // Is this subscription just for 12 months? + if ( $this->_request['recur'] == '1' ) { + $attribute = $gc_xml->createAttribute('times'); + $text_node = $gc_xml->createTextNode('12'); + $attribute->appendChild($text_node); + $gc_xml->getElementsByTagName('subscription-payment')->item(0)->appendChild($attribute); + } + + // Unfortunately, Google doesn't provide a unique ID to indentify a + // payment as belonging to a certain subscription, so we generate a + // (hopefully) unique ID here and add it to our $custom_data for + // later reference. + $add_custom_data['subscription_id'] = md5( time() . rand() ); + } + + // Set where user goes when order is complete at GC + $node_return = $gc_xml->createTextNode($this->_return); + $gc_xml->getElementsByTagName('continue-shopping-url')->item(0)->appendChild($node_return); + + // Set where user goes if they leave GC before finishing + $node_cancel = $gc_xml->createTextNode($this->_cancel_return); + $gc_xml->getElementsByTagName('edit-cart-url')->item(0)->appendChild($node_cancel); + + // Set up custom OneClick custom_data. + if ( $custom_data = $this->oc_create_custom_data($add_custom_data) ) { + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: custom_data = $custom_data"); + } + $text_node = $gc_xml->createTextNode($custom_data); + $gc_xml->getElementsByTagName('merchant-private-data')->item(0)->appendChild($text_node); + if ( isset($this->_request['recur']) ) { + $text_node = $gc_xml->createTextNode($custom_data); + $gc_xml->getElementsByTagName('recurrent-custom-data')->item(0)->appendChild($text_node); + } + } + + $xml_request = $gc_xml->saveXML(); + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: GC XML request:\n$xml_request"); + } + + return $xml_request; + + } + + + /** + * Make a CURL request to the GC API. If it's successful return the redirect + * URL, else echo the error. + */ + function oc_gc_request($gc_xml, $payment_processor) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_gc_request()"); + } + + $gc_url = "{$this->_gc_base_url}/{$payment_processor['user_name']}"; + $gc_auth = base64_encode("{$payment_processor['user_name']}:{$payment_processor['password']}"); + + $header = array(); + $header[] = "Authorization: Basic $gc_auth"; + $header[] = "Content-type: application/xml;charset=UTF-8"; + $header[] = "Accept: application/xml;charset=UTF-8"; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $gc_url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $gc_xml); + curl_setopt($ch, CURLOPT_HTTPHEADER, $header); + + // Execute the API request. + $gc_response = curl_exec($ch); + + $response_xml = new DomDocument('1.0'); + $response_xml->loadXML($gc_response); + $response_type = $response_xml->documentElement->nodeName; + + switch ($response_type) { + case "checkout-redirect": + return $response_xml->getElementsByTagName('redirect-url')->item(0)->nodeValue; + break; + case "error": + $error = $response_xml->getElementsByTagName('error-message')->item(0)->nodeValue; + echo "Google Checkout returned error:\n\n$error"; + exit; + break; + default: + $data = $response_xml->saveXML(); + echo "Unknown response from Google Checkout:\n\n$data"; + exit; + } + + } + + + /** + * Generate a unique CiviCRM invoice number. + */ + function oc_create_invoice_id() { + // CRM/Contribute/Form/Contribution/Main.php:849 + return md5(uniqid(rand(), TRUE)); + } + + + /** + * Post contribution and contributor details to each of an + * array of URLs. + */ + function oc_notify_urls($notification) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_notify_urls()"); + } + + if ( ! $this->_notify_urls ) { + return false; + } + + $json_data = json_encode($notification); + $hash = sha1(CIVICRM_SITE_KEY . $json_data); + + $post_data = "data=$json_data"; + $post_data .= "&hash=$hash"; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_POST, TRUE); + curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE); + + foreach ( $this->_notify_urls as $notify_url ) { + curl_setopt($ch, CURLOPT_URL, $notify_url); + $response = curl_exec($ch); + $status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ( $status_code != '200' ) { + if ( self::OC_DEBUG ) { + $debug_output = "Notify URL '$notify_url' returned HTTP status code: '$status_code'\n"; + $debug_output .= "The response was:\n$response"; + CRM_Core_Error::debug_log_message("OneClick: $debug_output"); + } + } + } + + } + + + /** + * Fetch payment processor object from CiviCRM database. + */ + function oc_get_paymentprocessor($pp_name, $pp_type) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_get_paymentprocessor()"); + } + + require_once 'CRM/Core/BAO/PaymentProcessor.php'; + + // This means that we have to be consistent about naming our GC payment + // processor in CiviCRM + $params = array( + 'name' => $pp_name, + 'payment_processor_type' => $pp_type, + 'is_active' => 1, + 'is_test' => 0 + ); + $payment_processor = array(); + CRM_Core_BAO_PaymentProcessor::retrieve($params, $payment_processor); + + if ( $payment_processor ) { + return $payment_processor; + } else { + return FALSE; + } + + } + + + /** + * Stores or fetches OneClick custom data in/from a CiviCRM custom data field. + * This could be useful to record, and we can't be guaranteed that every IPN + * will contain this data, but we may need it. This is somewhat of a hack, but + * to stay away from coding for a database ID we'll just require that the custom + * data field label is "OneClick Custom Data" so that we can find it in the DB. + */ + function oc_store_fetch_custom_data($operation, $contribution_id, $custom_data = NULL) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_store_fetch_custom_data()"); + } + + if ( ! $operation || ! $contribution_id ) { + return FALSE; + } + + $return = FALSE; + + $query = "SELECT id FROM civicrm_custom_field WHERE label = 'OneClick Custom Data'"; + $custom_id = CRM_Core_DAO::singleValueQuery( $query, CRM_Core_DAO::$_nullArray ); + + if ( $custom_id ) { + require_once 'CRM/Core/BAO/CustomValueTable.php'; + if ( $operation == 'setValues' ) { + $custom_value = serialize($custom_data); + } else { + $custom_value = 1; + } + + $custom_params = array( + 'entityID' => $contribution_id, + "custom_{$custom_id}" => $custom_value + ); + + $custom_value = CRM_Core_BAO_CustomValueTable::$operation($custom_params); + if ( ! $custom_value['is_error'] ) { + if ( $operation == 'getValues' ) { + $return = unserialize($custom_value["custom_{$custom_id}"]); + } elseif ( $operation == 'setValues' ) { + $return = TRUE; + } + } + } + + return $return; + + } + + + /** + * Stores the payment processor in a custom field for the contribution. + */ + function oc_set_payment_processor($payment_processor, $contribution_id) { + + if ( ! $payment_processor || ! $contribution_id ) { + return FALSE; + } + + $return = FALSE; + + $query = "SELECT id FROM civicrm_custom_field WHERE label = 'Payment Processor'"; + $custom_id = CRM_Core_DAO::singleValueQuery( $query, CRM_Core_DAO::$_nullArray ); + + if ( $custom_id ) { + require_once 'CRM/Core/BAO/CustomValueTable.php'; + + $custom_params = array( + 'entityID' => $contribution_id, + "custom_{$custom_id}" => $payment_processor + ); + + $custom_value = CRM_Core_BAO_CustomValueTable::setValues($custom_params); + if ( ! $custom_value['is_error'] ) { + $return = TRUE; + } + } + + return $return; + + } + + + /** + * Converts ISO-8601 dates to MySQL suitable dates. + */ + function oc_iso8601_to_mysql($date) { + $unix_timestamp = strtotime($date); + $date_field = date('YmdHis', $unix_timestamp); + return $date_field; + } + + + /** + * Takes the POSTed input from the payment processor IPN and normalizes the + * data. + */ + function oc_map_ipn_fields($payment_processor, $ipn_data, &$existing_contr) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_map_ipn_fields()"); + } + + $contact = array(); + $email = array(); + $address = array(); + $recur = array(); + $contribution = array(); + + // This value gets stored in a custom field in the contribution + // making searching based on payment processor easy + $contribution['payment_processor'] = $payment_processor; + + if ( $payment_processor == 'paypal' ) { + + // Contact information + $contact['first_name'] = $ipn_data['first_name']; + $contact['last_name'] = $ipn_data['last_name']; + + // Email address + $email['email'] = $ipn_data['payer_email']; + + // Address information + if ( $ipn_data['address_name'] ) { + $address['address_name'] = $ipn_data['address_name']; + $address['street_address'] = $ipn_data['address_street']; + $address['city'] = $ipn_data['address_city']; + $address['state_province'] = $ipn_data['address_state']; + $address['postal_code'] = $ipn_data['address_zip']; + $address['country'] = $ipn_data['address_country_code']; + } + + // Common contribution information + $contribution['source'] = $ipn_data['item_name']; + if ( $ipn_data['memo'] ) { + $contribution['note'] = $ipn_data['memo']; + } + + // The fields in the IPN change depending on the transaction type. + // web_accept and subscr_payment are mostly identical. + switch ( $ipn_data['txn_type'] ) { + case 'subscr_signup': + // If N==1, then the subscr_payment IPN already arrived, so + // we set these fields so that this IPN updates the + // existing record rather recording a new one. + if ( $existing_contr->N == 1 ) { + $contribution['id'] = $existing_contr->id; + $contribution['contact_id'] = $existing_contr->contact_id; + $contribution['contribution_recur_id'] = $existing_contr->contribution_recur_id; + } else { + // subscr_signup came in first, so mark contr. as + // pending and set the invoice_id temporarily (later it + // will be changed to be the same as the trxn_id when + // the subscr_payment IPN arrives) + $contribution['contribution_status_id'] = '2'; + $contribution['invoice_id'] = $ipn_data['invoice']; + } + + $contribution['total_amount'] = $ipn_data['mc_amount3']; + $contribution['currency'] = $ipn_data['mc_currency']; + $recur['amount'] = $ipn_data['mc_amount3']; + $recur['start_date'] = $this->oc_iso8601_to_mysql($ipn_data['subscr_date']); + $recur['processor_id'] = $ipn_data['subscr_id']; + + // PayPal sends two distinct data elements in a single field, + // so we break them out here. + $freq_units = array ( + 'D' => 'day', + 'W' => 'week', + 'M' => 'month', + 'Y' => 'year' + ); + list($frequency_interval, $frequency_unit) = split(' ', $ipn_data['period3']); + $recur['frequency_interval'] = $frequency_interval; + $recur['frequency_unit'] = $freq_units[$frequency_unit]; + $recur['installments'] = $ipn_data['recur_times']; + $contribution['is_recur'] = TRUE; + + // This IPN only arrives at the time of the first payment, + // and it's only at the time of the first payment that we + // want to record a premium for this subscr. + $contribution['record_premium'] = TRUE; + + break; + case 'subscr_payment': + // If N==1 and trxn_id is NULL then the subscr_signup IPN + // came in before this IPN and this must be the + // subscr_payment IPN for the very first payment, so we'll + // update the existing record instead of creating a new + // one. Similarly, if the txn_id of the IPN is equal to + // the trxn_id of the existing contribution, then this must + // be an update to an existing record, and we don't want to + // create a new record. This latter case can happen when + // someone pays with an eCheck, where we get two + // subscr_payment IPN, one to notify that it's pending and + // the next to notify that it's completed. + if ( ($existing_contr->N == 1 && empty($existing_contr->trxn_id)) || + ($existing_contr->trxn_id == $ipn_data['txn_id']) + ) { + $contribution['id'] = $existing_contr->id; + $contribution['contact_id'] = $existing_contr->contact_id; + $contribution['contribution_recur_id'] = $existing_contr->contribution_recur_id; + } elseif ( $existing_contr->N >= 1 ) { + // This must simply be a subsequent subscr payment, in + // which case we want to create a new contribution + // record, but reuse the recur_id and contact_id + $contribution['contact_id'] = $existing_contr->contact_id; + $contribution['contribution_recur_id'] = $existing_contr->contribution_recur_id; + } + + $contribution['is_recur'] = TRUE; + $recur['modified_date'] = $this->oc_iso8601_to_mysql($ipn_data['payment_date']); + $recur['trxn_id'] = $ipn_data['txn_id']; + $recur['invoice_id'] = $ipn_data['txn_id']; + $recur['processor_id'] = $ipn_data['subscr_id']; + // NOTE we do not break here, but proceed to web_accept + case 'web_accept': + $contribution['trxn_id'] = $ipn_data['txn_id']; + $contribution['invoice_id'] = $ipn_data['txn_id']; + $contribution['total_amount'] = $ipn_data['mc_gross']; + $contribution['fee_amount'] = $ipn_data['mc_fee']; + $contribution['net_amount'] = $ipn_data['mc_gross'] - $ipn_data['mc_fee']; + $contribution['currency'] = $ipn_data['mc_currency']; + $contribution['receive_date'] = $this->oc_iso8601_to_mysql($ipn_data['payment_date']); + if ( $ipn_data['payment_status'] == 'Completed' ) { + $contribution['contribution_status_id'] = '1'; + } else { + $contribution['contribution_status_id'] = '2'; // Pending + } + + // web_accept IPN are for single time donations, and all + // single donations need to have any premium recorded, but + // since the subscr_payment IPN passes into this block, we + // need to check for the IPN type again + if ( $ipn_data['txn_type'] == 'web_accept' ) { + $contribution['record_premium'] = TRUE; + } + + } + + // Extract the contents of any custom data + if ( $ipn_data['custom'] ) { + $contribution['custom_data'] = unserialize($ipn_data['custom']); + } + + } elseif ( $payment_processor == 'google' ) { + + + $gc_contact = $ipn_data['buyer-shipping-address']; + $gc_order = $ipn_data['shopping-cart']['items']['item']; + + // Extract the contents of any custom data. Google doesn't send merchant-private-data + // with subsequent payments in a subscription, so we have to stick it in our own field. + if ( isset($gc_order['merchant-private-item-data']['recurrent-custom-data']) ) { + $contribution['custom_data'] = unserialize($gc_order['merchant-private-item-data']['recurrent-custom-data']['VALUE']); + } elseif ( isset($ipn_data['shopping-cart']['merchant-private-data']) ) { + $contribution['custom_data'] = unserialize($ipn_data['shopping-cart']['merchant-private-data']['VALUE']); + } + + // If the contribution already exists in the database then we can + // assume that we've already recorded all the relevant information + // for the contact and can just pass on the existing contact_ id, + // and we can also pass along the contribution id so that we update + // the existing record rather than creating a new one. + if ( $existing_contr ) { + $contribution['id'] = $existing_contr->id; + $contribution['contact_id'] = $existing_contr->contact_id; + } else { + // Contact name + if ( isset($gc_contact['structured-name']) ) { + $contact['first_name'] = $gc_contact['structured-name']['first-name']['VALUE']; + $contact['last_name'] = $gc_contact['structured-name']['last-name']['VALUE']; + } else { + $contact['full_name'] = $gc_contact['contact-name']['VALUE']; + } + + // Email address + $email['email'] = $gc_contact['email']['VALUE']; + + // Address information + $address['address_name'] = $gc_contact['contact-name']['VALUE']; + $address['street_address'] = $gc_contact['address1']['VALUE']; + if ( isset($gc_contact['address2']) ) { + $address['supplemental_address_1'] = $gc_contact['address2']['VALUE']; + } + $address['city'] = $gc_contact['city']['VALUE']; + $address['state_province'] = $gc_contact['region']['VALUE']; + $address['postal_code'] = $gc_contact['postal-code']['VALUE']; + $address['country'] = $gc_contact['country-code']['VALUE']; + + } + + // Contribution information + $contribution['source'] = $gc_order['item-name']['VALUE']; + $contribution['trxn_id'] = $ipn_data['google-order-number']['VALUE']; + $contribution['invoice_id'] = $contribution['trxn_id']; + $contribution['total_amount'] = $ipn_data['order-total']['VALUE']; + $contribution['currency'] = $ipn_data['order-total']['currency']; + $contribution['receive_date'] = $this->oc_iso8601_to_mysql($ipn_data['timestamp']['VALUE']); + // This function is only called by new-order-notifications and we + // have several more notifications before this order is done, so + // set this to Pending. + $contribution['contribution_status_id'] = '2'; + + // This is a subscription (recurring) contribution. + if ( isset($gc_order['subscription']) ) { + $recur['amount'] = $contribution['total_amount']; + $recur['start_date'] = $this->oc_iso8601_to_mysql($contribution['receive_date']); + $recur['modified_date'] = $this->oc_iso8601_to_mysql($contribution['receive_date']); + $contribution['is_recur'] = 1; + $freqUnits = array ( + 'DAILY' => 'day', + 'WEEKLY' => 'week', + 'MONTHLY' => 'month', + 'YEARLY' => 'year' + ); + $recur['frequency_unit'] = $freqUnits[$gc_order['subscription']['period']]; + $recur['frequency_interval'] = 1; + $recur['installments'] = $gc_order['subscription']['payments']['subscription-payment']['times']; + // This is a number we generated and stuck in merchant-private-data. + $recur['processor_id'] = $contribution['custom_data']['subscription_id']; + $recur['trxn_id'] = $contribution['trxn_id']; + $recur['invoice_id'] = $contribution['invoice_id']; + + // The <subscription> element is only present in the first + // new-order-notification of a subscription and it's only at + // the time of the first payment that we want to record a + // premium for this subscr. + $contribution['record_premium'] = TRUE; + + } elseif ( isset($gc_order['merchant-private-item-data']['recurrent-custom-data']) ) { + + // We will enter into this block if this is any other than the first + // payment in a subscription i.e. subsequent payments. + $recur['trxn_id'] = $contribution['trxn_id']; + $recur['invoice_id'] = $contribution['invoice_id']; + $recur['modified_date'] = $this->oc_iso8601_to_mysql($contribution['receive_date']); + $contribution['is_recur'] = 1; + + // We have to use our generated subscription_id to find out which record in the + // contribution_recur table this belongs to. + if ( isset($contribution['custom_data']) ) { + require_once 'CRM/Contribute/BAO/ContributionRecur.php'; + $existing_recur_obj = new CRM_Contribute_BAO_ContributionRecur; + $existing_recur_obj->processor_id = $contribution['custom_data']['subscription_id']; + if ( $existing_recur_obj->find(true) ) { + $recur['id'] = $existing_recur_obj->id; + } + } + } + + } + + // If this is not a recurring contribution, for which only the first + // payment should have a premium recorded, then we record a premium in + // every case + if ( ! $contribution['is_recur'] ) { + $contribution['record_premium'] = TRUE; + } + + $contact['email'][1] = $email; + $contact['transaction'] = $contribution; + if ( ! empty($address) ) { + $contact['address'][1] = $address; + } + + if ( ! empty($recur) ) { + $contact['recur'] = $recur; + } + + return $contact; + + } + + + /** + * Records a contribution based on IPN data. This should be payment processor + * agnostic. + */ + function oc_record_contribution($params) { + + if ( self::OC_DEBUG ) { + CRM_Core_Error::debug_log_message("OneClick: Entering oc_record_contribution()"); + } + + $transaction = $params['transaction']; + if ( $params['recur'] ) { + $recur = $params['recur']; + } + + // If contact_id is already set then we can just fetch all the related + // data for the contact, else we'll need to record it all new. + require_once 'CRM/Contact/BAO/Contact.php'; + if ( $transaction['contact_id'] ) { + $find_params = array('contact_id' => $transaction['contact_id']); + $contact = CRM_Contact_BAO_Contact::retrieve($find_params, + CRM_Core_DAO::$_nullArray, + CRM_Core_DAO::$_nullArray); + } else { + + // Get the address and email fields configured. The following is + // largely borrowed from: CRM/Contribute/BAO/Contribution/Utils.php + + $billing_loc_type_id = CRM_Core_DAO::getFieldValue( 'CRM_Core_DAO_LocationType', 'Billing', 'id', 'name' ); + if ( ! $billing_loc_type_id ) { + $billing_loc_type_id = 1; + } + if ( ! CRM_Utils_System::isNull($params['address']) ) { + $params['address'][1]['is_primary'] = 1; + $params['address'][1]['location_type_id'] = $billing_loc_type_id; + } + if ( ! CRM_Utils_System::isNull($params['email']) ) { + $params['email'][1]['location_type_id'] = $billing_loc_type_id; + } + + // Just assume that all donations via a payment processor are of + // type Individual. + $params['contact_type'] = 'Individual'; + + // Add contact using dedupe rule + require_once 'CRM/Dedupe/Finder.php'; + $dedupe_params = CRM_Dedupe_Finder::formatParams ($params, 'Individual'); + $dupe_ids = CRM_Dedupe_Finder::dupesByParams($dedupe_params, 'Individual'); + // if we find more than one contact, use the first one + if ( CRM_Utils_Array::value( 0, $dupe_ids ) ) { + $params['contact_id'] = $dupe_ids[0]; + } + $contact = CRM_Contact_BAO_Contact::create($params); + if ( $contact->id ) { + $transaction['contact_id'] = $contact->id; + } else { + return FALSE; + } + + } + + // Set to default contribution Type (Donation) unless it was already passed. + if ( ! isset($transaction['contribution_type_id']) ) { + require_once 'CRM/Contribute/BAO/ContributionType.php'; + $params = array('name' => 'Donation'); + $contribution_type = CRM_Contribute_BAO_ContributionType::retrieve($params, CRM_Core_DAO::$_nullArray); + if ( $contribution_type ) { + $transaction['contribution_type_id'] = $contribution_type->id; + } else { + // This is what CiviCRM does as a complete fallback method. + // CRM/Contribute/BAO/Contribution/Utils.php:410 + require_once 'CRM/Contribute/PseudoConstant.php'; + $contributionTypes = array_keys( CRM_Contribute_PseudoConstant::contributionType( ) ); + $transaction['contribution_type_id'] = $contributionTypes[0]; + } + } + + // Handle a subscription payment before the contribution + if ( $transaction['is_recur'] && $recur ) { + require_once 'CRM/Contribute/BAO/ContributionRecur.php'; + $recur_obj = new CRM_Contribute_BAO_ContributionRecur; + if ( $transaction['contribution_recur_id'] ) { + $recur_obj->id = $transaction['contribution_recur_id']; + } + $recur['contact_id'] = $contact->id; + $recur_obj->copyValues($recur); + $recur_obj->save(); + if ( self::OC_DEBUG ) { + $debug_output = "RECURRING OBJECT:\n" . print_r($recur_obj, TRUE); + CRM_Core_Error::debug_log_message($debug_output); + } + + $transaction['contribution_recur_id'] = $recur_obj->id; + } + + $ids = array(); + $ids['contribution'] = $transaction['id'] ? $transaction['id'] : ''; + require_once 'CRM/Contribute/BAO/Contribution.php'; + $contribution = CRM_Contribute_BAO_Contribution::create($transaction, $ids); + + if ( is_a( $contribution, 'CRM_Core_Error') ) { + CRM_Core_Error::debug_log_message($contribution->_errors[0]['message']); + return FALSE; + } + + if ( $contribution ) { + + // When a contribution is added/update with create() (above), the + // returned contribution object may only contain the fields that were + // input to create(), but more often than not we're going to want every + // data point in the contribution, so we just look it up and re-capture + // it here. + $find_params = array('id' => $contribution->id); + $contribution = CRM_Contribute_BAO_Contribution::retrieve( $find_params, + CRM_Core_DAO::$_nullArray, + CRM_Core_DAO::$_nullArray); + + // If we end up pinging the notification callback URLs, it's nice + // for us to include some contact info in the data we send. These + // fields are also used by some of the oc_ functions. + $contribution->first_name = $contact->first_name; + $contribution->last_name = $contact->last_name; + $contribution->full_name = $contact->display_name; + $email = array_shift($contact->email); + $contribution->email = $email->email; + + // Here we record which payment processor was used. + $this->oc_set_payment_processor($transaction['payment_processor'], $contribution->id); + + // The OneClick process packs a number of data elements into an array + // called custom_data. We store that data in a CiviCRM custom data + // field with the contribution for future reference. We may use it for + // anything, but in specific the motivating factor to store it is + // Google Checkout, since Google doesn't send custom data + // (merchant-private-data) with charge-amount-notifications and it is + // for this notification that we need the email donation receipt ID + // stored in the custom data. First Check to see if any custom data has + // already been stored. + $has_custom_data = $this->oc_store_fetch_custom_data('getValues', $contribution->id); + if ( isset($transaction['custom_data']) ) { + $custom_data = $transaction['custom_data']; + if ( ! $has_custom_data ) { + $this->oc_store_fetch_custom_data('setValues', $contribution->id, $custom_data); + } + } else { + $custom_data = $has_custom_data; + } + + if ( $custom_data ) { + // Add a premium to the contribution + if ( $custom_data['premium'] && $transaction['record_premium'] ) { + $this->oc_set_premium( $contribution, $custom_data ); + } + + // This is a contribution for a PCP. + if ( array_key_exists("pcpid", $custom_data) ) { + $this->oc_add_soft_contribution( $contribution, $custom_data ); + } + + // The contributor wants to join some mail lists (groups) + if ( array_key_exists("groups", $custom_data) && $custom_data['groups'] ) { + $this->oc_set_groups( $contribution, $custom_data ); + } + + // Add user to public supporter lists unless they opted out. + if ( ! isset($custom_data['sloptout']) ) { + $this->oc_set_supporter_list( $contribution ); + } + + // Store the contributor's donation source + // This allows us to more easily track total contributions from + // mailings, special events, the widget, or other external source + if ( array_key_exists("contrib_source", $custom_data) ) { + $this->oc_set_contributor_source( $contribution, $custom_data ); + } + } else { + // If no custom data exists, then the default is to add someone + // to supporter lists. + $this->oc_set_supporter_list( $contribution ); + } + + // Send the contributor a receipt, but only if the transaction is complete + // and we haven't already sent the user a receipt. + if ( $contribution->contribution_status_id == '1' && ! $contribution->receipt_date ) { + if ( $custom_data && $custom_data['receipt'] ) { + $receipt_id = $custom_data['receipt']; + // If this is a recurring contribution, then we should check to see + // if this is the last payment in the subscription. If it is, then + // we may send a special receipt that prompts the user to renew. + if ( $contribution->contribution_recur_id ) { + $query = " + SELECT installments, count(contr.id) AS payments + FROM civicrm_contribution_recur AS recur JOIN civicrm_contribution AS contr + ON recur.id = contr.contribution_recur_id + WHERE recur.id = '$contribution->contribution_recur_id' + AND recur.installments IS NOT NULL + AND recur.installments <> '' + GROUP BY recur.id; + "; + $recur_dao = CRM_Core_DAO::executeQuery( $query ); + $recur_dao->fetch(); + + if ( isset($recur_dao->installments) && isset($recur_dao->payments) ) { + if ( $recur_dao->installments == $recur_dao->payments ) { + if ( isset($custom_data['final_receipt'] ) ) { + $receipt_id = $custom_data['final_receipt']; + } + } + } + } + $this->oc_send_receipt($contribution, $receipt_id); + } + } + + } + + return $contribution; + + } + +} + +?> diff --git a/OneClick.php b/OneClick.php new file mode 100644 index 0000000..515d2de --- /dev/null +++ b/OneClick.php @@ -0,0 +1,52 @@ +<?php + +/** + * This is just a wrapper/router for OneClick.class.php + */ + +// Fire up CiviCRM +require_once '../civicrm.config.php'; +require_once 'CRM/Core/Config.php'; +$config =& CRM_Core_Config::singleton(); + +// Only allow this to run one at a time to avoid overlap of database operations +// if IPN come in at nearly the same time. This is mostly here because of +// PayPal recurring contributions and the fact that subscr_payment and +// subscr_signup IPN come in at virtually the same time. +require_once 'CRM/Core/Lock.php'; +while ( ! $lock = new CRM_Core_Lock('OneClick') ) { + // Sleep for 1 second, then try again. + sleep(1); +} + +require 'OneClick.class.php'; +$oc = new OneClick($_REQUEST); + +// What are we doing? +switch ( $_REQUEST['oc_action'] ) { + case 'donate': + if ( isset($_REQUEST['premium']) && ! isset($_REQUEST['size']) ) { + // Javascript should prevent this, but as a failsafe send them back + // if for some reason there is a premium set yet no shirt size. + $err_msg = "You must select a shirt size."; + $err_msg = urlencode($err_msg); + header("Location: {$oc->_cancel_return}?{$_SERVER['QUERY_STRING']}&error=$err_msg"); + } else { + $oc->oc_donate(); + } + break; + case 'paypalipn': + $oc->oc_ipn('paypal'); + break; + case 'googleipn': + $oc->oc_ipn('google'); + break; + default: + $err_msg = "Unknown oc_action."; + $err_msg = urlencode($err_msg); + header("Location: {$oc->_cancel_return}?{$_SERVER['QUERY_STRING']}&error=$err_msg"); +} + +$lock->release(); + +?> |
