Skip to content
Snippets Groups Projects
Commit 117484b0 authored by mattwire's avatar mattwire
Browse files

Add IPN tests for StripeCheckout one-off payments

parent 09f8d997
Branches
Tags
1 merge request!217Implement Stripe Checkout (with support for SEPA and ACH)
......@@ -52,6 +52,11 @@ class CRM_Core_Payment_StripeIPN {
*/
protected $customer_id = NULL;
/**
* @var string The Stripe PaymentIntent ID
*/
protected $payment_intent_id = NULL;
/**
* @var string The Stripe Charge ID
*/
......@@ -215,6 +220,7 @@ class CRM_Core_Payment_StripeIPN {
}
$this->charge_id = $this->retrieve('charge_id', 'String', FALSE);
$this->payment_intent_id = $this->retrieve('payment_intent_id', 'String', FALSE);
$this->setInputParametersHasRun = TRUE;
}
......@@ -246,7 +252,7 @@ class CRM_Core_Payment_StripeIPN {
* @return string
*/
private function getWebhookUniqueIdentifier() {
return "{$this->charge_id}:{$this->invoice_id}:{$this->subscription_id}";
return "{$this->payment_intent_id}:{$this->charge_id}:{$this->invoice_id}:{$this->subscription_id}";
}
/**
......@@ -437,7 +443,7 @@ class CRM_Core_Payment_StripeIPN {
if ($this->exceptionOnFailure) {
// Re-throw a modified exception. (Special case for phpunit testing).
$return->message = get_class($e) . ": " . $e->getMessage();
throw new PaymentProcessorException($return->message, $e->getCode(), $e);
throw new PaymentProcessorException($return->message, $e->getCode());
}
else {
// Normal use.
......
......@@ -564,7 +564,31 @@ class Events {
->execute();
}
$return->message = __FUNCTION__ . ' contributionID: ' . $contribution['id'];
// charge.succeeded often arrives before checkout.session.completed and we have no way
// to match it to a contribution so it will be ignored.
// Now we have processed checkout.session.completed see if we need to process
// charge.succeeded again.
$chargeSucceededWebhook = \Civi\Api4\PaymentprocessorWebhook::get(FALSE)
->addSelect('id')
->addWhere('identifier', 'CONTAINS', $paymentIntentID)
->addWhere('trigger', '=', 'charge.succeeded')
->addWhere('status', '=', 'success')
->addOrderBy('created_date', 'DESC')
->execute()
->first();
if (!empty($chargeSucceededWebhook)) {
// Flag charge.succeeded for re-processing
\Civi\Api4\PaymentprocessorWebhook::update(FALSE)
->addValue('status', 'new')
->addValue('processed_date', NULL)
->addWhere('id', '=', $chargeSucceededWebhook['id'])
->execute();
$return->message = __FUNCTION__ . ' contributionID: ' . $contribution['id'] . ' charge.succeeded flagged for re-process';
}
else {
$return->message = __FUNCTION__ . ' contributionID: ' . $contribution['id'];
}
$return->ok = TRUE;
return $return;
}
......
......@@ -35,6 +35,8 @@ function _civicrm_api3_stripe_Setuptest_spec(&$spec) {
*
* @return array API result descriptor
* @throws \CiviCRM_API3_Exception
*
* @deprecated
*/
function civicrm_api3_stripe_Setuptest($params) {
$params = [
......
......@@ -80,7 +80,10 @@ abstract class CRM_Stripe_BaseTest extends \PHPUnit\Framework\TestCase implement
public function setUp(): void {
civicrm_api3('Extension', 'install', ['keys' => 'com.drastikbydesign.stripe']);
require_once('vendor/stripe/stripe-php/init.php');
$this->createPaymentProcessor();
// Create Stripe Checkout processor
$this->setOrCreateStripeCheckoutPaymentProcessor();
// Create Stripe processor
$this->setOrCreateStripePaymentProcessor();
$this->createContact();
$this->created_ts = time();
}
......@@ -121,12 +124,53 @@ abstract class CRM_Stripe_BaseTest extends \PHPUnit\Framework\TestCase implement
* Create a stripe payment processor.
*
*/
function createPaymentProcessor($params = []) {
$result = civicrm_api3('Stripe', 'setuptest', $params);
$processor = array_pop($result['values']);
$this->paymentProcessor = $processor;
$this->paymentProcessorID = $result['id'];
$this->paymentObject = \Civi\Payment\System::singleton()->getById($result['id']);
function createPaymentProcessor($overrideParams = []) {
$params = array_merge([
'name' => 'Stripe',
'domain_id' => 'current_domain',
'payment_processor_type_id:name' => 'Stripe',
'title' => 'Stripe',
'is_active' => 1,
'is_default' => 0,
'is_test' => 1,
'is_recur' => 1,
'user_name' => 'pk_test_k2hELLGpBLsOJr6jZ2z9RaYh',
'password' => 'sk_test_TlGdeoi8e1EOPC3nvcJ4q5UZ',
'class_name' => 'Payment_Stripe',
'billing_mode' => 1,
'payment_instrument_id' => 1,
], $overrideParams);
// First see if it already exists.
$paymentProcessor = \Civi\Api4\PaymentProcessor::get(FALSE)
->addWhere('class_name', '=', $params['class_name'])
->addWhere('is_test', '=', $params['is_test'])
->execute()
->first();
if (empty($paymentProcessor)) {
// Nope, create it.
$paymentProcessor = \Civi\Api4\PaymentProcessor::create(FALSE)
->setValues($params)
->execute()
->first();
}
$this->paymentProcessor = $paymentProcessor;
$this->paymentProcessorID = $paymentProcessor['id'];
$this->paymentObject = \Civi\Payment\System::singleton()->getById($paymentProcessor['id']);
}
public function setOrCreateStripeCheckoutPaymentProcessor() {
$this->createPaymentProcessor([
'name' => 'StripeCheckout',
'payment_processor_type_id:name' => 'StripeCheckout',
'title' => 'Stripe Checkout',
'class_name' => 'Payment_StripeCheckout',
]);
}
public function setOrCreateStripePaymentProcessor() {
$this->createPaymentProcessor();
}
/**
......@@ -140,7 +184,7 @@ abstract class CRM_Stripe_BaseTest extends \PHPUnit\Framework\TestCase implement
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Stripe\Exception\ApiErrorException
*/
public function doPayment(array $params = []): array {
public function doPaymentStripe(array $params = []): array {
// Send in credit card to get payment method. xxx mock here
$paymentMethod = $this->paymentObject->stripeClient->paymentMethods->create([
'type' => 'card',
......@@ -243,8 +287,13 @@ abstract class CRM_Stripe_BaseTest extends \PHPUnit\Framework\TestCase implement
/**
* Create contribition
*
* @param array $params
*
* @return array The created contribution
* @throws \CRM_Core_Exception
*/
public function setupTransaction($params = []) {
public function setupPendingContribution($params = []): array {
$contribution = civicrm_api3('contribution', 'create', array_merge([
'contact_id' => $this->contactID,
'payment_processor_id' => $this->paymentProcessorID,
......@@ -256,7 +305,12 @@ abstract class CRM_Stripe_BaseTest extends \PHPUnit\Framework\TestCase implement
'is_test' => 1,
], $params));
$this->assertEquals(0, $contribution['is_error']);
$contribution = \Civi\Api4\Contribution::get(FALSE)
->addWhere('id', '=', $contribution['id'])
->execute()
->first();
$this->contributionID = $contribution['id'];
return $contribution;
}
/**
......
......@@ -80,6 +80,218 @@ class CRM_Stripe_IpnTest extends CRM_Stripe_BaseTest {
]);
}
/**
* Test completing a one-off contribution with trxn_id = paymentIntentID
* For Stripe checkout we find the contribution using contribution.invoice_id=checkout.client_reference_id
* Then we set the contribution.trxn_id=checkout.payment_intent_id (we don't have charge_id yet)
* So when charge.succeeded comes in we need to match on payment_intent_id.
*
*/
public function testNewOneOffStripeCheckout() {
$this->setOrCreateStripeCheckoutPaymentProcessor();
$this->getMocksForOneOffPayment();
$contribution = $this->setupPendingContribution(['invoice_id' => md5(uniqid(mt_rand(), TRUE))]);
// Simulate payment
$this->assertInstanceOf('CRM_Core_Payment_StripeCheckout', $this->paymentObject);
//
// Check the Contribution
// ...should be pending
// ...its transaction ID should be our Charge ID.
//
$this->checkContrib([
'contribution_status_id' => 'Pending',
'trxn_id' => '',
'invoice_id' => $contribution['invoice_id']
]);
// Set the new contribution to have trxn_id=pi_mock
$success = $this->simulateEvent([
'type' => 'checkout.session.completed',
'id' => 'evt_mock',
'object' => 'event', // ?
'livemode' => FALSE,
'pending_webhooks' => 0,
'request' => [ 'id' => NULL ],
'data' => [
'object' => [
'id' => 'cs_mock',
'object' => 'checkout.session',
'customer' => 'cus_mock',
'payment_intent' => 'pi_mock',
'client_reference_id' => $contribution['invoice_id'],
]
],
]);
$this->assertEquals(TRUE, $success, 'IPN did not return OK');
$this->checkContrib([
'contribution_status_id' => 'Pending',
'trxn_id' => 'pi_mock',
]);
$success = $this->simulateEvent([
'type' => 'charge.succeeded',
'id' => 'evt_mock',
'object' => 'event', // ?
'livemode' => FALSE,
'pending_webhooks' => 0,
'request' => [ 'id' => NULL ],
'data' => [
'object' => [
'id' => 'ch_mock',
'object' => 'charge',
'customer' => 'cus_mock',
'payment_intent' => 'pi_mock',
'created' => time(),
'amount' => $this->total*100,
'status' => 'succeeded',
"captured" => TRUE,
]
],
]);
$this->assertEquals(TRUE, $success, 'IPN did not return OK');
// Ensure Contribution status is updated to complete and that we now have both invoice ID and charge ID as the transaction ID.
$this->checkContrib([
'contribution_status_id' => 'Completed',
'trxn_id' => 'pi_mock,ch_mock',
]);
}
/**
* charge.succeeded and checkout.session.completed arrive at the same time.
* If charge.succeeded arrives first we can't match the contribution so we re-trigger it
* once checkout.session.completed has processed.
*
* @return void
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public function testNewOneOffStripeCheckoutOutOfOrder() {
$this->setOrCreateStripeCheckoutPaymentProcessor();
$this->getMocksForOneOffPayment();
$contribution = $this->setupPendingContribution(['invoice_id' => md5(uniqid(mt_rand(), TRUE))]);
// Simulate payment
$this->assertInstanceOf('CRM_Core_Payment_StripeCheckout', $this->paymentObject);
//
// Check the Contribution
// ...should be pending
// ...its transaction ID should be our Charge ID.
//
$this->checkContrib([
'contribution_status_id' => 'Pending',
'trxn_id' => '',
'invoice_id' => $contribution['invoice_id']
]);
// This will be ignored as it comes in before checkout.session.completed
$success = $this->simulateEvent([
'type' => 'charge.succeeded',
'id' => 'evt_mock',
'object' => 'event', // ?
'livemode' => FALSE,
'pending_webhooks' => 0,
'request' => [ 'id' => NULL ],
'data' => [
'object' => [
'id' => 'ch_mock',
'object' => 'charge',
'customer' => 'cus_mock',
'payment_intent' => 'pi_mock',
'created' => time(),
'amount' => $this->total*100,
'status' => 'succeeded',
"captured" => TRUE,
]
],
]);
$this->assertEquals(TRUE, $success, 'IPN did not return OK');
// Ensure Contribution status is updated to complete and that we now have both invoice ID and charge ID as the transaction ID.
$this->checkContrib([
'contribution_status_id' => 'Pending',
'trxn_id' => '',
]);
// Create dummy webhook record
\Civi\Api4\PaymentprocessorWebhook::create(FALSE)
->addValue('payment_processor_id', $this->paymentProcessorID)
->addValue('event_id', 'ev_mock')
->addValue('trigger', 'charge.succeeded')
->addValue('status', 'success')
->addValue('identifier', 'pi_mock::')
->addValue('data', '')
->addValue('message', 'already processed')
->execute();
// Set the new contribution to have trxn_id=pi_mock
$success = $this->simulateEvent([
'type' => 'checkout.session.completed',
'id' => 'evt_mock',
'object' => 'event', // ?
'livemode' => FALSE,
'pending_webhooks' => 0,
'request' => [ 'id' => NULL ],
'data' => [
'object' => [
'id' => 'cs_mock',
'object' => 'checkout.session',
'customer' => 'cus_mock',
'payment_intent' => 'pi_mock',
'client_reference_id' => $contribution['invoice_id'],
]
],
]);
$this->assertEquals(TRUE, $success, 'IPN did not return OK');
$this->checkContrib([
'contribution_status_id' => 'Pending',
'trxn_id' => 'pi_mock',
]);
$chargeSucceededWebhook = \Civi\Api4\PaymentprocessorWebhook::get(FALSE)
->addWhere('identifier', 'CONTAINS', 'pi_mock')
->addWhere('trigger', '=', 'charge.succeeded')
->addWhere('status', '=', 'new')
->addWhere('processed_date', 'IS EMPTY')
->execute()
->first();
$this->assertNotEmpty($chargeSucceededWebhook, 'charge.succeeded should queued for processing but is not');
// Now trigger charge.succeeded again
$success = $this->simulateEvent([
'type' => 'charge.succeeded',
'id' => 'evt_mock',
'object' => 'event', // ?
'livemode' => FALSE,
'pending_webhooks' => 0,
'request' => [ 'id' => NULL ],
'data' => [
'object' => [
'id' => 'ch_mock',
'object' => 'charge',
'customer' => 'cus_mock',
'payment_intent' => 'pi_mock',
'created' => time(),
'amount' => $this->total*100,
'status' => 'succeeded',
"captured" => TRUE,
]
],
]);
$this->assertEquals(TRUE, $success, 'IPN did not return OK');
// Ensure Contribution status is updated to complete and that we now have both invoice ID and charge ID as the transaction ID.
$this->checkContrib([
'contribution_status_id' => 'Completed',
'trxn_id' => 'pi_mock,ch_mock',
]);
}
/**
* Test creating a one-off contribution and
* update it after creation.
......@@ -822,7 +1034,7 @@ class CRM_Stripe_IpnTest extends CRM_Stripe_BaseTest {
/**
* Create recurring contribition
*/
public function setupRecurringTransaction($params = []) {
public function setupRecurringContribution($params = []) {
$contributionRecur = civicrm_api3('contribution_recur', 'create', array_merge([
'financial_type_id' => $this->financialTypeID,
'payment_instrument_id' => CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_ContributionRecur', 'payment_instrument_id', 'Credit Card'),
......@@ -919,35 +1131,12 @@ class CRM_Stripe_IpnTest extends CRM_Stripe_BaseTest {
return [$mockCharge1, $mockCharge2, $mockInvoice2, $balanceTransaction2];
}
/**
* DRY code. Sets up the database as it would be after a recurring contrib
* has been set up with Stripe.
*
* Results in a pending ContributionRecur and a pending Contribution record.
*
* The following mock Stripe IDs strings are used:
*
* - pm_mock PaymentMethod
* - pi_mock PaymentIntent
* - cus_mock Customer
* - ch_mock Charge
* - txn_mock Balance transaction
* - sub_mock Subscription
*
* @return array The result from doPayment()
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Stripe\Exception\ApiErrorException
*/
protected function mockOneOffPaymentSetup(): array {
protected function getMocksForOneOffPayment() {
PropertySpy::$buffer = 'none';
// Set this to 'print' or 'log' maybe more helpful in debugging but for
// generally running tests 'exception' suits as we don't expect any output.
PropertySpy::$outputMode = 'exception';
$this->assertInstanceOf('CRM_Core_Payment_Stripe', $this->paymentObject);
// Create a mock stripe client.
$stripeClient = $this->createMock('Stripe\\StripeClient');
// Update our CRM_Core_Payment_Stripe object and ensure any others
......@@ -1058,15 +1247,42 @@ class CRM_Stripe_IpnTest extends CRM_Stripe_BaseTest {
$stripeClient->refunds
->method('all')
->willReturn(new PropertySpy('refunds.all', [ 'data' => [ $mockRefund ] ]));
}
$this->setupTransaction();
/**
* DRY code. Sets up the database as it would be after a recurring contrib
* has been set up with Stripe.
*
* Results in a pending ContributionRecur and a pending Contribution record.
*
* The following mock Stripe IDs strings are used:
*
* - pm_mock PaymentMethod
* - pi_mock PaymentIntent
* - cus_mock Customer
* - ch_mock Charge
* - txn_mock Balance transaction
* - sub_mock Subscription
*
* @return array The result from doPayment()
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Stripe\Exception\ApiErrorException
*/
protected function mockOneOffPaymentSetup(): array {
$this->getMocksForOneOffPayment();
$this->setupPendingContribution();
// Submit the payment.
$payment_extra_params = [
'contributionID' => $this->contributionID,
'paymentIntentID' => 'pi_mock',
];
$doPaymentResult = $this->doPayment($payment_extra_params);
// Simulate payment
$this->assertInstanceOf('CRM_Core_Payment_Stripe', $this->paymentObject);
$doPaymentResult = $this->doPaymentStripe($payment_extra_params);
//
// Check the Contribution
......@@ -1082,28 +1298,14 @@ class CRM_Stripe_IpnTest extends CRM_Stripe_BaseTest {
}
/**
* DRY code. Sets up the database as it would be after a recurring contrib
* has been set up with Stripe.
*
* Results in a pending ContributionRecur and a pending Contribution record.
*
* The following mock Stripe IDs strings are used:
*
* - pm_mock PaymentMethod
* - pi_mock PaymentIntent
* - cus_mock Customer
* - ch_mock Charge
* - txn_mock Balance transaction
* - sub_mock Subscription
* @return void
*/
protected function mockRecurringPaymentSetup() {
protected function getMocksForRecurringPayment() {
PropertySpy::$buffer = 'none';
// Set this to 'print' or 'log' maybe more helpful in debugging but for
// generally running tests 'exception' suits as we don't expect any output.
PropertySpy::$outputMode = 'exception';
$this->assertInstanceOf('CRM_Core_Payment_Stripe', $this->paymentObject);
// Create a mock stripe client.
$stripeClient = $this->createMock('Stripe\\StripeClient');
// Update our CRM_Core_Payment_Stripe object and ensure any others
......@@ -1250,15 +1452,28 @@ class CRM_Stripe_IpnTest extends CRM_Stripe_BaseTest {
$stripeClient->invoices = $this->createMock('Stripe\\Service\\InvoiceService');
$stripeClient->invoices
->expects($this->never())
->method($this->anything())
;
/*
->method('all')
->willReturn(['data' => $mockInvoice]);
*/
->method($this->anything());
}
/**
* DRY code. Sets up the database as it would be after a recurring contrib
* has been set up with Stripe.
*
* Results in a pending ContributionRecur and a pending Contribution record.
*
* The following mock Stripe IDs strings are used:
*
* - pm_mock PaymentMethod
* - pi_mock PaymentIntent
* - cus_mock Customer
* - ch_mock Charge
* - txn_mock Balance transaction
* - sub_mock Subscription
*/
protected function mockRecurringPaymentSetup() {
$this->getMocksForRecurringPayment();
// Setup a recurring contribution for $this->total per month.
$this->setupRecurringTransaction();
$this->setupRecurringContribution();
// Submit the payment.
$payment_extra_params = [
......@@ -1270,7 +1485,9 @@ class CRM_Stripe_IpnTest extends CRM_Stripe_BaseTest {
'installments' => $this->contributionRecur['installments'],
];
$this->doPayment($payment_extra_params);
// Simulate payment
$this->assertInstanceOf('CRM_Core_Payment_Stripe', $this->paymentObject);
$this->doPaymentStripe($payment_extra_params);
//
// Check the Contribution
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment