From fb97c0d9426cc5037244353b7f8ab4c8ccfc3ce3 Mon Sep 17 00:00:00 2001
From: Ruben <r.pineda@ixiam.com>
Date: Tue, 14 Apr 2020 17:57:52 +0200
Subject: [PATCH] Add api to import subscribers and customers

---
 CRM/Stripe/Api.php                       |   6 +-
 api/v3/Stripe/Importallcustomers.php     |  53 +++++
 api/v3/Stripe/Importallsubscriptions.php |  53 +++++
 api/v3/Stripe/Importcustomers.php        | 255 +++++++++++++++++++++++
 api/v3/Stripe/Importsubscriptions.php    | 128 ++++++++++++
 api/v3/StripeSubscription.php            |   8 +-
 docs/api.md                              |  26 +++
 docs/release/release_notes.md            |   5 +
 8 files changed, 526 insertions(+), 8 deletions(-)
 create mode 100644 api/v3/Stripe/Importallcustomers.php
 create mode 100644 api/v3/Stripe/Importallsubscriptions.php
 create mode 100644 api/v3/Stripe/Importcustomers.php
 create mode 100644 api/v3/Stripe/Importsubscriptions.php

diff --git a/CRM/Stripe/Api.php b/CRM/Stripe/Api.php
index 55137eba..df7abf29 100644
--- a/CRM/Stripe/Api.php
+++ b/CRM/Stripe/Api.php
@@ -126,10 +126,12 @@ class CRM_Stripe_Api {
             switch ($stripeObject->status) {
               case \Stripe\Subscription::STATUS_ACTIVE:
                 return CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'In Progress');
-
               case \Stripe\Subscription::STATUS_CANCELED:
                 return CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Cancelled');
-
+              case \Stripe\Subscription::STATUS_PAST_DUE:
+                return CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Overdue');
+              default:
+                return NULL;
             }
 
           case 'customer_id':
