diff --git a/CRM/Twingle/Exceptions/BaseException.php b/CRM/Twingle/Exceptions/BaseException.php deleted file mode 100644 index cebf692..0000000 --- a/CRM/Twingle/Exceptions/BaseException.php +++ /dev/null @@ -1,48 +0,0 @@ -log_message = !empty($message) ? E::LONG_NAME . ': ' . $message : ''; - $this->code = $error_code; - } - - /** - * Returns the error message, but with the extension name prefixed. - * @return string - */ - public function getLogMessage() { - return $this->log_message; - } - - /** - * Returns the error code. - * @return string - */ - public function getErrorCode() { - return $this->code; - } - -} diff --git a/CRM/Twingle/Form/Profile.php b/CRM/Twingle/Form/Profile.php index 41d50be..73022cd 100644 --- a/CRM/Twingle/Form/Profile.php +++ b/CRM/Twingle/Form/Profile.php @@ -251,6 +251,7 @@ class CRM_Twingle_Form_Profile extends CRM_Core_Form { // Assign template variables. $this->assign('op', $this->_op); + $this->assign('twingle_use_shop', (int) Civi::settings()->get('twingle_use_shop')); $this->assign('profile_name', $profile_name); $this->assign('is_default', $is_default); @@ -354,6 +355,10 @@ class CRM_Twingle_Form_Profile extends CRM_Core_Form { static::getPrefixOptions() ); + // Add script and css for Twingle Shop integration + Civi::resources()->addScriptUrl(E::url('js/twingle_shop.js')); + Civi::resources()->addStyleFile(E::LONG_NAME, 'css/twingle_shop.css'); + $payment_instruments = CRM_Twingle_Profile::paymentInstruments(); $this->assign('payment_instruments', $payment_instruments); foreach ($payment_instruments as $pi_name => $pi_label) { @@ -523,6 +528,42 @@ class CRM_Twingle_Form_Profile extends CRM_Core_Form { ['class' => 'crm-select2 huge', 'multiple' => 'multiple'] ); + if (Civi::settings()->get('twingle_use_shop')) { + $this->add( + 'checkbox', // field type + 'enable_shop_integration', // field name + E::ts('Enable Shop Integration'), // field label + FALSE, + [] + ); + + $this->add( + 'select', // field type + 'shop_financial_type', // field name + E::ts('Default Financial Type'), // field label + static::getFinancialTypes(), // list of options + TRUE, + ['class' => 'crm-select2 huge'] + ); + + $this->add( + 'select', // field type + 'shop_donation_financial_type', // field name + E::ts('Financial Type for top up donations'), // field label + static::getFinancialTypes(), // list of options + TRUE, + ['class' => 'crm-select2 huge'] + ); + + $this->add( + 'checkbox', // field type + 'shop_map_products', // field name + E::ts('Map Products as Price Fields'), // field label + FALSE, // is not required + [] + ); + } + $this->addButtons([ [ 'type' => 'submit', @@ -566,9 +607,6 @@ class CRM_Twingle_Form_Profile extends CRM_Core_Form { CRM_Core_Session::setStatus($e->getMessage(), E::ts('Warning')); } } - catch (ProfileValidationError $e) { - $this->setElementError($e->getAffectedFieldName(), $e->getMessage()); - } } return parent::validate(); @@ -993,5 +1031,4 @@ class CRM_Twingle_Form_Profile extends CRM_Core_Form { } return static::$_campaigns; } - } diff --git a/CRM/Twingle/Form/Settings.php b/CRM/Twingle/Form/Settings.php index 0634dbc..a92535e 100644 --- a/CRM/Twingle/Form/Settings.php +++ b/CRM/Twingle/Form/Settings.php @@ -29,14 +29,16 @@ class CRM_Twingle_Form_Settings extends CRM_Core_Form { * List of all settings options. */ public static $SETTINGS_LIST = [ - 'twingle_prefix', - 'twingle_use_sepa', - 'twingle_dont_use_reference', - 'twingle_protect_recurring', - 'twingle_protect_recurring_activity_type', - 'twingle_protect_recurring_activity_subject', - 'twingle_protect_recurring_activity_status', - 'twingle_protect_recurring_activity_assignee', + 'twingle_prefix', + 'twingle_use_sepa', + 'twingle_dont_use_reference', + 'twingle_protect_recurring', + 'twingle_protect_recurring_activity_type', + 'twingle_protect_recurring_activity_subject', + 'twingle_protect_recurring_activity_status', + 'twingle_protect_recurring_activity_assignee', + 'twingle_use_shop', + 'twingle_access_key', ]; /** @@ -105,13 +107,25 @@ class CRM_Twingle_Form_Settings extends CRM_Core_Form { ] ); - $this->addButtons([ - [ - 'type' => 'submit', - 'name' => E::ts('Save'), - 'isDefault' => TRUE, - ], - ]); + $this->add( + 'checkbox', + 'twingle_use_shop', + E::ts("Use Twingle Shop Integration") + ); + + $this->add( + 'text', + 'twingle_access_key', + E::ts("Twingle Access Key") + ); + + $this->addButtons(array( + array ( + 'type' => 'submit', + 'name' => E::ts('Save'), + 'isDefault' => TRUE, + ) + )); // set defaults foreach (self::$SETTINGS_LIST as $setting) { @@ -124,8 +138,7 @@ class CRM_Twingle_Form_Settings extends CRM_Core_Form { } /** - * Custom form validation, because the activity creation fields - * are only mandatory if activity creation is active + * Custom form validation, as some fields are mandatory only when others are active. * @return bool */ public function validate() { @@ -146,6 +159,14 @@ class CRM_Twingle_Form_Settings extends CRM_Core_Form { } } + // Twingle Access Key is required if Shop Integration is enabled + if ( + CRM_Utils_Array::value('twingle_use_shop', $this->_submitValues) && + !CRM_Utils_Array::value('twingle_access_key', $this->_submitValues, FALSE) + ) { + $this->_errors['twingle_access_key'] = E::ts("An Access Key is required to enable Twingle Shop Integration"); + } + return (0 == count($this->_errors)); } diff --git a/CRM/Twingle/Page/Profiles.php b/CRM/Twingle/Page/Profiles.php index 790d340..da8743e 100644 --- a/CRM/Twingle/Page/Profiles.php +++ b/CRM/Twingle/Page/Profiles.php @@ -33,6 +33,7 @@ class CRM_Twingle_Page_Profiles extends CRM_Core_Page { } $this->assign('profiles', $profiles); $this->assign('profile_stats', CRM_Twingle_Profile::getProfileStats()); + $this->assign('twingle_use_shop', (int) Civi::settings()->get('twingle_use_shop')); // Add custom css Civi::resources()->addStyleFile(E::LONG_NAME, 'css/twingle.css'); diff --git a/CRM/Twingle/Profile.php b/CRM/Twingle/Profile.php index 10d412c..a0d0cff 100644 --- a/CRM/Twingle/Profile.php +++ b/CRM/Twingle/Profile.php @@ -43,6 +43,16 @@ class CRM_Twingle_Profile { */ protected $data; + /** + * @var array $check_box_fields + * List of check box fields + */ + public $check_box_fields = [ + 'newsletter_double_opt_in', + 'enable_shop_integration', + 'shop_map_products', + ]; + /** * CRM_Twingle_Profile constructor. * @@ -197,6 +207,16 @@ class CRM_Twingle_Profile { ); } + /** + * Determine if Twingle Shop integration is enabled in general and + * specifically for this profile. + * @return bool + */ + public function isShopEnabled(): bool { + return Civi::settings()->get('twingle_use_shop') && + $this->data['enable_shop_integration']; + } + /** * Retrieves an attribute of the profile. * @@ -532,6 +552,10 @@ class CRM_Twingle_Profile { 'required_address_components' => ['required' => FALSE], 'map_as_contribution_notes' => ['required' => FALSE], 'map_as_contact_notes' => ['required' => FALSE], + 'enable_shop_integration' => ['required' => FALSE], + 'shop_financial_type' => ['required' => FALSE], + 'shop_donation_financial_type' => ['required' => FALSE], + 'shop_map_products' => ['required' => FALSE], ], // Add payment methods. array_combine( @@ -650,6 +674,10 @@ class CRM_Twingle_Profile { ], 'map_as_contribution_notes' => [], 'map_as_contact_notes' => [], + 'enable_shop_integration' => FALSE, + 'shop_financial_type' => 1, + 'shop_donation_financial_type' => 1, + 'shop_map_products' => FALSE, ] // Add contribution status for all payment methods. // phpcs:ignore Drupal.Formatting.SpaceUnaryOperator.PlusMinus @@ -666,7 +694,7 @@ class CRM_Twingle_Profile { * @param string $project_id * * @return CRM_Twingle_Profile - * @throws \CRM\Twingle\Exceptions\ProfileException + * @throws \Civi\Twingle\Exceptions\ProfileException * @throws \Civi\Core\Exception\DBQueryException */ public static function getProfileForProject($project_id) { @@ -683,7 +711,6 @@ class CRM_Twingle_Profile { } // If none matches, use the default profile. - $default_profile = $profiles['default']; if (!empty($default_profile)) { return $default_profile; } @@ -780,5 +807,4 @@ class CRM_Twingle_Profile { } return $stats; } - } diff --git a/CRM/Twingle/Submission.php b/CRM/Twingle/Submission.php index 258f153..606a714 100644 --- a/CRM/Twingle/Submission.php +++ b/CRM/Twingle/Submission.php @@ -17,6 +17,8 @@ declare(strict_types = 1); use CRM_Twingle_ExtensionUtil as E; use Civi\Twingle\Exceptions\BaseException; +use Civi\Twingle\Shop\Exceptions\LineItemException; +use Civi\Twingle\Shop\BAO\TwingleProduct; class CRM_Twingle_Submission { @@ -41,7 +43,18 @@ class CRM_Twingle_Submission { public const EMPLOYER_RELATIONSHIP_TYPE_ID = 5; /** - * @param array &$params + * List of allowed product attributes. + */ + const ALLOWED_PRODUCT_ATTRIBUTES = [ + 'name', + 'internal_id', + 'price', + 'count', + 'total_value', + ]; + + /** + * @param array &$params * A reference to the parameters array of the submission. * * @param \CRM_Twingle_Profile $profile @@ -123,6 +136,22 @@ class CRM_Twingle_Submission { } } + // 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. @@ -433,4 +462,131 @@ class CRM_Twingle_Submission { } } + /** + * @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 = 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 differs from the submission + if ($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 '$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; + } } diff --git a/CRM/Twingle/Upgrader.php b/CRM/Twingle/Upgrader.php index 70f0180..aa80b58 100644 --- a/CRM/Twingle/Upgrader.php +++ b/CRM/Twingle/Upgrader.php @@ -137,4 +137,15 @@ class CRM_Twingle_Upgrader extends CRM_Extension_Upgrader_Base { return TRUE; } + /** + * The Upgrade to 1.5.1 creates the tables civicrm_twingle_product and + * civicrm_twingle_shop. + * + * @return TRUE on success + */ + public function upgrade_5151() { + $this->ctx->log->info('Creating tables for Twingle Shop.'); + $this->executeSqlFile('sql/civicrm_twingle_shop.sql'); + return TRUE; + } } diff --git a/Civi/Api4/TwingleProduct.php b/Civi/Api4/TwingleProduct.php new file mode 100644 index 0000000..839de01 --- /dev/null +++ b/Civi/Api4/TwingleProduct.php @@ -0,0 +1,12 @@ +. - */ - -declare(strict_types = 1); namespace Civi\Twingle\Exceptions; @@ -28,7 +11,7 @@ use CRM_Twingle_ExtensionUtil as E; class BaseException extends \Exception { /** - * @var string + * @var int|string */ protected $code; protected string $log_message; @@ -36,15 +19,13 @@ class BaseException extends \Exception { /** * BaseException Constructor * @param string $message - * Error message + * Error message * @param string $error_code - * A meaningful error code - * @param \Throwable $previous - * A previously thrown exception to include. + * A meaningful error code */ - public function __construct(string $message = '', string $error_code = '', \Throwable $previous = NULL) { - parent::__construct($message, 1, $previous); - $this->log_message = '' !== $message ? E::LONG_NAME . ': ' . $message : ''; + public function __construct(string $message = '', string $error_code = '') { + parent::__construct($message, 1); + $this->log_message = !empty($message) ? E::LONG_NAME . ': ' . $message : ''; $this->code = $error_code; } diff --git a/Civi/Twingle/Exceptions/BaseException.php~refs/remotes/origin/master b/Civi/Twingle/Exceptions/BaseException.php~refs/remotes/origin/master new file mode 100644 index 0000000..a5413d5 --- /dev/null +++ b/Civi/Twingle/Exceptions/BaseException.php~refs/remotes/origin/master @@ -0,0 +1,67 @@ +. + */ + +declare(strict_types = 1); + +namespace Civi\Twingle\Exceptions; + +use CRM_Twingle_ExtensionUtil as E; + +/** + * A simple custom exception class that indicates a problem within a class + * of the Twingle API extension. + */ +class BaseException extends \Exception { + + /** + * @var string + */ + protected $code; + protected string $log_message; + + /** + * BaseException Constructor + * @param string $message + * Error message + * @param string $error_code + * A meaningful error code + * @param \Throwable $previous + * A previously thrown exception to include. + */ + public function __construct(string $message = '', string $error_code = '', \Throwable $previous = NULL) { + parent::__construct($message, 1, $previous); + $this->log_message = '' !== $message ? E::LONG_NAME . ': ' . $message : ''; + $this->code = $error_code; + } + + /** + * Returns the error message, but with the extension name prefixed. + * @return string + */ + public function getLogMessage() { + return $this->log_message; + } + + /** + * Returns the error code. + * @return string + */ + public function getErrorCode() { + return $this->code; + } + +} diff --git a/CRM/Twingle/Exceptions/ProfileException.php b/Civi/Twingle/Exceptions/ProfileException.php~implement TwingleShop integration similarity index 94% rename from CRM/Twingle/Exceptions/ProfileException.php rename to Civi/Twingle/Exceptions/ProfileException.php~implement TwingleShop integration index 7011507..b9e5954 100644 --- a/CRM/Twingle/Exceptions/ProfileException.php +++ b/Civi/Twingle/Exceptions/ProfileException.php~implement TwingleShop integration @@ -1,6 +1,6 @@ + * @var string $apiToken + */ + private string $apiToken; + + /** + * The ID of your organization in the Twingle database. + * Automatically retrieved by sending a request with the associated API token. + * @var int $organisationId + */ + public int $organisationId; + + /** + * This boolean indicates whether the connection was successful. + * + * @var bool $isConnected + */ + public bool $isConnected; + + /** + * Limit the number of items requested per API call. + * @var int $limit + */ + public int $limit = 40; + + /** + * Header for cURL request. + * @var string[] $header + */ + private array $header; + + /** + * The cURL wrapper + * @var \Civi\Twingle\Shop\CurlWrapper $curlWrapper + */ + private CurlWrapper $curlWrapper; + + /** + * Protected TwingleApiCall constructor. + * Use \Civi\Twingle\ApiCall::singleton() instead. + * @param \Civi\Twingle\Shop\CurlWrapper $curlWrapper + */ + protected function __construct(CurlWrapper $curlWrapper) { + $this->curlWrapper = $curlWrapper; + $this->isConnected = FALSE; + } + + /** + * Returns \Civi\Twingle\Shop\ApiCall singleton + * + * @param \Civi\Twingle\Shop\CurlWrapper|null $curlWrapper + * Optional cURL wrapper for testing purposes + * @return \Civi\Twingle\Shop\ApiCall + */ + public static function singleton(CurlWrapper $curlWrapper = null): ApiCall { + if (empty(self::$singleton)) { + $curlWrapper = $curlWrapper ?? new CurlWrapper(); + self::$singleton = new ApiCall($curlWrapper); + return self::$singleton; + } + else { + return self::$singleton; + } + } + + /** + * Try to connect to the Twingle API and retrieve the organisation ID. + * + * @return bool + * returns TRUE if the connection was successfully established + * + * @throws \Civi\Twingle\Shop\Exceptions\ApiCallError + */ + public function connect(): bool { + + $this->isConnected = FALSE; + + try { + // Get api token from settings + $apiToken = \Civi::settings()->get("twingle_access_key"); + if (empty($apiToken)) { + throw new \TypeError(); + } + $this->apiToken = $apiToken; + } catch (\TypeError $e) { + throw new ApiCallError( + E::ts("Could not find Twingle API token"), + ApiCallError::ERROR_CODE_API_TOKEN_MISSING, + ); + } + + $this->header = [ + "x-access-code: $this->apiToken", + 'Content-Type: application/json', + ]; + + $url = self::PROTOCOL . 'organisation' . self::BASE_URL . "/"; + $curl = curl_init($url); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE); + curl_setopt($curl, CURLOPT_HTTPHEADER, $this->header); + + $response = json_decode(curl_exec($curl), TRUE); + + if (empty($response)) { + curl_close($curl); + throw new ApiCallError( + E::ts("Call to Twingle API failed. Please check your api token."), + ApiCallError::ERROR_CODE_CONNECTION_FAILED, + ); + } + self::check_response_and_close($response, $curl); + + $this->organisationId = array_column($response, 'id')[0]; + $this->isConnected = TRUE; + return $this->isConnected; + } + + /** + * Check response on cURL + * + * @param $response + * the cURL response to check + * @param $curl + * the cURL resource + * + * @return bool + * returns true if the response is fine + * + * @throws \Civi\Twingle\Shop\Exceptions\ApiCallError + */ + protected static function check_response_and_close($response, $curl) { + + $curl_status_code = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if ($response == FALSE) { + throw new ApiCallError( + E::ts('GET curl failed'), + ApiCallError::ERROR_CODE_GET_REQUEST_FAILED, + ); + } + if ($curl_status_code == 404) { + throw new ApiCallError( + E::ts('http status code 404 (not found)'), + ApiCallError::ERROR_CODE_404, + ); + } + elseif ($curl_status_code == 500) { + throw new ApiCallError( + E::ts('https status code 500 (internal error)'), + ApiCallError::ERROR_CODE_500, + ); + } + + return TRUE; + } + + /** + * Sends a GET cURL and returns the result array. + * + * @param $entity + * Twingle entity + * + * @param null $params + * Optional GET parameters + * + * @return array + * Returns the result array of the or FALSE, if the cURL failed + * @throws \Civi\Twingle\Shop\Exceptions\ApiCallError + */ + public function get( + string $entity, + string $entityId = NULL, + string $endpoint = NULL, + string $endpointId = NULL, + array $params = NULL + ): array { + + // Throw an error, if connection is not yet established + if ($this->isConnected == FALSE) { + throw new ApiCallError( + E::ts("Connection not yet established. Use connect() method."), + ApiCallError::ERROR_CODE_NOT_CONNECTED, + ); + } + + // Build URL and initialize cURL + $url = self::PROTOCOL . $entity . self::BASE_URL; + if (!empty($entityId)) { + $url .= "/$entityId"; + } + if (!empty($endpoint)) { + $url .= "/$endpoint"; + } + if (!empty($endpointId)) { + $url .= "/$endpointId"; + } + if (!empty($params)) { + $url .= '?' . http_build_query($params); + } + $curl = curl_init($url); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE); + curl_setopt($curl, CURLOPT_HTTPHEADER, $this->header); + + // Execute cURL + $response = json_decode(curl_exec($curl), TRUE); + self::check_response_and_close($response, $curl); + + return $response; + } +} + +/** + * A simple wrapper for the cURL functions to allow for easier testing. + */ +class CurlWrapper { + public function init($url) { + return curl_init($url); + } + + public function setopt($ch, $option, $value) { + return curl_setopt($ch, $option, $value); + } + + public function exec($ch) { + return curl_exec($ch); + } + + public function getinfo($ch, $option) { + return curl_getinfo($ch, $option); + } + + public function close($ch) { + curl_close($ch); + } +} diff --git a/Civi/Twingle/Shop/BAO/TwingleProduct.php b/Civi/Twingle/Shop/BAO/TwingleProduct.php new file mode 100644 index 0000000..3e2d120 --- /dev/null +++ b/Civi/Twingle/Shop/BAO/TwingleProduct.php @@ -0,0 +1,649 @@ + CRM_Utils_Type::T_INT, + "external_id" => CRM_Utils_Type::T_INT, + "name" => CRM_Utils_Type::T_STRING, + "is_active" => CRM_Utils_Type::T_BOOLEAN, + "description" => CRM_Utils_Type::T_STRING, + "price" => CRM_Utils_Type::T_INT, + "created_at" => CRM_Utils_Type::T_INT, + "tw_created_at" => CRM_Utils_Type::T_INT, + "updated_at" => CRM_Utils_Type::T_INT, + "tw_updated_at" => CRM_Utils_Type::T_INT, + "is_orphaned" => CRM_Utils_Type::T_BOOLEAN, + "is_outdated" => CRM_Utils_Type::T_BOOLEAN, + "project_id" => CRM_Utils_Type::T_INT, + "sort" => CRM_Utils_Type::T_INT, + "financial_type_id" => CRM_Utils_Type::T_INT, + "twingle_shop_id" => CRM_Utils_Type::T_INT, + "price_field_id" => CRM_Utils_Type::T_INT, + # "text" => \CRM_Utils_Type::T_STRING, + # "images" => \CRM_Utils_Type::T_STRING, + # "categories" = \CRM_Utils_Type::T_STRING, + # "internal_id" => \CRM_Utils_Type::T_STRING, + # "has_zero_price" => \CRM_Utils_Type::T_BOOLEAN, + # "name_plural" => \CRM_Utils_Type::T_STRING, + # "max_count" => \CRM_Utils_Type::T_INT, + # "has_textinput" => \CRM_Utils_Type::T_BOOLEAN, + # "count" => \CRM_Utils_Type::T_INT, + ]; + + /** + * Change attribute names to match the database column names. + * + * @param array $values + * Array with product data from Twingle API + * + * @return array + */ + public static function renameTwingleAttrs(array $values) { + $new_values = []; + foreach ($values as $key => $value) { + // replace 'id' with 'external_id' + if ($key == 'id') { + $key = 'external_id'; + } + // replace 'updated_at' with 'tw_updated_at' + if ($key == 'updated_at') { + $key = 'tw_updated_at'; + } + // replace 'created_at' with 'tw_created_at' + if ($key == 'created_at') { + $key = 'tw_created_at'; + } + $new_values[$key] = $value; + } + return $new_values; + } + + /** + * Load product data. + * + * @param array $product_data + * Array with product data + * + * @return void + * + * @throws ProductException + * @throws \Exception + */ + public function load(array $product_data): void { + // Filter for allowed attributes + filter_attributes( + $product_data, + self::ALLOWED_ATTRIBUTES, + self::CAN_BE_ZERO, + ); + + // Amend data from corresponding PriceFieldValue + if (isset($product_data['price_field_id'])) { + try { + $price_field_value = civicrm_api3('PriceFieldValue', 'getsingle', [ + 'price_field_id' => $product_data['price_field_id'], + ]); + } + catch (CRM_Core_Exception $e) { + throw new ProductException( + E::ts("Could not find PriceFieldValue for Twingle Product ['id': %1, 'external_id': %2]: %3", + [ + 1 => $product_data['id'], + 2 => $product_data['external_id'], + 3 => $e->getMessage(), + ]), + ProductException::ERROR_CODE_PRICE_FIELD_VALUE_NOT_FOUND); + } + $product_data['name'] = $product_data['name'] ?? $price_field_value['label']; + $product_data['price'] = $product_data['price'] ?? $price_field_value['amount']; + $product_data['financial_type_id'] = $product_data['financial_type_id'] ?? $price_field_value['financial_type_id']; + $product_data['is_active'] = $product_data['is_active'] ?? $price_field_value['is_active']; + $product_data['sort'] = $product_data['sort'] ?? $price_field_value['weight']; + $product_data['description'] = $product_data['description'] ?? $price_field_value['description']; + } + + // Change data types + try { + convert_str_to_int($product_data, self::STR_TO_INT_CONVERSION); + convert_int_to_bool($product_data, self::INT_TO_BOOL_CONVERSION); + convert_str_to_date($product_data, self::STR_TO_DATE_CONVERSION); + convert_null_to_int($product_data, self::NULL_TO_INT_CONVERSION); + } + catch (\Exception $e) { + throw new ProductException($e->getMessage(), ProductException::ERROR_CODE_ATTRIBUTE_WRONG_DATA_TYPE); + } + + // Validate data types + try { + validate_data_types($product_data, self::ALLOWED_ATTRIBUTES); + } + catch (\Exception $e) { + throw new ProductException($e->getMessage(), ProductException::ERROR_CODE_ATTRIBUTE_WRONG_DATA_TYPE); + } + + // Set attributes + foreach ($product_data as $key => $value) { + $this->$key = $value; + } + } + + /** + * Creates a price field to represents this product in CiviCRM. + * + * @param string $mode + * 'create' or 'edit' + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + */ + public function createPriceField() { + // Define mode for PriceField + $mode = $this->price_field_id ? 'edit' : 'create'; + $action = $mode == 'create' ? 'create' : 'update'; + + // Check if PriceSet for this Shop already exists + try { + $price_field = civicrm_api3('PriceField', 'get', [ + 'name' => 'tw_product_' . $this->external_id, + ]); + if ($price_field['count'] > 0 && $mode == 'create') { + throw new ProductException( + E::ts('PriceField for this Twingle Product already exists.'), + ProductException::ERROR_CODE_PRICE_FIELD_ALREADY_EXISTS, + ); + } elseif ($price_field['count'] == 0 && $mode == 'edit') { + throw new ProductException( + E::ts('PriceField for this Twingle Product does not exist and cannot be edited.'), + ProductException::ERROR_CODE_PRICE_FIELD_NOT_FOUND, + ); + } + } + catch (CRM_Core_Exception $e) { + throw new ProductException( + E::ts('Could not check if PriceField for this Twingle Product already exists.'), + ProductException::ERROR_CODE_PRICE_FIELD_NOT_FOUND, + ); + } + + // Try to find corresponding price set via TwingleShop + try { + $shop = civicrm_api3('TwingleShop', 'getsingle', [ + 'id' => $this->twingle_shop_id, + ]); + $this->price_set_id = (int) $shop['price_set_id']; + } + catch (CRM_Core_Exception $e) { + throw new ProductException( + E::ts('Could not find PriceSet for this Twingle Product.'), + ProductException::ERROR_CODE_PRICE_SET_NOT_FOUND, + ); + } + + // Create PriceField + $price_field_data = [ + 'price_set_id' => $this->price_set_id, + 'name' => 'tw_product_' . $this->external_id, + 'label' => $this->name, + 'is_active' => $this->is_active, + 'weight' => $this->sort, + 'html_type' => 'Text', + ]; + // Add id if in edit mode + if ($mode == 'edit') { + $price_field_data['id'] = $this->price_field_id; + } + try { + $price_field = civicrm_api4( + 'PriceField', + $action, + ['values' => $price_field_data], + )->first(); + $this->price_field_id = (int) $price_field['id']; + } + catch (CRM_Core_Exception $e) { + throw new ProductException( + E::ts('Could not create PriceField for this Twingle Product: %1', + [1 => $e->getMessage()]), + ProductException::ERROR_CODE_COULD_NOT_CREATE_PRICE_FIELD); + } + + // Try to find existing PriceFieldValue if in edit mode + $price_field_value = NULL; + if ($mode == 'edit') { + try { + $price_field_value = civicrm_api3('PriceFieldValue', 'getsingle', [ + 'price_field_id' => $this->price_field_id, + ]); + } + catch (CRM_Core_Exception $e) { + throw new ProductException( + E::ts('Could not find PriceFieldValue for this Twingle Product: %1', + [1 => $e->getMessage()]), + ProductException::ERROR_CODE_PRICE_FIELD_VALUE_NOT_FOUND); + } + } + + // Create PriceFieldValue + $price_field_value_data = [ + 'price_field_id' => $this->price_field_id, + 'financial_type_id' => $this->financial_type_id, + 'label' => $this->name, + 'amount' => $this->price, + 'is_active' => $this->is_active, + 'description' => $this->description, + ]; + // Add id if in edit mode + if ($mode == 'edit' && $price_field_value) { + $price_field_value_data['id'] = $price_field_value['id']; + } + try { + civicrm_api4( + 'PriceFieldValue', + $action, + ['values' => $price_field_value_data], + ); + } + catch (CRM_Core_Exception $e) { + throw new ProductException( + E::ts('Could not create PriceFieldValue for this Twingle Product: %1', + [1 => $e->getMessage()]), + ProductException::ERROR_CODE_COULD_NOT_CREATE_PRICE_FIELD_VALUE); + } + } + + /** + * Returns this TwingleProduct's attributes. + * + * @return array + * @throws \CRM_Core_Exception + */ + public function getAttributes() { + // Filter for allowed attributes + return array_intersect_key( + get_object_vars($this), + $this::ALLOWED_ATTRIBUTES + ) // Add financial type id of this product if it exists + + ['financial_type_id' => $this->getFinancialTypeId()]; + } + + /** + * Find TwingleProduct by its external ID. + * + * @param int $external_id + * External id of the product (by Twingle) + * + * @return TwingleProduct|null + * TwingleProduct object or NULL if not found + * + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + * @throws \Civi\Core\Exception\DBQueryException + */ + public static function findByExternalId($external_id) { + $dao = TwingleShopDAO::executeQuery("SELECT * FROM civicrm_twingle_product WHERE external_id = %1", + [1 => [$external_id, 'String']]); + if ($dao->fetch()) { + $product = new self(); + $product->load($dao->toArray()); + return $product; + } + return NULL; + } + + /** + * Add Twingle Product + * + * @param string $mode + * 'create' or 'edit' + * @return array + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + * @throws \Exception + */ + public function add($mode = 'create') { + + $tx = new CRM_Core_Transaction(); + + // Define mode + $mode = $this->id ? 'edit' : 'create'; + + // Try to lookup object in database + try { + $dao = TwingleShopDAO::executeQuery("SELECT * FROM civicrm_twingle_product WHERE external_id = %1", + [1 => [$this->external_id, 'String']]); + if ($dao->fetch()) { + $this->copyValues(array_merge($dao->toArray(), $this->getAttributes())); + } + } + catch (\Civi\Core\Exception\DBQueryException $e) { + throw new ProductException( + E::ts('Could not find TwingleProduct in database: ' . $e->getMessage()), + ShopException::ERROR_CODE_COULD_NOT_FIND_SHOP_IN_DB); + } + + // Register pre-hook + $twingle_product_values = $this->getAttributes(); + try { + \CRM_Utils_Hook::pre($mode, 'TwingleProduct', $this->id, $twingle_product_values); + } catch (\Exception $e) { + $tx->rollback(); + throw $e; + } + $this->load($twingle_product_values); + + // Set latest tw_updated_at as new updated_at + $this->updated_at = \CRM_Utils_Time::date('Y-m-d H:i:s', $this->tw_updated_at); + + // Convert created_at to date string + $this->created_at = \CRM_Utils_Time::date('Y-m-d H:i:s', $this->created_at); + + // Save object to database + try { + $this->save(); + } catch (\Exception $e) { + $tx->rollback(); + throw new ProductException( + E::ts('Could not save TwingleProduct to database: ' . $e->getMessage()), + ProductException::ERROR_CODE_COULD_NOT_CREATE_PRODUCT); + } + $result = self::findById($this->id); + /* @var self $result */ + $this->load($result->getAttributes()); + + // Register post-hook + $twingle_product_values = $this->getAttributes(); + try { + \CRM_Utils_Hook::post($mode, 'TwingleProduct', $this->id, $twingle_product_values); + } + catch (\Exception $e) { + $tx->rollback(); + throw $e; + } + $this->load($twingle_product_values); + + return $result->toArray(); + } + + /** + * Delete TwingleProduct along with associated PriceField and PriceFieldValue. + * + * @override \Civi\Twingle\Shop\DAO\TwingleProduct::delete + * @throws \CRM_Core_Exception + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + */ + function delete($useWhere = FALSE) { + // Register post-hook + $twingle_product_values = $this->getAttributes(); + \CRM_Utils_Hook::pre('delete', 'TwingleProduct', $this->id, $twingle_product_values); + $this->load($twingle_product_values); + + // Delete TwingleProduct + parent::delete($useWhere); + + // Register post-hook + \CRM_Utils_Hook::post('delete', 'TwingleProduct', $this->id, $instance); + + // Free global arrays associated with this object + $this->free(); + + return true; + } + + /** + * Complements the data with the data that was fetched from Twingle. + * + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + */ + public function complementWithDataFromTwingle($product_from_twingle) { + // Complement with data from Twingle + $this->load([ + 'project_id' => $product_from_twingle['project_id'], + 'tw_updated_at' => $product_from_twingle['updated_at'], + 'tw_created_at' => $product_from_twingle['created_at'], + ]); + } + + /** + * Check if the product is outdated. + * + * @param $product_from_twingle + * + * @return void + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + */ + public function checkOutdated($product_from_twingle) { + // Mark outdated products which have a newer timestamp in Twingle + if ($this->updated_at < intval($product_from_twingle['updated_at'])) { + // Overwrite the product with the data from Twingle + $this->load(self::renameTwingleAttrs($product_from_twingle)); + $this->is_outdated = TRUE; + } + } + + /** + * Compare two products + * + * @param TwingleProduct $product_to_compare_with + * Product from database + * + * @return bool + */ + public function equals($product_to_compare_with) { + return + $this->name === $product_to_compare_with->name && + $this->description === $product_to_compare_with->description && + $this->text === $product_to_compare_with->text && + $this->price === $product_to_compare_with->price && + $this->external_id === $product_to_compare_with->external_id; + } + + /** + * Returns the financial type id of this product. + * + * @return int|null + * @throws \CRM_Core_Exception + */ + public function getFinancialTypeId(): ?int { + if (!empty($this->price_field_id)) { + $price_set = \Civi\Api4\PriceField::get() + ->addSelect('financial_type_id') + ->addWhere('id', '=', $this->price_field_id) + ->execute() + ->first(); + return $price_set['financial_type_id']; + } + return NULL; + } + + /** + * Returns the price field value id of this product. + * + * @return int|null + * @throws \CRM_Core_Exception + */ + public function getPriceFieldValueId() { + if (!empty($this->price_field_id)) { + $price_field_value = \Civi\Api4\PriceFieldValue::get() + ->addSelect('id') + ->addWhere('price_field_id', '=', $this->price_field_id) + ->execute() + ->first(); + return $price_field_value['id']; + } + return NULL; + } + + /** + * Delete PriceField and PriceFieldValue of this TwingleProduct if they exist. + * + * @return void + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + */ + public function deletePriceField(): void { + // Before we can delete the PriceField we need to delete the associated + // PriceFieldValue + try { + $result = civicrm_api3('PriceFieldValue', 'getsingle', + ['price_field_id' => $this->price_field_id]); + } + catch (CRM_Core_Exception $e) { + throw new ProductException( + E::ts('An Error occurred while searching for the associated PriceFieldValue: ' . $e->getMessage()), + ProductException::ERROR_CODE_PRICE_FIELD_VALUE_NOT_FOUND); + } + try { + civicrm_api3('PriceFieldValue', 'delete', ['id' => $result['id']]); + } + catch (CRM_Core_Exception $e) { + throw new ProductException( + E::ts('Could not delete associated PriceFieldValue: ' . $e->getMessage()), + ProductException::ERROR_CODE_COULD_NOT_DELETE_PRICE_FIELD_VALUE); + } + + // Try to delete PriceField + // If no PriceField is found, we assume that it has already been deleted + try { + civicrm_api3('PriceField', 'delete', + ['id' => $this->price_field_id]); + } + catch (CRM_Core_Exception $e) { + // Check if PriceField yet exists + try { + $result = civicrm_api3('PriceField', 'get', + ['id' => $this->price_field_id]); + // Throw exception if PriceField still exists + if ($result['count'] > 0) { + throw new ProductException( + E::ts('PriceField for this Twingle Product still exists.'), + ProductException::ERROR_CODE_PRICE_FIELD_STILL_EXISTS); + } + } + catch (CRM_Core_Exception $e) { + throw new ProductException( + E::ts('An Error occurred while searching for the associated PriceField: ' . $e->getMessage()), + ProductException::ERROR_CODE_PRICE_FIELD_NOT_FOUND); + } + throw new ProductException( + E::ts('Could not delete associated PriceField: ' . $e->getMessage()), + ProductException::ERROR_CODE_COULD_NOT_DELETE_PRICE_FIELD); + } + $this->price_field_id = NULL; + } +} diff --git a/Civi/Twingle/Shop/BAO/TwingleShop.php b/Civi/Twingle/Shop/BAO/TwingleShop.php new file mode 100644 index 0000000..c27853c --- /dev/null +++ b/Civi/Twingle/Shop/BAO/TwingleShop.php @@ -0,0 +1,478 @@ + \CRM_Utils_Type::T_INT, + 'project_identifier' => \CRM_Utils_Type::T_STRING, + 'numerical_project_id' => \CRM_Utils_Type::T_INT, + 'name' => \CRM_Utils_Type::T_STRING, + 'price_set_id' => \CRM_Utils_Type::T_INT, + 'financial_type_id' => \CRM_Utils_Type::T_INT, + ]; + + public const STR_TO_INT_CONVERSION = [ + 'id', + 'numerical_project_id', + 'price_set_id', + 'financial_type_id', + ]; + + /** + * @var array $products + * Array of Twingle Shop products (Cache) + */ + public $products; + + /** + * FK to Financial Type + * + * @var int + */ + public $financial_type_id; + + /** + * TwingleShop constructor + */ + public function __construct() { + parent::__construct(); + // Get TwingleApiCall singleton + $this->twingleApi = ApiCall::singleton(); + } + + /** + * Get Twingle Shop from database by its project identifier + * (like 'tw620214349ac97') + * + * @param string $project_identifier + * Twingle project identifier + * + * @return TwingleShop + * + * @throws ShopException + * @throws \Civi\Twingle\Shop\Exceptions\ApiCallError + * @throws \CRM_Core_Exception + */ + public static function findByProjectIdentifier(string $project_identifier) { + $shop = new TwingleShop(); + $shop->get('project_identifier', $project_identifier); + if (!$shop->id) { + $shop->fetchDataFromTwingle($project_identifier); + } + else { + $shop->price_set_id = civicrm_api3('PriceSet', 'getvalue', + ['return' => 'id', 'name' => $project_identifier]); + } + return $shop; + } + + /** + * Load Twingle Shop data + * + * @param array $shop_data + * Array with shop data + * + * @return void + * + * @throws ShopException + */ + public function load(array $shop_data): void { + // Filter for allowed attributes + filter_attributes($shop_data, self::ALLOWED_ATTRIBUTES); + + // Convert string to int + try { + convert_str_to_int($shop_data, self::STR_TO_INT_CONVERSION); + } + catch (Exception $e) { + throw new ShopException($e->getMessage(), ShopException::ERROR_CODE_ATTRIBUTE_WRONG_DATA_TYPE); + } + + // Validate data types + try { + validate_data_types($shop_data, self::ALLOWED_ATTRIBUTES); + } + catch (Exception $e) { + throw new ShopException($e->getMessage(), ShopException::ERROR_CODE_ATTRIBUTE_WRONG_DATA_TYPE); + } + + // Set attributes + foreach ($shop_data as $key => $value) { + $this->$key = $value; + } + } + + /** + * Get attributes + * + * @return array + */ + function getAttributes(): array { + return [ + 'id' => $this->id, + 'project_identifier' => $this->project_identifier, + 'numerical_project_id' => $this->numerical_project_id, + 'name' => $this->name, + 'price_set_id' => $this->price_set_id, + 'financial_type_id' => $this->financial_type_id, + ]; + } + + /** + * Add Twingle Shop + * + * @param string $mode + * 'create' or 'edit' + * @return array + * @throws \Civi\Twingle\Shop\Exceptions\ShopException + */ + public function add($mode = 'create') { + + // Try to lookup object in database + try { + $dao = TwingleShopDAO::executeQuery("SELECT * FROM civicrm_twingle_shop WHERE project_identifier = %1", + [1 => [$this->project_identifier, 'String']]); + if ($dao->fetch()) { + $this->load($dao->toArray()); + } + } catch (\Civi\Core\Exception\DBQueryException $e) { + throw new ShopException( + E::ts('Could not find TwingleShop in database: ' . $e->getMessage()), + ShopException::ERROR_CODE_COULD_NOT_FIND_SHOP_IN_DB); + } + + // Register pre-hook + $twingle_shop_values = $this->getAttributes(); + \CRM_Utils_Hook::pre($mode, 'TwingleShop', $this->id, $twingle_shop_values); + $this->load($twingle_shop_values); + + // Save object to database + $result = $this->save(); + + // Register post-hook + \CRM_Utils_Hook::post($mode, 'TwingleShop', $this->id, $instance); + + return $result->toArray(); + } + + /** + * Delete object by deleting the associated PriceSet and letting the foreign + * key constraint do the rest. + * + * @throws \Civi\Twingle\Shop\Exceptions\ShopException* + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + */ + function deleteByConstraint() { + // Register post-hook + $twingle_shop_values = $this->getAttributes(); + \CRM_Utils_Hook::pre('delete', 'TwingleShop', $this->id, $twingle_shop_values); + $this->load($twingle_shop_values); + + // Delete associated products + $this->deleteProducts(); + + // Try to get single PriceSet + try { + civicrm_api3('PriceSet', 'getsingle', + ['id' => $this->price_set_id]); + } + catch (\CRM_Core_Exception $e) { + if ($e->getMessage() != 'Expected one PriceSet but found 0') { + throw new ShopException( + E::ts('Could not find associated PriceSet: ' . $e->getMessage()), + ShopException::ERROR_CODE_PRICE_SET_NOT_FOUND); + } + else { + // If no PriceSet is found, we can simply delete the TwingleShop + return $this->delete(); + } + } + + // Deleting the associated PriceSet will also lead to the deletion of this + // TwingleShop because of the foreign key constraint and cascading. + try { + $result = civicrm_api3('PriceSet', 'delete', + ['id' => $this->price_set_id]); + } catch (\CRM_Core_Exception $e) { + throw new ShopException( + E::ts('Could not delete associated PriceSet: ' . $e->getMessage()), + ShopException::ERROR_CODE_COULD_NOT_DELETE_PRICE_SET); + } + + // Register post-hook + \CRM_Utils_Hook::post('delete', 'TwingleShop', $this->id, $instance); + + // Free global arrays associated with this object + $this->free(); + + return $result['is_error'] == 0; + } + + /** + * Fetch Twingle Shop products from Twingle + * + * @return array + * array of CRM_Twingle_Shop_BAO_Product + * + * @throws \Civi\Twingle\Shop\Exceptions\ApiCallError; + * @throws \Civi\Twingle\Shop\Exceptions\ProductException; + * @throws \Civi\Core\Exception\DBQueryException + * @throws \CRM_Core_Exception + */ + public function fetchProducts(): array { + // Establish connection, if not already connected + if (!$this->twingleApi->isConnected) { + $this->twingleApi->connect(); + } + + // Fetch products from Twingle API + $products_from_twingle = $this->twingleApi->get( + 'project', + $this->numerical_project_id, + 'products', + ); + + // Fetch products from database + if ($this->id) { + $products_from_db = $this->getProducts(); + + $products_from_twingle = array_reduce($products_from_twingle, function($carry, $product) { + $carry[$product['id']] = $product; + return $carry; + }, []); + + foreach ($products_from_db as $product) { + /* @var TwingleProductBAO $product */ + + // Find orphaned products which are in the database but not in Twingle + $found = array_key_exists($product->external_id, $products_from_twingle); + if (!$found) { + $product->is_orphaned = TRUE; + } + else { + // Complement with data from Twingle + $product->complementWithDataFromTwingle($products_from_twingle[$product->external_id]); + // Mark outdated products which have a newer version in Twingle + $product->checkOutdated($products_from_twingle[$product->external_id]); + } + $this->products[] = $product; + } + } + + // Create array with external_id as key + $products = array_reduce($this->products ?? [], function($carry, $product) { + $carry[$product->external_id] = $product; + return $carry; + }, []); + + // Add new products from Twingle + foreach ($products_from_twingle as $product_from_twingle) { + $found = array_key_exists($product_from_twingle['id'], $products); + if (!$found) { + $product = new TwingleProduct(); + $product->load(TwingleProduct::renameTwingleAttrs($product_from_twingle)); + $product->twingle_shop_id = $this->id; + $this->products[] = $product; + } + } + return $this->products; + } + + /** + * Get associated products. + * + * @return array[Civi\Twingle\Shop\BAO\TwingleProduct] + * @throws \Civi\Core\Exception\DBQueryException + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + */ + public function getProducts() { + $products = []; + + $result = TwingleProductBAO::executeQuery( + "SELECT * FROM civicrm_twingle_product WHERE twingle_shop_id = %1", + [1 => [$this->id, 'Integer']] + ); + + while ($result->fetch()) { + $product = new TwingleProductBAO(); + $product->load($result->toArray()); + $products[] = $product; + } + + return $products; + } + + /** + * Creates Twingle Shop as a price set in CiviCRM. + * + * @param string $mode + * 'create' or 'edit' + * @throws \Civi\Twingle\Shop\Exceptions\ShopException + */ + public function createPriceSet($mode = 'create') { + + // Define mode + $mode = $this->price_set_id ? 'edit' : 'create'; + + // Check if PriceSet for this Shop already exists + try { + $price_set = civicrm_api3('PriceSet', 'get', [ + 'name' => $this->project_identifier, + ]); + if ($price_set['count'] > 0 && $mode == 'create') { + throw new ShopException( + E::ts('PriceSet for this Twingle Shop already exists.'), + ShopException::ERROR_CODE_PRICE_SET_ALREADY_EXISTS, + ); + } + elseif ($price_set['count'] == 0 && $mode == 'edit') { + throw new ShopException( + E::ts('PriceSet for this Twingle Shop does not exist and cannot be edited.'), + ShopException::ERROR_CODE_PRICE_SET_NOT_FOUND, + ); + } + } catch (\CRM_Core_Exception $e) { + throw new ShopException( + E::ts('Could not check if PriceSet for this TwingleShop already exists.'), + ShopException::ERROR_CODE_PRICE_SET_NOT_FOUND, + ); + } + + // Create PriceSet + $price_set_data = [ + 'name' => $this->project_identifier, + 'title' => "$this->name ($this->project_identifier)", + 'is_active' => 1, + 'extends' => 2, + 'financial_type_id' => $this->financial_type_id, + ]; + // Set id if in edit mode + if ($mode == 'edit') { + $price_set_data['id'] = $this->price_set_id; + } + try { + $price_set = civicrm_api4('PriceSet', 'create', + ['values' => $price_set_data])->first(); + $this->price_set_id = (int) $price_set['id']; + } catch (\CRM_Core_Exception $e) { + throw new ShopException( + E::ts('Could not create PriceSet for this TwingleShop.'), + ShopException::ERROR_CODE_COULD_NOT_CREATE_PRICE_SET, + ); + } + } + + /** + * Retrieves the numerical project ID and the name of this shop from Twingle. + * + * @throws \Civi\Twingle\Shop\Exceptions\ShopException + * @throws \Civi\Twingle\Shop\Exceptions\ApiCallError + */ + private function fetchDataFromTwingle() { + + // Establish connection, if not already connected + if (!$this->twingleApi->isConnected) { + $this->twingleApi->connect(); + } + + // Get shops from Twingle if not cached + $shops = \Civi::cache('long')->get('twingle_shops'); + if (empty($shops)) { + $this::fetchShops($this->twingleApi); + $shops = \Civi::cache('long')->get('twingle_shops'); + } + + // Set Shop ID and name + foreach ($shops as $shop) { + if (isset($shop['identifier']) && $shop['identifier'] == $this->project_identifier) { + $this->numerical_project_id = $shop['id']; + $this->name = $shop['name']; + } + } + + // Throw an Exception if this Twingle Project is not of type 'shop' + if (!isset($this->numerical_project_id)) { + throw new ShopException( + E::ts('This Twingle Project is not a shop.'), + ShopException::ERROR_CODE_NOT_A_SHOP, + ); + } + } + + /** + * Retrieves all Twingle projects of the type 'shop'. + * + * @throws \Civi\Twingle\Shop\Exceptions\ShopException + */ + static private function fetchShops(ApiCall $api): void { + $organisationId = $api->organisationId; + try { + $projects = $api->get( + 'project', + NULL, + 'by-organisation', + $organisationId, + ); + $shops = array_filter( + $projects, + function($project) { + return isset($project['type']) && $project['type'] == 'shop'; + } + ); + \Civi::cache('long')->set('twingle_shops', $shops); + } + catch (Exception $e) { + throw new ShopException( + E::ts('Could not retrieve Twingle projects from API. + Please check your API credentials.'), + ShopException::ERROR_CODE_COULD_NOT_GET_PROJECTS, + ); + } + } + + /** + * Deletes all associated products. + * + * @return void + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + */ + public function deleteProducts() { + try { + $products = $this->getProducts(); + } catch (\Civi\Core\Exception\DBQueryException $e) { + throw new ProductException( + E::ts('Could not retrieve associated products: ' . $e->getMessage()), + ProductException::ERROR_CODE_COULD_NOT_GET_PRODUCTS + ); + } + try { + foreach ($products as $product) { + $product->delete(); + } + } + catch (ProductException $e) { + throw new ProductException( + E::ts('Could not delete associated products: ' . $e->getMessage()), + ProductException::ERROR_CODE_COULD_NOT_DELETE_PRICE_SET + ); + } + } + +} diff --git a/Civi/Twingle/Shop/DAO/TwingleProduct.php b/Civi/Twingle/Shop/DAO/TwingleProduct.php new file mode 100644 index 0000000..9d4f075 --- /dev/null +++ b/Civi/Twingle/Shop/DAO/TwingleProduct.php @@ -0,0 +1,327 @@ +__table = 'civicrm_twingle_product'; + parent::__construct(); + } + + /** + * Returns localized title of this entity. + * + * @param bool $plural + * Whether to return the plural version of the title. + */ + public static function getEntityTitle($plural = FALSE) { + return $plural ? E::ts('Twingle Products') : E::ts('Twingle Product'); + } + + /** + * Returns foreign keys and entity references. + * + * @return array + * [CRM_Core_Reference_Interface] + */ + public static function getReferenceColumns() { + if (!isset(\Civi::$statics[__CLASS__]['links'])) { + \Civi::$statics[__CLASS__]['links'] = static::createReferenceColumns(__CLASS__); + \Civi::$statics[__CLASS__]['links'][] = new \CRM_Core_Reference_Basic(self::getTableName(), 'price_field_id', 'civicrm_contact', 'id'); + \Civi::$statics[__CLASS__]['links'][] = new \CRM_Core_Reference_Basic(self::getTableName(), 'twingle_shop_id', 'civicrm_twingle_shop', 'id'); + \CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'links_callback', \Civi::$statics[__CLASS__]['links']); + } + return \Civi::$statics[__CLASS__]['links']; + } + + /** + * Returns all the column names of this table + * + * @return array + */ + public static function &fields() { + if (!isset(\Civi::$statics[__CLASS__]['fields'])) { + \Civi::$statics[__CLASS__]['fields'] = [ + 'id' => [ + 'name' => 'id', + 'type' => \CRM_Utils_Type::T_INT, + 'title' => E::ts('ID'), + 'description' => E::ts('Unique TwingleProduct ID'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_product.id', + 'table_name' => 'civicrm_twingle_product', + 'entity' => 'TwingleProduct', + 'bao' => 'Civi\Twingle\Shop\DAO\TwingleProduct', + 'localizable' => 0, + 'html' => [ + 'type' => 'Number', + ], + 'readonly' => TRUE, + 'add' => NULL, + ], + 'external_id' => [ + 'name' => 'external_id', + 'type' => \CRM_Utils_Type::T_INT, + 'title' => E::ts('External ID'), + 'description' => E::ts('The ID of this product in the Twingle database'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_product.external_id', + 'table_name' => 'civicrm_twingle_product', + 'entity' => 'TwingleProduct', + 'bao' => 'Civi\Twingle\Shop\DAO\TwingleProduct', + 'localizable' => 0, + 'html' => [ + 'type' => 'Number', + ], + 'add' => NULL, + ], + 'price_field_id' => [ + 'name' => 'price_field_id', + 'type' => \CRM_Utils_Type::T_INT, + 'title' => E::ts('Price Field ID'), + 'description' => E::ts('FK to Price Field'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_product.price_field_id', + 'table_name' => 'civicrm_twingle_product', + 'entity' => 'TwingleProduct', + 'bao' => 'Civi\Twingle\Shop\DAO\TwingleProduct', + 'localizable' => 0, + 'FKClassName' => 'CRM_Contact_DAO_Contact', + 'add' => NULL, + ], + 'twingle_shop_id' => [ + 'name' => 'twingle_shop_id', + 'type' => \CRM_Utils_Type::T_INT, + 'title' => E::ts('Twingle Shop ID'), + 'description' => E::ts('FK to Twingle Shop'), + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_product.twingle_shop_id', + 'table_name' => 'civicrm_twingle_product', + 'entity' => 'TwingleProduct', + 'bao' => 'Civi\Twingle\Shop\DAO\TwingleProduct', + 'localizable' => 0, + 'FKClassName' => 'Civi\Twingle\Shop\DAO\TwingleShop', + 'add' => NULL, + ], + 'created_at' => [ + 'name' => 'created_at', + 'type' => \CRM_Utils_Type::T_DATE + \CRM_Utils_Type::T_TIME, + 'title' => E::ts('Created At'), + 'description' => E::ts('Timestamp of when the product was created in the database'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_product.created_at', + 'table_name' => 'civicrm_twingle_product', + 'entity' => 'TwingleProduct', + 'bao' => 'Civi\Twingle\Shop\DAO\TwingleProduct', + 'localizable' => 0, + 'add' => NULL, + ], + 'updated_at' => [ + 'name' => 'updated_at', + 'type' => \CRM_Utils_Type::T_DATE + \CRM_Utils_Type::T_TIME, + 'title' => E::ts('Updated At'), + 'description' => E::ts('Timestamp of when the product was last updated in the database'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_product.updated_at', + 'table_name' => 'civicrm_twingle_product', + 'entity' => 'TwingleProduct', + 'bao' => 'Civi\Twingle\Shop\DAO\TwingleProduct', + 'localizable' => 0, + 'add' => NULL, + ], + ]; + \CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', \Civi::$statics[__CLASS__]['fields']); + } + return \Civi::$statics[__CLASS__]['fields']; + } + + /** + * Return a mapping from field-name to the corresponding key (as used in fields()). + * + * @return array + * Array(string $name => string $uniqueName). + */ + public static function &fieldKeys() { + if (!isset(\Civi::$statics[__CLASS__]['fieldKeys'])) { + \Civi::$statics[__CLASS__]['fieldKeys'] = array_flip(\CRM_Utils_Array::collect('name', self::fields())); + } + return \Civi::$statics[__CLASS__]['fieldKeys']; + } + + /** + * Returns the names of this table + * + * @return string + */ + public static function getTableName() { + return self::$_tableName; + } + + /** + * Returns if this table needs to be logged + * + * @return bool + */ + public function getLog() { + return self::$_log; + } + + /** + * Returns the list of fields that can be imported + * + * @param bool $prefix + * + * @return array + */ + public static function &import($prefix = FALSE) { + $r = \CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'twingle_product', $prefix, []); + return $r; + } + + /** + * Returns the list of fields that can be exported + * + * @param bool $prefix + * + * @return array + */ + public static function &export($prefix = FALSE) { + $r = \CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'twingle_product', $prefix, []); + return $r; + } + + /** + * Returns the list of indices + * + * @param bool $localize + * + * @return array + */ + public static function indices($localize = TRUE) { + $indices = []; + return ($localize && !empty($indices)) ? \CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices; + } + +} diff --git a/Civi/Twingle/Shop/DAO/TwingleShop.php b/Civi/Twingle/Shop/DAO/TwingleShop.php new file mode 100644 index 0000000..bef6db7 --- /dev/null +++ b/Civi/Twingle/Shop/DAO/TwingleShop.php @@ -0,0 +1,307 @@ +__table = 'civicrm_twingle_shop'; + parent::__construct(); + } + + /** + * Returns localized title of this entity. + * + * @param bool $plural + * Whether to return the plural version of the title. + */ + public static function getEntityTitle($plural = FALSE) { + return $plural ? E::ts('Twingle Shops') : E::ts('Twingle Shop'); + } + + /** + * Returns foreign keys and entity references. + * + * @return array + * [CRM_Core_Reference_Interface] + */ + public static function getReferenceColumns() { + if (!isset(\Civi::$statics[__CLASS__]['links'])) { + \Civi::$statics[__CLASS__]['links'] = static::createReferenceColumns(__CLASS__); + \Civi::$statics[__CLASS__]['links'][] = new \CRM_Core_Reference_Basic(self::getTableName(), 'price_set_id', 'civicrm_price_set', 'id'); + \CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'links_callback', \Civi::$statics[__CLASS__]['links']); + } + return \Civi::$statics[__CLASS__]['links']; + } + + /** + * Returns all the column names of this table + * + * @return array + */ + public static function &fields() { + if (!isset(\Civi::$statics[__CLASS__]['fields'])) { + \Civi::$statics[__CLASS__]['fields'] = [ + 'id' => [ + 'name' => 'id', + 'type' => \CRM_Utils_Type::T_INT, + 'title' => E::ts('ID'), + 'description' => E::ts('Unique TwingleShop ID'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_shop.id', + 'table_name' => 'civicrm_twingle_shop', + 'entity' => 'TwingleShop', + 'bao' => 'Civi\Twingle\Shop\DAO\TwingleShop', + 'localizable' => 0, + 'html' => [ + 'type' => 'Number', + ], + 'readonly' => TRUE, + 'add' => NULL, + ], + 'project_identifier' => [ + 'name' => 'project_identifier', + 'type' => \CRM_Utils_Type::T_STRING, + 'title' => E::ts('Project Identifier'), + 'description' => E::ts('Twingle Project Identifier'), + 'required' => TRUE, + 'maxlength' => 32, + 'size' => \CRM_Utils_Type::MEDIUM, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_shop.project_identifier', + 'table_name' => 'civicrm_twingle_shop', + 'entity' => 'TwingleShop', + 'bao' => 'Civi\Twingle\Shop\DAO\TwingleShop', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + ], + 'add' => NULL, + ], + 'numerical_project_id' => [ + 'name' => 'numerical_project_id', + 'type' => \CRM_Utils_Type::T_INT, + 'title' => E::ts('Numerical Project ID'), + 'description' => E::ts('Numerical Twingle Project Identifier'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_shop.numerical_project_id', + 'table_name' => 'civicrm_twingle_shop', + 'entity' => 'TwingleShop', + 'bao' => 'Civi\Twingle\Shop\DAO\TwingleShop', + 'localizable' => 0, + 'html' => [ + 'type' => 'Number', + ], + 'add' => NULL, + ], + 'price_set_id' => [ + 'name' => 'price_set_id', + 'type' => \CRM_Utils_Type::T_INT, + 'title' => E::ts('Price Set ID'), + 'description' => E::ts('FK to Price Set'), + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_shop.price_set_id', + 'table_name' => 'civicrm_twingle_shop', + 'entity' => 'TwingleShop', + 'bao' => 'Civi\Twingle\Shop\DAO\TwingleShop', + 'localizable' => 0, + 'FKClassName' => 'CRM_Price_DAO_PriceSet', + 'add' => NULL, + ], + 'name' => [ + 'name' => 'name', + 'type' => \CRM_Utils_Type::T_STRING, + 'title' => E::ts('Name'), + 'description' => E::ts('name of the shop'), + 'required' => TRUE, + 'maxlength' => 64, + 'size' => \CRM_Utils_Type::BIG, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_twingle_shop.name', + 'table_name' => 'civicrm_twingle_shop', + 'entity' => 'TwingleShop', + 'bao' => 'CRM_Twingle_Shop_DAO_TwingleShop', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + ], + 'add' => NULL, + ], + ]; + \CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', \Civi::$statics[__CLASS__]['fields']); + } + return \Civi::$statics[__CLASS__]['fields']; + } + + /** + * Return a mapping from field-name to the corresponding key (as used in fields()). + * + * @return array + * Array(string $name => string $uniqueName). + */ + public static function &fieldKeys() { + if (!isset(\Civi::$statics[__CLASS__]['fieldKeys'])) { + \Civi::$statics[__CLASS__]['fieldKeys'] = array_flip(\CRM_Utils_Array::collect('name', self::fields())); + } + return \Civi::$statics[__CLASS__]['fieldKeys']; + } + + /** + * Returns the names of this table + * + * @return string + */ + public static function getTableName() { + return self::$_tableName; + } + + /** + * Returns if this table needs to be logged + * + * @return bool + */ + public function getLog() { + return self::$_log; + } + + /** + * Returns the list of fields that can be imported + * + * @param bool $prefix + * + * @return array + */ + public static function &import($prefix = FALSE) { + $r = \CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'twingle_shop', $prefix, []); + return $r; + } + + /** + * Returns the list of fields that can be exported + * + * @param bool $prefix + * + * @return array + */ + public static function &export($prefix = FALSE) { + $r = \CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'twingle_shop', $prefix, []); + return $r; + } + + /** + * Returns the list of indices + * + * @param bool $localize + * + * @return array + */ + public static function indices($localize = TRUE) { + $indices = []; + return ($localize && !empty($indices)) ? \CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices; + } + +} diff --git a/Civi/Twingle/Shop/Exceptions/ApiCallError.php b/Civi/Twingle/Shop/Exceptions/ApiCallError.php new file mode 100644 index 0000000..7d611ca --- /dev/null +++ b/Civi/Twingle/Shop/Exceptions/ApiCallError.php @@ -0,0 +1,19 @@ + $value) { + // Skip empty values + if (empty($value)) { + continue; + } + + // Find expected data type + $expected_data_type = strtolower(\CRM_Utils_Type::typeToString($allowed_attributes[$key])); // It could be so easy... + + // Validate data type + if (!\CRM_Utils_Type::validatePhpType($value, $expected_data_type)) { + $given_type = gettype($value); + throw new \Exception( + "Data type of attribute '$key' is $given_type, but $expected_data_type was expected." + ); + } + } +} + diff --git a/api/v3/TwingleDonation/Submit.php b/api/v3/TwingleDonation/Submit.php index be27fc8..17cb107 100644 --- a/api/v3/TwingleDonation/Submit.php +++ b/api/v3/TwingleDonation/Submit.php @@ -254,7 +254,14 @@ function _civicrm_api3_twingle_donation_Submit_spec(&$params) { 'title' => E::ts('Custom fields'), 'type' => CRM_Utils_Type::T_STRING, 'api.required' => 0, - 'description' => E::ts('Additional information for either the contact or the (recurring) contribution.'), + 'description' => E::ts('Additional information for either the contact or the (recurring) contribution.'), + ); + $params['products'] = [ + 'name' => 'products', + 'title' => E::ts('Products'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 0, + 'description' => E::ts('Products ordered via TwingleShop'), ]; $params['remarks'] = [ 'name' => 'remarks', @@ -634,6 +641,11 @@ function civicrm_api3_twingle_donation_Submit($params) { 'total_amount' => $params['amount'] / 100, ]; + // If the submission contains products, do not auto-create a line item + if (!empty($params['products']) && $profile->isShopEnabled()) { + $contribution_data['skipLineItem'] = 1; + } + // Add custom field values. if (isset($custom_fields['Contribution'])) { $contribution_data += $custom_fields['Contribution']; @@ -727,6 +739,27 @@ function civicrm_api3_twingle_donation_Submit($params) { $mandate = civicrm_api3('SepaMandate', 'createfull', $mandate_data); $result_values['sepa_mandate'] = reset($mandate['values']); + + // Add contribution data to result_values for later use + $contribution_id = $result_values['sepa_mandate']['entity_id']; + if ($contribution_id) { + $contribution = civicrm_api3( + 'Contribution', + 'getsingle', + ['id' => $contribution_id] + ); + $result_values['contribution'] = $contribution; + } else { + $mandate_id = $result_values['sepa_mandate']['id']; + $message = E::LONG_NAME . ": could not find contribution for sepa mandate $mandate_id"; + throw new CiviCRM_API3_Exception($message, 'api_error'); + } + + // Add products as line items to the contribution + if (!empty($params['products']) && $profile->isShopEnabled()) { + $line_items = CRM_Twingle_Submission::createLineItems($result_values, $params, $profile); + $result_values['contribution']['line_items'] = $line_items; + } } else { // Set financial type depending on donation rhythm. This applies for @@ -827,6 +860,12 @@ function civicrm_api3_twingle_donation_Submit($params) { } $result_values['contribution'] = $contribution; + + // Add products as line items to the contribution + if (!empty($params['products']) && $profile->isShopEnabled()) { + $line_items = CRM_Twingle_Submission::createLineItems($result_values, $params, $profile); + $result_values['contribution']['line_items'] = $line_items; + } } // MEMBERSHIP CREATION diff --git a/api/v3/TwingleProduct/Create.php b/api/v3/TwingleProduct/Create.php new file mode 100644 index 0000000..36bb48a --- /dev/null +++ b/api/v3/TwingleProduct/Create.php @@ -0,0 +1,138 @@ + 'id', + 'title' => E::ts('TwingleProduct ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('The TwingleProduct ID in the database'), + ]; + $spec['external_id'] = [ + 'name' => 'external_id', + 'title' => E::ts('Twingle ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 1, + 'description' => E::ts('External product ID in Twingle database'), + ]; + $spec['project_id'] = [ + 'name' => 'project_id', + 'title' => E::ts('Twingle Shop ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 1, + 'description' => E::ts('ID of the corresponding Twingle Shop'), + ]; + $spec['name'] = [ + 'name' => 'name', + 'title' => E::ts('Product Name'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 1, + 'description' => E::ts('Name of the product'), + ]; + $spec['is_active'] = [ + 'name' => 'is_active', + 'title' => E::ts('Is active?'), + 'type' => CRM_Utils_Type::T_BOOLEAN, + 'api.required' => 0, + 'api.default' => 1, + 'description' => E::ts('Is the product active?'), + ]; + $spec['description'] = [ + 'name' => 'description', + 'title' => E::ts('Product Description'), + 'type' => CRM_Utils_Type::T_TEXT, + 'api.required' => 0, + 'description' => E::ts('Short description of the product'), + ]; + $spec['price'] = [ + 'name' => 'price', + 'title' => E::ts('Product Price'), + 'type' => CRM_Utils_Type::T_FLOAT, + 'api.required' => 0, + 'description' => E::ts('Price of the product'), + ]; + $spec['sort'] = [ + 'name' => 'sort', + 'title' => E::ts('Sort'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('Sort order of the product'), + ]; + $spec['financial_type_id'] = [ + 'name' => 'financial_type_id', + 'title' => E::ts('Financial Type ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 1, + 'description' => E::ts('ID of the financial type of the product'), + ]; + $spec['twingle_shop_id'] = [ + 'name' => 'twingle_shop_id', + 'title' => E::ts('FK to TwingleShop'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 1, + 'description' => E::ts('FK to TwingleShop'), + ]; + $spec['tw_updated_at'] = [ + 'name' => 'tw_updated_at', + 'title' => E::ts('Twingle timestamp'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 1, + 'description' => E::ts('Timestamp of last update in Twingle db'), + ]; + $spec['price_field_id'] = [ + 'name' => 'price_field_id', + 'title' => E::ts('FK to PriceField'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('FK to PriceField'), + ]; +} + +/** + * TwingleProduct.Create API + * + * @param array $params + * + * @return array + * API result descriptor + * + * @see civicrm_api3_create_success + * + * @throws API_Exception + * @throws \Exception + */ +function civicrm_api3_twingle_product_Create($params): array { + // Filter for allowed params + $allowed_params = []; + _civicrm_api3_twingle_product_Create_spec($allowed_params); + $params = array_intersect_key($params, $allowed_params); + + try { + // Create TwingleProduct and load params + $product = new TwingleProduct(); + $product->load($params); + + // Save TwingleProduct + $product->add(); + $result = $product->getAttributes(); + return civicrm_api3_create_success($result, $params, 'TwingleProduct', 'Create'); + } + catch (ProductException $e) { + return civicrm_api3_create_error($e->getMessage(), [ + 'error_code' => $e->getCode(), + 'params' => $params, + ]); + } +} diff --git a/api/v3/TwingleProduct/Delete.php b/api/v3/TwingleProduct/Delete.php new file mode 100644 index 0000000..60c9591 --- /dev/null +++ b/api/v3/TwingleProduct/Delete.php @@ -0,0 +1,72 @@ + 'id', + 'title' => E::ts('TwingleProduct ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('The TwingleProduct ID in CiviCRM'), + ]; + $spec['external_id'] = [ + 'name' => 'external_id', + 'title' => E::ts('External TwingleProduct ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('Twingle\'s ID of the product'), + ]; +} + +/** + * TwingleProduct.Delete API + * + * @param array $params + * + * @return array + * API result descriptor + * + * @throws API_Exception*@throws \Exception + * @throws \Exception + * @see civicrm_api3_create_success + * + */ +function civicrm_api3_twingle_product_Delete($params) { + // Filter for allowed params + $allowed_params = []; + _civicrm_api3_twingle_product_Delete_spec($allowed_params); + $params = array_intersect_key($params, $allowed_params); + + // Find TwingleProduct via getsingle API + $product_data = civicrm_api3('TwingleProduct', 'getsingle', $params); + if ($product_data['is_error']) { + return civicrm_api3_create_error($product_data['error_message'], + ['error_code' => $product_data['error_code'], 'params' => $params] + ); + } + + // Get TwingleProduct object + $product = TwingleProduct::findById($product_data['id']); + + // Delete TwingleProduct and associated PriceField and PriceFieldValue + $result = $product->delete(); + if ($result) { + return civicrm_api3_create_success(1, $params, 'TwingleProduct', 'Delete'); + } + else { + return civicrm_api3_create_error( + E::ts('TwingleProduct could not be deleted.'), + ['error_code' => 'delete_failed', 'params' => $params] + ); + } +} diff --git a/api/v3/TwingleProduct/Get.php b/api/v3/TwingleProduct/Get.php new file mode 100644 index 0000000..a7f64ca --- /dev/null +++ b/api/v3/TwingleProduct/Get.php @@ -0,0 +1,137 @@ + 'id', + 'title' => E::ts('TwingleProduct ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('The TwingleProduct ID in CiviCRM'), + ]; + $spec['external_id'] = [ + 'name' => 'external_id', + 'title' => E::ts('External TwingleProduct ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('Twingle\'s ID of the product'), + ]; + $spec['price_field_id'] = [ + 'name' => 'Price Field ID', + 'title' => E::ts('Price Field ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('FK to civicrm_price_field'), + ]; + $spec['twingle_shop_id'] = [ + 'name' => 'twingle_shop_id', + 'title' => E::ts('TwingleShop ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('The TwingleShop ID in CiviCRM'), + ]; + $spec['project_identifier'] = [ + 'name' => 'project_identifier', + 'title' => E::ts('Project Identifier'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 0, + 'description' => E::ts('Twingle project identifier'), + ]; + $spec['numerical_project_id'] = [ + 'name' => 'numerical_project_id', + 'title' => E::ts('Numerical Project Identifier'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('Twingle numerical project identifier'), + ]; +} + +/** + * TwingleProduct.Get API + * + * @param array $params + * + * @return array + * API result descriptor + * + * @throws API_Exception + * @see civicrm_api3_create_success + * + */ +function civicrm_api3_twingle_product_Get($params) { + // Filter for allowed params + $allowed_params = []; + _civicrm_api3_twingle_product_Get_spec($allowed_params); + $params = array_intersect_key($params, $allowed_params); + + // Build query + $query = 'SELECT ctp.* FROM civicrm_twingle_product ctp + INNER JOIN civicrm_twingle_shop cts ON ctp.twingle_shop_id = cts.id'; + $query_params = []; + + if (!empty($params)) { + $query = $query . ' WHERE'; + $possible_params = []; + _civicrm_api3_twingle_product_Get_spec($possible_params); + $param_count = 1; + $altered_params = []; + + // Specify product fields to define table prefix + $productFields = array_keys(TwingleProduct::fields()); + + // Alter params (prefix with table name) + foreach ($possible_params as $param) { + if (!empty($params[$param['name']])) { + // Prefix with table name + $table_prefix = in_array($param['name'], $productFields) ? 'ctp.' : 'cts.'; + $altered_params[] = [ + 'name' => $table_prefix . $param['name'], + 'value' => $params[$param['name']], + 'type' => $param['type'], + ]; + } + } + + // Add altered params to query + foreach ($altered_params as $param) { + $query = $query . ' ' . $param['name'] . " = %$param_count AND"; + $query_params[$param_count] = [ + $param['value'], + $param['type'] == CRM_Utils_Type::T_INT ? 'Integer' : 'String', + ]; + $param_count++; + } + } + + // Cut away last 'AND' + $query = substr($query, 0, -4); + + // Execute query + try { + $dao = TwingleProduct::executeQuery($query, $query_params); + } + catch (Exception $e) { + return civicrm_api3_create_error($e->getMessage(), [ + 'error_code' => $e->getCode(), + 'params' => $params, + ]); + } + + // Prepare return values + $returnValues = []; + while ($dao->fetch()) { + $returnValues[] = $dao->toArray(); + } + + return civicrm_api3_create_success($returnValues, $params, 'TwingleProduct', 'Get'); +} diff --git a/api/v3/TwingleProduct/Getsingle.php b/api/v3/TwingleProduct/Getsingle.php new file mode 100644 index 0000000..06eb88a --- /dev/null +++ b/api/v3/TwingleProduct/Getsingle.php @@ -0,0 +1,54 @@ + 'missing_parameter', 'params' => $params] + ); + } + + // Find TwingleProduct via get API + $returnValues = civicrm_api3('TwingleProduct', 'get', $params); + $count = $returnValues['count']; + + // Check whether only a single TwingleProduct is found + if ($count != 1) { + return civicrm_api3_create_error( + "Expected one TwingleProduct but found $count", + ['error_code' => 'not_found', 'params' => $params] + ); + } + return $returnValues['values'][$returnValues['id']]; +} diff --git a/api/v3/TwingleShop/Create.php b/api/v3/TwingleShop/Create.php new file mode 100644 index 0000000..ebb34f4 --- /dev/null +++ b/api/v3/TwingleShop/Create.php @@ -0,0 +1,80 @@ + 'project_identifier', + 'title' => E::ts('Project Identifier'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 1, + 'description' => E::ts('Twingle project identifier'), + ]; + $spec['numerical_project_id'] = [ + 'name' => 'numerical_project_id', + 'title' => E::ts('Numerical Project Identifier'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 1, + 'description' => E::ts('Numerical Twingle project identifier'), + ]; + $spec['name'] = [ + 'name' => 'name', + 'title' => E::ts('Shop Name'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 1, + 'description' => E::ts('Name of the shop'), + ]; + $spec['financial_type_id'] = [ + 'name' => 'financial_type_id', + 'title' => E::ts('Financial Type ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 1, + 'description' => E::ts('FK to civicrm_financial_type'), + ]; +} + +/** + * TwingleShop.Create API + * + * @param array $params + * + * @return array + * API result descriptor + * + * @see civicrm_api3_create_success + * + * @throws API_Exception + */ +function civicrm_api3_twingle_shop_Create($params) { + // Filter for allowed params + $allowed_params = []; + _civicrm_api3_twingle_shop_Create_spec($allowed_params); + $params = array_intersect_key($params, $allowed_params); + + try { + // Create TwingleShop and load params + $shop = new TwingleShop(); + $shop->load($params); + + // Save TwingleShop + $result = $shop->add(); + + // Return success + return civicrm_api3_create_success($result, $params, 'TwingleShop', 'Create'); + } + catch (ShopException $e) { + return civicrm_api3_create_error($e->getMessage(), [ + 'error_code' => $e->getErrorCode(), + 'params' => $params, + ]); + } +} diff --git a/api/v3/TwingleShop/Delete.php b/api/v3/TwingleShop/Delete.php new file mode 100644 index 0000000..d1f3323 --- /dev/null +++ b/api/v3/TwingleShop/Delete.php @@ -0,0 +1,79 @@ + 'id', + 'title' => E::ts('TwingleShop ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('The TwingleShop ID in CiviCRM'), + ]; + $spec['project_identifier'] = [ + 'name' => 'project_identifier', + 'title' => E::ts('Project Identifier'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 0, + 'description' => E::ts('Twingle project identifier'), + ]; +} + +/** + * TwingleShop.Delete API + * + * @param array $params + * + * @return array + * API result descriptor + * + * @throws \API_Exception + * @throws \Civi\Twingle\Shop\Exceptions\ShopException + * @throws \Exception + * @see civicrm_api3_create_success + * + */ +function civicrm_api3_twingle_shop_Delete($params) { + // Filter for allowed params + $allowed_params = []; + _civicrm_api3_twingle_shop_Delete_spec($allowed_params); + $params = array_intersect_key($params, $allowed_params); + + // Find TwingleShop via getsingle API + $shop_data = civicrm_api3('TwingleShop', 'getsingle', $params); + if ($shop_data['is_error']) { + return civicrm_api3_create_error($shop_data['error_message'], + ['error_code' => $shop_data['error_code'], 'params' => $params] + ); + } + + // Get TwingleShop object + $shop = TwingleShop::findById($shop_data['id']); + + // Delete TwingleShop + /* @var \Civi\Twingle\Shop\BAO\TwingleShop $shop */ + $result = $shop->deleteByConstraint(); + if ($result) { + return civicrm_api3_create_success(1, $params, 'TwingleShop', 'Delete'); + } + elseif ($result === 0) { + return civicrm_api3_create_error( + E::ts('TwingleShop could not be found.'), + ['error_code' => 'not_found', 'params' => $params] + ); + } + else { + return civicrm_api3_create_error( + E::ts('TwingleShop could not be deleted.'), + ['error_code' => 'delete_failed', 'params' => $params] + ); + } +} diff --git a/api/v3/TwingleShop/Fetch.php b/api/v3/TwingleShop/Fetch.php new file mode 100644 index 0000000..961f42b --- /dev/null +++ b/api/v3/TwingleShop/Fetch.php @@ -0,0 +1,97 @@ + 'project_identifiers', + 'title' => E::ts('Project Identifiers'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 1, + 'description' => E::ts('Comma separated list of Twingle project identifiers.'), + ]; +} + +/** + * TwingleShop.Fetch API + * + * @param array $params + * + * @return array + * API result descriptor + * + * @see civicrm_api3_create_success + * + * @throws API_Exception + */ +function civicrm_api3_twingle_shop_Fetch($params) { + // Filter for allowed params + $allowed_params = []; + _civicrm_api3_twingle_shop_Fetch_spec($allowed_params); + $params = array_intersect_key($params, $allowed_params); + + $returnValues = []; + + // Explode string with project IDs and trim + $projectIds = array_map( + function ($projectId) { + return trim($projectId); + }, + explode(',', $params['project_identifiers']) + ); + + // Get products for all projects of type 'shop' + foreach ($projectIds as $projectId) { + try { + $shop = TwingleShop::findByProjectIdentifier($projectId); + $products = $shop->fetchProducts(); + $returnValues[$projectId] = []; + $returnValues[$projectId] += $shop->getAttributes(); + $returnValues[$projectId]['products'] = array_map(function ($product) { + return $product->getAttributes(); + }, $products); + } + catch (ShopException|ApiCallError|ProductException $e) { + // If this project identifier doesn't belong to a project of type + // 'shop', just skip it + if ($e->getErrorCode() == ShopException::ERROR_CODE_NOT_A_SHOP) { + $returnValues[$projectId] = "project is not of type 'shop'"; + continue; + } + // Else, log error and throw exception + else { + Civi::log()->error( + $e->getMessage(), + [ + 'project_identifier' => $projectId, + 'params' => $params, + ] + ); + return civicrm_api3_create_error($e->getMessage(), [ + 'error_code' => $e->getErrorCode(), + 'project_identifier' => $projectId, + 'params' => $params, + ]); + } + } + } + + return civicrm_api3_create_success( + $returnValues, + $params, + 'TwingleShop', + 'Fetch' + ); +} diff --git a/api/v3/TwingleShop/Get.php b/api/v3/TwingleShop/Get.php new file mode 100644 index 0000000..2915212 --- /dev/null +++ b/api/v3/TwingleShop/Get.php @@ -0,0 +1,111 @@ + 'id', + 'title' => E::ts('TwingleShop ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('The TwingleShop ID in CiviCRM'), + ]; + $spec['project_identifier'] = [ + 'name' => 'project_identifier', + 'title' => E::ts('Project Identifier'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 0, + 'description' => E::ts('Twingle project identifier'), + ]; + $spec['numerical_project_id'] = [ + 'name' => 'numerical_project_id', + 'title' => E::ts('Numerical Project Identifier'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('Twingle numerical project identifier'), + ]; + $spec['name'] = [ + 'name' => 'name', + 'title' => E::ts('Name'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 0, + 'description' => E::ts('Name of the TwingleShop'), + ]; + $spec['price_set_id'] = [ + 'name' => 'price_set_id', + 'title' => E::ts('Price Set ID'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 0, + 'description' => E::ts('FK to civicrm_price_set'), + ]; +} + +/** + * TwingleShop.Get API + * + * @param array $params + * + * @return array + * API result descriptor + * + * @see civicrm_api3_create_success + * + * @throws API_Exception + */ +function civicrm_api3_twingle_shop_Get($params) { + // Filter for allowed params + $allowed_params = []; + _civicrm_api3_twingle_shop_Get_spec($allowed_params); + $params = array_intersect_key($params, $allowed_params); + + // Build query + $query = 'SELECT * FROM civicrm_twingle_shop'; + $query_params = []; + + if (!empty($params)) { + $query = $query . ' WHERE'; + $possible_params = []; + _civicrm_api3_twingle_shop_Get_spec($possible_params); + $param_count = 1; + + foreach ($possible_params as $param) { + if (!empty($params[$param['name']])) { + $query = $query . ' ' . $param['name'] . " = %$param_count AND"; + $query_params[$param_count] = [ + $params[$param['name']], + $param['type'] == CRM_Utils_Type::T_INT ? 'Integer' : 'String' + ]; + $param_count++; + } + } + // Cut away last 'AND' + $query = substr($query, 0, -4); + } + + // Execute query + try { + $dao = TwingleShop::executeQuery($query, $query_params); + } + catch (\Exception $e) { + return civicrm_api3_create_error($e->getMessage(), [ + 'error_code' => $e->getCode(), + 'params' => $params, + ]); + } + + // Prepare return values + $returnValues = []; + while ($dao->fetch()) { + $returnValues[] = $dao->toArray(); + } + + return civicrm_api3_create_success($returnValues, $params, 'TwingleShop', 'Get'); +} diff --git a/api/v3/TwingleShop/Getsingle.php b/api/v3/TwingleShop/Getsingle.php new file mode 100644 index 0000000..ff5cf86 --- /dev/null +++ b/api/v3/TwingleShop/Getsingle.php @@ -0,0 +1,54 @@ + 'missing_parameter', 'params' => $params] + ); + } + + // Find TwingleShop via get API + $returnValues = civicrm_api3('TwingleShop', 'get', $params); + $count = $returnValues['count']; + + // Check whether only a single TwingleShop is found + if ($count != 1) { + return civicrm_api3_create_error( + "Expected one TwingleShop but found $count", + ['error_code' => 'not_found', 'params' => $params] + ); + } + return $returnValues['values'][$returnValues['id']]; +} diff --git a/css/twingle_shop.css b/css/twingle_shop.css new file mode 100644 index 0000000..898001f --- /dev/null +++ b/css/twingle_shop.css @@ -0,0 +1,37 @@ +.twingle-shop-table-caption { + font-weight: bold; + font-size: 13px; + padding: 6px; + text-align: left; +} + +.twingle-shop-table-divider { + border-top: 1px solid #ddd; + margin-top: 20px; + margin-bottom: 4px; +} + +#twingle-shop-spinner { + animation: spin 2s linear infinite; +} + +.twingle-shop-table tbody tr { + height: 32px; +} + +.twingle-shop-table-button { + margin: 10px 15px 0 0 !important; +} + +.twingle-shop-cell-button { + margin: 3px 5px 3px 5px; +} + +.strikethrough { + text-decoration: line-through; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/info.xml b/info.xml index d0876c8..d715ec8 100644 --- a/info.xml +++ b/info.xml @@ -18,7 +18,7 @@ 1.5-dev dev - 5.56 + 5.58 diff --git a/js/twingle_shop.js b/js/twingle_shop.js new file mode 100644 index 0000000..fb7dd40 --- /dev/null +++ b/js/twingle_shop.js @@ -0,0 +1,741 @@ +/** + * This file contains the JavaScript code for the Twingle Shop integration. + */ + +/** + * This function initializes the Twingle Shop integration. + */ +function twingleShopInit() { + cj('#twingle-shop-spinner').hide(); + + // Run once on page load + load_financial_types(); + twingle_shop_active_changed(); + twingle_map_products_changed(); + twingle_fetch_products(); + + // Add event listeners + cj('#enable_shop_integration:checkbox').change(twingle_shop_active_changed); + cj('#shop_map_products:checkbox').change(twingle_map_products_changed); + cj('#btn_fetch_products').click(function (event) { + event.preventDefault(); // Prevent the default form submission behavior + twingle_fetch_products(); + }); +} + +// Define financial types as global variable +let financialTypes = {}; + +/** + * Load financial types from CiviCRM + */ +function load_financial_types() { + CRM.api3('FinancialType', 'get', { + 'sequential': 1, + 'options': { 'limit': 0 }, + }).then(function (result) { + financialTypes = result.values.reduce((obj, item) => { + obj[item.id] = item.name; + return obj; + }, {}); + }); +} + +/** + * Fetches the Twingle products for the given project identifiers. + */ +function twingle_fetch_products() { + let active = cj('#shop_map_products:checkbox:checked').length; + if (active) { + cj('#twingle-shop-spinner').show(); + CRM.api3('TwingleShop', 'fetch', { + 'project_identifiers': cj('#selectors :input').val(), + }).then(function (result) { + if (result.is_error === 1) { + cj('#btn_fetch_products').crmError(result.error_message, ts('Could not fetch products', [])); + cj('#twingle-shop-spinner').hide(); + return; + } + buildShopTables(result); + cj('#twingle-shop-spinner').hide(); + }, function () { + cj('#btn_fetch_products').crmError(ts('Could not fetch products. Please check your Twingle API key.', [])); + cj('#twingle-shop-spinner').hide(); + }); + } +} + +/** + * Update the form fields based on whether shop integration is currently active + */ +function twingle_shop_active_changed() { + let active = cj('#enable_shop_integration:checkbox:checked').length; + if (active) { + cj('.twingle-shop-element').show(); + } else { + cj('.twingle-shop-element').hide(); + } +} + +/** + * Display fetch button and product mapping when the corresponding option is active + */ +function twingle_map_products_changed() { + let active = cj('#shop_map_products:checkbox:checked').length; + if (active) { + cj('.twingle-product-mapping').show(); + } else { + cj('.twingle-product-mapping').hide(); + } +} + +/** + * This function builds the shop tables. + * @param shopData + */ +function buildShopTables(shopData) { + + let productTables = []; + + // Create table for each project (shop) + for (const key in shopData.values) { + productTables.push(new ProductsTable(shopData.values[key])); + } + + // Add table container to DOM + const tableContainer = document.getElementById('tableContainer'); + + // Add tables to table container + for (const productTable of productTables) { + tableContainer.appendChild(productTable.table); + } +} + +/** + * Get the value of the default financial type for the shops defined in this profile. + * @returns {string|string} + */ +function getShopDefaultFinancialType() { + const default_selection = document.getElementById('s2id_shop_financial_type'); + const selected = default_selection.getElementsByClassName('select2-chosen')[0]; + return selected ? selected.textContent : ''; +} + +/** + * Get the value of the default financial type. + * @returns {string} + */ +function getShopDefaultFinancialTypeValue() { + const shopDefaultFinancialType = getShopDefaultFinancialType(); + return Object.keys(financialTypes).find(key => financialTypes[key] === shopDefaultFinancialType); +} + +/** + * This class represents a Twingle Product. + */ +class Product { + + /** + * Creates a new Product object. + * @param productData + * @param parentTable + */ + constructor(productData, parentTable) { + this.parentTable = parentTable; + this.setProps(productData); + } + + /** + * Sets the properties of this product. + * @param productData + * @private + */ + setProps(productData) { + this.id = productData.id; + this.name = productData.name; + this.isActive = productData.is_active; + this.price = productData.price; + this.sort = productData.sort; + this.description = productData.description; + this.projectId = productData.project_id; + this.externalId = productData.external_id; + this.isOutdated = productData.is_outdated; + this.isOrphaned = productData.is_orphaned; + // this.updatedAt = productData.updated_at; + this.createdAt = productData.created_at; + this.twUpdatedAt = productData.tw_updated_at; + this.financialTypeId = productData.financial_type_id; + this.priceFieldId = productData.price_field_id; + } + + /** + * Dumps the product data. + * @returns {{id, name, is_active, price, sort, description, project_id, external_id, financial_type_id, tw_updated_at, twingle_shop_id: *}} + */ + dumpData() { + return { + 'id': this.id, + 'name': this.name, + 'is_active': this.isActive, + 'price': this.price, + 'sort': this.sort, + 'description': this.description, + 'project_id': this.projectId, + 'external_id': this.externalId, + 'financial_type_id': this.financialTypeId, + 'price_field_id': this.priceFieldId, + 'tw_updated_at': this.twUpdatedAt, + 'twingle_shop_id': this.parentTable.id, + }; + } + + /** + * Creates a button for creating, updating or deleting the price field for + * this product. + * @param action + * @param handler + * @returns {HTMLButtonElement} + * @private + */ + createProductButton(action, handler) { + // Create button + const button = document.createElement('button'); + button.id = action + '_twingle_product_tw_' + this.externalId; + button.classList.add('twingle-shop-cell-button'); + + // Add button text + let text = action === 'create' ? ts('Create', []) : action === 'update' ? ts('Update', []) : ts('Delete', []); + button.textContent = ' ' + ts(text, []); + + // Add button handler + if (handler) { + button.onclick = handler; + } else { + button.disabled = true; + } + + // Deactivate 'create' button if product hast no financial type + if (action === 'create' && this.financialTypeId === null) { + button.disabled = true; + } + + // Add icon + const icon = document.createElement('i'); + const iconClass = action === 'create' ? 'fa-plus-circle' : action === 'update' ? 'fa-refresh' : 'fa-trash'; + icon.classList.add('crm-i', iconClass); + button.insertBefore(icon, button.firstChild); + + return button; + } + + /** + * Creates a handler for creating a price field for this product. + * @returns {(function(*): void)|*} + * @private + */ + createPriceFieldHandler() { + const self = this; + return function (event) { + event.preventDefault(); + const action = event.target.innerText.includes('Update') ? 'updated' : 'created'; + CRM.api3('TwingleProduct', 'create', self.dumpData()) + .then(function (result) { + if (result.is_error === 1) { + cj('#create_twingle_product_tw_' + self.id).crmError(result.error_message, ts('Could not create Price Field for this product', [])); + } else { + self.update(result.values); + CRM.alert(ts(`The Price Field was ${action} successfully.`, []), ts(`Price Field ${action}`, []), 'success', {'expires': 5000}); + } + }, function (error) { + cj('#create_twingle_product_tw_' + self.id).crmError(error.message, ts('Could not create Price Field for this product', [])); + }); + }; + } + + /** + * Creates a handler for creating a price field for this product. + * @returns {(function(*): void)|*} + * @private + */ + deletePriceFieldHandler() { + let self = this; + return function (event) { + event.preventDefault(); + const options = { + 'title': ts('Delete Price Field', []), + 'message': ts('Are you sure you want to delete the price field associated with this product?', []), + }; + CRM.confirm(options) + .on('crmConfirm:yes', function () { + CRM.api3('TwingleProduct', 'delete', { 'id': self.id }) + .then(function (result) { + if (result.is_error === 1) { + cj('#create_twingle_product_tw_' + self.id).crmError(result.error_message, ts('Could not delete Price Field', [])); + } else { + self.update(); + } + CRM.alert(ts('The Price Field was deleted successfully.', []), ts('Price Field deleted', []), 'success', {'expires': 5000}); + }, function (error) { + cj('#create_twingle_product_tw_' + self.id).crmError(error.message, ts('Could not delete Price Field', [])); + }); + }); + }; + } + + /** + * Creates a new row with the product name and buttons for creating, updating + * or deleting the price field for this product. + * @returns {*} + */ + createRow() { + let row; + + // Clear row + if (this.row) { + for (let i = this.row.cells.length - 1; i >= 0; i--) { + // Delete everything from row + this.row.deleteCell(i); + } + row = this.row; + } else { + // Create new row element + row = document.createElement('tr'); + + // Add id to row + row.id = 'twingle_product_tw_' + this.externalId; + } + + // Add cell with product name + const nameCell = document.createElement('td'); + if (this.isOrphaned) { + nameCell.classList.add('strikethrough'); + } + nameCell.textContent = this.name; + row.appendChild(nameCell); + + // Add cell for buttons + let buttonCell = row.insertCell(1); + + // Add product buttons which allow to create, update or delete the price + // field for this product + if (this.parentTable.id) { + let buttons = this.createProductButtons(); + for (const button of buttons) { + buttonCell.appendChild(button); + } + } + + // Add financial type dropdown for each product if price set exists + if (this.parentTable.id) { + let dropdown = this.createFinancialTypeDropdown(); + const cell = document.createElement('td'); + cell.classList.add('twingle-shop-financial-type-select'); + cell.appendChild(dropdown); + row.insertCell(2).appendChild(cell); + } + // else add default financial type + else { + const cell = document.createElement('td'); + cell.classList.add('twingle-shop-financial-type-default'); + cell.innerHTML = '' + getShopDefaultFinancialType() + ''; + row.insertCell(1).appendChild(cell); + } + + this.row = row; + return this.row; + } + + /** + * Determining which actions are available for this product and creating a + * button for each of them. + * @returns {Array} Array of buttons + */ + createProductButtons() { + let actionsAndHandlers = []; + let buttons = []; + + // Determine actions; if product has price field id, it can be updated or + // deleted, otherwise it can be created + if (this.priceFieldId) { + if (this.isOutdated) { + actionsAndHandlers.push(['update', this.createPriceFieldHandler()]); + } else if (!this.isOrphaned) { + actionsAndHandlers.push(['update', null]); + } + actionsAndHandlers.push(['delete', this.deletePriceFieldHandler()]); + } else { + actionsAndHandlers.push(['create', this.createPriceFieldHandler()]); + } + + // Create button for each action + for (const [action, handler] of actionsAndHandlers) { + buttons.push(this.createProductButton(action, handler)); + } + + return buttons; + } + + /** + * Creates a dropdown for selecting the financial type for this product. + * @returns {HTMLSelectElement} + * @private + */ + createFinancialTypeDropdown() { + // Create new dropdown element + const dropdown = document.createElement('select'); + dropdown.id = 'twingle_product_tw_' + this.externalId + '_financial_type'; + + // Add empty option if no price field exists + if (!this.priceFieldId) { + let option = document.createElement('option'); + option.value = ''; + option.innerHTML = '<' + ts('select financial type', []) + '>'; + option.selected = true; + option.disabled = true; + dropdown.appendChild(option); + } + + // Add options for each financial type available in CiviCRM + for (const key in financialTypes) { + let option = document.createElement('option'); + option.value = key; + option.text = financialTypes[key]; // financialTypes is defined in twingle_shop.tpl as smarty variable + if (this.financialTypeId !== null && this.financialTypeId.toString() === key) { + option.selected = true; + } + dropdown.appendChild(option); + } + + // Add handlers + let self = this; + dropdown.onchange = function () { + + // Enable 'create' button if financial type is selected + const createButton = document.getElementById('twingle_product_tw_' + self.externalId).getElementsByClassName('twingle-shop-cell-button')[0]; + if (createButton.textContent.includes('Create')) { + createButton.disabled = dropdown.value === '0'; + } + + // Update financial type + self.financialTypeId = dropdown.value; + }; + + return dropdown; + } + + /** + * Updates the product properties and rebuilds the row. + * @param productData + */ + update(productData = null) { + if (productData) { + this.setProps(productData); + } else { + this.reset(); + } + this.createRow(); + } + + /** + * Resets the product properties. + */ + reset() { + this.financialTypeId = null; + this.priceFieldId = null; + this.isOutdated = null; + this.isOutdated = null; + // this.updatedAt = null; + this.createdAt = null; + this.id = null; + } + +} + +/** + * This class represents a Twingle Shop. + */ +class ProductsTable { + + /** + * Creates a new ProductsTable object. + * @param projectData + */ + constructor(projectData) { + this.setProps(projectData); + } + + /** + * Sets the properties of this project. + * @param projectData + * @private + */ + setProps(projectData) { + this.id = projectData.id; + this.name = projectData.name; + this.numericalProjectId = projectData.numerical_project_id; + this.projectIdentifier = projectData.project_identifier; + this.products = projectData.products.map(productData => new Product(productData, this)); + this.priceSetId = projectData.price_set_id; + this.table = this.buildTable(); + } + + /** + * Dumps the projects data. + * @returns {{price_set_id, financial_type_id, numerical_project_id, name, id, project_identifier, products: *}} + */ + dumpData() { + return { + 'id': this.id, + 'name': this.name, + 'numerical_project_id': this.numericalProjectId, + 'project_identifier': this.projectIdentifier, + 'price_set_id': this.priceSetId, + 'products': this.products.map(product => product.dumpData()), + 'financial_type_id': getShopDefaultFinancialTypeValue() + }; + } + + /** + * Builds the table for this project (shop). + * @returns {HTMLTableElement} + * @private + */ + buildTable() { + let table; + + // Clear table body + if (this.table) { + this.clearTableHeader(); + this.clearTableBody(); + this.updateTableButtons(); + table = this.table; + } else { + // Create new table element + table = document.createElement('table'); + table.classList.add('twingle-shop-table'); + table.id = this.projectIdentifier; + + // Add caption + const caption = table.createCaption(); + caption.textContent = this.name + ' (' + this.projectIdentifier + ')'; + caption.classList.add('twingle-shop-table-caption'); + + // Add table body + const tbody = document.createElement('tbody'); + table.appendChild(tbody); + + // Add table buttons + this.addTableButtons(table); + } + + // Add header row + const thead = table.createTHead(); + const headerRow = thead.insertRow(); + const headers = [ts('Product', []), ts('Financial Type', [])]; + + // Add price field column if price set exists + if (this.priceSetId) { + headers.splice(1, 0, ts('Price Field', [])); + } + + for (const headerText of headers) { + const headerCell = document.createElement('th'); + headerCell.textContent = headerText; + headerRow.appendChild(headerCell); + } + + // Add products to table + this.addProductsToTable(table); + + return table; + } + + /** + * Adds buttons for creating, updating or deleting the price set for the + * given project (shop). + * @private + */ + addTableButtons(table) { + table.appendChild(this.createTableButton('update', this.updatePriceSetHandler())); + if (this.priceSetId === null) { + table.appendChild(this.createTableButton('create', this.createPriceSetHandler())); + } else { + table.appendChild(this.createTableButton('delete', this.deletePriceSetHandler())); + } + } + + /** + * Creates a button for creating, updating or deleting the price set for the + * given project (shop). + * @param action + * @param handler + * @returns {HTMLButtonElement} + * @private + */ + createTableButton(action, handler) { + // Create button + const button = document.createElement('button'); + button.id = 'btn_' + action + '_twingle_shop_' + this.projectIdentifier; + button.classList.add('crm-button', 'twingle-shop-table-button'); + + // Add button text + const text = action === 'create' ? ts('Create Price Set', []) : action === 'update' ? ts('Update Price Set', []) : ts('Delete Price Set', []); + button.textContent = ' ' + ts(text, []); + + // Add button handler + button.onclick = handler; + + // Add icon + const icon = document.createElement('i'); + const iconClass = action === 'create' ? 'fa-plus-circle' : action === 'update' ? 'fa-refresh' : 'fa-trash'; + icon.classList.add('crm-i', iconClass); + button.insertBefore(icon, button.firstChild); + + return button; + } + + /** + * Adds products to table body. + * @param table + * @private + */ + addProductsToTable(table) { + // Get table body + const tbody = table.getElementsByTagName('tbody')[0]; + + // Add products to table body + for (const product of this.products) { + // Add row for product + const row = product.createRow(); + // Add row to table + tbody.appendChild(row); + } + } + + /** + * Updates the table buttons. + */ + updateTableButtons() { + const table_buttons = this.table.getElementsByClassName('twingle-shop-table-button'); + // Remove all price set buttons from table + while (table_buttons.length > 0) { + table_buttons[0].remove(); + } + this.addTableButtons(this.table); + } + + /** + * Clears the table header. + * @private + */ + clearTableHeader() { + const thead = this.table.getElementsByTagName('thead')[0]; + while (thead.firstChild) { + thead.removeChild(thead.firstChild); + } + } + + /** + * Clears the table body. + */ + clearTableBody() { + const tbody = this.table.getElementsByTagName('tbody')[0]; + while (tbody.firstChild) { + tbody.removeChild(tbody.firstChild); + } + } + + /** + * Creates a handler for creating the price set for the given project (shop). + * @returns {(function(*): void)|*} + */ + createPriceSetHandler() { + let self = this; + return function (event) { + event.preventDefault(); + CRM.api3('TwingleShop', 'create', self.dumpData()) + .then(function (result) { + if (result.is_error === 1) { + cj('#btn_create_price_set_' + self.projectIdentifier).crmError(result.error_message, ts('Could not create Twingle Shop', [])); + } else { + self.update(); + CRM.alert(ts('The Price Set was created successfully.', []), ts('Price Field created', []), 'success', {'expires': 5000}); + } + }, function (error) { + cj('#btn_create_price_set_' + self.projectIdentifier).crmError(error.message, ts('Could not create TwingleShop', [])); + }); + }; + } + + /** + * Creates a handler for deleting the price set for the given project (shop). + * @returns {(function(*): void)|*} + */ + deletePriceSetHandler() { + let self = this; + return function (event) { + event.preventDefault(); + const options = { + 'title': ts('Delete Price Set', []), + 'message': ts('Are you sure you want to delete the price set associated with this Twingle Shop?', []), + }; + CRM.confirm(options) + .on('crmConfirm:yes', function () { + CRM.api3('TwingleShop', 'delete', { + 'project_identifier': self.projectIdentifier, + }).then(function (result) { + if (result.is_error === 1) { + cj('#btn_create_price_set_' + self.projectIdentifier).crmError(result.error_message, ts('Could not delete Twingle Shop', [])); + } else { + self.update(); + CRM.alert(ts('The Price Set was deleted successfully.', []), ts('Price Set deleted', []), 'success', {'expires': 5000}); + } + }, function (error) { + cj('#btn_delete_price_set_' + self.projectIdentifier).crmError(error.message, ts('Could not delete Twingle Shop', [])); + }); + }); + }; + } + + /** + * Creates a handler for updating the price set for the given project (shop). + * @returns {(function(*): void)|*} + */ + updatePriceSetHandler() { + let self = this; + return function (event) { + cj('#twingle-shop-spinner').show(); + if (event) { + event.preventDefault(); + } + CRM.api3('TwingleShop', 'fetch', { + 'project_identifiers': self.projectIdentifier, + }).then(function (result) { + if (result.is_error === 1) { + cj('#btn_create_price_set_' + self.projectIdentifier).crmError(result.error_message, ts('Could not delete Twingle Shop', [])); + cj('#twingle-shop-spinner').hide(); + } else { + self.update(result.values[self.projectIdentifier]); + cj('#twingle-shop-spinner').hide(); + } + }, function (error) { + cj('#btn_update_price_set_' + self.projectIdentifier).crmError(error.message, ts('Could not update Twingle Shop', [])); + cj('#twingle-shop-spinner').hide(); + }); + }; + } + + /** + * Updates the project properties and rebuilds the table. + * @param projectData + */ + update(projectData) { + if (!projectData) { + const updatePriceSet = this.updatePriceSetHandler(); + updatePriceSet(); + } else { + this.setProps(projectData); + this.buildTable(); + } + } +} diff --git a/sql/auto_uninstall.sql b/sql/auto_uninstall.sql new file mode 100644 index 0000000..b37b946 --- /dev/null +++ b/sql/auto_uninstall.sql @@ -0,0 +1,21 @@ +-- +--------------------------------------------------------------------+ +-- | Copyright CiviCRM LLC. All rights reserved. | +-- | | +-- | This work is published under the GNU AGPLv3 license with some | +-- | permitted exceptions and without any warranty. For full license | +-- | and copyright information, see https://civicrm.org/licensing | +-- +--------------------------------------------------------------------+ +-- +-- Generated from drop.tpl +-- DO NOT EDIT. Generated by CRM_Core_CodeGen +---- /******************************************************* +-- * +-- * Clean up the existing tables-- * +-- *******************************************************/ + +SET FOREIGN_KEY_CHECKS=0; + +DROP TABLE IF EXISTS `civicrm_twingle_product`; +DROP TABLE IF EXISTS `civicrm_twingle_shop`; + +SET FOREIGN_KEY_CHECKS=1; \ No newline at end of file diff --git a/sql/civicrm_twingle_shop.sql b/sql/civicrm_twingle_shop.sql new file mode 100644 index 0000000..918b0ef --- /dev/null +++ b/sql/civicrm_twingle_shop.sql @@ -0,0 +1,66 @@ +-- +--------------------------------------------------------------------+ +-- | Copyright CiviCRM LLC. All rights reserved. | +-- | | +-- | This work is published under the GNU AGPLv3 license with some | +-- | permitted exceptions and without any warranty. For full license | +-- | and copyright information, see https://civicrm.org/licensing | +-- +--------------------------------------------------------------------+ +-- +-- Generated from schema.tpl +-- DO NOT EDIT. Generated by CRM_Core_CodeGen +-- +-- /******************************************************* +-- * +-- * Clean up the existing tables - this section generated from drop.tpl +-- * +-- *******************************************************/ + +SET FOREIGN_KEY_CHECKS=0; + +DROP TABLE IF EXISTS `civicrm_twingle_product`; +DROP TABLE IF EXISTS `civicrm_twingle_shop`; + +SET FOREIGN_KEY_CHECKS=1; +-- /******************************************************* +-- * +-- * Create new tables +-- * +-- *******************************************************/ + +-- /******************************************************* +-- * +-- * civicrm_twingle_shop +-- * +-- * This table contains the Twingle Shop data. Each Twingle Shop is linked to a corresponding Price Set. +-- * +-- *******************************************************/ +CREATE TABLE `civicrm_twingle_shop` ( + `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique TwingleShop ID', + `project_identifier` varchar(32) NOT NULL COMMENT 'Twingle Project Identifier', + `numerical_project_id` int unsigned NOT NULL COMMENT 'Numerical Twingle Project Identifier', + `price_set_id` int unsigned COMMENT 'FK to Price Set', + `name` varchar(64) NOT NULL COMMENT 'name of the shop', + PRIMARY KEY (`id`), + CONSTRAINT FK_civicrm_twingle_shop_price_set_id FOREIGN KEY (`price_set_id`) REFERENCES `civicrm_price_set`(`id`) ON DELETE CASCADE +) + ENGINE=InnoDB; + +-- /******************************************************* +-- * +-- * civicrm_twingle_product +-- * +-- * This table contains the Twingle Product data. +-- * +-- *******************************************************/ +CREATE TABLE `civicrm_twingle_product` ( + `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique TwingleProduct ID', + `external_id` int unsigned NOT NULL COMMENT 'The ID of this product in the Twingle database', + `price_field_id` int unsigned NOT NULL COMMENT 'FK to Price Field', + `twingle_shop_id` int unsigned COMMENT 'FK to Twingle Shop', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Timestamp of when the product was created in the database', + `updated_at` datetime NOT NULL COMMENT 'Timestamp of when the product was last updated in the Twingle database', + PRIMARY KEY (`id`), + CONSTRAINT FK_civicrm_twingle_product_price_field_id FOREIGN KEY (`price_field_id`) REFERENCES `civicrm_price_field`(`id`) ON DELETE CASCADE, + CONSTRAINT FK_civicrm_twingle_product_twingle_shop_id FOREIGN KEY (`twingle_shop_id`) REFERENCES `civicrm_twingle_shop`(`id`) ON DELETE CASCADE +) + ENGINE=InnoDB; diff --git a/templates/CRM/Twingle/Form/Profile.hlp b/templates/CRM/Twingle/Form/Profile.hlp index a8efdc5..8098450 100644 --- a/templates/CRM/Twingle/Form/Profile.hlp +++ b/templates/CRM/Twingle/Form/Profile.hlp @@ -85,4 +85,13 @@ {ts domain="de.systopia.twingle"}

Create a contact note for each field specified in this selection.

Tip: You can enable or disable this fields in the TwingleMANAGER.

{/ts} {/htxt} + +{htxt id='id-enable_shop_integration'} +

{ts domain="de.systopia.twingle"}Enable the processing of orders via Twingle Shop for this profile. The ordered products will then appear as line items in the contribution.{/ts}

+{/htxt} + +{htxt id='id-shop_map_products'} +

{ts domain="de.systopia.twingle"}If this option is enabled, all Twingle Shop products corresponding to the specified project IDs will be retrieved from Twingle and mapped as price sets and price fields. Each Twingle Shop is mapped as a price set with its products as price fields.

+

This allows you to manually create contributions with the same line items for phone orders, for example, as would be the case for orders placed through the Twingle Shop.

+{/htxt} {/crmScope} diff --git a/templates/CRM/Twingle/Form/Profile.tpl b/templates/CRM/Twingle/Form/Profile.tpl index 1577b5f..8224e84 100644 --- a/templates/CRM/Twingle/Form/Profile.tpl +++ b/templates/CRM/Twingle/Form/Profile.tpl @@ -46,7 +46,7 @@ class="helpicon" > - {$form.selector.html} + {$form.selector.html} {/if} @@ -353,6 +353,71 @@ + {if $twingle_use_shop eq 1} + + {ts domain="de.systopia.twingle"}Shop Integration{/ts} + + + + + + + + + + + + + + + + + + + + + +
+ {$form.enable_shop_integration.label} + + {$form.enable_shop_integration.html}
{$form.shop_financial_type.label}{$form.shop_financial_type.html}
{$form.shop_donation_financial_type.label}{$form.shop_donation_financial_type.html}
{$form.shop_map_products.label} + {$form.shop_map_products.html} + +
+
+
+
+ + {/if} + {elseif $op == 'delete'} @@ -389,11 +454,18 @@ } } - // register events and run once + // register events cj(document).ready(function (){ cj('#membership_type_id').change(twingle_membership_active_changed); cj('#membership_type_id_recur').change(twingle_membership_active_changed); + + // init Twingle Shop integration + if ({/literal}{if $twingle_use_shop eq 1}true{else}false{/if}{literal}) { + twingleShopInit(); + } }); + + // run once twingle_membership_active_changed(); {/literal} diff --git a/templates/CRM/Twingle/Form/Settings.hlp b/templates/CRM/Twingle/Form/Settings.hlp index 7b82e7b..bfd00e2 100644 --- a/templates/CRM/Twingle/Form/Settings.hlp +++ b/templates/CRM/Twingle/Form/Settings.hlp @@ -27,3 +27,7 @@ {htxt id='id-twingle_prefix'} {ts domain="de.systopia.twingle"}You can use this setting to add a prefix to the Twingle transaction ID, in order to avoid collisions with other transaction ids.{/ts} {/htxt} + +{htxt id='id-twingle_use_shop'} + {ts domain="de.systopia.twingle"}If you enable Twingle Shop integration, you can configure Twingle API profiles to include products ordered through Twingle Shop as line items in the created contribution.{/ts} +{/htxt} diff --git a/templates/CRM/Twingle/Form/Settings.tpl b/templates/CRM/Twingle/Form/Settings.tpl index 42953e2..906a91c 100644 --- a/templates/CRM/Twingle/Form/Settings.tpl +++ b/templates/CRM/Twingle/Form/Settings.tpl @@ -37,7 +37,6 @@ - {$form.twingle_prefix.label} {help id="id-twingle_prefix" title=$form.twingle_prefix.label} @@ -86,6 +85,31 @@ +

Twingle Shop Integration

+ + + + + + + + + + +
{$form.twingle_use_shop.label}   + {$form.twingle_use_shop.html} +
+ + {$formElements.twingle_use_shop.description} + +
{$form.twingle_access_key.label}   + {$form.twingle_access_key.html} +
+ + {$formElements.twingle_access_key.description} + +
+
{include file="CRM/common/formButtons.tpl" location="bottom"}
diff --git a/templates/CRM/Twingle/Page/Profiles.tpl b/templates/CRM/Twingle/Page/Profiles.tpl index 6d4bc90..1ef751b 100644 --- a/templates/CRM/Twingle/Page/Profiles.tpl +++ b/templates/CRM/Twingle/Page/Profiles.tpl @@ -26,6 +26,9 @@ {ts domain="de.systopia.twingle"}Profile name{/ts} {ts domain="de.systopia.twingle"}Selectors{/ts} + {if $twingle_use_shop eq 1} + {ts domain="de.systopia.twingle"}Shop Integration{/ts} + {/if} {ts domain="de.systopia.twingle"}Used{/ts} {ts domain="de.systopia.twingle"}Last Used{/ts} {ts domain="de.systopia.twingle"}Operations{/ts} @@ -46,6 +49,9 @@ {/if} + {if $twingle_use_shop eq 1} + {if $profile.enable_shop_integration}{ts domain="de.systopia.twingle"}enabled{/ts}{else}{ts domain="de.systopia.twingle"}disabled{/ts}{/if} + {/if} {ts domain="de.systopia.twingle"}{$profile_stats.$profile_name.access_counter_txt}{/ts} {ts domain="de.systopia.twingle"}{$profile_stats.$profile_name.last_access_txt}{/ts} @@ -56,7 +62,6 @@ {else} {ts domain="de.systopia.twingle"}Delete{/ts} {/if} - {/foreach} diff --git a/tests/phpunit/api/v3/TwingleProduct/CreateTest.php b/tests/phpunit/api/v3/TwingleProduct/CreateTest.php new file mode 100644 index 0000000..7d489aa --- /dev/null +++ b/tests/phpunit/api/v3/TwingleProduct/CreateTest.php @@ -0,0 +1,54 @@ +installMe(__DIR__) + ->apply(); + } + + /** + * The setup() method is executed before the test is executed (optional). + */ + public function setUp() { + parent::setUp(); + } + + /** + * The tearDown() method is executed after the test was executed (optional) + * This can be used for cleanup. + */ + public function tearDown() { + parent::tearDown(); + } + + /** + * Simple example test case. + * + * Note how the function name begins with the word "test". + */ + public function testApiExample() { + $result = civicrm_api3('TwingleProduct', 'create', array('magicword' => 'sesame')); + $this->assertEquals('Twelve', $result['values'][12]['name']); + } + +} diff --git a/tests/phpunit/api/v3/TwingleProduct/DeleteTest.php b/tests/phpunit/api/v3/TwingleProduct/DeleteTest.php new file mode 100644 index 0000000..7edac01 --- /dev/null +++ b/tests/phpunit/api/v3/TwingleProduct/DeleteTest.php @@ -0,0 +1,54 @@ +installMe(__DIR__) + ->apply(); + } + + /** + * The setup() method is executed before the test is executed (optional). + */ + public function setUp() { + parent::setUp(); + } + + /** + * The tearDown() method is executed after the test was executed (optional) + * This can be used for cleanup. + */ + public function tearDown() { + parent::tearDown(); + } + + /** + * Simple example test case. + * + * Note how the function name begins with the word "test". + */ + public function testApiExample() { + $result = civicrm_api3('TwingleProduct', 'delete', array('magicword' => 'sesame')); + $this->assertEquals('Twelve', $result['values'][12]['name']); + } + +} diff --git a/tests/phpunit/api/v3/TwingleProduct/GetTest.php b/tests/phpunit/api/v3/TwingleProduct/GetTest.php new file mode 100644 index 0000000..670de99 --- /dev/null +++ b/tests/phpunit/api/v3/TwingleProduct/GetTest.php @@ -0,0 +1,54 @@ +installMe(__DIR__) + ->apply(); + } + + /** + * The setup() method is executed before the test is executed (optional). + */ + public function setUp() { + parent::setUp(); + } + + /** + * The tearDown() method is executed after the test was executed (optional) + * This can be used for cleanup. + */ + public function tearDown() { + parent::tearDown(); + } + + /** + * Simple example test case. + * + * Note how the function name begins with the word "test". + */ + public function testApiExample() { + $result = civicrm_api3('TwingleProduct', 'get', array('magicword' => 'sesame')); + $this->assertEquals('Twelve', $result['values'][12]['name']); + } + +} diff --git a/tests/phpunit/api/v3/TwingleProduct/GetsingleTest.php b/tests/phpunit/api/v3/TwingleProduct/GetsingleTest.php new file mode 100644 index 0000000..1bf43da --- /dev/null +++ b/tests/phpunit/api/v3/TwingleProduct/GetsingleTest.php @@ -0,0 +1,54 @@ +installMe(__DIR__) + ->apply(); + } + + /** + * The setup() method is executed before the test is executed (optional). + */ + public function setUp() { + parent::setUp(); + } + + /** + * The tearDown() method is executed after the test was executed (optional) + * This can be used for cleanup. + */ + public function tearDown() { + parent::tearDown(); + } + + /** + * Simple example test case. + * + * Note how the function name begins with the word "test". + */ + public function testApiExample() { + $result = civicrm_api3('TwingleProduct', 'getsingle', array('magicword' => 'sesame')); + $this->assertEquals('Twelve', $result['values'][12]['name']); + } + +} diff --git a/tests/phpunit/api/v3/TwingleShop/CreateTest.php b/tests/phpunit/api/v3/TwingleShop/CreateTest.php new file mode 100644 index 0000000..12ccac6 --- /dev/null +++ b/tests/phpunit/api/v3/TwingleShop/CreateTest.php @@ -0,0 +1,54 @@ +installMe(__DIR__) + ->apply(); + } + + /** + * The setup() method is executed before the test is executed (optional). + */ + public function setUp() { + parent::setUp(); + } + + /** + * The tearDown() method is executed after the test was executed (optional) + * This can be used for cleanup. + */ + public function tearDown() { + parent::tearDown(); + } + + /** + * Simple example test case. + * + * Note how the function name begins with the word "test". + */ + public function testApiExample() { + $result = civicrm_api3('TwingleShop', 'create', array('magicword' => 'sesame')); + $this->assertEquals('Twelve', $result['values'][12]['name']); + } + +} diff --git a/tests/phpunit/api/v3/TwingleShop/DeleteTest.php b/tests/phpunit/api/v3/TwingleShop/DeleteTest.php new file mode 100644 index 0000000..8e1a9ca --- /dev/null +++ b/tests/phpunit/api/v3/TwingleShop/DeleteTest.php @@ -0,0 +1,54 @@ +installMe(__DIR__) + ->apply(); + } + + /** + * The setup() method is executed before the test is executed (optional). + */ + public function setUp() { + parent::setUp(); + } + + /** + * The tearDown() method is executed after the test was executed (optional) + * This can be used for cleanup. + */ + public function tearDown() { + parent::tearDown(); + } + + /** + * Simple example test case. + * + * Note how the function name begins with the word "test". + */ + public function testApiExample() { + $result = civicrm_api3('TwingleShop', 'delete', array('magicword' => 'sesame')); + $this->assertEquals('Twelve', $result['values'][12]['name']); + } + +} diff --git a/tests/phpunit/api/v3/TwingleShop/GetTest.php b/tests/phpunit/api/v3/TwingleShop/GetTest.php new file mode 100644 index 0000000..e9fa453 --- /dev/null +++ b/tests/phpunit/api/v3/TwingleShop/GetTest.php @@ -0,0 +1,54 @@ +installMe(__DIR__) + ->apply(); + } + + /** + * The setup() method is executed before the test is executed (optional). + */ + public function setUp() { + parent::setUp(); + } + + /** + * The tearDown() method is executed after the test was executed (optional) + * This can be used for cleanup. + */ + public function tearDown() { + parent::tearDown(); + } + + /** + * Simple example test case. + * + * Note how the function name begins with the word "test". + */ + public function testApiExample() { + $result = civicrm_api3('TwingleShop', 'get', array('magicword' => 'sesame')); + $this->assertEquals('Twelve', $result['values'][12]['name']); + } + +} diff --git a/tests/phpunit/api/v3/TwingleShop/GetsingleTest.php b/tests/phpunit/api/v3/TwingleShop/GetsingleTest.php new file mode 100644 index 0000000..96a4efa --- /dev/null +++ b/tests/phpunit/api/v3/TwingleShop/GetsingleTest.php @@ -0,0 +1,54 @@ +installMe(__DIR__) + ->apply(); + } + + /** + * The setup() method is executed before the test is executed (optional). + */ + public function setUp() { + parent::setUp(); + } + + /** + * The tearDown() method is executed after the test was executed (optional) + * This can be used for cleanup. + */ + public function tearDown() { + parent::tearDown(); + } + + /** + * Simple example test case. + * + * Note how the function name begins with the word "test". + */ + public function testApiExample() { + $result = civicrm_api3('TwingleShop', 'getsingle', array('magicword' => 'sesame')); + $this->assertEquals('Twelve', $result['values'][12]['name']); + } + +} diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php new file mode 100644 index 0000000..eaa8379 --- /dev/null +++ b/tests/phpunit/bootstrap.php @@ -0,0 +1,65 @@ +add('CRM_', [__DIR__ . '/../..', __DIR__]); +$loader->addPsr4('Civi\\', [__DIR__ . '/../../Civi', __DIR__ . '/Civi']); +$loader->add('api_', [__DIR__ . '/../..', __DIR__]); +$loader->addPsr4('api\\', [__DIR__ . '/../../api', __DIR__ . '/api']); + +$loader->register(); + +/** + * Call the "cv" command. + * + * @param string $cmd + * The rest of the command to send. + * @param string $decode + * Ex: 'json' or 'phpcode'. + * @return mixed + * Response output (if the command executed normally). + * For 'raw' or 'phpcode', this will be a string. For 'json', it could be any JSON value. + * @throws \RuntimeException + * If the command terminates abnormally. + */ +function cv(string $cmd, string $decode = 'json') { + $cmd = 'cv ' . $cmd; + $descriptorSpec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => STDERR]; + $oldOutput = getenv('CV_OUTPUT'); + putenv('CV_OUTPUT=json'); + + // Execute `cv` in the original folder. This is a work-around for + // phpunit/codeception, which seem to manipulate PWD. + $cmd = sprintf('cd %s; %s', escapeshellarg(getenv('PWD')), $cmd); + + $process = proc_open($cmd, $descriptorSpec, $pipes, __DIR__); + putenv("CV_OUTPUT=$oldOutput"); + fclose($pipes[0]); + $result = stream_get_contents($pipes[1]); + fclose($pipes[1]); + if (proc_close($process) !== 0) { + throw new RuntimeException("Command failed ($cmd):\n$result"); + } + switch ($decode) { + case 'raw': + return $result; + + case 'phpcode': + // If the last output is /*PHPCODE*/, then we managed to complete execution. + if (substr(trim($result), 0, 12) !== '/*BEGINPHP*/' || substr(trim($result), -10) !== '/*ENDPHP*/') { + throw new \RuntimeException("Command failed ($cmd):\n$result"); + } + return $result; + + case 'json': + return json_decode($result, 1); + + default: + throw new RuntimeException("Bad decoder format ($decode)"); + } +} diff --git a/twingle.civix.php b/twingle.civix.php index c207adf..6d84d1c 100644 --- a/twingle.civix.php +++ b/twingle.civix.php @@ -198,3 +198,25 @@ function _twingle_civix_fixNavigationMenuItems(&$nodes, &$maxNavID, $parentID) { } } } + +/** + * (Delegated) Implements hook_civicrm_entityTypes(). + * + * Find any *.entityType.php files, merge their content, and return. + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes + */ +function _twingle_civix_civicrm_entityTypes(&$entityTypes) { + $entityTypes = array_merge($entityTypes, [ + 'Civi\Twingle\Shop\DAO\TwingleProduct' => [ + 'name' => 'TwingleProduct', + 'class' => 'Civi\Twingle\Shop\DAO\TwingleProduct', + 'table' => 'civicrm_twingle_product', + ], + 'Civi\Twingle\Shop\DAO\TwingleShop' => [ + 'name' => 'TwingleShop', + 'class' => 'Civi\Twingle\Shop\DAO\TwingleShop', + 'table' => 'civicrm_twingle_shop', + ], + ]); +} diff --git a/twingle.php b/twingle.php index 8a838ea..c00ac35 100644 --- a/twingle.php +++ b/twingle.php @@ -5,11 +5,36 @@ use CRM_Twingle_ExtensionUtil as E; /** * Implements hook_civicrm_pre(). + * + * @throws \Civi\Twingle\Shop\Exceptions\ProductException + * @throws \CRM_Core_Exception + * @throws \Civi\Twingle\Shop\Exceptions\ShopException */ function twingle_civicrm_pre($op, $objectName, $id, &$params) { if ($objectName == 'ContributionRecur' && $op == 'edit') { CRM_Twingle_Tools::checkRecurringContributionChange((int) $id, $params); } + + // Create/delete PriceField and PriceFieldValue for TwingleProduct + elseif ($objectName == 'TwingleProduct') { + $twingle_product = new \Civi\Twingle\Shop\BAO\TwingleProduct(); + $twingle_product->load($params); + if ($op == 'create' || $op == 'edit') { + $twingle_product->createPriceField(); + } + elseif ($op == 'delete') { + $twingle_product->deletePriceField(); + } + $params = $twingle_product->getAttributes(); + } + + // Create PriceSet for TwingleShop + elseif ($objectName == 'TwingleShop' && ($op == 'create' || $op == 'edit')) { + $twingle_shop = new \Civi\Twingle\Shop\BAO\TwingleShop(); + $twingle_shop->load($params); + $twingle_shop->createPriceSet(); + $params = $twingle_shop->getAttributes(); + } } /** diff --git a/xml/schema/CRM/Twingle/TwingleProduct.entityType.php b/xml/schema/CRM/Twingle/TwingleProduct.entityType.php new file mode 100644 index 0000000..5b8879b --- /dev/null +++ b/xml/schema/CRM/Twingle/TwingleProduct.entityType.php @@ -0,0 +1,10 @@ + 'TwingleProduct', + 'class' => 'Civi\Twingle\Shop\DAO\TwingleProduct', + 'table' => 'civicrm_twingle_product', + ], +]; diff --git a/xml/schema/CRM/Twingle/TwingleProduct.xml b/xml/schema/CRM/Twingle/TwingleProduct.xml new file mode 100644 index 0000000..5787e69 --- /dev/null +++ b/xml/schema/CRM/Twingle/TwingleProduct.xml @@ -0,0 +1,75 @@ + + + + CRM/Twingle/Shop + TwingleProduct + civicrm_twingle_product + This table contains the Twingle Product data. + false + + + + id + int unsigned + true + true + Unique TwingleProduct ID + + Number + + + + id + true + + + + external_id + int unsigned + true + The ID of this product in the Twingle database + + Number + + + + + price_field_id + int unsigned + FK to Price Field + true + + + price_field_id +
civicrm_contact
+ id + CASCADE + + + + twingle_shop_id + int unsigned + true + FK to Twingle Shop + + + twingle_shop_id + civicrm_twingle_shop
+ id + CASCADE +
+ + + created_at + datetime + true + Timestamp of when the product was created in the database + + + + updated_at + datetime + true + Timestamp of when the product was last updated in the database + + diff --git a/xml/schema/CRM/Twingle/TwingleShop.entityType.php b/xml/schema/CRM/Twingle/TwingleShop.entityType.php new file mode 100644 index 0000000..34e6ce2 --- /dev/null +++ b/xml/schema/CRM/Twingle/TwingleShop.entityType.php @@ -0,0 +1,10 @@ + 'TwingleShop', + 'class' => 'Civi\Twingle\Shop\DAO\TwingleShop', + 'table' => 'civicrm_twingle_shop', + ], +]; diff --git a/xml/schema/CRM/Twingle/TwingleShop.xml b/xml/schema/CRM/Twingle/TwingleShop.xml new file mode 100644 index 0000000..3d9d6ba --- /dev/null +++ b/xml/schema/CRM/Twingle/TwingleShop.xml @@ -0,0 +1,73 @@ + + + + CRM/Twingle/Shop + TwingleShop + civicrm_twingle_shop + This table contains the Twingle Shop data. Each Twingle Shop is linked to a corresponding Price Set. + false + + + + id + int unsigned + true + true + Unique TwingleShop ID + + Number + + + + id + true + + + + project_identifier + varchar + 32 + true + true + Twingle Project Identifier + + Text + + + + + numerical_project_id + int unsigned + true + true + Numerical Twingle Project Identifier + + Number + + + + + price_set_id + int unsigned + true + FK to Price Set + + + price_set_id +
civicrm_price_set
+ id + CASCADE + + + + name + varchar + false + 64 + true + name of the shop + + Text + + +