3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
13 * Class CRM_Contribute_BAO_ContributionRecurTest
16 class CRM_Contribute_BAO_ContributionRecurTest
extends CiviUnitTestCase
{
18 use CRMTraits_Financial_OrderTrait
;
23 * @throws \CRM_Core_Exception
25 public function setUp() {
27 $this->_ids
['payment_processor'] = $this->paymentProcessorCreate();
29 'contact_id' => $this->individualCreate(),
31 'frequency_unit' => 'week',
32 'frequency_interval' => 1,
34 'start_date' => 'yesterday',
35 'create_date' => 'yesterday',
36 'modified_date' => 'yesterday',
37 'cancel_date' => NULL,
38 'end_date' => '+ 2 weeks',
39 'processor_id' => '643411460836',
40 'trxn_id' => 'e0d0808e26f3e661c6c18eb7c039d363',
41 'invoice_id' => 'e0d0808e26f3e661c6c18eb7c039d363',
42 'contribution_status_id' => 1,
45 'next_sched_contribution_date' => '+ 1 week',
47 'failure_retry_date' => NULL,
50 'payment_processor_id' => $this->_ids
['payment_processor'],
51 'is_email_receipt' => 1,
52 'financial_type_id' => 1,
53 'payment_instrument_id' => 1,
54 'campaign_id' => NULL,
61 * @throws \CRM_Core_Exception
63 public function teardown() {
64 $this->quickCleanUpFinancialEntities();
68 * Test that an object can be retrieved & saved (per CRM-14986).
70 * This has been causing a DB error so we are checking for absence of error
72 * @throws \CRM_Core_Exception
74 public function testFindSave() {
75 $contributionRecur = $this->callAPISuccess('contribution_recur', 'create', $this->_params
);
76 $dao = new CRM_Contribute_BAO_ContributionRecur();
77 $dao->id
= $contributionRecur['id'];
79 $dao->is_email_receipt
= 0;
84 * Test cancellation works per CRM-14986.
86 * We are checking for absence of error.
88 * @throws \CRM_Core_Exception
90 public function testCancelRecur() {
91 $contributionRecur = $this->callAPISuccess('contribution_recur', 'create', $this->_params
);
92 CRM_Contribute_BAO_ContributionRecur
::cancelRecurContribution(['id' => $contributionRecur['id']]);
96 * Test checking if contribution recur object can allow for changes to financial types.
98 * @throws \CRM_Core_Exception
100 public function testSupportFinancialTypeChange() {
101 $contributionRecur = $this->callAPISuccess('contribution_recur', 'create', $this->_params
);
102 $this->callAPISuccess('Contribution', 'create', [
103 'contribution_recur_id' => $contributionRecur['id'],
104 'total_amount' => '3.00',
105 'financial_type_id' => 1,
106 'payment_instrument_id' => 1,
108 'contact_id' => $this->individualCreate(),
109 'contribution_status_id' => 1,
110 'receive_date' => 'yesterday',
112 $this->assertTrue(CRM_Contribute_BAO_ContributionRecur
::supportsFinancialTypeChange($contributionRecur['id']));
116 * Test we don't change unintended fields on API edit
118 * @throws \CRM_Core_Exception
120 public function testUpdateRecur() {
121 $createParams = $this->_params
;
122 $createParams['currency'] = 'XAU';
123 $contributionRecur = $this->callAPISuccess('contribution_recur', 'create', $createParams);
125 'id' => $contributionRecur['id'],
126 'end_date' => '+ 4 weeks',
128 $contributionRecur = $this->callAPISuccess('contribution_recur', 'create', $editParams);
129 $dao = new CRM_Contribute_BAO_ContributionRecur();
130 $dao->id
= $contributionRecur['id'];
132 $this->assertEquals('XAU', $dao->currency
, 'Edit clobbered recur currency');
136 * Check test contributions aren't picked up as template for non-test recurs
138 * @throws \API_Exception
139 * @throws \CRM_Core_Exception
140 * @throws \CiviCRM_API3_Exception
141 * @throws \Civi\API\Exception\UnauthorizedException
143 public function testGetTemplateContributionMatchTest1() {
144 $contributionRecur = $this->callAPISuccess('contribution_recur', 'create', $this->_params
);
145 // Create a first contrib
146 $firstContrib = $this->callAPISuccess('Contribution', 'create', [
147 'contribution_recur_id' => $contributionRecur['id'],
148 'total_amount' => '3.00',
149 'financial_type_id' => 1,
150 'payment_instrument_id' => 1,
152 'contact_id' => $this->individualCreate(),
153 'contribution_status_id' => 1,
154 'receive_date' => 'yesterday',
156 // Create a test contrib - should not be picked up as template for non-test recur
157 $this->callAPISuccess('Contribution', 'create', [
158 'contribution_recur_id' => $contributionRecur['id'],
159 'total_amount' => '3.00',
160 'financial_type_id' => 1,
161 'payment_instrument_id' => 1,
163 'contact_id' => $this->individualCreate(),
164 'contribution_status_id' => 1,
165 'receive_date' => 'yesterday',
168 $fetchedTemplate = CRM_Contribute_BAO_ContributionRecur
::getTemplateContribution($contributionRecur['id']);
169 $this->assertEquals($firstContrib['id'], $fetchedTemplate['id']);
173 * Check non-test contributions aren't picked up as template for test recurs
175 * @throws \API_Exception
176 * @throws \CRM_Core_Exception
177 * @throws \CiviCRM_API3_Exception
178 * @throws \Civi\API\Exception\UnauthorizedException
180 public function testGetTemplateContributionMatchTest() {
181 $params = $this->_params
;
182 $params['is_test'] = 1;
183 $contributionRecur = $this->callAPISuccess('contribution_recur', 'create', $params);
184 // Create a first test contrib
185 $firstContrib = $this->callAPISuccess('Contribution', 'create', [
186 'contribution_recur_id' => $contributionRecur['id'],
187 'total_amount' => '3.00',
188 'financial_type_id' => 1,
189 'payment_instrument_id' => 1,
191 'contact_id' => $this->individualCreate(),
192 'contribution_status_id' => 1,
193 'receive_date' => 'yesterday',
196 // Create a non-test contrib - should not be picked up as template for non-test recur
197 // This shouldn't occur - a live contrib against a test recur, but that's not the point...
198 $this->callAPISuccess('Contribution', 'create', [
199 'contribution_recur_id' => $contributionRecur['id'],
200 'total_amount' => '3.00',
201 'financial_type_id' => 1,
202 'payment_instrument_id' => 1,
204 'contact_id' => $this->individualCreate(),
205 'contribution_status_id' => 1,
206 'receive_date' => 'yesterday',
209 $fetchedTemplate = CRM_Contribute_BAO_ContributionRecur
::getTemplateContribution($contributionRecur['id']);
210 $this->assertEquals($firstContrib['id'], $fetchedTemplate['id']);
214 * Test that is_template contribution is used where available
216 * @throws \API_Exception
217 * @throws \CRM_Core_Exception
218 * @throws \CiviCRM_API3_Exception
219 * @throws \Civi\API\Exception\UnauthorizedException
221 public function testGetTemplateContributionNewTemplate() {
222 $contributionRecur = $this->callAPISuccess('contribution_recur', 'create', $this->_params
);
223 // Create the template
224 $templateContrib = $this->callAPISuccess('Contribution', 'create', [
225 'contribution_recur_id' => $contributionRecur['id'],
226 'total_amount' => '3.00',
227 'financial_type_id' => 1,
228 'source' => 'Template Contribution',
229 'payment_instrument_id' => 1,
231 'contact_id' => $this->individualCreate(),
232 'contribution_status_id' => 1,
233 'receive_date' => 'yesterday',
236 // Create another normal contrib
237 $this->callAPISuccess('Contribution', 'create', [
238 'contribution_recur_id' => $contributionRecur['id'],
239 'total_amount' => '3.00',
240 'financial_type_id' => 1,
241 'source' => 'Non-template Contribution',
242 'payment_instrument_id' => 1,
244 'contact_id' => $this->individualCreate(),
245 'contribution_status_id' => 1,
246 'receive_date' => 'yesterday',
248 $fetchedTemplate = CRM_Contribute_BAO_ContributionRecur
::getTemplateContribution($contributionRecur['id']);
249 // Fetched template should be the is_template, not the latest contrib
250 $this->assertEquals($fetchedTemplate['id'], $templateContrib['id']);
252 $repeatContribution = $this->callAPISuccess('Contribution', 'repeattransaction', [
253 'contribution_status_id' => 'Completed',
254 'contribution_recur_id' => $contributionRecur['id'],
256 $this->assertEquals('Template Contribution', $repeatContribution['values'][$repeatContribution['id']]['source']);
257 $this->assertEquals('AUD', $repeatContribution['values'][$repeatContribution['id']]['currency']);
261 * Test to check if correct membership is auto renewed.
263 * @throws \CRM_Core_Exception
265 public function testAutoRenewalWhenOneMemberIsDeceased() {
266 $contactId1 = $this->individualCreate();
267 $contactId2 = $this->individualCreate();
268 $membershipOrganizationId = $this->organizationCreate();
270 $this->createExtraneousContribution();
271 $this->callAPISuccess('Contribution', 'create', [
272 'contact_id' => $contactId1,
273 'receive_date' => '2010-01-20',
274 'financial_type_id' => 'Member Dues',
275 'contribution_status_id' => 'Completed',
276 'total_amount' => 150,
279 // create membership type
280 $membershipTypeId1 = $this->callAPISuccess('MembershipType', 'create', [
282 'member_of_contact_id' => $membershipOrganizationId,
283 'financial_type_id' => 'Member Dues',
284 'duration_unit' => 'month',
285 'duration_interval' => 1,
286 'period_type' => 'rolling',
287 'minimum_fee' => 100,
291 $membershipTypeID = $this->callAPISuccess('MembershipType', 'create', [
293 'member_of_contact_id' => $membershipOrganizationId,
294 'financial_type_id' => 'Member Dues',
295 'duration_unit' => 'month',
296 'duration_interval' => 1,
297 'period_type' => 'rolling',
303 $contactId1 => $membershipTypeId1,
304 $contactId2 => $membershipTypeID,
307 $contributionRecurId = $this->callAPISuccess('contribution_recur', 'create', $this->_params
)['id'];
309 $priceFields = CRM_Price_BAO_PriceSet
::getDefaultPriceSet('membership');
311 // prepare order api params.
313 'contact_id' => $contactId1,
314 'receive_date' => '2010-01-20',
315 'financial_type_id' => 'Member Dues',
316 'contribution_recur_id' => $contributionRecurId,
317 'total_amount' => 150,
318 'api.Payment.create' => ['total_amount' => 150],
321 foreach ($priceFields as $priceField) {
323 $contactId = array_search($priceField['membership_type_id'], $contactIDs);
325 'price_field_id' => $priceField['priceFieldID'],
326 'price_field_value_id' => $priceField['priceFieldValueID'],
327 'label' => $priceField['label'],
328 'field_title' => $priceField['label'],
330 'unit_price' => $priceField['amount'],
331 'line_total' => $priceField['amount'],
332 'financial_type_id' => $priceField['financial_type_id'],
333 'entity_table' => 'civicrm_membership',
334 'membership_type_id' => $priceField['membership_type_id'],
336 $params['line_items'][] = [
337 'line_item' => $lineItems,
339 'contact_id' => $contactId,
340 'membership_type_id' => $priceField['membership_type_id'],
341 'source' => 'Payment',
342 'join_date' => date('Y-m', strtotime('1 month ago')) . '-28',
343 'start_date' => date('Y-m') . '-28',
344 'contribution_recur_id' => $contributionRecurId,
345 'status_id' => 'Pending',
350 $order = $this->callAPISuccess('Order', 'create', $params);
351 $contributionId = $order['id'];
352 $membershipId1 = $this->callAPISuccessGetValue('Membership', [
353 'contact_id' => $contactId1,
354 'membership_type_id' => $membershipTypeId1,
358 $membershipId2 = $this->callAPISuccessGetValue('Membership', [
359 'contact_id' => $contactId2,
360 'membership_type_id' => $membershipTypeID,
364 // First renewal (2nd payment).
365 $this->callAPISuccess('Contribution', 'repeattransaction', [
366 'original_contribution_id' => $contributionId,
367 'contribution_status_id' => 'Completed',
370 // Second Renewal (3rd payment).
371 $this->callAPISuccess('Contribution', 'repeattransaction', [
372 'original_contribution_id' => $contributionId,
373 'contribution_status_id' => 'Completed',
376 // Third renewal (4th payment).
377 $this->callAPISuccess('Contribution', 'repeattransaction', ['original_contribution_id' => $contributionId, 'contribution_status_id' => 'Completed']);
379 // check line item and membership payment count.
380 $this->validateAllCounts($membershipId1, 4);
381 $this->validateAllCounts($membershipId2, 4);
383 $expectedDate = $this->getYearAndMonthFromOffset(4);
384 // check membership end date.
385 foreach ([$membershipId1, $membershipId2] as $mId) {
386 $endDate = $this->callAPISuccessGetValue('Membership', [
388 'return' => 'end_date',
390 $this->assertEquals("{$expectedDate['year']}-{$expectedDate['month']}-27", $endDate, ts('End date incorrect.'));
393 // At this moment Contact 2 is deceased, but we wait until payment is recorded in civi before marking the contact deceased.
394 // At payment Gateway we update the amount from 150 to 100
395 // IPN is recorded for subsequent payment (5th payment).
396 $contribution = $this->callAPISuccess('Contribution', 'repeattransaction', [
397 'original_contribution_id' => $contributionId,
398 'contribution_status_id' => 'Completed',
399 'total_amount' => '100',
402 // now we mark the contact2 as deceased.
403 $this->callAPISuccess('Contact', 'create', [
408 // We delete latest membership payment and line item.
409 $lineItemId = $this->callAPISuccessGetValue('LineItem', [
410 'contribution_id' => $contribution['id'],
411 'entity_id' => $membershipId2,
412 'entity_table' => 'civicrm_membership',
416 // No api to delete membership payment.
417 CRM_Core_DAO
::executeQuery('
418 DELETE FROM civicrm_membership_payment
419 WHERE contribution_id = %1
420 AND membership_id = %2
422 1 => [$contribution['id'], 'Integer'],
423 2 => [$membershipId2, 'Integer'],
426 $this->callAPISuccess('LineItem', 'delete', [
430 // set membership recurring to null.
431 $this->callAPISuccess('Membership', 'create', [
432 'id' => $membershipId2,
433 'contribution_recur_id' => NULL,
436 // check line item and membership payment count.
437 $this->validateAllCounts($membershipId1, 5);
438 $this->validateAllCounts($membershipId2, 4);
440 $checkAgainst = $this->callAPISuccessGetSingle('Membership', [
441 'id' => $membershipId2,
442 'return' => ['end_date', 'status_id'],
445 // record next subsequent payment (6th payment).
446 $this->callAPISuccess('Contribution', 'repeattransaction', [
447 'original_contribution_id' => $contributionId,
448 'contribution_status_id' => 'Completed',
449 'total_amount' => '100',
452 // check membership id 1 is renewed
453 $endDate = $this->callAPISuccessGetValue('Membership', [
454 'id' => $membershipId1,
455 'return' => 'end_date',
457 $expectedDate = $this->getYearAndMonthFromOffset(6);
458 $this->assertEquals("{$expectedDate['year']}-{$expectedDate['month']}-27", $endDate, ts('End date incorrect.'));
459 // check line item and membership payment count.
460 $this->validateAllCounts($membershipId1, 6);
461 $this->validateAllCounts($membershipId2, 4);
463 // check if membership status and end date is not changed.
464 $membership2 = $this->callAPISuccessGetSingle('Membership', [
465 'id' => $membershipId2,
466 'return' => ['end_date', 'status_id'],
468 $this->assertSame($membership2, $checkAgainst);
472 * Check line item and membership payment count.
474 * @param int $membershipId
477 * @throws \CRM_Core_Exception
479 public function validateAllCounts($membershipId, $count) {
481 'membership_id' => $membershipId,
484 'entity_id' => $membershipId,
485 'entity_table' => 'civicrm_membership',
487 $this->callAPISuccessGetCount('LineItem', $lineItemParams, $count);
488 $this->callAPISuccessGetCount('MembershipPayment', $memPayParams, $count);
492 * Given a number of months offset, get the year and month.
493 * Note the way php arithmetic works, using strtotime('+x months') doesn't
494 * work because it will roll over the day accounting for different number
495 * of days in the month, but we want the same day of the month, x months
497 * e.g. July 31 + 4 months will return Dec 1 if using php functions, but
501 * @param int $year Optional input year to start
502 * @param int $month Optional input month to start
505 * ['year' => int, 'month' => int]
507 private function getYearAndMonthFromOffset(int $offset, int $year = NULL, int $month = NULL) {
509 'year' => $year ??
date('Y'),
510 'month' => ($month ??
date('m')) +
$offset,
512 if ($dateInfo['month'] > 12) {
514 $dateInfo['month'] -= 12;
516 if ($dateInfo['month'] < 10) {
517 $dateInfo['month'] = "0{$dateInfo['month']}";
524 * Test getYearAndMonthFromOffset
525 * @dataProvider yearMonthProvider
527 * @param array $input
528 * @param array $expected
530 public function testGetYearAndMonthFromOffset($input, $expected) {
531 $this->assertEquals($expected, $this->getYearAndMonthFromOffset($input[0], $input[1], $input[2]));
535 * data provider for testGetYearAndMonthFromOffset
537 public function yearMonthProvider() {
539 // input = offset, year, current month
540 ['input' => [4, 2020, 1], 'output' => ['year' => '2020', 'month' => '05']],
541 ['input' => [6, 2020, 1], 'output' => ['year' => '2020', 'month' => '07']],
542 ['input' => [4, 2020, 2], 'output' => ['year' => '2020', 'month' => '06']],
543 ['input' => [6, 2020, 2], 'output' => ['year' => '2020', 'month' => '08']],
544 ['input' => [4, 2020, 3], 'output' => ['year' => '2020', 'month' => '07']],
545 ['input' => [6, 2020, 3], 'output' => ['year' => '2020', 'month' => '09']],
546 ['input' => [4, 2020, 4], 'output' => ['year' => '2020', 'month' => '08']],
547 ['input' => [6, 2020, 4], 'output' => ['year' => '2020', 'month' => '10']],
548 ['input' => [4, 2020, 5], 'output' => ['year' => '2020', 'month' => '09']],
549 ['input' => [6, 2020, 5], 'output' => ['year' => '2020', 'month' => '11']],
550 ['input' => [4, 2020, 6], 'output' => ['year' => '2020', 'month' => '10']],
551 ['input' => [6, 2020, 6], 'output' => ['year' => '2020', 'month' => '12']],
552 ['input' => [4, 2020, 7], 'output' => ['year' => '2020', 'month' => '11']],
553 ['input' => [6, 2020, 7], 'output' => ['year' => '2021', 'month' => '01']],
554 ['input' => [4, 2020, 8], 'output' => ['year' => '2020', 'month' => '12']],
555 ['input' => [6, 2020, 8], 'output' => ['year' => '2021', 'month' => '02']],
556 ['input' => [4, 2020, 9], 'output' => ['year' => '2021', 'month' => '01']],
557 ['input' => [6, 2020, 9], 'output' => ['year' => '2021', 'month' => '03']],
558 ['input' => [4, 2020, 10], 'output' => ['year' => '2021', 'month' => '02']],
559 ['input' => [6, 2020, 10], 'output' => ['year' => '2021', 'month' => '04']],
560 ['input' => [4, 2020, 11], 'output' => ['year' => '2021', 'month' => '03']],
561 ['input' => [6, 2020, 11], 'output' => ['year' => '2021', 'month' => '05']],
562 ['input' => [4, 2020, 12], 'output' => ['year' => '2021', 'month' => '04']],
563 ['input' => [6, 2020, 12], 'output' => ['year' => '2021', 'month' => '06']],