Merge pull request #23346 from mattwire/totaltaxamount
[civicrm-core.git] / CRM / Contribute / Import / Parser / Contribution.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 use Civi\Api4\Contact;
19 use Civi\Api4\Email;
20
21 /**
22 * Class to parse contribution csv files.
23 */
24 class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
25
26 protected $_mapperKeys;
27
28 /**
29 * Array of successfully imported contribution id's
30 *
31 * @var array
32 */
33 protected $_newContributions;
34
35 /**
36 * Class constructor.
37 *
38 * @param $mapperKeys
39 */
40 public function __construct($mapperKeys = []) {
41 parent::__construct();
42 $this->_mapperKeys = $mapperKeys;
43 }
44
45 /**
46 * Get information about the provided job.
47 * - name
48 * - id (generally the same as name)
49 * - label
50 *
51 * e.g. ['activity_import' => ['id' => 'activity_import', 'label' => ts('Activity Import'), 'name' => 'activity_import']]
52 *
53 * @return array
54 */
55 public static function getUserJobInfo(): array {
56 return [
57 'contribution_import' => [
58 'id' => 'contribution_import',
59 'name' => 'contribution_import',
60 'label' => ts('Contribution Import'),
61 ],
62 ];
63 }
64
65 /**
66 * Contribution-specific result codes
67 * @see CRM_Import_Parser result code constants
68 */
69 const SOFT_CREDIT = 512, SOFT_CREDIT_ERROR = 1024, PLEDGE_PAYMENT = 2048, PLEDGE_PAYMENT_ERROR = 4096;
70
71 /**
72 * Separator being used
73 * @var string
74 */
75 protected $_separator;
76
77 /**
78 * Total number of lines in file
79 * @var int
80 */
81 protected $_lineCount;
82
83 /**
84 * Running total number of valid soft credit rows
85 * @var int
86 */
87 protected $_validSoftCreditRowCount;
88
89 /**
90 * Running total number of invalid soft credit rows
91 * @var int
92 */
93 protected $_invalidSoftCreditRowCount;
94
95 /**
96 * Running total number of valid pledge payment rows
97 * @var int
98 */
99 protected $_validPledgePaymentRowCount;
100
101 /**
102 * Running total number of invalid pledge payment rows
103 * @var int
104 */
105 protected $_invalidPledgePaymentRowCount;
106
107 /**
108 * Array of pledge payment error lines, bounded by MAX_ERROR
109 * @var array
110 */
111 protected $_pledgePaymentErrors;
112
113 /**
114 * Array of pledge payment error lines, bounded by MAX_ERROR
115 * @var array
116 */
117 protected $_softCreditErrors;
118
119 /**
120 * Filename of pledge payment error data
121 *
122 * @var string
123 */
124 protected $_pledgePaymentErrorsFileName;
125
126 /**
127 * Filename of soft credit error data
128 *
129 * @var string
130 */
131 protected $_softCreditErrorsFileName;
132
133 /**
134 * Get the field mappings for the import.
135 *
136 * This is the same format as saved in civicrm_mapping_field except
137 * that location_type_id = 'Primary' rather than empty where relevant.
138 * Also 'im_provider_id' is mapped to the 'real' field name 'provider_id'
139 *
140 * @return array
141 * @throws \API_Exception
142 */
143 protected function getFieldMappings(): array {
144 $mappedFields = [];
145 foreach ($this->getSubmittedValue('mapper') as $i => $mapperRow) {
146 $mappedField = $this->getMappingFieldFromMapperInput($mapperRow, 0, $i);
147 // Just for clarity since 0 is a pseudo-value
148 unset($mappedField['mapping_id']);
149 $mappedFields[] = $mappedField;
150 }
151 return $mappedFields;
152 }
153
154 /**
155 * Get the required fields.
156 *
157 * @return array
158 */
159 public function getRequiredFields(): array {
160 return ['id' => ts('Contribution ID'), ['financial_type_id' => ts('Financial Type'), 'total_amount' => ts('Total Amount')]];
161 }
162
163 /**
164 * Transform the input parameters into the form handled by the input routine.
165 *
166 * @param array $values
167 * Input parameters as they come in from the datasource
168 * eg. ['Bob', 'Smith', 'bob@example.org', '123-456']
169 *
170 * @return array
171 * Parameters mapped to CiviCRM fields based on the mapping. eg.
172 * [
173 * 'total_amount' => '1230.99',
174 * 'financial_type_id' => 1,
175 * 'external_identifier' => 'abcd',
176 * 'soft_credit' => [3 => ['external_identifier' => '123', 'soft_credit_type_id' => 1]]
177 *
178 * @throws \API_Exception
179 */
180 public function getMappedRow(array $values): array {
181 $params = [];
182 foreach ($this->getFieldMappings() as $i => $mappedField) {
183 if ($mappedField['name'] === 'do_not_import' || !$mappedField['name']) {
184 continue;
185 }
186 if (!empty($mappedField['soft_credit_match_field'])) {
187 $params['soft_credit'][$i] = ['soft_credit_type_id' => $mappedField['soft_credit_type_id'], $mappedField['soft_credit_match_field'] => $values[$i]];
188 }
189 else {
190 $params[$this->getFieldMetadata($mappedField['name'])['name']] = $this->getTransformedFieldValue($mappedField['name'], $values[$i]);
191 }
192 }
193 return $params;
194 }
195
196 /**
197 * @param string $name
198 * @param $title
199 * @param int $type
200 * @param string $headerPattern
201 * @param string $dataPattern
202 */
203 public function addField($name, $title, $type = CRM_Utils_Type::T_INT, $headerPattern = '//', $dataPattern = '//') {
204 if (empty($name)) {
205 $this->_fields['doNotImport'] = new CRM_Contribute_Import_Field($name, $title, $type, $headerPattern, $dataPattern);
206 }
207 else {
208 $tempField = CRM_Contact_BAO_Contact::importableFields('All', NULL);
209 if (!array_key_exists($name, $tempField)) {
210 $this->_fields[$name] = new CRM_Contribute_Import_Field($name, $title, $type, $headerPattern, $dataPattern);
211 }
212 else {
213 $this->_fields[$name] = new CRM_Contact_Import_Field($name, $title, $type, $headerPattern, $dataPattern,
214 CRM_Utils_Array::value('hasLocationType', $tempField[$name])
215 );
216 }
217 }
218 }
219
220 /**
221 * The initializer code, called before the processing
222 */
223 public function init() {
224 // Force re-load of user job.
225 unset($this->userJob);
226 $this->setFieldMetadata();
227 foreach ($this->getImportableFieldsMetadata() as $name => $field) {
228 $this->addField($name, $field['title'], $field['type'], $field['headerPattern'], $field['dataPattern']);
229 }
230 }
231
232 /**
233 * Set field metadata.
234 */
235 protected function setFieldMetadata() {
236 if (empty($this->importableFieldsMetadata)) {
237 $fields = CRM_Contribute_BAO_Contribution::importableFields($this->getContactType(), FALSE);
238
239 $fields = array_merge($fields,
240 [
241 'soft_credit' => [
242 'title' => ts('Soft Credit'),
243 'softCredit' => TRUE,
244 'headerPattern' => '/Soft Credit/i',
245 'options' => FALSE,
246 'type' => CRM_Utils_Type::T_STRING,
247 ],
248 ]
249 );
250
251 // add pledge fields only if its is enabled
252 if (CRM_Core_Permission::access('CiviPledge')) {
253 $pledgeFields = [
254 'pledge_id' => [
255 'title' => ts('Pledge ID'),
256 'headerPattern' => '/Pledge ID/i',
257 'name' => 'pledge_id',
258 'entity' => 'Pledge',
259 'type' => CRM_Utils_Type::T_INT,
260 'options' => FALSE,
261 ],
262 ];
263
264 $fields = array_merge($fields, $pledgeFields);
265 }
266 foreach ($fields as $name => $field) {
267 $fields[$name] = array_merge([
268 'type' => CRM_Utils_Type::T_INT,
269 'dataPattern' => '//',
270 'headerPattern' => '//',
271 ], $field);
272 }
273 $this->importableFieldsMetadata = $fields;
274 }
275 }
276
277 /**
278 * Handle the values in import mode.
279 *
280 * @param array $values
281 * The array of values belonging to this line.
282 */
283 public function import($values): void {
284 $rowNumber = (int) ($values[array_key_last($values)]);
285 try {
286 $params = $this->getMappedRow($values);
287 $formatted = array_merge(['version' => 3, 'skipRecentView' => TRUE, 'skipCleanMoney' => TRUE, 'contribution_id' => $params['id'] ?? NULL], $params);
288 //CRM-10994
289 if (isset($params['total_amount']) && $params['total_amount'] == 0) {
290 $params['total_amount'] = '0.00';
291 }
292 $this->formatInput($params, $formatted);
293
294 $paramValues = [];
295 foreach ($params as $key => $field) {
296 if ($field == NULL || $field === '') {
297 continue;
298 }
299 $paramValues[$key] = $field;
300 }
301
302 //import contribution record according to select contact type
303 if ($this->isSkipDuplicates() &&
304 (!empty($paramValues['contribution_contact_id']) || !empty($paramValues['external_identifier']))
305 ) {
306 $paramValues['contact_type'] = $this->getContactType();
307 }
308 elseif ($this->isUpdateExisting() &&
309 (!empty($paramValues['contribution_id']) || !empty($values['trxn_id']) || !empty($paramValues['invoice_id']))
310 ) {
311 $paramValues['contact_type'] = $this->getContactType();
312 }
313 elseif (!empty($paramValues['pledge_payment'])) {
314 $paramValues['contact_type'] = $this->getContactType();
315 }
316
317 $formatError = $this->deprecatedFormatParams($paramValues, $formatted);
318
319 if ($formatError) {
320 if (CRM_Utils_Array::value('error_data', $formatError) == 'soft_credit') {
321 throw new CRM_Core_Exception('', self::SOFT_CREDIT_ERROR);
322 }
323 if (CRM_Utils_Array::value('error_data', $formatError) == 'pledge_payment') {
324 throw new CRM_Core_Exception('', self::PLEDGE_PAYMENT_ERROR);
325 }
326 throw new CRM_Core_Exception('', CRM_Import_Parser::ERROR);
327 }
328
329 if ($this->isUpdateExisting()) {
330 //fix for CRM-2219 - Update Contribution
331 // onDuplicate == CRM_Import_Parser::DUPLICATE_UPDATE
332 if (!empty($paramValues['invoice_id']) || !empty($paramValues['trxn_id']) || !empty($paramValues['contribution_id'])) {
333 $dupeIds = [
334 'id' => $paramValues['contribution_id'] ?? NULL,
335 'trxn_id' => $paramValues['trxn_id'] ?? NULL,
336 'invoice_id' => $paramValues['invoice_id'] ?? NULL,
337 ];
338 $ids['contribution'] = CRM_Contribute_BAO_Contribution::checkDuplicateIds($dupeIds);
339
340 if ($ids['contribution']) {
341 $formatted['id'] = $ids['contribution'];
342 //process note
343 if (!empty($paramValues['note'])) {
344 $noteID = [];
345 $contactID = CRM_Core_DAO::getFieldValue('CRM_Contribute_DAO_Contribution', $ids['contribution'], 'contact_id');
346 $daoNote = new CRM_Core_BAO_Note();
347 $daoNote->entity_table = 'civicrm_contribution';
348 $daoNote->entity_id = $ids['contribution'];
349 if ($daoNote->find(TRUE)) {
350 $noteID['id'] = $daoNote->id;
351 }
352
353 $noteParams = [
354 'entity_table' => 'civicrm_contribution',
355 'note' => $paramValues['note'],
356 'entity_id' => $ids['contribution'],
357 'contact_id' => $contactID,
358 ];
359 CRM_Core_BAO_Note::add($noteParams, $noteID);
360 unset($formatted['note']);
361 }
362
363 //need to check existing soft credit contribution, CRM-3968
364 if (!empty($formatted['soft_credit'])) {
365 $dupeSoftCredit = [
366 'contact_id' => $formatted['soft_credit'],
367 'contribution_id' => $ids['contribution'],
368 ];
369
370 //Delete all existing soft Contribution from contribution_soft table for pcp_id is_null
371 $existingSoftCredit = CRM_Contribute_BAO_ContributionSoft::getSoftContribution($dupeSoftCredit['contribution_id']);
372 if (isset($existingSoftCredit['soft_credit']) && !empty($existingSoftCredit['soft_credit'])) {
373 foreach ($existingSoftCredit['soft_credit'] as $key => $existingSoftCreditValues) {
374 if (!empty($existingSoftCreditValues['soft_credit_id'])) {
375 civicrm_api3('ContributionSoft', 'delete', [
376 'id' => $existingSoftCreditValues['soft_credit_id'],
377 'pcp_id' => NULL,
378 ]);
379 }
380 }
381 }
382 }
383
384 $formatted['id'] = $ids['contribution'];
385
386 $newContribution = civicrm_api3('contribution', 'create', $formatted);
387 $this->_newContributions[] = $newContribution['id'];
388
389 //return soft valid since we need to show how soft credits were added
390 if (!empty($formatted['soft_credit'])) {
391 $this->setImportStatus($rowNumber, $this->getStatus(self::SOFT_CREDIT));
392 return;
393 }
394
395 // process pledge payment assoc w/ the contribution
396 $this->processPledgePayments($formatted);
397 $this->setImportStatus($rowNumber, $this->getStatus(self::PLEDGE_PAYMENT));
398 return;
399 }
400 $labels = [
401 'id' => 'Contribution ID',
402 'trxn_id' => 'Transaction ID',
403 'invoice_id' => 'Invoice ID',
404 ];
405 foreach ($dupeIds as $k => $v) {
406 if ($v) {
407 $errorMsg[] = "$labels[$k] $v";
408 }
409 }
410 $errorMsg = implode(' AND ', $errorMsg);
411 throw new CRM_Core_Exception('Matching Contribution record not found for ' . $errorMsg . '. Row was skipped.', CRM_Import_Parser::ERROR);
412 }
413 }
414
415 if (empty($formatted['contact_id'])) {
416
417 $error = $this->checkContactDuplicate($paramValues);
418
419 if (CRM_Core_Error::isAPIError($error, CRM_Core_ERROR::DUPLICATE_CONTACT)) {
420 $matchedIDs = explode(',', $error['error_message']['params'][0]);
421 if (count($matchedIDs) > 1) {
422 throw new CRM_Core_Exception('Multiple matching contact records detected for this row. The contribution was not imported', CRM_Import_Parser::ERROR);
423 }
424 $cid = $matchedIDs[0];
425 $formatted['contact_id'] = $cid;
426
427 $newContribution = civicrm_api('contribution', 'create', $formatted);
428 if (civicrm_error($newContribution)) {
429 if (is_array($newContribution['error_message'])) {
430 if ($newContribution['error_message']['params'][0]) {
431 throw new CRM_Core_Exception($newContribution['error_message']['message'], CRM_Import_Parser::DUPLICATE);
432 }
433 }
434 else {
435 throw new CRM_Core_Exception($newContribution['error_message'], CRM_Import_Parser::ERROR);
436 }
437 }
438
439 $this->_newContributions[] = $newContribution['id'];
440 $formatted['contribution_id'] = $newContribution['id'];
441
442 //return soft valid since we need to show how soft credits were added
443 if (!empty($formatted['soft_credit'])) {
444 $this->setImportStatus($rowNumber, $this->getStatus(self::SOFT_CREDIT));
445 return;
446 }
447
448 $this->processPledgePayments($formatted);
449 $this->setImportStatus($rowNumber, $this->getStatus(self::PLEDGE_PAYMENT));
450 return;
451 }
452
453 // Using new Dedupe rule.
454 $ruleParams = [
455 'contact_type' => $this->getContactType(),
456 'used' => 'Unsupervised',
457 ];
458 $fieldsArray = CRM_Dedupe_BAO_DedupeRule::dedupeRuleFields($ruleParams);
459 $disp = NULL;
460 foreach ($fieldsArray as $value) {
461 if (array_key_exists(trim($value), $params)) {
462 $paramValue = $params[trim($value)];
463 if (is_array($paramValue)) {
464 $disp .= $params[trim($value)][0][trim($value)] . " ";
465 }
466 else {
467 $disp .= $params[trim($value)] . " ";
468 }
469 }
470 }
471
472 if (!empty($params['external_identifier'])) {
473 if ($disp) {
474 $disp .= "AND {$params['external_identifier']}";
475 }
476 else {
477 $disp = $params['external_identifier'];
478 }
479 }
480 $errorMessage = 'No matching Contact found for (' . $disp . ')';
481 throw new CRM_Core_Exception($errorMessage, CRM_Import_Parser::ERROR);
482 }
483
484 if (!empty($paramValues['external_identifier'])) {
485 $checkCid = new CRM_Contact_DAO_Contact();
486 $checkCid->external_identifier = $paramValues['external_identifier'];
487 $checkCid->find(TRUE);
488 if ($checkCid->id != $formatted['contact_id']) {
489 $errorMessage = 'Mismatch of External ID:' . $paramValues['external_identifier'] . ' and Contact Id:' . $formatted['contact_id'];
490 throw new CRM_Core_Exception($errorMessage, CRM_Import_Parser::ERROR);
491 }
492 }
493 $newContribution = civicrm_api('contribution', 'create', $formatted);
494 if (civicrm_error($newContribution)) {
495 if (is_array($newContribution['error_message'])) {
496 if ($newContribution['error_message']['params'][0]) {
497 throw new CRM_Core_Exception('', CRM_Import_Parser::DUPLICATE);
498 }
499 }
500 else {
501 throw new CRM_Core_Exception($newContribution['error_message'], CRM_Import_Parser::ERROR);
502 }
503 }
504
505 $this->_newContributions[] = $newContribution['id'];
506 $formatted['contribution_id'] = $newContribution['id'];
507
508 //return soft valid since we need to show how soft credits were added
509 if (!empty($formatted['soft_credit'])) {
510 $this->setImportStatus($rowNumber, $this->getStatus(self::SOFT_CREDIT), '');
511 return;
512 }
513
514 // process pledge payment assoc w/ the contribution
515 $this->processPledgePayments($formatted);
516 $this->setImportStatus($rowNumber, $this->getStatus(self::PLEDGE_PAYMENT));
517 return;
518
519 }
520 catch (CRM_Core_Exception $e) {
521 $this->setImportStatus($rowNumber, $this->getStatus($e->getErrorCode()), $e->getMessage());
522 }
523 }
524
525 /**
526 * Get the status to record.
527 *
528 * @param int|null $code
529 *
530 * @return string
531 */
532 protected function getStatus(?int $code): string {
533 $errorMapping = [
534 self::SOFT_CREDIT_ERROR => 'soft_credit_error',
535 self::PLEDGE_PAYMENT_ERROR => 'pledge_payment_error',
536 self::SOFT_CREDIT => 'soft_credit_imported',
537 self::PLEDGE_PAYMENT => 'pledge_payment_imported',
538 CRM_Import_Parser::DUPLICATE => 'DUPLICATE',
539 CRM_Import_Parser::VALID => 'IMPORTED',
540 ];
541 return $errorMapping[$code] ?? 'ERROR';
542 }
543
544 /**
545 * Process pledge payments.
546 *
547 * @param array $formatted
548 */
549 private function processPledgePayments(array $formatted) {
550 if (!empty($formatted['pledge_payment_id']) && !empty($formatted['pledge_id'])) {
551 //get completed status
552 $completeStatusID = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
553
554 //need to update payment record to map contribution_id
555 CRM_Core_DAO::setFieldValue('CRM_Pledge_DAO_PledgePayment', $formatted['pledge_payment_id'],
556 'contribution_id', $formatted['contribution_id']
557 );
558
559 CRM_Pledge_BAO_PledgePayment::updatePledgePaymentStatus($formatted['pledge_id'],
560 [$formatted['pledge_payment_id']],
561 $completeStatusID,
562 NULL,
563 $formatted['total_amount']
564 );
565 }
566 }
567
568 /**
569 * Get the array of successfully imported contribution id's
570 *
571 * @return array
572 */
573 public function &getImportedContributions() {
574 return $this->_newContributions;
575 }
576
577 /**
578 * Format input params to suit api handling.
579 *
580 * Over time all the parts of deprecatedFormatParams
581 * and all the parts of the import function on this class that relate to
582 * reformatting input should be moved here and tests should be added in
583 * CRM_Contribute_Import_Parser_ContributionTest.
584 *
585 * @param array $params
586 * @param array $formatted
587 */
588 public function formatInput(&$params, &$formatted = []) {
589 foreach ($params as $key => $val) {
590 // @todo - call formatDateFields instead.
591 if ($val) {
592 switch ($key) {
593
594 case 'pledge_payment':
595 $params[$key] = CRM_Utils_String::strtobool($val);
596 break;
597
598 }
599 }
600 }
601 }
602
603 /**
604 * take the input parameter list as specified in the data model and
605 * convert it into the same format that we use in QF and BAO object
606 *
607 * @param array $params
608 * Associative array of property name/value
609 * pairs to insert in new contact.
610 * @param array $values
611 * The reformatted properties that we can use internally.
612 * @param bool $create
613 *
614 * @return array|CRM_Error
615 * @throws \CRM_Core_Exception
616 */
617 private function deprecatedFormatParams($params, &$values, $create = FALSE) {
618 require_once 'CRM/Utils/DeprecatedUtils.php';
619 // copy all the contribution fields as is
620 require_once 'api/v3/utils.php';
621
622 foreach ($params as $key => $value) {
623 // ignore empty values or empty arrays etc
624 if (CRM_Utils_System::isNull($value)) {
625 continue;
626 }
627
628 switch ($key) {
629 case 'contact_id':
630 if (!CRM_Utils_Rule::integer($value)) {
631 return civicrm_api3_create_error("contact_id not valid: $value");
632 }
633 $dao = new CRM_Core_DAO();
634 $qParams = [];
635 $svq = $dao->singleValueQuery("SELECT is_deleted FROM civicrm_contact WHERE id = $value",
636 $qParams
637 );
638 if (!isset($svq)) {
639 return civicrm_api3_create_error("Invalid Contact ID: There is no contact record with contact_id = $value.");
640 }
641 elseif ($svq == 1) {
642 return civicrm_api3_create_error("Invalid Contact ID: contact_id $value is a soft-deleted contact.");
643 }
644 $values['contact_id'] = $value;
645 break;
646
647 case 'contact_type':
648 // import contribution record according to select contact type
649 require_once 'CRM/Contact/DAO/Contact.php';
650 $contactType = new CRM_Contact_DAO_Contact();
651 $contactId = $params['contribution_contact_id'] ?? NULL;
652 $externalId = $params['external_identifier'] ?? NULL;
653 $email = $params['email'] ?? NULL;
654 //when insert mode check contact id or external identifier
655 if ($contactId || $externalId) {
656 $contactType->id = $contactId;
657 $contactType->external_identifier = $externalId;
658 if ($contactType->find(TRUE)) {
659 if ($params['contact_type'] != $contactType->contact_type) {
660 return civicrm_api3_create_error("Contact Type is wrong: $contactType->contact_type");
661 }
662 }
663 }
664 elseif ($email) {
665 if (!CRM_Utils_Rule::email($email)) {
666 return civicrm_api3_create_error("Invalid email address $email provided. Row was skipped");
667 }
668
669 // get the contact id from duplicate contact rule, if more than one contact is returned
670 // we should return error, since current interface allows only one-one mapping
671 $emailParams = [
672 'email' => $email,
673 'contact_type' => $params['contact_type'],
674 ];
675 $checkDedupe = _civicrm_api3_deprecated_duplicate_formatted_contact($emailParams);
676 if (!$checkDedupe['is_error']) {
677 return civicrm_api3_create_error("Invalid email address(doesn't exist) $email. Row was skipped");
678 }
679 $matchingContactIds = explode(',', $checkDedupe['error_message']['params'][0]);
680 if (count($matchingContactIds) > 1) {
681 return civicrm_api3_create_error("Invalid email address(duplicate) $email. Row was skipped");
682 }
683 if (count($matchingContactIds) == 1) {
684 $params['contribution_contact_id'] = $matchingContactIds[0];
685 }
686 }
687 elseif (!empty($params['contribution_id']) || !empty($params['trxn_id']) || !empty($params['invoice_id'])) {
688 // when update mode check contribution id or trxn id or
689 // invoice id
690 $contactId = new CRM_Contribute_DAO_Contribution();
691 if (!empty($params['contribution_id'])) {
692 $contactId->id = $params['contribution_id'];
693 }
694 elseif (!empty($params['trxn_id'])) {
695 $contactId->trxn_id = $params['trxn_id'];
696 }
697 elseif (!empty($params['invoice_id'])) {
698 $contactId->invoice_id = $params['invoice_id'];
699 }
700 if ($contactId->find(TRUE)) {
701 $contactType->id = $contactId->contact_id;
702 if ($contactType->find(TRUE)) {
703 if ($params['contact_type'] != $contactType->contact_type) {
704 return civicrm_api3_create_error("Contact Type is wrong: $contactType->contact_type");
705 }
706 }
707 }
708 }
709 else {
710 if ($this->isUpdateExisting()) {
711 return civicrm_api3_create_error("Empty Contribution and Invoice and Transaction ID. Row was skipped.");
712 }
713 }
714 break;
715
716 case 'soft_credit':
717 // import contribution record according to select contact type
718 // validate contact id and external identifier.
719 foreach ($value as $softKey => $softParam) {
720 $values['soft_credit'][$softKey] = [
721 'contact_id' => $this->lookupMatchingContact($softParam),
722 'soft_credit_type_id' => $softParam['soft_credit_type_id'],
723 ];
724 }
725 break;
726
727 case 'pledge_id':
728 // get total amount of from import fields
729 $totalAmount = $params['total_amount'] ?? NULL;
730 // we need to get contact id $contributionContactID to
731 // retrieve pledge details as well as to validate pledge ID
732
733 // first need to check for update mode
734 if ($this->isUpdateExisting() &&
735 ($params['id'] || $params['trxn_id'] || $params['invoice_id'])
736 ) {
737 $contribution = new CRM_Contribute_DAO_Contribution();
738 if ($params['contribution_id']) {
739 $contribution->id = $params['contribution_id'];
740 }
741 elseif ($params['trxn_id']) {
742 $contribution->trxn_id = $params['trxn_id'];
743 }
744 elseif ($params['invoice_id']) {
745 $contribution->invoice_id = $params['invoice_id'];
746 }
747
748 if ($contribution->find(TRUE)) {
749 $contributionContactID = $contribution->contact_id;
750 if (!$totalAmount) {
751 $totalAmount = $contribution->total_amount;
752 }
753 }
754 else {
755 throw new CRM_Core_Exception('No match found for specified contact in pledge payment data. Row was skipped.');
756 }
757 }
758 else {
759 // first get the contact id for given contribution record.
760 if (!empty($params['contribution_contact_id'])) {
761 $contributionContactID = $params['contribution_contact_id'];
762 }
763 elseif (!empty($params['external_identifier'])) {
764 require_once 'CRM/Contact/DAO/Contact.php';
765 $contact = new CRM_Contact_DAO_Contact();
766 $contact->external_identifier = $params['external_identifier'];
767 if ($contact->find(TRUE)) {
768 $contributionContactID = $params['contribution_contact_id'] = $values['contribution_contact_id'] = $contact->id;
769 }
770 else {
771 return civicrm_api3_create_error('No match found for specified contact in pledge payment data. Row was skipped.');
772 }
773 }
774 else {
775 // we need to get contribution contact using de dupe
776 $error = $this->checkContactDuplicate($params);
777
778 if (isset($error['error_message']['params'][0])) {
779 $matchedIDs = explode(',', $error['error_message']['params'][0]);
780
781 // check if only one contact is found
782 if (count($matchedIDs) > 1) {
783 return civicrm_api3_create_error($error['error_message']['message']);
784 }
785 $contributionContactID = $params['contribution_contact_id'] = $values['contribution_contact_id'] = $matchedIDs[0];
786 }
787 else {
788 return civicrm_api3_create_error('No match found for specified contact in contribution data. Row was skipped.');
789 }
790 }
791 }
792
793 if (!empty($params['pledge_id'])) {
794 if (CRM_Core_DAO::getFieldValue('CRM_Pledge_DAO_Pledge', $params['pledge_id'], 'contact_id') != $contributionContactID) {
795 return civicrm_api3_create_error('Invalid Pledge ID provided. Contribution row was skipped.');
796 }
797 $values['pledge_id'] = $params['pledge_id'];
798 }
799 else {
800 // check if there are any pledge related to this contact, with payments pending or in progress
801 require_once 'CRM/Pledge/BAO/Pledge.php';
802 $pledgeDetails = CRM_Pledge_BAO_Pledge::getContactPledges($contributionContactID);
803
804 if (empty($pledgeDetails)) {
805 return civicrm_api3_create_error('No open pledges found for this contact. Contribution row was skipped.');
806 }
807 if (count($pledgeDetails) > 1) {
808 return civicrm_api3_create_error('This contact has more than one open pledge. Unable to determine which pledge to apply the contribution to. Contribution row was skipped.');
809 }
810
811 // this mean we have only one pending / in progress pledge
812 $values['pledge_id'] = $pledgeDetails[0];
813 }
814
815 // we need to check if oldest payment amount equal to contribution amount
816 require_once 'CRM/Pledge/BAO/PledgePayment.php';
817 $pledgePaymentDetails = CRM_Pledge_BAO_PledgePayment::getOldestPledgePayment($values['pledge_id']);
818
819 if ($pledgePaymentDetails['amount'] == $totalAmount) {
820 $values['pledge_payment_id'] = $pledgePaymentDetails['id'];
821 }
822 else {
823 return civicrm_api3_create_error('Contribution and Pledge Payment amount mismatch for this record. Contribution row was skipped.');
824 }
825 break;
826
827 case 'contribution_campaign_id':
828 if (empty(CRM_Core_DAO::getFieldValue('CRM_Campaign_DAO_Campaign', $params['contribution_campaign_id']))) {
829 return civicrm_api3_create_error('Invalid Campaign ID provided. Contribution row was skipped.');
830 }
831 $values['contribution_campaign_id'] = $params['contribution_campaign_id'];
832 break;
833
834 }
835 }
836
837 if (array_key_exists('note', $params)) {
838 $values['note'] = $params['note'];
839 }
840
841 if ($create) {
842 // CRM_Contribute_BAO_Contribution::add() handles contribution_source
843 // So, if $values contains contribution_source, convert it to source
844 $changes = ['contribution_source' => 'source'];
845
846 foreach ($changes as $orgVal => $changeVal) {
847 if (isset($values[$orgVal])) {
848 $values[$changeVal] = $values[$orgVal];
849 unset($values[$orgVal]);
850 }
851 }
852 }
853
854 return NULL;
855 }
856
857 /**
858 * Get the civicrm_mapping_field appropriate layout for the mapper input.
859 *
860 * The input looks something like ['street_address', 1]
861 * and would be mapped to ['name' => 'street_address', 'location_type_id' =>
862 * 1]
863 *
864 * @param array $fieldMapping
865 * @param int $mappingID
866 * @param int $columnNumber
867 *
868 * @return array
869 * @throws \API_Exception
870 */
871 public function getMappingFieldFromMapperInput(array $fieldMapping, int $mappingID, int $columnNumber): array {
872 return [
873 'name' => $fieldMapping[0],
874 'mapping_id' => $mappingID,
875 'column_number' => $columnNumber,
876 // The name of the field to match the soft credit on is (crazily)
877 // stored in 'contact_type'
878 'contact_type' => $fieldMapping[1] ?? NULL,
879 // We also store the field in a sensible key, even if it isn't saved sensibly.
880 'soft_credit_match_field' => $fieldMapping[1] ?? NULL,
881 // This field is actually not saved at all :-( It is lost each time.
882 'soft_credit_type_id' => $fieldMapping[2] ?? NULL,
883 ];
884 }
885
886 /**
887 * Lookup matching contact.
888 *
889 * This looks up the matching contact from the contact id, external identifier
890 * or email. For the email a straight email search is done - this is equivalent
891 * to what happens on a dedupe rule lookup when the only field is 'email' - but
892 * we can't be sure the rule is 'just email' - and we are not collecting the
893 * fields for any other lookup in the case of soft credits (if we
894 * extend this function to main-contact-lookup we can handle full dedupe
895 * lookups - but note the error messages will need tweaking.
896 *
897 * @param array $params
898 *
899 * @return int
900 * Contact ID
901 *
902 * @throws \API_Exception
903 * @throws \CRM_Core_Exception
904 */
905 private function lookupMatchingContact(array $params): int {
906 $lookupField = !empty($params['contact_id']) ? 'contact_id' : (!empty($params['external_identifier']) ? 'external_identifier' : 'email');
907 if (empty($params['email'])) {
908 $contact = Contact::get(FALSE)->addSelect('id')
909 ->addWhere($lookupField === 'contact_id' ? 'id' : $lookupField, '=', $params[$lookupField])
910 ->execute();
911 if (count($contact) !== 1) {
912 throw new CRM_Core_Exception(ts("Soft Credit %1 - %2 doesn't exist. Row was skipped.",
913 [
914 1 => $this->getFieldMetadata($lookupField),
915 2 => $params['contact_id'] ?? $params['external_identifier'],
916 ]));
917 }
918 return $contact->first()['id'];
919 }
920
921 if (!CRM_Utils_Rule::email($params['email'])) {
922 throw new CRM_Core_Exception(ts('Invalid email address %1 provided for Soft Credit. Row was skipped'), [1 => $params['email']]);
923 }
924 $emails = Email::get(FALSE)
925 ->addWhere('contact_id.is_deleted', '=', 0)
926 ->addWhere('contact_id.contact_type', '=', $this->getContactType())
927 ->addWhere('email', '=', $params['email'])
928 ->addSelect('contact_id')->execute();
929 if (count($emails) === 0) {
930 throw new CRM_Core_Exception(ts("Invalid email address(doesn't exist) %1 for Soft Credit. Row was skipped", [1 => $params['email']]));
931 }
932 if (count($emails) > 1) {
933 throw new CRM_Core_Exception(ts('Invalid email address(duplicate) %1 for Soft Credit. Row was skipped', [1 => $params['email']]));
934 }
935 return $emails->first()['contact_id'];
936 }
937
938 /**
939 * @param array $mappedField
940 * Field detail as would be saved in field_mapping table
941 * or as returned from getMappingFieldFromMapperInput
942 *
943 * @return string
944 * @throws \API_Exception
945 */
946 public function getMappedFieldLabel(array $mappedField): string {
947 if (empty($this->importableFieldsMetadata)) {
948 $this->setFieldMetadata();
949 }
950 if ($mappedField['name'] === '') {
951 return '';
952 }
953 $title = [];
954 $title[] = $this->getFieldMetadata($mappedField['name'])['title'];
955 if ($mappedField['soft_credit_match_field']) {
956 $title[] = $this->getFieldMetadata($mappedField['soft_credit_match_field'])['title'];
957 }
958 if ($mappedField['soft_credit_type_id']) {
959 $title[] = CRM_Core_PseudoConstant::getLabel('CRM_Contribute_BAO_ContributionSoft', 'soft_credit_type_id', $mappedField['soft_credit_type_id']);
960 }
961
962 return implode(' - ', $title);
963 }
964
965 /**
966 * Get the metadata field for which importable fields does not key the actual field name.
967 *
968 * @return string[]
969 */
970 protected function getOddlyMappedMetadataFields(): array {
971 $uniqueNames = ['contribution_id', 'contribution_contact_id', 'contribution_cancel_date', 'contribution_source', 'contribution_check_number'];
972 $fields = [];
973 foreach ($uniqueNames as $name) {
974 $fields[$this->importableFieldsMetadata[$name]['name']] = $name;
975 }
976 // Include the parent fields as they could be present if required for matching ...in theory.
977 return array_merge($fields, parent::getOddlyMappedMetadataFields());
978 }
979
980 }