Skip to content

Create a Payment Processor Extension

A payment processor extension provides the bridge between CiviCRM code and an external payment processing service (e.g Paypal, IATS etc). The extension is generated as a module using civix generate:module and needs to implement some specific conventions to integrate with CiviCRM.

Generally you should expect some challenges getting access to payment processor service's documentation and test accounts so start the process of accessing them as early as possible.

Processor extension requirements

A payment processor integration extension typically includes:

  1. An mgd file to declare the processor to CiviCRM.

  2. A Payment Processor class (required) - this provides CiviCRM with a standard interface/API bridge to the third party processor, and a way for CiviCRM to know which features are supported.

  3. A handler function for IPNs / web hooks if required for the payment processor.

  4. Unit tests.

  5. Other libraries and helpers for the particular Payment Processor service.

Declare your payment_processor_type to CiviCRM with an mgd file

Within your extension you should have an mgd file (e.g. /managed/PaymentProcessorType.mgd.php).

This file will cause the extension system to add a row to the civicrm_payment_processor_type table describing your processor. The file should look like:

<?php
/**

 * - user_name_label, password_label, signature_label, subject_label - these
 * are generally about telling the plugin what to call these when they pass
 * them to Omnipay. They are also shown to users so some reformatting is done
 * to turn it into lower-first-letter camel case. Take a look at the gateway
 * file for your gateway. This is directly under src. Some provide more than
 * one and the 'getName' function distinguishes them. The getDefaultParameters
 * will tell you what to pass. eg if you see
 * 'apiKey' you should enter 'user_name' => 'Api Key' (you might ? be able to
 * get away with 'API Key' - need to check). You can provide as many or as few
 * as you want of these and it's irrelevant which field you put them in but
 * note that the signature field is the longest and that in future versions of
 * CiviCRM hashing may be done on password and signature on the screen.
 *
 * - 'class_name' => 'Payment_OmnipayMultiProcessor', (always)
 *
 * - 'url_site_default' - this is ignored. But, by giving one you make it
 * easier for people adding processors
 *
 * - 'billing_mode' - 1 = onsite, 4 = redirect offsite (including transparent
 * redirects).
 *
 * - payment_mode - 1 = credit card, 2 = debit card, 3 = transparent redirect.
 * In practice 3 means that billing details are gathered on-site so it may also
 * be used with automatic redirects where address fields need to be mandatory
 * for the signature.
 *
 * The record will be automatically inserted, updated, or deleted from the
 * database as appropriate. For more details, see "hook_civicrm_managed" at:
 * http://wiki.civicrm.org/confluence/display/CRMDOC/Hook+Reference
 */
return [
  [
    'name' => 'MyProcessor',
    'entity' => 'payment_processor_type',
    'params' => [
      'version' => 3,
      'title' => 'My Processor',
      'name' => 'MyProcessor',
      'description' => 'My processor',
      // this will cause the user to be presented with a field,
      //  when adding this processor, labelled
      // api key which will save to civicrm_payment_processor.user_name
      // on save
      'user_name_label' => 'apiKey',
      // as per user_name_label, but saves to password
      'password_label' => 'secret',
      // as per user_name_label, but saves to signature
      'signature_label' => 'signature',
      // prefix of CRM_Core is implicit so the class ie CRM_Core_Payment_MyProcessor
      'class_name' => 'Payment_MyProcessor,
      // Any urls you might need stored for the user to be redirect to, for example.
      // Note it is quite common these days to hard code the urls in the processors
      // as they are not necessarily seen as configuration. But, if you enter
      // something here it will be the default for data entry.
      'url_site_default' => 'https://example.com',
      'url_api_default' => 'https://example.com',
      // this is a deprecated concept and these docs recommend you override
      // anything that references it. However, if you redirect the user offsite
      // enter 4 and if not enter 1 here.
      'billing_mode' => 4,
      // Generally 1 for credit card & 2 for direct debit. This will ideally 
      // become an option group at some point but also note that it's mostly
      // or possibly only used from functions that this documentation recommends
      // you override (eg. `getPaymentTypeLabel`)
      'payment_type' => 1,
    ],
  ],
];

The Payment Class

A payment processor object extends CRM_Core_Payment. This class provides CiviCRM with a standard interface/API bridge to the third party processor. It should be found at:

<myextension>/CRM/Core/Payment/MyExtension.php

The most important method the payment processor class implements is doPayment. This function is responsible for receiving information from CiviCRM (such as the amount, billing details and payment details) that need to be passed to the payment gateway, formatting this information for the gateway and submitting and returning information about its success or otherwise to CiviCRM.

