Commit 51b5670b authored by mattwire's avatar mattwire
Browse files

Add unit tests for Stripe.Importsubscription/ImportCharge API. Support...

Add unit tests for Stripe.Importsubscription/ImportCharge API. Support importing subscriptions with future startdate/trial period (no invoice so we create a template contribution)
parent 84ae2b38
......@@ -62,10 +62,10 @@ function civicrm_api3_stripe_importcharge($params) {
$paymentProcessor->setAPIParams();
// Retrieve the Stripe charge.
$charge = $paymentProcessor->stripeClient->charges->retrieve($params['charge']);
$stripeCharge = $paymentProcessor->stripeClient->charges->retrieve($params['charge']);
// Get the related invoice.
$stripeInvoice = $paymentProcessor->stripeClient->invoices->retrieve($charge->invoice);
$stripeInvoice = $paymentProcessor->stripeClient->invoices->retrieve($stripeCharge->invoice);
if (!$stripeInvoice) {
throw new \CiviCRM_API3_Exception(E::ts('The charge does not have an invoice, it cannot be imported.'));
}
......@@ -81,17 +81,14 @@ function civicrm_api3_stripe_importcharge($params) {
$sourceText = 'Stripe: Manual import via API';
}
$is_test = $paymentProcessor->getIsTestMode();
// Check for a subscription.
$subscription = CRM_Stripe_Api::getObjectParam('subscription_id', $stripeInvoice);
$contribution_recur_id = NULL;
if ($subscription) {
// Lookup the contribution_recur_id.
$cr_results = \Civi\Api4\ContributionRecur::get()
$cr_results = \Civi\Api4\ContributionRecur::get(FALSE)
->addWhere('trxn_id', '=', $subscription)
->addWhere('is_test', '=', $is_test)
->setCheckPermissions(FALSE)
->addWhere('is_test', '=', $paymentProcessor->getIsTestMode())
->execute();
$contribution_recur = $cr_results->first();
if (!$contribution_recur) {
......@@ -107,7 +104,6 @@ function civicrm_api3_stripe_importcharge($params) {
// We update these parameters regardless if it's a new contribution
// or an existing contributions.
$contributionParams['receive_date'] = CRM_Stripe_Api::getObjectParam('receive_date', $stripeInvoice);
$contributionParams['contribution_status_id'] = CRM_Stripe_Api::getObjectParam('status_id', $stripeInvoice);
$contributionParams['total_amount'] = CRM_Stripe_Api::getObjectParam('total_amount', $stripeInvoice);
// Check if a contribution already exists.
......@@ -118,10 +114,9 @@ function civicrm_api3_stripe_importcharge($params) {
}
else {
// Check database.
$c_results = \Civi\Api4\Contribution::get()
$c_results = \Civi\Api4\Contribution::get(FALSE)
->addWhere('trxn_id', 'LIKE', '%'. $params['charge'].'%')
->addWhere('is_test', '=', $is_test)
->setCheckPermissions(FALSE)
->addWhere('is_test', '=', $paymentProcessor->getIsTestMode())
->execute();
$contribution = $c_results->first();
if ($contribution) {
......@@ -140,17 +135,43 @@ function civicrm_api3_stripe_importcharge($params) {
$contributionParams['currency'] = CRM_Stripe_Api::getObjectParam('currency', $stripeInvoice);
$contributionParams['receive_date'] = CRM_Stripe_Api::getObjectParam('receive_date', $stripeInvoice);
$contributionParams['trxn_id'] = CRM_Stripe_Api::getObjectParam('charge_id', $stripeInvoice);
$contributionParams['contribution_status_id'] = CRM_Stripe_Api::getObjectParam('status_id', $stripeInvoice);
$contributionParams['payment_instrument_id'] = !empty($params['payment_instrument_id']) ? $params['payment_instrument_id'] : 'Credit Card';
$contributionParams['financial_type_id'] = !empty($params['financial_type_id']) ? $params['financial_type_id'] : 'Donation';
$contributionParams['is_test'] = isset($paymentProcessor['is_test']) && $paymentProcessor['is_test'] ? 1 : 0;
$contributionParams['is_test'] = $paymentProcessor->getIsTestMode();
$contributionParams['contribution_source'] = $sourceText;
$contributionParams['is_test'] = $is_test;
$contributionParams['contribution_status_id:name'] = 'Pending';
if ($contribution_recur_id) {
$contributionParams['contribution_recur_id'] = $contribution_recur_id;
}
}
$contribution = civicrm_api3('Contribution', 'create', $contributionParams);
return civicrm_api3_create_success($contribution['values']);
$contribution = \Civi\Api4\Contribution::create(FALSE)
->setValues($contributionParams)
->execute()
->first();
if (CRM_Stripe_Api::getObjectParam('status_id', $stripeInvoice) === 'Completed') {
$paymentParams = [
'contribution_id' => $contribution['id'],
'total_amount' => $contributionParams['total_amount'],
'trxn_date' => $contributionParams['receive_date'],
'payment_processor_id' => $params['ppid'],
'is_send_contribution_notification' => FALSE,
'trxn_id' => CRM_Stripe_Api::getObjectParam('charge_id', $stripeInvoice),
'order_reference' => CRM_Stripe_Api::getObjectParam('invoice_id', $stripeInvoice),
'payment_instrument_id' => $contributionParams['payment_instrument_id'],
];
if (!empty(CRM_Stripe_Api::getObjectParam('balance_transaction', $stripeCharge))) {
$stripeBalanceTransaction = $paymentProcessor->stripeClient->balanceTransactions->retrieve(
CRM_Stripe_Api::getObjectParam('balance_transaction', $stripeCharge)
);
$paymentParams['fee_amount'] = $stripeBalanceTransaction->fee / 100;
}
civicrm_api3('Payment', 'create', $paymentParams);
}
$contribution = \Civi\Api4\Contribution::get(FALSE)
->addWhere('id', '=', $contribution['id'])
->execute()
->first();
return civicrm_api3_create_success($contribution);
}
......@@ -26,6 +26,9 @@ use CRM_Stripe_ExtensionUtil as E;
function civicrm_api3_stripe_importsubscription($params) {
civicrm_api3_verify_mandatory($params, NULL, ['subscription', 'contact_id', 'ppid']);
$params['payment_instrument_id'] = $params['payment_instrument_id'] ?? 1; // Credit Card
$params['financial_type_id'] = $params['financial_type_id'] ?? 1; // Donation
/** @var \CRM_Core_Payment_Stripe $paymentProcessor */
$paymentProcessor = \Civi\Payment\System::singleton()->getById($params['ppid']);
$paymentProcessor->setAPIParams();
......@@ -37,13 +40,14 @@ function civicrm_api3_stripe_importsubscription($params) {
$customerParams = [
'customer_id' => CRM_Stripe_Api::getObjectParam('customer_id', $stripeSubscription),
'processor_id' => (int) $params['ppid'],
'contact_id' => $params['contact_id']
];
$custresult = civicrm_api3('StripeCustomer', 'get', $customerParams);
if ($custresult['count'] == 0) {
// Create the customer.
$customerParams['contact_id'] = $params['contact_id'];
$custresult = civicrm_api3('StripeCustomer', 'create', $customerParams);
civicrm_api3('StripeCustomer', 'create', $customerParams);
$custresult = civicrm_api3('StripeCustomer', 'get', $customerParams);
}
$customer = array_pop($custresult['values']);
......@@ -52,9 +56,12 @@ function civicrm_api3_stripe_importsubscription($params) {
}
// Create the recur record in CiviCRM if it doesn't exist.
$contributionRecur = civicrm_api3('ContributionRecur', 'get', ['trxn_id' => $params['subscription'] ]);
$contributionRecur = \Civi\Api4\ContributionRecur::get(FALSE)
->addWhere('processor_id', '=', $params['subscription'])
->execute()
->first();
if ($contributionRecur['count'] == 0) {
if (empty($contributionRecur)) {
$contributionRecurParams = [
'contact_id' => $params['contact_id'],
'amount' => CRM_Stripe_Api::getObjectParam('plan_amount', $stripeSubscription),
......@@ -63,13 +70,11 @@ function civicrm_api3_stripe_importsubscription($params) {
'frequency_interval' => CRM_Stripe_Api::getObjectParam('frequency_interval', $stripeSubscription),
'start_date' => CRM_Stripe_Api::getObjectParam('plan_start', $stripeSubscription),
'processor_id' => $params['subscription'],
'trxn_id' => $params['subscription'],
'contribution_status_id' => CRM_Stripe_Api::getObjectParam('status_id', $stripeSubscription),
'cycle_day' => CRM_Stripe_Api::getObjectParam('cycle_day', $stripeSubscription),
'auto_renew' => 1,
'payment_processor_id' => $params['ppid'],
'payment_instrument_id' => !empty($params['payment_instrument_id']) ? $params['payment_instrument_id'] : 'Credit Card',
'financial_type_id' => !empty($params['financial_type_id']) ? $params['financial_type_id'] : 'Donation',
'payment_instrument_id' => $params['payment_instrument_id'],
'financial_type_id' => $params['financial_type_id'],
'is_email_receipt' => !empty($params['is_email_receipt']) ? 1 : 0,
'is_test' => $paymentProcessor->getIsTestMode(),
'contribution_source' => !empty($params['contribution_source']) ? $params['contribution_source'] : '',
......@@ -77,7 +82,10 @@ function civicrm_api3_stripe_importsubscription($params) {
if (isset($params['recur_id']) && $params['recur_id']) {
$contributionRecurParams['id'] = $params['recur_id'];
}
$contributionRecur = civicrm_api3('ContributionRecur', 'create', $contributionRecurParams);
$contributionRecur = \Civi\Api4\ContributionRecur::create(FALSE)
->setValues($contributionRecurParams)
->execute()
->first();
}
// Get the invoices for the subscription
$invoiceParams = [
......@@ -85,58 +93,81 @@ function civicrm_api3_stripe_importsubscription($params) {
'limit' => 100,
];
$stripeInvoices = $paymentProcessor->stripeClient->invoices->all($invoiceParams);
foreach ($stripeInvoices->data as $stripeInvoice) {
if (CRM_Stripe_Api::getObjectParam('subscription_id', $stripeInvoice) === $params['subscription']) {
$charge = CRM_Stripe_Api::getObjectParam('charge_id', $stripeInvoice);
if (empty($charge)) {
continue;
}
$exists_params = [
'contribution_test' => $paymentProcessor->getIsTestMode(),
'trxn_id' => $charge
];
$contribution = civicrm_api3('Mjwpayment', 'get_contribution', $exists_params);
if ($contribution['count'] == 0) {
// It has not been imported, so import it now.
$charge_params = [
'charge' => $charge,
'financial_type_id' => $params['financial_type_id'],
'payment_instrument_id' => $params['payment_instrument_id'],
'ppid' => $params['ppid'],
'contact_id' => $params['contact_id'],
'contribution_source' => ($params['contribution_source'] ?? ''),
];
$contribution = civicrm_api3('Stripe', 'Importcharge', $charge_params);
// Link to membership record
// By default we'll match the latest active membership, unless membership_id is passed in.
if (!empty($params['membership_id'])) {
$membershipParams = [
'id' => $params['membership_id'],
'contribution_recur_id' => $contributionRecur['id'],
];
$membership = civicrm_api3('Membership', 'create', $membershipParams);
if ($stripeInvoices->count()) {
// We have one or more invoices to import (as contributions)
foreach ($stripeInvoices->data as $stripeInvoice) {
if (CRM_Stripe_Api::getObjectParam('subscription_id', $stripeInvoice) === $params['subscription']) {
$charge = CRM_Stripe_Api::getObjectParam('charge_id', $stripeInvoice);
if (empty($charge)) {
continue;
}
elseif (!empty($params['membership_auto'])) {
$membershipParams = [
$exists_params = [
'contribution_test' => $paymentProcessor->getIsTestMode(),
'trxn_id' => $charge
];
$contribution = civicrm_api3('Mjwpayment', 'get_contribution', $exists_params);
if ($contribution['count'] == 0) {
// It has not been imported, so import it now.
$charge_params = [
'charge' => $charge,
'financial_type_id' => $params['financial_type_id'],
'payment_instrument_id' => $params['payment_instrument_id'],
'ppid' => $params['ppid'],
'contact_id' => $params['contact_id'],
'options' => ['limit' => 1, 'sort' => "id DESC"],
'contribution_recur_id' => ['IS NULL' => 1],
'is_test' => $paymentProcessor->getIsTestMode(),
'active_only' => 1,
'contribution_source' => ($params['contribution_source'] ?? ''),
];
$membership = civicrm_api3('Membership', 'get', $membershipParams);
if (!empty($membership['id'])) {
$contribution = civicrm_api3('Stripe', 'Importcharge', $charge_params);
// Link to membership record
// By default we'll match the latest active membership, unless membership_id is passed in.
if (!empty($params['membership_id'])) {
$membershipParams = [
'id' => $membership['id'],
'id' => $params['membership_id'],
'contribution_recur_id' => $contributionRecur['id'],
];
$membership = civicrm_api3('Membership', 'create', $membershipParams);
}
elseif (!empty($params['membership_auto'])) {
$membershipParams = [
'contact_id' => $params['contact_id'],
'options' => ['limit' => 1, 'sort' => "id DESC"],
'contribution_recur_id' => ['IS NULL' => 1],
'is_test' => $paymentProcessor->getIsTestMode(),
'active_only' => 1,
];
$membership = civicrm_api3('Membership', 'get', $membershipParams);
if (!empty($membership['id'])) {
$membershipParams = [
'id' => $membership['id'],
'contribution_recur_id' => $contributionRecur['id'],
];
civicrm_api3('Membership', 'create', $membershipParams);
}
}
}
}
}
}
else {
// We have no invoices to import. This will be for one of the following reasons:
// - Stripe subscription is in a free "trial" period.
// - Stripe subscription has not yet reached the start date.
// In this case we have to create a template contribution (see https://lab.civicrm.org/dev/financial/-/issues/6)
// because CiviCRM currently expects at least 1 contribution per ContributionRecur.
$contribution = \Civi\Api4\Contribution::create(FALSE)
->addValue('contribution_recur_id', $contributionRecur['id'])
->addValue('contact_id', $contributionRecur['contact_id'])
->addValue('financial_type_id', $contributionRecur['financial_type_id'])
->addValue('payment_instrument_id', $contributionRecur['payment_instrument_id'])
->addValue('source', $params['contribution_source'] ?? '')
->addValue('total_amount', $contributionRecur['amount'])
->addValue('currency', $contributionRecur['currency'])
->addValue('is_test', $paymentProcessor->getIsTestMode())
->addValue('is_template', TRUE)
->addValue('contribution_status_id:name', 'Template')
->execute()
->first();
}
$results = [
'subscription' => $params['subscription'],
......
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/
/**
* @file
*
* The purpose of these tests is to test this extension's code. We are not
* focussed on testing that the StripeAPI behaves as it should, and therefore
* we mock the Stripe API. This approach enables us to focus on our code,
* removes external factors like network connectivity, and enables tests to
* run quickly.
*
* Gotchas for developers new to phpunit's mock objects
*
* - once you have created a mock and called method('x') you cannot call
* method('x') again; you'll need to make a new mock.
* - $this->any() refers to an argument for a with() matcher.
* - $this->anything() refers to a method for a method() matcher.
*
*/
/**
* Stripe (CiviCRM) API3 tests
*
* @group headless
*/
require ('BaseTest.php');
class CRM_Stripe_ApiTest extends CRM_Stripe_BaseTest {
protected $contributionRecurID;
protected $created_ts;
protected $contributionRecur = [
'frequency_unit' => 'month',
'frequency_interval' => 1,
'installments' => 5,
];
// This test is particularly dirty for some reason so we have to
// force a reset.
public function setUpHeadless() {
$force = FALSE;
return \Civi\Test::headless()
->install('mjwshared')
->installMe(__DIR__)
->apply($force);
}
/**
* Test importing a subscription which has one paid invoice
* This also tests the Stripe.Importcharge API
*/
public function testImportSubscription() {
$this->mockStripeSubscription();
/** @var \CRM_Core_Payment_Stripe $paymentProcessor */
$paymentProcessor = \Civi\Payment\System::singleton()->getById($this->paymentProcessorID);
$paymentProcessor->setAPIParams();
$result = civicrm_api3('Stripe', 'importsubscription', [
'subscription' => 'sub_mock',
'contact_id' => $this->contactID,
'ppid' => $this->paymentProcessorID
]);
$this->contributionID = $result['values']['contribution_id'];
$this->contributionRecurID = $result['values']['recur_id'];
//
// Check the Contribution
// ...should be Completed
// ...its transaction ID should be our Charge ID.
//
$this->checkContrib([
'contribution_status_id' => 'Completed',
'trxn_id' => 'ch_mock',
]);
//
// Check the ContributionRecur
//
// The subscription ID should be in both processor_id and trxn_id fields
// We expect it to be 'In Progress' (because we have a Completed contribution).
$this->checkContribRecur([
'contribution_status_id' => 'In Progress',
'trxn_id' => 'sub_mock',
'processor_id' => 'sub_mock',
]);
// Check the payment. It should have trxn_id=Stripe charge ID and order_reference=Stripe Invoice ID
$this->checkPayment([
// Completed
'status_id' => CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed'),
'trxn_id' => 'ch_mock',
'order_reference' => 'in_mock',
]);
}
/**
* Test importing a subscription which has no paid invoices
* This can happen if start_date is in the future or subscription has a free trial period
* In this case we create a template contribution
* This also tests the Stripe.Importcharge API
*/
public function testImportSubscriptionWithNoInvoice() {
$this->mockStripeSubscription(['hasPaidInvoice' => FALSE]);
/** @var \CRM_Core_Payment_Stripe $paymentProcessor */
$paymentProcessor = \Civi\Payment\System::singleton()->getById($this->paymentProcessorID);
$paymentProcessor->setAPIParams();
$result = civicrm_api3('Stripe', 'importsubscription', [
'subscription' => 'sub_mock',
'contact_id' => $this->contactID,
'ppid' => $this->paymentProcessorID
]);
$this->contributionID = $result['values']['contribution_id'];
$this->contributionRecurID = $result['values']['recur_id'];
//
// Check the Contribution
// ...should be Completed
// ...its transaction ID should be our Charge ID.
//
$this->checkContrib([
'contribution_status_id' => 'Template',
'trxn_id' => '',
'is_template' => TRUE
]);
//
// Check the ContributionRecur
//
// The subscription ID should be in both processor_id and trxn_id fields
// We expect it to be 'In Progress' (because we have a Completed contribution).
$this->checkContribRecur([
'contribution_status_id' => 'Pending',
'trxn_id' => 'sub_mock',
'processor_id' => 'sub_mock',
]);
}
/**
* DRY code. Sets up the Stripe objects needed to import a subscription
*
* 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 \PHPUnit\Framework\MockObject\MockObject
*/
protected function mockStripeSubscription($subscriptionParams = []) {
$subscriptionParams['hasPaidInvoice'] = $subscriptionParams['hasPaidInvoice'] ?? TRUE;
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
// instantiated separately will also use it.
$this->paymentObject->setMockStripeClient($stripeClient);
// Mock the Customers service
$stripeClient->customers = $this->createMock('Stripe\\Service\\CustomerService');
$stripeClient->customers
->method('create')
->willReturn(
new PropertySpy('customers.create', ['id' => 'cus_mock'])
);
$stripeClient->customers
->method('retrieve')
->with($this->equalTo('cus_mock'))
->willReturn(
new PropertySpy('customers.retrieve', ['id' => 'cus_mock'])
);
$mockPlan = $this->createMock('Stripe\\Plan');
$mockPlan
->method('__get')
->will($this->returnValueMap([
['id', 'every-1-month-' . ($this->total * 100) . '-usd-test'],
['amount', $this->total*100],
['currency', 'usd'],
['interval_count', $this->contributionRecur['frequency_interval']],
['interval', $this->contributionRecur['frequency_unit']],
]));
$stripeClient->plans = $this->createMock('Stripe\\Service\\PlanService');
$stripeClient->plans
->method('retrieve')
->willReturn($mockPlan);
$mockSubscriptionParams = [
'id' => 'sub_mock',
'object' => 'subscription',
'customer' => 'cus_mock',
'current_period_end' => time()+60*60*24,
'pending_setup_intent' => '',
'plan' => $mockPlan,
'start_date' => time(),
];
if ($subscriptionParams['hasPaidInvoice']) {
// Need a mock intent with id and status
$mockCharge = $this->createMock('Stripe\\Charge');
$mockCharge
->method('__get')
->will($this->returnValueMap([
['id', 'ch_mock'],
['object', 'charge'],
['captured', TRUE],
['status', 'succeeded'],
['balance_transaction', 'txn_mock'],
['invoice', 'in_mock']
]));
$mockChargesCollection = new \Stripe\Collection();
$mockChargesCollection->data = [$mockCharge];
$mockPaymentIntent = $this->createMock('Stripe\\PaymentIntent');
$mockPaymentIntent
->method('__get')
->will($this->returnValueMap([
['id', 'pi_mock'],
['status', 'succeeded'],
['charges', $mockChargesCollection]
]));
$mockSubscriptionParams['latest_invoice'] = [
'id' => 'in_mock',
'payment_intent' => $mockPaymentIntent,
];
}
$mockSubscription = new PropertySpy('subscription.create', $mockSubscriptionParams);
$stripeClient->subscriptions = $this->createMock('Stripe\\Service\\SubscriptionService');
$stripeClient->subscriptions
->method('create')
->willReturn($mockSubscription);
$stripeClient->subscriptions
->method('retrieve')
->with($this->equalTo('sub_mock'))
->willReturn($mockSubscription);
if ($subscriptionParams['hasPaidInvoice']) {
$stripeClient->balanceTransactions = $this->createMock('Stripe\\Service\\BalanceTransactionService');
$stripeClient->balanceTransactions
->method('retrieve')
->with($this->equalTo('txn_mock'))
->willReturn(new PropertySpy('balanceTransaction', [
'id' => 'txn_mock',
'fee' => 1190, /* means $11.90 */
'currency' => 'usd',
'exchange_rate' => NULL,
'object' => 'balance_transaction',
]));
$mockCharge = new PropertySpy('Charge', [
'id' => 'ch_mock',
'object' => 'charge',
'captured' => TRUE,
'status' => 'succeeded',
'balance_transaction' => 'txn_mock',
'invoice' => 'in_mock'
]);
$stripeClient->charges = $this->createMock('Stripe\\Service\\ChargeService');
$stripeClient->charges
->method('retrieve')
->with($this->equalTo('ch_mock'))
->willReturn($mockCharge);
$mockInvoice = new PropertySpy('Invoice', [
'amount_due' => $this->total * 100,
'charge' => 'ch_mock', //xxx
'created' => time(),
'currency' => 'usd',
'customer' => 'cus_mock',
'id' => 'in_mock',
'object' => 'invoice',
'subscription' => 'sub_mock',
'paid' => TRUE
]);
$mockInvoicesCollection = new \Stripe\Collection();
$mockInvoicesCollection->data = [$mockInvoice];
$stripeClient->invoices = $this->createMock('Stripe\\Service\\InvoiceService');
$stripeClient->invoices
->method('all')
->willReturn($mockInvoicesCollection);
$stripeClient->invoices
->method('retrieve')
->with($this->equalTo('in_mock'))
->willReturn($mockInvoice);
}
else {
// No invoices
$mockInvoicesCollection = new \Stripe\Collection();
$mockInvoicesCollection->data = [];
$stripeClient->invoices = $this->createMock('Stripe\\Service\\InvoiceService');