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 +--------------------------------------------------------------------+
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
19 * This class contains payment related functions.
21 class CRM_Financial_BAO_Payment
{
24 * Function to process additional payment for partial and refund contributions.
26 * This function is called via API payment.create function. All forms that add payments
29 * @param array $params
34 * @return \CRM_Financial_DAO_FinancialTrxn
36 * @throws \CRM_Core_Exception
37 * @throws \CiviCRM_API3_Exception
39 public static function create($params) {
40 $contribution = civicrm_api3('Contribution', 'getsingle', ['id' => $params['contribution_id']]);
41 $contributionStatus = CRM_Core_PseudoConstant
::getName('CRM_Contribute_BAO_Contribution', 'contribution_status_id', $contribution['contribution_status_id']);
42 $isPaymentCompletesContribution = self
::isPaymentCompletesContribution($params['contribution_id'], $params['total_amount'], $contributionStatus);
43 $lineItems = self
::getPayableLineItems($params);
45 $whiteList = ['check_number', 'payment_processor_id', 'fee_amount', 'total_amount', 'contribution_id', 'net_amount', 'card_type_id', 'pan_truncation', 'trxn_result_code', 'payment_instrument_id', 'trxn_id', 'trxn_date', 'order_reference'];
46 $paymentTrxnParams = array_intersect_key($params, array_fill_keys($whiteList, 1));
47 $paymentTrxnParams['is_payment'] = 1;
48 // Really we should have a DB default.
49 $paymentTrxnParams['fee_amount'] = $paymentTrxnParams['fee_amount'] ??
0;
51 if (isset($paymentTrxnParams['payment_processor_id']) && empty($paymentTrxnParams['payment_processor_id'])) {
52 // Don't pass 0 - ie the Pay Later processor as it is a pseudo-processor.
53 unset($paymentTrxnParams['payment_processor_id']);
55 if (empty($paymentTrxnParams['payment_instrument_id'])) {
56 if (!empty($params['payment_processor_id'])) {
57 $paymentTrxnParams['payment_instrument_id'] = civicrm_api3('PaymentProcessor', 'getvalue', ['return' => 'payment_instrument_id', 'id' => $paymentTrxnParams['payment_processor_id']]);
60 // Fall back on the payment instrument already used - should we deprecate this?
61 $paymentTrxnParams['payment_instrument_id'] = $contribution['payment_instrument_id'];
65 $paymentTrxnParams['currency'] = $contribution['currency'];
67 $accountsReceivableAccount = CRM_Financial_BAO_FinancialAccount
::getFinancialAccountForFinancialTypeByRelationship($contribution['financial_type_id'], 'Accounts Receivable Account is');
68 $paymentTrxnParams['to_financial_account_id'] = CRM_Contribute_BAO_Contribution
::getToFinancialAccount($contribution, $params);
69 $paymentTrxnParams['from_financial_account_id'] = $accountsReceivableAccount;
71 if ($params['total_amount'] > 0) {
72 $paymentTrxnParams['status_id'] = CRM_Core_PseudoConstant
::getKey('CRM_Core_BAO_FinancialTrxn', 'status_id', 'Completed');
74 elseif ($params['total_amount'] < 0) {
75 $paymentTrxnParams['status_id'] = CRM_Core_PseudoConstant
::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Refunded');
78 //If Payment is recorded on Failed contribution, update it to Pending.
79 if ($contributionStatus === 'Failed' && $params['total_amount'] > 0) {
80 //Enter a financial trxn to record a payment in receivable account
81 //as failed transaction does not insert any trxn values. Hence, if Payment is
82 //recorded on a failed contribution, the transition happens from Failed -> Pending -> Completed.
83 $ftParams = array_merge($paymentTrxnParams, [
84 'from_financial_account_id' => NULL,
85 'to_financial_account_id' => $accountsReceivableAccount,
87 'status_id' => CRM_Core_PseudoConstant
::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending'),
89 CRM_Core_BAO_FinancialTrxn
::create($ftParams);
90 $contributionStatus = 'Pending';
91 self
::updateContributionStatus($contribution['id'], $contributionStatus);
93 $trxn = CRM_Core_BAO_FinancialTrxn
::create($paymentTrxnParams);
95 if ($params['total_amount'] < 0 && !empty($params['cancelled_payment_id'])) {
96 self
::reverseAllocationsFromPreviousPayment($params, $trxn->id
);
99 list($ftIds, $taxItems) = CRM_Contribute_BAO_Contribution
::getLastFinancialItemIds($params['contribution_id']);
101 foreach ($lineItems as $key => $value) {
102 if ($value['allocation'] === (float) 0) {
106 if (!empty($ftIds[$value['price_field_value_id']])) {
107 $financialItemID = $ftIds[$value['price_field_value_id']];
110 $financialItemID = self
::getNewFinancialItemID($value, $params['trxn_date'], $contribution['contact_id'], $paymentTrxnParams['currency']);
114 'entity_table' => 'civicrm_financial_item',
115 'financial_trxn_id' => $trxn->id
,
116 'entity_id' => $financialItemID,
117 'amount' => $value['allocation'],
120 civicrm_api3('EntityFinancialTrxn', 'create', $eftParams);
122 if (array_key_exists($value['price_field_value_id'], $taxItems)) {
123 // @todo - this is expected to be broken - it should be fixed to
124 // a) have the getPayableLineItems add the amount to allocate for tax
125 // b) call EntityFinancialTrxn directly - per above.
126 // - see https://github.com/civicrm/civicrm-core/pull/14763
128 'contribution_total_amount' => $contribution['total_amount'],
129 'trxn_total_amount' => $params['total_amount'],
130 'trxn_id' => $trxn->id
,
131 'line_item_amount' => $taxItems[$value['price_field_value_id']]['amount'],
133 $eftParams['entity_id'] = $taxItems[$value['price_field_value_id']]['financial_item_id'];
134 CRM_Contribute_BAO_Contribution
::createProportionalEntry($entityParams, $eftParams);
139 if ($isPaymentCompletesContribution) {
140 if ($contributionStatus === 'Pending refund') {
141 // Ideally we could still call completetransaction as non-payment related actions should
142 // be outside this class. However, for now we just update the contribution here.
143 // Unit test cover in CRM_Event_BAO_AdditionalPaymentTest::testTransactionInfo.
144 civicrm_api3('Contribution', 'create',
146 'id' => $contribution['id'],
147 'contribution_status_id' => 'Completed',
152 civicrm_api3('Contribution', 'completetransaction', [
153 'id' => $contribution['id'],
154 'is_post_payment_create' => TRUE,
155 'is_email_receipt' => $params['is_send_contribution_notification'],
156 'trxn_date' => $params['trxn_date'],
157 'payment_instrument_id' => $paymentTrxnParams['payment_instrument_id'],
160 $trxnId = CRM_Core_BAO_FinancialTrxn
::getFinancialTrxnId($contribution['id'], 'DESC');
161 $ftParams = ['id' => $trxnId['financialTrxnId']];
162 $trxn = CRM_Core_BAO_FinancialTrxn
::retrieve($ftParams);
165 elseif ($contributionStatus === 'Pending' && $params['total_amount'] > 0) {
166 self
::updateContributionStatus($contribution['id'], 'Partially Paid');
167 $participantPayments = civicrm_api3('ParticipantPayment', 'get', [
168 'contribution_id' => $contribution['id'],
169 'participant_id.status_id' => ['IN' => ['Pending from pay later', 'Pending from incomplete transaction']],
171 foreach ($participantPayments as $participantPayment) {
172 civicrm_api3('Participant', 'create', ['id' => $participantPayment['participant_id'], 'status_id' => 'Partially paid']);
175 elseif ($contributionStatus === 'Completed' && ((float) CRM_Core_BAO_FinancialTrxn
::getTotalPayments($contribution['id'], TRUE) === 0.0)) {
176 // If the contribution has previously been completed (fully paid) and now has total payments adding up to 0
177 // change status to refunded.
178 self
::updateContributionStatus($contribution['id'], 'Refunded');
180 self
::updateRelatedContribution($params, $params['contribution_id']);
181 CRM_Contribute_BAO_Contribution
::recordPaymentActivity($params['contribution_id'], CRM_Utils_Array
::value('participant_id', $params), $params['total_amount'], $trxn->currency
, $trxn->trxn_date
);
186 * Function to update contribution's check_number and trxn_id by
187 * concatenating values from financial trxn's check_number and trxn_id respectively
189 * @param array $params
190 * @param int $contributionID
192 public static function updateRelatedContribution($params, $contributionID) {
193 $contributionDAO = new CRM_Contribute_DAO_Contribution();
194 $contributionDAO->id
= $contributionID;
195 $contributionDAO->find(TRUE);
197 foreach (['trxn_id', 'check_number'] as $fieldName) {
198 if (!empty($params[$fieldName])) {
200 if (!empty($contributionDAO->$fieldName)) {
201 $values = explode(',', $contributionDAO->$fieldName);
203 // if submitted check_number or trxn_id value is
204 // already present then ignore else add to $values array
205 if (!in_array($params[$fieldName], $values)) {
206 $values[] = $params[$fieldName];
208 $contributionDAO->$fieldName = implode(',', $values);
212 $contributionDAO->save();
216 * Send an email confirming a payment that has been received.
218 * @param array $params
222 * @throws \CiviCRM_API3_Exception
224 public static function sendConfirmation($params) {
226 $entities = self
::loadRelatedEntities($params['id']);
227 $sendTemplateParams = [
228 'groupName' => 'msg_tpl_workflow_contribution',
229 'valueName' => 'payment_or_refund_notification',
230 'PDFFilename' => ts('notification') . '.pdf',
231 'contactId' => $entities['contact']['id'],
232 'toName' => $entities['contact']['display_name'],
233 'toEmail' => $entities['contact']['email'],
234 'tplParams' => self
::getConfirmationTemplateParameters($entities),
236 if (!empty($params['from']) && !empty($params['check_permissions'])) {
237 // Filter from against permitted emails.
238 $validEmails = self
::getValidFromEmailsForPayment($entities['event']['id'] ??
NULL);
239 if (!isset($validEmails[$params['from']])) {
240 // Ignore unpermitted parameter.
241 unset($params['from']);
244 $sendTemplateParams['from'] = $params['from'] ??
key(CRM_Core_BAO_Email
::domainEmails());
245 return CRM_Core_BAO_MessageTemplate
::sendTemplate($sendTemplateParams);
249 * Get valid from emails for payment.
251 * @param int $eventID
255 public static function getValidFromEmailsForPayment($eventID = NULL) {
257 $emails = CRM_Event_BAO_Event
::getFromEmailIds($eventID);
260 $emails['from_email_id'] = CRM_Core_BAO_Email
::getFromEmail();
262 return $emails['from_email_id'];
266 * Load entities related to the current payment id.
268 * This gives us all the data we need to send an email confirmation but avoiding
269 * getting anything not tested for the confirmations. We retrieve the 'full' event as
270 * it has been traditionally assigned in full.
275 * - contact = ['id' => x, 'display_name' => y, 'email' => z]
276 * - event = [.... full event details......]
277 * - contribution = ['id' => x],
278 * - payment = [payment info + payment summary info]
279 * @throws \CiviCRM_API3_Exception
281 protected static function loadRelatedEntities($id) {
283 $contributionID = (int) civicrm_api3('EntityFinancialTrxn', 'getvalue', [
284 'financial_trxn_id' => $id,
285 'entity_table' => 'civicrm_contribution',
286 'return' => 'entity_id',
288 $entities['contribution'] = ['id' => $contributionID];
289 $entities['payment'] = array_merge(civicrm_api3('FinancialTrxn', 'getsingle', ['id' => $id]),
290 CRM_Contribute_BAO_Contribution
::getPaymentInfo($contributionID)
293 $contactID = self
::getPaymentContactID($contributionID);
294 list($displayName, $email) = CRM_Contact_BAO_Contact_Location
::getEmailDetails($contactID);
295 $entities['contact'] = ['id' => $contactID, 'display_name' => $displayName, 'email' => $email];
296 $contact = civicrm_api3('Contact', 'getsingle', ['id' => $contactID, 'return' => 'email_greeting']);
297 $entities['contact']['email_greeting'] = $contact['email_greeting_display'];
299 $participantRecords = civicrm_api3('ParticipantPayment', 'get', [
300 'contribution_id' => $contributionID,
301 'api.Participant.get' => ['return' => 'event_id'],
304 if (!empty($participantRecords)) {
305 $entities['event'] = civicrm_api3('Event', 'getsingle', ['id' => $participantRecords[0]['api.Participant.get']['values'][0]['event_id']]);
306 if (!empty($entities['event']['is_show_location'])) {
308 'entity_id' => $entities['event']['id'],
309 'entity_table' => 'civicrm_event',
311 $entities['location'] = CRM_Core_BAO_Location
::getValues($locationParams, TRUE);
319 * @param int $contributionID
322 * @throws \CiviCRM_API3_Exception
323 * @throws \CiviCRM_API3_Exception
325 public static function getPaymentContactID($contributionID) {
326 $contribution = civicrm_api3('Contribution', 'getsingle', [
327 'id' => $contributionID ,
328 'return' => ['contact_id'],
330 return (int) $contribution['contact_id'];
334 * @param array $entities
335 * Related entities as an array keyed by the various entities.
338 * Values required for the notification
340 * - template_variables
341 * - event (DAO of event if relevant)
343 public static function getConfirmationTemplateParameters($entities) {
344 $templateVariables = [
345 'contactDisplayName' => $entities['contact']['display_name'],
346 'emailGreeting' => $entities['contact']['email_greeting'],
347 'totalAmount' => $entities['payment']['total'],
348 'amountOwed' => $entities['payment']['balance'],
349 'totalPaid' => $entities['payment']['paid'],
350 'paymentAmount' => $entities['payment']['total_amount'],
351 'checkNumber' => $entities['payment']['check_number'] ??
NULL,
352 'receive_date' => $entities['payment']['trxn_date'],
353 'paidBy' => CRM_Core_PseudoConstant
::getLabel('CRM_Core_BAO_FinancialTrxn', 'payment_instrument_id', $entities['payment']['payment_instrument_id']),
354 'isShowLocation' => (!empty($entities['event']) ?
$entities['event']['is_show_location'] : FALSE),
355 'location' => $entities['location'] ??
NULL,
356 'event' => $entities['event'] ??
NULL,
357 'component' => (!empty($entities['event']) ?
'event' : 'contribution'),
358 'isRefund' => $entities['payment']['total_amount'] < 0,
359 'isAmountzero' => $entities['payment']['total_amount'] === 0,
360 'refundAmount' => ($entities['payment']['total_amount'] < 0 ?
$entities['payment']['total_amount'] : NULL),
361 'paymentsComplete' => ($entities['payment']['balance'] == 0),
364 return self
::filterUntestedTemplateVariables($templateVariables);
368 * Filter out any untested variables.
370 * This just serves to highlight if any variables are added without a unit test also being added.
372 * (if hit then add a unit test for the param & add to this array).
374 * @param array $params
378 public static function filterUntestedTemplateVariables($params) {
379 $testedTemplateVariables = [
380 'contactDisplayName',
398 // These are assigned by the payment form - they still 'get through' from the
399 // form for now without being in here but we should ideally load
400 // and assign. Note we should update the tpl to use {if $billingName}
401 // and ditch contributeMode - although it might need to be deprecated rather than removed.
407 'credit_card_number',
408 'credit_card_exp_date',
410 $filteredParams = [];
411 foreach ($testedTemplateVariables as $templateVariable) {
412 // This will cause an a-notice if any are NOT set - by design. Ensuring
413 // they are set prevents leakage.
414 $filteredParams[$templateVariable] = $params[$templateVariable];
416 return $filteredParams;
420 * Does this payment complete the contribution.
422 * @param int $contributionID
423 * @param float $paymentAmount
424 * @param string $previousStatus
428 protected static function isPaymentCompletesContribution($contributionID, $paymentAmount, $previousStatus) {
429 if ($previousStatus === 'Completed') {
432 $outstandingBalance = CRM_Contribute_BAO_Contribution
::getContributionBalance($contributionID);
433 $cmp = bccomp($paymentAmount, $outstandingBalance, 5);
434 return ($cmp == 0 ||
$cmp == 1);
438 * Update the status of the contribution.
440 * We pass the is_post_payment_create as we have already created the line items
442 * @param int $contributionID
443 * @param string $status
445 * @throws \CiviCRM_API3_Exception
447 private static function updateContributionStatus(int $contributionID, string $status) {
448 civicrm_api3('Contribution', 'create',
450 'id' => $contributionID,
451 'is_post_payment_create' => TRUE,
452 'contribution_status_id' => $status,
458 * Get the line items for the contribution.
460 * Retrieve the line items and wrangle the following
462 * - get the outstanding balance on a line item basis.
463 * - determine what amount is being paid on this line item - we get the total being paid
464 * for the whole contribution and determine the ratio of the balance that is being paid
465 * and then assign apply that ratio to each line item.
466 * - if overrides have been passed in we use those amounts instead.
471 * @throws \CiviCRM_API3_Exception
473 protected static function getPayableLineItems($params): array {
474 $lineItems = CRM_Price_BAO_LineItem
::getLineItemsByContributionID($params['contribution_id']);
475 $lineItemOverrides = [];
476 if (!empty($params['line_item'])) {
477 // The format is a bit weird here - $params['line_item'] => [[1 => 10], [2 => 40]]
478 // Squash to [1 => 10, 2 => 40]
479 foreach ($params['line_item'] as $lineItem) {
480 $lineItemOverrides +
= $lineItem;
483 $outstandingBalance = CRM_Contribute_BAO_Contribution
::getContributionBalance($params['contribution_id']);
484 if ($outstandingBalance !== 0.0) {
485 $ratio = $params['total_amount'] / $outstandingBalance;
487 elseif ($params['total_amount'] < 0) {
488 $ratio = $params['total_amount'] / (float) CRM_Core_BAO_FinancialTrxn
::getTotalPayments($params['contribution_id'], TRUE);
491 // Help we are making a payment but no money is owed. We won't allocate the overpayment to any line item.
494 foreach ($lineItems as $lineItemID => $lineItem) {
495 // Ideally id would be set deeper but for now just add in here.
496 $lineItems[$lineItemID]['id'] = $lineItemID;
497 $lineItems[$lineItemID]['paid'] = self
::getAmountOfLineItemPaid($lineItemID);
498 $lineItems[$lineItemID]['balance'] = $lineItem['subTotal'] - $lineItems[$lineItemID]['paid'];
499 if (!empty($lineItemOverrides)) {
500 $lineItems[$lineItemID]['allocation'] = $lineItemOverrides[$lineItemID] ??
NULL;
503 if (empty($lineItems[$lineItemID]['balance']) && !empty($ratio) && $params['total_amount'] < 0) {
504 $lineItems[$lineItemID]['allocation'] = $lineItem['subTotal'] * $ratio;
507 $lineItems[$lineItemID]['allocation'] = $lineItems[$lineItemID]['balance'] * $ratio;
515 * Get the amount paid so far against this line item.
517 * @param int $lineItemID
521 * @throws \CiviCRM_API3_Exception
523 protected static function getAmountOfLineItemPaid($lineItemID) {
525 $financialItems = civicrm_api3('FinancialItem', 'get', [
526 'entity_id' => $lineItemID,
527 'entity_table' => 'civicrm_line_item',
528 'options' => ['sort' => 'id DESC', 'limit' => 0],
530 if (!empty($financialItems)) {
531 $entityFinancialTrxns = civicrm_api3('EntityFinancialTrxn', 'get', [
532 'entity_table' => 'civicrm_financial_item',
533 'entity_id' => ['IN' => array_keys($financialItems)],
534 'options' => ['limit' => 0],
535 'financial_trxn_id.is_payment' => 1,
537 foreach ($entityFinancialTrxns as $entityFinancialTrxn) {
538 $paid +
= $entityFinancialTrxn['amount'];
541 return (float) $paid;
545 * Reverse the entity financial transactions associated with the cancelled payment.
547 * The reversals are linked to the new payemnt.
549 * @param array $params
552 * @throws \CiviCRM_API3_Exception
554 protected static function reverseAllocationsFromPreviousPayment($params, $trxnID) {
555 // Do a direct reversal of any entity_financial_trxn records being cancelled.
556 $entityFinancialTrxns = civicrm_api3('EntityFinancialTrxn', 'get', [
557 'entity_table' => 'civicrm_financial_item',
558 'options' => ['limit' => 0],
559 'financial_trxn_id.id' => $params['cancelled_payment_id'],
561 foreach ($entityFinancialTrxns as $entityFinancialTrxn) {
562 civicrm_api3('EntityFinancialTrxn', 'create', [
563 'entity_table' => 'civicrm_financial_item',
564 'entity_id' => $entityFinancialTrxn['entity_id'],
565 'amount' => -$entityFinancialTrxn['amount'],
566 'financial_trxn_id' => $trxnID,
572 * Create a financial items & return the ID.
574 * Ideally this will never be called.
576 * However, I hit a scenario in testing where 'something' had created a pending payment with
577 * no financial items and that would result in a fatal error without handling here. I failed
578 * to replicate & am not investigating via a new test methodology
579 * https://github.com/civicrm/civicrm-core/pull/15706
581 * After this is in I will do more digging & once I feel confident new instances are not being
582 * created I will add deprecation notices into this function with a view to removing.
584 * However, I think we want to add it in 5.20 as there is a risk of users experiencing an error
585 * if there is incorrect data & we need time to ensure that what I hit was not a 'thing.
586 * (it might be the demo site data is a bit flawed & that was the issue).
588 * @param array $lineItem
589 * @param string $trxn_date
590 * @param int $contactID
591 * @param string $currency
595 * @throws \CiviCRM_API3_Exception
597 protected static function getNewFinancialItemID($lineItem, $trxn_date, $contactID, $currency): int {
598 $financialAccount = CRM_Financial_BAO_FinancialAccount
::getFinancialAccountForFinancialTypeByRelationship(
599 $lineItem['financial_type_id'],
603 'transaction_date' => $trxn_date,
604 'contact_id' => $contactID,
605 'currency' => $currency,
606 'amount' => $lineItem['line_total'],
607 'description' => $lineItem['label'],
608 'status_id' => 'Unpaid',
609 'financial_account_id' => $financialAccount,
610 'entity_table' => 'civicrm_line_item',
611 'entity_id' => $lineItem['id'],
613 return (int) civicrm_api3('FinancialItem', 'create', $itemParams)['id'];