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
+