A while ago, I wrote a blog demonstrating how one could integrate PayPal express checkout in Drupal, but...; in tech, things move(change) fast, mostly for better:
that approach had some limitations like; handling credit cards, the user checkout experience was not as 'great', the PayPal SDK library we were extending has been abandoned (deprecated) for a better one (which was recently deprecated but no other alternative, so here we're ).
In this blog we will attempt to achieve the same and make things better.
Code for this blog is available on github.
And a live example available @https://flaircore.com, you're welcome(invited) to test with real money, which will be spent on your behalf.
In an age of smart phones, fridges, rings, contracts..., we will implement smart payment buttons, not because smart is trendy, but because of the user experience and the added security around it.
Smart buttons can be used to initiate, authorize and capture an order, but in this blog, we will only initiate and authorize, and add the necessary logic in Drupal to process (capture) the authorized order (payment) details in the request object.
All this will be done without leaving the page the button is on.
### Prerequisites:
- Working instance of Drupal where dependencies are managed via composer.
- Basic understanding of Drupal module structure.
### A look into the module structure (paypal_example):
![paypal_example_module_file_structure](/sites/default/files/inline-images/paypal_example_module_file_structure.png)
Below, we will go through the contents of each file and it's purpose in this project (it's a prerequisite to at least be familiar with Drupal module structure/development), starting with the base files and later with the sub folders.
### paypal_example.info.yml
This files defines the module and makes it discoverable by Drupal to install, it also contains the supported core versions and a 'configure' key with a value of 'paypal_example.paypal_settings_form' which is a route id defined in the routing.yml (more to that later). This renders a configure link below this module description on the modules' listing page.
![configure module link](/sites/default/files/inline-images/configure_module_option.png)
### paypal_example.libraries.yml
Defines the javascipt file(s) and their dependencies that will enable us to implement the smart buttons on the client side.
### paypal_example.module
Contains a hook_theme() implementation, registering the `templates\paypal-example.html.twig` file for use by
any part of Drupal that wants to render that template.
### paypal_example.routing.yml
Defines two routes, one to render the config form (src\Form\PayPalPaymentsConfigForm.php) and the other to render the page we will be testing the paypal functionality as defined in the src\Controller\PayPalController.php file, and finally
### paypal_example.services.yml
Registers the src\PayPalClient.php class definition as a Drupal service for use by any Drupal module that wants to.
We will now go through the contents of each file below before moving on with how the each relate with the subfolders and their files.
# paypal_example.info.yml name: 'Paypal example' type: module description: 'Paypal payment example for Drupal' package: 'Payment' core_version_requirement: ^8.8.0 || ^9 || ^10 configure: paypal_example.paypal_settings_form # End of file. #paypal_example.libraries.yml # https://github.com/paypal/paypal-js#legacy-browser-support paypal_cdn: version: VERSION header: true js: //unpkg.com/@paypal/paypal-js@5.1.1/dist/iife/paypal-js.min.js: { type: external, minified: true, attributes: { } #attributes: { defer: true, async: true} # window.paypalLoadScript is not a function error, see # https://github.com/paypal/paypal-js; should be managed # via npm as setting attributes to defer and async # causes the error above sometimes. } paypal: js: js/paypal_example.js: {attributes: { defer: true, async: true}} dependencies: - core/jquery - core/drupalSettings - paypal_example/paypal_cdn # End of file. <?php /** * @file * Contains paypal_example.module. */ use Drupal\Core\Routing\RouteMatchInterface; /** * Implements hook_help(). */ function paypal_example_help($route_name, RouteMatchInterface $route_match) { switch ($route_name) { // Main module help for the paypal_example module. case 'help.page.paypal_example': $output = ''; $output .= '<h3>' . t('About') . '</h3>'; $output .= '<p>' . t('Paypal example checkout') . '</p>'; return $output; default: } } /** * Implements hook_theme(). */ function paypal_example_theme() { return [ 'paypal_example' => [ 'render element' => 'children', 'template' => 'paypal-example', 'variables' => [ 'data' => NULL, ] ] ]; } # paypal_example.routing.yml paypal_example.paypal_settings_form: path: '/admin/config/paypal_example/settings' defaults: _form: '\Drupal\paypal_example\Form\PayPalPaymentsConfigForm' _title: 'Your PayPal payments settings' requirements: _permission: 'administer paypal_payments' paypal_example.payment_example: path: '/flair-core/paypal_payment' defaults: _controller: '\Drupal\paypal_example\Controller\PayPalController::pay' _title: 'pay with Paypal' requirements: _permission: 'access content' # End of file. # paypal_example.services.yml services: paypal_example.paypal_client: class: Drupal\paypal_example\PayPalClient arguments: [] calls: - [setConfig, ['@config.factory']] - [setEntity, ['@entity_type.manager'] ] # End of file.
### Recap so far.
- In the services.yml file, the service id `paypal_example.paypal_client` is well isolated and re-usable, it relies on the V2 php SDK we are extending, which will also make it easier to swap between newer sdks in the future if need be.
- In the libraries.yml, the library id `paypal_cdn` which is required by the library id `paypal` below it as a dependency, defines paypal.js with legacy browser support to make sure the buttons' functionality is consistent across browsers. Even though the recommended way is to use a package manager with paypal.js, I made that compromise to keep the scope of this blog relevant to the topic, omitting some important attributes (paypal_cdn lib definitions) to make sure that library is loaded before the library that depends on it is.
### Subfolder section:
For the sub folders and 'files', we will start with those that interact with Drupal 'internal' apis and finish up the once that interact with the user facing apis (Render/Controller).
Before proceeding to implement the `paypal_example.paypal_client` service definitions (services.yml file) let's install the paypal sdk it relies on by running `composer require paypal/paypal-checkout-sdk` inside the Drupal root directory.
### src\PayPalClient.php
This file relies on 3 main variants;
- `paypal_example.settings` configuration entity id, updatable via the `PayPalPaymentsConfigForm` definitions.
- `PayPalCheckoutSdk\Core\PayPalHttpClient` which is part of the php package we installed earlier and can be independently swappable as you wish.
- `paypal_payment_example` entity id as defined in the `src\Entity\PayPalPaymentExample.php` file
This class contains two public methods we can use with our class instance anywhere, these are;
- getConfigs(), which returns values of `paypal_example.settings` configuration entity as an array.
- captureOrder(), takes two arguments; $order_id and $sku, the order_id is the token PayPay gives an authorized order, once a client has gone through their api authentification, and is passed to the request, while sku is any unique string to identify the product item associated with this order. This method also creates a new entry of `paypal_payment_example` entity content with the values from the captured order.
### templates\paypal-example.html.twig
Defines the content markup (see hook_theme() in .module file) and contains the amount input field and an empty html dom to anchor the PayPal smart buttons defined in the file `js\paypal_example.js`
### js\paypal_example.js
This file depends on `<module-id>/paypal_cdn` defined in the .libraries.yml file, and defines a way to create an order with the data passed from the Controller as `drupalSettings.paypal_payment_data` and the amount input field value, it also redirects an approved order to the current page, with the relevant url params as defined in the onAprrove call back in the `paypal.Buttons({})` object.
Below is the contents of each of these three files in details.
<?php # PayPalClient.php namespace Drupal\paypal_example; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use PayPalCheckoutSdk\Core\PayPalHttpClient; use PayPalCheckoutSdk\Core\SandboxEnvironment; use PayPalCheckoutSdk\Core\ProductionEnvironment; use PayPalCheckoutSdk\Orders\OrdersCaptureRequest; use PayPalHttp\HttpException; class PayPalClient { /** * @var \Drupal\Core\Config\ConfigFactoryInterface */ protected $configFactory; /** @var \Drupal\Core\Entity\EntityTypeManagerInterface */ protected $entityTypeManager; public function setConfig(ConfigFactoryInterface $configFactory){ $this->configFactory = $configFactory; } public function setEntity(EntityTypeManagerInterface $entityTypeManager){ $this->entityTypeManager = $entityTypeManager; } public function getConfigs(){ $config = $this->configFactory->getEditable('paypal_example.settings'); $client_id = $config->get('client_id'); $client_secret = $config->get('client_secret'); $environment = $config->get('environment'); $store_currency = $config->get('currency'); $payment_title = $config->get('payment_title'); return [ 'client_id' => $client_id, 'client_secret' => $client_secret, 'environment' => $environment, 'currency' => $store_currency, 'payment_title' => $payment_title, ]; } /** * Returns PayPal HTTP client instance with environment that has access * credentials context. Use this instance to invoke PayPal APIs, provided the * credentials have access. */ public function client() { return new PayPalHttpClient($this->environment()); } /** * Set up and return PayPal PHP SDK environment with PayPal access credentials. * This sample uses SandboxEnvironment. In production, use LiveEnvironment. */ protected function environment() { // $config = $this->getConfigs(); $clientId = getenv("PP_CLIENT_ID") ?: $config['client_id']; $clientSecret = getenv("PP_CLIENT_SECRET") ?: $config['client_secret']; if ($config['environment'] === 'sandbox') { return new SandboxEnvironment($clientId, $clientSecret); } else return new ProductionEnvironment($clientId, $clientSecret); } /** * @param $order_id * */ /** * @param $order_id * the APPROVED-ORDER-ID * @param $sku * The product sku * * @return array * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException * @throws \Drupal\Core\Entity\EntityStorageException * @throws \PayPalHttp\IOException */ public function captureOrder($order_id, $sku){ $request = new OrdersCaptureRequest($order_id); $request->prefer('return=representation'); try { // Call API with your client and get a response for your call $response = $this->client()->execute($request); //$status_code = $response->statusCode; $status = $response->result->status; $id = $response->result->id; $email_address = $response->result->payer->email_address; //$intent = $response->result->intent; $currency_code = $response->result->purchase_units[0]->amount->currency_code; $payments_id = $response->result->purchase_units[0]->payments->captures[0]->id; $amount = $response->result->purchase_units[0]->amount->value; $values = [ 'payer_email' => $email_address, 'amount' => $amount, 'transaction_id' => $payments_id, 'sale_id' => $id, 'payment_status' => $status, 'invoice_id' => $id, 'sku' => $sku, ]; $entity = $this->entityTypeManager->getStorage('paypal_payment_example'); $entity->create($values)->save(); # TODO:: add event emitter above \Drupal::messenger()->addMessage(t("Transaction completed for $amount $currency_code .")); return $values; }catch (HttpException $ex) { \Drupal::messenger()->addError('Issue completing the transaction : '.$ex->getMessage()); return ['error' => $ex->getMessage()]; } } } # End of file {# paypal-example.html.twig #} {% if data.client_id %} <div class="content"> <div style="padding-bottom: 1em; font-weight: 600"> {{ data.title }} </div> <div id="paypal-example" class="paypal-example" style="padding-bottom: 1em;"> <label for="amount">Amount in {{ data.currency }} : </label> <input type="number" id="amount" name="amount" min="5" value="{{ data.amount }}"> </div> <div id="paypal-button-container"></div> </div> {% else %} <div> <h2> Please configure your app with the relevant info provided by paypal.</h2> <span> Message : {{ data.info }} .</span> </div> {% endif %} {# End of file #} // paypal_example.js (function ($, Drupal, drupalSettings) { const data = drupalSettings.paypal_payment_data if (!data.client_id) { console.warn("Some information is missing!!") return } $(document).ready(function () { console.log(data) console.log(data) console.log(data) window.paypalLoadScript({ "client-id": data.client_id }).then((paypal) => { const amountInput = document.querySelector('#paypal-example input') const uniqueId = data.title + ((Math.random() * Math.pow(36, 6)) | 0).toString(36) paypal.Buttons({ // Set up the transaction createOrder: function(dt, actions) { // This function sets up the details of the transaction, including the amount and line item details. data.amount = amountInput.value if (!data.amount) { console.error('Please enter an amount') return } return actions.order.create({ intent: 'CAPTURE', purchase_units: [{ reference_id: uniqueId, custom_id: uniqueId, amount: { value: data.amount, currency_code: data.currency, breakdown: { item_total: { currency_code: data.currency, value: data.amount } } }, items: [ { name: data.title, description: data.title, sku: uniqueId, unit_amount: { currency_code: data.currency, value: data.amount }, quantity: 1 }, ] }] }); }, onInit: function (dt, actions) { // Btns initialized }, onApprove: function(dt, actions) { amountInput.value = data.amount window.location = `?&order_sku=${uniqueId}&order_id=${dt.orderID }` } }).render('#paypal-button-container'); }); }) })(jQuery, Drupal, drupalSettings) // End of file
### Finally the last three files to complete this module.
### src/Entity/PayPalPaymentExample.php
This file contains the `paypal_payment_example` database table definitions, as well as getters and setters to use with any instance of this Class (Object).
### src/Form/PayPalPaymentsConfigForm.php
Contains the markup (Form field definitions) and renders on the route id `paypal_example.paypal_settings_form` that's defined in the .routing.yml file. The form writes to `paypal_example.settings` config entity with the new values everytime it's saved.
### src/Controller/PayPalController.php
It's in this file that, almost everything we've worked on, so far, is wired to work with each other, and complete this payment's feature implementation. This file defines the content to render in the `paypal_example.payment_example` route id, defined in the .routing.yml file. This include libraries too (js/css). The `pay` method serves this route and supplies the $data array values to our template file and js file via the drupalSettings. Also the onApprove method, in the smart button implementation (js\paypal_example.js) redirects to this route, supplying some url params accordingly. This also handles the payment capture(charge) and save to database, once the order is authorized and the right values are available in the url params.
### Below is the code that goes into each of these three files.
<?php # start of PayPalPaymentExample.php namespace Drupal\paypal_example\Entity; use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; /** * Defines PayPalPaymentExample entity. * * @ingroup paypal_payment_example * * @ContentEntityType( * id = "paypal_payment_example", * label = @Translation("PayPalPaymentExample"), * base_table = "paypal_payment_example", * entity_keys = { * "id" = "id", * "uuid" = "uuid", * }, * ) */ class PayPalPaymentExample extends ContentEntityBase implements ContentEntityInterface { /** * {@inheritdoc} */ public function getCreatedTime() { return $this->get('created')->value; } public function getPayerEmail() { return $this->get('payer_email')->value; } public function setPayerEmail($payer_email) { $this->set('payer_email', $payer_email); return $this; } public function getAmount() { return $this->get('amount')->value; } public function setAmount($amount) { $this->set('amount', $amount); return $this; } public function getTransactionId() { return $this->get('transaction_id')->value; } public function setTransactionId($transaction_id) { $this->set('transaction_id', $transaction_id); return $this; } public function getSaleId() { return $this->get('sale_id')->value; } public function setSaleId($sale_id) { $this->set('sale_id', $sale_id); return $this; } public function getInvoiceId() { return $this->get('invoice_id')->value; } public function setInvoiceId($invoice_id) { $this->set('invoice_id', $invoice_id); return $this; } public function getPaymentStatus() { return $this->get('payment_status')->value; } public function setPaymentStatus($payment_status) { $this->set('payment_status', $payment_status); return $this; } public function getSku() { return $this->get('sku')->value; } public function setSku($sku) { $this->set('sku', $sku); return $this; } /** * Determines the schema for slack_settings entity table */ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { // Standard field, used as unique if primary index. $fields['id'] = BaseFieldDefinition::create('integer') ->setLabel(t('ID')) ->setDescription(t('The ID of the content entity.')) ->setReadOnly(TRUE); // Standard field, unique outside of the scope of the current project. $fields['uuid'] = BaseFieldDefinition::create('uuid') ->setLabel(t('UUID')) ->setDescription(t('The UUID of the content entity.')) ->setReadOnly(TRUE); $fields['payer_email'] = BaseFieldDefinition::create('string') ->setLabel(t('Paypal email')) ->setDescription(t('The Paypal email address')); $fields['amount'] = BaseFieldDefinition::create('float') ->setLabel(t('Amount')) ->setDescription(t('The Amount in Store currency')); $fields['transaction_id'] = BaseFieldDefinition::create('string') ->setLabel(t('Transaction id')) ->setSettings(array( 'max_length' => 60, )) ->setDescription(t('The unique transaction id generated by paypal')); $fields['sale_id'] = BaseFieldDefinition::create('string') ->setLabel(t('Sale ID')) ->setSettings(array( 'max_length' => 60, )) ->setDescription(t('Stores the sale id in-case of a refund')); $fields['invoice_id'] = BaseFieldDefinition::create('string') ->setLabel(t('Invoice ID')) ->setSettings(array( 'max_length' => 60, )) ->setDescription(t('The invoice number of the payment')); $fields['payment_status'] = BaseFieldDefinition::create('string') ->setLabel(t('Payment Status')) ->setSettings(array( 'default_value' => '', 'max_length' => 15, )) ->setDescription(t('Status either a Success or a Refund')); $fields['sku'] = BaseFieldDefinition::create('string') ->setLabel(t('SKU')) ->setSettings(array( 'max_length' => 60, )) ->setDescription(t('The product SKU')); $fields['created'] = BaseFieldDefinition::create('created') ->setLabel(t('Created')) ->setDescription(t('The time that the entity was created.')); return $fields; } } # End of file <?php # start of PayPalPaymentsConfigForm.php namespace Drupal\paypal_example\Form; use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; /** * Class PayPalPaymentsSettingsForm. * * Store the paypal credentials required to make the api calls */ class PayPalPaymentsConfigForm extends ConfigFormBase { /** * Gets the configuration names that will be editable. * * @return array * An array of configuration object names that are editable if called in * conjunction with the trait's config() method. */ protected function getEditableConfigNames() { return ['paypal_example.settings']; } /** * {@inheritdoc} */ public function getFormId() { return 'paypal_example_settings_form'; } /** * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state) { $config = $this->config('paypal_example.settings'); $environmentTypes = [ 'live' => 'Live', 'sandbox' => 'Sandbox', ]; $currency = [ 'USD' => 'USD', 'GBP' => 'GBP', 'AUD' => 'AUD', 'CAD' => 'CAD', 'EUR' => 'EUR', 'JPY' => 'JPY' ]; $form['client_id'] = [ '#type' => 'textfield', '#title' => $this->t('Client ID'), '#description' => $this->t('The Client ID from PayPal, you can put any value here if you have set PP_CLIENT_ID in your environment variables'), '#default_value' => $config->get('client_id'), '#maxlength' => 128, '#size' => 64, '#required' => TRUE, ]; $form['client_secret'] = [ '#type' => 'textfield', '#title' => $this->t('Client Secret'), '#description' => $this->t('The Client Secret Key From PayPal, (You can put any value here, if you have set PP_CLIENT_ID in your env variables.)'), '#default_value' => $config->get('client_secret'), '#maxlength' => 128, '#size' => 64, '#required' => TRUE, ]; $form['environment'] = [ '#type' => 'select', '#title' => $this->t('Environment'), '#options' => $environmentTypes, '#description' => $this->t('Select either; live or sandbox(for development)'), '#default_value' => $config->get('environment'), '#required' => TRUE, '#multiple' => FALSE, ]; $form['currency'] = [ '#type' => 'select', '#title' => $this->t('Store Currency'), '#options' => $currency, '#description' => $this->t('Select the currency to use with your store'), '#default_value' => $config->get('currency'), '#required' => TRUE, ]; $form['payment_title'] = [ '#type' => 'textfield', '#title' => $this->t('Payment Title'), '#description' => $this->t('The title to associate with this payment'), '#placeholder' => 'Example Payment', '#default_value' => $config->get('payment_title'), '#maxlength' => 180, '#size' => 120, '#required' => TRUE, ]; $form['paypal_instructions'] = [ '#type' => 'markup', '#markup' => $this->paypalDocumentation(), ]; $form['submit'] = [ '#type' => 'submit', '#value' => $this->t('Save'), ]; return $form; } /** * {@inheritdoc} */ public function validateForm(array &$form, FormStateInterface $form_state) { parent::validateForm($form, $form_state); } /** * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { $env = $form_state->getValue('environment'); $config = $this->config('paypal_example.settings'); $config ->set('currency', $form_state->getValue('currency')) ->set('environment',$env) ->set('client_secret', $form_state->getValue('client_secret')) ->set('client_id', $form_state->getValue('client_id')) ->set('payment_title', $form_state->getValue('payment_title')) ->save(); drupal_flush_all_caches(); parent::submitForm($form, $form_state); } private function paypalDocumentation() { return ' <div> <p> <strong>Getting started. </strong></p> <p> * After logging in at: <a target="_blank" href="https://www.paypal.com/">Paypal.com</a> , got to <a target="_blank" href="https://developer.paypal.com/developer/accountStatus/">Developer.paypal.com </a> </p> <p> * Under <strong>Dashboard > My Apps & Credentials </strong>, click on Create App button, give it a name, select a sandbox Business Account to use while testing, and confirm create. </p> <p> * Note the <strong>Client ID</strong>, and the <strong>Secret</strong> as those are required in the above inputs. </p> </div> <br> '; } } # End of file <?php # start of PayPalController.php namespace Drupal\paypal_example\Controller; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\paypal_example\PayPalClient; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\DependencyInjection\ContainerInterface; class PayPalController extends ControllerBase { /** * @var \Drupal\Core\Config\ConfigFactoryInterface */ protected $configFactory; /** @var \Drupal\paypal_example\PayPalClient */ protected $paypalClient; public function __construct(ConfigFactoryInterface $configFactory, PayPalClient $paypalClient) { $this->configFactory = $configFactory; $this->paypalClient = $paypalClient; } public static function create(ContainerInterface $container) { return new static( $container->get('config.factory'), $container->get('paypal_example.paypal_client'), ); } public function pay(Request $request) { $config = $this->paypalClient->getConfigs(); $currency = $config['currency']; $client_id = $config['client_id']; $payment_title = $config['payment_title']; $order_id = $request->get('order_id'); $order_sku = $request->get('order_sku'); if ($order_sku && $order_id) { $capture_order = $this->paypalClient->captureOrder($order_id, $order_sku); if (isset($capture_order['payment_status'])) { // Do something } (new RedirectResponse('/flair-core/paypal_payment'))->send(); } $info = !$client_id ? 'Missing paypal app details.' : null; $data = [ 'title' => $payment_title, 'info' => $info, 'client_id' => $client_id, 'currency' => $currency, 'amount' => 10 ]; return [ '#theme' => 'paypal_example', '#cache' => [ 'max-age' => 0 ], '#attached' => [ 'library' => 'paypal_example/paypal', 'drupalSettings' => [ 'paypal_payment_data' => $data ] ], // The items twig variable as an array as defined paypal_example_theme(). '#data' => $data, ]; } } # End of file.
### The 'Test Drive!'
We have put some time and effort into this, so it's time to give it a test (be an end user).
- Lets enable the module by navigating to the module listing page, checking on the Paypal example module from the modules list, and clicking install.
- After the module is installed, a Configure link is available below it's description (still on the module listing page), click on that to go the paypal configuration form we created, another way to access the form would be going to <your-site-homepage-url>/admin/config/paypal_example/settings url.
Paypal example configuration form - On a new tab, login to https://www.paypal.com/ and then got to https://developer.paypal.com/developer/accountStatus/ and under Dashboard > My Apps & Credentials, click on Create App,
note the Client ID and Secret, as these are required in the configuration form. A sandbox user account to use with this app will be auto created for use (where test funds will be sent to/business owner), you will also be required to create a sandbox account(s) to test with as the buyer. A sandbox account lets you create a fake user (email, password and some bucks in that account) to test payments (only in sandbox environment).
- Input the configuration form with the right info(details) and click save.
- Head to <your-site-homepage-url>/flair-core/paypal_payment url. (remember the route id `paypal_example.payment_example` defined in the .routing.yml)
Payment btn From here, you can use one of the sandbox accounts you created earlier, as a buyer to test, and later upgrade to a live (real business account) account to collect funds as it fits your business/shop.
### CONCLUSION:
From here, some improvements would include;
- Managing our javascript dependencies via a package manager, to achieve the performance associated with that, and avoid running into issues, when `paypal_example/paypal` is loaded before the `paypal_example/paypal_cdn` is.
- We have payment entries/records in the database, a way to display that should be among the next things towards making this example module complete.
- The php library we updated to, is also recently abandoned, but no other alternative is provided by PayPal, for this, all the logic that interacts with their api has been separated into it's own class (src\PayPalClient.php) so it's easier to change to any other wrapper/SDK in the future.
### RESOURCES: