Compare commits

...

136 commits

Author SHA1 Message Date
Jens Schuppe
b8f44d962d Back to dev (1.6-dev) 2025-02-21 13:14:04 +01:00
Jens Schuppe
21f29ce169 Version 1.5.0 2025-02-21 13:13:44 +01:00
Jens Schuppe
c8a577b651 Back to dev (1.5-dev) 2025-01-23 15:18:14 +01:00
Jens Schuppe
2ee06faf34 Version 1.5-beta4 2025-01-23 15:17:54 +01:00
Jens Schuppe
b9b26d9524 Merge branch 'fixClassNamespaces'
[#105] Fix BAO class namespace issues
2024-10-09 13:12:32 +02:00
Jens Schuppe
c7c766d926 Fix BAO class namespace issues 2024-10-09 12:39:57 +02:00
Jens Schuppe
82952a0162 Back to 1.5-dev 2024-10-01 14:28:06 +02:00
Jens Schuppe
eaf9d53169 Version 1.5-beta3 2024-10-01 14:27:53 +02:00
Jens Schuppe
e26b5c3933 Merge branch 'germanTranslation'
[#103] Update German translation
2024-10-01 14:25:49 +02:00
Jens Schuppe
82456d2ae4 Update German translation 2024-10-01 14:24:23 +02:00
Jens Schuppe
9c9fed20d7 Back to 1.5-dev 2024-09-24 14:29:38 +02:00
Jens Schuppe
30c34f72be Version 1.5-beta2 2024-09-24 14:29:26 +02:00
Jens Schuppe
355a377c4f Merge branch 'translationTemplate'
[#101] Update translation template and fix incorrect use of ts()
2024-09-24 14:28:58 +02:00
Jens Schuppe
612224901a Update translation template 2024-09-24 14:28:34 +02:00
Jens Schuppe
fa301676e3 Update translation template and fix incorrect use of ts() 2024-09-24 14:26:37 +02:00
Jens Schuppe
2834f8028d Merge branch 'formTemplates'
[#100] Fix form template issues (help texts, undefined template variables, etc.)
2024-09-24 14:26:16 +02:00
Jens Schuppe
f6cd3614c3 Merge branch 'twingleShopFixes'
[#99] Fix DAO/BAO namespaces and definitions
2024-09-24 14:25:52 +02:00
Jens Schuppe
4ae20a1b04 Fix form template issues (help texts, undefined template variables, etc.) 2024-09-11 13:19:54 +02:00
Jens Schuppe
64d6b48813 Fix DAO/BAO namespaces and definitions 2024-09-11 12:59:22 +02:00
Jens Schuppe
7cca29b458 Back to 1.5-dev 2024-09-03 13:19:48 +02:00
Jens Schuppe
8de34f7b2a Version 1.5-beta1 2024-09-03 13:19:27 +02:00
Jens Schuppe
26132e785e Merge branch 'integrate_twingle_shop'
[#69] Integrate Twingle Shop
2024-09-03 13:13:04 +02:00
Marc Michalsky
33cb076d42 activate update button on financial type change 2024-09-02 15:26:12 +02:00
Marc Michalsky
d3ccb3b092 allow products with self-selected price 2024-09-02 15:26:12 +02:00
Marc Michalsky
477c57ca53 fix another messed up merge 2024-09-02 15:26:12 +02:00
Marc Michalsky
ac892c9afc fix messed up merge conflicts 2024-09-02 15:26:12 +02:00
Marc Michalsky
1fc6529064 set price_field to is_required = false 2024-09-02 15:26:12 +02:00
Marc Michalsky
8cfa270dff implement TwingleShop integration 2024-09-02 15:26:12 +02:00
Marc Michalsky
ea46e6a747 cherry pick of "make sure that default values are present" 2024-09-02 15:25:51 +02:00
Marc Michalsky
1a5f77c090 refactoring 2024-09-02 15:25:51 +02:00
Marc Michalsky
eacc9cf496 pass $error_code to parent BaseException 2024-09-02 15:25:51 +02:00
Marc Michalsky
7c7c040b30 add error code for profile validation warning 2024-09-02 15:25:51 +02:00
Marc Michalsky
0f947e4277 override the $code property inherited from Exception in BaseException 2024-09-02 15:25:51 +02:00
Marc Michalsky
db94f26d6d use new namespace style 2024-09-02 15:25:51 +02:00
Marc Michalsky
c971b6f8eb let CRM_Twingle_Profile class handle its validation 2024-09-02 15:25:51 +02:00
Marc Michalsky
72bfa3fb2c create custom exceptions 2024-09-02 15:25:51 +02:00
Jens Schuppe
6606d09dce Back to 1.5-dev 2024-06-13 13:47:22 +02:00
Jens Schuppe
a363e1c888 Version 1.5-alpha2 2024-06-13 13:47:07 +02:00
Jens Schuppe
9ff9234644 Merge remote-tracking branch 'MarcMichalsky/improve_api_result_values' 2024-06-13 13:38:43 +02:00
Marc Michalsky
07435ad997
remove unnecessary reset() for $params[$target] 2024-06-13 13:35:09 +02:00
Jens Schuppe
8d30b2a52a Fix excess reset() in note creation 2024-06-13 13:34:49 +02:00
Jens Schuppe
96d0e5fbec Code style 2024-06-13 13:24:34 +02:00
Jens Schuppe
f7b15ac4f6 Update German translation 2024-06-13 13:03:57 +02:00
Jens Schuppe
a8c8401be3 Update translation template 2024-06-13 13:03:49 +02:00
Jens Schuppe
61f45034c6 Update help text for custom field mapping configuration option to reflect that all parameters are supported. 2024-06-13 13:03:17 +02:00
Marc Michalsky
ff24256bc1
improve contribution result values 2024-06-12 15:28:41 +02:00
Marc Michalsky
c0af2e16ab
improve SEPA mandate result values 2024-06-12 15:27:01 +02:00
Marc Michalsky
67283fa1a7
improve donation receipt result values 2024-06-12 15:27:00 +02:00
Marc Michalsky
90f27f70c7
improve newsletter subcription result values 2024-06-12 15:26:59 +02:00
Jens Schuppe
221f9c72f3 Back to 1.5-dev 2024-06-12 15:21:10 +02:00
Jens Schuppe
1dbc842253 Version 1.5-alpha1 2024-06-12 15:20:52 +02:00
Jens Schuppe
0b9c1709fd Merge branch 'fix_incorrect_fixes'
[#92] Some more fixed conditions
2024-06-12 15:19:49 +02:00
Jens Schuppe
0cf7ccb1cb Code style 2024-06-12 15:18:35 +02:00
Marc Michalsky
cd008d9545
fix: Check for $creditor_id always fails 2024-06-12 15:02:36 +02:00
Marc Michalsky
4432060d05
add a condition to check if the value is 1 2024-06-12 15:01:55 +02:00
Marc Michalsky
ab5d0906d7
fix accidentally flipped condition 2024-06-12 15:01:54 +02:00
Jens Schuppe
d9d68fa937 Merge branch 'donationReceiptOrganization'
[#94] Add the organisation instead of the individual to donation receipts group
2024-06-12 14:51:54 +02:00
Marc Michalsky
03a37aed31 add the organisation instead of the individual to donation_receipt group 2024-06-12 14:47:48 +02:00
Jens Schuppe
933c51c48e Merge branch 'issue/84'
[#88] Improve profile options for note creation
2024-06-12 14:44:26 +02:00
Jens Schuppe
ac1b08b775 Code style 2024-06-12 14:35:44 +02:00
Marc Michalsky
cf9483ca3e
fix: "in_array() expects parameter 2 to be array, null given" 2024-06-12 11:08:03 +02:00
Marc Michalsky
f5ab576ed9
remove html tags from transalation strings 2024-06-11 15:03:15 +02:00
Marc Michalsky
0caf9bf98e
Revert "fix obsolete use of CRM_Utils_Array::first()" 2024-06-11 14:57:15 +02:00
Marc Michalsky
bea59b4365
use strict comparisons 2024-06-11 14:57:14 +02:00
Marc Michalsky
089bbdf934
use api4 instead of api3 2024-06-11 14:57:13 +02:00
Marc Michalsky
1ddcc217e3
fix obsolete use of CRM_Utils_Array::first() 2024-06-11 14:57:13 +02:00
Marc Michalsky
87ca179791
add German translations 2024-06-11 14:57:12 +02:00
Marc Michalsky
547254158c
add Upgrader to maintain profile behaviour 2024-06-11 14:57:11 +02:00
Marc Michalsky
758b793c0d
add logic to create selected contact and contribution notes 2024-06-11 14:57:11 +02:00
Marc Michalsky
9836168122
add profile settings for note mapping 2024-06-11 14:57:10 +02:00
Jens Schuppe
aee56769b7 Merge branch 'settingsTemplate'
[#91] Repair help links in settings form
2024-06-06 12:03:56 +02:00
Jens Schuppe
8020491bf1 Repair help links in settings form (and reformat) 2024-06-06 12:01:05 +02:00
Marc Michalsky
f5723a4e7d
fix obsolete use of CRM_Utils_Array::first() 2024-06-06 11:57:32 +02:00
Jens Schuppe
5169e5a0ce Merge branch 'issue/86' 2024-06-06 11:07:15 +02:00
Jens Schuppe
d1f3dd871c Merge branch 'defaultProfileProjectId'
[#90] Do not require project IDs for default profile forms
2024-06-06 11:04:45 +02:00
Jens Schuppe
75676d42eb Fix condition in profile form template for not showing selector field for default profile 2024-06-06 11:01:56 +02:00
Jens Schuppe
a0b2879b69 Prevent undefined index warnings for unchecked checkbpxes in the settings form 2024-06-06 10:56:49 +02:00
Jens Schuppe
c7f1b7cb6e Do not require project IDs for default profile forms 2024-06-06 10:33:19 +02:00
Jens Schuppe
2b8ab813db Fix ambiguous conditions replacing empty() 2024-05-10 12:27:33 +02:00
Marc Michalsky
63b7e4d3ee
add German translations 2024-04-30 16:52:30 +02:00
Marc Michalsky
b3f82fbfba
add Upgrader to maintain profile behaviour 2024-04-30 16:52:30 +02:00
Marc Michalsky
df51d59cea
add logic to create selected contact and contribution notes 2024-04-30 15:44:45 +02:00
Marc Michalsky
10f6ca4e89
add profile settings for note mapping 2024-04-30 15:44:45 +02:00
Marc Michalsky
48c49c1814
avoid the use of empty() 2024-04-30 10:21:32 +02:00
Marc Michalsky
02bac833de
fix #86 2024-04-26 16:17:02 +02:00
Jens Schuppe
670984854f Fix accidentally flipped condition 2024-04-09 13:14:37 +02:00
Jens Schuppe
3612122650 Update translation (seems to have caused GetText seek errors) 2024-04-08 12:31:14 +02:00
Jens Schuppe
6f555c660e Merge branch 'patch-2'
[#68] Add generic payment method
2024-04-05 14:31:09 +02:00
Jens Schuppe
313d2f648f Show error messages for missing configuration values 2024-04-05 14:29:58 +02:00
Maria
a91fbd0c20 Update Profile.php
adding generic payment method
2024-04-05 13:52:09 +02:00
Jens Schuppe
692c66a9a5 Merge remote-tracking branch 'MarcMichalsky/improve_profile_list'
[#76] Improve profile list view
2024-04-05 13:47:39 +02:00
Jens Schuppe
88850bbce3 PHPStan fixes 2024-04-05 13:46:03 +02:00
Jens Schuppe
b480d87ed7 Merge branch 'impove_default_profile_behaviour' 2024-04-05 13:27:34 +02:00
Jens Schuppe
4f845ca07e Merge branch 'improve_validation'
[#75] Improve profile validation
2024-04-05 13:19:07 +02:00
Jens Schuppe
6114772d07 PHP Code Sniffer and PHPStan fixes 2024-04-05 13:18:45 +02:00
Marc Michalsky
8d1d93d77a display a warning instead of an error if the project_id is a duplicate 2024-04-05 12:35:12 +02:00
Marc Michalsky
96c072eb8e let CRM_Twingle_Profile class handle its validation 2024-04-05 12:35:12 +02:00
Jens Schuppe
aa2c938abe Merge branch 'pick_profile_by_id'
[#73] Identify profiles by ID
2024-04-05 12:28:38 +02:00
Jens Schuppe
8bcdff4a85 Fix PHPStan issues 2024-04-05 12:25:00 +02:00
Jens Schuppe
fc40af8db5 Fix code style 2024-04-04 12:14:00 +02:00
Jens Schuppe
d7b066751a PHP Code Sniffer fixes 2024-04-03 12:22:39 +02:00
Jens Schuppe
6313fffa44 Merge branch 'extend_custom_field_mapping'
[#66] Extend custom field mapping
2024-04-03 12:09:42 +02:00
Jens Schuppe
47756f68b4 Correct indentation 2024-04-03 12:02:32 +02:00
Marc Michalsky
bd54c039c8 make all fields available for custom mapping
This is useful to be able to map fields like `purpose` or `remarks` to custom fields.
2024-04-03 12:01:23 +02:00
Jens Schuppe
5efed1f6c8 Fix @throws tags with wrong class name 2024-03-25 16:03:07 +01:00
Marc Michalsky
f81b476a30 fix exception on profile creation 2024-03-25 15:58:36 +01:00
Marc Michalsky
aef1ae7396 refactoring 2024-03-25 15:58:36 +01:00
Marc Michalsky
4feeb01611 make sure that default values are present 2024-03-25 15:58:26 +01:00
Marc Michalsky
09dda832a4 validate profile when copying 2024-03-25 15:58:11 +01:00
Marc Michalsky
c7e51b4d3c do not attempt to delete unverified profile_id 2024-03-25 15:58:11 +01:00
Marc Michalsky
6f6b9e0599 use correct error code 2024-03-25 15:58:11 +01:00
Marc Michalsky
a16be91822 include profile name in update query 2024-03-25 15:58:11 +01:00
Marc Michalsky
d9e51c67f6 use the id instead of the name to look up profiles 2024-03-25 15:58:11 +01:00
Jens Schuppe
1d9e973e46 Merge branch 'use_custom_exeptions'
[#72] Create custom exceptions
2024-03-25 15:52:29 +01:00
Jens Schuppe
fe0ce3f57d Fix @throws tag with wrong class name 2024-03-25 15:51:58 +01:00
Jens Schuppe
53745a3e07 PHP Code Sniffer fixes 2024-03-25 15:49:01 +01:00
Jens Schuppe
4e072b416a Move exception class files into correct directory according to namespace 2024-03-25 15:45:00 +01:00
Marc Michalsky
b0d5bdefa5 use correct namespace 2024-03-25 15:45:00 +01:00
Marc Michalsky
1875861735 pass $error_code to parent BaseException 2024-03-25 15:45:00 +01:00
Marc Michalsky
e83a898cb8 add error code for profile validation warning 2024-03-25 15:45:00 +01:00
Marc Michalsky
9baf2c0e2a override the $code property inherited from Exception in BaseException 2024-03-25 15:45:00 +01:00
Marc Michalsky
27675b7219 use new namespace style 2024-03-25 15:45:00 +01:00
Marc Michalsky
43be624bf6 create custom exceptions 2024-03-25 15:45:00 +01:00
Jens Schuppe
7a751e92bf phpcs: Conflicts with PHPStan type hints 2024-03-25 15:44:43 +01:00
Jens Schuppe
f42bc9b7ed PHP Code Sniffer fixes 2024-03-25 15:26:15 +01:00
Jens Schuppe
322c2d0dd3 Run Civix upgrade to Civix version 23.02.1 2024-03-25 15:01:36 +01:00
Jens Schuppe
69843bc981 PHP Code Beautifier fixes 2024-03-25 14:58:51 +01:00
Jens Schuppe
fad228315d Remove invalid @throws 2024-03-25 14:52:29 +01:00
Jens Schuppe
8cd928caa9 Add extension template with PHPStan, PHPUnit and phpcs 2024-03-25 14:51:57 +01:00
Jens Schuppe
b4c6581d4f Set version to 1.5-dev 2024-03-25 14:36:18 +01:00
Marc Michalsky
b7b0e6d610
use custom css file instead of inline style 2023-08-29 17:02:44 +02:00
Marc Michalsky
ab5b0b3929
use correct array key 2023-08-29 16:39:28 +02:00
Marc Michalsky
3241583542
use variable to avoid multiple method calls 2023-08-17 10:08:51 +02:00
Marc Michalsky
e442ca6249
fix index 2023-08-17 10:06:03 +02:00
Marc Michalsky
63d713f9f0
display selectors as list in profiles view 2023-08-16 11:26:10 +02:00
Marc Michalsky
ab27dccbe7
improve default profile behavior 2023-08-16 11:22:54 +02:00
91 changed files with 11166 additions and 2127 deletions

240
.editorconfig Normal file
View file

@ -0,0 +1,240 @@
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 2
ij_continuation_indent_size = 2
ij_visual_guides = 80,120
[{*.php}]
ij_php_align_assignments = false
ij_php_align_class_constants = false
ij_php_align_enum_cases = false
ij_php_align_group_field_declarations = false
ij_php_align_inline_comments = false
ij_php_align_key_value_pairs = false
ij_php_align_match_arm_bodies = false
ij_php_align_multiline_array_initializer_expression = false
ij_php_align_multiline_binary_operation = false
ij_php_align_multiline_chained_methods = false
ij_php_align_multiline_extends_list = false
ij_php_align_multiline_for = true
ij_php_align_multiline_parameters = false
ij_php_align_multiline_parameters_in_calls = false
ij_php_align_multiline_ternary_operation = false
ij_php_align_named_arguments = false
ij_php_align_phpdoc_comments = false
ij_php_align_phpdoc_param_names = false
ij_php_anonymous_brace_style = end_of_line
ij_php_api_weight = 28
ij_php_array_initializer_new_line_after_left_brace = true
ij_php_array_initializer_right_brace_on_new_line = true
ij_php_array_initializer_wrap = on_every_item
ij_php_assignment_wrap = normal
ij_php_attributes_wrap = normal
ij_php_author_weight = 28
ij_php_binary_operation_sign_on_next_line = false
ij_php_binary_operation_wrap = normal
ij_php_blank_lines_after_class_header = 1
ij_php_blank_lines_after_function = 1
ij_php_blank_lines_after_imports = 1
ij_php_blank_lines_after_opening_tag = 0
ij_php_blank_lines_after_package = 1
ij_php_blank_lines_around_class = 1
ij_php_blank_lines_around_constants = 1
ij_php_blank_lines_around_enum_cases = 0
ij_php_blank_lines_around_field = 1
ij_php_blank_lines_around_method = 1
ij_php_blank_lines_before_class_end = 1
ij_php_blank_lines_before_imports = 1
ij_php_blank_lines_before_method_body = 0
ij_php_blank_lines_before_package = 1
ij_php_blank_lines_before_return_statement = 1
ij_php_blank_lines_between_imports = 0
ij_php_block_brace_style = end_of_line
ij_php_call_parameters_new_line_after_left_paren = true
ij_php_call_parameters_right_paren_on_new_line = true
ij_php_call_parameters_wrap = on_every_item
ij_php_catch_on_new_line = true
ij_php_category_weight = 28
ij_php_class_brace_style = end_of_line
ij_php_comma_after_last_argument = false
ij_php_comma_after_last_array_element = true
ij_php_comma_after_last_closure_use_var = false
ij_php_comma_after_last_match_arm = false
ij_php_comma_after_last_parameter = false
ij_php_concat_spaces = true
ij_php_copyright_weight = 28
ij_php_deprecated_weight = 4
ij_php_do_while_brace_force = always
ij_php_else_if_style = as_is
ij_php_else_on_new_line = true
ij_php_example_weight = 28
ij_php_extends_keyword_wrap = off
ij_php_extends_list_wrap = off
ij_php_fields_default_visibility = private
ij_php_filesource_weight = 28
ij_php_finally_on_new_line = true
ij_php_for_brace_force = always
ij_php_for_statement_new_line_after_left_paren = false
ij_php_for_statement_right_paren_on_new_line = false
ij_php_for_statement_wrap = off
ij_php_force_empty_methods_in_one_line = false
ij_php_force_short_declaration_array_style = true
ij_php_getters_setters_naming_style = camel_case
ij_php_getters_setters_order_style = getters_first
ij_php_global_weight = 28
ij_php_group_use_wrap = on_every_item
ij_php_if_brace_force = always
ij_php_if_lparen_on_next_line = false
ij_php_if_rparen_on_next_line = false
ij_php_ignore_weight = 28
ij_php_import_sorting = alphabetic
ij_php_indent_break_from_case = true
ij_php_indent_case_from_switch = true
ij_php_indent_code_in_php_tags = false
ij_php_internal_weight = 28
ij_php_keep_blank_lines_after_lbrace = 1
ij_php_keep_blank_lines_before_right_brace = 1
ij_php_keep_blank_lines_in_code = 1
ij_php_keep_blank_lines_in_declarations = 1
ij_php_keep_control_statement_in_one_line = false
ij_php_keep_first_column_comment = false
ij_php_keep_indents_on_empty_lines = false
ij_php_keep_line_breaks = false
ij_php_keep_rparen_and_lbrace_on_one_line = true
ij_php_keep_simple_classes_in_one_line = false
ij_php_keep_simple_methods_in_one_line = false
ij_php_lambda_brace_style = end_of_line
ij_php_license_weight = 28
ij_php_line_comment_add_space = false
ij_php_line_comment_at_first_column = true
ij_php_link_weight = 28
ij_php_lower_case_boolean_const = false
ij_php_lower_case_keywords = true
ij_php_lower_case_null_const = false
ij_php_method_brace_style = end_of_line
ij_php_method_call_chain_wrap = on_every_item
ij_php_method_parameters_new_line_after_left_paren = true
ij_php_method_parameters_right_paren_on_new_line = true
ij_php_method_parameters_wrap = on_every_item
ij_php_method_weight = 28
ij_php_modifier_list_wrap = false
ij_php_multiline_chained_calls_semicolon_on_new_line = true
ij_php_namespace_brace_style = 1
ij_php_new_line_after_php_opening_tag = true
ij_php_null_type_position = in_the_end
ij_php_package_weight = 28
ij_php_param_weight = 1
ij_php_parameters_attributes_wrap = normal
ij_php_parentheses_expression_new_line_after_left_paren = false
ij_php_parentheses_expression_right_paren_on_new_line = false
ij_php_phpdoc_blank_line_before_tags = true
ij_php_phpdoc_blank_lines_around_parameters = true
ij_php_phpdoc_keep_blank_lines = true
ij_php_phpdoc_param_spaces_between_name_and_description = 1
ij_php_phpdoc_param_spaces_between_tag_and_type = 1
ij_php_phpdoc_param_spaces_between_type_and_name = 1
ij_php_phpdoc_use_fqcn = true
ij_php_phpdoc_wrap_long_lines = true
ij_php_place_assignment_sign_on_next_line = false
ij_php_place_parens_for_constructor = 1
ij_php_property_read_weight = 28
ij_php_property_weight = 28
ij_php_property_write_weight = 28
ij_php_return_type_on_new_line = false
ij_php_return_weight = 2
ij_php_see_weight = 5
ij_php_since_weight = 28
ij_php_sort_phpdoc_elements = true
ij_php_space_after_colon = true
ij_php_space_after_colon_in_enum_backed_type = true
ij_php_space_after_colon_in_named_argument = true
ij_php_space_after_colon_in_return_type = true
ij_php_space_after_comma = true
ij_php_space_after_for_semicolon = true
ij_php_space_after_quest = true
ij_php_space_after_type_cast = true
ij_php_space_after_unary_not = false
ij_php_space_before_array_initializer_left_brace = false
ij_php_space_before_catch_keyword = true
ij_php_space_before_catch_left_brace = true
ij_php_space_before_catch_parentheses = true
ij_php_space_before_class_left_brace = true
ij_php_space_before_closure_left_parenthesis = true
ij_php_space_before_colon = true
ij_php_space_before_colon_in_enum_backed_type = false
ij_php_space_before_colon_in_named_argument = false
ij_php_space_before_colon_in_return_type = false
ij_php_space_before_comma = false
ij_php_space_before_do_left_brace = true
ij_php_space_before_else_keyword = true
ij_php_space_before_else_left_brace = true
ij_php_space_before_finally_keyword = true
ij_php_space_before_finally_left_brace = true
ij_php_space_before_for_left_brace = true
ij_php_space_before_for_parentheses = true
ij_php_space_before_for_semicolon = false
ij_php_space_before_if_left_brace = true
ij_php_space_before_if_parentheses = true
ij_php_space_before_method_call_parentheses = false
ij_php_space_before_method_left_brace = true
ij_php_space_before_method_parentheses = false
ij_php_space_before_quest = true
ij_php_space_before_short_closure_left_parenthesis = false
ij_php_space_before_switch_left_brace = true
ij_php_space_before_switch_parentheses = true
ij_php_space_before_try_left_brace = true
ij_php_space_before_unary_not = false
ij_php_space_before_while_keyword = true
ij_php_space_before_while_left_brace = true
ij_php_space_before_while_parentheses = true
ij_php_space_between_ternary_quest_and_colon = false
ij_php_spaces_around_additive_operators = true
ij_php_spaces_around_arrow = false
ij_php_spaces_around_assignment_in_declare = true
ij_php_spaces_around_assignment_operators = true
ij_php_spaces_around_bitwise_operators = true
ij_php_spaces_around_equality_operators = true
ij_php_spaces_around_logical_operators = true
ij_php_spaces_around_multiplicative_operators = true
ij_php_spaces_around_null_coalesce_operator = true
ij_php_spaces_around_pipe_in_union_type = false
ij_php_spaces_around_relational_operators = true
ij_php_spaces_around_shift_operators = true
ij_php_spaces_around_unary_operator = false
ij_php_spaces_around_var_within_brackets = false
ij_php_spaces_within_array_initializer_braces = false
ij_php_spaces_within_brackets = false
ij_php_spaces_within_catch_parentheses = false
ij_php_spaces_within_for_parentheses = false
ij_php_spaces_within_if_parentheses = false
ij_php_spaces_within_method_call_parentheses = false
ij_php_spaces_within_method_parentheses = false
ij_php_spaces_within_parentheses = false
ij_php_spaces_within_short_echo_tags = true
ij_php_spaces_within_switch_parentheses = false
ij_php_spaces_within_while_parentheses = false
ij_php_special_else_if_treatment = false
ij_php_subpackage_weight = 28
ij_php_ternary_operation_signs_on_next_line = true
ij_php_ternary_operation_wrap = on_every_item
ij_php_throws_weight = 3
ij_php_todo_weight = 6
ij_php_treat_multiline_arrays_and_lambdas_multiline = false
ij_php_unknown_tag_weight = 28
ij_php_upper_case_boolean_const = true
ij_php_upper_case_null_const = true
ij_php_uses_weight = 28
ij_php_var_weight = 0
ij_php_variable_naming_style = camel_case
ij_php_version_weight = 28
ij_php_while_brace_force = always
ij_php_while_on_new_line = false
[{*.neon,*.neon.dist,*neon.template}]
indent_style = tab
tab_width = 4

42
.github/workflows/phpcs.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: PHP_CodeSniffer
on:
pull_request:
paths:
- '**.php'
- tools/phpcs/composer.json
- phpcs.xml.dist
jobs:
phpcs:
runs-on: ubuntu-latest
name: PHP_CodeSniffer
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.1
coverage: none
tools: cs2pr
env:
fail-fast: true
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('tools/phpcs/composer.json') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install dependencies
run: composer composer-phpcs -- update --no-progress --prefer-dist
- name: Run PHP_CodeSniffer
run: composer phpcs -- -q --report=checkstyle | cs2pr

52
.github/workflows/phpstan.yml vendored Normal file
View file

@ -0,0 +1,52 @@
name: PHPStan
on:
pull_request:
paths:
- '**.php'
- composer.json
- tools/phpstan/composer.json
- ci/composer.json
- phpstan.ci.neon
- phpstan.neon.dist
jobs:
phpstan:
runs-on: ubuntu-latest
strategy:
matrix:
php-versions: ['7.4', '8.0', '8.1']
prefer: ['prefer-stable', 'prefer-lowest']
name: PHPStan with PHP ${{ matrix.php-versions }} ${{ matrix.prefer }}
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-versions }}
coverage: none
env:
fail-fast: true
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.prefer }}-${{ hashFiles('**/composer.json') }}
restore-keys: ${{ runner.os }}-composer-${{ matrix.prefer }}-
- name: Install dependencies
run: |
composer update --no-progress --prefer-dist --${{ matrix.prefer }} &&
composer composer-phpunit -- update --no-progress --prefer-dist &&
composer composer-phpstan -- update --no-progress --prefer-dist --optimize-autoloader &&
composer --working-dir=ci update --no-progress --prefer-dist --${{ matrix.prefer }} --optimize-autoloader --ignore-platform-req=ext-gd
- name: Run PHPStan
run: composer phpstan -- analyse -c phpstan.ci.neon

37
.github/workflows/phpunit.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: PHPUnit
on:
pull_request:
paths:
- '**.php'
- composer.json
- tools/phpunit/composer.json
- phpunit.xml.dist
- tests/docker-prepare.sh
env:
# On github CI machine creating the "/vendor" volume fails otherwise with: read-only file system: unknown
BIND_VOLUME_PERMISSIONS: rw
jobs:
phpunit:
runs-on: ubuntu-latest
strategy:
matrix:
civicrm-image-tags: [ '5-drupal-php8.1', '5-drupal-php7.4', '5.56-drupal-php7.4' ]
name: PHPUnit with Docker image michaelmcandrew/civicrm:${{ matrix.civicrm-image-tags }}
env:
CIVICRM_IMAGE_TAG: ${{ matrix.civicrm-image-tags }}
steps:
- uses: actions/checkout@v3
- name: Pull images
run: docker compose -f tests/docker-compose.yml pull --quiet
- name: Start containers
run: docker compose -f tests/docker-compose.yml up -d
- name: Prepare environment
run: docker compose -f tests/docker-compose.yml exec civicrm sites/default/files/civicrm/ext/de.systopia.twingle/tests/docker-prepare.sh
- name: Run PHPUnit
run: docker compose -f tests/docker-compose.yml exec civicrm sites/default/files/civicrm/ext/de.systopia.twingle/tests/docker-phpunit.sh
- name: Remove containers
run: docker compose -f tests/docker-compose.yml down -v

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
/.phpcs.cache
/.phpunit.result.cache
/.phpstan/
/ci/composer.lock
/ci/vendor/
/composer.lock
/phpstan.neon
/tools/*/vendor/
/tools/*/composer.lock
/vendor/

View file

@ -0,0 +1,676 @@
<?php
use Civi\Api4\PriceField;
use Civi\Api4\PriceFieldValue;
use Civi\Twingle\Shop\Exceptions\ProductException;
use Civi\Twingle\Shop\Exceptions\ShopException;
use CRM_Twingle_ExtensionUtil as E;
use function Civi\Twingle\Shop\Utils\convert_int_to_bool;
use function Civi\Twingle\Shop\Utils\convert_str_to_date;
use function Civi\Twingle\Shop\Utils\convert_str_to_int;
use function Civi\Twingle\Shop\Utils\convert_empty_string_to_null;
use function Civi\Twingle\Shop\Utils\filter_attributes;
use function Civi\Twingle\Shop\Utils\validate_data_types;
require_once E::path() . '/Civi/Twingle/Shop/Utils/TwingleShopUtils.php';
/**
* TwingleProduct BAO class.
* This class is used to implement the logic for the TwingleProduct entity.
*/
class CRM_Twingle_BAO_TwingleProduct extends CRM_Twingle_DAO_TwingleProduct {
/**
* Name of this product.
*/
public $name;
/**
* Is this product active?
*/
public $is_active;
/**
* Price of this product.
*/
public $price;
/**
* Sort order of this product.
*/
public $sort;
/**
* Short description of this Product.
*/
public $text;
/**
* Long description of this Product.
*/
public $description;
/**
* ID of the corresponding Twingle Shop.
*/
public $project_id;
/**
* ID of the financial type of this product.
*/
public $financial_type_id;
/**
* Timestamp of the last update in Twingle db.
*/
public $tw_updated_at;
/**
* The values of this attributes can be 0.
* (For filtering purposes)
*/
protected const CAN_BE_ZERO = [
"price",
"sort",
];
/**
* Attributes that need to be converted to int.
*/
protected const STR_TO_INT_CONVERSION = [
"id",
"twingle_shop_id",
"financial_type_id",
"price_field_id",
"project_id",
"external_id",
"tw_updated_at",
"tw_created_at",
"price",
"sort",
];
/**
* Attributes that need to be converted to boolean.
*/
protected const INT_TO_BOOL_CONVERSION = [
"is_active",
];
/**
* String to date conversion.
*/
protected const STR_TO_DATE_CONVERSION = [
"created_at",
"updated_at",
];
/**
* Empty string to null conversion.
*/
protected const EMPTY_STRING_TO_NULL = [
"price",
];
/**
* Allowed product attributes.
* Attributes that we currently don't support are commented out.
*/
protected const ALLOWED_ATTRIBUTES = [
"id" => 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,
);
// Does this product allow to enter a custom price?
$custom_price = array_key_exists('price', $product_data) && $product_data['price'] === Null;
if (!$custom_price && isset($product_data['price_field_id'])) {
try {
$price_field = civicrm_api3('PriceField', 'getsingle', [
'id' => $product_data['price_field_id'],
'return' => 'is_enter_qty',
]);
$custom_price = (bool) $price_field['is_enter_qty'];
}
catch (CRM_Core_Exception $e) {
throw new ProductException(
E::ts("Could not find PriceField 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_NOT_FOUND);
}
}
// 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'] = $custom_price ? Null : $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_empty_string_to_null($product_data, self::EMPTY_STRING_TO_NULL);
}
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',
'is_required' => false,
];
// If the product has no fixed price, allow the user to enter a custom price
if ($this->price === Null) {
$price_field_data['is_enter_qty'] = true;
$price_field_data['is_display_amounts'] = false;
}
// 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 === Null ? 1 : $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 CRM_Twingle_BAO_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 = CRM_Twingle_BAO_TwingleShop::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 = CRM_Twingle_BAO_TwingleShop::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: %1', [1 => $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: %1', [1 => $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 \CRM_Twingle_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 CRM_Twingle_BAO_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 = 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 = 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: %1', [1 => $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: %1', [1 => $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: %1', [1 => $e->getMessage()]),
ProductException::ERROR_CODE_PRICE_FIELD_NOT_FOUND);
}
throw new ProductException(
E::ts('Could not delete associated PriceField: %1', [1 => $e->getMessage()]),
ProductException::ERROR_CODE_COULD_NOT_DELETE_PRICE_FIELD);
}
$this->price_field_id = NULL;
}
}

View file

@ -0,0 +1,475 @@
<?php
// phpcs:disable
use CRM_Twingle_ExtensionUtil as E;
use Civi\Twingle\Shop\ApiCall;
use Civi\Twingle\Shop\Exceptions\ShopException;
use Civi\Twingle\Shop\Exceptions\ProductException;
use function Civi\Twingle\Shop\Utils\filter_attributes;
use function Civi\Twingle\Shop\Utils\convert_str_to_int;
use function Civi\Twingle\Shop\Utils\validate_data_types;
// phpcs:enable
require_once E::path() . '/Civi/Twingle/Shop/Utils/TwingleShopUtils.php';
class CRM_Twingle_BAO_TwingleShop extends CRM_Twingle_DAO_TwingleShop {
public const ALLOWED_ATTRIBUTES = [
'id' => \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 CRM_Twingle_BAO_TwingleShop
*
* @throws ShopException
* @throws \Civi\Twingle\Shop\Exceptions\ApiCallError
* @throws \CRM_Core_Exception
*/
public static function findByProjectIdentifier(string $project_identifier) {
$shop = new CRM_Twingle_BAO_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 = self::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: %1', [1 => $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: %1', [1 => $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: %1', [1 => $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 CRM_Twingle_BAO_TwingleProduct $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 CRM_Twingle_BAO_TwingleProduct();
$product->load(CRM_Twingle_BAO_TwingleProduct::renameTwingleAttrs($product_from_twingle));
$product->twingle_shop_id = $this->id;
$this->products[] = $product;
}
}
return $this->products;
}
/**
* Get associated products.
*
* @return list<CRM_Twingle_BAO_TwingleProduct>
* @throws \Civi\Core\Exception\DBQueryException
* @throws \Civi\Twingle\Shop\Exceptions\ProductException
*/
public function getProducts() {
$products = [];
$result = CRM_Twingle_BAO_TwingleProduct::executeQuery(
"SELECT * FROM civicrm_twingle_product WHERE twingle_shop_id = %1",
[1 => [$this->id, 'Integer']]
);
while ($result->fetch()) {
$product = new CRM_Twingle_BAO_TwingleProduct();
$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: %1', [1 => $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: %1', [1 => $e->getMessage()]),
ProductException::ERROR_CODE_COULD_NOT_DELETE_PRICE_SET,
);
}
}
}

View file

@ -13,25 +13,28 @@
| written permission from the original author(s). | | written permission from the original author(s). |
+-------------------------------------------------------------*/ +-------------------------------------------------------------*/
declare(strict_types = 1);
use CRM_Twingle_ExtensionUtil as E; use CRM_Twingle_ExtensionUtil as E;
class CRM_Twingle_Config { class CRM_Twingle_Config {
const RCUR_PROTECTION_OFF = 0; public const RCUR_PROTECTION_OFF = 0;
const RCUR_PROTECTION_EXCEPTION = 1; public const RCUR_PROTECTION_EXCEPTION = 1;
const RCUR_PROTECTION_ACTIVITY = 2; public const RCUR_PROTECTION_ACTIVITY = 2;
/** /**
* Get the options for protecting a recurring contribution linked Twingle * Get the options for protecting a recurring contribution linked Twingle
* against ending or cancellation (because Twingle would keep on collecting them) * against ending or cancellation (because Twingle would keep on collecting them)
* *
* @return array * @return array<int, string>
*/ */
public static function getRecurringProtectionOptions() { public static function getRecurringProtectionOptions() {
return [ return [
self::RCUR_PROTECTION_OFF => E::ts("No"), self::RCUR_PROTECTION_OFF => E::ts('No'),
self::RCUR_PROTECTION_EXCEPTION => E::ts("Raise Exception"), self::RCUR_PROTECTION_EXCEPTION => E::ts('Raise Exception'),
self::RCUR_PROTECTION_ACTIVITY => E::ts("Create Activity"), self::RCUR_PROTECTION_ACTIVITY => E::ts('Create Activity'),
]; ];
} }
} }

View file

@ -0,0 +1,325 @@
<?php
/**
* @package CRM
* @copyright CiviCRM LLC https://civicrm.org/licensing
*
* Generated from de.systopia.twingle/xml/schema/CRM/Twingle/TwingleProduct.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
* (GenCodeChecksum:b8ced533ba6f4249619ffe5353965a4f)
*/
use CRM_Twingle_ExtensionUtil as E;
/**
* Database access object for the TwingleProduct entity.
*/
class CRM_Twingle_DAO_TwingleProduct extends CRM_Core_DAO {
const EXT = E::LONG_NAME;
const TABLE_ADDED = '';
/**
* Static instance to hold the table name.
*
* @var string
*/
public static $_tableName = 'civicrm_twingle_product';
/**
* Should CiviCRM log any modifications to this table in the civicrm_log table.
*
* @var bool
*/
public static $_log = FALSE;
/**
* Unique TwingleProduct ID
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $id;
/**
* The ID of this product in the Twingle database
*
* @var int|string
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $external_id;
/**
* FK to Price Field
*
* @var int|string
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $price_field_id;
/**
* FK to Twingle Shop
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $twingle_shop_id;
/**
* Timestamp of when the product was created in the database
*
* @var string
* (SQL type: datetime)
* Note that values will be retrieved from the database as a string.
*/
public $created_at;
/**
* Timestamp of when the product was last updated in the database
*
* @var string
* (SQL type: datetime)
* Note that values will be retrieved from the database as a string.
*/
public $updated_at;
/**
* Class constructor.
*/
public function __construct() {
$this->__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' => 'CRM_Twingle_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' => 'CRM_Twingle_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' => 'CRM_Twingle_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' => 'CRM_Twingle_DAO_TwingleProduct',
'localizable' => 0,
'FKClassName' => 'CRM_Twingle_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' => 'CRM_Twingle_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' => 'CRM_Twingle_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;
}
}

View file

@ -0,0 +1,305 @@
<?php
/**
* @package CRM
* @copyright CiviCRM LLC https://civicrm.org/licensing
*
* Generated from de.systopia.twingle/xml/schema/CRM/Twingle/TwingleShop.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
* (GenCodeChecksum:95a46935c30b3da52e4df2bec871b962)
*/
use CRM_Twingle_ExtensionUtil as E;
/**
* Database access object for the TwingleShop entity.
*/
class CRM_Twingle_DAO_TwingleShop extends CRM_Core_DAO {
const EXT = E::LONG_NAME;
const TABLE_ADDED = '';
/**
* Static instance to hold the table name.
*
* @var string
*/
public static $_tableName = 'civicrm_twingle_shop';
/**
* Should CiviCRM log any modifications to this table in the civicrm_log table.
*
* @var bool
*/
public static $_log = FALSE;
/**
* Unique TwingleShop ID
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $id;
/**
* Twingle Project Identifier
*
* @var string
* (SQL type: varchar(32))
* Note that values will be retrieved from the database as a string.
*/
public $project_identifier;
/**
* Numerical Twingle Project Identifier
*
* @var int|string
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $numerical_project_id;
/**
* FK to Price Set
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $price_set_id;
/**
* name of the shop
*
* @var string
* (SQL type: varchar(64))
* Note that values will be retrieved from the database as a string.
*/
public $name;
/**
* Class constructor.
*/
public function __construct() {
$this->__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' => 'CRM_Twingle_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' => 'CRM_Twingle_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' => 'CRM_Twingle_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' => 'CRM_Twingle_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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,8 @@
| written permission from the original author(s). | | written permission from the original author(s). |
+-------------------------------------------------------------*/ +-------------------------------------------------------------*/
declare(strict_types = 1);
use CRM_Twingle_ExtensionUtil as E; use CRM_Twingle_ExtensionUtil as E;
/** /**
@ -23,7 +25,8 @@ use CRM_Twingle_ExtensionUtil as E;
class CRM_Twingle_Form_Settings extends CRM_Core_Form { class CRM_Twingle_Form_Settings extends CRM_Core_Form {
/** /**
* @var array list of all settings options * @var array<string>
* List of all settings options.
*/ */
public static $SETTINGS_LIST = [ public static $SETTINGS_LIST = [
'twingle_prefix', 'twingle_prefix',
@ -34,58 +37,60 @@ class CRM_Twingle_Form_Settings extends CRM_Core_Form {
'twingle_protect_recurring_activity_subject', 'twingle_protect_recurring_activity_subject',
'twingle_protect_recurring_activity_status', 'twingle_protect_recurring_activity_status',
'twingle_protect_recurring_activity_assignee', 'twingle_protect_recurring_activity_assignee',
'twingle_use_shop',
'twingle_access_key',
]; ];
/** /**
* @inheritdoc * @inheritdoc
*/ */
function buildQuickForm() { public function buildQuickForm(): void {
// Set redirect destination. // Set redirect destination.
$this->controller->_destination = CRM_Utils_System::url('civicrm/admin/settings/twingle', 'reset=1'); $this->controller->_destination = CRM_Utils_System::url('civicrm/admin/settings/twingle', 'reset=1');
$this->add( $this->add(
'text', 'text',
'twingle_prefix', 'twingle_prefix',
E::ts("Twingle ID Prefix") E::ts('Twingle ID Prefix')
); );
$this->add( $this->add(
'checkbox', 'checkbox',
'twingle_use_sepa', 'twingle_use_sepa',
E::ts("Use CiviSEPA") E::ts('Use CiviSEPA')
); );
$this->add( $this->add(
'checkbox', 'checkbox',
'twingle_dont_use_reference', 'twingle_dont_use_reference',
E::ts("Use CiviSEPA generated reference") E::ts('Use CiviSEPA generated reference')
); );
$this->add( $this->add(
'select', 'select',
'twingle_protect_recurring', 'twingle_protect_recurring',
E::ts("Protect Recurring Contributions"), E::ts('Protect Recurring Contributions'),
CRM_Twingle_Config::getRecurringProtectionOptions() CRM_Twingle_Config::getRecurringProtectionOptions()
); );
$this->add( $this->add(
'select', 'select',
'twingle_protect_recurring_activity_type', 'twingle_protect_recurring_activity_type',
E::ts("Activity Type"), E::ts('Activity Type'),
$this->getOptionValueList('activity_type', [0]) $this->getOptionValueList('activity_type', [0])
); );
$this->add( $this->add(
'text', 'text',
'twingle_protect_recurring_activity_subject', 'twingle_protect_recurring_activity_subject',
E::ts("Subject"), E::ts('Subject'),
['class' => 'huge'] ['class' => 'huge']
); );
$this->add( $this->add(
'select', 'select',
'twingle_protect_recurring_activity_status', 'twingle_protect_recurring_activity_status',
E::ts("Status"), E::ts('Status'),
$this->getOptionValueList('activity_status') $this->getOptionValueList('activity_status')
); );
@ -102,18 +107,30 @@ class CRM_Twingle_Form_Settings extends CRM_Core_Form {
] ]
); );
$this->addButtons(array( $this->add(
array ( 'checkbox',
'twingle_use_shop',
E::ts('Use Twingle Shop Integration')
);
$this->add(
'text',
'twingle_access_key',
E::ts('Twingle Access Key')
);
$this->addButtons([
[
'type' => 'submit', 'type' => 'submit',
'name' => E::ts('Save'), 'name' => E::ts('Save'),
'isDefault' => TRUE, 'isDefault' => TRUE,
) ],
)); ]);
// set defaults // set defaults
foreach (self::$SETTINGS_LIST as $setting) { foreach (self::$SETTINGS_LIST as $setting) {
$this->setDefaults([ $this->setDefaults([
$setting => Civi::settings()->get($setting) $setting => Civi::settings()->get($setting),
]); ]);
} }
@ -121,54 +138,62 @@ class CRM_Twingle_Form_Settings extends CRM_Core_Form {
} }
/** /**
* Custom form validation, because the activity creation fields * Custom form validation, as some fields are mandatory only when others are active.
* are only mandatory if activity creation is active
* @return bool * @return bool
*/ */
public function validate() { public function validate() {
parent::validate(); parent::validate();
// if activity creation is active, make sure the fields are set // if activity creation is active, make sure the fields are set
$protection_mode = CRM_Utils_Array::value('twingle_protect_recurring', $this->_submitValues); $protection_mode = $this->_submitValues['twingle_protect_recurring'] ?? NULL;
if ($protection_mode == CRM_Twingle_Config::RCUR_PROTECTION_ACTIVITY) { if ($protection_mode == CRM_Twingle_Config::RCUR_PROTECTION_ACTIVITY) {
foreach (['twingle_protect_recurring_activity_type', foreach ([
'twingle_protect_recurring_activity_type',
'twingle_protect_recurring_activity_subject', 'twingle_protect_recurring_activity_subject',
'twingle_protect_recurring_activity_status', 'twingle_protect_recurring_activity_status',
'twingle_protect_recurring_activity_assignee',] as $activity_field) { 'twingle_protect_recurring_activity_assignee',
$current_value = CRM_Utils_Array::value($activity_field, $this->_submitValues); ] as $activity_field) {
if (empty($current_value)) { if (NULL !== ($this->_submitValues[$activity_field] ?? NULL)) {
$this->_errors[$activity_field] = E::ts("This is required for activity creation"); $this->_errors[$activity_field] = E::ts('This is required for activity creation');
} }
} }
} }
// 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)); return (0 == count($this->_errors));
} }
/** /**
* @inheritdoc * @inheritdoc
*/ */
function postProcess() { public function postProcess(): void {
$values = $this->exportValues(); $values = $this->exportValues();
// store settings // store settings
foreach (self::$SETTINGS_LIST as $setting) { foreach (self::$SETTINGS_LIST as $setting) {
Civi::settings()->set($setting, CRM_Utils_Array::value($setting, $values)); Civi::settings()->set($setting, $values[$setting] ?? NULL);
} }
parent::postProcess(); parent::postProcess();
} }
/** /**
* Get a list of option group items * Get a list of option group items
* @param $group_id string group ID or name * @param string $group_id
* @return array list of ID(value) => label * Group ID or name.
* @throws CiviCRM_API3_Exception * @param array<int> $reserved
* @return array<int|string, string> list of ID(value) => label
* @throws \CRM_Core_Exception
*/ */
protected function getOptionValueList($group_id, $reserved = [0,1]) { protected function getOptionValueList(string $group_id, array $reserved = [0, 1]): array {
$list = ['' => E::ts("-select-")]; $list = ['' => E::ts('-select-')];
$query = civicrm_api3('OptionValue', 'get', [ $query = civicrm_api3('OptionValue', 'get', [
'option_group_id' => $group_id, 'option_group_id' => $group_id,
'option.limit' => 0, 'option.limit' => 0,
@ -181,4 +206,5 @@ class CRM_Twingle_Form_Settings extends CRM_Core_Form {
} }
return $list; return $list;
} }
} }

View file

@ -13,11 +13,13 @@
| written permission from the original author(s). | | written permission from the original author(s). |
+-------------------------------------------------------------*/ +-------------------------------------------------------------*/
declare(strict_types = 1);
use CRM_Twingle_ExtensionUtil as E; use CRM_Twingle_ExtensionUtil as E;
class CRM_Twingle_Page_Configuration extends CRM_Core_Page { class CRM_Twingle_Page_Configuration extends CRM_Core_Page {
public function run() { public function run(): void {
parent::run(); parent::run();
} }

View file

@ -13,21 +13,30 @@
| written permission from the original author(s). | | written permission from the original author(s). |
+-------------------------------------------------------------*/ +-------------------------------------------------------------*/
declare(strict_types = 1);
use CRM_Twingle_ExtensionUtil as E; use CRM_Twingle_ExtensionUtil as E;
class CRM_Twingle_Page_Profiles extends CRM_Core_Page { class CRM_Twingle_Page_Profiles extends CRM_Core_Page {
public function run() { public function run():void {
CRM_Utils_System::setTitle(E::ts("Twingle API Profiles")); CRM_Utils_System::setTitle(E::ts('Twingle API Profiles'));
$profiles = []; $profiles = [];
foreach (CRM_Twingle_Profile::getProfiles() as $profile_name => $profile) { foreach (CRM_Twingle_Profile::getProfiles() as $profile_id => $profile) {
$profiles[$profile_name]['name'] = $profile_name; $profiles[$profile_id]['id'] = $profile_id;
$profiles[$profile_id]['name'] = $profile->getName();
$profiles[$profile_id]['is_default'] = $profile->is_default();
$profiles[$profile_id]['selectors'] = $profile->getProjectIds();
foreach (CRM_Twingle_Profile::allowedAttributes() as $attribute) { foreach (CRM_Twingle_Profile::allowedAttributes() as $attribute) {
$profiles[$profile_name][$attribute] = $profile->getAttribute($attribute); $profiles[$profile_id][$attribute] = $profile->getAttribute($attribute);
} }
} }
$this->assign('profiles', $profiles); $this->assign('profiles', $profiles);
$this->assign('profile_stats', CRM_Twingle_Profile::getProfileStats()); $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');
parent::run(); parent::run();
} }

View file

@ -13,7 +13,11 @@
| written permission from the original author(s). | | written permission from the original author(s). |
+-------------------------------------------------------------*/ +-------------------------------------------------------------*/
declare(strict_types = 1);
use CRM_Twingle_ExtensionUtil as E; use CRM_Twingle_ExtensionUtil as E;
use Civi\Twingle\Exceptions\ProfileException as ProfileException;
use Civi\Twingle\Exceptions\ProfileValidationError;
/** /**
* Profiles define how incoming submissions from the Twingle API are * Profiles define how incoming submissions from the Twingle API are
@ -22,26 +26,44 @@ use CRM_Twingle_ExtensionUtil as E;
class CRM_Twingle_Profile { class CRM_Twingle_Profile {
/** /**
* @var string $name * @var int
* The name of the profile. * The id of the profile.
*/ */
protected $name = NULL; protected ?int $id;
/** /**
* @var array $data * @var string
* The name of the profile.
*/
protected string $name;
/**
* @var array<string, mixed>
* The properties of the profile. * The properties of the profile.
*/ */
protected $data = NULL; 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. * CRM_Twingle_Profile constructor.
* *
* @param string $name * @param string $name
* The name of the profile. * The name of the profile.
* @param array $data * @param array<string, mixed> $data
* The properties of the profile * The properties of the profile
* @param int|NULL $id
*/ */
public function __construct($name, $data) { public function __construct($name, $data, $id = NULL) {
$this->id = $id;
$this->name = $name; $this->name = $name;
$allowed_attributes = self::allowedAttributes(); $allowed_attributes = self::allowedAttributes();
$this->data = $data + array_combine( $this->data = $data + array_combine(
@ -53,13 +75,31 @@ class CRM_Twingle_Profile {
/** /**
* Logs (production) access to this profile * Logs (production) access to this profile
*/ */
public function logAccess() { public function logAccess(): void {
CRM_Core_DAO::executeQuery(" CRM_Core_DAO::executeQuery('
UPDATE civicrm_twingle_profile UPDATE civicrm_twingle_profile
SET SET
last_access = NOW(), last_access = NOW(),
access_counter = access_counter + 1 access_counter = access_counter + 1
WHERE name = %1", [1 => [$this->name, 'String']]); WHERE name = %1', [1 => [$this->name, 'String']]);
}
/**
* Copy this profile by returning a clone with all unique information removed.
*
* @return CRM_Twingle_Profile
*/
public function copy() {
$copy = clone $this;
// Remove unique data
$copy->id = NULL;
$copy->data['selector'] = NULL;
// Propose a new name for this profile.
$profile_name = $this->getName() . '_copy';
$copy->setName($profile_name);
return $copy;
} }
/** /**
@ -70,58 +110,113 @@ class CRM_Twingle_Profile {
* @return bool * @return bool
*/ */
public function matches($project_id) { public function matches($project_id) {
$selector = $this->getAttribute('selector'); return in_array($project_id, $this->getProjectIds(), TRUE);
$project_ids = array_map(
function($project_id) {
return trim($project_id);
},
explode(',', $selector)
);
return in_array($project_id, $project_ids);
} }
/** /**
* @return array * Retrieves the profile's configured custom field mapping.
*
* @return array<string, string>
* The profile's configured custom field mapping * The profile's configured custom field mapping
*/ */
public function getCustomFieldMapping() { public function getCustomFieldMapping() {
$custom_field_mapping = []; $custom_field_mapping = [];
if (!empty($custom_field_definition = $this->getAttribute('custom_field_mapping'))) { if ('' !== ($custom_field_definition = $this->getAttribute('custom_field_mapping', ''))) {
foreach (preg_split('/\r\n|\r|\n/', $custom_field_definition, -1, PREG_SPLIT_NO_EMPTY) as $custom_field_map) { /** @var string $custom_field_definition */
[$twingle_field_name, $custom_field_name] = explode("=", $custom_field_map); $custom_field_maps = preg_split(
'/\r\n|\r|\n/',
$custom_field_definition,
-1,
PREG_SPLIT_NO_EMPTY
);
if (FALSE !== $custom_field_maps) {
foreach ($custom_field_maps as $custom_field_map) {
[$twingle_field_name, $custom_field_name] = explode('=', $custom_field_map);
$custom_field_mapping[$twingle_field_name] = $custom_field_name; $custom_field_mapping[$twingle_field_name] = $custom_field_name;
} }
} }
}
return $custom_field_mapping; return $custom_field_mapping;
} }
/** /**
* Retrieves all data attributes of the profile. * Retrieves all data attributes of the profile.
* *
* @return array * @return array<string, mixed>
*/ */
public function getData() { public function getData() {
return $this->data; return $this->data;
} }
/**
* Retrieves the profile id.
*
* @return int
*/
public function getId(): ?int {
return $this->id;
}
/**
* Set the profile id.
*
* @param int $id
*/
public function setId(int $id): void {
$this->id = $id;
}
/** /**
* Retrieves the profile name. * Retrieves the profile name.
* *
* @return string * @return string
*/ */
public function getName() { public function getName(): string {
return $this->name; return $this->name;
} }
/** /**
* Sets the profile name. * Sets the profile name.
* *
* @param $name * @param string $name
*/ */
public function setName($name) { public function setName(string $name): void {
$this->name = $name; $this->name = $name;
} }
/**
* Is this the default profile?
*
* @return bool
*/
public function is_default(): bool {
return $this->name === 'default';
}
/**
* Retrieves the profile's project IDs.
*
* @return array<string>
*/
public function getProjectIds(): array {
return array_map(
function($project_id) {
return trim($project_id);
},
explode(',', $this->getAttribute('selector') ?? '')
);
}
/**
* 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. * Retrieves an attribute of the profile.
* *
@ -131,7 +226,9 @@ class CRM_Twingle_Profile {
* @return mixed | NULL * @return mixed | NULL
*/ */
public function getAttribute($attribute_name, $default = NULL) { public function getAttribute($attribute_name, $default = NULL) {
return $this->data[$attribute_name] ?? $default; return (isset($this->data[$attribute_name]) && $this->data[$attribute_name] !== '')
? $this->data[$attribute_name]
: $default;
} }
/** /**
@ -140,12 +237,15 @@ class CRM_Twingle_Profile {
* @param string $attribute_name * @param string $attribute_name
* @param mixed $value * @param mixed $value
* *
* @throws \Exception * @throws \Civi\Twingle\Exceptions\ProfileException
* When the attribute name is not known. * When the attribute name is not known.
*/ */
public function setAttribute($attribute_name, $value) { public function setAttribute($attribute_name, $value): void {
if (!in_array($attribute_name, self::allowedAttributes())) { if (!in_array($attribute_name, self::allowedAttributes(), TRUE)) {
throw new Exception(E::ts('Unknown attribute %1.', [1 => $attribute_name])); throw new ProfileException(
E::ts('Unknown attribute %1.', [1 => $attribute_name]),
ProfileException::ERROR_CODE_UNKNOWN_PROFILE_ATTRIBUTE
);
} }
// TODO: Check if value is acceptable. // TODO: Check if value is acceptable.
$this->data[$attribute_name] = $value; $this->data[$attribute_name] = $value;
@ -154,117 +254,347 @@ class CRM_Twingle_Profile {
/** /**
* Get the CiviCRM transaction ID (to be used in contributions and recurring contributions) * Get the CiviCRM transaction ID (to be used in contributions and recurring contributions)
* *
* @param $twingle_id string Twingle ID * @param string $twingle_id Twingle ID
* @return string CiviCRM transaction ID * @return string CiviCRM transaction ID
*/ */
public function getTransactionID($twingle_id) { public function getTransactionID(string $twingle_id) {
$prefix = Civi::settings()->get('twingle_prefix'); $prefix = Civi::settings()->get('twingle_prefix');
if (empty($prefix)) { return ($prefix ?? '') . $twingle_id;
return $twingle_id;
} else {
return $prefix . $twingle_id;
}
} }
/** /**
* Verifies whether the profile is valid (i.e. consistent and not colliding * Verifies whether the profile is valid (i.e. consistent and not colliding
* with other profiles). * with other profiles).
* *
* @throws Exception * @throws \Civi\Twingle\Exceptions\ProfileValidationError
* @throws \Civi\Core\Exception\DBQueryException
* When the profile could not be successfully validated. * When the profile could not be successfully validated.
*/ */
public function verifyProfile() { public function validate(): void {
// TODO: check
// data of this profile consistent? // Name cannot be empty
// conflicts with other profiles? if ('' === $this->getName()) {
throw new ProfileValidationError(
'name',
E::ts('Profile name cannot be empty.'),
ProfileValidationError::ERROR_CODE_PROFILE_VALIDATION_FAILED
);
}
// Restrict profile names to alphanumeric characters, space and the underscore.
if (1 === preg_match('/[^A-Za-z0-9_\s]/', $this->getName())) {
throw new ProfileValidationError(
'name',
E::ts('Only alphanumeric characters, space and the underscore (_) are allowed for profile names.'),
ProfileValidationError::ERROR_CODE_PROFILE_VALIDATION_FAILED
);
}
// Check if profile name is already used for other profile
$profile_name_duplicates = array_filter(
CRM_Twingle_Profile::getProfiles(),
function($profile) {
return $profile->getName() == $this->getName() && $this->getId() != $profile->getId();
});
if ([] !== $profile_name_duplicates) {
throw new ProfileValidationError(
'name',
E::ts("A profile with the name '%1' already exists.", [1 => $this->getName()]),
ProfileValidationError::ERROR_CODE_PROFILE_VALIDATION_FAILED
);
}
// Check if project_id is already used in other profile
$profiles = $this::getProfiles();
foreach ($profiles as $profile) {
if ($profile->getId() == $this->getId() || $profile->is_default()) {
continue;
};
$project_ids = $this->getProjectIds();
$id_duplicates = array_intersect($profile->getProjectIds(), $project_ids);
if ([] !== $id_duplicates) {
throw new ProfileValidationError(
'selector',
E::ts(
"Project ID(s) [%1] already used in profile '%2'.",
[
1 => implode(', ', $id_duplicates),
2 => $profile->getName(),
]
),
ProfileValidationError::ERROR_CODE_PROFILE_VALIDATION_WARNING
);
}
}
// Validate custom field mapping.
$custom_field_mapping = $this->getAttribute('custom_field_mapping');
if (is_string($custom_field_mapping)) {
$custom_field_mapping = preg_split('/\r\n|\r|\n/', $custom_field_mapping, -1, PREG_SPLIT_NO_EMPTY);
$parsing_error = new ProfileValidationError(
'custom_field_mapping',
E::ts('Could not parse custom field mapping.'),
ProfileValidationError::ERROR_CODE_PROFILE_VALIDATION_FAILED
);
if (!is_array($custom_field_mapping)) {
throw $parsing_error;
}
foreach ($custom_field_mapping as $custom_field_map) {
$custom_field_map = explode('=', $custom_field_map);
if (count($custom_field_map) !== 2) {
throw $parsing_error;
}
[$twingle_field_name, $custom_field_name] = $custom_field_map;
$custom_field_id = substr($custom_field_name, strlen('custom_'));
// Check for custom field existence
try {
/**
* @phpstan-var array<string, mixed> $custom_field
*/
$custom_field = civicrm_api3(
'CustomField', 'getsingle', ['id' => $custom_field_id]
);
}
catch (CRM_Core_Exception $exception) {
throw new ProfileValidationError(
'custom_field_mapping',
E::ts(
'Custom field custom_%1 does not exist.',
[1 => $custom_field_id]
),
ProfileValidationError::ERROR_CODE_PROFILE_VALIDATION_FAILED,
$exception
);
}
// Only allow custom fields on relevant entities.
try {
civicrm_api3('CustomGroup', 'getsingle',
[
'id' => $custom_field['custom_group_id'],
'extends' => [
'IN' => [
'Contact',
'Individual',
'Organization',
'Contribution',
'ContributionRecur',
],
],
]);
}
catch (CRM_Core_Exception $exception) {
throw new ProfileValidationError(
'custom_field_mapping',
E::ts(
'Custom field custom_%1 is not in a CustomGroup that extends one of the supported CiviCRM entities.',
[1 => $custom_field['id']]
),
ProfileValidationError::ERROR_CODE_PROFILE_VALIDATION_FAILED,
$exception
);
}
}
}
} }
/** /**
* Persists the profile within the CiviCRM settings. * Persists the profile within the database.
*
* @throws \Civi\Twingle\Exceptions\ProfileException
*/ */
public function saveProfile() { public function saveProfile(): void {
// make sure it's valid try {
$this->verifyProfile(); if (isset($this->id)) {
// check if the profile exists
$profile_id = CRM_Core_DAO::singleValueQuery(
"SELECT id FROM civicrm_twingle_profile WHERE name = %1", [1 => [$this->name, 'String']]);
if ($profile_id) {
// existing profile -> just update the config // existing profile -> just update the config
CRM_Core_DAO::executeQuery( CRM_Core_DAO::executeQuery(
"UPDATE civicrm_twingle_profile SET config = %2 WHERE name = %1", 'UPDATE civicrm_twingle_profile SET config = %2, name = %3 WHERE id = %1',
[ [
1 => [$this->name, 'String'], 1 => [$this->id, 'String'],
2 => [json_encode($this->data), 'String'] 2 => [json_encode($this->data), 'String'],
3 => [$this->name, 'String'],
]); ]);
} else { }
else {
// new profile -> add new entry to the DB // new profile -> add new entry to the DB
CRM_Core_DAO::executeQuery( CRM_Core_DAO::executeQuery(
"INSERT IGNORE INTO civicrm_twingle_profile(name,config,last_access,access_counter) VALUES (%1, %2, null, 0)", <<<SQL
INSERT IGNORE INTO civicrm_twingle_profile(name,config,last_access,access_counter) VALUES (%1, %2, null, 0)
SQL,
[ [
1 => [$this->name, 'String'], 1 => [$this->name, 'String'],
2 => [json_encode($this->data), 'String'] 2 => [json_encode($this->data), 'String'],
]); ]);
} }
} }
catch (Exception $exception) {
throw new ProfileException(
E::ts('Could not save/update profile: %1', [1 => $exception->getMessage()]),
ProfileException::ERROR_CODE_COULD_NOT_SAVE_PROFILE,
$exception
);
}
}
/** /**
* Deletes the profile from the database * Deletes the profile from the database
*
* @throws \Civi\Twingle\Exceptions\ProfileException
*/ */
public function deleteProfile() { public function deleteProfile(): void {
CRM_Core_DAO::executeQuery("DELETE FROM civicrm_twingle_profile WHERE name = %1", [1 => [$this->name, 'String']]); // Do only reset default profile
if ($this->getName() == 'default') {
try {
$default_profile = CRM_Twingle_Profile::createDefaultProfile();
$default_profile->setId($this->getId());
$default_profile->saveProfile();
// Reset counter
CRM_Core_DAO::executeQuery(
'UPDATE civicrm_twingle_profile SET access_counter = 0, last_access = NULL WHERE id = %1',
[1 => [$this->id, 'Integer']]
);
}
catch (Exception $exception) {
throw new ProfileException(
E::ts('Could not reset default profile: %1', [1 => $exception->getMessage()]),
ProfileException::ERROR_CODE_COULD_NOT_RESET_PROFILE,
$exception
);
}
}
else {
try {
CRM_Core_DAO::executeQuery(
'DELETE FROM civicrm_twingle_profile WHERE id = %1',
[1 => [$this->id, 'Integer']]
);
}
catch (Exception $exception) {
throw new ProfileException(
E::ts('Could not delete profile: %1', [1 => $exception->getMessage()]),
ProfileException::ERROR_CODE_COULD_NOT_DELETE_PROFILE,
$exception
);
}
}
} }
/** /**
* Returns an array of attributes allowed for a profile. * Returns an array of attributes allowed for a profile.
* *
* @return array * @return array<string>|array<string, bool>
*/ */
public static function allowedAttributes() { public static function allowedAttributes(bool $asMetadata = FALSE) {
return array_merge( $attributes = array_merge(
[ [
'selector', 'selector' => [
'xcm_profile', 'label' => E::ts('Project IDs'),
'location_type_id', 'required' => TRUE,
'location_type_id_organisation', ],
'financial_type_id', 'xcm_profile' => ['required' => FALSE],
'financial_type_id_recur', 'location_type_id' => [
'sepa_creditor_id', 'label' => E::ts('Location type'),
'gender_male', 'required' => TRUE,
'gender_female', ],
'gender_other', 'location_type_id_organisation' => [
'prefix_male', 'label' => E::ts('Location type for organisations'),
'prefix_female', 'required' => TRUE,
'prefix_other', ],
'newsletter_groups', 'financial_type_id' => [
'postinfo_groups', 'label' => E::ts('Financial type'),
'donation_receipt_groups', 'required' => TRUE,
'campaign', ],
'campaign_targets', 'financial_type_id_recur' => [
'contribution_source', 'label' => E::ts('Financial type (recurring)'),
'custom_field_mapping', 'required' => TRUE,
'membership_type_id', ],
'membership_type_id_recur', 'sepa_creditor_id' => [
'membership_postprocess_call', 'label' => E::ts('CiviSEPA creditor'),
'newsletter_double_opt_in', 'required' => CRM_Twingle_Submission::civiSepaEnabled(),
'required_address_components', ],
'gender_male' => [
'label' => E::ts('Gender option for submitted value "male"'),
'required' => TRUE,
],
'gender_female' => [
'label' => E::ts('Gender option for submitted value "female"'),
'required' => TRUE,
],
'gender_other' => [
'label' => E::ts('Gender option for submitted value "other"'),
'required' => TRUE,
],
'prefix_male' => [
'label' => E::ts('Prefix option for submitted value "male"'),
'required' => TRUE,
],
'prefix_female' => [
'label' => E::ts('Prefix option for submitted value "female"'),
'required' => TRUE,
],
'prefix_other' => [
'label' => E::ts('Prefix option for submitted value "other"'),
'required' => TRUE,
],
'newsletter_groups' => ['required' => FALSE],
'postinfo_groups' => ['required' => FALSE],
'donation_receipt_groups' => ['required' => FALSE],
'campaign' => ['required' => FALSE],
'campaign_targets' => ['required' => FALSE],
'contribution_source' => ['required' => FALSE],
'custom_field_mapping' => ['required' => FALSE],
'membership_type_id' => ['required' => FALSE],
'membership_type_id_recur' => ['required' => FALSE],
'membership_postprocess_call' => ['required' => FALSE],
'newsletter_double_opt_in' => ['required' => FALSE],
'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. // Add payment methods.
array_combine(
array_keys(static::paymentInstruments()), array_keys(static::paymentInstruments()),
array_map(
function ($value) {
return [
'label' => $value,
'required' => TRUE,
];
},
static::paymentInstruments()
)),
// Add contribution status for all payment methods. // Add contribution status for all payment methods.
array_combine(
array_map(function($attribute) { array_map(function($attribute) {
return $attribute . '_status'; return $attribute . '_status';
}, array_keys(static::paymentInstruments())) }, array_keys(static::paymentInstruments())),
array_map(
function($value) {
return [
'label' => $value . ' - ' . E::ts('Contribution Status'),
'required' => TRUE,
];
},
static::paymentInstruments()
)),
); );
return $asMetadata ? $attributes : array_keys($attributes);
} }
/** /**
* Retrieves a list of supported payment methods. * Retrieves a list of supported payment methods.
* *
* @return array * @return array<string, string>
*/ */
public static function paymentInstruments() { public static function paymentInstruments(): array {
return [ return [
'pi_banktransfer' => E::ts('Bank transfer'), 'pi_banktransfer' => E::ts('Bank transfer'),
'pi_debit_manual' => E::ts('Debit manual'), 'pi_debit_manual' => E::ts('Debit manual'),
@ -281,6 +611,7 @@ class CRM_Twingle_Profile {
'pi_ideal' => E::ts('iDEAL'), 'pi_ideal' => E::ts('iDEAL'),
'pi_post_finance' => E::ts('Postfinance'), 'pi_post_finance' => E::ts('Postfinance'),
'pi_bancontact' => E::ts('Bancontact'), 'pi_bancontact' => E::ts('Bancontact'),
'pi_generic' => E::ts('Generic Payment Method'),
]; ];
} }
@ -294,16 +625,21 @@ class CRM_Twingle_Profile {
*/ */
public static function createDefaultProfile($name = 'default') { public static function createDefaultProfile($name = 'default') {
return new CRM_Twingle_Profile($name, [ return new CRM_Twingle_Profile($name, [
'selector' => '', 'selector' => NULL,
'xcm_profile' => '', 'xcm_profile' => '',
'location_type_id' => CRM_Twingle_Submission::LOCATION_TYPE_ID_WORK, 'location_type_id' => CRM_Twingle_Submission::LOCATION_TYPE_ID_WORK,
'location_type_id_organisation' => CRM_Twingle_Submission::LOCATION_TYPE_ID_WORK, 'location_type_id_organisation' => CRM_Twingle_Submission::LOCATION_TYPE_ID_WORK,
'financial_type_id' => 1, // "Donation" // "Donation"
'financial_type_id_recur' => 1, // "Donation" 'financial_type_id' => 1,
'pi_banktransfer' => 5, // "EFT" // "Donation"
'financial_type_id_recur' => 1,
// "EFT"
'pi_banktransfer' => 5,
'pi_debit_manual' => NULL, 'pi_debit_manual' => NULL,
'pi_debit_automatic' => 2, // Debit // Debit
'pi_creditcard' => 1, // "Credit Card" 'pi_debit_automatic' => 2,
// "Credit Card"
'pi_creditcard' => 1,
'pi_mobilephone_germany' => NULL, 'pi_mobilephone_germany' => NULL,
'pi_paypal' => NULL, 'pi_paypal' => NULL,
'pi_sofortueberweisung' => NULL, 'pi_sofortueberweisung' => NULL,
@ -315,6 +651,7 @@ class CRM_Twingle_Profile {
'pi_ideal' => NULL, 'pi_ideal' => NULL,
'pi_post_finance' => NULL, 'pi_post_finance' => NULL,
'pi_bancontact' => NULL, 'pi_bancontact' => NULL,
'pi_generic' => NULL,
'sepa_creditor_id' => NULL, 'sepa_creditor_id' => NULL,
'gender_male' => 2, 'gender_male' => 2,
'gender_female' => 1, 'gender_female' => 1,
@ -335,8 +672,15 @@ class CRM_Twingle_Profile {
'city', 'city',
'country', 'country',
], ],
'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. // Add contribution status for all payment methods.
// phpcs:ignore Drupal.Formatting.SpaceUnaryOperator.PlusMinus
+ array_fill_keys(array_map(function($attribute) { + array_fill_keys(array_map(function($attribute) {
return $attribute . '_status'; return $attribute . '_status';
}, array_keys(static::paymentInstruments())), CRM_Twingle_Submission::CONTRIBUTION_STATUS_COMPLETED)); }, array_keys(static::paymentInstruments())), CRM_Twingle_Submission::CONTRIBUTION_STATUS_COMPLETED));
@ -347,23 +691,26 @@ class CRM_Twingle_Profile {
* which is responsible for processing the project's data. * which is responsible for processing the project's data.
* Returns the default profile if no match was found. * Returns the default profile if no match was found.
* *
* @param $project_id * @param string $project_id
* *
* @return CRM_Twingle_Profile * @return CRM_Twingle_Profile
* @throws \CRM\Twingle\Exceptions\ProfileException * @throws \Civi\Twingle\Exceptions\ProfileException
* @throws \Civi\Core\Exception\DBQueryException * @throws \Civi\Core\Exception\DBQueryException
*/ */
public static function getProfileForProject($project_id) { public static function getProfileForProject($project_id) {
$profiles = self::getProfiles(); $profiles = self::getProfiles();
$default_profile = NULL;
foreach ($profiles as $profile) { foreach ($profiles as $profile) {
if ($profile->matches($project_id)) { if ($profile->matches($project_id)) {
return $profile; return $profile;
} }
if ($profile->is_default()) {
$default_profile = $profile;
}
} }
// If none matches, use the default profile. // If none matches, use the default profile.
$default_profile = $profiles['default'];
if (!empty($default_profile)) { if (!empty($default_profile)) {
return $default_profile; return $default_profile;
} }
@ -376,36 +723,55 @@ class CRM_Twingle_Profile {
} }
/** /**
* Retrieves the profile with the given name. * Retrieves the profile with the given ID.
* *
* @param string $name * @param int|NULL $id
* *
* @return CRM_Twingle_Profile | NULL * @return CRM_Twingle_Profile | NULL
* @throws \Civi\Core\Exception\DBQueryException
* @throws \Civi\Twingle\Exceptions\ProfileException
*/ */
public static function getProfile($name) { public static function getProfile(int $id = NULL) {
if (!empty($name)) { if (isset($id)) {
$profile_data = CRM_Core_DAO::singleValueQuery("SELECT config FROM civicrm_twingle_profile WHERE name = %1", [ /**
1 => [$name, 'String']]); * @var CRM_Core_DAO $profile_data
if ($profile_data) { */
return new CRM_Twingle_Profile($name, json_decode($profile_data, 1)); $profile_data = CRM_Core_DAO::executeQuery(
'SELECT id, name, config FROM civicrm_twingle_profile WHERE id = %1',
[1 => [$id, 'Integer']]
);
if ($profile_data->fetch()) {
return new CRM_Twingle_Profile(
$profile_data->name,
json_decode($profile_data->config, TRUE),
(int) $profile_data->id
);
} }
} }
return NULL; throw new ProfileException('Profile not found.', ProfileException::ERROR_CODE_PROFILE_NOT_FOUND);
} }
/** /**
* Retrieves the list of all profiles persisted within the current CiviCRM * Retrieves the list of all profiles persisted within the current CiviCRM
* settings, including the default profile. * settings, including the default profile.
* *
* @return array * @return array<int, \CRM_Twingle_Profile>
* profile_name => CRM_Twingle_Profile * An array of profiles with profile IDs as keys and profile objects as values.
* @throws \Civi\Core\Exception\DBQueryException
*/ */
public static function getProfiles() { public static function getProfiles(): array {
// todo: cache? // todo: cache?
$profiles = []; $profiles = [];
$profile_data = CRM_Core_DAO::executeQuery("SELECT name, config FROM civicrm_twingle_profile"); /**
* @var CRM_Core_DAO $profile_data
*/
$profile_data = CRM_Core_DAO::executeQuery('SELECT id, name, config FROM civicrm_twingle_profile');
while ($profile_data->fetch()) { while ($profile_data->fetch()) {
$profiles[$profile_data->name] = new CRM_Twingle_Profile($profile_data->name, json_decode($profile_data->config, 1)); $profiles[(int) $profile_data->id] = new CRM_Twingle_Profile(
$profile_data->name,
json_decode($profile_data->config, TRUE),
(int) $profile_data->id
);
} }
return $profiles; return $profiles;
} }
@ -413,21 +779,32 @@ class CRM_Twingle_Profile {
/** /**
* Get the stats (access_count, last_access) for all twingle profiles * Get the stats (access_count, last_access) for all twingle profiles
* *
* @return CRM_Twingle_Profile[] * @return array<string, array<string, mixed>>
* @throws \Civi\Core\Exception\DBQueryException
*/ */
public static function getProfileStats() { public static function getProfileStats() {
$stats = []; $stats = [];
$profile_data = CRM_Core_DAO::executeQuery("SELECT name, last_access, access_counter FROM civicrm_twingle_profile"); /**
* @var CRM_Core_DAO $profile_data
*/
$profile_data = CRM_Core_DAO::executeQuery(
'SELECT name, last_access, access_counter FROM civicrm_twingle_profile'
);
while ($profile_data->fetch()) { while ($profile_data->fetch()) {
$stats[$profile_data->name] = [ // phpcs:disable Drupal.Arrays.Array.ArrayIndentation
$stats[(string) $profile_data->name] = [
'name' => $profile_data->name, 'name' => $profile_data->name,
'last_access' => $profile_data->last_access, 'last_access' => $profile_data->last_access,
'last_access_txt' => $profile_data->last_access ? date('Y-m-d H:i:s', strtotime($profile_data->last_access)) : E::ts("never"), 'last_access_txt' => $profile_data->last_access
? date('Y-m-d H:i:s', strtotime($profile_data->last_access))
: E::ts('never'),
'access_counter' => $profile_data->access_counter, 'access_counter' => $profile_data->access_counter,
'access_counter_txt' => $profile_data->access_counter ? ((int) $profile_data->access_counter) . 'x' : E::ts("never"), 'access_counter_txt' => $profile_data->access_counter
? ((int) $profile_data->access_counter) . 'x'
: E::ts('never'),
]; ];
// phpcs:enable
} }
return $stats; return $stats;
} }
} }

View file

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

View file

@ -13,7 +13,10 @@
| written permission from the original author(s). | | written permission from the original author(s). |
+-------------------------------------------------------------*/ +-------------------------------------------------------------*/
declare(strict_types = 1);
use CRM_Twingle_ExtensionUtil as E; use CRM_Twingle_ExtensionUtil as E;
use Civi\Twingle\Exceptions\BaseException;
class CRM_Twingle_Tools { class CRM_Twingle_Tools {
@ -27,31 +30,42 @@ class CRM_Twingle_Tools {
* Check if the attempted modification of the recurring contribution is allowed. * Check if the attempted modification of the recurring contribution is allowed.
* If not, an exception will be raised * If not, an exception will be raised
* *
* @param $recurring_contribution_id int * @param int $recurring_contribution_id
* @param $change array * @param array<mixed> $change
* @throws Exception if the change is not allowed * @throws Exception if the change is not allowed
*/ */
public static function checkRecurringContributionChange($recurring_contribution_id, $change) { public static function checkRecurringContributionChange(int $recurring_contribution_id, array $change): void {
// check if a change to the status is planned // check if a change to the status is planned
if (empty($change['contribution_status_id'])) return; if (empty($change['contribution_status_id'])) {
return;
}
// check if the target status is not closed // check if the target status is not closed
if (in_array($change['contribution_status_id'], [2,5])) return; if (in_array($change['contribution_status_id'], [2, 5])) {
return;
}
// check if we're suspended // check if we're suspended
if (self::$protection_suspended) return; if (self::$protection_suspended) {
return;
}
// check if protection is turned on // check if protection is turned on
$protection_on = Civi::settings()->get('twingle_protect_recurring'); $protection_on = Civi::settings()->get('twingle_protect_recurring');
if (empty($protection_on)) return; if (empty($protection_on)) {
return;
}
// load the recurring contribution // load the recurring contribution
$recurring_contribution = civicrm_api3('ContributionRecur', 'getsingle', [ $recurring_contribution = civicrm_api3('ContributionRecur', 'getsingle', [
'return' => 'trxn_id,contribution_status_id,payment_instrument_id,contact_id', 'return' => 'trxn_id,contribution_status_id,payment_instrument_id,contact_id',
'id' => $recurring_contribution_id]); 'id' => $recurring_contribution_id,
]);
// check if this is a SEPA transaction (doesn't concern us) // check if this is a SEPA transaction (doesn't concern us)
if (self::isSDD($recurring_contribution['payment_instrument_id'])) return; if (self::isSDD($recurring_contribution['payment_instrument_id'])) {
return;
}
// see if this recurring contribution is from Twingle // see if this recurring contribution is from Twingle
if (!self::isTwingleRecurringContribution($recurring_contribution_id, $recurring_contribution)) { if (!self::isTwingleRecurringContribution($recurring_contribution_id, $recurring_contribution)) {
@ -59,22 +73,29 @@ class CRM_Twingle_Tools {
} }
// check if it's really a termination (i.e. current status is 2 or 5) // check if it's really a termination (i.e. current status is 2 or 5)
if (!in_array($recurring_contribution['contribution_status_id'], [2,5])) return; if (!in_array($recurring_contribution['contribution_status_id'], [2, 5])) {
return;
}
// this _IS_ on of the cases where we should step in: // this _IS_ on of the cases where we should step in:
CRM_Twingle_Tools::processRecurringContributionTermination($recurring_contribution_id, $recurring_contribution); CRM_Twingle_Tools::processRecurringContributionTermination(
$recurring_contribution_id,
$recurring_contribution
);
} }
/** /**
* @param $recurring_contribution_id int recurring contribution ID to check * @param $recurring_contribution_id int recurring contribution ID to check
* @param $recurring_contribution array recurring contribution data, optional * @param $recurring_contribution array recurring contribution data, optional
* @return bool|null true, false or null if can't be determined * @return bool|null true, false or null if can't be determined
* @throws CiviCRM_API3_Exception * @throws \CRM_Core_Exception
*/ */
public static function isTwingleRecurringContribution($recurring_contribution_id, $recurring_contribution = NULL) { public static function isTwingleRecurringContribution($recurring_contribution_id, $recurring_contribution = NULL) {
// this currently only works with prefixes // this currently only works with prefixes
$prefix = Civi::settings()->get('twingle_prefix'); $prefix = Civi::settings()->get('twingle_prefix');
if (empty($prefix)) return null; if (empty($prefix)) {
return NULL;
}
// load recurring contribution if necessary // load recurring contribution if necessary
if (empty($recurring_contribution['trxn_id'])) { if (empty($recurring_contribution['trxn_id'])) {
@ -89,13 +110,20 @@ class CRM_Twingle_Tools {
/** /**
* Execute the recurring contribution protection * Execute the recurring contribution protection
* *
* @param $recurring_contribution_id int recurring contribution ID * @param int $recurring_contribution_id
* @param $recurring_contribution array recurring contribution fields * Recurring contribution ID.
* @param array<mixed> $recurring_contribution
* Recurring contribution fields.
* @throws Exception could be one of the measures * @throws Exception could be one of the measures
*/ */
public static function processRecurringContributionTermination($recurring_contribution_id, $recurring_contribution) { public static function processRecurringContributionTermination(
int $recurring_contribution_id,
array $recurring_contribution
) {
// check if we're suspended // check if we're suspended
if (self::$protection_suspended) return; if (self::$protection_suspended) {
return;
}
$protection_mode = Civi::settings()->get('twingle_protect_recurring'); $protection_mode = Civi::settings()->get('twingle_protect_recurring');
switch ($protection_mode) { switch ($protection_mode) {
@ -104,24 +132,33 @@ class CRM_Twingle_Tools {
break; break;
case CRM_Twingle_Config::RCUR_PROTECTION_EXCEPTION: case CRM_Twingle_Config::RCUR_PROTECTION_EXCEPTION:
throw new Exception(E::ts("This is a Twingle recurring contribution. It should be terminated through the Twingle interface, otherwise it will still be collected.")); // phpcs:disable Generic.Files.LineLength.TooLong
throw new BaseException(E::ts(
'This is a Twingle recurring contribution. It should be terminated through the Twingle interface, otherwise it will still be collected.'
));
// phpcs:enable
case CRM_Twingle_Config::RCUR_PROTECTION_ACTIVITY: case CRM_Twingle_Config::RCUR_PROTECTION_ACTIVITY:
// create contact source activity // create contact source activity
// first: get the contact ID // first: get the contact ID
if (!empty($recurring_contribution['contact_id'])) { if (!empty($recurring_contribution['contact_id'])) {
$target_id = (int) $recurring_contribution['contact_id']; $target_id = (int) $recurring_contribution['contact_id'];
} else { }
else {
$target_id = (int) civicrm_api3('ContributionRecur', 'getvalue', [ $target_id = (int) civicrm_api3('ContributionRecur', 'getvalue', [
'id' => $recurring_contribution_id, 'id' => $recurring_contribution_id,
'return' => 'contact_id']); 'return' => 'contact_id',
]);
} }
if (!empty($recurring_contribution['trxn_id'])) { if (!empty($recurring_contribution['trxn_id'])) {
$trxn_id = $recurring_contribution['trxn_id']; $trxn_id = $recurring_contribution['trxn_id'];
} else { }
else {
$trxn_id = civicrm_api3('ContributionRecur', 'getvalue', [ $trxn_id = civicrm_api3('ContributionRecur', 'getvalue', [
'id' => $recurring_contribution_id, 'id' => $recurring_contribution_id,
'return' => 'trxn_id']); 'return' => 'trxn_id',
]);
} }
try { try {
@ -132,11 +169,16 @@ class CRM_Twingle_Tools {
'target_id' => $target_id, 'target_id' => $target_id,
'assignee_id' => Civi::settings()->get('twingle_protect_recurring_activity_assignee'), 'assignee_id' => Civi::settings()->get('twingle_protect_recurring_activity_assignee'),
'status_id' => Civi::settings()->get('twingle_protect_recurring_activity_status'), 'status_id' => Civi::settings()->get('twingle_protect_recurring_activity_status'),
'details' => E::ts("Recurring contribution [%1] (Transaction ID '%2') was terminated by a user. You need to end the corresponding record in Twingle as well, or it will still be collected.", // phpcs:disable Generic.Files.LineLength.TooLong
[1 => $recurring_contribution_id, 2 => $trxn_id]), 'details' => E::ts(
"Recurring contribution [%1] (Transaction ID '%2') was terminated by a user. You need to end the corresponding record in Twingle as well, or it will still be collected.",
[1 => $recurring_contribution_id, 2 => $trxn_id]
),
// phpcs:enable
'source_contact_id' => CRM_Core_Session::getLoggedInContactID(), 'source_contact_id' => CRM_Core_Session::getLoggedInContactID(),
]); ]);
} catch (Exception $ex) { }
catch (Exception $ex) {
Civi::log()->warning("TwingleAPI: Couldn't create recurring protection activity: " . $ex->getMessage()); Civi::log()->warning("TwingleAPI: Couldn't create recurring protection activity: " . $ex->getMessage());
} }
break; break;
@ -150,10 +192,10 @@ class CRM_Twingle_Tools {
/** /**
* Check if the given payment instrument is SEPA * Check if the given payment instrument is SEPA
* *
* @param $payment_instrument_id string payment instrument * @param string $payment_instrument_id
* @return boolean * @return boolean
*/ */
public static function isSDD($payment_instrument_id) { public static function isSDD(string $payment_instrument_id) {
static $sepa_payment_instruments = NULL; static $sepa_payment_instruments = NULL;
if ($sepa_payment_instruments === NULL) { if ($sepa_payment_instruments === NULL) {
// init with instrument names // init with instrument names
@ -163,7 +205,7 @@ class CRM_Twingle_Tools {
$lookup = civicrm_api3('OptionValue', 'get', [ $lookup = civicrm_api3('OptionValue', 'get', [
'option_group_id' => 'payment_instrument', 'option_group_id' => 'payment_instrument',
'name' => ['IN' => $sepa_payment_instruments], 'name' => ['IN' => $sepa_payment_instruments],
'return' => 'value' 'return' => 'value',
]); ]);
foreach ($lookup['values'] as $payment_instrument) { foreach ($lookup['values'] as $payment_instrument) {
$sepa_payment_instruments[] = $payment_instrument['value']; $sepa_payment_instruments[] = $payment_instrument['value'];
@ -175,11 +217,10 @@ class CRM_Twingle_Tools {
/** /**
* Get a CiviSEPA mandate for the given contribution ID * Get a CiviSEPA mandate for the given contribution ID
* *
* @param $contribution_id integer contribution ID *or* recurring contribution ID * @param int $contribution_id contribution ID *or* recurring contribution ID
* @return integer mandate ID or null * @return array<string, mixed>|null mandate or null
*/ */
public static function getMandateFor($contribution_id) { public static function getMandateFor(int $contribution_id): ?array {
$contribution_id = (int) $contribution_id;
if ($contribution_id) { if ($contribution_id) {
try { try {
// try recurring mandate // try recurring mandate
@ -202,10 +243,12 @@ class CRM_Twingle_Tools {
if ($ooff_mandate['count'] == 1) { if ($ooff_mandate['count'] == 1) {
return reset($ooff_mandate['values']); return reset($ooff_mandate['values']);
} }
} catch (Exception $ex) { }
catch (Exception $ex) {
Civi::log()->warning("CRM_Twingle_Tools::getMandate failed for [{$contribution_id}]: " . $ex->getMessage()); Civi::log()->warning("CRM_Twingle_Tools::getMandate failed for [{$contribution_id}]: " . $ex->getMessage());
} }
} }
return NULL; return NULL;
} }
} }

View file

@ -1,15 +1,31 @@
<?php <?php
/*------------------------------------------------------------+
| SYSTOPIA Twingle Integration |
| Copyright (C) 2019 SYSTOPIA |
| Author: J. Schuppe (schuppe@systopia.de) |
+-------------------------------------------------------------+
| This program is released as free software under the |
| Affero GPL license. You can redistribute it and/or |
| modify it under the terms of this license which you |
| can read by viewing the included agpl.txt or online |
| at www.gnu.org/licenses/agpl.html. Removal of this |
| copyright header is strictly prohibited without |
| written permission from the original author(s). |
+-------------------------------------------------------------*/
declare(strict_types = 1);
use CRM_Twingle_ExtensionUtil as E; use CRM_Twingle_ExtensionUtil as E;
/** /**
* Collection of upgrade steps. * Collection of upgrade steps.
*/ */
class CRM_Twingle_Upgrader extends CRM_Twingle_Upgrader_Base { class CRM_Twingle_Upgrader extends CRM_Extension_Upgrader_Base {
/** /**
* Installer script * Installer script
*/ */
public function install() { public function install(): void {
// create a DB table for the twingle profiles // create a DB table for the twingle profiles
$this->executeSqlFile('sql/civicrm_twingle_profile.sql'); $this->executeSqlFile('sql/civicrm_twingle_profile.sql');
@ -20,14 +36,14 @@ class CRM_Twingle_Upgrader extends CRM_Twingle_Upgrader_Base {
/** /**
* Example: Run an external SQL script when the module is uninstalled. * Example: Run an external SQL script when the module is uninstalled.
* *
public function uninstall() { * public function uninstall() {
$this->executeSqlFile('sql/myuninstall.sql'); * $this->executeSqlFile('sql/myuninstall.sql');
} * }
*
/** * /**
* Copy financial_type_id setting to new setting financial_type_id_recur. * Copy financial_type_id setting to new setting financial_type_id_recur.
*/ */
public function upgrade_4000() { public function upgrade_4000(): bool {
$this->ctx->log->info('Applying update 4000: Copying Financial type to new setting Financial type (recurring).'); $this->ctx->log->info('Applying update 4000: Copying Financial type to new setting Financial type (recurring).');
foreach (CRM_Twingle_Profile::getProfiles() as $profile) { foreach (CRM_Twingle_Profile::getProfiles() as $profile) {
$profile->setAttribute('financial_type_id_recur', $profile->getAttribute('financial_type_id')); $profile->setAttribute('financial_type_id_recur', $profile->getAttribute('financial_type_id'));
@ -41,7 +57,7 @@ class CRM_Twingle_Upgrader extends CRM_Twingle_Upgrader_Base {
* *
* @link https://civicrm.org/advisory/civi-sa-2019-21-poi-saved-search-and-report-instance-apis * @link https://civicrm.org/advisory/civi-sa-2019-21-poi-saved-search-and-report-instance-apis
*/ */
public function upgrade_5011() { public function upgrade_5011(): bool {
// Do not use CRM_Core_BAO::getItem() or Civi::settings()->get(). // Do not use CRM_Core_BAO::getItem() or Civi::settings()->get().
// Extract and unserialize directly from the database. // Extract and unserialize directly from the database.
$twingle_profiles_query = CRM_Core_DAO::executeQuery(" $twingle_profiles_query = CRM_Core_DAO::executeQuery("
@ -62,26 +78,74 @@ class CRM_Twingle_Upgrader extends CRM_Twingle_Upgrader_Base {
* @return TRUE on success * @return TRUE on success
* @throws Exception * @throws Exception
*/ */
public function upgrade_5140() { public function upgrade_5140(): bool {
$this->ctx->log->info('Converting twingle profiles.'); $this->ctx->log->info('Converting twingle profiles.');
// create a DB table for the twingle profiles // create a DB table for the twingle profiles
$this->executeSqlFile('sql/civicrm_twingle_profile.sql'); $this->executeSqlFile('sql/civicrm_twingle_profile.sql');
// migrate the current profiles // migrate the current profiles
if ($profiles_data = Civi::settings()->get('twingle_profiles')) { if (is_array($profiles_data = Civi::settings()->get('twingle_profiles'))) {
foreach ($profiles_data as $profile_name => $profile_data) { foreach ($profiles_data as $profile_name => $profile_data) {
$profile = new CRM_Twingle_Profile($profile_name, $profile_data); $profile = new CRM_Twingle_Profile($profile_name, $profile_data);
$data = json_encode($profile->getData()); $data = json_encode($profile->getData());
CRM_Core_DAO::executeQuery( CRM_Core_DAO::executeQuery(<<<SQL
"INSERT IGNORE INTO civicrm_twingle_profile(name,config,last_access,access_counter) VALUES (%1, %2, NOW(), 0)", INSERT IGNORE INTO civicrm_twingle_profile(name,config,last_access,access_counter) VALUES (%1, %2, NOW(), 0)
SQL,
[ [
1 => [$profile_name, 'String'], 1 => [$profile_name, 'String'],
2 => [$data, 'String'] 2 => [$data, 'String'],
]); ]);
} }
} }
return TRUE; return TRUE;
} }
/**
* Upgrade to 1.5.0
*
* - Activate mapping of `purpose` and `user_extra_field` to notes in each existing profile to
* maintain default behavior after making the fields optional.
*
* @return bool
* @throws \Civi\Core\Exception\DBQueryException
* @throws \Civi\Twingle\Exceptions\ProfileException
*/
public function upgrade_5150(): bool {
$this->ctx->log->info('Activate mapping of `purpose` and `user_extra_field` to notes in each existing profile.');
foreach (CRM_Twingle_Profile::getProfiles() as $profile) {
$profile_changed = FALSE;
/** @phpstan-var array<string> $contribution_notes */
$contribution_notes = $profile->getAttribute('map_as_contribution_notes', []);
/** @phpstan-var array<string> $contact_notes */
$contact_notes = $profile->getAttribute('map_as_contact_notes', []);
if (!in_array('purpose', $contribution_notes, TRUE)) {
$profile->setAttribute('map_as_contribution_notes', array_merge($contribution_notes, ['purpose']));
$profile_changed = TRUE;
}
if (!in_array('user_extrafield', $contact_notes, TRUE)) {
$profile->setAttribute('map_as_contact_notes', array_merge($contact_notes, ['user_extrafield']));
$profile_changed = TRUE;
}
if ($profile_changed) {
$profile->saveProfile();
}
}
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;
}
} }

View file

@ -1,376 +0,0 @@
<?php
// AUTO-GENERATED FILE -- Civix may overwrite any changes made to this file
use CRM_Twingle_ExtensionUtil as E;
/**
* Base class which provides helpers to execute upgrade logic
*/
class CRM_Twingle_Upgrader_Base {
/**
* @var varies, subclass of this
*/
static $instance;
/**
* @var CRM_Queue_TaskContext
*/
protected $ctx;
/**
* @var string, eg 'com.example.myextension'
*/
protected $extensionName;
/**
* @var string, full path to the extension's source tree
*/
protected $extensionDir;
/**
* @var array(revisionNumber) sorted numerically
*/
private $revisions;
/**
* @var boolean
* Flag to clean up extension revision data in civicrm_setting
*/
private $revisionStorageIsDeprecated = FALSE;
/**
* Obtain a reference to the active upgrade handler.
*/
static public function instance() {
if (!self::$instance) {
// FIXME auto-generate
self::$instance = new CRM_Twingle_Upgrader(
'de.systopia.twingle',
realpath(__DIR__ . '/../../../')
);
}
return self::$instance;
}
/**
* Adapter that lets you add normal (non-static) member functions to the queue.
*
* Note: Each upgrader instance should only be associated with one
* task-context; otherwise, this will be non-reentrant.
*
* @code
* CRM_Twingle_Upgrader_Base::_queueAdapter($ctx, 'methodName', 'arg1', 'arg2');
* @endcode
*/
static public function _queueAdapter() {
$instance = self::instance();
$args = func_get_args();
$instance->ctx = array_shift($args);
$instance->queue = $instance->ctx->queue;
$method = array_shift($args);
return call_user_func_array(array($instance, $method), $args);
}
public function __construct($extensionName, $extensionDir) {
$this->extensionName = $extensionName;
$this->extensionDir = $extensionDir;
}
// ******** Task helpers ********
/**
* Run a CustomData file.
*
* @param string $relativePath the CustomData XML file path (relative to this extension's dir)
* @return bool
*/
public function executeCustomDataFile($relativePath) {
$xml_file = $this->extensionDir . '/' . $relativePath;
return $this->executeCustomDataFileByAbsPath($xml_file);
}
/**
* Run a CustomData file
*
* @param string $xml_file the CustomData XML file path (absolute path)
*
* @return bool
*/
protected static function executeCustomDataFileByAbsPath($xml_file) {
$import = new CRM_Utils_Migrate_Import();
$import->run($xml_file);
return TRUE;
}
/**
* Run a SQL file.
*
* @param string $relativePath the SQL file path (relative to this extension's dir)
*
* @return bool
*/
public function executeSqlFile($relativePath) {
CRM_Utils_File::sourceSQLFile(
CIVICRM_DSN,
$this->extensionDir . DIRECTORY_SEPARATOR . $relativePath
);
return TRUE;
}
/**
* @param string $tplFile
* The SQL file path (relative to this extension's dir).
* Ex: "sql/mydata.mysql.tpl".
* @return bool
*/
public function executeSqlTemplate($tplFile) {
// Assign multilingual variable to Smarty.
$upgrade = new CRM_Upgrade_Form();
$tplFile = CRM_Utils_File::isAbsolute($tplFile) ? $tplFile : $this->extensionDir . DIRECTORY_SEPARATOR . $tplFile;
$smarty = CRM_Core_Smarty::singleton();
$smarty->assign('domainID', CRM_Core_Config::domainID());
CRM_Utils_File::sourceSQLFile(
CIVICRM_DSN, $smarty->fetch($tplFile), NULL, TRUE
);
return TRUE;
}
/**
* Run one SQL query.
*
* This is just a wrapper for CRM_Core_DAO::executeSql, but it
* provides syntatic sugar for queueing several tasks that
* run different queries
*/
public function executeSql($query, $params = array()) {
// FIXME verify that we raise an exception on error
CRM_Core_DAO::executeQuery($query, $params);
return TRUE;
}
/**
* Syntatic sugar for enqueuing a task which calls a function in this class.
*
* The task is weighted so that it is processed
* as part of the currently-pending revision.
*
* After passing the $funcName, you can also pass parameters that will go to
* the function. Note that all params must be serializable.
*/
public function addTask($title) {
$args = func_get_args();
$title = array_shift($args);
$task = new CRM_Queue_Task(
array(get_class($this), '_queueAdapter'),
$args,
$title
);
return $this->queue->createItem($task, array('weight' => -1));
}
// ******** Revision-tracking helpers ********
/**
* Determine if there are any pending revisions.
*
* @return bool
*/
public function hasPendingRevisions() {
$revisions = $this->getRevisions();
$currentRevision = $this->getCurrentRevision();
if (empty($revisions)) {
return FALSE;
}
if (empty($currentRevision)) {
return TRUE;
}
return ($currentRevision < max($revisions));
}
/**
* Add any pending revisions to the queue.
*/
public function enqueuePendingRevisions(CRM_Queue_Queue $queue) {
$this->queue = $queue;
$currentRevision = $this->getCurrentRevision();
foreach ($this->getRevisions() as $revision) {
if ($revision > $currentRevision) {
$title = ts('Upgrade %1 to revision %2', array(
1 => $this->extensionName,
2 => $revision,
));
// note: don't use addTask() because it sets weight=-1
$task = new CRM_Queue_Task(
array(get_class($this), '_queueAdapter'),
array('upgrade_' . $revision),
$title
);
$this->queue->createItem($task);
$task = new CRM_Queue_Task(
array(get_class($this), '_queueAdapter'),
array('setCurrentRevision', $revision),
$title
);
$this->queue->createItem($task);
}
}
}
/**
* Get a list of revisions.
*
* @return array(revisionNumbers) sorted numerically
*/
public function getRevisions() {
if (!is_array($this->revisions)) {
$this->revisions = array();
$clazz = new ReflectionClass(get_class($this));
$methods = $clazz->getMethods();
foreach ($methods as $method) {
if (preg_match('/^upgrade_(.*)/', $method->name, $matches)) {
$this->revisions[] = $matches[1];
}
}
sort($this->revisions, SORT_NUMERIC);
}
return $this->revisions;
}
public function getCurrentRevision() {
$revision = CRM_Core_BAO_Extension::getSchemaVersion($this->extensionName);
if (!$revision) {
$revision = $this->getCurrentRevisionDeprecated();
}
return $revision;
}
private function getCurrentRevisionDeprecated() {
$key = $this->extensionName . ':version';
if ($revision = CRM_Core_BAO_Setting::getItem('Extension', $key)) {
$this->revisionStorageIsDeprecated = TRUE;
}
return $revision;
}
public function setCurrentRevision($revision) {
CRM_Core_BAO_Extension::setSchemaVersion($this->extensionName, $revision);
// clean up legacy schema version store (CRM-19252)
$this->deleteDeprecatedRevision();
return TRUE;
}
private function deleteDeprecatedRevision() {
if ($this->revisionStorageIsDeprecated) {
$setting = new CRM_Core_BAO_Setting();
$setting->name = $this->extensionName . ':version';
$setting->delete();
Civi::log()->debug("Migrated extension schema revision ID for {$this->extensionName} from civicrm_setting (deprecated) to civicrm_extension.\n");
}
}
// ******** Hook delegates ********
/**
* @see https://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_install
*/
public function onInstall() {
$files = glob($this->extensionDir . '/sql/*_install.sql');
if (is_array($files)) {
foreach ($files as $file) {
CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file);
}
}
$files = glob($this->extensionDir . '/sql/*_install.mysql.tpl');
if (is_array($files)) {
foreach ($files as $file) {
$this->executeSqlTemplate($file);
}
}
$files = glob($this->extensionDir . '/xml/*_install.xml');
if (is_array($files)) {
foreach ($files as $file) {
$this->executeCustomDataFileByAbsPath($file);
}
}
if (is_callable(array($this, 'install'))) {
$this->install();
}
}
/**
* @see https://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_postInstall
*/
public function onPostInstall() {
$revisions = $this->getRevisions();
if (!empty($revisions)) {
$this->setCurrentRevision(max($revisions));
}
if (is_callable(array($this, 'postInstall'))) {
$this->postInstall();
}
}
/**
* @see https://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_uninstall
*/
public function onUninstall() {
$files = glob($this->extensionDir . '/sql/*_uninstall.mysql.tpl');
if (is_array($files)) {
foreach ($files as $file) {
$this->executeSqlTemplate($file);
}
}
if (is_callable(array($this, 'uninstall'))) {
$this->uninstall();
}
$files = glob($this->extensionDir . '/sql/*_uninstall.sql');
if (is_array($files)) {
foreach ($files as $file) {
CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file);
}
}
}
/**
* @see https://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_enable
*/
public function onEnable() {
// stub for possible future use
if (is_callable(array($this, 'enable'))) {
$this->enable();
}
}
/**
* @see https://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_disable
*/
public function onDisable() {
// stub for possible future use
if (is_callable(array($this, 'disable'))) {
$this->disable();
}
}
public function onUpgrade($op, CRM_Queue_Queue $queue = NULL) {
switch ($op) {
case 'check':
return array($this->hasPendingRevisions());
case 'enqueue':
return $this->enqueuePendingRevisions($queue);
default:
}
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace Civi\Api4;
/**
* TwingleShop entity.
*
* @package Civi\Api4
*/
class TwingleProduct extends Generic\DAOEntity {
}

12
Civi/Api4/TwingleShop.php Normal file
View file

@ -0,0 +1,12 @@
<?php
namespace Civi\Api4;
/**
* TwingleShop entity.
*
* @package Civi\Api4
*/
class TwingleShop extends Generic\DAOEntity {
}

View file

@ -0,0 +1,65 @@
<?php
/*
* Copyright (C) 2024 SYSTOPIA GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation in version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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 int|string
*/
protected $code;
protected string $log_message;
/**
* BaseException Constructor
* @param string $message
* Error message
* @param string $error_code
* A meaningful error code
*/
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;
}
/**
* 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;
}
}

View file

@ -0,0 +1,35 @@
<?php
/*
* Copyright (C) 2024 SYSTOPIA GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation in version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types = 1);
namespace Civi\Twingle\Exceptions;
/**
* A simple custom exception that indicates a problem within the
* CRM_Twingle_Profile class
*/
class ProfileException extends BaseException {
public const ERROR_CODE_PROFILE_NOT_FOUND = 'profile_not_found';
public const ERROR_CODE_DEFAULT_PROFILE_NOT_FOUND = 'default_profile_not_found';
public const ERROR_CODE_COULD_NOT_SAVE_PROFILE = 'could_not_save_profile';
public const ERROR_CODE_COULD_NOT_RESET_PROFILE = 'could_not_reset_profile';
public const ERROR_CODE_COULD_NOT_DELETE_PROFILE = 'could_not_delete_profile';
public const ERROR_CODE_UNKNOWN_PROFILE_ATTRIBUTE = 'unknown_profile_attribute';
}

View file

@ -0,0 +1,54 @@
<?php
/*
* Copyright (C) 2024 SYSTOPIA GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation in version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types = 1);
namespace Civi\Twingle\Exceptions;
/**
* A simple custom error indicating a problem with the validation of the
* CRM_Twingle_Profile
*/
class ProfileValidationError extends BaseException {
private string $affected_field_name;
public const ERROR_CODE_PROFILE_VALIDATION_FAILED = 'profile_validation_failed';
public const ERROR_CODE_PROFILE_VALIDATION_WARNING = 'profile_validation_warning';
/**
* ProfileValidationError Constructor
* @param string $affected_field_name
* The name of the profile field which caused the exception
* @param string $message
* Error message
* @param string $error_code
* A meaningful error code
*/
public function __construct(string $affected_field_name, string $message = '', string $error_code = '') {
parent::__construct($message, $error_code);
$this->affected_field_name = $affected_field_name;
}
/**
* Returns the name of the profile field that caused the exception.
* @return string
*/
public function getAffectedFieldName() {
return $this->affected_field_name;
}
}

View file

@ -0,0 +1,269 @@
<?php /** @noinspection ALL */
namespace Civi\Twingle\Shop;
use CRM_TwingleCampaign_ExtensionUtil as E;
use Civi\Twingle\Shop\Exceptions\ApiCallError;
/**
* This class communicates with the Twingle API via cURL.
* To keep the overhead of initialization low, this class is implemented as
* a singleton. Please use CRM_Twingle_TwingleApiCall::singleton() to retrieve
* an instance.
*/
class ApiCall {
/**
* Twingle API url
*/
const BASE_URL = '.twingle.de/api';
/**
* The transfer protocol
*/
const PROTOCOL = 'https://';
/**
* The singleton object
* @var \Civi\Twingle\Shop\ApiCall $singleton
*/
public static ApiCall $singleton;
/**
* Your Twingle API token.
* You can request an API token from Twingle support: <hilfe@twingle.de>
* @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);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace Civi\Twingle\Shop\Exceptions;
use Civi\Twingle\Exceptions\BaseException as BaseException;
/**
* A simple custom exception that indicates a problem within the
* Civi\Twingle\Shop\ApiCall class
*/
class ApiCallError extends BaseException {
public const ERROR_CODE_API_TOKEN_MISSING = "api_token_missing";
public const ERROR_CODE_CONNECTION_FAILED = "connection_failed";
public const ERROR_CODE_NOT_CONNECTED = "not_connected";
public const ERROR_CODE_GET_REQUEST_FAILED = "get_request_failed";
public const ERROR_CODE_404 = "404";
public const ERROR_CODE_500 = "500";
}

View file

@ -0,0 +1,14 @@
<?php
namespace Civi\Twingle\Shop\Exceptions;
use Civi\Twingle\Exceptions\BaseException as BaseException;
/**
* A simple custom exception that indicates a problem within the Line Items
*/
class LineItemException extends BaseException {
public const ERROR_CODE_CONTRIBUTION_NOT_FOUND = "contribution_not_found";
}

View file

@ -0,0 +1,26 @@
<?php
namespace Civi\Twingle\Shop\Exceptions;
use Civi\Twingle\Exceptions\BaseException as BaseException;
/**
* A simple custom exception that indicates a problem within the
* CRM_Twingle_Product class
*/
class ProductException extends BaseException {
public const ERROR_CODE_ATTRIBUTE_WRONG_DATA_TYPE = 'attribute_wrong_data_type';
public const ERROR_CODE_PRICE_FIELD_ALREADY_EXISTS = 'price_field_already_exists';
public const ERROR_CODE_PRICE_FIELD_VALUE_NOT_FOUND = 'price_field_value_not_found';
public const ERROR_CODE_PRICE_FIELD_NOT_FOUND = 'price_field_not_found';
public const ERROR_CODE_PRICE_SET_NOT_FOUND = 'price_set_not_found';
public const ERROR_CODE_COULD_NOT_CREATE_PRICE_FIELD = 'price_field_creation_failed';
public const ERROR_CODE_COULD_NOT_CREATE_PRICE_FIELD_VALUE = 'price_field_value_creation_failed';
public const ERROR_CODE_COULD_NOT_DELETE_PRICE_FIELD = 'price_field_deletion_failed';
public const ERROR_CODE_COULD_NOT_DELETE_PRICE_FIELD_VALUE = 'price_field_value_deletion_failed';
public const ERROR_CODE_PRICE_FIELD_STILL_EXISTS = 'price_field_still_exists';
public const ERROR_CODE_COULD_NOT_CREATE_PRODUCT = 'product_creation_failed';
public const ERROR_CODE_COULD_NOT_GET_PRODUCTS = 'product_retrieval_failed';
public const ERROR_CODE_COULD_NOT_DELETE_PRICE_SET = 'price_set_deletion_failed';
}

View file

@ -0,0 +1,22 @@
<?php
namespace Civi\Twingle\Shop\Exceptions;
use Civi\Twingle\Exceptions\BaseException as BaseException;
/**
* A simple custom exception that indicates a problem within the
* CRM_Twingle_Shop class
*/
class ShopException extends BaseException {
public const ERROR_CODE_NOT_A_SHOP = "not_a_shop";
public const ERROR_CODE_COULD_NOT_GET_PROJECTS = "could_not_get_projects";
public const ERROR_CODE_COULD_NOT_FIND_SHOP_IN_DB = "could_not_find_shop_in_db";
public const ERROR_CODE_PRICE_SET_ALREADY_EXISTS = "price_set_already_exists";
public const ERROR_CODE_COULD_NOT_CREATE_PRICE_SET = "price_set_creation_failed";
public const ERROR_CODE_PRICE_SET_NOT_FOUND = "price_set_not_found";
public const ERROR_CODE_COULD_NOT_DELETE_PRICE_SET = "price_set_deletion_failed";
public const ERROR_CODE_ATTRIBUTE_WRONG_DATA_TYPE = "attribute_wrong_data_type";
}

View file

@ -0,0 +1,154 @@
<?php
namespace Civi\Twingle\Shop\Utils;
/**
* Filter for allowed attributes.
*
* @param array $data
* Data to filter.
* @param array $allowed_attributes
* Allowed attributes.
* @param array $can_be_zero
* Attributes that can be zero.
*
* @return void
*/
function filter_attributes(array &$data, array $allowed_attributes, array $can_be_zero = Null): void {
$can_be_zero = $can_be_zero ?? [];
// Remove empty values if not of type int
$data = array_filter(
$data,
function($value, $key) use ($can_be_zero) {
return !empty($value) || in_array($key, $can_be_zero);
},
ARRAY_FILTER_USE_BOTH
);
// Filter for allowed attributes
$data = array_intersect_key(
$data,
$allowed_attributes
);
}
/**
* Convert string values to int.
*
* @param array $data
* @param array $str_to_int_conversion
*
* @return void
* @throws \Exception
*/
function convert_str_to_int(array &$data, array $str_to_int_conversion): void {
// Convert string to int
foreach ($str_to_int_conversion as $attribute) {
if (isset($data[$attribute]) && $data[$attribute] !== '') {
try {
$data[$attribute] = (int) $data[$attribute];
} catch (\Exception $e) {
throw new \Exception(
"Could not convert attribute '$attribute' to int."
);
}
}
}
}
/**
* Convert int values to bool.
*
* @param array $data
* @param array $int_to_bool_conversion
*
* @return void
* @throws \Exception
*/
function convert_int_to_bool(array &$data, array $int_to_bool_conversion): void {
// Convert int to bool
foreach ($int_to_bool_conversion as $attribute) {
if (isset($data[$attribute])) {
try {
$data[$attribute] = (bool) $data[$attribute];
}
catch (\Exception $e) {
throw new \Exception(
"Could not convert attribute '$attribute' to bool."
);
}
}
}
}
/**
* Convert string values to date.
*
* @param array $data
* @param array $str_to_date_conversion
*
* @return void
* @throws \Exception
*/
function convert_str_to_date(array &$data, array $str_to_date_conversion): void {
// Convert string to date
foreach ($str_to_date_conversion as $attribute) {
if (isset($data[$attribute]) && is_string($data[$attribute])) {
try {
$data[$attribute] = strtotime($data[$attribute]);
}
catch (\Exception $e) {
throw new \Exception(
"Could not convert attribute '$attribute' to date."
);
}
}
}
}
/**
* Convert an empty string to null.
*
* @param array $data
* @param array $empty_string_to_null
*
* @return void
*/
function convert_empty_string_to_null(array &$data, array $empty_string_to_null): void {
foreach ($empty_string_to_null as $attribute) {
if (isset($data[$attribute]) && $data[$attribute] === '') {
$data[$attribute] = NULL;
}
}
}
/**
* Validate data types. Throws an exception if data type is not valid.
*
* @param array $data
* @param array $allowed_attributes
*
* @return void
* @throws \Exception
*/
function validate_data_types(array &$data, array $allowed_attributes): void {
foreach ($data as $key => $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."
);
}
}
}

View file

@ -13,54 +13,56 @@
| written permission from the original author(s). | | written permission from the original author(s). |
+-------------------------------------------------------------*/ +-------------------------------------------------------------*/
declare(strict_types = 1);
use CRM_Twingle_ExtensionUtil as E; use CRM_Twingle_ExtensionUtil as E;
/** /**
* TwingleDonation.Cancel API specification (optional) * TwingleDonation.Cancel API specification (optional)
* This is used for documentation and validation. * This is used for documentation and validation.
* *
* @param array $params description of fields supported by this API call * @param array<string, array<string, mixed>> $params description of fields supported by this API call
* *
* @return void * @return void
* *
* @see http://wiki.civicrm.org/confluence/display/CRMDOC/API+Architecture+Standards * @see http://wiki.civicrm.org/confluence/display/CRMDOC/API+Architecture+Standards
*/ */
function _civicrm_api3_twingle_donation_Cancel_spec(&$params) { function _civicrm_api3_twingle_donation_Cancel_spec(&$params) {
$params['project_id'] = array( $params['project_id'] = [
'name' => 'project_id', 'name' => 'project_id',
'title' => E::ts('Project ID'), 'title' => E::ts('Project ID'),
'type' => CRM_Utils_Type::T_STRING, 'type' => CRM_Utils_Type::T_STRING,
'api.required' => 1, 'api.required' => 1,
'description' => E::ts('The Twingle project ID.'), 'description' => E::ts('The Twingle project ID.'),
); ];
$params['trx_id'] = array( $params['trx_id'] = [
'name' => 'trx_id', 'name' => 'trx_id',
'title' => E::ts('Transaction ID'), 'title' => E::ts('Transaction ID'),
'type' => CRM_Utils_Type::T_STRING, 'type' => CRM_Utils_Type::T_STRING,
'api.required' => 1, 'api.required' => 1,
'description' => E::ts('The unique transaction ID of the donation'), 'description' => E::ts('The unique transaction ID of the donation'),
); ];
$params['cancelled_at'] = array( $params['cancelled_at'] = [
'name' => 'cancelled_at', 'name' => 'cancelled_at',
'title' => E::ts('Cancelled at'), 'title' => E::ts('Cancelled at'),
'type' => CRM_Utils_Type::T_INT, 'type' => CRM_Utils_Type::T_INT,
'api.required' => 1, 'api.required' => 1,
'description' => E::ts('The date when the donation was cancelled, format: YmdHis.'), 'description' => E::ts('The date when the donation was cancelled, format: YmdHis.'),
); ];
$params['cancel_reason'] = array( $params['cancel_reason'] = [
'name' => 'cancel_reason', 'name' => 'cancel_reason',
'title' => E::ts('Cancel reason'), 'title' => E::ts('Cancel reason'),
'type' => CRM_Utils_Type::T_STRING, 'type' => CRM_Utils_Type::T_STRING,
'api.required' => 1, 'api.required' => 1,
'description' => E::ts('The reason for the donation being cancelled.'), 'description' => E::ts('The reason for the donation being cancelled.'),
); ];
} }
/** /**
* TwingleDonation.Cancel API * TwingleDonation.Cancel API
* *
* @param array $params * @param array<string, mixed> $params
* @return array API result descriptor * @return array<string, mixed> API result descriptor
* @see civicrm_api3_create_success * @see civicrm_api3_create_success
* @see civicrm_api3_create_error * @see civicrm_api3_create_error
*/ */
@ -73,7 +75,7 @@ function civicrm_api3_twingle_donation_Cancel($params) {
try { try {
// Validate date for parameter "cancelled_at". // Validate date for parameter "cancelled_at".
if (!DateTime::createFromFormat('YmdHis', $params['cancelled_at'])) { if (!DateTime::createFromFormat('YmdHis', $params['cancelled_at'])) {
throw new CiviCRM_API3_Exception( throw new CRM_Core_Exception(
E::ts('Invalid date for parameter "cancelled_at".'), E::ts('Invalid date for parameter "cancelled_at".'),
'invalid_format' 'invalid_format'
); );
@ -82,15 +84,15 @@ function civicrm_api3_twingle_donation_Cancel($params) {
// Retrieve (recurring) contribution. // Retrieve (recurring) contribution.
$default_profile = CRM_Twingle_Profile::getProfile('default'); $default_profile = CRM_Twingle_Profile::getProfile('default');
try { try {
$contribution = civicrm_api3('Contribution', 'getsingle', array( $contribution = civicrm_api3('Contribution', 'getsingle', [
'trxn_id' => $default_profile->getTransactionID($params['trx_id']), 'trxn_id' => $default_profile->getTransactionID($params['trx_id']),
)); ]);
$contribution_type = 'Contribution'; $contribution_type = 'Contribution';
} }
catch (CiviCRM_API3_Exception $exception) { catch (CRM_Core_Exception $exception) {
$contribution = civicrm_api3('ContributionRecur', 'getsingle', array( $contribution = civicrm_api3('ContributionRecur', 'getsingle', [
'trxn_id' => $default_profile->getTransactionID($params['trx_id']), 'trxn_id' => $default_profile->getTransactionID($params['trx_id']),
)); ]);
$contribution_type = 'ContributionRecur'; $contribution_type = 'ContributionRecur';
} }
@ -99,10 +101,10 @@ function civicrm_api3_twingle_donation_Cancel($params) {
&& CRM_Twingle_Tools::isSDD($contribution['payment_instrument_id']) && CRM_Twingle_Tools::isSDD($contribution['payment_instrument_id'])
) { ) {
// End SEPA mandate if applicable. // End SEPA mandate if applicable.
$mandate = CRM_Twingle_Tools::getMandateFor($contribution['id']); $mandate = CRM_Twingle_Tools::getMandateFor((int) $contribution['id']);
if (!$mandate) { if (!$mandate) {
throw new CiviCRM_API3_Exception( throw new CRM_Core_Exception(
E::ts("SEPA Mandate for contribution [%1 not found.", [1 => $contribution['id']]), E::ts('SEPA Mandate for contribution [%1 not found.', [1 => $contribution['id']]),
'api_error' 'api_error'
); );
} }
@ -110,12 +112,13 @@ function civicrm_api3_twingle_donation_Cancel($params) {
// Mandates can not be terminated in the past. // Mandates can not be terminated in the past.
$end_date = date_create_from_format('YmdHis', $params['cancelled_at']); $end_date = date_create_from_format('YmdHis', $params['cancelled_at']);
if ($end_date) { if (FALSE !== $end_date) {
// Mandates can not be terminated in the past: // Mandates can not be terminated in the past:
$end_date = date('Ymd', max( $end_date = date('Ymd', max(
time(), time(),
$end_date->getTimestamp())); $end_date->getTimestamp()));
} else { }
else {
// end date couldn't be parsed, use 'now' // end date couldn't be parsed, use 'now'
$end_date = date('Ymd'); $end_date = date('Ymd');
} }
@ -125,26 +128,26 @@ function civicrm_api3_twingle_donation_Cancel($params) {
$end_date, $end_date,
$params['cancel_reason'] $params['cancel_reason']
)) { )) {
throw new CiviCRM_API3_Exception( throw new CRM_Core_Exception(
E::ts('Could not terminate SEPA mandate'), E::ts('Could not terminate SEPA mandate'),
'api_error' 'api_error'
); );
} }
// Retrieve updated contribution for return value. // Retrieve updated contribution for return value.
$contribution = civicrm_api3($contribution_type, 'getsingle', array( $contribution = civicrm_api3($contribution_type, 'getsingle', [
'id' => $contribution['id'], 'id' => $contribution['id'],
)); ]);
} }
else { else {
// regular contribution // regular contribution
CRM_Twingle_Tools::$protection_suspended = TRUE; CRM_Twingle_Tools::$protection_suspended = TRUE;
$contribution = civicrm_api3($contribution_type, 'create', array( $contribution = civicrm_api3($contribution_type, 'create', [
'id' => $contribution['id'], 'id' => $contribution['id'],
'cancel_date' => $params['cancelled_at'], 'cancel_date' => $params['cancelled_at'],
'contribution_status_id' => 'Cancelled', 'contribution_status_id' => 'Cancelled',
'cancel_reason' => $params['cancel_reason'], 'cancel_reason' => $params['cancel_reason'],
)); ]);
CRM_Twingle_Tools::$protection_suspended = FALSE; CRM_Twingle_Tools::$protection_suspended = FALSE;
} }

View file

@ -13,47 +13,49 @@
| written permission from the original author(s). | | written permission from the original author(s). |
+-------------------------------------------------------------*/ +-------------------------------------------------------------*/
declare(strict_types = 1);
use CRM_Twingle_ExtensionUtil as E; use CRM_Twingle_ExtensionUtil as E;
/** /**
* TwingleDonation.Endrecurring API specification (optional) * TwingleDonation.Endrecurring API specification (optional)
* This is used for documentation and validation. * This is used for documentation and validation.
* *
* @param array $params description of fields supported by this API call * @param array<string, array<string, mixed>> $params description of fields supported by this API call
* *
* @return void * @return void
* *
* @see http://wiki.civicrm.org/confluence/display/CRMDOC/API+Architecture+Standards * @see http://wiki.civicrm.org/confluence/display/CRMDOC/API+Architecture+Standards
*/ */
function _civicrm_api3_twingle_donation_endrecurring_spec(&$params) { function _civicrm_api3_twingle_donation_endrecurring_spec(&$params) {
$params['project_id'] = array( $params['project_id'] = [
'name' => 'project_id', 'name' => 'project_id',
'title' => E::ts('Project ID'), 'title' => E::ts('Project ID'),
'type' => CRM_Utils_Type::T_STRING, 'type' => CRM_Utils_Type::T_STRING,
'api.required' => 1, 'api.required' => 1,
'description' => E::ts('The Twingle project ID.'), 'description' => E::ts('The Twingle project ID.'),
); ];
$params['trx_id'] = array( $params['trx_id'] = [
'name' => 'trx_id', 'name' => 'trx_id',
'title' => E::ts('Transaction ID'), 'title' => E::ts('Transaction ID'),
'type' => CRM_Utils_Type::T_STRING, 'type' => CRM_Utils_Type::T_STRING,
'api.required' => 1, 'api.required' => 1,
'description' => E::ts('The unique transaction ID of the donation'), 'description' => E::ts('The unique transaction ID of the donation'),
); ];
$params['ended_at'] = array( $params['ended_at'] = [
'name' => 'ended_at', 'name' => 'ended_at',
'title' => E::ts('Ended at'), 'title' => E::ts('Ended at'),
'type' => CRM_Utils_Type::T_INT, 'type' => CRM_Utils_Type::T_INT,
'api.required' => 1, 'api.required' => 1,
'description' => E::ts('The date when the recurring donation was ended, format: YmdHis.'), 'description' => E::ts('The date when the recurring donation was ended, format: YmdHis.'),
); ];
} }
/** /**
* TwingleDonation.Endrecurring API * TwingleDonation.Endrecurring API
* *
* @param array $params * @param array<string, mixed> $params
* @return array API result descriptor * @return array<string, mixed> API result descriptor
* @see civicrm_api3_create_success * @see civicrm_api3_create_success
* @see civicrm_api3_create_error * @see civicrm_api3_create_error
*/ */
@ -65,17 +67,17 @@ function civicrm_api3_twingle_donation_endrecurring($params) {
try { try {
// Validate date for parameter "ended_at". // Validate date for parameter "ended_at".
if (!DateTime::createFromFormat('YmdHis', $params['ended_at'])) { if (FALSE === DateTime::createFromFormat('YmdHis', $params['ended_at'])) {
throw new CiviCRM_API3_Exception( throw new CRM_Core_Exception(
E::ts('Invalid date for parameter "ended_at".'), E::ts('Invalid date for parameter "ended_at".'),
'invalid_format' 'invalid_format'
); );
} }
$default_profile = CRM_Twingle_Profile::getProfile('default'); $default_profile = CRM_Twingle_Profile::getProfile('default');
$contribution = civicrm_api3('ContributionRecur', 'getsingle', array( $contribution = civicrm_api3('ContributionRecur', 'getsingle', [
'trxn_id' => $default_profile->getTransactionID($params['trx_id']), 'trxn_id' => $default_profile->getTransactionID($params['trx_id']),
)); ]);
// End SEPA mandate (which ends the associated recurring contribution) or // End SEPA mandate (which ends the associated recurring contribution) or
// recurring contributions. // recurring contributions.
@ -84,30 +86,31 @@ function civicrm_api3_twingle_donation_endrecurring($params) {
&& CRM_Twingle_Tools::isSDD($contribution['payment_instrument_id']) && CRM_Twingle_Tools::isSDD($contribution['payment_instrument_id'])
) { ) {
// END SEPA MANDATE // END SEPA MANDATE
$mandate = CRM_Twingle_Tools::getMandateFor($contribution['id']); $mandate = CRM_Twingle_Tools::getMandateFor((int) $contribution['id']);
if (!$mandate) { if (!isset($mandate)) {
throw new CiviCRM_API3_Exception( throw new CRM_Core_Exception(
E::ts("SEPA Mandate for recurring contribution [%1 not found.", [1 => $contribution['id']]), E::ts('SEPA Mandate for recurring contribution [%1 not found.', [1 => $contribution['id']]),
'api_error' 'api_error'
); );
} }
$mandate_id = $mandate['id']; $mandate_id = $mandate['id'];
$end_date = date_create_from_format('YmdHis', $params['ended_at']); $end_date = date_create_from_format('YmdHis', $params['ended_at']);
if ($end_date) { if (FALSE !== $end_date) {
// Mandates can not be terminated in the past: // Mandates can not be terminated in the past:
$end_date = date('Ymd', max( $end_date = date('Ymd', max(
time(), time(),
$end_date->getTimestamp())); $end_date->getTimestamp()));
} else { }
else {
// end date couldn't be parsed, use 'now' // end date couldn't be parsed, use 'now'
$end_date = date('Ymd'); $end_date = date('Ymd');
} }
// verify that the mandate has not been terminated in the past // verify that the mandate has not been terminated in the past
if ($mandate['status'] != 'FRST' && $mandate['status'] != 'RCUR') { if ($mandate['status'] != 'FRST' && $mandate['status'] != 'RCUR') {
throw new CiviCRM_API3_Exception( throw new CRM_Core_Exception(
E::ts("SEPA Mandate [%1] already terminated.", [1 => $mandate_id]), E::ts('SEPA Mandate [%1] already terminated.', [1 => $mandate_id]),
'api_error' 'api_error'
); );
} }
@ -117,23 +120,23 @@ function civicrm_api3_twingle_donation_endrecurring($params) {
$end_date, $end_date,
E::ts('Mandate closed by TwingleDonation.Endrecurring API call') E::ts('Mandate closed by TwingleDonation.Endrecurring API call')
)) { )) {
throw new CiviCRM_API3_Exception( throw new CRM_Core_Exception(
E::ts('Could not terminate SEPA mandate'), E::ts('Could not terminate SEPA mandate'),
'api_error' 'api_error'
); );
} }
$contribution = civicrm_api3('ContributionRecur', 'getsingle', array( $contribution = civicrm_api3('ContributionRecur', 'getsingle', [
'id' => $contribution['id'], 'id' => $contribution['id'],
)); ]);
} }
else { else {
// END RECURRING CONTRIBUTION // END RECURRING CONTRIBUTION
CRM_Twingle_Tools::$protection_suspended = TRUE; CRM_Twingle_Tools::$protection_suspended = TRUE;
$contribution = civicrm_api3('ContributionRecur', 'create', array( $contribution = civicrm_api3('ContributionRecur', 'create', [
'id' => $contribution['id'], 'id' => $contribution['id'],
'end_date' => $params['ended_at'], 'end_date' => $params['ended_at'],
'contribution_status_id' => CRM_Twingle_Submission::CONTRIBUTION_STATUS_COMPLETED, 'contribution_status_id' => CRM_Twingle_Submission::CONTRIBUTION_STATUS_COMPLETED,
)); ]);
CRM_Twingle_Tools::$protection_suspended = FALSE; CRM_Twingle_Tools::$protection_suspended = FALSE;
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,137 @@
<?php
use Civi\Twingle\Shop\Exceptions\ProductException;
use CRM_Twingle_ExtensionUtil as E;
/**
* TwingleProduct.Create API specification (optional)
* This is used for documentation and validation.
*
* @param array $spec description of fields supported by this API call
*
* @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
*/
function _civicrm_api3_twingle_product_Create_spec(&$spec) {
$spec['id'] = [
'name' => '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 CRM_Twingle_BAO_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,
]);
}
}

View file

@ -0,0 +1,71 @@
<?php
use CRM_Twingle_ExtensionUtil as E;
/**
* TwingleProduct.Delete API specification (optional)
* This is used for documentation and validation.
*
* @param array $spec description of fields supported by this API call
*
* @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
*/
function _civicrm_api3_twingle_product_Delete_spec(&$spec) {
$spec['id'] = [
'name' => '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 = CRM_Twingle_BAO_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]
);
}
}

View file

@ -0,0 +1,136 @@
<?php
use CRM_Twingle_ExtensionUtil as E;
/**
* TwingleProduct.Get API specification (optional)
* This is used for documentation and validation.
*
* @param array $spec description of fields supported by this API call
*
* @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
*/
function _civicrm_api3_twingle_product_Get_spec(&$spec) {
$spec['id'] = [
'name' => '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(CRM_Twingle_BAO_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 = CRM_Twingle_BAO_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');
}

View file

@ -0,0 +1,54 @@
<?php
use CRM_Twingle_ExtensionUtil as E;
/**
* TwingleProduct.Getsingle API specification (optional)
* This is used for documentation and validation.
*
* @param array $spec description of fields supported by this API call
*
* @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
*/
function _civicrm_api3_twingle_product_Getsingle_spec(&$spec) {
_civicrm_api3_twingle_product_Get_spec($spec);
}
/**
* TwingleProduct.Getsingle API
*
* @param array $params
*
* @return array
* API result descriptor
*
* @see civicrm_api3_create_success
*
* @throws API_Exception
*/
function civicrm_api3_twingle_product_Getsingle($params) {
// Filter for allowed params
$allowed_params = [];
_civicrm_api3_twingle_product_Getsingle_spec($allowed_params);
$params = array_intersect_key($params, $allowed_params);
// Check whether any parameters are set
if (empty($params)) {
return civicrm_api3_create_error(
"At least one parameter must be set",
['error_code' => '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']];
}

View file

@ -0,0 +1,79 @@
<?php
use CRM_Twingle_ExtensionUtil as E;
use Civi\Twingle\Shop\Exceptions\ShopException;
/**
* TwingleShop.Create API specification (optional)
* This is used for documentation and validation.
*
* @param array $spec description of fields supported by this API call
*
* @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
*/
function _civicrm_api3_twingle_shop_Create_spec(&$spec) {
$spec['project_identifier'] = [
'name' => '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 CRM_Twingle_BAO_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,
]);
}
}

View file

@ -0,0 +1,79 @@
<?php
use CRM_Twingle_ExtensionUtil as E;
/**
* TwingleShop.Delete API specification (optional)
* This is used for documentation and validation.
*
* @param array $spec description of fields supported by this API call
*
* @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
*/
function _civicrm_api3_twingle_shop_Delete_spec(&$spec) {
$spec['id'] = [
'name' => '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 = CRM_Twingle_BAO_TwingleShop::findById($shop_data['id']);
// Delete TwingleShop
/** @var \CRM_Twingle_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]
);
}
}

View file

@ -0,0 +1,96 @@
<?php
use CRM_Twingle_ExtensionUtil as E;
use Civi\Twingle\Shop\Exceptions\ApiCallError;
use Civi\Twingle\Shop\Exceptions\ProductException;
use Civi\Twingle\Shop\Exceptions\ShopException;
/**
* TwingleShop.Fetch API specification (optional)
* This is used for documentation and validation.
*
* @param array $spec description of fields supported by this API call
*
* @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
*/
function _civicrm_api3_twingle_shop_Fetch_spec(&$spec) {
$spec['project_identifiers'] = [
'name' => '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 = CRM_Twingle_BAO_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'
);
}

111
api/v3/TwingleShop/Get.php Normal file
View file

@ -0,0 +1,111 @@
<?php
use CRM_Twingle_ExtensionUtil as E;
/**
* TwingleShop.Get API specification (optional)
* This is used for documentation and validation.
*
* @param array $spec description of fields supported by this API call
*
* @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
*/
function _civicrm_api3_twingle_shop_Get_spec(&$spec) {
$spec['id'] = [
'name' => '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 = CRM_Twingle_BAO_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');
}

View file

@ -0,0 +1,54 @@
<?php
use CRM_Twingle_ExtensionUtil as E;
/**
* TwingleShop.Getsingle API specification (optional)
* This is used for documentation and validation.
*
* @param array $spec description of fields supported by this API call
*
* @see https://docs.civicrm.org/dev/en/latest/framework/api-architecture/
*/
function _civicrm_api3_twingle_shop_Getsingle_spec(&$spec) {
_civicrm_api3_twingle_shop_Get_spec($spec);
}
/**
* TwingleShop.Getsingle API
*
* @param array $params
*
* @return array
* API result descriptor
*
* @see civicrm_api3_create_success
*
* @throws API_Exception
*/
function civicrm_api3_twingle_shop_Getsingle($params) {
// Filter for allowed params
$allowed_params = [];
_civicrm_api3_twingle_shop_Getsingle_spec($allowed_params);
$params = array_intersect_key($params, $allowed_params);
// Check whether any parameters are set
if (empty($params)) {
return civicrm_api3_create_error(
"At least one parameter must be set",
['error_code' => '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']];
}

2
ci/README.md Normal file
View file

@ -0,0 +1,2 @@
The dependencies specified in composer.json of this directory are required to
run phpstan in CI.

33
ci/composer.json Normal file
View file

@ -0,0 +1,33 @@
{
"minimum-stability": "dev",
"prefer-stable": true,
"config": {
"allow-plugins": {
"civicrm/composer-compile-plugin": false,
"civicrm/composer-downloads-plugin": true,
"cweagans/composer-patches": true
}
},
"require": {
"civicrm/civicrm-core": "^5"
},
"scripts": {
"post-install-or-update": [
"# The following statements are only necessary when the extension is inside a CiviCRM installation, actually not required in CI.",
"# Avoid redeclaration of function \\GuzzleHttp\\http_build_query()",
"if [ -e vendor/civicrm/civicrm-core/guzzle_php81_shim.php ]; then echo '' >vendor/civicrm/civicrm-core/guzzle_php81_shim.php; fi",
"# Avoid redeclaration of function \\GuzzleHttp\\Promise\\queue()",
"if [ -e vendor/guzzlehttp/promises/src/functions.php ]; then echo '' >vendor/guzzlehttp/promises/src/functions.php; fi",
"# Avoid CiviCRM load extensions in vendor",
"if [ -e vendor/civicrm ]; then find vendor/civicrm -name 'info.xml' -delete; fi",
"# Avoid Class 'CRM_AfformAdmin_ExtensionUtil' not found",
"find vendor -name '*.mgd.php' -delete"
],
"post-install-cmd": [
"@post-install-or-update"
],
"post-update-cmd": [
"@post-install-or-update"
]
}
}

45
composer.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "systopia/de.systopia.twingle",
"type": "civicrm-ext",
"license": "AGPL-3.0-or-later",
"minimum-stability": "dev",
"prefer-stable": true,
"config": {
"sort-packages": true
},
"require": {
},
"scripts": {
"composer-phpcs": [
"@composer --working-dir=tools/phpcs"
],
"composer-phpstan": [
"@composer --working-dir=tools/phpstan"
],
"composer-phpunit": [
"@composer --working-dir=tools/phpunit"
],
"composer-tools": [
"@composer-phpcs",
"@composer-phpstan",
"@composer-phpunit"
],
"phpcs": [
"@php tools/phpcs/vendor/bin/phpcs"
],
"phpcbf": [
"@php tools/phpcs/vendor/bin/phpcbf"
],
"phpstan": [
"@php tools/phpstan/vendor/bin/phpstan"
],
"phpunit": [
"@php tools/phpunit/vendor/bin/simple-phpunit --coverage-text"
],
"test": [
"@phpcs",
"@phpstan",
"@phpunit"
]
}
}

3
css/twingle.css Normal file
View file

@ -0,0 +1,3 @@
.twingle-profile-list {
border-bottom: 1px solid #cfcec3;
}

37
css/twingle_shop.css Normal file
View file

@ -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); }
}

View file

@ -15,24 +15,28 @@
<url desc="Licensing">http://www.gnu.org/licenses/agpl-3.0.html</url> <url desc="Licensing">http://www.gnu.org/licenses/agpl-3.0.html</url>
</urls> </urls>
<releaseDate></releaseDate> <releaseDate></releaseDate>
<version>1.4-dev</version> <version>1.6-dev</version>
<develStage>dev</develStage> <develStage>dev</develStage>
<compatibility> <compatibility>
<ver>5.19</ver> <ver>5.58</ver>
</compatibility> </compatibility>
<comments></comments> <comments></comments>
<classloader>
<psr4 prefix="Civi\" path="Civi"/>
<psr0 prefix="CRM_" path="."/>
</classloader>
<requires> <requires>
<ext>de.systopia.xcm</ext> <ext>de.systopia.xcm</ext>
</requires> </requires>
<civix> <civix>
<namespace>CRM/Twingle</namespace> <namespace>CRM/Twingle</namespace>
<format>22.10.0</format> <format>23.02.1</format>
</civix> </civix>
<mixins> <mixins>
<mixin>menu-xml@1.0.0</mixin> <mixin>menu-xml@1.0.0</mixin>
<mixin>mgd-php@1.0.0</mixin> <mixin>mgd-php@1.0.0</mixin>
<mixin>smarty-v2@1.0.1</mixin>
<mixin>entity-types-php@1.0.0</mixin>
</mixins> </mixins>
<classloader> <upgrader>CRM_Twingle_Upgrader</upgrader>
<psr0 prefix="CRM_" path="."/>
</classloader>
</extension> </extension>

744
js/twingle_shop.js Normal file
View file

@ -0,0 +1,744 @@
/**
* 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;
}
// Deactivate 'update' button if product is not outdated
if (action === 'update' && !this.isOutdated) {
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 = '<i>' + getShopDefaultFinancialType() + '</i>';
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.isOrphaned) {
actionsAndHandlers.push(['update', this.createPriceFieldHandler()]);
}
actionsAndHandlers.push(['delete', this.deletePriceFieldHandler()]);
} else if (!this.isOrphaned) {
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 = '&lt;' + ts('select financial type', []) + '&gt;';
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' or 'update' button if financial type is selected
const button = document.getElementById('twingle_product_tw_' + self.externalId).getElementsByClassName('twingle-shop-cell-button')[0];
if (button.textContent.includes('Create') || button.textContent.includes('Update')) {
button.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();
}
}
}

1600
l10n/de.systopia.twingle.pot Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -1,276 +0,0 @@
#: ./CRM/Twingle/Form/Profile.php
msgid "Delete Twingle API profile <em>%1</em>"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "Reset"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "Delete"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Edit Twingle API profile <em>%1</em>"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "New Twingle API profile"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "Profile name"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Project IDs"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Location type"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Financial Type"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Gender option for submitted value \"male\""
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Gender option for submitted value \"female\""
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Gender option for submitted value \"other\""
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Record %1 as"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "CiviSEPA creditor"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Sign up for newsletter groups"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Sign up for postal mail groups"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Sign up for Donation receipt groups"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php ./CRM/Twingle/Form/Settings.php
msgid "Save"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "Only alphanumeric characters and the underscore (_) are allowed for profile names."
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "CiviSEPA"
msgstr ""
#: ./CRM/Twingle/Form/Profile.php
msgid "No mailing lists available"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "Unknown attribute %1."
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "Bank transfer"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "Debit manual"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "Debit automatic"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "Credit card"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "Mobile phone Germany"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "PayPal"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "SOFORT Überweisung"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "Amazon Pay"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "paydirekt"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "Apple Pay"
msgstr ""
#: ./CRM/Twingle/Profile.php
msgid "Google Pay"
msgstr ""
#: ./CRM/Twingle/Submission.php
msgid "Invalid donation rhythm."
msgstr ""
#: ./CRM/Twingle/Submission.php
msgid "Payment method could not be matched to existing payment instrument."
msgstr ""
#: ./CRM/Twingle/Submission.php
msgid "Invalid date for parameter \"confirmed_at\"."
msgstr ""
#: ./CRM/Twingle/Submission.php
msgid "Invalid date for parameter \"user_birthdate\"."
msgstr ""
#: ./CRM/Twingle/Submission.php
msgid "Gender could not be matched to existing gender."
msgstr ""
#: ./CRM/Twingle/Submission.php
msgid "Unknown country %1."
msgstr ""
#: ./api/v3/TwingleDonation/Cancel.php
msgid "Invalid date for parameter \"cancelled_at\"."
msgstr ""
#: ./api/v3/TwingleDonation/Cancel.php ./api/v3/TwingleDonation/Endrecurring.php
msgid "Could not terminate SEPA mandate"
msgstr ""
#: ./api/v3/TwingleDonation/Endrecurring.php
msgid "Invalid date for parameter \"ended_at\"."
msgstr ""
#: ./api/v3/TwingleDonation/Endrecurring.php
msgid "Mandate closed by TwingleDonation.Endrecurring API call"
msgstr ""
#: ./api/v3/TwingleDonation/Submit.php
msgid "Contribution with the given transaction ID already exists."
msgstr ""
#: ./api/v3/TwingleDonation/Submit.php
msgid "Individual contact could not be found or created."
msgstr ""
#: ./api/v3/TwingleDonation/Submit.php
msgid "Organisation contact could not be found or created."
msgstr ""
#: ./api/v3/TwingleDonation/Submit.php
msgid "Missing attribute %1 for SEPA mandate"
msgstr ""
#: ./api/v3/TwingleDonation/Submit.php
msgid "Could not create recurring contribution."
msgstr ""
#: ./api/v3/TwingleDonation/Submit.php
msgid "Could not create contribution"
msgstr ""
#: ./templates/CRM/Twingle/Form/Profile.tpl
msgid "General settings"
msgstr ""
#: ./templates/CRM/Twingle/Form/Profile.tpl
msgid "Payment methods"
msgstr ""
#: ./templates/CRM/Twingle/Form/Profile.tpl
msgid "Groups"
msgstr ""
#: ./templates/CRM/Twingle/Form/Profile.tpl
msgid "Are you sure you want to reset the default profile?"
msgstr ""
#: ./templates/CRM/Twingle/Form/Profile.tpl
msgid "Are you sure you want to delete the profile <em>%1</em>?"
msgstr ""
#: ./templates/CRM/Twingle/Form/Profile.tpl
msgid "Profile name not given or invalid."
msgstr ""
#: ./templates/CRM/Twingle/Form/Settings.hlp
msgid "When the %1 is enabled and one of its payment instruments is assigned to a Twingle payment method (practically the <em>debit_manual</em> payment method), submitting a Twingle donation through the API will create a SEPA mandate with the given data."
msgstr ""
#: ./templates/CRM/Twingle/Form/Settings.tpl
msgid "Help"
msgstr ""
#: ./templates/CRM/Twingle/Page/Configuration.tpl
msgid "Profiles"
msgstr ""
#: ./templates/CRM/Twingle/Page/Configuration.tpl
msgid "Configure profiles"
msgstr ""
#: ./templates/CRM/Twingle/Page/Configuration.tpl
msgid "Settings"
msgstr ""
#: ./templates/CRM/Twingle/Page/Configuration.tpl
msgid "Configure extension settings"
msgstr ""
#: ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "New profile"
msgstr ""
#: ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "Properties"
msgstr ""
#: ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "Operations"
msgstr ""
#: ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "Selector"
msgstr ""
#: ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "Edit profile %1"
msgstr ""
#: ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "Edit"
msgstr ""
#: ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "Reset profile %1"
msgstr ""
#: ./templates/CRM/Twingle/Page/Profiles.tpl
msgid "Delete profile %1"
msgstr ""

View file

@ -1,31 +0,0 @@
<?php
/**
* Auto-register "xml/Menu/*.xml" files.
*
* @mixinName menu-xml
* @mixinVersion 1.0.0
*
* @param CRM_Extension_MixInfo $mixInfo
* On newer deployments, this will be an instance of MixInfo. On older deployments, Civix may polyfill with a work-a-like.
* @param \CRM_Extension_BootCache $bootCache
* On newer deployments, this will be an instance of MixInfo. On older deployments, Civix may polyfill with a work-a-like.
*/
return function ($mixInfo, $bootCache) {
/**
* @param \Civi\Core\Event\GenericHookEvent $e
* @see CRM_Utils_Hook::xmlMenu()
*/
Civi::dispatcher()->addListener('hook_civicrm_xmlMenu', function ($e) use ($mixInfo) {
if (!$mixInfo->isActive()) {
return;
}
$files = (array) glob($mixInfo->getPath('xml/Menu/*.xml'));
foreach ($files as $file) {
$e->files[] = $file;
}
});
};

View file

@ -1,101 +0,0 @@
<?php
/**
* When deploying on systems that lack mixin support, fake it.
*
* @mixinFile polyfill.php
*
* This polyfill does some (persnickity) deduplication, but it doesn't allow upgrades or shipping replacements in core.
*
* Note: The polyfill.php is designed to be copied into extensions for interoperability. Consequently, this file is
* not used 'live' by `civicrm-core`. However, the file does need a canonical home, and it's convenient to keep it
* adjacent to the actual mixin files.
*
* @param string $longName
* @param string $shortName
* @param string $basePath
*/
return function ($longName, $shortName, $basePath) {
// Construct imitations of the mixin services. These cannot work as well (e.g. with respect to
// number of file-reads, deduping, upgrading)... but they should be OK for a few months while
// the mixin services become available.
// List of active mixins; deduped by version
$mixinVers = [];
foreach ((array) glob($basePath . '/mixin/*.mixin.php') as $f) {
[$name, $ver] = explode('@', substr(basename($f), 0, -10));
if (!isset($mixinVers[$name]) || version_compare($ver, $mixinVers[$name], '>')) {
$mixinVers[$name] = $ver;
}
}
$mixins = [];
foreach ($mixinVers as $name => $ver) {
$mixins[] = "$name@$ver";
}
// Imitate CRM_Extension_MixInfo.
$mixInfo = new class() {
/**
* @var string
*/
public $longName;
/**
* @var string
*/
public $shortName;
public $_basePath;
public function getPath($file = NULL) {
return $this->_basePath . ($file === NULL ? '' : (DIRECTORY_SEPARATOR . $file));
}
public function isActive() {
return \CRM_Extension_System::singleton()->getMapper()->isActiveModule($this->shortName);
}
};
$mixInfo->longName = $longName;
$mixInfo->shortName = $shortName;
$mixInfo->_basePath = $basePath;
// Imitate CRM_Extension_BootCache.
$bootCache = new class() {
public function define($name, $callback) {
$envId = \CRM_Core_Config_Runtime::getId();
$oldExtCachePath = \Civi::paths()->getPath("[civicrm.compile]/CachedExtLoader.{$envId}.php");
$stat = stat($oldExtCachePath);
$file = Civi::paths()->getPath('[civicrm.compile]/CachedMixin.' . md5($name . ($stat['mtime'] ?? 0)) . '.php');
if (file_exists($file)) {
return include $file;
}
else {
$data = $callback();
file_put_contents($file, '<' . "?php\nreturn " . var_export($data, 1) . ';');
return $data;
}
}
};
// Imitate CRM_Extension_MixinLoader::run()
// Parse all live mixins before trying to scan any classes.
global $_CIVIX_MIXIN_POLYFILL;
foreach ($mixins as $mixin) {
// If the exact same mixin is defined by multiple exts, just use the first one.
if (!isset($_CIVIX_MIXIN_POLYFILL[$mixin])) {
$_CIVIX_MIXIN_POLYFILL[$mixin] = include_once $basePath . '/mixin/' . $mixin . '.mixin.php';
}
}
foreach ($mixins as $mixin) {
// If there's trickery about installs/uninstalls/resets, then we may need to register a second time.
if (!isset(\Civi::$statics[__FUNCTION__][$mixin])) {
\Civi::$statics[__FUNCTION__][$mixin] = 1;
$func = $_CIVIX_MIXIN_POLYFILL[$mixin];
$func($mixInfo, $bootCache);
}
}
};

View file

@ -0,0 +1,51 @@
<?php
/**
* Auto-register "templates/" folder.
*
* @mixinName smarty-v2
* @mixinVersion 1.0.1
* @since 5.59
*
* @param CRM_Extension_MixInfo $mixInfo
* On newer deployments, this will be an instance of MixInfo. On older deployments, Civix may polyfill with a work-a-like.
* @param \CRM_Extension_BootCache $bootCache
* On newer deployments, this will be an instance of MixInfo. On older deployments, Civix may polyfill with a work-a-like.
*/
return function ($mixInfo, $bootCache) {
$dir = $mixInfo->getPath('templates');
if (!file_exists($dir)) {
return;
}
$register = function() use ($dir) {
// This implementation has a theoretical edge-case bug on older versions of CiviCRM where a template could
// be registered more than once.
CRM_Core_Smarty::singleton()->addTemplateDir($dir);
};
// Let's figure out what environment we're in -- so that we know the best way to call $register().
if (!empty($GLOBALS['_CIVIX_MIXIN_POLYFILL'])) {
// Polyfill Loader (v<=5.45): We're already in the middle of firing `hook_config`.
if ($mixInfo->isActive()) {
$register();
}
return;
}
if (CRM_Extension_System::singleton()->getManager()->extensionIsBeingInstalledOrEnabled($mixInfo->longName)) {
// New Install, Standard Loader: The extension has just been enabled, and we're now setting it up.
// System has already booted. New templates may be needed for upcoming installation steps.
$register();
return;
}
// Typical Pageview, Standard Loader: Defer the actual registration for a moment -- to ensure that Smarty is online.
\Civi::dispatcher()->addListener('hook_civicrm_config', function() use ($mixInfo, $register) {
if ($mixInfo->isActive()) {
$register();
}
});
};

83
phpcs.xml.dist Normal file
View file

@ -0,0 +1,83 @@
<?xml version="1.0"?>
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" name="CiviCRM - Modified"
xsi:noNamespaceSchemaLocation="tools/phpcs/vendor/squizlabs/php_codesniffer/phpcs.xsd">
<description>CiviCRM coding standard with some additional changes</description>
<file>api</file>
<file>Civi</file>
<file>CRM</file>
<file>tests</file>
<exclude-pattern>/CRM/[^/]+/DAO/.*\.php$</exclude-pattern>
<arg name="extensions" value="php"/>
<arg name="cache" value=".phpcs.cache"/>
<arg name="colors"/>
<arg value="sp"/>
<!-- Exit with code 0 if warnings, but no error occurred -->
<config name="ignore_warnings_on_exit" value="true"/>
<rule ref="tools/phpcs/vendor/drupal/coder/coder_sniffer/Drupal">
<!-- Conflicts with PHPStan type hints -->
<exclude name="Drupal.Commenting.VariableComment.IncorrectVarType"/>
<exclude name="Drupal.Commenting.FunctionComment.ParamTypeSpaces"/>
<exclude name="Drupal.Commenting.FunctionComment.ReturnTypeSpaces"/>
<exclude name="Drupal.Commenting.VariableComment.MissingVar"/>
<!-- Don't enforce phpdoc type hint because it (might) only duplicate a PHP type hint -->
<exclude name="Drupal.Commenting.VariableComment.MissingVar"/>
<!-- Don't enforce phpdoc type hint because it (might) only duplicate a PHP type hint -->
<exclude name="Drupal.Commenting.FunctionComment.ParamMissingDefinition"/>
<!-- False positive when license header is set and variable has no comment -->
<exclude name="Drupal.Commenting.VariableComment.WrongStyle"/>
</rule>
<rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
<rule ref="Generic.CodeAnalysis.EmptyStatement">
<exclude name="Generic.CodeAnalysis.EmptyStatement.DetectedCatch"/>
</rule>
<rule ref="Generic.CodeAnalysis.ForLoopWithTestFunctionCall"/>
<rule ref="Generic.Files.OneClassPerFile"/>
<rule ref="Generic.Files.OneInterfacePerFile"/>
<rule ref="Generic.Files.OneObjectStructurePerFile"/>
<rule ref="Generic.Files.OneTraitPerFile"/>
<rule ref="Generic.Formatting.DisallowMultipleStatements"/>
<rule ref="Generic.Metrics.CyclomaticComplexity"/>
<rule ref="Generic.Metrics.NestingLevel"/>
<rule ref="Generic.NamingConventions.AbstractClassNamePrefix"/>
<rule ref="Generic.NamingConventions.InterfaceNameSuffix"/>
<rule ref="Generic.NamingConventions.TraitNameSuffix"/>
<rule ref="Generic.PHP.RequireStrictTypes"/>
<rule ref="PSR1.Files.SideEffects"/>
<rule ref="PSR12.Classes.ClassInstantiation"/>
<rule ref="PSR12.Properties.ConstantVisibility"/>
<rule ref="Squiz.PHP.CommentedOutCode"/>
<rule ref="Squiz.PHP.GlobalKeyword"/>
<rule ref="Squiz.Strings.DoubleQuoteUsage">
<exclude name="Squiz.Strings.DoubleQuoteUsage.ContainsVar"/>
</rule>
<!-- Lines can be 120 chars long, but never show errors -->
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="120"/>
<property name="absoluteLineLimit" value="0"/>
</properties>
</rule>
<!-- Ban some functions -->
<rule ref="Generic.PHP.ForbiddenFunctions">
<properties>
<property name="forbiddenFunctions" type="array">
<element key="sizeof" value="count"/>
<element key="delete" value="unset"/>
<element key="print" value="echo"/>
<element key="is_null" value="null"/>
<element key="create_function" value="null"/>
</property>
</properties>
</rule>
</ruleset>

13
phpstan.ci.neon Normal file
View file

@ -0,0 +1,13 @@
includes:
- phpstan.neon.dist
parameters:
scanDirectories:
- ci/vendor/civicrm/civicrm-core/CRM/
bootstrapFiles:
- ci/vendor/autoload.php
# Because we test with different versions in CI we have unmatched errors
reportUnmatchedIgnoredErrors: false
ignoreErrors:
# Errors we get when using "prefer-lowest"
- '#::getSubscribedEvents\(\) return type has no value type specified in iterable type array.$#'

42
phpstan.neon.dist Normal file
View file

@ -0,0 +1,42 @@
parameters:
paths:
- api
- Civi
- CRM
- tests
excludePaths:
analyse:
- CRM/*/DAO/*
- tests/phpunit/bootstrap.php
scanFiles:
- twingle.civix.php
- tools/phpunit/vendor/bin/.phpunit/phpunit/src/Framework/TestCase.php
scanDirectories:
- tools/phpunit/vendor/bin/.phpunit/phpunit/src/Framework
bootstrapFiles:
- tools/phpunit/vendor/bin/.phpunit/phpunit/vendor/autoload.php
- vendor/autoload.php
- phpstanBootstrap.php
level: 9
universalObjectCratesClasses:
- Civi\Core\Event\GenericHookEvent
checkTooWideReturnTypesInProtectedAndPublicMethods: true
checkUninitializedProperties: true
checkMissingCallableSignature: true
treatPhpDocTypesAsCertain: false
exceptions:
check:
missingCheckedExceptionInThrows: true
tooWideThrowType: true
checkedExceptionClasses:
- \Webmozart\Assert\InvalidArgumentException
implicitThrows: false
ignoreErrors:
# Note paths are prefixed with "*/" to work with inspections in PHPStorm because of:
# https://youtrack.jetbrains.com/issue/WI-63891/PHPStan-ignoreErrors-configuration-isnt-working-with-inspections
# Example
#- # Accessing results of API requests
#message: "#^Offset '[^']+' does not exist on array[^\\|]+\\|null.$#"
#path: */tests/phpunit/**/*Test.php
tmpDir: .phpstan

14
phpstan.neon.template Normal file
View file

@ -0,0 +1,14 @@
# Copy this file to phpstan.neon and replace {VENDOR_DIR} with the appropriate
# path.
includes:
- phpstan.neon.dist
parameters:
scanDirectories:
- {VENDOR_DIR}/civicrm/civicrm-core/Civi/
- {VENDOR_DIR}/civicrm/civicrm-core/CRM/
- {VENDOR_DIR}/civicrm/civicrm-core/api/
- {VENDOR_DIR}/civicrm/civicrm-packages/
bootstrapFiles:
- {VENDOR_DIR}/autoload.php

43
phpstanBootstrap.php Normal file
View file

@ -0,0 +1,43 @@
<?php
/*
* Copyright (C) 2022 SYSTOPIA GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation in version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types = 1);
// phpcs:disable Drupal.Commenting.DocComment.ContentAfterOpen
/** @var \PHPStan\DependencyInjection\Container $container */
/** @phpstan-var array<string> $bootstrapFiles */
$bootstrapFiles = $container->getParameter('bootstrapFiles');
foreach ($bootstrapFiles as $bootstrapFile) {
if (str_ends_with($bootstrapFile, 'vendor/autoload.php')) {
$vendorDir = dirname($bootstrapFile);
$civiCrmVendorDir = $vendorDir . '/civicrm';
$civiCrmCoreDir = $civiCrmVendorDir . '/civicrm-core';
if (file_exists($civiCrmCoreDir)) {
set_include_path(get_include_path()
. PATH_SEPARATOR . $civiCrmCoreDir
. PATH_SEPARATOR . $civiCrmVendorDir . '/civicrm-packages'
);
// $bootstrapFile might not be included, yet. It is required for the
// following require_once, though.
require_once $bootstrapFile;
// Prevent error "Class 'CRM_Core_Exception' not found in file".
require_once $civiCrmCoreDir . '/CRM/Core/Exception.php';
break;
}
}
}

35
phpunit.xml.dist Normal file
View file

@ -0,0 +1,35 @@
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
colors="true"
failOnRisky="true"
failOnWarning="true"
forceCoversAnnotation="true"
bootstrap="tests/phpunit/bootstrap.php">
<php>
<ini name="error_reporting" value="-1" />
<env name="SYMFONY_DEPRECATIONS_HELPER" value="max[direct]=0&amp;baselineFile=./tests/ignored-deprecations.json"/>
</php>
<testsuites>
<testsuite name="Extension Test Suite">
<directory>./tests/phpunit</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">api</directory>
<directory suffix=".php">CRM</directory>
<directory suffix=".php">Civi</directory>
<exclude>
<directory>CRM/*/DAO</directory>
</exclude>
</whitelist>
</filter>
<listeners>
<listener class="Civi\Test\CiviTestListener">
<arguments/>
</listener>
</listeners>
</phpunit>

21
sql/auto_uninstall.sql Normal file
View file

@ -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;

View file

@ -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;

View file

@ -62,10 +62,10 @@
{/htxt} {/htxt}
{htxt id='id-custom_field_mapping'} {htxt id='id-custom_field_mapping'}
{ts domain="de.systopia.twingle"}<p>Map Twingle custom fields to CiviCRM custom fields using the following format (each assignment in a separate line):</p> {ts domain="de.systopia.twingle"}<p>Map Twingle custom fields to CiviCRM fields using the following format (each assignment in a separate line):</p>
<pre>twingle_field_1=custom_123<br />twingle_field_2=custom_789</pre> <pre>twingle_field_1=custom_123<br />twingle_field_2=custom_789</pre>
<p>Always use the <code>custom_[id]</code> notation for CiviCRM custom fields.</p> <p>Always use the <code>custom_[id]</code> notation for CiviCRM custom fields.</p>
<p>This only works for fields that Twingle themselves provide in the <code>custom_fields</code> parameter, not for any parameters; e.g. <code>user_extrafield</code> always ends up in a note.</p> <p>This works for fields that Twingle themselves provide in the <code>custom_fields</code> parameter, and for any other parameter (e.g. <code>user_extrafield</code>)</p>
<p>Only custom fields extending one of the following CiviCRM entities are allowed:</p> <p>Only custom fields extending one of the following CiviCRM entities are allowed:</p>
<ul> <ul>
<li><strong>Contact</strong> &ndash; Will be set on the Individual contact</li> <li><strong>Contact</strong> &ndash; Will be set on the Individual contact</li>
@ -75,4 +75,23 @@
<li><strong>ContributionRecur</strong> &ndash; Will be set on the recurring contribution and deriving single contributions</li> <li><strong>ContributionRecur</strong> &ndash; Will be set on the recurring contribution and deriving single contributions</li>
</ul>{/ts} </ul>{/ts}
{/htxt} {/htxt}
{htxt id='id-map_as_contribution_notes'}
{ts domain="de.systopia.twingle"}<p>Create a contribution note for each field specified in this selection.</p>
<p><i>Tip: You can enable or disable this fields in the TwingleMANAGER.</i></p>{/ts}
{/htxt}
{htxt id='id-map_as_contact_notes'}
{ts domain="de.systopia.twingle"}<p>Create a contact note for each field specified in this selection.</p>
<p><i>Tip: You can enable or disable this fields in the TwingleMANAGER.</i></p>{/ts}
{/htxt}
{htxt id='id-enable_shop_integration'}
<p>{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}</p>
{/htxt}
{htxt id='id-shop_map_products'}
<p>{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.{/ts}</p>
<p>{ts domain="de.systopia.twingle"}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.{/ts}</p>
{/htxt}
{/crmScope} {/crmScope}

View file

@ -28,6 +28,7 @@
</tr> </tr>
<tr class="crm-section"> <tr class="crm-section">
{if not $is_default}
<td class="label">{$form.selector.label} <td class="label">{$form.selector.label}
<a <a
onclick=' onclick='
@ -45,7 +46,8 @@
class="helpicon" class="helpicon"
></a> ></a>
</td> </td>
<td class="content">{$form.selector.html}</td> <td id="selectors" class="content">{$form.selector.html}</td>
{/if}
</tr> </tr>
<tr class="crm-section"> <tr class="crm-section">
@ -311,6 +313,22 @@
<td class="content">{$form.contribution_source.html}</td> <td class="content">{$form.contribution_source.html}</td>
</tr> </tr>
<tr class="crm-section">
<td class="label">
{$form.map_as_contribution_notes.label}
{help id="id-map_as_contribution_notes" title=$form.map_as_contribution_notes.label}
</td>
<td class="content">{$form.map_as_contribution_notes.html}</td>
</tr>
<tr class="crm-section">
<td class="label">
{$form.map_as_contact_notes.label}
{help id="id-map_as_contact_notes" title=$form.map_as_contact_notes.label}
</td>
<td class="content">{$form.map_as_contact_notes.html}</td>
</tr>
<tr class="crm-section"> <tr class="crm-section">
<td class="label"> <td class="label">
{$form.custom_field_mapping.label} {$form.custom_field_mapping.label}
@ -335,6 +353,71 @@
</table> </table>
{if $twingle_use_shop eq 1}
<legend>{ts domain="de.systopia.twingle"}Shop Integration{/ts}</legend>
<table class="form-layout-compressed">
<tr class="crm-section">
<td class="label">
{$form.enable_shop_integration.label}
<a
onclick='
CRM.help(
"{ts domain="de.systopia.twingle"}Enable Shop Integration{/ts}",
{literal}{
"id": "id-enable_shop_integration",
"file": "CRM\/Twingle\/Form\/Profile"
}{/literal}
);
return false;
'
href="#"
title="{ts domain="de.systopia.twingle"}Help{/ts}"
class="helpicon"
></a>
</td>
<td class="content">{$form.enable_shop_integration.html}</td>
</tr>
<tr class="crm-section twingle-shop-element">
<td class="label">{$form.shop_financial_type.label}</td>
<td class="content">{$form.shop_financial_type.html}</td>
</tr>
<tr class="crm-section twingle-shop-element">
<td class="label">{$form.shop_donation_financial_type.label}</td>
<td class="content">{$form.shop_donation_financial_type.html}</td>
</tr>
<tr class="crm-section twingle-shop-element">
<td class="label">{$form.shop_map_products.label}
<a
onclick='
CRM.help(
"{ts domain="de.systopia.twingle"}Map Products as Price Fields{/ts}",
{literal}{
"id": "id-shop_map_products",
"file": "CRM\/Twingle\/Form\/Profile"
}{/literal}
);
return false;
'
href="#"
title="{ts domain="de.systopia.twingle"}Help{/ts}"
class="helpicon"
></a></td>
<td class="content">{$form.shop_map_products.html}
<i id="twingle-shop-spinner" class="crm-i fa-spinner fa-spin"></i>
<div class="twingle-product-mapping">
<div id="tableContainer"></div>
</div>
</td>
</tr>
</table>
{/if}
</fieldset> </fieldset>
{elseif $op == 'delete'} {elseif $op == 'delete'}
@ -371,11 +454,18 @@
} }
} }
// register events and run once // register events
cj(document).ready(function (){ cj(document).ready(function (){
cj('#membership_type_id').change(twingle_membership_active_changed); cj('#membership_type_id').change(twingle_membership_active_changed);
cj('#membership_type_id_recur').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(); twingle_membership_active_changed();
</script> </script>
{/literal} {/literal}

View file

@ -27,3 +27,11 @@
{htxt id='id-twingle_prefix'} {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} {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}
{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}
{htxt id='id-twingle_access_key'}
{ts domain="de.systopia.twingle"}Enter your twingle API access key.{/ts}
{/htxt}

View file

@ -18,47 +18,40 @@
<table class="form-layout-compressed"> <table class="form-layout-compressed">
<tr class="crm-twingle-form-block-use-sepa"> <tr class="crm-twingle-form-block-use-sepa">
<td class="label">{$form.twingle_use_sepa.label} &nbsp;<a onclick='CRM.help("{$form.twingle_use_sepa.label}", {literal}{"id":"id-{/literal}{$form.twingle_use_sepa.name}{literal}","file":"CRM\/Twingle\/Form\/Settings"}{/literal}); return false;' href="#" title="{ts domain="de.systopia.twingle"}Help{/ts}" class="helpicon"></a></td> <td class="label">
{$form.twingle_use_sepa.label}
{help id="id-twingle_use_sepa" title=$form.twingle_use_sepa.label}
</td>
<td> <td>
{$form.twingle_use_sepa.html} {$form.twingle_use_sepa.html}
<br />
<span class="description">
{$formElements.twingle_use_sepa.description}
</span>
</td> </td>
</tr> </tr>
<tr class="crm-twingle-form-block-use-sepa-reference"> <tr class="crm-twingle-form-block-use-sepa-reference">
<td class="label">{$form.twingle_dont_use_reference.label} &nbsp; <a onclick='CRM.help("{$form.twingle_dont_use_reference.label}", {literal}{"id":"id-{/literal}{$form.twingle_dont_use_reference.name}{literal}","file":"CRM\/Twingle\/Form\/Settings"}{/literal}); return false;' href="#" title="{ts domain="de.systopia.twingle"}Help{/ts}" class="helpicon"></a></td> <td class="label">
{$form.twingle_dont_use_reference.label}
{help id="id-twingle_dont_use_reference" title=$form.twingle_dont_use_reference.label}
</td>
<td> <td>
{$form.twingle_dont_use_reference.html} {$form.twingle_dont_use_reference.html}
<br />
<span class="description">
{$formElements.twingle_dont_use_reference.description}
</span>
</td> </td>
</tr> </tr>
<tr class="crm-twingle-form-block-prefix"> <tr class="crm-twingle-form-block-prefix">
<td class="label">{$form.twingle_prefix.label} &nbsp;<a onclick='CRM.help("{$form.twingle_prefix.label}", {literal}{"id":"id-{/literal}{$form.twingle_prefix.name}{literal}","file":"CRM\/Twingle\/Form\/Settings"}{/literal}); return false;' href="#" title="{ts domain="de.systopia.twingle"}Help{/ts}" class="helpicon"></a></td> <td class="label">{$form.twingle_prefix.label}
{help id="id-twingle_prefix" title=$form.twingle_prefix.label}
</td>
<td> <td>
{$form.twingle_prefix.html} {$form.twingle_prefix.html}
<br />
<span class="description">
{$formElements.twingle_prefix.description}
</span>
</td> </td>
</tr> </tr>
<tr class="crm-twingle-form-block-recurring-protection"> <tr class="crm-twingle-form-block-recurring-protection">
<td class="label">{$form.twingle_protect_recurring.label} &nbsp;<a onclick='CRM.help("{$form.twingle_protect_recurring.label}", {literal}{"id":"id-{/literal}{$form.twingle_protect_recurring.name}{literal}","file":"CRM\/Twingle\/Form\/Settings"}{/literal}); return false;' href="#" title="{ts domain="de.systopia.twingle"}Help{/ts}" class="helpicon"></a></td> <td class="label">{$form.twingle_protect_recurring.label}
{help id="id-twingle_protect_recurring" title=$form.twingle_protect_recurring.label}
</td>
<td> <td>
{$form.twingle_protect_recurring.html} {$form.twingle_protect_recurring.html}
<br />
<span class="description">
{$formElements.protect_recurring.description}
</span>
</td> </td>
</tr> </tr>
@ -66,10 +59,6 @@
<td class="label">{$form.twingle_protect_recurring_activity_type.label}</td> <td class="label">{$form.twingle_protect_recurring_activity_type.label}</td>
<td> <td>
{$form.twingle_protect_recurring_activity_type.html} {$form.twingle_protect_recurring_activity_type.html}
<br />
<span class="description">
{$formElements.twingle_protect_recurring_activity_type.description}
</span>
</td> </td>
</tr> </tr>
@ -77,10 +66,6 @@
<td class="label">{$form.twingle_protect_recurring_activity_subject.label}</td> <td class="label">{$form.twingle_protect_recurring_activity_subject.label}</td>
<td> <td>
{$form.twingle_protect_recurring_activity_subject.html} {$form.twingle_protect_recurring_activity_subject.html}
<br />
<span class="description">
{$formElements.twingle_protect_recurring_activity_subject.description}
</span>
</td> </td>
</tr> </tr>
@ -88,10 +73,6 @@
<td class="label">{$form.twingle_protect_recurring_activity_status.label}</td> <td class="label">{$form.twingle_protect_recurring_activity_status.label}</td>
<td> <td>
{$form.twingle_protect_recurring_activity_status.html} {$form.twingle_protect_recurring_activity_status.html}
<br />
<span class="description">
{$formElements.twingle_protect_recurring_activity_status.description}
</span>
</td> </td>
</tr> </tr>
@ -99,15 +80,27 @@
<td class="label">{$form.twingle_protect_recurring_activity_assignee.label}</td> <td class="label">{$form.twingle_protect_recurring_activity_assignee.label}</td>
<td> <td>
{$form.twingle_protect_recurring_activity_assignee.html} {$form.twingle_protect_recurring_activity_assignee.html}
<br />
<span class="description">
{$formElements.twingle_protect_recurring_activity_assignee.description}
</span>
</td> </td>
</tr> </tr>
</table> </table>
<h3>Twingle Shop Integration</h3>
<table class="form-layout-compressed">
<tr class="crm-twingle-form-block-use-shop">
<td class="label">{$form.twingle_use_shop.label}
{help id="id-twingle_use_shop" title=$form.twingle_use_shop.label}
</td>
<td>{$form.twingle_use_shop.html}</td>
</tr>
<tr class="crm-twingle-form-block-access-key twingle-shop-element">
<td class="label">{$form.twingle_access_key.label}
{help id="id-twingle_access_key" title=$form.twingle_access_key.label}
</td>
<td>{$form.twingle_access_key.html}</td>
</tr>
</table>
<div class="crm-submit-buttons"> <div class="crm-submit-buttons">
{include file="CRM/common/formButtons.tpl" location="bottom"} {include file="CRM/common/formButtons.tpl" location="bottom"}
@ -122,15 +115,16 @@
* whether activity creation is selected * whether activity creation is selected
*/ */
function twingle_protect_recurring_change() { function twingle_protect_recurring_change() {
if (cj("#twingle_protect_recurring").val() == '2') { if (cj('#twingle_protect_recurring').val() == '2') {
cj("tr.crm-twingle-form-block-recurring-protection-activity").show(); cj('tr.crm-twingle-form-block-recurring-protection-activity').show();
} else { }
cj("tr.crm-twingle-form-block-recurring-protection-activity").hide(); else {
cj('tr.crm-twingle-form-block-recurring-protection-activity').hide();
} }
} }
cj(document).ready(function () { cj(document).ready(function () {
cj("#twingle_protect_recurring").change(twingle_protect_recurring_change); cj('#twingle_protect_recurring').change(twingle_protect_recurring_change);
twingle_protect_recurring_change(); twingle_protect_recurring_change();
}); });
</script> </script>

View file

@ -25,7 +25,10 @@
<thead> <thead>
<tr> <tr>
<th>{ts domain="de.systopia.twingle"}Profile name{/ts}</th> <th>{ts domain="de.systopia.twingle"}Profile name{/ts}</th>
<th>{ts domain="de.systopia.twingle"}Properties{/ts}</th> <th>{ts domain="de.systopia.twingle"}Selectors{/ts}</th>
{if $twingle_use_shop eq 1}
<th>{ts domain="de.systopia.twingle"}Shop Integration{/ts}</th>
{/if}
<th>{ts domain="de.systopia.twingle"}Used{/ts}</th> <th>{ts domain="de.systopia.twingle"}Used{/ts}</th>
<th>{ts domain="de.systopia.twingle"}Last Used{/ts}</th> <th>{ts domain="de.systopia.twingle"}Last Used{/ts}</th>
<th>{ts domain="de.systopia.twingle"}Operations{/ts}</th> <th>{ts domain="de.systopia.twingle"}Operations{/ts}</th>
@ -33,23 +36,32 @@
</thead> </thead>
<tbody> <tbody>
{foreach from=$profiles item=profile} {foreach from=$profiles item=profile}
{assign var="profile_id" value=$profile.id}
{assign var="profile_name" value=$profile.name} {assign var="profile_name" value=$profile.name}
<tr> <tr class="twingle-profile-list">
<td>{$profile.name}</td> <td>{$profile.name}</td>
<td> <td>
<div><strong>{ts domain="de.systopia.twingle"}Selector{/ts}:</strong> {$profile.selector}</div> {if not $profile.is_default}
<ul>
{foreach from=$profile.selectors item=selector}
<li><strong></strong> {$selector}</li>
{/foreach}
</ul>
{/if}
</td> </td>
{if $twingle_use_shop eq 1}
<td>{if $profile.enable_shop_integration}<span style="color:green">{ts domain="de.systopia.twingle"}enabled{/ts}</span>{else}<span>{ts domain="de.systopia.twingle"}disabled{/ts}</span>{/if}</td>
{/if}
<td>{ts domain="de.systopia.twingle"}{$profile_stats.$profile_name.access_counter_txt}{/ts}</td> <td>{ts domain="de.systopia.twingle"}{$profile_stats.$profile_name.access_counter_txt}{/ts}</td>
<td>{ts domain="de.systopia.twingle"}{$profile_stats.$profile_name.last_access_txt}{/ts}</td> <td>{ts domain="de.systopia.twingle"}{$profile_stats.$profile_name.last_access_txt}{/ts}</td>
<td> <td>
<a href="{crmURL p="civicrm/admin/settings/twingle/profile" q="op=edit&name=$profile_name"}" title="{ts domain="de.systopia.twingle" 1=$profile.name}Edit profile %1{/ts}" class="action-item crm-hover-button">{ts domain="de.systopia.twingle"}Edit{/ts}</a> <a href="{crmURL p="civicrm/admin/settings/twingle/profile" q="op=edit&id=$profile_id"}" title="{ts domain="de.systopia.twingle" 1=$profile.name}Edit profile %1{/ts}" class="action-item crm-hover-button">{ts domain="de.systopia.twingle"}Edit{/ts}</a>
<a href="{crmURL p="civicrm/admin/settings/twingle/profile" q="op=copy&source_name=$profile_name"}" title="{ts domain="de.systopia.twingle" 1=$profile.name}Copy profile %1{/ts}" class="action-item crm-hover-button">{ts domain="de.systopia.twingle"}Copy{/ts}</a> <a href="{crmURL p="civicrm/admin/settings/twingle/profile" q="op=copy&source_id=$profile_id"}" title="{ts domain="de.systopia.twingle" 1=$profile.name}Copy profile %1{/ts}" class="action-item crm-hover-button">{ts domain="de.systopia.twingle"}Copy{/ts}</a>
{if $profile_name == 'default'} {if $profile_name == 'default'}
<a href="{crmURL p="civicrm/admin/settings/twingle/profile" q="op=delete&name=$profile_name"}" title="{ts domain="de.systopia.twingle" 1=$profile.name}Reset profile %1{/ts}" class="action-item crm-hover-button">{ts domain="de.systopia.twingle"}Reset{/ts}</a> <a href="{crmURL p="civicrm/admin/settings/twingle/profile" q="op=delete&id=$profile_id"}" title="{ts domain="de.systopia.twingle" 1=$profile.name}Reset profile %1{/ts}" class="action-item crm-hover-button">{ts domain="de.systopia.twingle"}Reset{/ts}</a>
{else} {else}
<a href="{crmURL p="civicrm/admin/settings/twingle/profile" q="op=delete&name=$profile_name"}" title="{ts domain="de.systopia.twingle" 1=$profile.name}Delete profile %1{/ts}" class="action-item crm-hover-button">{ts domain="de.systopia.twingle"}Delete{/ts}</a> <a href="{crmURL p="civicrm/admin/settings/twingle/profile" q="op=delete&id=$profile_id"}" title="{ts domain="de.systopia.twingle" 1=$profile.name}Delete profile %1{/ts}" class="action-item crm-hover-button">{ts domain="de.systopia.twingle"}Delete{/ts}</a>
{/if} {/if}
</td> </td>
</tr> </tr>
{/foreach} {/foreach}

33
tests/docker-compose.yml Normal file
View file

@ -0,0 +1,33 @@
version: "3"
services:
civicrm:
image: michaelmcandrew/civicrm:${CIVICRM_IMAGE_TAG:-5-drupal-php8.1}
environment:
- PROJECT_NAME=test
- BASE_URL=http://localhost
- CIVICRM_DB_NAME=test
- CIVICRM_DB_USER=root
- CIVICRM_DB_PASS=secret
- CIVICRM_DB_HOST=mysql
- CIVICRM_DB_PORT=3306
- DRUPAL_DB_NAME=test
- DRUPAL_DB_USER=root
- DRUPAL_DB_PASS=secret
- DRUPAL_DB_HOST=mysql
- DRUPAL_DB_PORT=3306
- PHP_DATE_TIMEZONE=UTC
- DEBUG=ON
- SMTP_HOST=localhost
- SMTP_MAILDOMAIN=example.org
volumes:
- ../:/var/www/html/sites/default/files/civicrm/ext/de.systopia.twingle:${BIND_VOLUME_PERMISSIONS:-ro}
- /var/www/html/sites/default/files/civicrm/ext/de.systopia.twingle/vendor
- /var/www/html/sites/default/files/civicrm/ext/de.systopia.twingle/tools/phpunit/vendor
# Don't start Apache HTTP Server, but keep container running
command: ["tail", "-f", "/dev/null"]
stop_signal: SIGKILL
mysql:
image: mariadb
environment:
MARIADB_ROOT_PASSWORD: secret
MARIADB_DATABASE: test

19
tests/docker-phpunit.sh Executable file
View file

@ -0,0 +1,19 @@
#!/bin/bash
set -eu -o pipefail
SCRIPT_DIR=$(realpath "$(dirname "$0")")
EXT_DIR=$(dirname "$SCRIPT_DIR")
cd "$EXT_DIR"
if [ ! -e tools/phpunit/vendor/bin ]; then
"$SCRIPT_DIR/docker-prepare.sh"
fi
export XDEBUG_MODE=coverage
# TODO: Remove when not needed, anymore.
# In Docker container with CiviCRM 5.5? all deprecations are reported as direct
# deprecations so "disabling" check of deprecation count is necessary for the
# tests to pass (if baselineFile does not contain all deprecations).
export SYMFONY_DEPRECATIONS_HELPER="max[total]=99999&baselineFile=./tests/ignored-deprecations.json"
composer phpunit -- --cache-result-file=/tmp/.phpunit.result.cache "$@"

45
tests/docker-prepare.sh Executable file
View file

@ -0,0 +1,45 @@
#!/bin/bash
set -eu -o pipefail
EXT_DIR=$(dirname "$(dirname "$(realpath "$0")")")
EXT_NAME=$(basename "$EXT_DIR")
i=0
while ! mysql -h "$CIVICRM_DB_HOST" -P "$CIVICRM_DB_PORT" -u "$CIVICRM_DB_USER" --password="$CIVICRM_DB_PASS" -e 'SELECT 1;' >/dev/null 2>&1; do
i=$((i+1))
if [ $i -gt 10 ]; then
echo "Failed to connect to database" >&2
exit 1
fi
echo -n .
sleep 1
done
echo
export XDEBUG_MODE=off
if mysql -h "$CIVICRM_DB_HOST" -P "$CIVICRM_DB_PORT" -u "$CIVICRM_DB_USER" --password="$CIVICRM_DB_PASS" "$CIVICRM_DB_NAME" -e 'SELECT 1 FROM civicrm_setting LIMIT 1;' >/dev/null 2>&1; then
cv flush
else
# For headless tests it is required that CIVICRM_UF is defined using the corresponding env variable.
sed -E "s/define\('CIVICRM_UF', '([^']+)'\);/define('CIVICRM_UF', getenv('CIVICRM_UF') ?: '\1');/g" \
-i /var/www/html/sites/default/civicrm.settings.php
civicrm-docker-install
# Avoid this error:
# The autoloader expected class "Civi\ActionSchedule\Mapping" to be defined in
# file "[...]/Civi/ActionSchedule/Mapping.php". The file was found but the
# class was not in it, the class name or namespace probably has a typo.
rm -f /var/www/html/sites/all/modules/civicrm/Civi/ActionSchedule/Mapping.php
# For headless tests these files need to exist.
touch /var/www/html/sites/all/modules/civicrm/sql/test_data.mysql
touch /var/www/html/sites/all/modules/civicrm/sql/test_data_second_domain.mysql
cv ext:enable "$EXT_NAME"
fi
cd "$EXT_DIR"
composer update --no-progress --prefer-dist --optimize-autoloader --no-dev
composer composer-phpunit -- update --no-progress --prefer-dist

View file

@ -0,0 +1 @@
[]

View file

@ -0,0 +1,54 @@
<?php
use Civi\Test\CiviEnvBuilder;
use Civi\Test\HeadlessInterface;
use Civi\Test\HookInterface;
use Civi\Test\TransactionalInterface;
/**
* TwingleProduct.Create API Test Case
* This is a generic test class implemented with PHPUnit.
* @group headless
*/
class api_v3_TwingleProduct_CreateTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
use \Civi\Test\Api3TestTrait;
/**
* Set up for headless tests.
*
* Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
*
* See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
*/
public function setUpHeadless(): CiviEnvBuilder {
return \Civi\Test::headless()
->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']);
}
}

View file

@ -0,0 +1,54 @@
<?php
use Civi\Test\CiviEnvBuilder;
use Civi\Test\HeadlessInterface;
use Civi\Test\HookInterface;
use Civi\Test\TransactionalInterface;
/**
* TwingleProduct.Delete API Test Case
* This is a generic test class implemented with PHPUnit.
* @group headless
*/
class api_v3_TwingleProduct_DeleteTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
use \Civi\Test\Api3TestTrait;
/**
* Set up for headless tests.
*
* Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
*
* See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
*/
public function setUpHeadless(): CiviEnvBuilder {
return \Civi\Test::headless()
->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']);
}
}

View file

@ -0,0 +1,54 @@
<?php
use Civi\Test\CiviEnvBuilder;
use Civi\Test\HeadlessInterface;
use Civi\Test\HookInterface;
use Civi\Test\TransactionalInterface;
/**
* TwingleProduct.Get API Test Case
* This is a generic test class implemented with PHPUnit.
* @group headless
*/
class api_v3_TwingleProduct_GetTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
use \Civi\Test\Api3TestTrait;
/**
* Set up for headless tests.
*
* Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
*
* See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
*/
public function setUpHeadless(): CiviEnvBuilder {
return \Civi\Test::headless()
->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']);
}
}

View file

@ -0,0 +1,54 @@
<?php
use Civi\Test\CiviEnvBuilder;
use Civi\Test\HeadlessInterface;
use Civi\Test\HookInterface;
use Civi\Test\TransactionalInterface;
/**
* TwingleProduct.Getsingle API Test Case
* This is a generic test class implemented with PHPUnit.
* @group headless
*/
class api_v3_TwingleProduct_GetsingleTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
use \Civi\Test\Api3TestTrait;
/**
* Set up for headless tests.
*
* Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
*
* See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
*/
public function setUpHeadless(): CiviEnvBuilder {
return \Civi\Test::headless()
->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']);
}
}

View file

@ -0,0 +1,54 @@
<?php
use Civi\Test\CiviEnvBuilder;
use Civi\Test\HeadlessInterface;
use Civi\Test\HookInterface;
use Civi\Test\TransactionalInterface;
/**
* TwingleShop.Create API Test Case
* This is a generic test class implemented with PHPUnit.
* @group headless
*/
class api_v3_TwingleShop_CreateTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
use \Civi\Test\Api3TestTrait;
/**
* Set up for headless tests.
*
* Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
*
* See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
*/
public function setUpHeadless(): CiviEnvBuilder {
return \Civi\Test::headless()
->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']);
}
}

View file

@ -0,0 +1,54 @@
<?php
use Civi\Test\CiviEnvBuilder;
use Civi\Test\HeadlessInterface;
use Civi\Test\HookInterface;
use Civi\Test\TransactionalInterface;
/**
* TwingleShop.Delete API Test Case
* This is a generic test class implemented with PHPUnit.
* @group headless
*/
class api_v3_TwingleShop_DeleteTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
use \Civi\Test\Api3TestTrait;
/**
* Set up for headless tests.
*
* Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
*
* See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
*/
public function setUpHeadless(): CiviEnvBuilder {
return \Civi\Test::headless()
->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']);
}
}

View file

@ -0,0 +1,54 @@
<?php
use Civi\Test\CiviEnvBuilder;
use Civi\Test\HeadlessInterface;
use Civi\Test\HookInterface;
use Civi\Test\TransactionalInterface;
/**
* TwingleShop.Get API Test Case
* This is a generic test class implemented with PHPUnit.
* @group headless
*/
class api_v3_TwingleShop_GetTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
use \Civi\Test\Api3TestTrait;
/**
* Set up for headless tests.
*
* Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
*
* See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
*/
public function setUpHeadless(): CiviEnvBuilder {
return \Civi\Test::headless()
->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']);
}
}

View file

@ -0,0 +1,54 @@
<?php
use Civi\Test\CiviEnvBuilder;
use Civi\Test\HeadlessInterface;
use Civi\Test\HookInterface;
use Civi\Test\TransactionalInterface;
/**
* TwingleShop.Getsingle API Test Case
* This is a generic test class implemented with PHPUnit.
* @group headless
*/
class api_v3_TwingleShop_GetsingleTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
use \Civi\Test\Api3TestTrait;
/**
* Set up for headless tests.
*
* Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
*
* See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
*/
public function setUpHeadless(): CiviEnvBuilder {
return \Civi\Test::headless()
->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']);
}
}

View file

@ -0,0 +1,65 @@
<?php
ini_set('memory_limit', '2G');
// phpcs:disable
eval(cv('php:boot --level=classloader', 'phpcode'));
// phpcs:enable
// Allow autoloading of PHPUnit helper classes in this extension.
$loader = new \Composer\Autoload\ClassLoader();
$loader->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)");
}
}

11
tools/phpcs/composer.json Normal file
View file

@ -0,0 +1,11 @@
{
"repositories": [
{
"type": "git",
"url": "https://github.com/civicrm/coder.git"
}
],
"require": {
"drupal/coder": "dev-8.x-2.x-civi"
}
}

View file

@ -0,0 +1,18 @@
{
"require": {
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan": "^1.7",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-phpunit": "^1.1",
"phpstan/phpstan-strict-rules": "^1.2",
"phpstan/phpstan-webmozart-assert": "^1.2",
"thecodingmachine/phpstan-strict-rules": "^1.0",
"voku/phpstan-rules": "^3.0"
},
"config": {
"allow-plugins": {
"phpstan/extension-installer": true
},
"sort-packages": true
}
}

View file

@ -0,0 +1,13 @@
{
"require": {
"symfony/phpunit-bridge": "^6.1"
},
"scripts": {
"post-install-cmd": [
"@php vendor/bin/simple-phpunit install"
],
"post-update-cmd": [
"@php vendor/bin/simple-phpunit install"
]
}
}

View file

@ -79,40 +79,22 @@ class CRM_Twingle_ExtensionUtil {
use CRM_Twingle_ExtensionUtil as E; use CRM_Twingle_ExtensionUtil as E;
function _twingle_civix_mixin_polyfill() {
if (!class_exists('CRM_Extension_MixInfo')) {
$polyfill = __DIR__ . '/mixin/polyfill.php';
(require $polyfill)(E::LONG_NAME, E::SHORT_NAME, E::path());
}
}
/** /**
* (Delegated) Implements hook_civicrm_config(). * (Delegated) Implements hook_civicrm_config().
* *
* @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_config * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_config
*/ */
function _twingle_civix_civicrm_config(&$config = NULL) { function _twingle_civix_civicrm_config($config = NULL) {
static $configured = FALSE; static $configured = FALSE;
if ($configured) { if ($configured) {
return; return;
} }
$configured = TRUE; $configured = TRUE;
$template = CRM_Core_Smarty::singleton();
$extRoot = __DIR__ . DIRECTORY_SEPARATOR; $extRoot = __DIR__ . DIRECTORY_SEPARATOR;
$extDir = $extRoot . 'templates';
if (is_array($template->template_dir)) {
array_unshift($template->template_dir, $extDir);
}
else {
$template->template_dir = [$extDir, $template->template_dir];
}
$include_path = $extRoot . PATH_SEPARATOR . get_include_path(); $include_path = $extRoot . PATH_SEPARATOR . get_include_path();
set_include_path($include_path); set_include_path($include_path);
_twingle_civix_mixin_polyfill(); // Based on <compatibility>, this does not currently require mixin/polyfill.php.
} }
/** /**
@ -122,36 +104,7 @@ function _twingle_civix_civicrm_config(&$config = NULL) {
*/ */
function _twingle_civix_civicrm_install() { function _twingle_civix_civicrm_install() {
_twingle_civix_civicrm_config(); _twingle_civix_civicrm_config();
if ($upgrader = _twingle_civix_upgrader()) { // Based on <compatibility>, this does not currently require mixin/polyfill.php.
$upgrader->onInstall();
}
_twingle_civix_mixin_polyfill();
}
/**
* Implements hook_civicrm_postInstall().
*
* @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_postInstall
*/
function _twingle_civix_civicrm_postInstall() {
_twingle_civix_civicrm_config();
if ($upgrader = _twingle_civix_upgrader()) {
if (is_callable([$upgrader, 'onPostInstall'])) {
$upgrader->onPostInstall();
}
}
}
/**
* Implements hook_civicrm_uninstall().
*
* @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_uninstall
*/
function _twingle_civix_civicrm_uninstall(): void {
_twingle_civix_civicrm_config();
if ($upgrader = _twingle_civix_upgrader()) {
$upgrader->onUninstall();
}
} }
/** /**
@ -161,57 +114,7 @@ function _twingle_civix_civicrm_uninstall(): void {
*/ */
function _twingle_civix_civicrm_enable(): void { function _twingle_civix_civicrm_enable(): void {
_twingle_civix_civicrm_config(); _twingle_civix_civicrm_config();
if ($upgrader = _twingle_civix_upgrader()) { // Based on <compatibility>, this does not currently require mixin/polyfill.php.
if (is_callable([$upgrader, 'onEnable'])) {
$upgrader->onEnable();
}
}
_twingle_civix_mixin_polyfill();
}
/**
* (Delegated) Implements hook_civicrm_disable().
*
* @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_disable
* @return mixed
*/
function _twingle_civix_civicrm_disable(): void {
_twingle_civix_civicrm_config();
if ($upgrader = _twingle_civix_upgrader()) {
if (is_callable([$upgrader, 'onDisable'])) {
$upgrader->onDisable();
}
}
}
/**
* (Delegated) Implements hook_civicrm_upgrade().
*
* @param $op string, the type of operation being performed; 'check' or 'enqueue'
* @param $queue CRM_Queue_Queue, (for 'enqueue') the modifiable list of pending up upgrade tasks
*
* @return mixed
* based on op. for 'check', returns array(boolean) (TRUE if upgrades are pending)
* for 'enqueue', returns void
*
* @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_upgrade
*/
function _twingle_civix_civicrm_upgrade($op, CRM_Queue_Queue $queue = NULL) {
if ($upgrader = _twingle_civix_upgrader()) {
return $upgrader->onUpgrade($op, $queue);
}
}
/**
* @return CRM_Twingle_Upgrader
*/
function _twingle_civix_upgrader() {
if (!file_exists(__DIR__ . '/CRM/Twingle/Upgrader.php')) {
return NULL;
}
else {
return CRM_Twingle_Upgrader_Base::instance();
}
} }
/** /**
@ -230,7 +133,7 @@ function _twingle_civix_insert_navigation_menu(&$menu, $path, $item) {
if (empty($path)) { if (empty($path)) {
$menu[] = [ $menu[] = [
'attributes' => array_merge([ 'attributes' => array_merge([
'label' => CRM_Utils_Array::value('name', $item), 'label' => $item['name'] ?? NULL,
'active' => 1, 'active' => 1,
], $item), ], $item),
]; ];
@ -295,14 +198,3 @@ 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, []);
}

View file

@ -5,10 +5,35 @@ use CRM_Twingle_ExtensionUtil as E;
/** /**
* Implements hook_civicrm_pre(). * 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) { function twingle_civicrm_pre($op, $objectName, $id, &$params) {
if ($objectName == 'ContributionRecur' && $op == 'edit') { if ($objectName == 'ContributionRecur' && $op == 'edit') {
CRM_Twingle_Tools::checkRecurringContributionChange($id, $params); CRM_Twingle_Tools::checkRecurringContributionChange((int) $id, $params);
}
// Create/delete PriceField and PriceFieldValue for TwingleProduct
elseif ($objectName == 'TwingleProduct') {
$twingle_product = new CRM_Twingle_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 CRM_Twingle_BAO_TwingleShop();
$twingle_shop->load($params);
$twingle_shop->createPriceSet();
$params = $twingle_shop->getAttributes();
} }
} }
@ -30,24 +55,6 @@ function twingle_civicrm_install() {
_twingle_civix_civicrm_install(); _twingle_civix_civicrm_install();
} }
/**
* Implements hook_civicrm_postInstall().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_postInstall
*/
function twingle_civicrm_postInstall() {
_twingle_civix_civicrm_postInstall();
}
/**
* Implements hook_civicrm_uninstall().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_uninstall
*/
function twingle_civicrm_uninstall() {
_twingle_civix_civicrm_uninstall();
}
/** /**
* Implements hook_civicrm_enable(). * Implements hook_civicrm_enable().
* *
@ -57,24 +64,6 @@ function twingle_civicrm_enable() {
_twingle_civix_civicrm_enable(); _twingle_civix_civicrm_enable();
} }
/**
* Implements hook_civicrm_disable().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_disable
*/
function twingle_civicrm_disable() {
_twingle_civix_civicrm_disable();
}
/**
* Implements hook_civicrm_upgrade().
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_upgrade
*/
function twingle_civicrm_upgrade($op, CRM_Queue_Queue $queue = NULL) {
return _twingle_civix_civicrm_upgrade($op, $queue);
}
/** /**
* Implements hook_civicrm_permission(). * Implements hook_civicrm_permission().
* *
@ -137,12 +126,3 @@ function twingle_civicrm_navigationMenu(&$menu) {
)); ));
_twingle_civix_navigationMenu($menu); _twingle_civix_navigationMenu($menu);
} // */ } // */
/**
* Implements hook_civicrm_entityTypes().
*
* @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes
*/
function twingle_civicrm_entityTypes(&$entityTypes) {
_twingle_civix_civicrm_entityTypes($entityTypes);
}

View file

@ -0,0 +1,10 @@
<?php
// This file declares a new entity type. For more details, see "hook_civicrm_entityTypes" at:
// https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes
return [
[
'name' => 'TwingleProduct',
'class' => 'CRM_Twingle_DAO_TwingleProduct',
'table' => 'civicrm_twingle_product',
],
];

View file

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="iso-8859-1" ?>
<table>
<base>CRM/Twingle</base>
<class>TwingleProduct</class>
<name>civicrm_twingle_product</name>
<comment>This table contains the Twingle Product data.</comment>
<log>false</log>
<!-- <add>1.5</add>-->
<field>
<name>id</name>
<type>int unsigned</type>
<required>true</required>
<unique>true</unique>
<comment>Unique TwingleProduct ID</comment>
<html>
<type>Number</type>
</html>
</field>
<primaryKey>
<name>id</name>
<autoincrement>true</autoincrement>
</primaryKey>
<field>
<name>external_id</name>
<type>int unsigned</type>
<required>true</required>
<comment>The ID of this product in the Twingle database</comment>
<html>
<type>Number</type>
</html>
</field>
<field>
<name>price_field_id</name>
<type>int unsigned</type>
<comment>FK to Price Field</comment>
<required>true</required>
</field>
<foreignKey>
<name>price_field_id</name>
<table>civicrm_contact</table>
<key>id</key>
<onDelete>CASCADE</onDelete>
</foreignKey>
<field>
<name>twingle_shop_id</name>
<type>int unsigned</type>
<unique>true</unique>
<comment>FK to Twingle Shop</comment>
</field>
<foreignKey>
<name>twingle_shop_id</name>
<table>civicrm_twingle_shop</table>
<key>id</key>
<onDelete>CASCADE</onDelete>
</foreignKey>
<field>
<name>created_at</name>
<type>datetime</type>
<required>true</required>
<comment>Timestamp of when the product was created in the database</comment>
</field>
<field>
<name>updated_at</name>
<type>datetime</type>
<required>true</required>
<comment>Timestamp of when the product was last updated in the database</comment>
</field>
</table>

View file

@ -0,0 +1,10 @@
<?php
// This file declares a new entity type. For more details, see "hook_civicrm_entityTypes" at:
// https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes
return [
[
'name' => 'TwingleShop',
'class' => 'CRM_Twingle_DAO_TwingleShop',
'table' => 'civicrm_twingle_shop',
],
];

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="iso-8859-1" ?>
<table>
<base>CRM/Twingle</base>
<class>TwingleShop</class>
<name>civicrm_twingle_shop</name>
<comment>This table contains the Twingle Shop data. Each Twingle Shop is linked to a corresponding Price Set.</comment>
<log>false</log>
<!-- <add>1.5</add>-->
<field>
<name>id</name>
<type>int unsigned</type>
<required>true</required>
<unique>true</unique>
<comment>Unique TwingleShop ID</comment>
<html>
<type>Number</type>
</html>
</field>
<primaryKey>
<name>id</name>
<autoincrement>true</autoincrement>
</primaryKey>
<field>
<name>project_identifier</name>
<type>varchar</type>
<length>32</length>
<required>true</required>
<unique>true</unique>
<comment>Twingle Project Identifier</comment>
<html>
<type>Text</type>
</html>
</field>
<field>
<name>numerical_project_id</name>
<type>int unsigned</type>
<required>true</required>
<unique>true</unique>
<comment>Numerical Twingle Project Identifier</comment>
<html>
<type>Number</type>
</html>
</field>
<field>
<name>price_set_id</name>
<type>int unsigned</type>
<unique>true</unique>
<comment>FK to Price Set</comment>
</field>
<foreignKey>
<name>price_set_id</name>
<table>civicrm_price_set</table>
<key>id</key>
<onDelete>CASCADE</onDelete>
</foreignKey>
<field>
<name>name</name>
<type>varchar</type>
<unique>false</unique>
<length>64</length>
<required>true</required>
<comment>name of the shop</comment>
<html>
<type>Text</type>
</html>
</field>
</table>