From 12678ad7bace225d4094208761f49739866665e5 Mon Sep 17 00:00:00 2001 From: Aaron Elliot Ross Date: Thu, 10 Feb 2022 15:27:27 +0100 Subject: [PATCH 1/4] Handle payments for Stripe Subscriptions --- .github/workflows/main.yml | 15 +- .gitignore | 3 +- CRM/WeAct/Action/Donation.php | 27 +- CRM/WeAct/Action/Proca.php | 44 ++- CRM/WeAct/ActionProcessor.php | 68 ++-- CRM/WeAct/Contact.php | 64 +++- CRM/WeAct/Page/Stripe.php | 331 ++++++++++++++++++ CRM/WeAct/Settings.php | 2 +- composer.json | 5 + info.xml | 6 +- tests/phpunit/CRM/WeAct/Action/ProcaTest.php | 39 ++- .../phpunit/CRM/WeAct/ActionProcessorTest.php | 7 +- tests/phpunit/CRM/WeAct/BaseTest.php | 2 +- tests/phpunit/CRM/WeAct/Page/StripeTest.php | 242 +++++++++++++ tests/phpunit/events/charge-refunded.json | 94 +++++ tests/phpunit/events/customer.json | 43 +++ tests/phpunit/events/subscription.json | 93 +++++ we_act.php | 2 + xml/Menu/we_act.xml | 7 + 19 files changed, 997 insertions(+), 97 deletions(-) create mode 100644 CRM/WeAct/Page/Stripe.php create mode 100644 composer.json create mode 100644 tests/phpunit/CRM/WeAct/Page/StripeTest.php create mode 100644 tests/phpunit/events/charge-refunded.json create mode 100644 tests/phpunit/events/customer.json create mode 100644 tests/phpunit/events/subscription.json diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1f98ea9..27e6dc4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,13 +2,14 @@ name: Run tests +env: + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + # Controls when the action will run. on: # Triggers the workflow on push or pull request events but only for the master branch push: - branches: - - master - - stripe-endpoint + branches: [ master ] pull_request: branches: [ master ] @@ -21,6 +22,7 @@ jobs: build: # The type of runner that the job will run on runs-on: ubuntu-18.04 + environment: testing # Steps represent a sequence of tasks that will be executed as part of the job steps: @@ -62,7 +64,12 @@ jobs: with: path: drupal/sites/all/modules/civicrm/ext/we-act + - name: Run composer + run: composer install + working-directory: drupal/sites/all/modules/civicrm/ext/we-act + - name: Run unit tests - run: phpunit + run: STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }} phpunit working-directory: drupal/sites/all/modules/civicrm/ext/we-act + diff --git a/.gitignore b/.gitignore index 5310b0f..6cc9287 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ Session.vim *~ # auto-generated tag files tags - +vendor +composer.lock diff --git a/CRM/WeAct/Action/Donation.php b/CRM/WeAct/Action/Donation.php index a2608a3..3e8cb87 100644 --- a/CRM/WeAct/Action/Donation.php +++ b/CRM/WeAct/Action/Donation.php @@ -48,7 +48,7 @@ public function createContribRecur($campaign_id, $contact_id, $action_page, $loc if (substr($this->processor, -5) == '-sepa') { $create_mandate = $this->createMandate($contact_id, 'RCUR', $campaign_id, $action_page); //Mandates don't have utm fields, so associate them to recurring contrib created along with mandate - civicrm_api3('ContributionRecur', 'create', [ + return civicrm_api3('ContributionRecur', 'create', [ 'id' => $create_mandate['values'][0]['entity_id'], $this->settings->customFields['recur_utm_source'] => CRM_Utils_Array::value('source', $utm), $this->settings->customFields['recur_utm_medium'] => CRM_Utils_Array::value('medium', $utm), @@ -57,19 +57,6 @@ public function createContribRecur($campaign_id, $contact_id, $action_page, $loc } else { $processor_id = $this->settings->paymentProcessorIds[$this->processor]; - if ($this->processor == 'proca-stripe') { - //Stripe webhook requires a link customer<->contact to process events, so we create it here if needed - $customer_params = [ - 'customer_id' => $this->providerDonorId, - 'contact_id' => $contact_id, - 'processor_id' => $processor_id - ]; - $customer = civicrm_api3('StripeCustomer', 'get', $customer_params); - if ($customer['count'] == 0) { - civicrm_api3('StripeCustomer', 'create', $customer_params); - } - } - $params = [ 'sequential' => 1, 'contact_id' => $contact_id, @@ -91,7 +78,7 @@ public function createContribRecur($campaign_id, $contact_id, $action_page, $loc $this->settings->customFields['recur_utm_campaign'] => CRM_Utils_Array::value('campaign', $utm), ]; $create_recur = civicrm_api3('ContributionRecur', 'create', $params); - $this->createContrib($campaign_id, $contact_id, $action_page, $location, $utm, $create_recur['id']); + return $this->createContrib($campaign_id, $contact_id, $action_page, $location, $utm, $create_recur['id']); } } @@ -100,7 +87,7 @@ public function createContrib($campaign_id, $contact_id, $action_page, $location if (substr($this->processor, -5) == '-sepa') { $create_mandate = $this->createMandate($contact_id, 'OOFF', $campaign_id, $action_page); //Mandates don't have utm fields, so associate them to recurring contrib created along with mandate - civicrm_api3('Contribution', 'create', [ + $created = civicrm_api3('Contribution', 'create', [ 'id' => $create_mandate['values'][0]['entity_id'], $this->settings->customFields['utm_source'] => CRM_Utils_Array::value('source', $utm), $this->settings->customFields['utm_medium'] => CRM_Utils_Array::value('medium', $utm), @@ -130,15 +117,19 @@ public function createContrib($campaign_id, $contact_id, $action_page, $location ]; if ($recurring_id) { $params['contribution_recur_id'] = $recurring_id; - //The utm params will be set by a hook in contributm extension, let's not mess with it + //The utm params will be set by a hook in contributm extension, let's + //not mess with it + $created = NULL; } else { $params[$this->settings->customFields['utm_source']] = CRM_Utils_Array::value('source', $utm); $params[$this->settings->customFields['utm_medium']] = CRM_Utils_Array::value('medium', $utm); $params[$this->settings->customFields['utm_campaign']] = CRM_Utils_Array::value('campaign', $utm); } - civicrm_api3('Contribution', 'create', $params); + $created = civicrm_api3('Contribution', 'create', $params); } + + return $created; } protected function createMandate($contact_id, $mandate_type, $campaign_id, $source) { diff --git a/CRM/WeAct/Action/Proca.php b/CRM/WeAct/Action/Proca.php index fdbc3d1..2c1f770 100644 --- a/CRM/WeAct/Action/Proca.php +++ b/CRM/WeAct/Action/Proca.php @@ -8,8 +8,9 @@ public function __construct($json_msg) { $this->createdAt = $json_msg->action->createdAt; $this->actionPageId = $json_msg->actionPageId; $this->actionPageName = $json_msg->actionPage->name; - $this->language = $this->determineLanguage($json_msg->actionPage->locale); $this->contact = $this->buildContact(json_decode($json_msg->contact->payload)); + $this->language = $this->contact->determineLanguage($json_msg->actionPage->locale); + $this->details = $this->buildDonation($json_msg->actionId, $json_msg->action); $this->locationId = @$json_msg->action->fields->speakoutCampaign; @@ -36,8 +37,26 @@ protected function buildContact($json_contact) { return $contact; } + protected function _lookupCharge($pi) { + $sk = CRM_Core_DAO::singleValueQuery( + "SELECT password FROM civicrm_payment_processor WHERE id = 1" // I know, but it works + ); + if (!$sk) { + $sk = getenv("STRIPE_SECRET_KEY"); + } + if (!$sk) { + throw new Exception("Oops, couldn't find a secret key for Stripe. Can't go on!"); + } + $stripe = new \Stripe\StripeClient($sk); + $charges = $stripe->charges->all(['payment_intent' => $pi->id]); + if (! $charges->data) { + throw new Exception("Couldn't find a Charge for PaymentIntent: {$pi->id}"); + } + return $charges->data[0]; + } + protected function buildDonation($action_id, $json_action) { - $statusMap = ['succeeded' => 'Completed', 'failed' => 'Failed']; + // $statusMap = ['succeeded' => 'Completed', 'failed' => 'Failed']; $frequencyMap = ['one_off' => 'one-off', 'monthly' => 'month', 'weekly' => 'week', 'daily' => 'day']; $donation = new CRM_WeAct_Action_Donation(); @@ -60,12 +79,18 @@ protected function buildDonation($action_id, $json_action) { } else if ($provider == 'stripe') { $donation->paymentMethod = $json_action->donation->payload->paymentConfirm->payment_method_types[0]; $donation->isTest = !$json_action->donation->payload->paymentIntent->response->livemode; + if ($_ENV['CIVICRM_UF'] == 'UnitTests') { + $charge_id = property_exists($json_action->donation->payload, 'testingChargeId') + ? $json_action->donation->payload->testingChargeId + : 'ch_yetanothercharge'; + } + else { + $charge_id = $this->_lookupCharge($json_action->donation->payload->paymentIntent->response); + } + $donation->paymentId = $charge_id; if ($donation->frequency == 'one-off') { - $donation->paymentId = $json_action->donation->payload->paymentIntent->response->id; $donation->donationId = $donation->paymentId; } else { - //Stripe webhook expects invoice id as trxn_id of contributions - $donation->paymentId = $json_action->donation->payload->paymentIntent->response->latest_invoice->id; $donation->donationId = $json_action->donation->payload->subscriptionId; $donation->providerDonorId = $json_action->donation->payload->customerId; } @@ -82,12 +107,5 @@ protected function buildDonation($action_id, $json_action) { return $donation; } - protected function determineLanguage($procaLanguage) { - $language = strtoupper($procaLanguage); - $countryLangMapping = Civi::settings()->get('country_lang_mapping'); - if (array_key_exists($language, $countryLangMapping)) { - return $countryLangMapping[$language]; - } - return 'en_GB'; - } + } diff --git a/CRM/WeAct/ActionProcessor.php b/CRM/WeAct/ActionProcessor.php index 2149629..4ed2815 100644 --- a/CRM/WeAct/ActionProcessor.php +++ b/CRM/WeAct/ActionProcessor.php @@ -17,52 +17,40 @@ public function process(CRM_WeAct_Action $action) { } public function getOrCreateContact(CRM_WeAct_Action $action, $campaign_id) { - if ($action->contact->isAnonymous()) { - return $this->settings->anonymousId; + $result = $action->contact->createOrUpdate( + $action->language, + $action->source() + ); + + if ($this->requestConsents) { + $action->contact->sendConsents( + $result['id'], + $campaign_id, + [ 'source' => CRM_Utils_Array::value('source', $action->utm), + 'medium' => CRM_Utils_Array::value('medium', $action->utm), + 'campaign' => CRM_Utils_Array::value('campaign', $action->utm) ] + ); } - $contact_ids = $action->contact->getMatchingIds(); - if (count($contact_ids) == 0) { - $contact = $action->contact->create($action->language, $action->source()); - } else { - //There shouldn't be more than one contact, but if does we'll simply use the "oldest" one - //TODO send an alert to someone that a merge is required if more than one id - $contact_id = min($contact_ids); - $contact = $action->contact->getAndUpdate($contact_id); - } - - Civi::log()->debug("Checking for group membership - {$contact['api.GroupContact.get']['count']}"); - - //Membership was retrieved from a joined query to GroupContact for the members group - if ($this->requestConsents && $contact['api.GroupContact.get']['count'] == 0) { - Civi::log()->debug("Sending consent request to contact {$contact['id']}"); - $consentParams = [ - 'contact_id' => $contact['id'], - 'campaign_id' => $campaign_id, - 'utm_source' => CRM_Utils_Array::value('source', $action->utm), - 'utm_medium' => CRM_Utils_Array::value('medium', $action->utm), - 'utm_campaign' => CRM_Utils_Array::value('campaign', $action->utm), - ]; - civicrm_api3('Gidipirus', 'send_consent_request', $consentParams); - } - - return $contact['id']; + return $result['id']; } public function processDonation($action, $campaign_id, $contact_id) { - CRM_Core_Transaction::create(TRUE)->run(function(CRM_Core_Transaction $tx) use ($action, $campaign_id, $contact_id) { - $donation = $action->details; - if ($donation->isRecurring()) { - $recur_id = $donation->findMatchingContribRecur(); - if (!$recur_id) { - $donation->createContribRecur($campaign_id, $contact_id, $action->actionPageName, $action->location, $action->utm); - } else if (!$donation->findMatchingContrib()) { - $donation->createContrib($campaign_id, $contact_id, $action->actionPageName, $action->location, $action->utm, $recur_id); + CRM_Core_Transaction::create(TRUE)->run( + function(CRM_Core_Transaction $tx) use ($action, $campaign_id, $contact_id) { + $donation = $action->details; + if ($donation->isRecurring()) { + $recur_id = $donation->findMatchingContribRecur(); + if (!$recur_id) { + return $donation->createContribRecur($campaign_id, $contact_id, $action->actionPageName, $action->location, $action->utm); + } + else if (!$donation->findMatchingContrib()) { + return $donation->createContrib($campaign_id, $contact_id, $action->actionPageName, $action->location, $action->utm, $recur_id); + } + } + else if (!$donation->findMatchingContrib()) { + return $donation->createContrib($campaign_id, $contact_id, $action->actionPageName, $action->location, $action->utm); } - } - else if (!$donation->findMatchingContrib()) { - $donation->createContrib($campaign_id, $contact_id, $action->actionPageName, $action->location, $action->utm); - } }); } } diff --git a/CRM/WeAct/Contact.php b/CRM/WeAct/Contact.php index 661e5f6..8ce4903 100644 --- a/CRM/WeAct/Contact.php +++ b/CRM/WeAct/Contact.php @@ -7,6 +7,7 @@ class CRM_WeAct_Contact { public $email; public $postcode; public $country; + public $isMember = false; public function __construct() { $this->settings = CRM_WeAct_Settings::instance(); @@ -34,6 +35,12 @@ public function getMatchingIds() { } public function create($language, $source) { + + $country_code = NULL; + if (key_exists($this->country, $this->settings->countryIds)) { + $country_code = $this->settings->countryIds[$this->country]; + } + $create_params = [ 'sequential' => 1, 'contact_type' => 'Individual', @@ -46,16 +53,67 @@ public function create($language, $source) { 'api.Address.create' => [ 'location_type_id' => 1, 'postal_code' => $this->postcode, - 'country_id' => $this->settings->countryIds[$this->country], + 'country_id' => $country_code, ] ]; $create_result = civicrm_api3('Contact', 'create', $create_params); $contact = $create_result['values'][0]; - //Indicate to caller that the contact is not in the members group - $contact['api.GroupContact.get']['count'] = 0; + + // Indicate to caller that the contact is not in the members group, because + // we just created them + // $contact['api.GroupContact.get']['count'] = 0; + $this->isMember = false; + return $contact; } + public function determineLanguage($code) { + $language = strtoupper($code); + $countryLangMapping = Civi::settings()->get('country_lang_mapping'); + if (array_key_exists($language, $countryLangMapping)) { + return $countryLangMapping[$language]; + } + return 'en_GB'; + } + + public function createOrUpdate($language, $source) { + if ($this->isAnonymous()) { + return $this->settings->anonymousId; + } + + $ids = $this->getMatchingIds(); + if (count($ids) == 0) { + $contact = $this->create($language, $source); + } else { + //There shouldn't be more than one contact, but if does we'll simply use the "oldest" one + //TODO send an alert to someone that a merge is required if more than one id + $contact = $this->getAndUpdate(min($ids)); + } + + return $contact; + } + + + public function sendConsents($contact_id, $campaign_id, $utms = []) { + + Civi::log()->debug("Checking for group membership - {$this->isMember}"); + + // Membership was retrieved from a joined query to GroupContact for the members group + if (! $this->isMember) { + Civi::log()->debug("Sending consent request to contact {$contact_id}"); + $consentParams = [ + 'contact_id' => $contact_id, + 'campaign_id' => $campaign_id, + 'utm_source' => CRM_Utils_Array::value('source', $utms), + 'utm_medium' => CRM_Utils_Array::value('medium', $utms), + 'utm_campaign' => CRM_Utils_Array::value('campaign', $utms), + ]; + civicrm_api3('Gidipirus', 'send_consent_request', $consentParams); + } + + } + + public function getAndUpdate($contact_id) { $get_params = [ 'id' => $contact_id, diff --git a/CRM/WeAct/Page/Stripe.php b/CRM/WeAct/Page/Stripe.php new file mode 100644 index 0000000..315cd4b --- /dev/null +++ b/CRM/WeAct/Page/Stripe.php @@ -0,0 +1,331 @@ +logEvent($post); + + $request = json_decode($post); + if (!$request) { + throw new CiviCRM_API3_Exception("Unable to parse JSON in POST: $post"); + } + $this->processNotification($request); + } + + public function processNotification($event) + { + switch ($event->type) { + case 'invoice.payment_succeeded': + case 'invoice.payment_failed': + $this->handlePayment($event->data->object); + break; + case 'customer.subscription.updated': + case 'customer.subscription.deleted': + $this->handleSubscriptionUpdate($event->data->object); + break; + case 'customer.subscription.created': + $this->handleSubscriptionCreate($event->data->object); + break; + case 'charge.refunded': + case 'charge.voided': + $this->handleRefund($event->data->object); + break; + case 'customer.created': + $this->handleCustomerCreate($event->data->object); + break; + default: + CRM_Core_Error::debug_log_message("Ignoring event: {$event->id} of type {$event->type}"); + } + } + + private function handleSubscriptionUpdate($subscription) + { + $id = $subscription->id; + $status = $subscription->status; + + # find the subscription + try { + $contrib_recur = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $subscription->id]); + } catch (CiviCRM_API3_Exception $ex) { + CRM_Core_Error::debug_log_message("handleSubscriptionUpdate: No recurring contribution with trxn_id={$subscription->id} Exception: {$ex}"); + // TODO: Try harder - look up using other keys? + return; + } + + $CIVI_STATUS = [ + 'active' => 'In Progress', + 'past_due' => 'Failed', // end state for us, since no more payment attempts are made + 'unpaid' => 'Failed', // same + 'canceled' => 'Cancelled', + // 'incomplete' + 'incomplete_expired' => 'Failed', // terminal state + // 'trialing' + ]; + + $to_update = ['id' => $contrib_recur['id']]; + + // only update the status if we know what it is ? Not sure what to do here really. + if (array_key_exists($status, $CIVI_STATUS)) { + + $to_update['contribution_status_id'] = $CIVI_STATUS[$status]; + + if ($status == 'canceled') { + $canceled_at = new DateTime("@{$subscription->canceled_at}"); + $to_update['cancel_date'] = $canceled_at->format('Y-m-d H:i:s T'); + } + + if ($subscription->ended_at) { + $ended_at = new DateTime("@{$subscription->ended_at}"); + $to_update['end_date'] = $ended_at->format('Y-m-d H:i:s T'); + } + } else { + CRM_Core_Error::debug_log_message("handleSubscriptionUpdate: Skipping unknown status $status for recurring contribution {$contrib_recur['id']}"); + } + + $item = $subscription->items->data[0]; + $amount = $item->price->unit_amount * $item->quantity; // meh, but why not + $to_update['amount'] = $amount / 100; + + civicrm_api3('ContributionRecur', 'create', $to_update); + } + + private function _findContribution($charge, $invoice) + { + + # Find the charge using charge_id or invoice_id or ... - this is totally mad + # because we have so many systems sending payments / charges to our db. + # + # contribution.trxn_id = charge->id + # if invoice + # contribution.trxn_id = charge->invoice + # contribution.trxn_id like 'charge_id ... %' + # contribution.trxn_id like 'invoice_id ... %' + # + # What a mess... let's update the db and make sure everything saves a + # charge id to the contribution table. + + $contrib_id = CRM_Core_DAO::singleValueQuery( + "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", + [1 => [$charge, 'String']] + ); + if ($contrib_id) {return $contrib_id;} + + if ($invoice) { + $contrib_id = CRM_Core_DAO::singleValueQuery( + "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", + [1 => [$invoice, 'String']] + ); + if ($contrib_id) {return $contrib_id;} + + $contrib_id = CRM_Core_DAO::singleValueQuery( + "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", + [1 => ["{$invoice},{$charge}", 'String']] + ); + if ($contrib_id) {return $contrib_id;} + + $contrib_id = CRM_Core_DAO::singleValueQuery( + "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", + [1 => ["{$charge},{$invoice}", 'String']] + ); + if ($contrib_id) {return $contrib_id;} + } + + return false; + } + + private function handleRefund($charge) + { + $contribution_id = $this->_findContribution($charge->id, $charge->invoice); + if (!$contribution_id) { + CRM_Core_Error::debug_log_message("handleRefund: No contribution found for charge {$charge->id}"); + return; + } + + CRM_Core_Error::debug_log_message("handleRefund: Refunding $contribution_id Stripe Charge {$charge->id}"); + + civicrm_api3('Contribution', 'create', [ + 'id' => $contribution_id, + 'contribution_status_id' => 'Refunded', + ]); + } + + private function handlePayment($invoice) + { + try { + // i bet we'll need to try more than one field here ... + $contrib_recur = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $invoice->subscription]); + } catch (CiviCRM_API3_Exception $ex) { + CRM_Core_Error::debug_log_message("handlePayment: No recurring contribution with trxn_id={$invoice->subscription} Exception: {$ex}"); + // TODO: Try harder - look up using other keys? + return; + } + + CRM_Core_Error::debug_log_message("handlePayment: Found recurring contribution {$contrib_recur['id']}"); + + try { + $contrib = civicrm_api3('Contribution', 'getsingle', [ + 'contribution_recur_id' => $contrib_recur['id'], + 'options' => ['limit' => 1, 'sort' => 'id DESC'], + ]); + } catch (CiviCRM_API3_Exception $ex) { + CRM_Core_Error::debug_log_message("handlePayment: No contribution found for recurring: {$contrib_recur['id']}"); + + // TODO: Try harder - create a contribution + return; + } + + $contrib_id = $contrib['id']; + + if ($contrib['trxn_id'] == $invoice->id) { + CRM_Core_Error::debug_log_message("handlePayment: Already got this contribution: $contrib_id for recurring {$contrib_recur['id']}"); + return; + } + + CRM_Core_Error::debug_log_message("handlePayment: Found contribution $contrib_id for recurring {$contrib_recur['id']}"); + + $created_dt = new DateTime("@{$invoice->created}"); + $repeat_params = [ + 'contribution_recur_id' => $contrib_recur['id'], + 'original_contribution_id' => $contrib_id, + 'contribution_status_id' => $invoice->paid ? 'Completed' : 'Failed', # XXX: only works for payment_succeeded and payment_failed + 'receive_date' => $created_dt->format('Y-m-d H:i:s T'), + 'trxn_id' => "{$invoice->id}", #,{$invoice->charge},{$invoice->payment_intent}", # invoice / charge / payment intent - PI is new! + # 'processor_id' => "{$invoice->id}" + ]; + CRM_Core_Error::debug_log_message("handlePayment: Repeating contribution with " . json_encode($repeat_params)); + civicrm_api3('Contribution', 'repeattransaction', $repeat_params); + } + + private function handleCustomerCreate($customer) + { + return $this->createContactFromCustomer($customer); + + } + + private function createContactFromCustomer($customer) + { + $contact = new CRM_WeAct_Contact(); + $contact->name = $customer->name; + $contact->email = $customer->email; + $contact->postcode = $customer->address ? $customer->address->postal_code : ''; + $contact->country = $customer->address ? $customer->address->country : ''; + + # Stripe locales aren't country specific, so we're fucked trying to + # match up. This is why integrations are hell. Happy happy! + $locales = $customer->preferred_locales; + $language = count($locales) > 0 + ? $contact->determineLanguage($locales[0]) + : "en_GB"; + + return $contact->createOrUpdate($language, 'stripe'); + } + + // TODO - move to a shared place with Proca.php::_lookupCharge + private function getStripeClient() + { + $sk = CRM_Core_DAO::singleValueQuery( + "SELECT password FROM civicrm_payment_processor WHERE id = 1" // I know, but it works + ); + if (!$sk) { + $sk = getenv("STRIPE_SECRET_KEY"); + } + if (!$sk) { + throw new Exception("Oops, couldn't find a secret key for Stripe. Can't go on!"); + } + return new \Stripe\StripeClient($sk); + } + + /* + * Handle Subscription Create + * + * event: customer.subscription.created + * + */ + public function handleSubscriptionCreate($subscription) + { + + try { + $existing = civicrm_api3( + 'ContributionRecur', + 'get', + [ + 'sequential' => 1, + 'trxn_id' => $subscription->id, + ] + ); + + if ($existing) { + return; + } + } catch (CiviCRM_API3_Exception $ex) { + CRM_Core_Error::debug_log_message("handleSubscriptionCreate: didn't find an existing Houdini subscription, that's fine: {$ex}"); + } + + $customer_id = $subscription->customer; + $stripe = $this->getStripeClient(); + $customer = $stripe->customers->retrieve($customer_id, []); + $email = $customer->email; + + $contact = new CRM_WeAct_Contact(); + $contact->email = $email; + $ids = $contact->getMatchingIds(); + if (count($ids) == 0) { + $created = $this->createContactFromCustomer($customer); + $contact_id = $created->id; + } else { + $contact_id = min($ids); + } + $item = $subscription->items->data[0]; + $price = $item->price; + + civicrm_api3('ContributionRecur', 'create', [ + 'trxn_id' => $subscription->id, + 'processor_id' => 1, # Stripe Live (or test in staging) + 'amount' => ($price->unit_amount * $item->quantity) / 100, + 'start_date' => "@{$subscription->start_date}", + 'currency' => $price->currency, + 'contact_id' => $contact_id, + ]); + + } + + public function logEvent($msg) + { + // CRM_Core_Error::debug_log_message("request: $msg"); + $queryParams = [ + 1 => [$msg, 'String'], + ]; + try { + CRM_Core_DAO::executeQuery( + "INSERT INTO civicrm_stripe_webhook_log (event) VALUES (%1)", + $queryParams + ); + } catch (CRM_Core_Exception $e) { + CRM_Core_Error::debug_log_message("Stripe Webhook not logged: {$msg} {$e}"); + } + } + +} diff --git a/CRM/WeAct/Settings.php b/CRM/WeAct/Settings.php index 3e216c2..ce9d043 100644 --- a/CRM/WeAct/Settings.php +++ b/CRM/WeAct/Settings.php @@ -143,6 +143,6 @@ public function getEmailGreetingId($locale) { if (array_key_exists($locale, $this->emailGreetingIds)) { return $this->emailGreetingIds[$locale]['']; } - return 0; + return NULL; } } diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..81000c7 --- /dev/null +++ b/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "stripe/stripe-php": "*" + } +} \ No newline at end of file diff --git a/info.xml b/info.xml index 4807e07..04ce2d2 100644 --- a/info.xml +++ b/info.xml @@ -23,13 +23,11 @@ eu.wemove.gidipirus eu.wemove.contributm org.project60.sepa - mjwshared - com.drastikbydesign.stripe - + CRM/WeAct - + \ No newline at end of file diff --git a/tests/phpunit/CRM/WeAct/Action/ProcaTest.php b/tests/phpunit/CRM/WeAct/Action/ProcaTest.php index 34cd4d1..028453b 100644 --- a/tests/phpunit/CRM/WeAct/Action/ProcaTest.php +++ b/tests/phpunit/CRM/WeAct/Action/ProcaTest.php @@ -33,11 +33,11 @@ protected static function sepaPayload() { JSON; } - protected static function stripePayload($frequency, $livemode = "true") { + protected static function stripePayload($frequency, $livemode = "true", $subscriptionID = 'sub_scription') { $subscription = ""; $latest_invoice = ""; if ($frequency != "one_off") { - $subscription = ', "subscriptionId": "sub_scription", "customerId": "cus_TomEr"'; + $subscription = ", \"subscriptionId\": \"{$subscriptionID}\", \"customerId\": \"cus_TomEr\""; $latest_invoice = ', "latest_invoice": {"id": "in_thevoice"}'; } return <<processDonation($action, $this->campaignId, $this->contactId); - $this->assertExists('Contribution', ['trxn_id' => 'pi_somegarbage', 'is_test' => $is_test]); + $this->assertExists('Contribution', ['trxn_id' => 'ch_yetanothercharge', 'is_test' => $is_test]); } /** @@ -59,8 +59,8 @@ public function testProcaStripeRecur($frequency, $crmFrequency) { $processor->processDonation($action, $this->campaignId, $this->contactId); $this->assertExists('ContributionRecur', ['trxn_id' => $sub_id, 'frequency_unit' => $crmFrequency]); - $this->assertExists('Contribution', ['trxn_id' => 'in_thevoice']); - $this->assertExists('StripeCustomer', ['contact_id' => $this->contactId]); + $this->assertExists('Contribution', ['trxn_id' => 'ch_yetanothercharge']); + # $this->assertExists('StripeCustomer', ['contact_id' => $this->contactId]); } public function testProcaSepaOneoff() { @@ -89,6 +89,7 @@ public function testProcaPaypalRecur() { $this->assertExists('Contribution', ['trxn_id' => 'S0M31D']); } + // this could die, but doesn't have to public function testHoudiniStripeRecur() { $action = CRM_WeAct_Action_HoudiniTest::recurringStripeAction(); $processor = new CRM_WeAct_ActionProcessor(); diff --git a/tests/phpunit/CRM/WeAct/BaseTest.php b/tests/phpunit/CRM/WeAct/BaseTest.php index 0c386c8..5bb7d12 100644 --- a/tests/phpunit/CRM/WeAct/BaseTest.php +++ b/tests/phpunit/CRM/WeAct/BaseTest.php @@ -23,7 +23,7 @@ abstract class CRM_WeAct_BaseTest extends \PHPUnit\Framework\TestCase implements public function setUpHeadless() { return \Civi\Test::headless() - ->install(['eu.wemove.gidipirus', 'eu.wemove.contributm', 'org.project60.sepa', 'mjwshared', 'com.drastikbydesign.stripe']) + ->install(['eu.wemove.gidipirus', 'eu.wemove.contributm', 'org.project60.sepa']) ->sql("UPDATE civicrm_sdd_creditor SET creditor_type = 'SEPA' WHERE creditor_type IS NULL") ->installMe(__DIR__) ->callback(function($ctx) { diff --git a/tests/phpunit/CRM/WeAct/Page/StripeTest.php b/tests/phpunit/CRM/WeAct/Page/StripeTest.php new file mode 100644 index 0000000..52e55e1 --- /dev/null +++ b/tests/phpunit/CRM/WeAct/Page/StripeTest.php @@ -0,0 +1,242 @@ +details->paymentId = $charge_id; + + $processor = new CRM_WeAct_ActionProcessor(); + $processor->processDonation( + $action, $this->campaignId, $this->contactId + ); + + $refund = json_decode(file_get_contents("./tests/phpunit/events/charge-refunded.json")); + $refund->data->object->id = $charge_id; + + $page = new CRM_WeAct_Page_Stripe(); + $page->processNotification($refund); + + # find the contribution and check it's marked refunded + + $contribution = civicrm_api3('Contribution', 'getsingle', ['trxn_id' => $charge_id]); + $this->assertTrue($contribution != NULL); + $this->assertEquals($contribution['contribution_status_id'], 7); + } + + public function testSubscriptionCreate() + { + # create the customer + $customer_event = json_decode(file_get_contents("./tests/phpunit/events/customer.json")); + $page = new CRM_WeAct_Page_Stripe(); + $page->processNotification($customer_event); + + # create the subscription + $subscription_event = json_decode(file_get_contents("./tests/phpunit/events/subscription.json")); + $page->processNotification($subscription_event); + + $subscription = $subscription_event->data->object; + $recurring = civicrm_api3('ContributionRecur', 'get', ['trxn_id' => $subscription->id]); + $this->assertTrue($recurring != NULL); + } + + public function testSubscriptionUpdateAmount() + { + $subscription = "sub_uqWHvXgyuwzTQ"; + $amount = 2000; + $action = CRM_WeAct_Action_ProcaTest::recurringStripeAction( + 'monthly', null, $subscription, $amount + ); + + // print(json_encode($action)); # // details->amount + + $processor = new CRM_WeAct_ActionProcessor(); + $processor->processDonation($action, $this->campaignId, $this->contactId); + + $contribution = civicrm_api3( + 'ContributionRecur', + 'getsingle', + ['trxn_id' => $subscription] + ); + $this->assertEquals("20.00", $contribution['amount']); + + $amount = 4000; + + $stripe_event = $this->customerSubscriptionUpdatedEvent( + $subscription, + $amount, + ); + + $page = new CRM_WeAct_Page_Stripe(); + $page->processNotification(json_decode($stripe_event)); + + $contribution = civicrm_api3( + 'ContributionRecur', 'getsingle', ['trxn_id' => $subscription] + ); + + $this->assertEquals("40.00", $contribution['amount']); + } + + // Test update status + + public function testSubscriptionCancel() + { + + // ... test the subscription is canceled changes ... + + } + + public function testNewinvoicePaymentSucceededEvent() + { + $subscription = "sub_AccmDyDhCXVvJtXf"; + $action = CRM_WeAct_Action_ProcaTest::recurringStripeAction('monthly', null, $subscription); + $processor = new CRM_WeAct_ActionProcessor(); + $processor->processDonation($action, $this->campaignId, $this->contactId); + + $invoice = "in_sZJpHDMwEnahHziF"; + $charge = "ch_rgWvkHQrADdcgPdY"; + $payment_intent = "pi_uFoDjxBJLjaZpqBn"; + + $created = '2022-01-28 01:34:00'; + $created_dt = new DateTime($created); + + $stripe_event = $this->invoicePaymentSucceededEvent( + $subscription, + $invoice, + $charge, + $payment_intent, + $created_dt->getTimestamp() + ); + + $page = new CRM_WeAct_Page_Stripe(); + $page->processNotification(json_decode($stripe_event)); + + $contribution = civicrm_api3('Contribution', 'getsingle', ['trxn_id' => $invoice]); + $this->assertEquals($contribution['receive_date'], $created); + $this->assertEquals($contribution['contribution_status_id'], 1); # Completed + + # do it again, make sure we don't add it again + $page->processNotification(json_decode($stripe_event)); + $contributions = civicrm_api3('Contribution', 'get', ['trxn_id' => $invoice]); + $this->assertEquals($contributions['count'], 1); + } + + public function testUnknownEvent() + { + $page = new CRM_WeAct_Page_Stripe(); + $event = <<processNotification(json_decode($event)); + $this->assertTrue(true); + } + + // TODO: Unknown subscription - But what should it do? Create the subscription right? + // public function testUnknownRecurringDonation() {} + + protected function invoicePaymentSucceededEvent($subscription, $invoice, $charge, $payment_intent, $created) + { + return <<1 true + + civicrm/we-act/stripe + CRM_WeAct_Page_Stripe + WeMove Stripe Webhook + 1 + true + From be0faa71b1c187780fe4e3c74ac556d55f662884 Mon Sep 17 00:00:00 2001 From: Aaron Elliot Ross Date: Thu, 24 Feb 2022 13:04:17 +0100 Subject: [PATCH 2/4] WIP --- tests/phpunit/events/proca-stripe-oneoff.json | 144 ++++++ .../events/proca-stripe-subscription.json | 457 ++++++++++++++++++ 2 files changed, 601 insertions(+) create mode 100644 tests/phpunit/events/proca-stripe-oneoff.json create mode 100644 tests/phpunit/events/proca-stripe-subscription.json diff --git a/tests/phpunit/events/proca-stripe-oneoff.json b/tests/phpunit/events/proca-stripe-oneoff.json new file mode 100644 index 0000000..d8039f6 --- /dev/null +++ b/tests/phpunit/events/proca-stripe-oneoff.json @@ -0,0 +1,144 @@ +{ + "action": { + "actionType": "donate", + "createdAt": "2022-02-22T13:11:28Z", + "donation": { + "amount": 400, + "currency": "EUR", + "frequencyUnit": "one_off", + "payload": { + "formValues": { + "country": "ES", + "email": "aaron+zacuvaxoz@example.com", + "firstname": "Brittany", + "lastname": "Ballard", + "postcode": "88 LL CC" + }, + "paymentConfirm": { + "amount": 400, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3KVy9DLEJyfuWvBB0Y7xD8y9_secret_i4t6RE2n1mQhoViwZmyqeLgjf", + "confirmation_method": "automatic", + "created": 1645535479, + "currency": "eur", + "description": null, + "id": "pi_3KVy9DLEJyfuWvBB0Y7xD8y9", + "last_payment_error": null, + "livemode": false, + "next_action": null, + "object": "payment_intent", + "payment_method": "pm_1KVy9DLEJyfuWvBBeXCBovod", + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "setup_future_usage": "off_session", + "shipping": null, + "source": null, + "status": "succeeded" + }, + "paymentIntent": { + "client_secret": "pi_3KVy9DLEJyfuWvBB0Y7xD8y9_secret_i4t6RE2n1mQhoViwZmyqeLgjf", + "response": { + "setup_future_usage": "off_session", + "payment_method": null, + "livemode": false, + "capture_method": "automatic", + "payment_method_options": { + "card": { + "installments": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "charges": { + "data": [], + "has_more": false, + "object": "list", + "total_count": 0, + "url": "/v1/charges?payment_intent=pi_3KVy9DLEJyfuWvBB0Y7xD8y9" + }, + "amount_received": 0, + "metadata": {}, + "description": null, + "payment_method_types": [ + "card" + ], + "amount_capturable": 0, + "review": null, + "confirmation_method": "automatic", + "next_action": null, + "on_behalf_of": null, + "customer": "cus_LCMsYDx6E7HtI4", + "amount": 400, + "invoice": null, + "statement_descriptor": null, + "application": null, + "client_secret": "pi_3KVy9DLEJyfuWvBB0Y7xD8y9_secret_i4t6RE2n1mQhoViwZmyqeLgjf", + "receipt_email": null, + "object": "payment_intent", + "source": null, + "last_payment_error": null, + "canceled_at": null, + "currency": "eur", + "created": 1645535479, + "cancellation_reason": null, + "status": "requires_payment_method", + "transfer_group": null, + "application_fee_amount": null, + "statement_descriptor_suffix": null, + "transfer_data": null, + "id": "pi_3KVy9DLEJyfuWvBB0Y7xD8y9", + "shipping": null + } + }, + "provider": "stripe" + } + }, + "fields": { + "amount": "4", + "frequency": "\"oneoff\"", + "initialAmount": "8", + "paymentMethod": "\"stripe\"", + "speakoutCampaign": "1378" + } + }, + "actionId": 256, + "actionPage": { + "locale": "fr", + "name": "birds/minimumbasicseeds", + "thankYouTemplateRef": "3568892" + }, + "actionPageId": 1, + "campaign": { + "externalId": null, + "name": "letsdoit", + "title": "Let's do it!" + }, + "campaignId": 1, + "contact": { + "area": "ES", + "email": "aaron+zacuvaxoz@example.com", + "firstName": "Brittany", + "payload": "{\"area\":\"ES\",\"country\":\"ES\",\"email\":\"aaron+zacuvaxoz@example.com\",\"firstName\":\"Brittany\",\"lastName\":\"Ballard\",\"postcode\":\"88 LL CC\"}", + "ref": "pQ78FVrBAjQU6rizh9BJEsasXGYEDV3H2l7bdbhOIG8" + }, + "orgId": 2, + "privacy": { + "communication": false, + "givenAt": "2022-02-22T13:11:28Z" + }, + "schema": "proca:action:1", + "stage": "deliver", + "tracking": { + "campaign": "unknown", + "content": "", + "location": "https://act.not-a2.eu/", + "medium": "unknown", + "source": "unknown" + } +} \ No newline at end of file diff --git a/tests/phpunit/events/proca-stripe-subscription.json b/tests/phpunit/events/proca-stripe-subscription.json new file mode 100644 index 0000000..c970750 --- /dev/null +++ b/tests/phpunit/events/proca-stripe-subscription.json @@ -0,0 +1,457 @@ +{ + "action": { + "actionType": "donate", + "createdAt": "2022-02-18T12:35:11Z", + "donation": { + "amount": 800, + "currency": "EUR", + "frequencyUnit": "monthly", + "payload": { + "customerId": "cus_LArONzYAEOe2tS", + "formValues": { + "country": "MT", + "email": "aaron+cijyjy@example.com", + "firstname": "Cleo", + "lastname": "Marsh", + "postcode": "88 LL CC" + }, + "paymentConfirm": { + "amount": 800, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3KUVfkLEJyfuWvBB0T1RjuaY_secret_raCNPQxrRBZ2XXnuhYME8cUH4", + "confirmation_method": "automatic", + "created": 1645187692, + "currency": "eur", + "description": "Subscription creation", + "id": "pi_3KUVfkLEJyfuWvBB0T1RjuaY", + "last_payment_error": null, + "livemode": false, + "next_action": null, + "object": "payment_intent", + "payment_method": "pm_1KUVflLEJyfuWvBBmtq1DNZC", + "payment_method_types": [ + "card", + "sepa_debit" + ], + "processing": null, + "receipt_email": null, + "setup_future_usage": "off_session", + "shipping": null, + "source": null, + "status": "succeeded" + }, + "paymentIntent": { + "client_secret": "pi_3KUVfkLEJyfuWvBB0T1RjuaY_secret_raCNPQxrRBZ2XXnuhYME8cUH4", + "response": { + "application_fee_percent": null, + "livemode": false, + "collection_method": "charge_automatically", + "default_payment_method": null, + "tax_percent": null, + "metadata": {}, + "pending_setup_intent": null, + "discount": null, + "start_date": 1645187691, + "pause_collection": null, + "customer": "cus_LArONzYAEOe2tS", + "ended_at": null, + "days_until_due": null, + "pending_invoice_item_interval": null, + "billing_thresholds": null, + "next_pending_invoice_item_invoice": null, + "default_source": null, + "cancel_at_period_end": false, + "object": "subscription", + "billing_cycle_anchor": 1645187691, + "schedule": null, + "canceled_at": null, + "current_period_end": 1647606891, + "latest_invoice": { + "customer_tax_exempt": "none", + "auto_advance": false, + "customer_phone": null, + "invoice_pdf": "https://pay.stripe.com/invoice/acct_16ExxKLEJyfuWvBB/test_YWNjdF8xNkV4eEtMRUp5ZnVXdkJCLF9MQXJPSmM3MGlXdWtMcFF0WUY5ZlVHcEJkc2RVZW5vLDM1NzI4NDky0200yxJvky8D/pdf?s=ap", + "starting_balance": 0, + "livemode": false, + "customer_tax_ids": [], + "collection_method": "charge_automatically", + "default_payment_method": null, + "amount_due": 800, + "attempt_count": 0, + "customer_name": "undefined undefined", + "custom_fields": null, + "tax_percent": null, + "subtotal": 800, + "account_country": "DE", + "metadata": {}, + "description": null, + "paid": false, + "pre_payment_credit_notes_amount": 0, + "subscription_proration_date": null, + "footer": null, + "customer_shipping": null, + "total": 800, + "payment_intent": { + "setup_future_usage": "off_session", + "payment_method": null, + "livemode": false, + "capture_method": "automatic", + "payment_method_options": { + "card": { + "installments": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "sepa_debit": {} + }, + "charges": { + "data": [], + "has_more": false, + "object": "list", + "total_count": 0, + "url": "/v1/charges?payment_intent=pi_3KUVfkLEJyfuWvBB0T1RjuaY" + }, + "amount_received": 0, + "metadata": {}, + "description": "Subscription creation", + "payment_method_types": [ + "card", + "sepa_debit" + ], + "amount_capturable": 0, + "review": null, + "confirmation_method": "automatic", + "next_action": null, + "on_behalf_of": null, + "customer": "cus_LArONzYAEOe2tS", + "amount": 800, + "invoice": "in_1KUVfjLEJyfuWvBB9u8lmHR4", + "statement_descriptor": "WeMove Europe", + "application": null, + "client_secret": "pi_3KUVfkLEJyfuWvBB0T1RjuaY_secret_raCNPQxrRBZ2XXnuhYME8cUH4", + "receipt_email": null, + "object": "payment_intent", + "source": null, + "last_payment_error": null, + "canceled_at": null, + "currency": "eur", + "created": 1645187692, + "cancellation_reason": null, + "status": "requires_payment_method", + "transfer_group": null, + "application_fee_amount": null, + "statement_descriptor_suffix": null, + "transfer_data": null, + "id": "pi_3KUVfkLEJyfuWvBB0T1RjuaY", + "shipping": null + }, + "threshold_reason": null, + "discount": null, + "customer_email": null, + "charge": null, + "account_name": "WeMove Europe SCE mbH", + "customer": "cus_LArONzYAEOe2tS", + "amount_paid": 0, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_16ExxKLEJyfuWvBB/test_YWNjdF8xNkV4eEtMRUp5ZnVXdkJCLF9MQXJPSmM3MGlXdWtMcFF0WUY5ZlVHcEJkc2RVZW5vLDM1NzI4NDky0200yxJvky8D?s=ap", + "statement_descriptor": null, + "due_date": null, + "default_source": null, + "receipt_number": null, + "billing_reason": "subscription_create", + "object": "invoice", + "status_transitions": { + "finalized_at": 1645187691, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "post_payment_credit_notes_amount": 0, + "currency": "eur", + "created": 1645187691, + "amount_remaining": 800, + "period_end": 1645187691, + "next_payment_attempt": null, + "subscription": "sub_1KUVfjLEJyfuWvBBoP9nB9zm", + "webhooks_delivered_at": 1645187691, + "total_tax_amounts": [], + "deleted": null, + "status": "open", + "ending_balance": 0, + "customer_address": { + "city": null, + "country": "", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "number": "5648FDC1-0001", + "application_fee_amount": null, + "tax": null, + "period_start": 1645187691, + "attempted": false, + "id": "in_1KUVfjLEJyfuWvBB9u8lmHR4", + "default_tax_rates": [], + "lines": { + "data": [ + { + "amount": 800, + "currency": "eur", + "description": "1 × WeMove Donation (at €8.00 / month)", + "discountable": true, + "id": "il_1KUVfjLEJyfuWvBBYNasr2Mo", + "invoice_item": null, + "livemode": false, + "metadata": {}, + "object": "line_item", + "period": { + "end": 1647606891, + "start": 1645187691 + }, + "plan": { + "active": false, + "aggregate_usage": null, + "amount": 800, + "amount_decimal": "800", + "billing_scheme": "per_unit", + "created": 1645187691, + "currency": "eur", + "deleted": null, + "id": "price_1KUVfjLEJyfuWvBBy65UuwlP", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "name": null, + "nickname": null, + "object": "plan", + "product": "prod_JjiIPD7pifRJMk", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "active": false, + "amount_decimal": null, + "billing_scheme": "per_unit", + "created": 1645187691, + "currency": "eur", + "id": "price_1KUVfjLEJyfuWvBBy65UuwlP", + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "object": "price", + "product": "prod_JjiIPD7pifRJMk", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tiers": null, + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 800 + }, + "proration": false, + "quantity": 1, + "subscription": "sub_1KUVfjLEJyfuWvBBoP9nB9zm", + "subscription_item": "si_LArOGZV7CUnrKk", + "tax_amounts": [], + "tax_rates": [], + "type": "subscription" + } + ], + "has_more": false, + "object": "list", + "total_count": 1, + "url": "/v1/invoices/in_1KUVfjLEJyfuWvBB9u8lmHR4/lines" + } + }, + "created": 1645187691, + "trial_end": null, + "cancel_at": null, + "trial_start": null, + "collection_method_thresholds": null, + "current_period_start": 1645187691, + "quantity": 1, + "status": "incomplete", + "items": { + "data": [ + { + "billing_thresholds": null, + "created": 1645187692, + "deleted": null, + "id": "si_LArOGZV7CUnrKk", + "metadata": {}, + "object": "subscription_item", + "plan": { + "active": false, + "aggregate_usage": null, + "amount": 800, + "amount_decimal": "800", + "billing_scheme": "per_unit", + "created": 1645187691, + "currency": "eur", + "deleted": null, + "id": "price_1KUVfjLEJyfuWvBBy65UuwlP", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "name": null, + "nickname": null, + "object": "plan", + "product": "prod_JjiIPD7pifRJMk", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "active": false, + "amount_decimal": null, + "billing_scheme": "per_unit", + "created": 1645187691, + "currency": "eur", + "id": "price_1KUVfjLEJyfuWvBBy65UuwlP", + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "object": "price", + "product": "prod_JjiIPD7pifRJMk", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tiers": null, + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 800 + }, + "quantity": 1, + "subscription": "sub_1KUVfjLEJyfuWvBBoP9nB9zm", + "tax_rates": [] + } + ], + "has_more": false, + "object": "list", + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1KUVfjLEJyfuWvBBoP9nB9zm" + }, + "collection_method_cycle_anchor": null, + "id": "sub_1KUVfjLEJyfuWvBBoP9nB9zm", + "pending_update": null, + "default_tax_rates": [], + "plan": { + "active": false, + "aggregate_usage": null, + "amount": 800, + "amount_decimal": "800", + "billing_scheme": "per_unit", + "created": 1645187691, + "currency": "eur", + "deleted": null, + "id": "price_1KUVfjLEJyfuWvBBy65UuwlP", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "name": null, + "nickname": null, + "object": "plan", + "product": "prod_JjiIPD7pifRJMk", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + } + }, + "subscriptionId": "sub_1KUVfjLEJyfuWvBBoP9nB9zm" + }, + "provider": "stripe", + "subscriptionId": "sub_1KUVfjLEJyfuWvBBoP9nB9zm", + "subscriptionPlan": { + "active": false, + "aggregate_usage": null, + "amount": 800, + "amount_decimal": "800", + "billing_scheme": "per_unit", + "created": 1645187691, + "currency": "eur", + "deleted": null, + "id": "price_1KUVfjLEJyfuWvBBy65UuwlP", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "name": null, + "nickname": null, + "object": "plan", + "product": "prod_JjiIPD7pifRJMk", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + } + } + }, + "fields": { + "amount": "8", + "frequency": "\"monthly\"", + "initialAmount": "8", + "paymentMethod": "\"stripe\"", + "speakoutCampaign": "1376" + } + }, + "actionId": 255, + "actionPage": { + "locale": "fr", + "name": "birds/minimumbasicseeds", + "thankYouTemplateRef": "3568892" + }, + "actionPageId": 1, + "campaign": { + "externalId": null, + "name": "letsdoit", + "title": "Let's do it!" + }, + "campaignId": 1, + "contact": { + "area": "MT", + "email": "aaron+cijyjy@example.com", + "firstName": "Cleo", + "payload": "{\"area\":\"MT\",\"country\":\"MT\",\"email\":\"aaron+cijyjy@example.com\",\"firstName\":\"Cleo\",\"lastName\":\"Marsh\",\"postcode\":\"88 LL CC\"}", + "ref": "11XP3Go5-jvyPbQzKZlGBjI4lN_QgeyUG3tUoRmhFG0" + }, + "orgId": 2, + "privacy": { + "communication": false, + "givenAt": "2022-02-18T12:35:11Z" + }, + "schema": "proca:action:1", + "stage": "deliver", + "tracking": { + "campaign": "unknown", + "content": "", + "location": "https://act.not-a2.eu/", + "medium": "unknown", + "source": "unknown" + } +} \ No newline at end of file From ddb098fa48708adba80d70468c90d02d881bf086 Mon Sep 17 00:00:00 2001 From: Aaron Elliot Ross Date: Fri, 25 Feb 2022 11:09:02 +0100 Subject: [PATCH 3/4] Wowsers --- tests/phpunit/CRM/WeAct/Page/StripeTest.php | 332 ++++++++++-------- tests/phpunit/events/charge-succeeded.json | 99 ++++++ tests/phpunit/events/customer.json | 4 +- .../events/subscription-cancelled.json | 138 ++++++++ tests/phpunit/events/subscription.json | 84 ++++- 5 files changed, 495 insertions(+), 162 deletions(-) create mode 100644 tests/phpunit/events/charge-succeeded.json create mode 100644 tests/phpunit/events/subscription-cancelled.json diff --git a/tests/phpunit/CRM/WeAct/Page/StripeTest.php b/tests/phpunit/CRM/WeAct/Page/StripeTest.php index 52e55e1..7295eac 100644 --- a/tests/phpunit/CRM/WeAct/Page/StripeTest.php +++ b/tests/phpunit/CRM/WeAct/Page/StripeTest.php @@ -5,155 +5,205 @@ /** * @group headless */ -class CRM_WeAct_Page_StripeTest extends CRM_WeAct_BaseTest -{ +class CRM_WeAct_Page_StripeTest extends CRM_WeAct_BaseTest { - public function setUp(): void - { - parent::setUp(); - } - - public function testRefund() - { - // as long as we're using Proca to get the first donation, be sure - // to test using that processor!! - - $action = CRM_WeAct_Action_ProcaTest::oneoffStripeAction(); - - $charge_id = 'ch_vuhQqUhzksniV'; - $action->details->paymentId = $charge_id; - - $processor = new CRM_WeAct_ActionProcessor(); - $processor->processDonation( - $action, $this->campaignId, $this->contactId - ); - - $refund = json_decode(file_get_contents("./tests/phpunit/events/charge-refunded.json")); - $refund->data->object->id = $charge_id; - - $page = new CRM_WeAct_Page_Stripe(); - $page->processNotification($refund); + public function setUp(): void { + parent::setUp(); + } + + public function testRefund() { + // as long as we're using Proca to get the first donation, be sure + // to test using that processor!! - # find the contribution and check it's marked refunded + $action = CRM_WeAct_Action_ProcaTest::oneoffStripeAction(); - $contribution = civicrm_api3('Contribution', 'getsingle', ['trxn_id' => $charge_id]); - $this->assertTrue($contribution != NULL); - $this->assertEquals($contribution['contribution_status_id'], 7); - } + $charge_id = 'ch_vuhQqUhzksniV'; + $action->details->paymentId = $charge_id; - public function testSubscriptionCreate() - { - # create the customer - $customer_event = json_decode(file_get_contents("./tests/phpunit/events/customer.json")); - $page = new CRM_WeAct_Page_Stripe(); - $page->processNotification($customer_event); - - # create the subscription - $subscription_event = json_decode(file_get_contents("./tests/phpunit/events/subscription.json")); - $page->processNotification($subscription_event); - - $subscription = $subscription_event->data->object; - $recurring = civicrm_api3('ContributionRecur', 'get', ['trxn_id' => $subscription->id]); - $this->assertTrue($recurring != NULL); - } - - public function testSubscriptionUpdateAmount() - { - $subscription = "sub_uqWHvXgyuwzTQ"; - $amount = 2000; - $action = CRM_WeAct_Action_ProcaTest::recurringStripeAction( - 'monthly', null, $subscription, $amount - ); + $processor = new CRM_WeAct_ActionProcessor(); + $processor->processDonation( + $action, + $this->campaignId, + $this->contactId + ); + + $refund = json_decode(file_get_contents("./tests/phpunit/events/charge-refunded.json")); + $refund->data->object->id = $charge_id; - // print(json_encode($action)); # // details->amount + $page = new CRM_WeAct_Page_Stripe(); + $page->processNotification($refund); - $processor = new CRM_WeAct_ActionProcessor(); - $processor->processDonation($action, $this->campaignId, $this->contactId); + # find the contribution and check it's marked refunded - $contribution = civicrm_api3( - 'ContributionRecur', - 'getsingle', - ['trxn_id' => $subscription] - ); - $this->assertEquals("20.00", $contribution['amount']); + $contribution = civicrm_api3('Contribution', 'getsingle', ['trxn_id' => $charge_id]); + $this->assertTrue($contribution != NULL); + $this->assertEquals($contribution['contribution_status_id'], 7); + } - $amount = 4000; + public function testSubscriptionCreate() { + + // TODO : test with a create customer - $stripe_event = $this->customerSubscriptionUpdatedEvent( - $subscription, - $amount, - ); - - $page = new CRM_WeAct_Page_Stripe(); - $page->processNotification(json_decode($stripe_event)); - - $contribution = civicrm_api3( - 'ContributionRecur', 'getsingle', ['trxn_id' => $subscription] - ); - - $this->assertEquals("40.00", $contribution['amount']); - } - - // Test update status - - public function testSubscriptionCancel() - { - - // ... test the subscription is canceled changes ... - - } - - public function testNewinvoicePaymentSucceededEvent() - { - $subscription = "sub_AccmDyDhCXVvJtXf"; - $action = CRM_WeAct_Action_ProcaTest::recurringStripeAction('monthly', null, $subscription); - $processor = new CRM_WeAct_ActionProcessor(); - $processor->processDonation($action, $this->campaignId, $this->contactId); - - $invoice = "in_sZJpHDMwEnahHziF"; - $charge = "ch_rgWvkHQrADdcgPdY"; - $payment_intent = "pi_uFoDjxBJLjaZpqBn"; - - $created = '2022-01-28 01:34:00'; - $created_dt = new DateTime($created); - - $stripe_event = $this->invoicePaymentSucceededEvent( - $subscription, - $invoice, - $charge, - $payment_intent, - $created_dt->getTimestamp() - ); - - $page = new CRM_WeAct_Page_Stripe(); - $page->processNotification(json_decode($stripe_event)); - - $contribution = civicrm_api3('Contribution', 'getsingle', ['trxn_id' => $invoice]); - $this->assertEquals($contribution['receive_date'], $created); - $this->assertEquals($contribution['contribution_status_id'], 1); # Completed - - # do it again, make sure we don't add it again - $page->processNotification(json_decode($stripe_event)); - $contributions = civicrm_api3('Contribution', 'get', ['trxn_id' => $invoice]); - $this->assertEquals($contributions['count'], 1); - } - - public function testUnknownEvent() - { - $page = new CRM_WeAct_Page_Stripe(); - $event = <<processNotification($customer_event); + + $contact_id = $contact['id']; + $_ENV['testing_contact_id'] = $contact_id; + + # create the contribution_recur (and the contact if needed, which it is here) + $subscription_event = json_decode(file_get_contents("./tests/phpunit/events/subscription.json")); + $page->processNotification($subscription_event); + + $subscription = $subscription_event->data->object; + $recurring = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $subscription->id]); + $this->assertTrue($recurring != NULL); + $this->assertEquals($recurring['contact_id'], $contact_id); + + $charge = civicrm_api3( + 'Contribution', + 'getsingle', + ['contribution_recur_id' => $recurring['id']] + ); + $this->assertEquals($charge['contact_id'], $contact_id); + $this->assertEquals( + substr($charge['trxn_id'], 0, 3), 'ch_' + ); + + unset($_ENV['testing_contact_id']); + } + + public function testSubscriptionUpdateAmount() { + $subscription = "sub_uqWHvXgyuwzTQ"; + $amount = 2000; + $action = CRM_WeAct_Action_ProcaTest::recurringStripeAction( + 'monthly', + null, + $subscription, + $amount + ); + + // print(json_encode($action)); # // details->amount + + $processor = new CRM_WeAct_ActionProcessor(); + $processor->processDonation($action, $this->campaignId, $this->contactId); + + $contribution = civicrm_api3( + 'ContributionRecur', + 'getsingle', + ['trxn_id' => $subscription] + ); + $this->assertEquals("20.00", $contribution['amount']); + + $amount = 4000; + + $stripe_event = $this->customerSubscriptionUpdatedEvent( + $subscription, + $amount, + ); + + $page = new CRM_WeAct_Page_Stripe(); + $page->processNotification(json_decode($stripe_event)); + + $contribution = civicrm_api3( + 'ContributionRecur', + 'getsingle', + ['trxn_id' => $subscription] + ); + + $this->assertEquals("40.00", $contribution['amount']); + } + + // Test update status + + public function testSubscriptionCancel() { + + $subscription = "sub_1KQYMRLEJyfuWvBB831StfM3"; + $amount = 1400; + $action = CRM_WeAct_Action_ProcaTest::recurringStripeAction( + 'monthly', + null, + $subscription, + $amount + ); + + // print(json_encode($action)); # // details->amount + + $processor = new CRM_WeAct_ActionProcessor(); + $processor->processDonation($action, $this->campaignId, $this->contactId); + + $contribution = civicrm_api3( + 'ContributionRecur', + 'getsingle', + ['trxn_id' => $subscription] + ); + $this->assertEquals("14.00", $contribution['amount']); + + $cancellation = json_decode(file_get_contents("./tests/phpunit/events/subscription-cancelled.json")); + $cancellation->data->object->id = $subscription; + + $page = new CRM_WeAct_Page_Stripe(); + $page->processNotification($cancellation); + + $contribution = civicrm_api3( + 'ContributionRecur', + 'getsingle', + ['trxn_id' => $subscription] + ); + $this->assertTrue($contribution != NULL); + $this->assertEquals($contribution['contribution_status_id'], 3); + $this->assertNotEquals($contribution['cancel_date'], NULL); + } + + public function testNewinvoicePaymentSucceededEvent() { + $subscription = "sub_AccmDyDhCXVvJtXf"; + $action = CRM_WeAct_Action_ProcaTest::recurringStripeAction('monthly', null, $subscription); + $processor = new CRM_WeAct_ActionProcessor(); + $processor->processDonation($action, $this->campaignId, $this->contactId); + + $invoice = "in_sZJpHDMwEnahHziF"; + $charge = "ch_rgWvkHQrADdcgPdY"; + $payment_intent = "pi_uFoDjxBJLjaZpqBn"; + + $created = '2022-01-28 01:34:00'; + $created_dt = new DateTime($created); + + $stripe_event = $this->invoicePaymentSucceededEvent( + $subscription, + $invoice, + $charge, + $payment_intent, + $created_dt->getTimestamp() + ); + + $page = new CRM_WeAct_Page_Stripe(); + $page->processNotification(json_decode($stripe_event)); + + $contribution = civicrm_api3('Contribution', 'getsingle', ['trxn_id' => $invoice]); + $this->assertEquals($contribution['receive_date'], $created); + $this->assertEquals($contribution['contribution_status_id'], 1); # Completed + + # do it again, make sure we don't add it again + $page->processNotification(json_decode($stripe_event)); + $contributions = civicrm_api3('Contribution', 'get', ['trxn_id' => $invoice]); + $this->assertEquals($contributions['count'], 1); + } + + public function testUnknownEvent() { + $page = new CRM_WeAct_Page_Stripe(); + $event = <<processNotification(json_decode($event)); - $this->assertTrue(true); - } + $page->processNotification(json_decode($event)); + $this->assertTrue(true); + } - // TODO: Unknown subscription - But what should it do? Create the subscription right? - // public function testUnknownRecurringDonation() {} + // TODO: Unknown subscription - But what should it do? Create the subscription right? + // public function testUnknownRecurringDonation() {} - protected function invoicePaymentSucceededEvent($subscription, $invoice, $charge, $payment_intent, $created) - { - return << Date: Fri, 25 Feb 2022 15:39:24 +0100 Subject: [PATCH 4/4] Whoooo wweeee, gonna squash this commit --- CRM/WeAct/Action/Proca.php | 1 + CRM/WeAct/Page/Stripe.php | 667 +++++++++++++++++++++---------------- CRM/WeAct/Settings.php | 5 + composer.json | 2 +- 4 files changed, 381 insertions(+), 294 deletions(-) diff --git a/CRM/WeAct/Action/Proca.php b/CRM/WeAct/Action/Proca.php index 2c1f770..528eacd 100644 --- a/CRM/WeAct/Action/Proca.php +++ b/CRM/WeAct/Action/Proca.php @@ -87,6 +87,7 @@ protected function buildDonation($action_id, $json_action) { else { $charge_id = $this->_lookupCharge($json_action->donation->payload->paymentIntent->response); } + # this becomes civicrm_contribution.trxn_id $donation->paymentId = $charge_id; if ($donation->frequency == 'one-off') { $donation->donationId = $donation->paymentId; diff --git a/CRM/WeAct/Page/Stripe.php b/CRM/WeAct/Page/Stripe.php index 315cd4b..99e60da 100644 --- a/CRM/WeAct/Page/Stripe.php +++ b/CRM/WeAct/Page/Stripe.php @@ -1,331 +1,412 @@ logEvent($post); + /* + * Notes: + * + * - Main feature : to handle recurring payments from Proca and Houdini + * created / migrated subscriptions, and any "god knows + * where" from subscriptions. + * + * - Contributions in CiviCRM should *always* be saved and found with the + * Stripe charge_id. - $request = json_decode($post); - if (!$request) { - throw new CiviCRM_API3_Exception("Unable to parse JSON in POST: $post"); - } - $this->processNotification($request); - } + * - If needed, update the db to sync trxn_id = Stripe.charge_id, don't add + * code. The _findTransaction can go away once that's done. + * + * - handleSubscriptionCreate does *NOT* create a civicrm contribution, just + * a civicrm contributionrecur. handlePayment will create the civicrm + * contribution and attach it to the contributionrecur. + * + * Questions: + * + * - Why do we have to look up the contribution when calling + * repeattransaction? We don't always, but we're better at it than + * CiviCRM because we use fewer criteria. Donation.createContrib should + * do the same? + * + * Refactoring notes: + * + * - Move more of this code to Action classes, specifically anything that + * creates Contribution and ContributionRecur should re-sue the code in + * Action/Donation.php; We want to have an Activity entry anytime a + * ContributionRecur is created, but that's not a priority. + * - Action/Stripe.php would have the switch / case and the handling code + * - Page/Stripe.php would decode, instantiate a Stripe instance and hand + * off processing. + * - Action Stripe.php could be split into multiple classes: StripePayment, + * StripeSubscription and so on. Yeesh, this is starting to look a lot + * like the Stripe extension! + */ - public function processNotification($event) - { - switch ($event->type) { - case 'invoice.payment_succeeded': - case 'invoice.payment_failed': - $this->handlePayment($event->data->object); - break; - case 'customer.subscription.updated': - case 'customer.subscription.deleted': - $this->handleSubscriptionUpdate($event->data->object); - break; - case 'customer.subscription.created': - $this->handleSubscriptionCreate($event->data->object); - break; - case 'charge.refunded': - case 'charge.voided': - $this->handleRefund($event->data->object); - break; - case 'customer.created': - $this->handleCustomerCreate($event->data->object); - break; - default: - CRM_Core_Error::debug_log_message("Ignoring event: {$event->id} of type {$event->type}"); - } - } + public function run() { + $post = file_get_contents('php://input'); + $this->logEvent($post); - private function handleSubscriptionUpdate($subscription) - { - $id = $subscription->id; - $status = $subscription->status; - - # find the subscription - try { - $contrib_recur = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $subscription->id]); - } catch (CiviCRM_API3_Exception $ex) { - CRM_Core_Error::debug_log_message("handleSubscriptionUpdate: No recurring contribution with trxn_id={$subscription->id} Exception: {$ex}"); - // TODO: Try harder - look up using other keys? - return; - } - - $CIVI_STATUS = [ - 'active' => 'In Progress', - 'past_due' => 'Failed', // end state for us, since no more payment attempts are made - 'unpaid' => 'Failed', // same - 'canceled' => 'Cancelled', - // 'incomplete' - 'incomplete_expired' => 'Failed', // terminal state - // 'trialing' - ]; - - $to_update = ['id' => $contrib_recur['id']]; - - // only update the status if we know what it is ? Not sure what to do here really. - if (array_key_exists($status, $CIVI_STATUS)) { - - $to_update['contribution_status_id'] = $CIVI_STATUS[$status]; - - if ($status == 'canceled') { - $canceled_at = new DateTime("@{$subscription->canceled_at}"); - $to_update['cancel_date'] = $canceled_at->format('Y-m-d H:i:s T'); - } - - if ($subscription->ended_at) { - $ended_at = new DateTime("@{$subscription->ended_at}"); - $to_update['end_date'] = $ended_at->format('Y-m-d H:i:s T'); - } - } else { - CRM_Core_Error::debug_log_message("handleSubscriptionUpdate: Skipping unknown status $status for recurring contribution {$contrib_recur['id']}"); - } - - $item = $subscription->items->data[0]; - $amount = $item->price->unit_amount * $item->quantity; // meh, but why not - $to_update['amount'] = $amount / 100; - - civicrm_api3('ContributionRecur', 'create', $to_update); + $request = json_decode($post); + if (!$request) { + throw new CiviCRM_API3_Exception("Unable to parse JSON in POST: $post"); + } + $this->processNotification($request); + } + + public function processNotification($event) { + switch ($event->type) { + case 'invoice.payment_succeeded': + case 'invoice.payment_failed': + return $this->handlePayment($event->data->object); + case 'customer.subscription.updated': + case 'customer.subscription.deleted': + return $this->handleSubscriptionUpdate($event->data->object); + case 'customer.subscription.created': + return $this->handleSubscriptionCreate($event->data->object); + case 'charge.succeeded': + return $this->handleChargeSucceeded($event->data->object); + case 'charge.refunded': + case 'charge.voided': + return $this->handleRefund($event->data->object); + case 'customer.created': + return $this->handleCustomerCreate($event->data->object); + default: + CRM_Core_Error::debug_log_message("Ignoring event: {$event->id} of type {$event->type}"); + return NULL; + } + + } + + private function handleSubscriptionUpdate($subscription) { + $id = $subscription->id; + $status = $subscription->status; + + # find the subscription + try { + $contrib_recur = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $subscription->id]); + } catch (CiviCRM_API3_Exception $ex) { + throw new Exception("handleSubscriptionUpdate: No recurring contribution with trxn_id={$subscription->id} Exception: {$ex}"); } - private function _findContribution($charge, $invoice) - { - - # Find the charge using charge_id or invoice_id or ... - this is totally mad - # because we have so many systems sending payments / charges to our db. - # - # contribution.trxn_id = charge->id - # if invoice - # contribution.trxn_id = charge->invoice - # contribution.trxn_id like 'charge_id ... %' - # contribution.trxn_id like 'invoice_id ... %' - # - # What a mess... let's update the db and make sure everything saves a - # charge id to the contribution table. - - $contrib_id = CRM_Core_DAO::singleValueQuery( - "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", - [1 => [$charge, 'String']] - ); - if ($contrib_id) {return $contrib_id;} - - if ($invoice) { - $contrib_id = CRM_Core_DAO::singleValueQuery( - "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", - [1 => [$invoice, 'String']] - ); - if ($contrib_id) {return $contrib_id;} - - $contrib_id = CRM_Core_DAO::singleValueQuery( - "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", - [1 => ["{$invoice},{$charge}", 'String']] - ); - if ($contrib_id) {return $contrib_id;} - - $contrib_id = CRM_Core_DAO::singleValueQuery( - "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", - [1 => ["{$charge},{$invoice}", 'String']] - ); - if ($contrib_id) {return $contrib_id;} - } - - return false; + $CIVI_STATUS = [ + 'active' => 'In Progress', + 'past_due' => 'Failed', // end state for us, since no more payment attempts are made + 'unpaid' => 'Failed', // same + 'canceled' => 'Cancelled', + // 'incomplete' + 'incomplete_expired' => 'Failed', // terminal state + // 'trialing' + ]; + + $to_update = ['id' => $contrib_recur['id']]; + + // only update the status if we know what it is ? Not sure what to do here really. + if (array_key_exists($status, $CIVI_STATUS)) { + + $to_update['contribution_status_id'] = $CIVI_STATUS[$status]; + + if ($status == 'canceled') { + $canceled_at = new DateTime("@{$subscription->canceled_at}"); + $to_update['cancel_date'] = $canceled_at->format('Y-m-d H:i:s T'); + } + + if ($subscription->ended_at) { + $ended_at = new DateTime("@{$subscription->ended_at}"); + $to_update['end_date'] = $ended_at->format('Y-m-d H:i:s T'); + } + } else { + CRM_Core_Error::debug_log_message("handleSubscriptionUpdate: Skipping unknown status $status for recurring contribution {$contrib_recur['id']}"); } - private function handleRefund($charge) - { - $contribution_id = $this->_findContribution($charge->id, $charge->invoice); - if (!$contribution_id) { - CRM_Core_Error::debug_log_message("handleRefund: No contribution found for charge {$charge->id}"); - return; - } + $item = $subscription->items->data[0]; + $amount = $item->price->unit_amount * $item->quantity; // meh, but why not + $to_update['amount'] = $amount / 100; - CRM_Core_Error::debug_log_message("handleRefund: Refunding $contribution_id Stripe Charge {$charge->id}"); + civicrm_api3('ContributionRecur', 'create', $to_update); + } - civicrm_api3('Contribution', 'create', [ - 'id' => $contribution_id, - 'contribution_status_id' => 'Refunded', - ]); + private function handleRefund($charge) { + $contribution_id = $this->_findContribution($charge->id, $charge->invoice); + if (!$contribution_id) { + CRM_Core_Error::debug_log_message("handleRefund: No contribution found for charge {$charge->id}"); + return; } - private function handlePayment($invoice) - { - try { - // i bet we'll need to try more than one field here ... - $contrib_recur = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $invoice->subscription]); - } catch (CiviCRM_API3_Exception $ex) { - CRM_Core_Error::debug_log_message("handlePayment: No recurring contribution with trxn_id={$invoice->subscription} Exception: {$ex}"); - // TODO: Try harder - look up using other keys? - return; - } - - CRM_Core_Error::debug_log_message("handlePayment: Found recurring contribution {$contrib_recur['id']}"); - - try { - $contrib = civicrm_api3('Contribution', 'getsingle', [ - 'contribution_recur_id' => $contrib_recur['id'], - 'options' => ['limit' => 1, 'sort' => 'id DESC'], - ]); - } catch (CiviCRM_API3_Exception $ex) { - CRM_Core_Error::debug_log_message("handlePayment: No contribution found for recurring: {$contrib_recur['id']}"); - - // TODO: Try harder - create a contribution - return; - } - - $contrib_id = $contrib['id']; - - if ($contrib['trxn_id'] == $invoice->id) { - CRM_Core_Error::debug_log_message("handlePayment: Already got this contribution: $contrib_id for recurring {$contrib_recur['id']}"); - return; - } - - CRM_Core_Error::debug_log_message("handlePayment: Found contribution $contrib_id for recurring {$contrib_recur['id']}"); - - $created_dt = new DateTime("@{$invoice->created}"); - $repeat_params = [ - 'contribution_recur_id' => $contrib_recur['id'], - 'original_contribution_id' => $contrib_id, - 'contribution_status_id' => $invoice->paid ? 'Completed' : 'Failed', # XXX: only works for payment_succeeded and payment_failed - 'receive_date' => $created_dt->format('Y-m-d H:i:s T'), - 'trxn_id' => "{$invoice->id}", #,{$invoice->charge},{$invoice->payment_intent}", # invoice / charge / payment intent - PI is new! - # 'processor_id' => "{$invoice->id}" - ]; - CRM_Core_Error::debug_log_message("handlePayment: Repeating contribution with " . json_encode($repeat_params)); - civicrm_api3('Contribution', 'repeattransaction', $repeat_params); + CRM_Core_Error::debug_log_message("handleRefund: Refunding $contribution_id Stripe Charge {$charge->id}"); + + civicrm_api3('Contribution', 'create', [ + 'id' => $contribution_id, + 'contribution_status_id' => 'Refunded', + ]); + } + + private function handleChargeSucceeded($charge) { + + // charges with an invoice are not our problem - let invoice.payment_succeeded + // those + if ($charge->invoice != NULL) { + return; } - private function handleCustomerCreate($customer) - { - return $this->createContactFromCustomer($customer); + throw new Exception("Single payments aren't handled here! The webhook shouldn't send them."); + } + private function handlePayment($invoice) { + + if ($invoice->subscription == NULL) { + return $this->handleSinglePayment($invoice); + } + + try { + // i bet we'll need to try more than one field here ... + $contrib_recur = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $invoice->subscription]); + } catch (CiviCRM_API3_Exception $ex) { + CRM_Core_Error::debug_log_message("handlePayment: No recurring contribution with trxn_id={$invoice->subscription} Exception: {$ex}"); + // TODO: Try harder - look up using other keys? + return; } - private function createContactFromCustomer($customer) - { - $contact = new CRM_WeAct_Contact(); - $contact->name = $customer->name; - $contact->email = $customer->email; - $contact->postcode = $customer->address ? $customer->address->postal_code : ''; - $contact->country = $customer->address ? $customer->address->country : ''; - - # Stripe locales aren't country specific, so we're fucked trying to - # match up. This is why integrations are hell. Happy happy! - $locales = $customer->preferred_locales; - $language = count($locales) > 0 - ? $contact->determineLanguage($locales[0]) - : "en_GB"; - - return $contact->createOrUpdate($language, 'stripe'); + CRM_Core_Error::debug_log_message("handlePayment: Found recurring contribution {$contrib_recur['id']}"); + + try { + $contrib = civicrm_api3('Contribution', 'getsingle', [ + 'contribution_recur_id' => $contrib_recur['id'], + 'options' => ['limit' => 1, 'sort' => 'id DESC'], + ]); + } catch (CiviCRM_API3_Exception $ex) { + CRM_Core_Error::debug_log_message("handlePayment: No contribution found for recurring: {$contrib_recur['id']}"); + // TODO: Try harder - create a contribution + return; } - // TODO - move to a shared place with Proca.php::_lookupCharge - private function getStripeClient() - { - $sk = CRM_Core_DAO::singleValueQuery( - "SELECT password FROM civicrm_payment_processor WHERE id = 1" // I know, but it works - ); - if (!$sk) { - $sk = getenv("STRIPE_SECRET_KEY"); - } - if (!$sk) { - throw new Exception("Oops, couldn't find a secret key for Stripe. Can't go on!"); - } - return new \Stripe\StripeClient($sk); + $contrib_id = $contrib['id']; + + if ($contrib['trxn_id'] == $invoice->id) { + CRM_Core_Error::debug_log_message("handlePayment: Already got this contribution: $contrib_id for recurring {$contrib_recur['id']}"); + return; } - /* + CRM_Core_Error::debug_log_message("handlePayment: Found contribution $contrib_id for recurring {$contrib_recur['id']}"); + + $created_dt = new DateTime("@{$invoice->created}"); + $repeat_params = [ + 'contribution_recur_id' => $contrib_recur['id'], + 'original_contribution_id' => $contrib_id, + 'contribution_status_id' => $invoice->paid ? 'Completed' : 'Failed', # XXX: only works for payment_succeeded and payment_failed + 'receive_date' => $created_dt->format('Y-m-d H:i:s T'), + 'trxn_id' => "{$invoice->id}", #,{$invoice->charge},{$invoice->payment_intent}", # invoice / charge / payment intent - PI is new! + # 'processor_id' => "{$invoice->id}" + ]; + CRM_Core_Error::debug_log_message("handlePayment: Repeating contribution with " . json_encode($repeat_params)); + + civicrm_api3('Contribution', 'repeattransaction', $repeat_params); + } + + private function handleCustomerCreate($customer) { + return $this->createContactFromCustomer($customer); + } + + /* * Handle Subscription Create * * event: customer.subscription.created * */ - public function handleSubscriptionCreate($subscription) - { - - try { - $existing = civicrm_api3( - 'ContributionRecur', - 'get', - [ - 'sequential' => 1, - 'trxn_id' => $subscription->id, - ] - ); - - if ($existing) { - return; - } - } catch (CiviCRM_API3_Exception $ex) { - CRM_Core_Error::debug_log_message("handleSubscriptionCreate: didn't find an existing Houdini subscription, that's fine: {$ex}"); - } - - $customer_id = $subscription->customer; - $stripe = $this->getStripeClient(); - $customer = $stripe->customers->retrieve($customer_id, []); - $email = $customer->email; - - $contact = new CRM_WeAct_Contact(); - $contact->email = $email; - $ids = $contact->getMatchingIds(); - if (count($ids) == 0) { - $created = $this->createContactFromCustomer($customer); - $contact_id = $created->id; - } else { - $contact_id = min($ids); - } - $item = $subscription->items->data[0]; - $price = $item->price; - - civicrm_api3('ContributionRecur', 'create', [ - 'trxn_id' => $subscription->id, - 'processor_id' => 1, # Stripe Live (or test in staging) - 'amount' => ($price->unit_amount * $item->quantity) / 100, - 'start_date' => "@{$subscription->start_date}", - 'currency' => $price->currency, - 'contact_id' => $contact_id, - ]); + public function handleSubscriptionCreate($subscription) { + + $settings = CRM_WeAct_Settings::instance(); + + $existing = civicrm_api3( + 'ContributionRecur', + 'get', + [ + 'sequential' => 1, + 'trxn_id' => $subscription->id, + ] + ); + + if ($existing['count'] > 0) { + CRM_Core_Error::debug_log_message("handleSubscriptionCreate: ignoring subscription we've already got: {$subscription->id}"); + return; + } + $customer_id = $subscription->customer; + $contact_id = $this->_findContactId($customer_id); + + $item = $subscription->items->data[0]; + $price = $item->price; + + $contribution = [ + 'amount' => ($price->unit_amount * $item->quantity) / 100, + 'contact_id' => $contact_id, + 'contribution_status_id' => 'In Progress', + 'create_date' => "@{$subscription->created}", + 'currency' => strtoupper($price->currency), + 'financial_type_id' => $settings->financialTypeId, + 'frequency_interval' => $price->recurring->interval_count, + 'frequency_unit' => $price->recurring->interval, // Stripe and CiviCRM Agree!!! + 'payment_instrument_id' => $settings->paymentInstrumentIds['card'], + 'payment_processor_id' => $settings->paymentProcessorIds['stripe'], + 'start_date' => "@{$subscription->start_date}", + 'trxn_id' => $subscription->id, + # TODO: + # 'campaign_id' => $campaign_id, + # 'is_test' => $this->isTest, + # $this->settings->customFields['recur_utm_source'] => CRM_Utils_Array::value('source', $utm), + # $this->settings->customFields['recur_utm_medium'] => CRM_Utils_Array::value('medium', $utm), + # $this->settings->customFields['recur_utm_campaign'] => CRM_Utils_Array::value('campaign', $utm), + ]; + + $recur = civicrm_api3('ContributionRecur', 'create', $contribution); + + # All this (creating a contribution) because CiviCRM has to have an + # "Orgininal Contribution". + + $contribution['contribution_recur_id'] = $recur['id']; + $contribution['contribution_status_id'] = 'Completed'; + unset($contribution['start_date']); + $contribution['receive_date'] = $contribution['create_date']; + unset($contribution['create_date']); + + if ($_ENV['CIVICRM_UF'] == 'UnitTests') { + $charge_id = 'ch_sosofakethisidisfake'; + } else { + $stripe = $this->getStripeClient(); + $invoice = $stripe->invoices->retrieve($subscription->latest_invoice); + $charge_id = $invoice->charge; + } + $contribution['trxn_id'] = $charge_id; + $contribution['total_amount'] = $contribution['amount']; + + civicrm_api3('Contribution', 'create', $contribution); + } + + private function _findContactId($customer_id) { + $stripe = $this->getStripeClient(); + + if ($_ENV['CIVICRM_UF'] == 'UnitTests') { + return $_ENV['testing_contact_id']; // cheap mock + } + + $customer = $stripe->customers->retrieve($customer_id); + $email = $customer->email; + + if (!$email) { + return []; + } + + $contact = new CRM_WeAct_Contact(); + $contact->email = $email; + + $ids = $contact->getMatchingIds(); + if (count($ids) == 0) { + $created = $this->createContactFromCustomer($customer); + $contact_id = $created['id']; + } else { + $contact_id = min($ids); + } + return $contact_id; + } + + public function logEvent($msg) { + // CRM_Core_Error::debug_log_message("request: $msg"); + $queryParams = [ + 1 => [$msg, 'String'], + ]; + try { + CRM_Core_DAO::executeQuery( + "INSERT INTO civicrm_stripe_webhook_log (event) VALUES (%1)", + $queryParams + ); + } catch (CRM_Core_Exception $e) { + CRM_Core_Error::debug_log_message("Stripe Webhook not logged: {$msg} {$e}"); + } + } + + private function createContactFromCustomer($customer) { + $contact = new CRM_WeAct_Contact(); + $contact->name = $customer->name; + $contact->email = $customer->email; + $contact->postcode = $customer->address ? $customer->address->postal_code : ''; + $contact->country = $customer->address ? $customer->address->country : ''; + + # Stripe locales aren't country specific, so we're fucked trying to + # match up. This is why integrations are hell. Happy happy! + $locales = $customer->preferred_locales; + $language = count($locales) > 0 + ? $contact->determineLanguage($locales[0]) + : "en_GB"; + + return $contact->createOrUpdate($language, 'stripe'); + } + + // TODO - move to a shared place with Proca.php::_lookupCharge + private function getStripeClient() { + $sk = CRM_Core_DAO::singleValueQuery( + "SELECT password FROM civicrm_payment_processor WHERE id = 1" // I know, but it works + ); + if (!$sk) { + $sk = getenv("STRIPE_SECRET_KEY"); + } + if (!$sk) { + throw new Exception("Oops, couldn't find a secret key for Stripe. Can't go on!"); + } + return new \Stripe\StripeClient($sk); + } + + + private function _findContribution($charge, $invoice) { + + # Find the charge using charge_id or invoice_id or ... - this is totally mad + # because we have so many systems sending payments / charges to our db. + # + # contribution.trxn_id = charge->id + # if invoice + # contribution.trxn_id = charge->invoice + # contribution.trxn_id like 'charge_id ... %' + # contribution.trxn_id like 'invoice_id ... %' + # + # What a mess... let's update the db and make sure everything saves a + # charge id to the contribution table. + + $contrib_id = CRM_Core_DAO::singleValueQuery( + "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", + [1 => [$charge, 'String']] + ); + if ($contrib_id) { + return $contrib_id; } - public function logEvent($msg) - { - // CRM_Core_Error::debug_log_message("request: $msg"); - $queryParams = [ - 1 => [$msg, 'String'], - ]; - try { - CRM_Core_DAO::executeQuery( - "INSERT INTO civicrm_stripe_webhook_log (event) VALUES (%1)", - $queryParams - ); - } catch (CRM_Core_Exception $e) { - CRM_Core_Error::debug_log_message("Stripe Webhook not logged: {$msg} {$e}"); - } + if ($invoice) { + $contrib_id = CRM_Core_DAO::singleValueQuery( + "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", + [1 => [$invoice, 'String']] + ); + if ($contrib_id) { + return $contrib_id; + } + + $contrib_id = CRM_Core_DAO::singleValueQuery( + "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", + [1 => ["{$invoice},{$charge}", 'String']] + ); + if ($contrib_id) { + return $contrib_id; + } + + $contrib_id = CRM_Core_DAO::singleValueQuery( + "SELECT id FROM civicrm_contribution WHERE trxn_id = %1", + [1 => ["{$charge},{$invoice}", 'String']] + ); + if ($contrib_id) { + return $contrib_id; + } } + return false; + } } diff --git a/CRM/WeAct/Settings.php b/CRM/WeAct/Settings.php index ce9d043..e83e378 100644 --- a/CRM/WeAct/Settings.php +++ b/CRM/WeAct/Settings.php @@ -89,6 +89,11 @@ protected function fetchPaymentProcessors() { 'name' => "Paypal-button", 'is_test' => 0, ])['id'], + 'stripe' => civicrm_api3('PaymentProcessor', 'getsingle', [ + 'return' => ["id"], + 'name' => "Credit Card", + 'is_test' => 0, + ])['id'], ]; } diff --git a/composer.json b/composer.json index 81000c7..902f2dd 100644 --- a/composer.json +++ b/composer.json @@ -2,4 +2,4 @@ "require": { "stripe/stripe-php": "*" } -} \ No newline at end of file +}