Important

Try to avoid infringing on CiviCRM's logic. The methods in your extension should take inputs, communicate with the third party, and return output data that CiviCRM can use to perform its logic. If you find your extension is sending emails, duplicating logic, updating or creating records in CiviCRM, outputting user content (e.g. status messages) then stop, check and consider separating out your code into different methods. Remember that you might be processing a webform or other non-core payment request so don't assume a user context (e.g. use CRM_Core_Error::statusBounce).

Note

Most methods should throw a Civi\Payment\Exception\PaymentProcessorException when they are unable to fulfill the expectations of a method.

doPayment function

The doPayment function receives information about the payment and is expected to return an array with the payment outcome, including any trxn_id from the payment processor and any fee_amount, if any. e.g.

/**
 * Make a payment by interacting with an external payment processor.
 *
 * @param array|PropertyBag $params
 *   This may be passed in as an array or a \Civi\Payment\PropertyBag
 *   It holds all the values that have been collected to make the payment (eg. amount, address, currency, email).
 * 
 * These values are documented at https://docs.civicrm.org/dev/en/latest/extensions/payment-processors/create/#available-parameters
 * h
 *   You can explicitly cast to PropertyBag and then work with that to get standardised keys and helpers to interact with the values passed in.
 *   See 
 *   Also https://docs.civicrm.org/dev/en/latest/extensions/payment-processors/create/#introducing-propertybag-objects explains how to interact with params as a property bag.
 *   Passed by reference to comply with the parent function but **should not be altered**.
 * @param string $component
 *   Component is either 'contribution' or 'event' and is primarily used to determine the url
 *   to return the browser to. (Membership purchases come through as 'contribution'.)
 *
 * @return array
 *   Result array:
 *   - MUST contain payment_status (Completed|Pending)
 *   - MUST contain payment_status_id
 *   - MAY contain trxn_id
 *   - MAY contain fee_amount
 *   See: https://lab.civicrm.org/dev/financial/-/issues/141
 *
 * @throws \Civi\Payment\Exception\PaymentProcessorException
 */
