Merge pull request #15826 from seamuslee001/dev_core_183_dedupe
[civicrm-core.git] / CRM / Financial / BAO / Payment.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
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 +--------------------------------------------------------------------+
10 */
11
12 /**
13 *
14 * @package CRM
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
16 */
17
18 /**
19 * This class contains payment related functions.
20 */
21 class CRM_Financial_BAO_Payment {
22
23 /**
24 * Function to process additional payment for partial and refund contributions.
25 *
26 * This function is called via API payment.create function. All forms that add payments
27 * should use this.
28 *
29 * @param array $params
30 * - contribution_id
31 * - total_amount
32 * - line_item
33 *
34 * @return \CRM_Financial_DAO_FinancialTrxn
35 *
36 * @throws \CRM_Core_Exception
37 * @throws \CiviCRM_API3_Exception
38 */
39 public static function create($params) {
40 $contribution = civicrm_api3('Contribution', 'getsingle', ['id' => $params['contribution_id']]);
41 $contributionStatus = CRM_Contribute_PseudoConstant::contributionStatus($contribution['contribution_status_id'], 'name');
42 $isPaymentCompletesContribution = self::isPaymentCompletesContribution($params['contribution_id'], $params['total_amount']);
43 $lineItems = self::getPayableLineItems($params);
44
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'];
46 $paymentTrxnParams = array_intersect_key($params, array_fill_keys($whiteList, 1));
47 $paymentTrxnParams['is_payment'] = 1;
48
49 if (isset($paymentTrxnParams['payment_processor_id']) && empty($paymentTrxnParams['payment_processor_id'])) {
50 // Don't pass 0 - ie the Pay Later processor as it is a pseudo-processor.
51 unset($paymentTrxnParams['payment_processor_id']);
52 }
53 if (empty($paymentTrxnParams['payment_instrument_id'])) {
54 if (!empty($params['payment_processor_id'])) {
55 $paymentTrxnParams['payment_instrument_id'] = civicrm_api3('PaymentProcessor', 'getvalue', ['return' => 'payment_instrument_id', 'id' => $paymentTrxnParams['payment_processor_id']]);
56 }
57 else {
58 // Fall back on the payment instrument already used - should we deprecate this?
59 $paymentTrxnParams['payment_instrument_id'] = $contribution['payment_instrument_id'];
60 }
61 }
62 if (empty($paymentTrxnParams['trxn_id']) && !empty($paymentTrxnParams['contribution_trxn_id'])) {
63 CRM_Core_Error::deprecatedFunctionWarning('contribution_trxn_id is deprecated - use trxn_id');
64 $paymentTrxnParams['trxn_id'] = $paymentTrxnParams['contribution_trxn_id'];
65 }
66
67 $paymentTrxnParams['currency'] = $contribution['currency'];
68
69 $accountsReceivableAccount = CRM_Financial_BAO_FinancialAccount::getFinancialAccountForFinancialTypeByRelationship($contribution['financial_type_id'], 'Accounts Receivable Account is');
70 $paymentTrxnParams['to_financial_account_id'] = CRM_Contribute_BAO_Contribution::getToFinancialAccount($contribution, $params);
71 $paymentTrxnParams['from_financial_account_id'] = $accountsReceivableAccount;
72
73 if ($params['total_amount'] > 0) {
74 $paymentTrxnParams['status_id'] = CRM_Core_PseudoConstant::getKey('CRM_Core_BAO_FinancialTrxn', 'status_id', 'Completed');
75 }
76 elseif ($params['total_amount'] < 0) {
77 $paymentTrxnParams['status_id'] = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Refunded');
78 }
79
80 $trxn = CRM_Core_BAO_FinancialTrxn::create($paymentTrxnParams);
81
82 if ($params['total_amount'] < 0 && !empty($params['cancelled_payment_id'])) {
83 self::reverseAllocationsFromPreviousPayment($params, $trxn->id);
84 return $trxn;
85 }
86 list($ftIds, $taxItems) = CRM_Contribute_BAO_Contribution::getLastFinancialItemIds($params['contribution_id']);
87
88 foreach ($lineItems as $key => $value) {
89 if ($value['allocation'] === (float) 0) {
90 continue;
91 }
92
93 if (!empty($ftIds[$value['price_field_value_id']])) {
94 $financialItemID = $ftIds[$value['price_field_value_id']];
95 }
96 else {
97 $financialItemID = self::getNewFinancialItemID($value, $params['trxn_date'], $contribution['contact_id'], $paymentTrxnParams['currency']);
98 }
99
100 $eftParams = [
101 'entity_table' => 'civicrm_financial_item',
102 'financial_trxn_id' => $trxn->id,
103 'entity_id' => $financialItemID,
104 'amount' => $value['allocation'],
105 ];
106
107 civicrm_api3('EntityFinancialTrxn', 'create', $eftParams);
108
109 if (array_key_exists($value['price_field_value_id'], $taxItems)) {
110 // @todo - this is expected to be broken - it should be fixed to
111 // a) have the getPayableLineItems add the amount to allocate for tax
112 // b) call EntityFinancialTrxn directly - per above.
113 // - see https://github.com/civicrm/civicrm-core/pull/14763
114 $entityParams = [
115 'contribution_total_amount' => $contribution['total_amount'],
116 'trxn_total_amount' => $params['total_amount'],
117 'trxn_id' => $trxn->id,
118 'line_item_amount' => $taxItems[$value['price_field_value_id']]['amount'],
119 ];
120 $eftParams['entity_id'] = $taxItems[$value['price_field_value_id']]['financial_item_id'];
121 CRM_Contribute_BAO_Contribution::createProportionalEntry($entityParams, $eftParams);
122 }
123 }
124
125 if ($isPaymentCompletesContribution) {
126 if ($contributionStatus === 'Pending refund') {
127 // Ideally we could still call completetransaction as non-payment related actions should
128 // be outside this class. However, for now we just update the contribution here.
129 // Unit test cover in CRM_Event_BAO_AdditionalPaymentTest::testTransactionInfo.
130 civicrm_api3('Contribution', 'create',
131 [
132 'id' => $contribution['id'],
133 'contribution_status_id' => 'Completed',
134 ]
135 );
136 }
137 else {
138 civicrm_api3('Contribution', 'completetransaction', [
139 'id' => $contribution['id'],
140 'is_post_payment_create' => TRUE,
141 'is_email_receipt' => $params['is_send_contribution_notification'],
142 ]);
143 // Get the trxn
144 $trxnId = CRM_Core_BAO_FinancialTrxn::getFinancialTrxnId($contribution['id'], 'DESC');
145 $ftParams = ['id' => $trxnId['financialTrxnId']];
146 $trxn = CRM_Core_BAO_FinancialTrxn::retrieve($ftParams);
147 }
148 }
149 elseif ($contributionStatus === 'Pending' && $params['total_amount'] > 0) {
150 self::updateContributionStatus($contribution['id'], 'Partially Paid');
151 }
152 CRM_Contribute_BAO_Contribution::recordPaymentActivity($params['contribution_id'], CRM_Utils_Array::value('participant_id', $params), $params['total_amount'], $trxn->currency, $trxn->trxn_date);
153 return $trxn;
154 }
155
156 /**
157 * Send an email confirming a payment that has been received.
158 *
159 * @param array $params
160 *
161 * @return array
162 *
163 * @throws \CiviCRM_API3_Exception
164 */
165 public static function sendConfirmation($params) {
166
167 $entities = self::loadRelatedEntities($params['id']);
168 $sendTemplateParams = [
169 'groupName' => 'msg_tpl_workflow_contribution',
170 'valueName' => 'payment_or_refund_notification',
171 'PDFFilename' => ts('notification') . '.pdf',
172 'contactId' => $entities['contact']['id'],
173 'toName' => $entities['contact']['display_name'],
174 'toEmail' => $entities['contact']['email'],
175 'tplParams' => self::getConfirmationTemplateParameters($entities),
176 ];
177 if (!empty($params['from']) && !empty($params['check_permissions'])) {
178 // Filter from against permitted emails.
179 $validEmails = self::getValidFromEmailsForPayment($entities['event']['id'] ?? NULL);
180 if (!isset($validEmails[$params['from']])) {
181 // Ignore unpermitted parameter.
182 unset($params['from']);
183 }
184 }
185 $sendTemplateParams['from'] = $params['from'] ?? key(CRM_Core_BAO_Email::domainEmails());
186 return CRM_Core_BAO_MessageTemplate::sendTemplate($sendTemplateParams);
187 }
188
189 /**
190 * Get valid from emails for payment.
191 *
192 * @param int $eventID
193 *
194 * @return array
195 */
196 public static function getValidFromEmailsForPayment($eventID = NULL) {
197 if ($eventID) {
198 $emails = CRM_Event_BAO_Event::getFromEmailIds($eventID);
199 }
200 else {
201 $emails['from_email_id'] = CRM_Core_BAO_Email::getFromEmail();
202 }
203 return $emails['from_email_id'];
204 }
205
206 /**
207 * Load entities related to the current payment id.
208 *
209 * This gives us all the data we need to send an email confirmation but avoiding
210 * getting anything not tested for the confirmations. We retrieve the 'full' event as
211 * it has been traditionally assigned in full.
212 *
213 * @param int $id
214 *
215 * @return array
216 * - contact = ['id' => x, 'display_name' => y, 'email' => z]
217 * - event = [.... full event details......]
218 * - contribution = ['id' => x],
219 * - payment = [payment info + payment summary info]
220 * @throws \CiviCRM_API3_Exception
221 */
222 protected static function loadRelatedEntities($id) {
223 $entities = [];
224 $contributionID = (int) civicrm_api3('EntityFinancialTrxn', 'getvalue', [
225 'financial_trxn_id' => $id,
226 'entity_table' => 'civicrm_contribution',
227 'return' => 'entity_id',
228 ]);
229 $entities['contribution'] = ['id' => $contributionID];
230 $entities['payment'] = array_merge(civicrm_api3('FinancialTrxn', 'getsingle', ['id' => $id]),
231 CRM_Contribute_BAO_Contribution::getPaymentInfo($contributionID)
232 );
233
234 $contactID = self::getPaymentContactID($contributionID);
235 list($displayName, $email) = CRM_Contact_BAO_Contact_Location::getEmailDetails($contactID);
236 $entities['contact'] = ['id' => $contactID, 'display_name' => $displayName, 'email' => $email];
237 $contact = civicrm_api3('Contact', 'getsingle', ['id' => $contactID, 'return' => 'email_greeting']);
238 $entities['contact']['email_greeting'] = $contact['email_greeting_display'];
239
240 $participantRecords = civicrm_api3('ParticipantPayment', 'get', [
241 'contribution_id' => $contributionID,
242 'api.Participant.get' => ['return' => 'event_id'],
243 'sequential' => 1,
244 ])['values'];
245 if (!empty($participantRecords)) {
246 $entities['event'] = civicrm_api3('Event', 'getsingle', ['id' => $participantRecords[0]['api.Participant.get']['values'][0]['event_id']]);
247 if (!empty($entities['event']['is_show_location'])) {
248 $locationParams = [
249 'entity_id' => $entities['event']['id'],
250 'entity_table' => 'civicrm_event',
251 ];
252 $entities['location'] = CRM_Core_BAO_Location::getValues($locationParams, TRUE);
253 }
254 }
255
256 return $entities;
257 }
258
259 /**
260 * @param int $contributionID
261 *
262 * @return int
263 * @throws \CiviCRM_API3_Exception
264 * @throws \CiviCRM_API3_Exception
265 */
266 public static function getPaymentContactID($contributionID) {
267 $contribution = civicrm_api3('Contribution', 'getsingle', [
268 'id' => $contributionID ,
269 'return' => ['contact_id'],
270 ]);
271 return (int) $contribution['contact_id'];
272 }
273
274 /**
275 * @param array $entities
276 * Related entities as an array keyed by the various entities.
277 *
278 * @return array
279 * Values required for the notification
280 * - contact_id
281 * - template_variables
282 * - event (DAO of event if relevant)
283 */
284 public static function getConfirmationTemplateParameters($entities) {
285 $templateVariables = [
286 'contactDisplayName' => $entities['contact']['display_name'],
287 'emailGreeting' => $entities['contact']['email_greeting'],
288 'totalAmount' => $entities['payment']['total'],
289 'amountOwed' => $entities['payment']['balance'],
290 'totalPaid' => $entities['payment']['paid'],
291 'paymentAmount' => $entities['payment']['total_amount'],
292 'checkNumber' => CRM_Utils_Array::value('check_number', $entities['payment']),
293 'receive_date' => $entities['payment']['trxn_date'],
294 'paidBy' => CRM_Core_PseudoConstant::getLabel('CRM_Core_BAO_FinancialTrxn', 'payment_instrument_id', $entities['payment']['payment_instrument_id']),
295 'isShowLocation' => (!empty($entities['event']) ? $entities['event']['is_show_location'] : FALSE),
296 'location' => CRM_Utils_Array::value('location', $entities),
297 'event' => CRM_Utils_Array::value('event', $entities),
298 'component' => (!empty($entities['event']) ? 'event' : 'contribution'),
299 'isRefund' => $entities['payment']['total_amount'] < 0,
300 'isAmountzero' => $entities['payment']['total_amount'] === 0,
301 'refundAmount' => ($entities['payment']['total_amount'] < 0 ? $entities['payment']['total_amount'] : NULL),
302 'paymentsComplete' => ($entities['payment']['balance'] == 0),
303 ];
304
305 return self::filterUntestedTemplateVariables($templateVariables);
306 }
307
308 /**
309 * Filter out any untested variables.
310 *
311 * This just serves to highlight if any variables are added without a unit test also being added.
312 *
313 * (if hit then add a unit test for the param & add to this array).
314 *
315 * @param array $params
316 *
317 * @return array
318 */
319 public static function filterUntestedTemplateVariables($params) {
320 $testedTemplateVariables = [
321 'contactDisplayName',
322 'totalAmount',
323 'amountOwed',
324 'paymentAmount',
325 'event',
326 'component',
327 'checkNumber',
328 'receive_date',
329 'paidBy',
330 'isShowLocation',
331 'location',
332 'isRefund',
333 'isAmountzero',
334 'refundAmount',
335 'totalPaid',
336 'paymentsComplete',
337 'emailGreeting',
338 ];
339 // These are assigned by the payment form - they still 'get through' from the
340 // form for now without being in here but we should ideally load
341 // and assign. Note we should update the tpl to use {if $billingName}
342 // and ditch contributeMode - although it might need to be deprecated rather than removed.
343 $todoParams = [
344 'contributeMode',
345 'billingName',
346 'address',
347 'credit_card_type',
348 'credit_card_number',
349 'credit_card_exp_date',
350 ];
351 $filteredParams = [];
352 foreach ($testedTemplateVariables as $templateVariable) {
353 // This will cause an a-notice if any are NOT set - by design. Ensuring
354 // they are set prevents leakage.
355 $filteredParams[$templateVariable] = $params[$templateVariable];
356 }
357 return $filteredParams;
358 }
359
360 /**
361 * Does this payment complete the contribution
362 *
363 * @param int $contributionID
364 * @param float $paymentAmount
365 *
366 * @return bool
367 */
368 protected static function isPaymentCompletesContribution($contributionID, $paymentAmount) {
369 $outstandingBalance = CRM_Contribute_BAO_Contribution::getContributionBalance($contributionID);
370 $cmp = bccomp($paymentAmount, $outstandingBalance, 5);
371 return ($cmp == 0 || $cmp == 1);
372 }
373
374 /**
375 * Update the status of the contribution.
376 *
377 * We pass the is_post_payment_create as we have already created the line items
378 *
379 * @param int $contributionID
380 * @param string $status
381 *
382 * @throws \CiviCRM_API3_Exception
383 */
384 private static function updateContributionStatus(int $contributionID, string $status) {
385 civicrm_api3('Contribution', 'create',
386 [
387 'id' => $contributionID,
388 'is_post_payment_create' => TRUE,
389 'contribution_status_id' => $status,
390 ]
391 );
392 }
393
394 /**
395 * Get the line items for the contribution.
396 *
397 * Retrieve the line items and wrangle the following
398 *
399 * - get the outstanding balance on a line item basis.
400 * - determine what amount is being paid on this line item - we get the total being paid
401 * for the whole contribution and determine the ratio of the balance that is being paid
402 * and then assign apply that ratio to each line item.
403 * - if overrides have been passed in we use those amounts instead.
404 *
405 * @param $params
406 *
407 * @return array
408 * @throws \CiviCRM_API3_Exception
409 */
410 protected static function getPayableLineItems($params): array {
411 $lineItems = CRM_Price_BAO_LineItem::getLineItemsByContributionID($params['contribution_id']);
412 $lineItemOverrides = [];
413 if (!empty($params['line_item'])) {
414 // The format is a bit weird here - $params['line_item'] => [[1 => 10], [2 => 40]]
415 // Squash to [1 => 10, 2 => 40]
416 foreach ($params['line_item'] as $lineItem) {
417 $lineItemOverrides += $lineItem;
418 }
419 }
420 $outstandingBalance = CRM_Contribute_BAO_Contribution::getContributionBalance($params['contribution_id']);
421 if ($outstandingBalance !== 0.0) {
422 $ratio = $params['total_amount'] / $outstandingBalance;
423 }
424 else {
425 // Help we are making a payment but no money is owed. We won't allocate the overpayment to any line item.
426 $ratio = 0;
427 }
428 foreach ($lineItems as $lineItemID => $lineItem) {
429 // Ideally id would be set deeper but for now just add in here.
430 $lineItems[$lineItemID]['id'] = $lineItemID;
431 $lineItems[$lineItemID]['paid'] = self::getAmountOfLineItemPaid($lineItemID);
432 $lineItems[$lineItemID]['balance'] = $lineItem['subTotal'] - $lineItems[$lineItemID]['paid'];
433
434 if (!empty($lineItemOverrides)) {
435 $lineItems[$lineItemID]['allocation'] = CRM_Utils_Array::value($lineItemID, $lineItemOverrides);
436 }
437 else {
438 $lineItems[$lineItemID]['allocation'] = $lineItems[$lineItemID]['balance'] * $ratio;
439 }
440 }
441 return $lineItems;
442 }
443
444 /**
445 * Get the amount paid so far against this line item.
446 *
447 * @param int $lineItemID
448 *
449 * @return float
450 *
451 * @throws \CiviCRM_API3_Exception
452 */
453 protected static function getAmountOfLineItemPaid($lineItemID) {
454 $paid = 0;
455 $financialItems = civicrm_api3('FinancialItem', 'get', [
456 'entity_id' => $lineItemID,
457 'entity_table' => 'civicrm_line_item',
458 'options' => ['sort' => 'id DESC', 'limit' => 0],
459 ])['values'];
460 if (!empty($financialItems)) {
461 $entityFinancialTrxns = civicrm_api3('EntityFinancialTrxn', 'get', [
462 'entity_table' => 'civicrm_financial_item',
463 'entity_id' => ['IN' => array_keys($financialItems)],
464 'options' => ['limit' => 0],
465 'financial_trxn_id.is_payment' => 1,
466 ])['values'];
467 foreach ($entityFinancialTrxns as $entityFinancialTrxn) {
468 $paid += $entityFinancialTrxn['amount'];
469 }
470 }
471 return (float) $paid;
472 }
473
474 /**
475 * Reverse the entity financial transactions associated with the cancelled payment.
476 *
477 * The reversals are linked to the new payemnt.
478 *
479 * @param array $params
480 * @param int $trxnID
481 *
482 * @throws \CiviCRM_API3_Exception
483 */
484 protected static function reverseAllocationsFromPreviousPayment($params, $trxnID) {
485 // Do a direct reversal of any entity_financial_trxn records being cancelled.
486 $entityFinancialTrxns = civicrm_api3('EntityFinancialTrxn', 'get', [
487 'entity_table' => 'civicrm_financial_item',
488 'options' => ['limit' => 0],
489 'financial_trxn_id.id' => $params['cancelled_payment_id'],
490 ])['values'];
491 foreach ($entityFinancialTrxns as $entityFinancialTrxn) {
492 civicrm_api3('EntityFinancialTrxn', 'create', [
493 'entity_table' => 'civicrm_financial_item',
494 'entity_id' => $entityFinancialTrxn['entity_id'],
495 'amount' => -$entityFinancialTrxn['amount'],
496 'financial_trxn_id' => $trxnID,
497 ]);
498 }
499 }
500
501 /**
502 * Create a financial items & return the ID.
503 *
504 * Ideally this will never be called.
505 *
506 * However, I hit a scenario in testing where 'something' had created a pending payment with
507 * no financial items and that would result in a fatal error without handling here. I failed
508 * to replicate & am not investigating via a new test methodology
509 * https://github.com/civicrm/civicrm-core/pull/15706
510 *
511 * After this is in I will do more digging & once I feel confident new instances are not being
512 * created I will add deprecation notices into this function with a view to removing.
513 *
514 * However, I think we want to add it in 5.20 as there is a risk of users experiencing an error
515 * if there is incorrect data & we need time to ensure that what I hit was not a 'thing.
516 * (it might be the demo site data is a bit flawed & that was the issue).
517 *
518 * @param array $lineItem
519 * @param string $trxn_date
520 * @param int $contactID
521 * @param string $currency
522 *
523 * @return int
524 *
525 * @throws \CiviCRM_API3_Exception
526 */
527 protected static function getNewFinancialItemID($lineItem, $trxn_date, $contactID, $currency): int {
528 $financialAccount = CRM_Financial_BAO_FinancialAccount::getFinancialAccountForFinancialTypeByRelationship(
529 $lineItem['financial_type_id'],
530 'Income Account Is'
531 );
532 $itemParams = [
533 'transaction_date' => $trxn_date,
534 'contact_id' => $contactID,
535 'currency' => $currency,
536 'amount' => $lineItem['line_total'],
537 'description' => $lineItem['label'],
538 'status_id' => 'Unpaid',
539 'financial_account_id' => $financialAccount,
540 'entity_table' => 'civicrm_line_item',
541 'entity_id' => $lineItem['id'],
542 ];
543 return (int) civicrm_api3('FinancialItem', 'create', $itemParams)['id'];
544 }
545
546 }