diff --git a/api/v3/Stripe/Importallcustomers.php b/api/v3/Stripe/Importallcustomers.php
new file mode 100644
index 00000000..698dfc8b
--- /dev/null
+++ b/api/v3/Stripe/Importallcustomers.php
@@ -0,0 +1,53 @@
+<?php
+
+use CRM_Stripe_ExtensionUtil as E;
+
+/**
+ * Stripe.Importallcustomers
+ *
+ * @param array $spec description of fields supported by this API call
+ *
+ * @return void
+ */
+function _civicrm_api3_stripe_importallcustomers_spec(&$spec) {
+  $spec['ppid']['title'] = ts("Use the given Payment Processor ID");
+  $spec['ppid']['type'] = CRM_Utils_Type::T_INT;
+  $spec['ppid']['api.required'] = TRUE;
+}
+
+/**
+ * Stripe.Importallcustomers API
+ *
+ * @param array $params
+ * @return void
+ */
+function civicrm_api3_stripe_importallcustomers($params) {
+  $result = civicrm_api3('Stripe', 'importcustomers', [
+    'ppid' => $params['ppid']
+  ]);
+
+  $imported = $result['values'];
+
+  while ($imported['continue_after']) {
+    $starting_after = $imported['continue_after'];
+
+    $result = civicrm_api3('Stripe', 'importcustomers', [
+      'ppid' => $params['ppid'],
+      'starting_after' => $starting_after,
+    ]);
+
+    $additional = $result['values'];
+
+    $imported['imported'] = array_merge($imported['imported'], $additional['imported']);
+    $imported['skipped'] = array_merge($imported['skipped'], $additional['skipped']);
+    $imported['errors'] = array_merge($imported['errors'], $additional['errors']);
+
+    if ($additional['continue_after']) {
+      $imported['continue_after'] = $additional['continue_after'];
+    } else {
+      unset($imported['continue_after']);
+    }
+  }
+
+  return civicrm_api3_create_success($imported);
+}
\ No newline at end of file
diff --git a/api/v3/Stripe/Importallsubscriptions.php b/api/v3/Stripe/Importallsubscriptions.php
new file mode 100644
index 00000000..90e3dda7
--- /dev/null
+++ b/api/v3/Stripe/Importallsubscriptions.php
@@ -0,0 +1,53 @@
+<?php
+
+use CRM_Stripe_ExtensionUtil as E;
+
+/**
+ * Stripe.Importallsubscriptions
+ *
+ * @param array $spec description of fields supported by this API call
+ *
+ * @return void
+ */
+function _civicrm_api3_stripe_importallsubscriptions_spec(&$spec) {
+  $spec['ppid']['title'] = ts("Use the given Payment Processor ID");
+  $spec['ppid']['type'] = CRM_Utils_Type::T_INT;
+  $spec['ppid']['api.required'] = TRUE;
+}
+
+/**
+ * Stripe.Importallsubscriptions API
+ *
+ * @param array $params
+ * @return void
+ */
+function civicrm_api3_stripe_importallsubscriptions($params) {
+  $result = civicrm_api3('Stripe', 'importsubscriptions', [
+    'ppid' => $params['ppid']
+  ]);
+
+  $imported = $result['values'];
+
+  while ($imported['continue_after']) {
+    $starting_after = $imported['continue_after'];
+
+    $result = civicrm_api3('Stripe', 'importsubscriptions', [
+      'ppid' => $params['ppid'],
+      'starting_after' => $starting_after,
+    ]);
+
+    $additional = $result['values'];
+
+    $imported['imported'] = array_merge($imported['imported'], $additional['imported']);
+    $imported['skipped'] = array_merge($imported['skipped'], $additional['skipped']);
+    $imported['errors'] = array_merge($imported['errors'], $additional['errors']);
+
+    if ($additional['continue_after']) {
+      $imported['continue_after'] = $additional['continue_after'];
+    } else {
+      unset($imported['continue_after']);
+    }
+  }
+
+  return civicrm_api3_create_success($imported);
+}
\ No newline at end of file
diff --git a/api/v3/Stripe/Importcustomers.php b/api/v3/Stripe/Importcustomers.php
new file mode 100644
index 00000000..43805322
--- /dev/null
+++ b/api/v3/Stripe/Importcustomers.php
@@ -0,0 +1,255 @@
+<?php
+
+use CRM_Stripe_ExtensionUtil as E;
+
+/**
+ * Stripe.Importcustomers
+ *
+ * @param array $spec description of fields supported by this API call
+ *
+ * @return void
+ */
+function _civicrm_api3_stripe_importcustomers_spec(&$spec) {
+  $spec['ppid']['title'] = ts("Use the given Payment Processor ID");
+  $spec['ppid']['type'] = CRM_Utils_Type::T_INT;
+  $spec['ppid']['api.required'] = TRUE;
+  $spec['limit']['title'] = ts("Limit number of Customers/Subscriptions to be imported");
+  $spec['limit']['type'] = CRM_Utils_Type::T_INT;
+  $spec['limit']['api.required'] = FALSE;
+  $spec['starting_after']['title'] = ts('Start importing customers after this one');
+  $spec['starting_after']['type'] = CRM_Utils_Type::T_STRING;
+  $spec['starting_after']['api.required'] = FALSE;
+  $spec['customer']['title'] = ts('Import a specific customer');
+  $spec['customer']['type'] = CRM_Utils_Type::T_STRING;
+  $spec['customer']['api.required'] = FALSE;
+}
+
+/**
+ * Stripe.Importcustomers API
+ *
+ * @param array $params
+ * @return void
+ */
+function civicrm_api3_stripe_importcustomers($params) {
+  $ppid = $params['ppid'];
+  $limit = isset($params['limit']) ? $params['limit'] : 100;
+  $starting_after = $params['starting_after'];
+
+  // Get the payment processor and activate the Stripe API
+  $payment_processor = civicrm_api3('PaymentProcessor', 'getsingle', ['id' => $ppid]);
+  $processor = new CRM_Core_Payment_Stripe('', $payment_processor);
+  $processor->setAPIParams();
+
+  // Prepare an array to collect the results
+  $results = [
+    'imported' => [],
+    'skipped' => [],
+    'errors' => [],
+    'continue_after' => NULL
+  ];
+
+  // Get customers from Stripe
+  $args = ["limit" => $limit];
+  if ($starting_after) {
+    $args['starting_after'] = $starting_after;
+  }
+
+  if ($params['customer']) {
+    $customer = \Stripe\Customer::retrieve($params['customer']);
+    $customers_stripe_clean = [$customer];
+    $customer_ids = [$customer->id];
+  }
+  else {
+    $customers_stripe = \Stripe\Customer::all($args);
+
+    // Exit if there aren't records to process
+    if (!count($customers_stripe->data)) {
+      return civicrm_api3_create_success($results);
+    }
+
+    // Search the customers in CiviCRM
+    $customer_ids = array_map(
+      function ($customer) { return $customer->id; },
+      $customers_stripe->data
+    );
+    $customers_stripe_clean = $customers_stripe->data;
+  }
+
+  $escaped_customer_ids = CRM_Utils_Type::escapeAll($customer_ids, 'String');
+  $filter_item = array_map(
+    function ($customer_id) { return "'$customer_id'"; },
+    $escaped_customer_ids
+  );
+
+  if (count($filter_item)) {
+    $select = "SELECT sc.*
+    FROM civicrm_stripe_customers AS sc
+    WHERE
+      sc.id IN (" . join(', ', $filter_item) . ") AND
+      sc.contact_id IS NOT NULL";
+    $dao = CRM_Core_DAO::executeQuery($select);
+    $customers_in_civicrm = $dao->fetchAll();
+    $customer_ids = array_map(
+      function ($customer) { return $customer['id']; },
+      $customers_in_civicrm
+    );
+  } else {
+    $customers_in_civicrm = $customer_ids = [];
+  }
+
+  foreach ($customers_stripe_clean as $customer) {
+    $results['continue_after'] = $customer->id;
+    $contact_id = NULL;
+
+    // Return if contact was found
+    if (array_search($customer->id, $customer_ids) !== FALSE) {
+      $customer_in_civicrm = array_filter($customers_in_civicrm,
+        function ($record) use($customer) { return $record['id'] == $customer->id; }
+      );
+      $results['skipped'][] = [
+        'contact_id' => end($customer_in_civicrm)['contact_id'],
+        'email' => $customer->email,
+        'stripe_id' => $customer->id,
+      ];
+      continue;
+    }
+
+    // Search contact by email
+    if ($customer->email || $customer->name) {
+
+      $re = '/^([^\s]*)\s(.*)$/m';
+      preg_match_all($re, $customer->name, $matches, PREG_SET_ORDER, 0);
+      $first_name = isset($matches[0][1]) ? $matches[0][1] : "-";
+      $last_name = isset($matches[0][2]) ? $matches[0][2] : "-";
+
+        // Case to create customer without email
+      if(!$customer->email) {
+        $contact_ids = [];
+      }
+      else {
+        $email_result = civicrm_api3('Email', 'get', [
+          'sequential' => 1,
+          'email' => $customer->email,
+        ]);
+
+        // List of contact ids using this email address
+        $contacts_by_email = array_map(
+          function ($found_email) { return $found_email['contact_id']; },
+          $email_result['values']
+        );
+
+        if (count($contacts_by_email)) {
+          // Only consider non deleted records of individuals
+          $undeleted_contacts = civicrm_api3('Contact', 'get', [
+            'return' => [ 'id' ],
+            'id' => [ 'IN' => $contacts_by_email ],
+            'is_deleted' => FALSE,
+            'contact_type' => 'Individual',
+          ]);
+
+          $contact_ids = array_unique(
+            array_values(
+              array_map(
+                function ($found_contact) { return $found_contact['id']; },
+                $undeleted_contacts['values']
+              )
+            )
+          );
+        } else {
+          $contact_ids = [];
+        }
+
+        $data = [
+          'email' => $customer->email,
+          'stripe_id' => $customer->id,
+        ];
+
+        if (property_exists($customer, 'name')) {
+          $data['name'] = $customer->name;
+        }
+      }
+
+      if (count($contact_ids) == 0) {
+        // Create the new contact record
+        $params_create_contact = [
+          'sequential' => 1,
+          'contact_type' => 'Individual',
+          'source' => 'Stripe > ' . $customer->description,
+          'first_name' => $first_name,
+          'last_name' => $last_name,
+        ];
+
+        if ($customer->email) {
+          $params_create_contact['email'] = $customer->email;
+        }
+
+        $contact = civicrm_api3('Contact', 'create', $params_create_contact);
+        $contact_id = $contact['id'];
+        // Report the contact creation
+        $tag = 'imported';
+        $data['contact_id'] = $contact_id;
+      }
+      else if (count($contact_ids) == 1) {
+        $contact_id = end($contact_ids);
+
+        // Report the contact as found by email
+        $tag = 'skipped';
+        $data['contact_id'] = $contact_id;
+      }
+      else {
+        $contact_id = end($contact_ids);
+
+        // Report the contact as duplicated
+        $tag = 'errors';
+        $data['warning'] = E::ts("Number of contact records " .
+          "with this email is greater than 1. Contact id: $contact_id " .
+          "will be used");
+        $data['contact_ids'] = $contact_ids;
+      }
+    }
+
+    $results[$tag][] = $data;
+
+    // Try to create the Stripe customer record
+    if ($contact_id != NULL && $contact_id > 0) {
+      // Keep running if it already existed
+      try {
+        CRM_Stripe_Customer::add(
+          [
+          'contact_id' => $contact_id,
+          'id' => $customer->id,
+          'processor_id' => $ppid
+          ]
+        );
+      } catch(Exception $e) {
+      }
+
+      // Update the record's 'is live' descriptor and its email
+      $is_live = ($payment_processor["is_test"] == 1) ? 0 : 1;
+
+      if ($customer->email) {
+        $queryParams = [
+          1 => [$customer->email, 'String'],
+          2 => [$customer->id, 'String'],
+          3 => [$is_live, 'Integer'],
+          4 => [$contact_id, 'Integer'],
+        ];
+        CRM_Core_DAO::executeQuery("UPDATE civicrm_stripe_customers
+          SET is_live = %3, email = %1, contact_id = %4
+          WHERE id = %2", $queryParams);
+      }
+      else {
+        $queryParams = [
+          1 => [$customer->id, 'String'],
+          2 => [$is_live, 'Integer'],
+          3 => [$contact_id, 'Integer'],
+        ];
+        CRM_Core_DAO::executeQuery("UPDATE civicrm_stripe_customers
+          SET is_live = %2, contact_id = %3
+          WHERE id = %1", $queryParams);
+      }
+    }
+  }
+
+  return civicrm_api3_create_success($results);
+}
diff --git a/api/v3/Stripe/Importsubscriptions.php b/api/v3/Stripe/Importsubscriptions.php
new file mode 100644
index 00000000..d2038a41
--- /dev/null
+++ b/api/v3/Stripe/Importsubscriptions.php
@@ -0,0 +1,128 @@
+<?php
+
+use CRM_Stripe_ExtensionUtil as E;
+
+/**
+ * Stripe.Importsubscriptions
+ *
+ * @param array $spec description of fields supported by this API call
+ *
+ * @return void
+ */
+function _civicrm_api3_stripe_importsubscriptions_spec(&$spec) {
+  $spec['ppid']['title'] = ts('Use the given Payment Processor ID');
+  $spec['ppid']['type'] = CRM_Utils_Type::T_INT;
+  $spec['ppid']['api.required'] = TRUE;
+  $spec['limit']['title'] = ts('Limit number of Customers/Subscriptions to be imported');
+  $spec['limit']['type'] = CRM_Utils_Type::T_INT;
+  $spec['limit']['api.required'] = FALSE;
+  $spec['starting_after']['title'] = ts('Start importing subscriptions after this one');
+  $spec['starting_after']['type'] = CRM_Utils_Type::T_STRING;
+  $spec['starting_after']['api.required'] = FALSE;
+}
+
+/**
+ * Stripe.Importsubscriptions API
+ *
+ * @param array $params
+ * @return void
+ */
+function civicrm_api3_stripe_importsubscriptions($params) {
+  $ppid = $params['ppid'];
+  $limit = isset($params['limit']) ? $params['limit'] : 100;
+  $starting_after = $params['starting_after'];
+
+  // Get the payment processor and activate the Stripe API
+  $payment_processor = civicrm_api3('PaymentProcessor', 'getsingle', ['id' => $ppid]);
+  $processor = new CRM_Core_Payment_Stripe('', $payment_processor);
+  $processor->setAPIParams();
+
+  // Get the subscriptions from Stripe
+  $args = [
+    'limit' => $limit,
+    'status' => 'all',
+  ];
+  if ($starting_after) {
+    $args['starting_after'] = $starting_after;
+  }
+  $stripe_subscriptions = \Stripe\Subscription::all($args);
+
+  $stripe_subscription_ids = array_map(
+    function ($subscription) { return $subscription->id; },
+    $stripe_subscriptions->data
+  );
+
+  // Prepare to collect the results
+  $results = [
+    'imported' => [],
+    'skipped' => [],
+    'errors' => [],
+    'continue_after' => NULL
+  ];
+
+  // Exit if there aren't records to process
+  if (!count($stripe_subscription_ids)) {
+    return civicrm_api3_create_success($results);
+  }
+
+  // Get subscriptions generated in CiviCRM
+  $recurring_contributions = civicrm_api3('ContributionRecur', 'get', [
+    'sequential' => 1,
+    'options' => [ 'limit' => 0 ],
+    'payment_processor_id' => $ppid,
+    'trxn_id' => [ 'IN' => $stripe_subscription_ids ]
+  ]);
+
+  $subscritpions_civicrm = [];
+  if (is_array($recurring_contributions['values'])) {
+    foreach ($recurring_contributions['values'] as $recurring_contribution) {
+      $trxn_id = $recurring_contribution['trxn_id'];
+      $subscritpions_civicrm[$trxn_id] = [
+        'contact_id' => $recurring_contribution['contact_id'],
+        'recur_id' => $recurring_contribution['id'],
+        'stripe_id' => $trxn_id,
+      ];
+    }
+  }
+
+  foreach ($stripe_subscriptions as $stripe_subscription) {
+    $results['continue_after'] = $stripe_subscription->id;
+
+    $new_subscription = [
+      'is_test' => $payment_processor['is_test'],
+      'payment_processor_id' => $ppid,
+      'subscription_id' => $stripe_subscription->id,
+    ];
+
+    // Check if the subscription exists in CiviCRM
+    if (isset($subscritpions_civicrm[$stripe_subscription->id])) {
+      $results['skipped'][] = $subscritpions_civicrm[$stripe_subscription->id];
+      $new_subscription['recur_id'] = $subscritpions_civicrm[$stripe_subscription->id]['recur_id'];
+    }
+
+    // Search the Stripe customer to get the contact id
+    $customer_civicrm = civicrm_api3('StripeCustomer', 'get', [
+      'sequential' => 1,
+      'id' => $stripe_subscription->customer,
+    ]);
+    if (isset($customer_civicrm['values'][0]['contact_id'])) {
+      $new_subscription['contact_id'] = $customer_civicrm['values'][0]['contact_id'];
+    }
+
+    // Return the record with error if the contact wasn't found
+    if (!isset($new_subscription['contact_id']) || (! $new_subscription['contact_id'])) {
+      $new_subscription['error'] = 'Customer not found';
+      $new_subscription['stripe_customer'] = $stripe_subscription->customer;
+      $results['errors'][] = $new_subscription;
+      continue;
+    }
+
+    // Create the subscription
+    $created_subscription = civicrm_api3('StripeSubscription', 'import', $new_subscription);
+    $new_subscription['recur_id'] = $created_subscription['values']['recur_id'];
+    $results['imported'][] = $new_subscription;
+  }
+
+  return civicrm_api3_create_success($results);
+}
+
diff --git a/api/v3/StripeSubscription.php b/api/v3/StripeSubscription.php
index 9c0b288d..ee093b55 100644
--- a/api/v3/StripeSubscription.php
+++ b/api/v3/StripeSubscription.php
@@ -166,9 +166,6 @@ function civicrm_api3_stripe_subscription_import($params) {
   ];
 
   $customer = civicrm_api3('StripeCustomer', 'get', $customerParams);
-  if (empty($customer['count'])) {
-    civicrm_api3('StripeCustomer', 'create', $customerParams);
-  }
 
   // Create the recur record in CiviCRM
   $contributionRecurParams = [
@@ -198,7 +195,7 @@ function civicrm_api3_stripe_subscription_import($params) {
   // Get the invoices for the subscription
   $invoiceParams = [
     'customer' => CRM_Stripe_Api::getObjectParam('customer_id', $stripeSubscription),
-    'limit' => 10,
+    'limit' => 100,
   ];
   $stripeInvoices = \Stripe\Invoice::all($invoiceParams);
   foreach ($stripeInvoices->data as $stripeInvoice) {
@@ -238,9 +235,8 @@ function civicrm_api3_stripe_subscription_import($params) {
       elseif ($params['contribution_id']) {
         $contributionParams['id'] = $params['contribution_id'];
       }
-
       $contribution = civicrm_api3('Contribution', 'create', $contributionParams);
-      break;
+
     }
 
   }
diff --git a/docs/api.md b/docs/api.md
index f64af2c3..80b3556e 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -1,4 +1,5 @@
 # API
+
 This extension comes with several APIs to help you troubleshoot problems. These can be run via /civicrm/api or via drush if you are using Drupal (drush cvapi Stripe.XXX).
 
 The api commands are:
@@ -27,6 +28,7 @@ The api commands are:
 * `StripeCustomer.updatestripemetadata` - Used to update stripe customers that were created using an older version of the extension (adds name to description and contact_id as a metadata field).
 
 ## StripeSubscription
+
 * `StripeSubscription.updatetransactionids` - Used to migrate civicrm_stripe_subscriptions to use recurring contributions directly.
 * `StripeSubscription.copytrxnidtoprocessorid` - Used to copy trxn_id to processor_id in civicrm_contribution_recur table so we can use cancelSubscription. Hopefully this won't be needed in future versions of CiviCRM if we can pass more sensible values to the cancelSubscription function.
 * `StripeSubscription.import` - Use to import subscriptions into CiviCRM that are in Stripe but not CiviCRM.
@@ -46,3 +48,27 @@ It's not advised that you use this API for anything else.
   Parameters:
   * delete_old: Delete old records from database. Specify 0 to disable. Default is "-3 month"
   * cancel_incomplete: Cancel incomplete paymentIntents in your stripe account. Specify 0 to disable. Default is "-1 hour"
+
+## Stripe Import
+
+This API import all customers and later the subcriptions from this method(and the contributions associated).
+
+* `Stripe.importsubscriptions`
+  * Parameters:
+    * `limit`: Numer of elements to import, customers/subscriptors.
+    * `ppid` : Use the given Payment Processor ID.
+    * `starting_after` : Not required, begin after the last subscriber.
+  * Example
+    * `drush cvapi Stripe.importsubscriptions ppid=6 limit=1 starting_after=sub_DDRzfRsxxxx`
+  * Doc
+    * [Stripe doc](https://stripe.com/docs/api/subscriptions/list)
+
+* `Stripe.importcustomers`
+  * Parameters:
+    * `limit`: Numer of elements to import, customers/subscriptors.
+    * `ppid` : Use the given Payment Processor ID.
+    * `starting_after` : Not required, begin after the last customer.
+  * Example
+    * `drush cvapi Stripe.importcustomers ppid=6 limit=1 starting_after=cus_GiMd3xxxx`
+  * Doc
+    * [Stripe doc](https://stripe.com/docs/api/customers/list)
diff --git a/docs/release/release_notes.md b/docs/release/release_notes.md
index 3f62d286..c216ee8d 100644
--- a/docs/release/release_notes.md
+++ b/docs/release/release_notes.md
@@ -50,6 +50,7 @@ Where:
 * Use minifier extension to minify js/css assets (much easier for development as we don't ship minified files anymore).
 
 ## Release 6.3.2 - Security Release
+
 If you are using Stripe on public forms (without authentication) it is **strongly** recommended that you upgrade and consider installing the new **firewall** extension.
 
 Increasingly spammers are finding CiviCRM sites and spamming the linked Stripe account with 1000s of attempted payments
@@ -268,17 +269,21 @@ There are no database changes in this release but you should update your Stripe
 * Use the parameter on the recurring contribution to decide whether to send out email receipts.
 
 ## Release 5.2
+
 *This release introduces a number of new features, standardises the behaviour of recurring contributions/memberships to match standard CiviCRM functionality and does a major cleanup of the backend code to improve stability and allow for new features.*
 
 ### Highlights:
+
 * Support Cancel Subscription from CiviCRM and from Stripe.
 
 ### Breaking changes:
+
 * The extension now uses the standard CiviCRM Contribution.completetransaction and Contribution.repeattransaction API to handle creation/update of recurring contributions. This means that automatic membership renewal etc. is handled in the standard CiviCRM way instead of using custom code in the Stripe extension. The behaviour *should* be the same but some edge-cases may be fixed while others may appear. Any bugs in this area will now need to be fixed in CiviCRM core - if you want to help with that see https://github.com/civicrm/civicrm-core/pull/11556.
 * When recurring contributions were updated by Stripe, they were marked cancelled and a new one created in CiviCRM. This was non-standard behaviour and causes issues with CiviCRM core functionality for membership renewal etc. This has now been changed so only one recurring contribution per subscription will ever exist, which will be updated as necessary during it's lifecycle.
 * Different payment amounts are now supported for each contribution in a recurring contribution. Previously they were explicitly rejected by the extension.
 
 ### Changes:
+
 * Add http response codes for webhook (invalid parameters now returns 400 Bad Request).
 * Major refactor of webhook / events handling (fixes multiple issues, now tested and working on Joomla / Wordpress / Drupal 7).
 * Update to latest version of stripe-php library.
-- 
GitLab