From 6c76546d67fab9889adbe68ecf3d1322fcf8312e Mon Sep 17 00:00:00 2001 From: Eileen McNaughton Date: Sat, 2 Jul 2022 12:42:02 +1200 Subject: [PATCH] RepeatTransaction - separate out repeat pledge handling from complete --- CRM/Contribute/BAO/Contribution.php | 56 ++-------- CRM/Contribute/BAO/ContributionRecur.php | 77 +++++--------- CRM/Pledge/BAO/PledgePayment.php | 118 ++++++++++++++++------ tests/phpunit/api/v3/ContributionTest.php | 54 ++++++++-- 4 files changed, 167 insertions(+), 138 deletions(-) diff --git a/CRM/Contribute/BAO/Contribution.php b/CRM/Contribute/BAO/Contribution.php index 28ed2129bb..b951356b50 100644 --- a/CRM/Contribute/BAO/Contribution.php +++ b/CRM/Contribute/BAO/Contribution.php @@ -16,7 +16,6 @@ use Civi\Api4\ContributionRecur; use Civi\Api4\LineItem; use Civi\Api4\ContributionSoft; use Civi\Api4\PaymentProcessor; -use Civi\Api4\PledgePayment; /** * @@ -506,11 +505,6 @@ class CRM_Contribute_BAO_Contribution extends CRM_Contribute_DAO_Contribution im CRM_Contribute_BAO_ContributionSoft::processSoftContribution($params, $contribution); - if (!empty($params['id']) && !empty($params['contribution_status_id']) - && CRM_Core_Component::isEnabled('CiviPledge') - ) { - self::disconnectPledgePaymentsIfCancelled((int) $params['id'], CRM_Core_PseudoConstant::getName('CRM_Contribute_BAO_Contribution', 'contribution_status_id', $params['contribution_status_id'])); - } $transaction->commit(); if (empty($contribution->contact_id)) { @@ -1107,44 +1101,6 @@ class CRM_Contribute_BAO_Contribution extends CRM_Contribute_DAO_Contribution im return TRUE; } - /** - * Disconnect pledge payments from cancelled or failed contributions. - * - * If the contribution has been cancelled or has failed check to - * see if it is linked to a pledge and unlink it. - * - * @param int $pledgePaymentID - * @param string $contributionStatus - * - * @throws \API_Exception - * @throws \Civi\API\Exception\UnauthorizedException - */ - protected static function disconnectPledgePaymentsIfCancelled(int $pledgePaymentID, $contributionStatus): void { - if (!in_array($contributionStatus, ['Failed', 'Cancelled'], TRUE)) { - return; - } - // Check first since just doing an update could be locking under load. - $pledgePayment = PledgePayment::get(FALSE) - ->addWhere('contribution_id', '=', $pledgePaymentID) - ->setSelect(['id', 'pledge_id', 'scheduled_date', 'scheduled_amount']) - ->execute() - ->first(); - if (!empty($pledgePayment)) { - PledgePayment::update(FALSE)->setValues([ - 'contribution_id' => NULL, - 'actual_amount' => NULL, - 'status_id:name' => 'Pending', - // We need to set these fields for now because the PledgePayment::create - // function doesn't handled updates well at the moment. Test cover - // in testCancelOrderWithPledge. - 'scheduled_date' => $pledgePayment['scheduled_date'], - 'installment_amount' => $pledgePayment['scheduled_amount'], - 'installments' => 1, - 'pledge_id' => $pledgePayment['pledge_id'], - ])->addWhere('id', '=', $pledgePayment['id'])->execute(); - } - } - /** * @param string $status * @param null $startDate @@ -2223,6 +2179,9 @@ LEFT JOIN civicrm_contribution contribution ON ( componentPayment.contribution_ $temporaryObject->copyCustomFields($templateContribution['id'], $createContribution['id']); // Add new soft credit against current $contribution. CRM_Contribute_BAO_ContributionRecur::addrecurSoftCredit($contributionParams['contribution_recur_id'], $createContribution['id']); + CRM_Contribute_BAO_ContributionRecur::updateRecurLinkedPledge($createContribution['id'], $contributionParams['contribution_recur_id'], + $contributionParams['status_id'], $contributionParams['total_amount']); + return $createContribution; } @@ -3821,7 +3780,6 @@ INNER JOIN civicrm_activity ON civicrm_activity_contact.activity_id = civicrm_ac 'contribution_status_id', 'card_type_id', 'pan_truncation', - 'financial_type_id', ]; $paymentProcessorId = $input['payment_processor_id'] ?? NULL; @@ -3846,6 +3804,11 @@ INNER JOIN civicrm_activity ON civicrm_activity_contact.activity_id = civicrm_ac if (!$contributionID) { $contributionResult = self::repeatTransaction($input, $contributionParams); $contributionID = $contributionResult['id']; + if ($contributionParams['contribution_status_id'] === $completedContributionStatusID) { + // Ideally add deprecation notice here & only accept pending for repeattransaction. + return self::completeOrder($input, NULL, $contributionID); + } + return $contributionResult; } if ($contributionParams['contribution_status_id'] === $completedContributionStatusID) { @@ -3882,9 +3845,6 @@ INNER JOIN civicrm_activity ON civicrm_activity_contact.activity_id = civicrm_ac if (!empty($contributionSoft)) { CRM_Contribute_BAO_ContributionSoft::pcpNotifyOwner($contributionID, $contributionSoft); } - // @todo - check if Contribution::create does this, test, remove. - CRM_Contribute_BAO_ContributionRecur::updateRecurLinkedPledge($contributionID, $recurringContributionID, - $contributionParams['contribution_status_id'], $input['amount']); if (self::isEmailReceipt($input, $contributionID, $recurringContributionID)) { civicrm_api3('Contribution', 'sendconfirmation', [ diff --git a/CRM/Contribute/BAO/ContributionRecur.php b/CRM/Contribute/BAO/ContributionRecur.php index 5928599940..7ede12463a 100644 --- a/CRM/Contribute/BAO/ContributionRecur.php +++ b/CRM/Contribute/BAO/ContributionRecur.php @@ -766,63 +766,40 @@ INNER JOIN civicrm_contribution con ON ( con.id = mp.contribution_id ) */ public static function updateRecurLinkedPledge($contributionID, $contributionRecurID, $contributionStatusID, $contributionAmount) { $returnProperties = ['id', 'pledge_id']; - $paymentDetails = $paymentIDs = []; + $paymentDetails = []; - if (CRM_Core_DAO::commonRetrieveAll('CRM_Pledge_DAO_PledgePayment', 'contribution_id', $contributionID, - $paymentDetails, $returnProperties - ) - ) { - foreach ($paymentDetails as $key => $value) { - $paymentIDs[] = $value['id']; - $pledgeId = $value['pledge_id']; - } - } - else { - //payment is not already linked - if it is linked with a pledge we need to create a link. - // return if it is not recurring contribution - if (!$contributionRecurID) { - return; - } + $relatedContributions = new CRM_Contribute_DAO_Contribution(); + $relatedContributions->contribution_recur_id = $contributionRecurID; + $relatedContributions->find(); - $relatedContributions = new CRM_Contribute_DAO_Contribution(); - $relatedContributions->contribution_recur_id = $contributionRecurID; - $relatedContributions->find(); - - while ($relatedContributions->fetch()) { - CRM_Core_DAO::commonRetrieveAll('CRM_Pledge_DAO_PledgePayment', 'contribution_id', $relatedContributions->id, - $paymentDetails, $returnProperties - ); - } - - if (empty($paymentDetails)) { - // payment is not linked with a pledge and neither are any other contributions on this - return; - } + while ($relatedContributions->fetch()) { + CRM_Core_DAO::commonRetrieveAll('CRM_Pledge_DAO_PledgePayment', 'contribution_id', $relatedContributions->id, + $paymentDetails, $returnProperties + ); + } - foreach ($paymentDetails as $key => $value) { - $pledgeId = $value['pledge_id']; - } + if (empty($paymentDetails)) { + // payment is not linked with a pledge and neither are any other contributions on this + return; + } - // we have a pledge now we need to get the oldest unpaid payment - $paymentDetails = CRM_Pledge_BAO_PledgePayment::getOldestPledgePayment($pledgeId); - if (empty($paymentDetails['id'])) { - // we can assume this pledge is now completed - // return now so we don't create a core error & roll back - return; - } - $paymentDetails['contribution_id'] = $contributionID; - $paymentDetails['status_id'] = $contributionStatusID; - $paymentDetails['actual_amount'] = $contributionAmount; + foreach ($paymentDetails as $value) { + $pledgeId = $value['pledge_id']; + } - // put contribution against it - $payment = civicrm_api3('PledgePayment', 'create', $paymentDetails); - $paymentIDs[] = $payment['id']; + // we have a pledge now we need to get the oldest unpaid payment + $paymentDetails = CRM_Pledge_BAO_PledgePayment::getOldestPledgePayment($pledgeId); + if (empty($paymentDetails['id'])) { + // we can assume this pledge is now completed + // return now so we don't create a core error & roll back + return; } + $paymentDetails['contribution_id'] = $contributionID; + $paymentDetails['status_id'] = $contributionStatusID; + $paymentDetails['actual_amount'] = $contributionAmount; - // update pledge and corresponding payment statuses - CRM_Pledge_BAO_PledgePayment::updatePledgePaymentStatus($pledgeId, $paymentIDs, $contributionStatusID, - NULL, $contributionAmount - ); + // put contribution against it + civicrm_api3('PledgePayment', 'create', $paymentDetails); } /** diff --git a/CRM/Pledge/BAO/PledgePayment.php b/CRM/Pledge/BAO/PledgePayment.php index b509707365..73fd3a3546 100644 --- a/CRM/Pledge/BAO/PledgePayment.php +++ b/CRM/Pledge/BAO/PledgePayment.php @@ -9,12 +9,18 @@ +--------------------------------------------------------------------+ */ +use Civi\Api4\Contribution; +use Civi\Api4\Pledge; +use Civi\Api4\PledgePayment; +use Civi\Core\HookInterface; +use Civi\Core\Event\GenericHookEvent; + /** * * @package CRM * @copyright CiviCRM LLC https://civicrm.org/licensing */ -class CRM_Pledge_BAO_PledgePayment extends CRM_Pledge_DAO_PledgePayment { +class CRM_Pledge_BAO_PledgePayment extends CRM_Pledge_DAO_PledgePayment implements HookInterface { /** * Get pledge payment details. @@ -552,43 +558,28 @@ WHERE civicrm_pledge.id = %2 * * @return int * $statusId calculated status id of pledge + * @throws \API_Exception */ - public static function calculatePledgeStatus($pledgeId) { - $paymentStatusTypes = CRM_Contribute_PseudoConstant::contributionStatus(NULL, 'name'); - $pledgeStatusTypes = CRM_Pledge_BAO_Pledge::buildOptions('status_id', 'validate'); - - //return if the pledge is cancelled. - $currentPledgeStatusId = CRM_Core_DAO::getFieldValue('CRM_Pledge_DAO_Pledge', $pledgeId, 'status_id', 'id', TRUE); - if ($currentPledgeStatusId == array_search('Cancelled', $pledgeStatusTypes)) { - return $currentPledgeStatusId; + public static function calculatePledgeStatus(int $pledgeId): int { + if (count(Pledge::get(FALSE) + ->addWhere('id', '=', $pledgeId) + ->addWhere('status_id:name', '=', 'Cancelled')->execute())) { + // Return Canceled if the pledge is cancelled. + return (int) CRM_Core_PseudoConstant::getKey('CRM_Pledge_BAO_Pledge', 'status_id', 'Cancelled'); } - // retrieve all pledge payments for this particular pledge - $allPledgePayments = $allStatus = []; - $returnProperties = ['status_id']; - CRM_Core_DAO::commonRetrieveAll('CRM_Pledge_DAO_PledgePayment', 'pledge_id', $pledgeId, $allPledgePayments, $returnProperties); - - // build pledge payment statuses - foreach ($allPledgePayments as $key => $value) { - $allStatus[$value['id']] = $paymentStatusTypes[$value['status_id']]; + $pledgePaymentStatuses = (array) PledgePayment::get(FALSE)->addWhere('pledge_id', '=', $pledgeId)->setSelect(['status_id', 'status_id:name'])->execute()->indexBy('status_id:name'); + if (!empty($pledgePaymentStatuses['Overdue'])) { + return (int) CRM_Core_PseudoConstant::getKey('CRM_Pledge_BAO_Pledge', 'status_id', 'Overdue'); } - - if (array_search('Overdue', $allStatus)) { - $statusId = array_search('Overdue', $pledgeStatusTypes); - } - elseif (array_search('Completed', $allStatus)) { - if (count(array_count_values($allStatus)) == 1) { - $statusId = array_search('Completed', $pledgeStatusTypes); - } - else { - $statusId = array_search('In Progress', $pledgeStatusTypes); - } + if (count($pledgePaymentStatuses) === 1 && !empty($pledgePaymentStatuses['Completed'])) { + return (int) CRM_Core_PseudoConstant::getKey('CRM_Pledge_BAO_Pledge', 'status_id', 'Completed'); } - else { - $statusId = array_search('Pending', $pledgeStatusTypes); + if (!empty($pledgePaymentStatuses['Completed'])) { + // In this case some are completed but not all (or it would have returned just above). + return (int) CRM_Core_PseudoConstant::getKey('CRM_Pledge_BAO_Pledge', 'status_id', 'In Progress'); } - - return $statusId; + return (int) CRM_Core_PseudoConstant::getKey('CRM_Pledge_BAO_Pledge', 'status_id', 'Pending'); } /** @@ -852,4 +843,67 @@ WHERE civicrm_pledge_payment.contribution_id = {$paymentContributionId} return $result; } + /** + * Update pledge payments based on contribution updates. + * + * - Disconnect pledge payments from cancelled or failed contributions. + * - Complete Completed payments + * + * Test cover in testCancelOrderWithPledge, testCompleteTransactionUpdatePledgePayment. + * + * @param \Civi\Core\Event\GenericHookEvent $event + * + * @throws \API_Exception + */ + public static function on_hook_civicrm_post(GenericHookEvent $event): void { + if (!CRM_Core_Component::isEnabled('CiviPledge')) { + return; + } + if ($event->entity === 'Contribution' && $event->action === 'edit' && !empty($event->object->contribution_status_id)) { + $contributionStatus = CRM_Core_PseudoConstant::getName('CRM_Contribute_BAO_Contribution', 'contribution_status_id', $event->object->contribution_status_id); + + if (!in_array($contributionStatus, ['Failed', 'Cancelled', 'Completed'], TRUE)) { + return; + } + $contributionID = $event->object->id; + if ($contributionStatus === 'Completed' && empty($event->object->total_amount)) { + // This is precautionary as it is likely it is always loaded in the BAO. + $event->object->total_amount = Contribution::get(FALSE)->addWhere('id', '=', $contributionID)->addSelect('total_amount')->execute()->first()['total_amount']; + } + + // Check first since just doing an update could be locking under load. + $pledgePayment = PledgePayment::get(FALSE) + ->addWhere('contribution_id', '=', $contributionID) + ->setSelect(['id', 'pledge_id', 'scheduled_date', 'scheduled_amount', 'status_id:name', 'pledge_id.status_id']) + ->execute() + ->first(); + if (!empty($pledgePayment)) { + if ($pledgePayment['status_id:name'] === 'Completed' && $contributionStatus === 'Completed') { + return; + } + PledgePayment::update(FALSE)->setValues([ + 'contribution_id' => $contributionStatus === 'Completed' ? $contributionID : NULL, + 'actual_amount' => $contributionStatus === 'Completed' ? $event->object->total_amount : NULL, + 'status_id:name' => $contributionStatus === 'Completed' ? 'Completed' : 'Pending', + // We need to set these fields for now because the PledgePayment::create + // function doesn't handled updates well at the moment. Test cover + // in testCancelOrderWithPledge. + 'scheduled_date' => $pledgePayment['scheduled_date'], + 'installment_amount' => $pledgePayment['scheduled_amount'], + 'installments' => 1, + 'pledge_id' => $pledgePayment['pledge_id'], + ])->addWhere('id', '=', $pledgePayment['id'])->execute(); + if ($contributionStatus === 'Completed') { + // Check if this completes the pledge. + // Ideally we would listen to PledgePayment update for this. + // The risk could be a code loop? For now just do for completed. + $pledgeExpectedStatus = self::calculatePledgeStatus($pledgePayment['pledge_id']); + if ($pledgeExpectedStatus !== $pledgePayment['pledge_id.status_id']) { + Pledge::update(FALSE)->addWhere('id', '=', $pledgePayment['pledge_id'])->setValues(['status_id', '=', $pledgeExpectedStatus])->execute(); + } + } + } + } + } + } diff --git a/tests/phpunit/api/v3/ContributionTest.php b/tests/phpunit/api/v3/ContributionTest.php index 39377fa245..f44f86d373 100644 --- a/tests/phpunit/api/v3/ContributionTest.php +++ b/tests/phpunit/api/v3/ContributionTest.php @@ -11,6 +11,8 @@ use Civi\Api4\ActivityContact; use Civi\Api4\Contribution; +use Civi\Api4\ContributionRecur; +use Civi\Api4\Pledge; use Civi\Api4\PriceField; use Civi\Api4\PriceFieldValue; use Civi\Api4\PriceSet; @@ -3370,7 +3372,7 @@ class api_v3_ContributionTest extends CiviUnitTestCase { * Note that we are creating a logged in user because email goes out from * that person. */ - public function testCompleteTransactionUpdatePledgePayment() { + public function testCompleteTransactionUpdatePledgePayment(): void { $this->swapMessageTemplateForTestTemplate(); $mut = new CiviMailUtils($this, TRUE); $mut->clearMessages(); @@ -3403,6 +3405,41 @@ class api_v3_ContributionTest extends CiviUnitTestCase { $this->revertTemplateToReservedTemplate(); } + /** + * Test repeating a pledge with the repeatTransaction api.. + * + * @throws \API_Exception + */ + public function testRepeatTransactionWithPledgePayment(): void { + $contributionID = $this->createPendingPledgeContribution(2); + $contributionRecurID = ContributionRecur::create()->setValues([ + 'contact_id' => $this->_individualId, + 'amount' => 250, + 'payment_processor_id' => $this->paymentProcessorID, + ])->execute()->first()['id']; + Contribution::update()->setValues([ + 'id' => $contributionID, + 'contribution_recur_id' => $contributionRecurID, + ])->execute(); + $this->callAPISuccess('contribution', 'completetransaction', [ + 'id' => $contributionID, + 'trxn_date' => '1 Feb 2013', + ]); + $this->assertEquals('In Progress', Pledge::get() + ->addWhere('id', '=', $this->_ids['pledge']) + ->addSelect('status_id:name')->execute()->first()['status_id:name'] + ); + $this->callAPISuccess('contribution', 'repeattransaction', [ + 'contribution_recur_id' => $contributionRecurID, + 'trxn_id' => '2013', + 'contribution_status_id' => 'Completed', + ]); + $this->assertEquals('Completed', Pledge::get() + ->addWhere('id', '=', $this->_ids['pledge']) + ->addSelect('status_id:name')->execute()->first()['status_id:name'] + ); + } + /** * Test completing a transaction with an event via the API. * @@ -3919,15 +3956,16 @@ class api_v3_ContributionTest extends CiviUnitTestCase { /** * Create a pending contribution & linked pending pledge record. * - * @throws \CRM_Core_Exception + * @param int $installments + * + * @return int */ - public function createPendingPledgeContribution() { - - $pledgeID = $this->pledgeCreate(['contact_id' => $this->_individualId, 'installments' => 1, 'amount' => 500]); + public function createPendingPledgeContribution(int $installments = 1): int { + $pledgeID = $this->pledgeCreate(['contact_id' => $this->_individualId, 'installments' => $installments, 'amount' => 500]); $this->_ids['pledge'] = $pledgeID; $contribution = $this->callAPISuccess('Contribution', 'create', array_merge($this->_params, [ 'contribution_status_id' => 'Pending', - 'total_amount' => 500, + 'total_amount' => (500 / $installments), ])); $paymentID = $this->callAPISuccessGetValue('PledgePayment', [ 'options' => ['limit' => 1], @@ -3938,10 +3976,10 @@ class api_v3_ContributionTest extends CiviUnitTestCase { 'contribution_id' => $contribution['id'], 'status_id' => 'Pending', - 'scheduled_amount' => 500, + 'scheduled_amount' => (500 / $installments), ]); - return $contribution['id']; + return (int) $contribution['id']; } /** -- 2.25.1