public function doPayment(&$params, $component = 'contribute') {
  /* @var \Civi\Payment\PropertyBag $propertyBag */
  $propertyBag = \Civi\Payment\PropertyBag::cast($params);

  if ($propertyBag->getAmount() == 0) {
    // The function needs to cope with the possibility of it being zero
    // this is because historically it was thought some processors
    // might want to do something with $0 amounts. It is unclear if this is the
    // case but it is baked in now.
    $result['payment_status_id'] = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
    $result['payment_status'] = 'Completed';
    return $result;
  }

  // Prepare whatever data the 3rd party processor requires to take a payment.
  // The contents of the array below are just examples of typical things that
  // might be used.
  $processorFormattedParams = [
    'authentication_key' => $this->getPaymentProcessor()['user_name'],
    'amount' => $propertyBag->getAmount(),
    'order_id' => $propertyBag->getter('contributionID', TRUE, ''),
    // getNotifyUrl helps you construct the url to tell an off-site
    // processor where to send payment notifications (IPNs/webhooks) to.
    // Not all 3rd party processors need this.
    'notifyUrl' => $this->getNotifyUrl(),
    // etc. depending on the features and requirements of the 3rd party API.
  ];
  if ($propertyBag->has('description')) {
    $processorFormattedParams['description'] = $propertyBag->getDescription();
  }

  // Allow further manipulation of the arguments via custom hooks
  CRM_Utils_Hook::alterPaymentProcessorParams($this, $propertyBag, $processorFormattedParams);

  // At this point you need to interact with the payment processor.
  $result = callThe3rdPartyAPI($processorFormattedParams);

  // Some processors require that you send the user off-site to complete the payment.
  // This can be done with CRM_Utils_System::redirect(), but note that in this case
  // the script execution ends before we have returned anything. Therefore the payment
  // processes must be picked up asynchronously (e.g. webhook/IPN or some other return
  // process). You may need to store data on the session in some cases to accommodate.

  // If you are interacting with the processor server side & get a result then
  // you should either throw an exception or return a result array, depending on
  // the outcome.
  if ($result['failed']) {
    throw new (\Civi\Payment\Exception\PaymentProcessorException($failureMessage);
  }

  return [
    'payment_status'    => 'Completed',
    'payment_status_id' => CRM_Core_PseudoConstant::getKey(
                             'CRM_Contribute_BAO_Contribution',
                             'contribution_status_id',
                             'Completed'),
    'trxn_id'           => $result['payment_id'],
    'fee_amount'        => $result['fee'],
  ];

  return $returnData;
}

doRefund function

This is largely similar to doPayment - if you implement this you need to declare that your site supportsRefund (see supports functions).

doPreApproval function

This is called when there is an opportunity for the user to pre-approve their credentials early in the process (and you declare supportsPreApproval) - they might be redirected to a different site to enter their card or perhaps do it onsite in a js intercept.

If this doPreApproval returns an array like

  return [
    'pre_approval_parameters' => ['token' => $params['token']],
  ];

That array will later be passed to the function getPreApprovalDetails for parsing and that parsed value will reach doPayment. For example getPreApprovalDetails could return ['token' => 'x'] and 'token' will be in the $params that gets passed to doPayment for finalisation (sometimes called 'Capture').

doCancelRecurring function

This functions cancels a recurring contribution series. In some cases it will be desirable to notify the processor for example, when the processor is responsible for initiating new payments in the series. In others it might be fine to simply return TRUE (e.g. if the schedule is maintained via the civicrm_contribution_recur table and CiviCRM initiates each payment in the series by calling the payment processor with a card on file token.) From CiviCRM 5.27 onwards this function is supported and accepts a PropertyBag rather than an array. See the PropertyBag section for more.

The payment class should implement the following methods for this to be exposed - (see supports section below):

  • protected function supportsCancelRecurring() { return TRUE; }
  • protected function supportsCancelRecurringNotifyOptional() { return TRUE; }
  /**
   * @param \Civi\Payment\PropertyBag $propertyBag
   *
   * @return array|null[]
   * @throws \Civi\Payment\Exception\PaymentProcessorException
   */
  public function doCancelRecurring(PropertyBag $propertyBag) {
    // Call processor here.
    // In this case we always receive a property bag - it can be
    // used as an array 
  }

The construct method

When the class is constructed it is passed a couple of parameters you will likely want to store. These are used internally (i.e. the properties they are stored into here are conventions rather than meaningful).

  /**
   * Constructor.
   *
   * @param string $mode the mode of operation: live or test
   * @param array $paymentProcessor
   *
   * @return void
   */
  function __construct($mode, $paymentProcessor) {
    $this->_mode = $mode;
    $this->_paymentProcessor = $paymentProcessor;
  }

The checkConfig method

This is called when the user configures a processor through the UI and when loading a processor for payment. If they have not entered required fields an error message should be returned.

  /**
   * This function reports any configuration errors.
   *
   * @return string the error message if any
   */
  public function checkConfig( ) {
    if (empty($this->_paymentProcessor['user_name'])) {
      return E::ts('The "Bill To ID" is not set in Administer > CiviContribute > Payment Processor.');
    }
  }

The handlePaymentNotification method for IPNs and Webhooks

IPN means Instant Payment Notification, although they are usually asynchronous and not "instant". Many third parties talk instead about Webhooks.

It refers to the data sent from the third party (i.e. Payment Processor) on various events e.g.:

- completed/confirmed payment,
- cancellation of recurring payment,
- often many more situations - depends heavily on the third party, often configurable in their account administration facilities

CiviCRM provides a menu route at `civicrm/payment/ipn/<N>` where `<N>` is the payment processor ID.

In older processors notifications may be handled in a separate class that extends the legacy BaseIPN class but the currently recommended method is to implement handlePaymentNotification on the main payment class.

Note that it's good practice to compile the parameters in this function and pass them to a second function for processing, as this allows you to write unit tests.

  /**
   * Handle response from processor.
   *
   * We simply get the params from the REQUEST and pass them to a static function that
   * can also be called / tested outside the normal process
   */
  public function handlePaymentNotification() {
    // You may have a way for the processor to pass the processor id as a $_GET
    // or post parameter - but otherwise it should be in the url being
    // hit by the external site or returning browser so this should work.
    $q = explode('/',$_GET['q']);
    $paymentProcessorID = array_pop($q);

    $params = array_merge($_GET, $_REQUEST);
    $this->_paymentProcessor = civicrm_api3('payment_processor', 'getsingle', ['id' => $paymentProcessorID]);
    $this->processPaymentNotification($params);
  }

   /**
   * Update CiviCRM based on outcome of the transaction processing.
   *
   * @param array $params
   *
   * @throws CRM_Core_Exception
   * @throws CiviCRM_API3_Exception
   */
  public function processPaymentNotification($params) {
    // Obviously all the below variables need to be extracted from the params.
    if ($isSuccess) {
      civicrm_api3('Payment', 'create', [
        'contribution_id' => $contributionID,
        'total_amount' => $totalAmount,
        'payment_instrument_id' => $this->_paymentProcessor['payment_instrment_id'],
        'trxn_id' => $trxnID,
        'credit_card_pan' => $last4CardsOfCardIfReturnedHere,

      ]);
      // Perhaps you are saving a payment token for future use (a token
      // is a string provided by the processor to allow you to recharge the card)
      $paymentToken = civicrm_api3('PaymentToken', 'create', [
        'contact_id' => $params['contact_id'],
        'token' => $params['token'],
        'payment_processor_id' => $params['payment_processor_id'] ?? $this->_paymentProcessor['id'],
        'created_id' => CRM_Core_Session::getLoggedInContactID() ?? $params['contact_id'],
        'email' => $params['email'],
        'billing_first_name' => $params['billing_first_name'] ?? NULL,
        'billing_middle_name' => $params['billing_middle_name'] ?? NULL,
        'billing_last_name' => $params['billing_last_name'] ?? NULL,
        'expiry_date' => $this->getCreditCardExpiry($params),
        'masked_account_number' => $this->getMaskedCreditCardNumber($params),
        'ip_address' => CRM_Utils_System::ipAddress(),
    ]);
    }

    if ($thisIsABrowserIwantToRedirect) {
      // This url was stored in the doPayment example above.
      $redirectURL = CRM_Core_Session::singleton()->get("ipn_success_url_{$this->transaction_id}");
      CRM_Utils_System::redirect($redirectUrl);
    }
    // Or perhaps just exit out for a server call.
    CRM_Utils_System::civiExit();
  }  

Methods to determine the form fields and insert javascript

Generally you will want to give the form information about how to build the form for your payment processor. These are the methods you should consider implementing to influence the form.

getPaymentTypeLabel | getPaymentTypeName | getTitle

By default the label is 'Credit card' or 'Debit card' and the name is 'credit_card' or 'debit_card' - the label may appear on the forms as a legend when displaying billing fields. The name may be used in css selectors. The title is the name of the processor and may be used when describing the processor ('redirect to Paypal?')

Override to avoid relying on deprecated 'billing_mode' concept.

getText

This allows you to override various bits of help text that might be presented to the user. The context specifies ... the context - eg.

  • contributionPageRecurringHelp
  • contributionPageContinueText
  • cancelRecurDetailText
  • cancelRecurNotSupportedText

getPaymentFormFields

It is recommended that you always override this method. The parent class will come up with a version based on defaults if you do not but it relies on the deprecated ' billing_mode' concept.

 /**
   * Get array of fields that should be displayed on the payment form.
   *
   * Common results are
   *   ['credit_card_type', 'credit_card_number', 'cvv2', 'credit_card_exp_date']
   *   or
   *   ['account_holder', 'bank_account_number', 'bank_identification_number', 'bank_name']
   *   or
   *   []
   *
   * @return array
   *   Array of payment fields appropriate to the payment processor.
   *
   * @throws CiviCRM_API3_Exception
   */
  public function getPaymentFormFields() {
    return ['credit_card_type', 'credit_card_number', 'cvv2', 'credit_card_exp_date'];
  }

getPaymentFormFieldsMetadata

This function allows you to provide information to the form about how to present your fields. If you wish to present fields that are not in the parent function you will need to override this. An example is not copied here as it makes more sense to look up the parent function.

getBillingAddressFields

This is similar to getPaymentFormFields but for billing fields. As with getPaymentFormFields it is recommended you override it so as not to rely on the deprecated 'billing_mode' concept.

getBillingAddressFieldsMetadata

Similar to getPaymentFormFieldsMetadata - override if necessary.

buildForm

This is where you might add javascript or smarty variables e.g.

  /**
   * Interact with the form construction.
   *
   * @param CRM_Core_Form $form
   *
   * @return bool
   *   Should form building stop at this point?
   * @throws \Civi\Payment\Exception\PaymentProcessorException
   */
   public function buildForm(&$form) {
     // refer https://docs.civicrm.org/dev/en/stable/framework/region/
     CRM_Core_Region::instance('billing-block-post')->add([
       'template' => 'CRM/Financial/Form/MyTemplate.tpl',
       'name' => 'money_money_money',
     ]);
   }

The subscriptionURL function (for recurring contributions)

This allows you to provide the url a user should be sent to for various actions that relate to managing their recuring contributions - its availablity depends on functionality being declared via the supports methods.

Supports methods

These methods declare what functionality your payment processor offers - they all return booleans. In some cases the parent classes will attempt to guess based on the deprecated 'billing_mode'.

supportsBackOffice

Can an administrative user use this processor? This would likely be false if the user would be redirected to enter card details. It is recommended you override this to avoid a default based on the deprecated 'billing_mode'.

supportsCancelRecurring

Can a recurring contribution be cancelled for this processor from within CiviCRM? CiviCRM will show cancellation links based on this.

supportsCancelRecurringNotifyOptional

  • added in CiviCRM 5.27

This dictates whether the checkbox 'Send cancellation request' is presented when cancelling a recurring contribution. It would be true if the processor allows you to cancel a payment within CiviCRM, thus cancelling future payments but it is ALSO possible to cancel within CiviCRM without doing so. An example would be when a user is cancelling a subscription within CiviCRM that is already cancelled at the payment processor end so they don't want a message sent to the processor's server.

If set to FALSE the payment processor should implement doCancelRecurring() and set a default using $propertyBag->setIsNotifyProcessorOnCancelRecur();

supportsChangeSubscriptionAmount

Can the monetary amount of a recurring contribution be changed from within CiviCRM?

supportsEditRecurringContribution

Can fields on the recurring contribution be changed from within CiviCRM? You should also implement getEditableRecurringScheduleFields to declare which fields (amount by default)

supportsFutureRecurStartDate

Can a recurring contribution have a start_date in the future?

supportsLiveMode | supportsTestMode

This is not a function you would normally override and relates to the processor instance not the functionality (ie is the test instance loaded)

supportsMultipleConcurrentPayments

Can two payments be processed in the same web request? Generally this relates to the separate membership payment functionality on the contribution page. An example of a processor that does not support it is paypal standard - the browser needs to be redirected & hence can only do one payment.

supportsNoEmailProvided

Will the processor work with no user email address? If not, one will be added to the payment form even if it is not otherwise required.

supportsPreApproval

This would be true in a flow like Paypal express where the browser is redirected early in the process for authorisation and then the token is charged at the end.

supportsRecurContributionsForPledges

Unused - deprecated.

supportsRecurring

Generally you should not override this and the database setting for the payment processor instance will be used.

supportsRefund

Can a refund be processed from within CiviCRM?

supportsUpdateSubscriptionBillingInfo

Can a user update their billing details from within CiviCRM? For example they might be able to update their credit card details if it has expired. You may need to update the subscriptionUrl function to provide an off-site url.

Available parameters

Core parameters

key ways to access / helpers propertyBag access notes
amount (required) $processor->getAmount($params) propertyBag->getAmount() amount is the money to charge in machine friendly formatting (e.g $4000.56). The helper functions will ensure it is rounded to 2 digits. Alternates, such as total_amount, are long-deprecated.
currency (required) $params['currency'] propertyBag->getCurrency()
contributionID (required .. but gaps) $params['contributionID'] ?? NULL $propertyBag->has('contributionID') && $propertyBag->getContributionID() There are a couple of outstanding cases where this is not yet available. These are treated as bugs.
invoiceID (required) params['invoiceID'] $propertyBag->getInvoiceID() This helps compensate for the instances where contribtion id is not passed in as this is a value the core forms 'intend' to save and it should be 100% reliably passed in.
contactID (required .. but gaps) $params['contactID'] ?? NULL $propertyBag->has('contactID') && $propertyBag->getContactID() As far as we know this is always present.
email $params['email'] $propertyBag->getEmail()
payment_token $params['payment_token'] ?? $propertyBag->has('paymentToken') && $propertyBag->getPaymentToken() A token provided by a payment processor that allows use of a pre-authorisation.
qfKey (only used in core forms) $params['qfKey'] ?? '' Used to construct return urls for the browser.
description $processor->getDescription($params) $propertyBag->has('description') && $propertyBag->getDescription() $processor->getDescription($params) returns a calculated string that includes the description but also makes various ids available.
ip_address $params['ip_address'] Probably better to just call CRM_Utils_System::ipAddress()
contributionPageID $params['contributionPageID'] ?? '' Available from core contribution page submissions.
eventID $params['eventID'] ?? '' Available from core event form registrations.
financial_type_id $params['financial_type_id'] Rarely used by processors but probably quite useful in the alterPaymentProcessorParams hook.
campaign_id $params['financial_type_id'] Rarely used by processors but probably quite useful in the alterPaymentProcessorParams hook.
accountingCode $params['accountingCode' ?? ''] Accounting code (based on financial type) - passed to paypal as a custom field - it's probably better to call CRM_Financial_BAO_FinancialAccount::getAccountingCode($params['financial_type_id']) than rely on this.

Billing fields

The available billing fields are processor dependent. The processor declares them in the function getBillingAddressFields() which directs the form builder to add them to the form. The metadata is declared with the function getBillingAddressFieldsMetadata(). This function is overrideable but the parent class (CRM_Core_Payment) declares the most common and best supported fields. If you take no action to define the fields to show core will make some assumptions based on 'billing mode' and only present billing fields if the 'billing_mode' is 2 - which would indicate taking details on site.

Also a note about billing fields - these traditionally look like billing_street_address-5 where the 5 is the output of CRM_Core_BAO_LocationType::getBilling();. Core forms will also pass just street_address but we have not audited enough to confirm that is always available. The examples below used $billingLocationID, assume retrieval by the above function.

Note that in testing the parameters from a default contribution page the propertyBag variants did not all work - i.e. the values with billingLocationID appended were not retrieved. However, they are included for completeness.

key ways to access / helpers propertyBag access notes
billing_first_name $params['billing_first_name] $propertyBag->has('firstName') && $propertyBag->getFirstName() $params['first_name'] is mostly or always also available (audit required)
billing_middle_name $params['billing_middle_name] ?? '' $propertyBag->getter('middle_name, TRUE) $params['middle_name'] is mostly or always also available (audit required)
billing_last_name $params['billing_last_name] $propertyBag->has('lastName') && $propertyBag->getLastName() $params['last_name'] is mostly or always also available (audit required)
address_name-{$billingLocationID} $params["address_name-$billingLocationID}"] ?? ''
billing_street_address-{$billingLocationID} $params["billing_street_address-$billingLocationID}"] ?? '' $propertyBag->has('billingStreetAddress') && $propertyBag->getBillingStreetAddress()
billing_supplemental_address-{$billingLocationID} $params["billing_supplemental_address-$billingLocationID}"] ?? '' $propertyBag->has('billingSupplementalAddress1') && $propertyBag->getBillingSupplementalAddress1()
billing_supplemental_address2-{$billingLocationID} $params["billing_supplemental_address2-$billingLocationID}"] ?? '' $propertyBag->has('billingSupplementalAddress2') && $propertyBag->getBillingSupplementalAddress2()
billing_supplemental_address3-{$billingLocationID} $params["billing_supplemental_address3-$billingLocationID}"] ?? '' $propertyBag->has('billingSupplementalAddress3') && $propertyBag->getBillingSupplementalAddress3()
billing_city-{$billingLocationID} $params["billing_city-$billingLocationID}"] ?? '' $propertyBag->has('billingCity') && $propertyBag-getBillingCity()
billing_postal_code-{$billingLocationID} $params["billing_postal_code-$billingLocationID}"] ?? '' $propertyBag->has('billingPostalCode') && $propertyBag-getBillingPostalCode()
billing_country-{$billingLocationID} $params["billing_country-$billingLocationID}"] ?? '' $propertyBag->has('billingCountry') && $propertyBag-getBillingCountry()
billing_state_province_id-{$billingLocationID} $params["billing_state_province_id-{$billingLocationID}"] ?? ''
billing_county-{$billingLocationID} $params["billing_county-$billingLocationID}"] ?? '' $propertyBag->has('billingCounty') && $propertyBag-getBillingCounty()
phone $params['phone'] ?? '' $propertyBag->has('phone') && $propertyBag-getPhone() This is in the property bag but it's not clear which forms, if any, pass it in.

Payment fields

The available payment fields are processor dependent. The processor declares them in the function getPaymentFormFields() which directs the form builder to add them to the form. The metadata is declared with the function getPaymentFormFieldsMetadata(). This function is overrideable but the parent class (CRM_Core_Payment) declares the most common and best supported fields. If you take no action to define the fields to show core will make some assumptions based on 'billing mode' and only present billing fields if the billing_mode is not 4 (transfer offsite). It will choose between credit and debit card related fields based on the payment_instrument_id.

key ways to access / helpers propertyBag access notes
credit_card_number $params['credit_card_number]
cvv2 $params['cvv2']
credit_card_exp_date $params['credit_card_exp_date'] This is an array like ['M' => 5, 'Y' => 2020]
credit_card_type $params['credit_card_type']
bank_account_number $params['bank_account_number'] For debit payments.
bank_account_holder $params['bank_account_holder'] For debit payments.
bank_identification_number $params['bank_identification_number'] For debit payments.
bank_name $params['bank_name'] For debit payments.

Recurring contribution parameters

key ways to access / helpers propertyBag access notes
is_recur $propertyBag->getIsRecur() Is this a recurring payment
contributionRecurID $params['contributionRecurID] ?? NULL $propertyBag->has('contributionRecurID') &&$propertyBag->getContributionRecurID()` id for the recurring contribution record, if available
installments $params['installments'] ?? ''
frequency_unit $params['frequency_unit'] ?? NULL $propertyBag->has('frequencyUnit') && $propertyBag->recurFrequencyUnit()
subscriptionId $params['subscriptionId'] ?? NULL $propertyBag->has('recurProcessorID') && $propertyBag->recurProcessorID() The processor_id field in the civicrm_contribution_recur table - this is the value the external processor uses as its reference.

Introducing PropertyBag objects

As noted above the params array can be confusing to work with. In 5.24 the option was introduced to cast it to a more prescriptive, typed way to pass in parameters by using a Civi\Payment\PropertyBag object instead of an array.

This object has getters and setters that enforce standardised property names and a certain level of validation and type casting. For example, the property contactID (note capitalisation) has getContactID() and setContactID().

For backwards compatibility, this class implements ArrayAccess which means if old code does $propertyBag['contact_id'] = '123' or $propertyBag['contactID'] = 123 it will translate this to the new contactID property and use that setter which will ensure that accessing the property returns the integer value 123. When this happens deprecation messages are emitted to the log file and displayed if your site is configured to show deprecation notices.

Checking for existence of a property

Calling a getter for a property that has not been set will throw a BadMethodCall exception.

Code can require certain properties by calling $propertyBag->require(['contactID', 'contributionID']) which will throw an InvalidArgumentException if any property is missing. These calls should go at the top of your methods so that it's clear to a developer.

You can check whether a property has been set using $propertyBag->has('contactID') which will return TRUE or FALSE.

Multiple values, e.g. changing amounts

All the getters and setters take an optional extra parameter called $label. This can be used to store two (or more) different versions of a property, e.g. 'old' and 'new'

<?php
use Civi\Payment\PropertyBag;
//...
$propertyBag = new PropertyBag();
$propertyBag->setAmount(1.23, 'old');
$propertyBag->setAmount(2.46, 'new');
//...
$propertyBag->getAmount('old'); // 1.23
$propertyBag->getAmount('new'); // 2.46
$propertyBag->getAmount(); // throws BadMethodCall

This means the value is still validated and type-cast as an amount (in this example).

Custom payment processor-specific data

Warning

This is currently holding back a fuller adoption of PropertyBag.

Sometimes a payment processor will require custom data. e.g. A company called Stripe offers payment processing gateway services with its own API which requires some extra parameters called paymentMethodID and paymentIntentID - these are what that particular 3rd party requires and separate to anything in CiviCRM (CiviCRM also uses the concept of "payment methods" and these have IDs, but here we're talking about something Stripe needs).

In order for us to be able to implement the doPayment() method for Stripe, we'll need data for these custom, bespoke-to-the-third-party parameters passing in, via the PropertyBag.

So that any custom, non-CiviCRM data is handled unambiguously, these property names should be prefixed, e.g. stripe_paymentMethodID and set using PropertyBag->setCustomProperty($prop, $value, $label = 'default').

The payment class is responsible for validating such data; anything is allowed by setCustomProperty, including NULL.

However, payment classes are rarely responsible for passing data in, this responsibility is for core and custom implementations. Core's contribution forms and other UIs need a way to take the data POSTed by the forms, and arrange it into a standard format for payment classes. They will also have to pass the rest of the data to the payment class so that the payment class can extract and validate anything that is bespoke to that payment processor; i.e. only Stripe is going to know to expect a paymentMethodID in this data because this does not exist for other processors. As of 5.24, this does not exist and data is still passed into a PropertyBag as an array, which means that unrecognised keys will be added as custom properties, but emit a deprecation warning in your logs.

Best practice right now would be to:

  • use PropertyBag getters for the data you want, whether that's a core field or use getCustomProperty for anything else.

  • where your processor requires adding in custom data to the form, prefix it with your extension's name to avoid ambiguity with core fields. e.g. your forms might use a field called myprocessor_weirdCustomToken and you would access this via $propertyBag->getCustomProperty('myprocessor_weirdCustomToken).

Testing Processor Plugins

Automated tests

You should write unit tests for your payment processors.

The way we bring payment processors under CI is by converting them to use guzzle for http requests (this also makes CURL preferred but optional at the server level). Guzzle has support for using mockHandlers to simulate http requests and responses without actually making them - so once we have set up guzzle testing we can supply an expected response and not rely on an outside service. We can also test the contents of the outgoing test - this gives us quick additional cover. There are several examples of this in core - eg. https://github.com/civicrm/civicrm-core/pull/18350/files#diff-bc4fe6e2e8a92ae3d6254fd13210d41e1da563f4c804bed510f4dbcec45d237c

The steps to convert and set up a test are basically:

  1. create the test shell
  2. switch the code from curl to guzzle
  3. switch to live credentials and comment out the mock handler. Use a breakpoint to capture the response from the provider
  4. re-enable the handler, run again - it should fail because we are comparing 'placeholder' with the actual request now
  5. use the failure from the actual request to put into the getExpectedSinglePaymentRequest() function

1. Create the test shell The initial test shell is pretty boilerplate. The guzzle helper class is available to extensions. The key when trying to capture the response is to comment out the line

$this->createMockHandler([$response]);

2. Switch the code from curl to guzzle Actually pretty straight forward. Some variable for different expectations by the processor - e.g content type of xml used in Authorize.net

At this stage our code looks pretty much like the first of the 2 commits in this PR

3. Capture the response Basically key processor credentials into the test so the request works in a test context, and then grab that string and plug it into the tests - these screenshots are from stepping through the PaypalImpl class in phpstorm

Screen Shot showing code getting response from processor site

Screen Shot showing example response

Alternatively you can call

$this->getResponseBodies();
from your test once it has run through

Screen Shot showing testing code

4. Run the test & capture the requests that went out These will be in the test fail or $this->container in your test class. To view them from $this->container you should cast to a string or use the helper $this->getRequestBodies()[0]

Manual testing

Here are some suggestions of what you might test as part of your user acceptance testing.

Important

Don't forget that you need to search specifically for TEST transactions

i.e. from this page civicrm/contribute/search&reset=1 chose "find test transactions".

Standard Payment processor tests

  1. Can process Successful transaction from

    • Event
    • Contribute Form
    • Individual Contact Record (for on-site processors only)

    Transaction should show as confirmed in CiviCRM and on the payment processor

  2. Can include , . & = ' " in address and name fields without problems. Overlong ZIP code is handled.

  3. Can process a failed transaction from a Contribute form.

    Can fix up details & resubmit for a successful transaction.

    e-mail address is successfully passed through to payment processor and payment processor sends e-mails if configured to do so.

    The invoice ID is processed and maintained in an adequate manner.

  4. Any result references and transaction codes are stored in an adequate manner.

Recurring Payment Processor tests

  1. Process a recurring contribution. Check

    • wording on confirm page is acceptable
    • wording on thankyou pages is acceptable
    • wording on any confirmation e-mails is acceptable
    • the payment processor shows the original transaction is successful
    • the payment processor shows the correct date for the next transaction
    • the payment processor shows the correct total number of transactions and / or the correct final date
  2. Try processing different types of frequencies. Preferably test a monthly contribution on the last day of a month where there isn't a similar day in the following month (e.g. 30 January).

  3. Process a transaction without filling in the total number of transactions (there should be no end date set).

  4. Process a recurring contribution with the total instalments set to 1 (it should be treated as a one-off rather than a recurring transaction). It should not show 'recurring contribution' when you search for it in CiviCRM.

  5. Depending on your processor it may be important to identify the transactions that need to be updated or checked. You may wish to check what it being recorded in the civicrm_contribution_recur table for payment processor id, end date and next transaction date.

Specific Live tests

  1. Successful and unsuccessful REAL transactions work.

  2. Money vests into the bank account.

  3. For recurring transactions wait for the first recurrent transaction to vest.

Cookbooks

Populate Help Text on the Payment Processor Administrator Screen

To populate the blue help icons for the settings fields needed for your payment processor at Administer -> System Settings -> Payment Processors follow the steps below:

  1. Add a template file to your extension with a !#twig {htxt id='$ppTypeName-live-$fieldname'} section for each settings field you are using.

    Example:

    The help text for the user-name field for a payment processor with the name 'AuthNet' would be implemented with code like this:

    twig{htxt id='AuthNet-live-user-name'}{ts}Generate your API Login and Transaction Key by logging in to your Merchant Account and navigating to <strong>Settings &raquo; General Security Settings</strong>.{/ts}</p>{/htxt}

    see core /templates/CRM/Admin/Page/PaymentProcessor.hlp for further examples.

  2. Add that template to the CRM_Admin_Form_PaymentProcessor form using a buildForm hook like so:

    if ($formName == 'CRM_Admin_Form_PaymentProcessor') {
        $templatePath = realpath(dirname(__FILE__) . "/templates");
        CRM_Core_Region::instance('form-buttons')->add(array(
          'template' => "{$templatePath}/{TEMPLATE FILE NAME}.tpl",
        ));
      }