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..528eacd 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,19 @@ 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); + } + # this becomes civicrm_contribution.trxn_id + $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 +108,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..99e60da --- /dev/null +++ b/CRM/WeAct/Page/Stripe.php @@ -0,0 +1,412 @@ +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': + 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}"); + } + + $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 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 handleChargeSucceeded($charge) { + + // charges with an invoice are not our problem - let invoice.payment_succeeded + // those + if ($charge->invoice != NULL) { + return; + } + + 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; + } + + 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); + } + + /* + * Handle Subscription Create + * + * event: customer.subscription.created + * + */ + 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; + } + + 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 3e216c2..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'], ]; } @@ -143,6 +148,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..902f2dd --- /dev/null +++ b/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "stripe/stripe-php": "*" + } +} 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..7295eac --- /dev/null +++ b/tests/phpunit/CRM/WeAct/Page/StripeTest.php @@ -0,0 +1,290 @@ +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() { + + // TODO : test with a create customer + + // # create the customer + $customer_event = json_decode(file_get_contents("./tests/phpunit/events/customer.json")); + $page = new CRM_WeAct_Page_Stripe(); + $contact = $page->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); + } + + // 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 +