From 835a531f13e34eda9b9606e1a61e639af2bc6aee Mon Sep 17 00:00:00 2001 From: ayduns <aidan.saunders@squiffle.uk> Date: Wed, 2 Nov 2022 14:52:23 +0000 Subject: [PATCH] Add Stripe.membershipcheck api --- api/v3/Stripe/Membershipcheck.php | 209 ++++++++++++++++++++++++++++++ docs/api.md | 1 + 2 files changed, 210 insertions(+) create mode 100644 api/v3/Stripe/Membershipcheck.php diff --git a/api/v3/Stripe/Membershipcheck.php b/api/v3/Stripe/Membershipcheck.php new file mode 100644 index 00000000..11e206d7 --- /dev/null +++ b/api/v3/Stripe/Membershipcheck.php @@ -0,0 +1,209 @@ +<?php +/* + +--------------------------------------------------------------------+ + | Copyright CiviCRM LLC. All rights reserved. | + | | + | This work is published under the GNU AGPLv3 license with some | + | permitted exceptions and without any warranty. For full license | + | and copyright information, see https://civicrm.org/licensing | + +--------------------------------------------------------------------+ + */ + +use CRM_Stripe_ExtensionUtil as E; + +/** + * This api checks autorenewing Memberships + * + * It reports problems but does not change any data in CiviCRM or Stripe + */ + +/** + * Stripe.Membershipcheck API specification + * + * @param array $spec description of fields supported by this API call + */ +function _civicrm_api3_stripe_membershipcheck_spec(&$spec) { + $spec['ppid']['title'] = E::ts('Use the given Payment Processor ID'); + $spec['ppid']['type'] = CRM_Utils_Type::T_INT; + $spec['ppid']['api.required'] = TRUE; + $spec['membership_id']['title'] = E::ts('Restrict to this membership id'); + $spec['membership_id']['type'] = CRM_Utils_Type::T_INT; +} + +/** + * Stripe.Membershipcheck API + * + * Note: The Stripe API can either bulk retrieve subscriptions up to 100 in a batch + * or get individual subscription. Would be nice to speed this up + * with a bulk retrieve but our starting is Civi Memberships + * + * @param $params + * + * @return array + * @throws \CiviCRM_API3_Exception + */ +function civicrm_api3_stripe_membershipcheck($params) { + $limit = isset($params['options']['limit']) ? $params['options']['limit'] : 50; + $offset = isset($params['options']['offset']) ? $params['options']['offset'] : 0; + + // Initialise return + $return = ['stats' => ['Total' => 0, 'OK' => 0]]; + + // Get the payment processor and activate the Stripe API + /** @var \CRM_Core_Payment_Stripe $paymentProcessor */ + $paymentProcessor = \Civi\Payment\System::singleton()->getById($params['ppid']); + + // Get our memberships + $memberships = \Civi\Api4\Membership::get() + ->addWhere('contribution_recur_id', 'IS NOT EMPTY') + ->addWhere('contribution_recur_id.payment_processor_id', '=', $params['ppid']) + ->addWhere('is_test', '=', FALSE) + ->addSelect('id', 'contact_id', 'membership_type_id', 'membership_type_id:name', 'status_id:name', 'contribution_recur_id', + 'contribution_recur_id.id', 'contribution_recur_id.contact_id', 'contribution_recur_id.trxn_id', + 'contribution_recur_id.contribution_status_id:name', 'contribution_recur_id.create_date', 'contribution_recur_id.auto_renew') + ->setLimit($limit) + ->setOffset($offset); + + if ($params['membership_id']) { + $memberships = $memberships->addWhere('id', '=', $params['membership_id']); + } + $memberships = $memberships->execute()->indexBy('id'); + + // Check each membership is consistent + // Some of these may be redundant, but we're looking for oddities + foreach ($memberships as $id => $membership) { + + $info = $membership; + $msgs = []; + + // Check recur contact_id matches membership contact_id + if ($membership['contact_id'] != $membership['contribution_recur_id.contact_id']) { + $msgs[] = 'Recur contact_id does not match Membership contact_id '; + } + + // Check recur id's match + if ($membership['contribution_recur_id'] != $membership['contribution_recur_id.id']) { + $msgs[] = 'Recur id on Membership does not match fetched Recur'; + } + + // Look up StripeCustomer record + $cus = civicrm_api3('StripeCustomer', 'get', [ + 'sequential' => 1, + 'contact_id' => $membership['contact_id'], + 'processor_id' => $params['ppid'], + ]); + + $customer_id = NULL; + if ($cus['count'] == 0) { + $msgs[] = 'StripeCustomer has no records for Membership contact_id'; + } + elseif ($cus['count'] > 1) { + $msgs[] = 'StripeCustomer has multiple customer_ids for Membership contact_id'; + foreach ($cus['values'] as $c) { + $info['customer_ids'][] = $c['id']; + } + } + else { + $customer_id = $cus['values'][0]['id']; + } + $info['customer_id'] = $customer_id; + + // Check stripe subscription id exists + $trxn_id = $membership['contribution_recur_id.trxn_id']; + if (!$trxn_id) { + $msgs[] = 'Recur missing Subscription id in trxn_id'; + } + else { + $stripe_subscription = NULL; + try { + $stripe_subscription = $paymentProcessor->stripeClient->subscriptions->retrieve($trxn_id); + } + catch (Exception $e) { + if ($membership['contribution_recur_id.contribution_status_id:name'] != 'Cancelled') { + $msgs[] = 'Subscription lookup failed (recur not cancelled)'; + $info['Subcription lookup error'] = $e->getMessage(); + } + } + + // Check subscription exists + if ($stripe_subscription) { + $info['subscription_customer_id'] = $stripe_subscription->customer; + $info['subscription_status'] = $stripe_subscription->status; + + // Check customer ids match + if ($stripe_subscription->customer != $customer_id) { + $msgs[] = 'Subscription customer does not match Civi StripeCustomer customer_id'; + + // Check the StripeCustomer table again to see if the customer_id is known with another contact_id + $cus = civicrm_api3('StripeCustomer', 'get', [ + 'sequential' => 1, + 'id' => $stripe_subscription->customer, + 'processor_id' => $params['ppid'], + ]); + if ($cus['count'] == 0) { + $msgs[] = 'No Civi StripeCustomer for the Subscription customer_id'; + } + else { + $otherid = $info['contact_id_of_customer_in_stripecustomer'] = $cus['values'][0]['contact_id']; + + // Gather more info about other contact + $contacts = \Civi\Api4\Contact::get() + ->addSelect('is_deleted', 'is_deceased', 'activity.subject') + ->addJoin('Activity AS activity', 'LEFT', 'ActivityContact') + ->addWhere('id', '=', $otherid) + ->addWhere('activity.activity_type_id', '=', 72) + ->execute(); + foreach ($contacts as $contact) { + $info['Subscription customer info'][] = (array) $contact; + } + } + + } + + // Compare the status of Subscription & Recur + $stripe_status = $stripe_subscription->status; + $recur_status = $membership['contribution_recur_id.contribution_status_id:name']; + switch ($stripe_status) { + case 'active': + if ($recur_status != 'In Progress') { + $msgs[] = 'Subscription is active but Recur is not'; + } + break; + + case 'canceled': + // Note spelling difference + if ($recur_status != 'Cancelled') { + $msgs[] = 'Subscription canceled but Recur is not'; + } + break; + + // Might want to break out more status combinations. For now, flag everything else + default: + $msgs[] = 'Check status of Subscription vs Recur'; + } + + // @todo: might want to compare amounts, contribution history etc + } + } + + // If we have messages, updates the stats for each and save info for return + // otherwise, just update the stats but don't clutter the output with info + if ($msgs) { + foreach ($msgs as $msg) { + if (!isset($return['stats'][$msg])) { + $return['stats'][$msg] = 0; + } + $return['stats'][$msg]++; + } + $info['messages'] = $msgs; + $return['info'][$id] = $info; + } + else { + $return['stats']['OK']++; + } + $return['stats']['Total']++; + + } + + return civicrm_api3_create_success($return, $params, 'Stripe', 'membershipcheck'); +} diff --git a/docs/api.md b/docs/api.md index e53567eb..e225b571 100644 --- a/docs/api.md +++ b/docs/api.md @@ -134,6 +134,7 @@ When importing subscriptions, all invoice/charges assigned to the subscription w * `StripeCustomer.delete` - Delete a customer by passing either civicrm contact id or stripe customer id. * `StripeCustomer.updatecontactids` - Used to migrate civicrm_stripe_customer table to match on contact_id instead of email address. * `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). +* `StripeCustomer.membershipcheck` - Used to look for potential problems and inconsistencies between Stripe and CiviCRM. Does not make any changes. ## StripePaymentintents -- GitLab