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