getAttribute('pi_' . $params['payment_method'], ''); if ('' === $payment_instrument_id) { throw new CRM_Core_Exception( E::ts('Payment method could not be matched to existing payment instrument.'), 'invalid_format' ); } $params['payment_instrument_id'] = $payment_instrument_id; // Validate date for parameter "confirmed_at". if (FALSE === DateTime::createFromFormat('YmdHis', $params['confirmed_at'])) { throw new CRM_Core_Exception( E::ts('Invalid date for parameter "confirmed_at".'), 'invalid_format' ); } // Validate date for parameter "user_birthdate". if (!empty($params['user_birthdate']) && FALSE === DateTime::createFromFormat('Ymd', $params['user_birthdate'])) { throw new CRM_Core_Exception( E::ts('Invalid date for parameter "user_birthdate".'), 'invalid_format' ); } // Get the gender ID defined within the profile, or return an error if none // matches (i.e. an unknown gender was submitted). if (is_string($params['user_gender'])) { $gender_id = $profile->getAttribute('gender_' . $params['user_gender']); if (!is_numeric($gender_id)) { throw new CRM_Core_Exception( E::ts('Gender could not be matched to existing gender.'), 'invalid_format' ); } $params['gender_id'] = $gender_id; } // Validate custom fields parameter, if given. if (isset($params['custom_fields'])) { if (is_string($params['custom_fields'])) { $params['custom_fields'] = json_decode($params['custom_fields'], TRUE); } if (!is_array($params['custom_fields'])) { throw new CRM_Core_Exception( E::ts('Invalid format for custom fields.'), 'invalid_format' ); } } // Validate products if (!empty($params['products']) && $profile->isShopEnabled()) { if (is_string($params['products'])) { $products = json_decode($params['products'], TRUE); $params['products'] = array_map(function ($product) { return array_intersect_key($product, array_flip(self::ALLOWED_PRODUCT_ATTRIBUTES)); }, $products); } if (!is_array($params['products'])) { throw new CiviCRM_API3_Exception( E::ts('Invalid format for products.'), 'invalid_format' ); } } // Validate campaign_id, if given. if (isset($params['campaign_id'])) { // Check whether campaign_id is a numeric string and cast it to an integer. if (is_numeric($params['campaign_id'])) { $params['campaign_id'] = intval($params['campaign_id']); } else { throw new CRM_Core_Exception( E::ts('campaign_id must be a numeric string. '), 'invalid_format' ); } // Check whether given campaign_id exists and if not, unset the parameter. try { civicrm_api3( 'Campaign', 'getsingle', ['id' => $params['campaign_id']] ); } catch (CRM_Core_Exception $e) { unset($params['campaign_id']); } } } /** * Retrieves the contact matching the given contact data or creates a new * contact. * * @param string $contact_type * The contact type to look for/to create. * @param array $contact_data * Data to use for contact lookup/to create a contact with. * @param CRM_Twingle_Profile $profile * Profile used for this process * @param array $submission * Submission data * * @return int|NULL * The ID of the matching/created contact, or NULL if no matching contact * was found and no new contact could be created. * @throws \CRM_Core_Exception * When invalid data was given. */ public static function getContact( string $contact_type, array $contact_data, CRM_Twingle_Profile $profile, array $submission = [] ) { // If no parameters are given, do nothing. if ([] === $contact_data) { return NULL; } // add xcm profile $xcm_profile = $profile->getAttribute('xcm_profile'); if (isset($xcm_profile) && '' !== $xcm_profile) { $contact_data['xcm_profile'] = $xcm_profile; } // add campaign, see issue #17 CRM_Twingle_Submission::setCampaign($contact_data, 'contact', $submission, $profile); // Prepare values: country. if (isset($contact_data['country'])) { if (is_numeric($contact_data['country'])) { // If a country ID is given, update the parameters. $contact_data['country_id'] = $contact_data['country']; unset($contact_data['country']); } else { // Look up the country depending on the given ISO code. $country = civicrm_api3('Country', 'get', ['iso_code' => $contact_data['country']]); if (isset($country['id'])) { $contact_data['country_id'] = $country['id']; unset($contact_data['country']); } else { throw new \CRM_Core_Exception( E::ts('Unknown country %1.', [1 => $contact_data['country']]), 'invalid_format' ); } } } // Prepare values: language. if (is_string($contact_data['preferred_language']) && '' !== $contact_data['preferred_language']) { $mapping = CRM_Core_I18n_PseudoConstant::longForShortMapping(); // Override the default mapping for German. $mapping['de'] = 'de_DE'; $contact_data['preferred_language'] = $mapping[$contact_data['preferred_language']]; } // Pass to XCM. $contact_data['contact_type'] = $contact_type; $contact = civicrm_api3('Contact', 'getorcreate', $contact_data); return isset($contact['id']) ? (int) $contact['id'] : NULL; } /** * Shares an organisation's work address, unless the contact already has one. * * @param int $contact_id * The ID of the contact to share the organisation address with. * @param int $organisation_id * The ID of the organisation whose address to share with the contact. * @param int $location_type_id * The ID of the location type to use for address lookup. * * @return boolean * Whether the organisation address has been shared with the contact. * * @throws \CRM_Core_Exception * When looking up or creating the shared address failed. */ public static function shareWorkAddress( int $contact_id, int $organisation_id, int $location_type_id = self::LOCATION_TYPE_ID_WORK ) { // Check whether organisation has a WORK address. $existing_org_addresses = civicrm_api3('Address', 'get', [ 'contact_id' => $organisation_id, 'location_type_id' => $location_type_id, ]); if ($existing_org_addresses['count'] <= 0) { // Organisation does not have a WORK address. return FALSE; } // Check whether contact already has a WORK address. $existing_contact_addresses = civicrm_api3('Address', 'get', [ 'contact_id' => $contact_id, 'location_type_id' => $location_type_id, ]); if ($existing_contact_addresses['count'] > 0) { // Contact already has a WORK address. return FALSE; } // Create a shared address. $address = reset($existing_org_addresses['values']); $address['contact_id'] = $contact_id; $address['master_id'] = $address['id']; unset($address['id']); civicrm_api3('Address', 'create', $address); return TRUE; } /** * Updates or creates an employer relationship between contact and * organisation. * * @param int $contact_id * The ID of the employee contact. * @param int $organisation_id * The ID of the employer contact. * * @throws \CRM_Core_Exception */ public static function updateEmployerRelation(int $contact_id, int $organisation_id): void { // see if there is already one $existing_relationship = civicrm_api3('Relationship', 'get', [ 'relationship_type_id' => self::EMPLOYER_RELATIONSHIP_TYPE_ID, 'contact_id_a' => $contact_id, 'contact_id_b' => $organisation_id, 'is_active' => 1, ]); if ($existing_relationship['count'] == 0) { // There is currently no (active) relationship between these contacts. $new_relationship_data = [ 'relationship_type_id' => self::EMPLOYER_RELATIONSHIP_TYPE_ID, 'contact_id_a' => $contact_id, 'contact_id_b' => $organisation_id, 'is_active' => 1, ]; civicrm_api3('Relationship', 'create', $new_relationship_data); } } /** * Check whether the CiviSEPA extension is installed and CiviSEPA * functionality is activated within the Twingle extension settings. * * @return bool * @throws \CRM_Core_Exception */ public static function civiSepaEnabled() { $sepa_extension = civicrm_api3('Extension', 'get', [ 'full_name' => 'org.project60.sepa', 'is_active' => 1, ]); return (bool) Civi::settings()->get('twingle_use_sepa') && $sepa_extension['count'] >= 0; } /** * Retrieves recurring contribution frequency attributes for a given donation * rhythm parameter value, according to a static mapping. * * @param string $donation_rhythm * The submitted "donation_rhythm" paramter according to the API action * specification. * * @return array{'frequency_unit'?: string, 'frequency_interval'?: int} * An array with "frequency_unit" and "frequency_interval" keys, to be added * to contribution parameter arrays. */ public static function getFrequencyMapping($donation_rhythm) { $mapping = [ 'halfyearly' => [ 'frequency_unit' => 'month', 'frequency_interval' => 6, ], 'quarterly' => [ 'frequency_unit' => 'month', 'frequency_interval' => 3, ], 'yearly' => [ 'frequency_unit' => 'month', 'frequency_interval' => 12, ], 'monthly' => [ 'frequency_unit' => 'month', 'frequency_interval' => 1, ], 'one_time' => [], ]; return $mapping[$donation_rhythm]; } /** * Retrieves the next possible cycle day for a SEPA mandate from a given start * date of the mandate, depending on CiviSEPA creditor configuration. * * @param string $start_date * A string representing a date in the format "Ymd". * * @param int $creditor_id * The ID of the CiviSEPA creditor to use for determining the cycle day. * * @return int * The next possible day of this or the next month to start collecting. */ public static function getSEPACycleDay($start_date, $creditor_id): int { $buffer_days = (int) CRM_Sepa_Logic_Settings::getSetting('pp_buffer_days'); $frst_notice_days = (int) CRM_Sepa_Logic_Settings::getSetting('batching.FRST.notice', $creditor_id); if (FALSE === ($earliest_rcur_date = strtotime("$start_date + $frst_notice_days days + $buffer_days days"))) { throw new BaseException(E::ts('Could not calculate SEPA cycle day from configuration.')); } // Find the next cycle day $cycle_days = CRM_Sepa_Logic_Settings::getListSetting('cycledays', range(1, 28), $creditor_id); $earliest_cycle_day = $earliest_rcur_date; while (!in_array(date('j', $earliest_cycle_day), $cycle_days, TRUE)) { $earliest_cycle_day = strtotime('+ 1 day', $earliest_cycle_day); } return (int) date('j', $earliest_cycle_day); } /** * Will set the campaign_id to the entity_data set, if the * profile is configured to do so. In that case the campaign is taken * from the submission data. Should that be empty, the profile's default * campaign is used. * * @param array $entity_data * the data set where the campaign_id should be set * @param string $context * defines the type of the entity_data: one of 'contribution', 'membership','mandate', 'recurring', 'contact' * @param array $submission * the submitted data * @param CRM_Twingle_Profile $profile * the twingle profile used */ public static function setCampaign( array &$entity_data, string $context, array $submission, CRM_Twingle_Profile $profile ): void { // first: make sure it's not set from other workflows unset($entity_data['campaign_id']); // then: check if campaign should be set it this context $enabled_contexts = $profile->getAttribute('campaign_targets'); if ($enabled_contexts === NULL || !is_array($enabled_contexts)) { // backward compatibility: $enabled_contexts = ['contribution', 'contact']; } if (in_array($context, $enabled_contexts, TRUE)) { // use the submitted campaign if set if (is_numeric($submission['campaign_id'])) { $entity_data['campaign_id'] = $submission['campaign_id']; } // otherwise use the profile's elseif (is_numeric($campaign = $profile->getAttribute('campaign'))) { $entity_data['campaign_id'] = $campaign; } } } /** * @param $values * Processed data * @param $submission * Submission data * @param $profile * The twingle profile used * * @throws \CiviCRM_API3_Exception * @throws \CRM_Core_Exception * @throws \Civi\Twingle\Shop\Exceptions\LineItemException */ public static function createLineItems($values, $submission, $profile): array { $line_items = []; $sum_line_items = 0; $contribution_id = $values['contribution']['id']; if (empty($contribution_id)) { throw new LineItemException( "Could not find contribution id for line item assignment.", LineItemException::ERROR_CODE_CONTRIBUTION_NOT_FOUND ); } foreach ($submission['products'] as $product) { $line_item_data = [ 'entity_table' => "civicrm_contribution", 'contribution_id' => $contribution_id, 'entity_id' => $contribution_id, 'label' => $product['name'], 'qty' => $product['count'], 'unit_price' => $product['price'], 'line_total' => $product['total_value'], 'sequential' => 1, ]; // Try to find the TwingleProduct with its corresponding PriceField // for this product try { $price_field = CRM_Twingle_BAO_TwingleProduct::findByExternalId($product['id']); } catch (Exception $e) { Civi::log()->error(E::LONG_NAME . ": An error occurred when searching for TwingleShop with the external ID " . $product['id'], ['exception' => $e]); $price_field = NULL; } // If found, use the financial type and price field id from the price field if ($price_field) { // Log warning if price is not variable and differs from the submission if ($price_field->price !== Null && $price_field->price != (int) $product['price']) { Civi::log()->warning(E::LONG_NAME . ": Price for product " . $product['name'] . " differs from the PriceField. " . "Using the price from the submission.", ['price_field' => $price_field->price, 'submission' => $product['price']]); } // Log warning if name differs from the submission if ($price_field->name != $product['name']) { Civi::log()->warning(E::LONG_NAME . ": Name for product " . $product['name'] . " differs from the PriceField " . "Using the name from the submission.", ['price_field' => $price_field->name, 'submission' => $product['name']]); } // Set the financial type and price field id $line_item_data['financial_type_id'] = $price_field->financial_type_id; $line_item_data['price_field_value_id'] = $price_field->getPriceFieldValueId(); $line_item_data['price_field_id'] = $price_field->price_field_id; $line_item_data['description'] = $price_field->description; } // If not found, use the shops default financial type else { $financial_type_id = $profile->getAttribute('shop_financial_type', 1); $line_item_data['financial_type_id'] = $financial_type_id; } // Create the line item $line_item = civicrm_api3('LineItem', 'create', $line_item_data); if (!empty($line_item['is_error'])) { $line_item_name = $line_item_data['name']; throw new CiviCRM_API3_Exception( E::ts("Could not create line item for product '%1'", [1 => $line_item_name]), 'api_error' ); } $line_items[] = array_pop($line_item['values']); $sum_line_items += $product['total_value']; } // Create line item for donation part $donation_sum = (float) $values['contribution']['total_amount'] - $sum_line_items; if ($donation_sum > 0) { $donation_financial_type_id = $profile->getAttribute('shop_donation_financial_type', 1); $donation_label = civicrm_api3('FinancialType', 'getsingle', [ 'return' => ['name'], 'id' => $donation_financial_type_id, ])['name']; $donation_line_item_data = [ 'entity_table' => "civicrm_contribution", 'contribution_id' => $contribution_id, 'entity_id' => $contribution_id, 'label' => $donation_label, 'qty' => 1, 'unit_price' => $donation_sum, 'line_total' => $donation_sum, 'financial_type_id' => $donation_financial_type_id, 'sequential' => 1, ]; $donation_line_item = civicrm_api3('LineItem', 'create', $donation_line_item_data); if (!empty($donation_line_item['is_error'])) { throw new CiviCRM_API3_Exception( E::ts("Could not create line item for donation"), 'api_error' ); } $line_items[] = array_pop($donation_line_item['values']); } return $line_items; } }