Compare commits

...
Sign in to create a new pull request.

238 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
Jens Schuppe
fb25af415c Back to 1.4-dev 2024-03-13 12:53:51 +01:00
Jens Schuppe
e7040c70d3 Version 1.4-beta1 2024-03-13 12:53:35 +01:00
Jens Schuppe
30425418e7 Merge branch 'permissions'
[#82] Define permissions with label and description
2024-03-13 12:47:13 +01:00
Jens Schuppe
75d9516da0 Define permissions with label and description
Fixes "User deprecated function: Permission 'access Twingle API' should be declared with 'label' and 'description' keys." deprecation warnings
2024-03-11 14:52:24 +01:00
peth-systopia
c00314c75d
Update README.md 2024-01-16 12:57:03 +01:00
Jens Schuppe
df608dc3d0 Merge remote-tracking branch 'MarcMichalsky/minor_changes'
[#77] Minor changes/Code cleanup
2023-10-16 14:22:52 +02:00
Jens Schuppe
c149275e15 Back to dev 2023-09-29 10:53:32 +02:00
Jens Schuppe
dc1118dac9 Version 1.4-alpha4 2023-09-29 10:53:17 +02:00
Jens Schuppe
8daddce005 Merge remote-tracking branch 'origin/issue/65'
[#65] Replace calls to deprecated method `CRM_Core_Error::debug_log_message()`
2023-09-29 10:52:09 +02:00
Jens Schuppe
9ccd86f03a Merge branch 'navigationMenu'
[#79] Add navigation menu items
2023-09-26 12:37:44 +02:00
Jens Schuppe
e39a91e477 Add navigation menu items 2023-09-26 12:35:38 +02:00
Marc Michalsky
89df7482a6
throw error if no default project is found 2023-09-06 16:45:13 +02: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
fd99f3b24f
add default option
add a 'select profile' default option
2023-08-29 16:35:03 +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
a868e87ba7
minor changes 2023-08-16 11:32:08 +02:00
Marc Michalsky
c3f4db8600
replace traditional syntax arrays 2023-08-16 11:31:41 +02:00
Marc Michalsky
0b2b8d6523
do not add the xcm default profile manually as an option
it simply does not work
2023-08-16 11:31:14 +02:00
Marc Michalsky
3644086ab3
set correct value for debit cards 2023-08-16 11:30:55 +02:00
Marc Michalsky
5a9a911c01
remove duplicate array keys 2023-08-16 11:30:48 +02:00
Marc Michalsky
225c4efd25
remove unnecessary default values 2023-08-16 11:30:35 +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
B. Endres
bd6c60c539 replaced old debug-log call with current one. 2023-05-15 12:03:32 +02:00
peth-systopia
518f8809c7
Update index.md 2023-04-21 15:51:28 +02:00
peth-systopia
aae3a6b6f1
Update index.md 2023-04-21 15:29:13 +02:00
peth-systopia
f4dfc4b937
Create index.md 2023-04-21 15:28:40 +02:00
peth-systopia
018d2f2ac3
Delete index.md 2023-04-21 15:24:55 +02:00
peth-systopia
b9f1e32100
Update README.md 2023-04-21 15:19:02 +02:00
Jens Schuppe
bae3461c19 Merge remote-tracking branch 'MarcMichalsky/issue/61'
Fix broken API parameter structure for activity assignee EntityRef field on extension settings page
2023-04-20 11:45:22 +02:00
Marc Michalsky
486da1818d
Merge remote-tracking branch 'fork/issue/61' into issue/61 2023-04-19 17:37:25 +02:00
Marc Michalsky
c62af9582e
[#61] fix bug on settings page
The array passed to $this->addEntityRef() was incorrectly structured
2023-04-19 17:36:01 +02:00
Jens Schuppe
c88be90532 Back to dev 2023-03-14 15:37:35 +01:00
Jens Schuppe
b581b37838 Version 1.4-alpha3 2023-03-14 15:37:21 +01:00
Jens Schuppe
945992d8be Add missing "pi_" prefixes for new payment instruments 2023-03-14 15:36:50 +01:00
Jens Schuppe
3b643b2e54 Add repo URL to mkdocs configuration 2023-03-14 15:22:23 +01:00
Jens Schuppe
b53befbb41 Merge branch 'DokuUp'
[#63] Documentation update
2023-03-14 15:18:26 +01:00
Jens Schuppe
8cd714362d Format and structure fixes for docs 2023-03-14 15:17:37 +01:00
Michael Amos
4d750b1ab6 Documentation update. 2023-03-13 16:04:08 +01:00
Jens Schuppe
30ad463549 Back to dev 2023-03-06 15:52:07 +01:00
Jens Schuppe
f5c2127805 Version 1.4-alpha2 2023-03-06 15:43:06 +01:00
Jens Schuppe
6132e7ad80 Add new payment methods 2023-02-16 13:34:56 +01:00
Marc Michalsky
92d15e7c7c
[#61] fix bug on settings page
The array passed to $this->addEntityRef() was incorrectly structured
2023-02-14 10:44:27 +01:00
Jens Schuppe
1b68462401 Merge branch 'issue/58_php8'
[#59] Upgrade Civix code to Civix version 22.10.0
2023-02-13 10:17:59 +01:00
Anna
40842ae325 [#58] civix upgrade 2022-12-21 15:10:56 +01:00
Björn Endres
a326a61ade
Merge pull request #55 from MarcMichalsky/issue/50
[#50] Include user_extrafield in custom_field_mapping
2022-07-04 14:36:42 +02:00
Marc Michalsky
d3c07c10fa
[#50] Include user_extrafield in custom_field_mapping 2022-06-27 17:28:53 +02:00
Jens Schuppe
29e0b50cd1 Back to dev 2022-05-31 11:03:04 +02:00
Jens Schuppe
c1a1dce52e Version 1.4-alpha1 2022-05-31 11:02:47 +02:00
Jens Schuppe
85081dc3ab Merge remote-tracking branch 'systopia/issue/42'
[#42] "Data too long" when saving many profiles
2022-05-31 11:01:23 +02:00
Jens Schuppe
4be5471d41 Back to dev 2022-05-31 11:00:31 +02:00
Jens Schuppe
aa0c35ca1b Version 1.3 2022-05-31 11:00:14 +02:00
B. Endres
c52dc77532 [#42] migrate profiles with last_access NOW() 2022-05-13 10:15:28 +02:00
B. Endres
c96abd8407 [#42] added logging exception 2022-05-12 13:29:16 +02:00
B. Endres
65424ab4bc [#42] added stats 2022-05-12 13:25:53 +02:00
B. Endres
b5b34ff678 [#42] fix for getProfile empty name 2022-05-12 13:04:03 +02:00
B. Endres
bf2a73f519 [#42] log (production) access 2022-05-11 21:45:55 +02:00
B. Endres
fd47b91b65 [#42] refactored profile management 2022-05-11 21:37:37 +02:00
B. Endres
62399657e7 [#42] added data structure and upgrader/migration 2022-05-11 20:55:42 +02:00
Jens Schuppe
6fbc1d4a5d Back to dev 2022-05-02 15:36:32 +02:00
Jens Schuppe
6036f62a22 Version 1.3-beta1 2022-05-02 15:36:12 +02:00
Jens Schuppe
3654f92bf8 Add link to docs.civicrm.org 2022-05-02 15:36:03 +02:00
Jens Schuppe
1c649ba812 Merge branch 'issue/28'
[#28] Add parameter for preferred language
2022-05-02 15:25:28 +02:00
Jens Schuppe
c81e10faaa [#28] Add API parameter documentation and fix typo in API sepc 2022-05-02 15:24:58 +02:00
Jens Schuppe
d95813d1ad Merge remote-tracking branch 'MarcMichalsky/issue/47'
[#48] Allow configuring required address comonents
2022-05-02 14:32:57 +02:00
Jens Schuppe
6779349505 [#47] Simplify evaluating required address components and re-arrange profile form 2022-05-02 14:30:35 +02:00
Marc Michalsky
3656b413a1
Merge branch 'systopia:master' into issue/47 2022-04-21 08:45:11 +02:00
Jens Schuppe
36958cb9ed Back to dev 2022-04-19 17:50:15 +02:00
Jens Schuppe
dbac84d13a Version 1.3-alpha1 2022-04-19 17:49:59 +02:00
Marc Michalsky
48164479f7 hard code array of address components to unset 2021-11-17 18:20:25 +01:00
Marc Michalsky
d910ce4846 Comment updated 2021-11-17 16:52:14 +01:00
Marc Michalsky
9969c99f70 Make required address components configurable
The required address components can be selected in the corresponding profile.
2021-11-17 16:32:10 +01:00
Marc Michalsky
89638a172d Merge remote-tracking branch 'MarcMichalsky/issue/47' into issue/47 2021-11-17 16:31:42 +01:00
Marc Michalsky forumZFD
a30b8f02cb fix [#47] | Don't create new address only from user_country 2021-11-17 16:31:34 +01:00
Jens Schuppe
197563920a Merge branch 'issue/53'
[#53] Duplicate XCM-initiated activites for personal addresses
2021-11-09 11:47:06 +01:00
Jens Schuppe
9751e8cee4 [#53] Remove duplicate XCM call for individual address 2021-09-08 14:46:27 +02:00
Jens Schuppe
cadf895def [#51] Update civix-generated code 2021-09-06 11:11:11 +02:00
Jens Schuppe
e01b1ef8f3 Merge branch 'patch-1'
[#49] Improve help text for custom fields mapping
2021-05-20 13:57:26 +02:00
Jens Schuppe
c889cf08e8 [#49] Improve help text on custom fields mapping 2021-05-20 13:56:43 +02:00
mariav0
c5f3b1081b
Update Profile.hlp
added information:
This only works for fields that Twingle itself provides in the custom_fields parameter, not for any parameters; i.e. user_extrafield always ends up in a note.
2021-05-20 13:42:18 +02:00
Marc Michalsky forumZFD
0ce2d2530b
fix [#47] | Don't create new address only from user_country 2021-03-09 21:39:54 +01:00
Jens Schuppe
4659b53522 Merge remote-tracking branch 'MarcMichalsky/issue/XCM_primary_details'
[#46] add a paragraph to warn users against systopia/de.systopia.xcm#78
2021-03-01 14:41:47 +01:00
Marc Michalsky forumZFD
287bb52e90
add a paragraph to warn users against systopia/de.systopia.xcm#78 2021-02-27 20:12:43 +01:00
Jens Schuppe
549916a0bc Merge remote-tracking branch 'MarcMichalsky/issue/43_44'
[#45] Change campaign_id parameter data type and add validation for it
2021-02-23 15:03:04 +01:00
Marc Michalsky forumZFD
c06ba098c1
[#44] change campaign_id data type to string 2021-02-19 14:52:32 +01:00
Marc Michalsky forumZFD
921ea49deb
[#43] [#44] validate campaign_id
cast numeric string to integer and test if a related campaign exists
2021-02-19 14:52:00 +01:00
Jens Schuppe
d3060c291e Merge branch 'issue/29'
[#29] Copying profile overwrites existing or default profile
2020-11-10 15:28:20 +01:00
Jens Schuppe
be55ad70a8 [#29] Prevent source profile be overwritten 2020-11-10 15:27:56 +01:00
Jens Schuppe
677f9b8380 Merge branch 'issue/29'
[#29] Copying profile overwrites existing or default profile
2020-11-10 12:25:44 +01:00
Jens Schuppe
228e964633 [#29] Use correct profile names and validate before saving profile copies 2020-11-10 11:30:58 +01:00
Jens Schuppe
69764ffc11 Merge remote-tracking branch 'MarcMichalsky/issue/39'
[#39] Trim project IDs when extracting from profile configuration
2020-09-25 15:36:42 +02:00
Marc Michalsky
7328b3893d
trim project_ids 2020-09-25 12:08:17 +02:00
Jens Schuppe
3139ec0fee Fix typo in documentation 2020-09-16 10:06:43 +02:00
Jens Schuppe
72a3515e8a Merge branch 'issue/36'
[#36] Use CiviCRM's double opt-in feature for newsletter subscription
2020-08-19 12:37:19 +02:00
Jens Schuppe
9d0ac14d35 Set version to 1.3-dev 2020-08-19 12:37:01 +02:00
Jens Schuppe
a65e98509a [#36] Improve help text on Double Opt-In option 2020-08-19 12:27:46 +02:00
Marc Michalsky
d7e42035c8
updated double opt-in description 2020-08-18 15:00:18 +02:00
Marc Michalsky
d140bd9ed7
filter out non-public groups
non-public groups are getting ignored
2020-08-18 14:15:59 +02:00
Jens Schuppe
021cd5257b [#36] Fix PHPDoc 2020-08-14 10:48:50 +02:00
Jens Schuppe
6b42c72bf8 [#36] Fix indentation 2020-08-14 10:43:06 +02:00
Jens Schuppe
3553ed83b9 [#36] Do not alter the mailing lists available for the profile field since that might be confusing and still produce invalid states 2020-08-14 10:33:12 +02:00
Jens Schuppe
94cc262c21 [#36] Add help text for the Newsletter Double-Opt-In setting 2020-08-14 10:25:04 +02:00
Jens Schuppe
11558d4fbd [#36] Save newsletter_double_opt_in property as integer 2020-08-14 10:19:54 +02:00
Jens Schuppe
d3c1aabfb4 [#36] Rename "double_opt_in" property to "newsletter_double_opt_in" 2020-08-14 10:13:51 +02:00
Marc Michalsky
4ff060884a
implement CiviCRM's double opt-in feature for newsletter 2020-07-24 10:57:23 +02:00
Jens Schuppe
36a756e4dd [#28] Add parameter for preferred language and pass to XCM 2020-06-04 14:21:06 +02:00
127 changed files with 12047 additions and 2527 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,93 +25,112 @@ 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',
'twingle_use_sepa', 'twingle_use_sepa',
'twingle_dont_use_reference', 'twingle_dont_use_reference',
'twingle_protect_recurring', 'twingle_protect_recurring',
'twingle_protect_recurring_activity_type', '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', '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')
); );
$this->addEntityRef( $this->addEntityRef(
'twingle_protect_recurring_activity_assignee', 'twingle_protect_recurring_activity_assignee',
E::ts('Assigned To'), E::ts('Assigned To'),
[ [
'contact_type' => ['IN' => ['Individual', 'Organization']], 'api' => [
'params' => [
'contact_type' => ['IN' => ['Individual', 'Organization']],
'check_permissions' => 0, 'check_permissions' => 0,
] ],
],
]
); );
$this->addButtons(array( $this->add(
array ( 'checkbox',
'type' => 'submit', 'twingle_use_shop',
'name' => E::ts('Save'), E::ts('Use Twingle Shop Integration')
'isDefault' => TRUE, );
)
)); $this->add(
'text',
'twingle_access_key',
E::ts('Twingle Access Key')
);
$this->addButtons([
[
'type' => 'submit',
'name' => E::ts('Save'),
'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),
]); ]);
} }
@ -117,64 +138,73 @@ 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_subject', 'twingle_protect_recurring_activity_type',
'twingle_protect_recurring_activity_status', 'twingle_protect_recurring_activity_subject',
'twingle_protect_recurring_activity_assignee',] as $activity_field) { 'twingle_protect_recurring_activity_status',
$current_value = CRM_Utils_Array::value($activity_field, $this->_submitValues); 'twingle_protect_recurring_activity_assignee',
if (empty($current_value)) { ] as $activity_field) {
$this->_errors[$activity_field] = E::ts("This is required for activity creation"); if (NULL !== ($this->_submitValues[$activity_field] ?? NULL)) {
$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,
'is_active' => 1, 'is_active' => 1,
'is_reserved' => ['IN' => $reserved], 'is_reserved' => ['IN' => $reserved],
'return' => 'value,label', 'return' => 'value,label',
]); ]);
foreach ($query['values'] as $value) { foreach ($query['values'] as $value) {
$list[$value['value']] = $value['label']; $list[$value['value']] = $value['label'];
} }
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,19 +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 {
$profiles = array(); CRM_Utils_System::setTitle(E::ts('Twingle API Profiles'));
foreach (CRM_Twingle_Profile::getProfiles() as $profile_name => $profile) { $profiles = [];
$profiles[$profile_name]['name'] = $profile_name; foreach (CRM_Twingle_Profile::getProfiles() as $profile_id => $profile) {
$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('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,32 +26,44 @@ use CRM_Twingle_ExtensionUtil as E;
class CRM_Twingle_Profile { class CRM_Twingle_Profile {
/** /**
* @var CRM_Twingle_Profile[] $_profiles * @var int
* Caches the profile objects. * The id of the profile.
*/ */
protected static $_profiles = NULL; protected ?int $id;
/** /**
* @var string $name * @var string
* The name of the profile. * The name of the profile.
*/ */
protected $name = NULL; protected string $name;
/** /**
* @var array $data * @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(
@ -56,29 +72,68 @@ class CRM_Twingle_Profile {
); );
} }
/**
* Logs (production) access to this profile
*/
public function logAccess(): void {
CRM_Core_DAO::executeQuery('
UPDATE civicrm_twingle_profile
SET
last_access = NOW(),
access_counter = access_counter + 1
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;
}
/** /**
* Checks whether the profile's selector matches the given project ID. * Checks whether the profile's selector matches the given project ID.
* *
* @param string | int $project_id * @param string|int $project_id
* *
* @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 = 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 = array(); $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 */
list($twingle_field_name, $custom_field_name) = explode("=", $custom_field_map); $custom_field_maps = preg_split(
$custom_field_mapping[$twingle_field_name] = $custom_field_name; '/\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;
}
} }
} }
return $custom_field_mapping; return $custom_field_mapping;
@ -87,30 +142,81 @@ class CRM_Twingle_Profile {
/** /**
* 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.
* *
@ -120,12 +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) {
if (isset($this->data[$attribute_name])) { return (isset($this->data[$attribute_name]) && $this->data[$attribute_name] !== '')
return $this->data[$attribute_name]; ? $this->data[$attribute_name]
} : $default;
else {
return $default;
}
} }
/** /**
@ -134,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.', array(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;
@ -148,97 +254,348 @@ 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 {
self::$_profiles[$this->getName()] = $this; try {
$this->verifyProfile(); if (isset($this->id)) {
self::storeProfiles(); // existing profile -> just update the config
CRM_Core_DAO::executeQuery(
'UPDATE civicrm_twingle_profile SET config = %2, name = %3 WHERE id = %1',
[
1 => [$this->id, 'String'],
2 => [json_encode($this->data), 'String'],
3 => [$this->name, 'String'],
]);
}
else {
// new profile -> add new entry to the DB
CRM_Core_DAO::executeQuery(
<<<SQL
INSERT IGNORE INTO civicrm_twingle_profile(name,config,last_access,access_counter) VALUES (%1, %2, null, 0)
SQL,
[
1 => [$this->name, '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 CiviCRM settings. * Deletes the profile from the database
*
* @throws \Civi\Twingle\Exceptions\ProfileException
*/ */
public function deleteProfile() { public function deleteProfile(): void {
unset(self::$_profiles[$this->getName()]); // Do only reset default profile
self::storeProfiles(); 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(
array( [
'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'),
), 'required' => CRM_Twingle_Submission::civiSepaEnabled(),
],
'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_keys(static::paymentInstruments()), array_combine(
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_map(function ($attribute) { array_combine(
return $attribute . '_status'; array_map(function($attribute) {
}, array_keys(static::paymentInstruments())) return $attribute . '_status';
}, 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 array( 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'),
'pi_debit_automatic' => E::ts('Debit automatic'), 'pi_debit_automatic' => E::ts('Debit automatic'),
@ -247,10 +604,15 @@ class CRM_Twingle_Profile {
'pi_paypal' => E::ts('PayPal'), 'pi_paypal' => E::ts('PayPal'),
'pi_sofortueberweisung' => E::ts('SOFORT Überweisung'), 'pi_sofortueberweisung' => E::ts('SOFORT Überweisung'),
'pi_amazonpay' => E::ts('Amazon Pay'), 'pi_amazonpay' => E::ts('Amazon Pay'),
'pi_paydirekt' => E::ts('paydirekt'),
'pi_applepay' => E::ts('Apple Pay'), 'pi_applepay' => E::ts('Apple Pay'),
'pi_googlepay' => E::ts('Google Pay'), 'pi_googlepay' => E::ts('Google Pay'),
); 'pi_paydirekt' => E::ts('Paydirekt'),
'pi_twint' => E::ts('Twint'),
'pi_ideal' => E::ts('iDEAL'),
'pi_post_finance' => E::ts('Postfinance'),
'pi_bancontact' => E::ts('Bancontact'),
'pi_generic' => E::ts('Generic Payment Method'),
];
} }
/** /**
@ -262,17 +624,22 @@ class CRM_Twingle_Profile {
* @return CRM_Twingle_Profile * @return CRM_Twingle_Profile
*/ */
public static function createDefaultProfile($name = 'default') { public static function createDefaultProfile($name = 'default') {
return new CRM_Twingle_Profile($name, array( 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' => 3, // 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,
@ -280,6 +647,11 @@ class CRM_Twingle_Profile {
'pi_paydirekt' => NULL, 'pi_paydirekt' => NULL,
'pi_applepay' => NULL, 'pi_applepay' => NULL,
'pi_googlepay' => NULL, 'pi_googlepay' => NULL,
'pi_twint' => NULL,
'pi_ideal' => NULL,
'pi_post_finance' => 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,
@ -293,11 +665,25 @@ class CRM_Twingle_Profile {
'custom_field_mapping' => NULL, 'custom_field_mapping' => NULL,
'membership_type_id' => NULL, 'membership_type_id' => NULL,
'membership_type_id_recur' => NULL, 'membership_type_id_recur' => NULL,
) 'newsletter_double_opt_in' => NULL,
// Add contribution status for all payment methods. 'required_address_components' => [
+ array_fill_keys(array_map(function($attribute) { 'street_address',
return $attribute . '_status'; 'postal_code',
}, array_keys(static::paymentInstruments())), CRM_Twingle_Submission::CONTRIBUTION_STATUS_COMPLETED)); 'city',
'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.
// phpcs:ignore Drupal.Formatting.SpaceUnaryOperator.PlusMinus
+ array_fill_keys(array_map(function($attribute) {
return $attribute . '_status';
}, array_keys(static::paymentInstruments())), CRM_Twingle_Submission::CONTRIBUTION_STATUS_COMPLETED));
} }
/** /**
@ -305,74 +691,120 @@ 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 \Civi\Twingle\Exceptions\ProfileException
* @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.
return $profiles['default']; if (!empty($default_profile)) {
return $default_profile;
}
else {
throw new ProfileException(
'Could not find default profile',
ProfileException::ERROR_CODE_DEFAULT_PROFILE_NOT_FOUND
);
}
} }
/** /**
* Retrieves the profile with the given name. * Retrieves the profile with the given ID.
* *
* @param $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) {
$profiles = self::getProfiles(); if (isset($id)) {
if (isset($profiles[$name])) { /**
return $profiles[$name]; * @var CRM_Core_DAO $profile_data
} */
else { $profile_data = CRM_Core_DAO::executeQuery(
return NULL; '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
);
}
} }
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 CRM_Twingle_Profile[] * @return array<int, \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 {
if (self::$_profiles === NULL) { // todo: cache?
self::$_profiles = array(); $profiles = [];
if ($profiles_data = Civi::settings()->get('twingle_profiles')) { /**
foreach ($profiles_data as $profile_name => $profile_data) { * @var CRM_Core_DAO $profile_data
self::$_profiles[$profile_name] = new CRM_Twingle_Profile($profile_name, $profile_data); */
} $profile_data = CRM_Core_DAO::executeQuery('SELECT id, name, config FROM civicrm_twingle_profile');
} while ($profile_data->fetch()) {
$profiles[(int) $profile_data->id] = new CRM_Twingle_Profile(
$profile_data->name,
json_decode($profile_data->config, TRUE),
(int) $profile_data->id
);
} }
return $profiles;
// Include the default profile if it was not overridden within the settings.
if (!isset(self::$_profiles['default'])) {
self::$_profiles['default'] = self::createDefaultProfile();
self::storeProfiles();
}
return self::$_profiles;
} }
/** /**
* Persists the list of profiles into the CiviCRM settings. * Get the stats (access_count, last_access) for all twingle profiles
*
* @return array<string, array<string, mixed>>
* @throws \Civi\Core\Exception\DBQueryException
*/ */
public static function storeProfiles() { public static function getProfileStats() {
$profile_data = array(); $stats = [];
foreach (self::$_profiles as $profile_name => $profile) { /**
$profile_data[$profile_name] = $profile->data; * @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()) {
// phpcs:disable Drupal.Arrays.Array.ArrayIndentation
$stats[(string) $profile_data->name] = [
'name' => $profile_data->name,
'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'),
'access_counter' => $profile_data->access_counter,
'access_counter_txt' => $profile_data->access_counter
? ((int) $profile_data->access_counter) . 'x'
: E::ts('never'),
];
// phpcs:enable
} }
Civi::settings()->set('twingle_profiles', $profile_data); 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,17 +124,58 @@ 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.
if (isset($params['campaign_id'])) {
// Check whether campaign_id is a numeric string and cast it to an integer.
if (is_numeric($params['campaign_id'])) {
$params['campaign_id'] = intval($params['campaign_id']);
}
else {
throw new CRM_Core_Exception(
E::ts('campaign_id must be a numeric string. '),
'invalid_format'
);
}
// Check whether given campaign_id exists and if not, unset the parameter.
try {
civicrm_api3(
'Campaign',
'getsingle',
['id' => $params['campaign_id']]
);
}
catch (CRM_Core_Exception $e) {
unset($params['campaign_id']);
}
}
} }
/** /**
@ -125,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;
} }
@ -154,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'];
@ -162,65 +226,71 @@ 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'
); );
} }
} }
} }
// Prepare values: language.
if (is_string($contact_data['preferred_language']) && '' !== $contact_data['preferred_language']) {
$mapping = CRM_Core_I18n_PseudoConstant::longForShortMapping();
// Override the default mapping for German.
$mapping['de'] = 'de_DE';
$contact_data['preferred_language'] = $mapping[$contact_data['preferred_language']];
}
// Pass to XCM. // 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;
@ -244,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);
} }
@ -277,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'];
} }
/** /**
@ -297,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];
} }
@ -338,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);
} }
/** /**
@ -359,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,39 +132,53 @@ 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 {
civicrm_api3('Activity', 'create', [ civicrm_api3('Activity', 'create', [
'activity_type_id' => Civi::settings()->get('twingle_protect_recurring_activity_type'), 'activity_type_id' => Civi::settings()->get('twingle_protect_recurring_activity_type'),
'subject' => Civi::settings()->get('twingle_protect_recurring_activity_subject'), 'subject' => Civi::settings()->get('twingle_protect_recurring_activity_subject'),
'activity_date_time' => date('YmdHis'), 'activity_date_time' => date('YmdHis'),
'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(
'source_contact_id' => CRM_Core_Session::getLoggedInContactID(), "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(),
]); ]);
} 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
@ -161,9 +203,9 @@ class CRM_Twingle_Tools {
// lookup and add instrument IDs // lookup and add instrument IDs
$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,18 +217,17 @@ 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
$rcur_mandate = civicrm_api3('SepaMandate', 'get', [ $rcur_mandate = civicrm_api3('SepaMandate', 'get', [
'entity_id' => $contribution_id, 'entity_id' => $contribution_id,
'entity_table' => 'civicrm_contribution_recur', 'entity_table' => 'civicrm_contribution_recur',
'type' => 'RCUR', 'type' => 'RCUR',
]); ]);
if ($rcur_mandate['count'] == 1) { if ($rcur_mandate['count'] == 1) {
return reset($rcur_mandate['values']); return reset($rcur_mandate['values']);
@ -195,17 +236,19 @@ class CRM_Twingle_Tools {
// try OOFF mandate // try OOFF mandate
// try recurring mandate // try recurring mandate
$ooff_mandate = civicrm_api3('SepaMandate', 'get', [ $ooff_mandate = civicrm_api3('SepaMandate', 'get', [
'entity_id' => $contribution_id, 'entity_id' => $contribution_id,
'entity_table' => 'civicrm_contribution', 'entity_table' => 'civicrm_contribution',
'type' => 'OOFF', 'type' => 'OOFF',
]); ]);
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,140 +1,49 @@
<?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 {
// By convention, functions that look like "function upgrade_NNNN()" are
// upgrade tasks. They are executed in order (like Drupal's hook_update_N).
/** /**
* Example: Run an external SQL script when the module is installed. * Installer script
* */
public function install() { public function install(): void {
$this->executeSqlFile('sql/myinstall.sql'); // create a DB table for the twingle profiles
} $this->executeSqlFile('sql/civicrm_twingle_profile.sql');
/** // add a default profile
* Example: Work with entities usually not available during the install step. CRM_Twingle_Profile::createDefaultProfile()->saveProfile();
*
* This method can be used for any post-install tasks. For example, if a step
* of your installation depends on accessing an entity that is itself
* created during the installation (e.g., a setting or a managed entity), do
* so here to avoid order of operation problems.
*
public function postInstall() {
$customFieldId = civicrm_api3('CustomField', 'getvalue', array(
'return' => array("id"),
'name' => "customFieldCreatedViaManagedHook",
));
civicrm_api3('Setting', 'create', array(
'myWeirdFieldSetting' => array('id' => $customFieldId, 'weirdness' => 1),
));
} }
/** /**
* 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');
} * }
/**
* Example: Run a simple query when a module is enabled.
* *
public function enable() { * /**
CRM_Core_DAO::executeQuery('UPDATE foo SET is_active = 1 WHERE bar = "whiz"');
}
/**
* Example: Run a simple query when a module is disabled.
*
public function disable() {
CRM_Core_DAO::executeQuery('UPDATE foo SET is_active = 0 WHERE bar = "whiz"');
}
/**
* Example: Run a couple simple queries.
*
* @return TRUE on success
* @throws Exception
*
public function upgrade_4200() {
$this->ctx->log->info('Applying update 4200');
CRM_Core_DAO::executeQuery('UPDATE foo SET bar = "whiz"');
CRM_Core_DAO::executeQuery('DELETE FROM bang WHERE willy = wonka(2)');
return TRUE;
} // */
/**
* Example: Run an external SQL script.
*
* @return TRUE on success
* @throws Exception
public function upgrade_4201() {
$this->ctx->log->info('Applying update 4201');
// this path is relative to the extension base dir
$this->executeSqlFile('sql/upgrade_4201.sql');
return TRUE;
} // */
/**
* Example: Run a slow upgrade process by breaking it up into smaller chunk.
*
* @return TRUE on success
* @throws Exception
public function upgrade_4202() {
$this->ctx->log->info('Planning update 4202'); // PEAR Log interface
$this->addTask(E::ts('Process first step'), 'processPart1', $arg1, $arg2);
$this->addTask(E::ts('Process second step'), 'processPart2', $arg3, $arg4);
$this->addTask(E::ts('Process second step'), 'processPart3', $arg5);
return TRUE;
}
public function processPart1($arg1, $arg2) { sleep(10); return TRUE; }
public function processPart2($arg3, $arg4) { sleep(10); return TRUE; }
public function processPart3($arg5) { sleep(10); return TRUE; }
// */
/**
* Example: Run an upgrade with a query that touches many (potentially
* millions) of records by breaking it up into smaller chunks.
*
* @return TRUE on success
* @throws Exception
public function upgrade_4203() {
$this->ctx->log->info('Planning update 4203'); // PEAR Log interface
$minId = CRM_Core_DAO::singleValueQuery('SELECT coalesce(min(id),0) FROM civicrm_contribution');
$maxId = CRM_Core_DAO::singleValueQuery('SELECT coalesce(max(id),0) FROM civicrm_contribution');
for ($startId = $minId; $startId <= $maxId; $startId += self::BATCH_SIZE) {
$endId = $startId + self::BATCH_SIZE - 1;
$title = E::ts('Upgrade Batch (%1 => %2)', array(
1 => $startId,
2 => $endId,
));
$sql = '
UPDATE civicrm_contribution SET foobar = whiz(wonky()+wanker)
WHERE id BETWEEN %1 and %2
';
$params = array(
1 => array($startId, 'Integer'),
2 => array($endId, 'Integer'),
);
$this->addTask($title, 'executeSql', $sql, $params);
}
return TRUE;
} // */
/**
* 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'));
@ -148,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("
@ -163,4 +72,80 @@ class CRM_Twingle_Upgrader extends CRM_Twingle_Upgrader_Base {
return TRUE; return TRUE;
} }
/**
* Upgrading to 1.4.0 needs to convert the profiles into the new infrastructure
*
* @return TRUE on success
* @throws Exception
*/
public function upgrade_5140(): bool {
$this->ctx->log->info('Converting twingle profiles.');
// create a DB table for the twingle profiles
$this->executeSqlFile('sql/civicrm_twingle_profile.sql');
// migrate the current profiles
if (is_array($profiles_data = Civi::settings()->get('twingle_profiles'))) {
foreach ($profiles_data as $profile_name => $profile_data) {
$profile = new CRM_Twingle_Profile($profile_name, $profile_data);
$data = json_encode($profile->getData());
CRM_Core_DAO::executeQuery(<<<SQL
INSERT IGNORE INTO civicrm_twingle_profile(name,config,last_access,access_counter) VALUES (%1, %2, NOW(), 0)
SQL,
[
1 => [$profile_name, 'String'],
2 => [$data, 'String'],
]);
}
}
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();
CRM_Core_Error::debug_log_message("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."
);
}
}
}

155
README.md
View file

@ -1,150 +1,15 @@
# Twingle API # Twingle API
Extension to connect to the Twingle fundraising service via its API. This extension integrates [Twingle donation and membership forms](https://www.twingle.de/) with CiviCRM.
* [About Twingle](https://www.twingle.de/) You can read the full documentation, including installation and configuration instructions and API specification, [here](https://docs.civicrm.org/twingle/en/latest/).
The extension is licensed under ## We need your support
[AGPL-3.0](https://github.com/systopia/de.systopia.twingle/blob/master/LICENSE.txt). This CiviCRM extension is provided as Free and Open Source Software,
and we are happy if you find it useful. However, we have put a lot of work into it
(and continue to do so), much of it unpaid for. So if you benefit from our software,
please consider making a financial contribution so we can continue to maintain and develop it further.
## Configuration If you are willing to support us in developing this CiviCRM extension,
please send an email to info@systopia.de to get an invoice or agree a different payment method.
### Configure Twingle Thank you!
Please refer to the
[Twingle FAQ on using Twingle with CiviCRM](https://support.twingle.de/faq/de-de/9/46)
(currently only available in German).
### Configure Extended Contact Matcher (XCM)
Make sure you use an XCM profile with the option *Match contacts by contact ID*
enabled.
### Configure CiviCRM
- Go to the Administration console `/civicrm/admin`
- Open "Twingle API Configuration" at `/civicrm/admin/settings/twingle`
#### Configure CiviSEPA integration
Open "Configure extension settings" at
`/civicrm/admin/settings/twingle/settings` and configure whether to integrate
with the [CiviSEPA](https://github.com/project60/org.project60.sepa) extension.
This enables you to map incoming donations from Twingle with a specific payment
method (e.g. *debit_manual*) to be processed with CiviSEPA, that is, creating a
SEPA mandate and managing recurring payments.
#### Configure profiles
Open "Configure profiles" at `/civicrm/admin/settings/twingle/profiles`.
The *default* profile is used whenever the plugin cannot match the Twingle
project ID from any other profile. Therefore the default profile will be used
for all newly created Twingle projects.
| Label | Description |
|---------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Profile name | Internal name, used inside the extension. |
| Project IDs | Twingle project IDs. Separate multiple IDs with commas. |
| Location type | Specify how the address data sent by the form should be categorised in CiviCRM. The list is based on your CiviCRM configuration. |
| Location type for organisations | Specify how the address data sent by the form should be categorised in CiviCRM for organisational donations. The list is based on your CiviCRM configuration. |
| Financial type | Specify which financial type incoming one-time donations should be recorded with in CiviCRM. The list is based on your CiviCRM configuration. |
| Financial type (recurring) | Specify which financial type incoming recurring donations should be recorded with in CiviCRM. The list is based on your CiviCRM configuration. |
| CiviSEPA creditor | When enabled to integrate with CiviSEPA, specify the CiviSEPA creditor to use. |
| Gender options | Specify which CiviCRM gender option the incoming Twingle gender value should be mapped to. The list is based on your CiviCRM configuration. |
| Record *Payment method* as | Specifiy the payment methods mapping for incoming donations for each Twingle payment method. |
| Sign up for groups | Whenever the donor checked the newsletter/postal mailing/donation receipt checkbox on the Twingle form, the contact will be added to the groups listed here. |
| Assign donation to campaign | The donation will be assigned to the selected campaign. If a campaign ID is being submitted using the `campaign_id` parameter, this setting will be overridden with the submitted value. |
| Create membership of type | A membership of the selected type will be created for the Individual contact for incoming one-time donations. If no membership type is selected, no membership will be created. |
| Create membership of type (recurring) | A membership of the selected type will be created for the Individual contact for incoming recurring donations. If no membership type is selected, no membership will be created. |
| Contribution source | The configured value will be set as the "Source" field for the contribution. |
| Custom field mapping | Additional field values may be set to CiviCRM custom fields using a mapping. See the option's help text for the exact format. |
## API documentation
The extension provides a new CiviCRM API entity `TwingleDonation` with API
actions to record a new donation, end a previously submitted recurring donation
and cancel previously submitted donation.
### Submit donation
This API action processes submitted Twingle donations and donor information.
- Entity: `TwingleDonation`
- Action: `Submit`
The action accepts the following parameters:
| Parameter | Type | Description | Values/Format | Required |
|----------------------------------------|---------|-------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------|
| <nobr>`project_id`</nobr> | String | The Twingle project ID | | Yes |
| <nobr>`trx_id`</nobr> | String | The unique transaction ID of the donation | A unique transaction ID for the donation. | Yes |
| <nobr>`confirmed_at`</nobr> | String | The date when the donation was issued | A string representing a date in the format `YmdHis` | Yes |
| <nobr>`purpose`</nobr> | String | The purpose of the donation | | |
| <nobr>`amount`</nobr> | Integer | The donation amount in minor currency unit | | Yes |
| <nobr>`currency`</nobr> | String | The ISO-4217 currency code of the donation | A valid ISO-4217 currency code | Yes |
| <nobr>`newsletter`</nobr> | Boolean | Whether to subscribe the contact to the newsletter group defined in the profile | | |
| <nobr>`postinfo`</nobr> | Boolean | Whether to subscribe the contact to the postal mailing group defined in the profile | | |
| <nobr>`donation_receipt`</nobr> | Boolean | Whether the contact requested a donation receipt | | |
| <nobr>`payment_method`</nobr> | String | The Twingle payment method used for the donation | One of:<br /><ul><li><nobr>`banktransfer`</nobr></li><li><nobr>`debit_manual`</nobr></li><li><nobr>`debit_automatic`</nobr></li><li><nobr>`creditcard`</nobr></li><li><nobr>`mobilephone_germany`</nobr></li><li><nobr>`paypal`</nobr></li><li><nobr>`sofortueberweisung`</nobr></li><li><nobr>`amazonpay`</nobr></li><li><nobr>`paydirekt`</nobr></li><li><nobr>`applepay`</nobr></li><li><nobr>`googlepay`</nobr></li></ul> | Yes |
| <nobr>`donation_rhythm`</nobr> | String | The interval which the donation is recurring in | One of:<br /><ul><li><nobr>`'one_time',`</nobr></li><li><nobr>`'halfyearly',`</nobr></li><li><nobr>`'quarterly',`</nobr></li><li><nobr>`'yearly',`</nobr></li><li><nobr>`'monthly'`</nobr></li></ul> | Yes |
| <nobr>`debit_iban`</nobr> | String | The IBAN for SEPA Direct Debit payments | A valid ISO 13616-1:2007 IBAN | Yes, if `payment_method` is `debit_manual` and CiviSEPA is used |
| <nobr>`debit_bic`</nobr> | String | The BIC for SEPA Direct Debit payments | A valid ISO 9362 BIC | Yes, if `payment_method` is `debit_manual` and CiviSEPA is used |
| <nobr>`debit_mandate_reference`</nobr> | String | The mandate reference for SEPA Direct Debit payments | | |
| <nobr>`debit_account_holder`</nobr> | String | The account holder for SEPA Direct Debit payments | | |
| <nobr>`is_anonymous`</nobr> | Boolean | Whether the donation is submitted anonymously | | |
| <nobr>`user_gender`</nobr> | String | The gender of the contact | | |
| <nobr>`user_birthdate`</nobr> | String | The date of birth of the contact | A string representing a date in the format `Ymd` | |
| <nobr>`user_title`</nobr> | String | The formal title of the contact | | |
| <nobr>`user_email`</nobr> | String | The e-mail address of the contact | A valid e-mail address | |
| <nobr>`user_firstname`</nobr> | String | The first name of the contact | | |
| <nobr>`user_lastname`</nobr> | String | The last name of the contact | | |
| <nobr>`user_street`</nobr> | String | The street address of the contact | | |
| <nobr>`user_postal_code`</nobr> | String | The postal code of the contact | | |
| <nobr>`user_city`</nobr> | String | The city of the contact | | |
| <nobr>`user_country`</nobr> | String | The country of the contact | [ISO 3166-1 Alpha-2 country codes](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements) | |
| <nobr>`user_telephone`</nobr> | String | The telephone number of the contact | | |
| <nobr>`user_company`</nobr> | String | The company of the contact | | |
| <nobr>`user_extrafield`</nobr> | String | Additional information of the contact | | |
| <nobr>`campaign_id`</nobr> | Integer | The CiviCRM ID of a campaign to assign the contribution | A valid CiviCRM Campaign ID. This overrides the campaign ID configured within the profile. | |
You may also refer to
[the code](https://github.com/systopia/de.systopia.twingle/blob/master/api/v3/TwingleDonation/Submit.php)
for more insight into this API action.
### End recurring donation
- Entity: `TwingleDonation`
- Action: `Endrecurring`
The action accepts the following parameters:
| Parameter | Type | Description | Values/Format | Required |
|---------------------------|---------|------------------------------------------------|-------------------------------------------------------|----------|
| <nobr>`project_id`</nobr> | String | The Twingle project ID | | Yes |
| <nobr>`trx_id`</nobr> | String | The unique transaction ID of the donation | A unique transaction ID for the donation. | Yes |
| <nobr>`ended_at`</nobr> | Integer | The date when the recurring donation was ended | A string representing a date in the format `YmdHis` | Yes |
You may also refer to
[the code](https://github.com/systopia/de.systopia.twingle/blob/master/api/v3/TwingleDonation/Endrecurring.php)
for more insight into this API action.
### Cancel donation
- Entity: `TwingleDonation`
- Action: `Cancel`
The action accepts the following parameters:
| Parameter | Type | Description | Values/Format | Required |
|------------------------------|--------|----------------------------------------------------|-------------------------------------------------------|----------|
| <nobr>`project_id`</nobr> | String | The Twingle project ID | | Yes |
| <nobr>`trx_id`</nobr> | String | The unique transaction ID of the donation | A unique transaction ID for the donation. | Yes |
| <nobr>`cancelled_at`</nobr> | String | The date when the recurring donation was cancelled | A string representing a date in the format `YmdHis` | Yes |
| <nobr>`cancel_reason`</nobr> | String | The reason for the donation being cancelled | | Yes |
You may also refer to
[the code](https://github.com/systopia/de.systopia.twingle/blob/master/api/v3/TwingleDonation/Cancel.php)
for more insight into this API action.

View file

@ -13,67 +13,69 @@
| 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
*/ */
function civicrm_api3_twingle_donation_Cancel($params) { function civicrm_api3_twingle_donation_Cancel($params) {
// Log call if debugging is enabled within civicrm.settings.php. // Log call if debugging is enabled within civicrm.settings.php.
if (defined('TWINGLE_API_LOGGING') && TWINGLE_API_LOGGING) { if (defined('TWINGLE_API_LOGGING') && TWINGLE_API_LOGGING) {
CRM_Core_Error::debug_log_message('TwingleDonation.Cancel: ' . json_encode($params, JSON_PRETTY_PRINT)); Civi::log()->debug('TwingleDonation.Cancel: ' . json_encode($params, JSON_PRETTY_PRINT));
} }
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,69 +13,71 @@
| 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
*/ */
function civicrm_api3_twingle_donation_endrecurring($params) { function civicrm_api3_twingle_donation_endrecurring($params) {
// Log call if debugging is enabled within civicrm.settings.php. // Log call if debugging is enabled within civicrm.settings.php.
if (defined('TWINGLE_API_LOGGING') && TWINGLE_API_LOGGING) { if (defined('TWINGLE_API_LOGGING') && TWINGLE_API_LOGGING) {
CRM_Core_Error::debug_log_message('TwingleDonation.Endrecurring: ' . json_encode($params, JSON_PRETTY_PRINT)); Civi::log()->debug('TwingleDonation.Endrecurring: ' . json_encode($params, JSON_PRETTY_PRINT));
} }
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); }
}

87
docs/api.md Normal file
View file

@ -0,0 +1,87 @@
# API documentation
The extension provides a new CiviCRM API 3 entity `TwingleDonation` with API
actions to record a new donation, end a previously submitted recurring donation
and cancel previously submitted donation.
### Submit donation
This API action processes submitted Twingle donations and donor information.
- Entity: `TwingleDonation`
- Action: `Submit`
The action accepts the following parameters:
| Parameter | Type | Description | Values/Format | Required |
| -------------------------------------- | ------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| <nobr>`project_id`</nobr> | String | The Twingle project ID | | Yes |
| <nobr>`trx_id`</nobr> | String | The unique transaction ID of the donation | A unique transaction ID for the donation. | Yes |
| <nobr>`confirmed_at`</nobr> | String | The date when the donation was issued | A string representing a date in the format `YmdHis` | Yes |
| <nobr>`purpose`</nobr> | String | The purpose of the donation | | |
| <nobr>`amount`</nobr> | Integer | The donation amount in minor currency unit | | Yes |
| <nobr>`currency`</nobr> | String | The ISO-4217 currency code of the donation | A valid ISO-4217 currency code | Yes |
| <nobr>`newsletter`</nobr> | Boolean | Whether to subscribe the contact to the newsletter group defined in the profile | | |
| <nobr>`postinfo`</nobr> | Boolean | Whether to subscribe the contact to the postal mailing group defined in the profile | | |
| <nobr>`donation_receipt`</nobr> | Boolean | Whether the contact requested a donation receipt | | |
| <nobr>`payment_method`</nobr> | String | The Twingle payment method used for the donation | One of:<br /><ul><li><nobr>`banktransfer`</nobr></li><li><nobr>`debit_manual`</nobr></li><li><nobr>`debit_automatic`</nobr></li><li><nobr>`creditcard`</nobr></li><li><nobr>`mobilephone_germany`</nobr></li><li><nobr>`paypal`</nobr></li><li><nobr>`sofortueberweisung`</nobr></li><li><nobr>`amazonpay`</nobr></li><li><nobr>`paydirekt`</nobr></li><li><nobr>`applepay`</nobr></li><li><nobr>`googlepay`</nobr></li></ul> | Yes |
| <nobr>`donation_rhythm`</nobr> | String | The interval which the donation is recurring in | One of:<br /><ul><li><nobr>`'one_time',`</nobr></li><li><nobr>`'halfyearly',`</nobr></li><li><nobr>`'quarterly',`</nobr></li><li><nobr>`'yearly',`</nobr></li><li><nobr>`'monthly'`</nobr></li></ul> | Yes |
| <nobr>`debit_iban`</nobr> | String | The IBAN for SEPA Direct Debit payments | A valid ISO 13616-1:2007 IBAN | Yes, if `payment_method` is `debit_manual` and CiviSEPA is used |
| <nobr>`debit_bic`</nobr> | String | The BIC for SEPA Direct Debit payments | A valid ISO 9362 BIC | Yes, if `payment_method` is `debit_manual` and CiviSEPA is used |
| <nobr>`debit_mandate_reference`</nobr> | String | The mandate reference for SEPA Direct Debit payments | | |
| <nobr>`debit_account_holder`</nobr> | String | The account holder for SEPA Direct Debit payments | | |
| <nobr>`is_anonymous`</nobr> | Boolean | Whether the donation is submitted anonymously | | |
| <nobr>`user_gender`</nobr> | String | The gender of the contact | | |
| <nobr>`user_birthdate`</nobr> | String | The date of birth of the contact | A string representing a date in the format `Ymd` | |
| <nobr>`user_title`</nobr> | String | The formal title of the contact | | |
| <nobr>`user_email`</nobr> | String | The e-mail address of the contact | A valid e-mail address | |
| <nobr>`user_firstname`</nobr> | String | The first name of the contact | | |
| <nobr>`user_lastname`</nobr> | String | The last name of the contact | | |
| <nobr>`user_street`</nobr> | String | The street address of the contact | | |
| <nobr>`user_postal_code`</nobr> | String | The postal code of the contact | | |
| <nobr>`user_city`</nobr> | String | The city of the contact | | |
| <nobr>`user_country`</nobr> | String | The country of the contact | A [ISO 3166-1 Alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements) | |
| <nobr>`user_telephone`</nobr> | String | The telephone number of the contact | | |
| <nobr>`user_company`</nobr> | String | The company of the contact | | |
| <nobr>`user_extrafield`</nobr> | String | Additional information of the contact | | |
| <nobr>`user_language`</nobr> | String | The preferred language of the contact. | A [ISO-639-1 2-digit language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) | |
| <nobr>`campaign_id`</nobr> | Integer | The CiviCRM ID of a campaign to assign the contribution | A valid CiviCRM Campaign ID. This overrides the campaign ID configured within the profile. | |
You may also refer to
[the code](https://github.com/systopia/de.systopia.twingle/blob/master/api/v3/TwingleDonation/Submit.php)
for more insight into this API action.
### End recurring donation
- Entity: `TwingleDonation`
- Action: `Endrecurring`
The action accepts the following parameters:
| Parameter | Type | Description | Values/Format | Required |
| ------------------------- | ------- | ---------------------------------------------- | --------------------------------------------------- | -------- |
| <nobr>`project_id`</nobr> | String | The Twingle project ID | | Yes |
| <nobr>`trx_id`</nobr> | String | The unique transaction ID of the donation | A unique transaction ID for the donation. | Yes |
| <nobr>`ended_at`</nobr> | Integer | The date when the recurring donation was ended | A string representing a date in the format `YmdHis` | Yes |
You may also refer to
[the code](https://github.com/systopia/de.systopia.twingle/blob/master/api/v3/TwingleDonation/Endrecurring.php)
for more insight into this API action.
### Cancel donation
- Entity: `TwingleDonation`
- Action: `Cancel`
The action accepts the following parameters:
| Parameter | Type | Description | Values/Format | Required |
| ---------------------------- | ------ | -------------------------------------------------- | --------------------------------------------------- | -------- |
| <nobr>`project_id`</nobr> | String | The Twingle project ID | | Yes |
| <nobr>`trx_id`</nobr> | String | The unique transaction ID of the donation | A unique transaction ID for the donation. | Yes |
| <nobr>`cancelled_at`</nobr> | String | The date when the recurring donation was cancelled | A string representing a date in the format `YmdHis` | Yes |
| <nobr>`cancel_reason`</nobr> | String | The reason for the donation being cancelled | | Yes |
You may also refer to
[the code](https://github.com/systopia/de.systopia.twingle/blob/master/api/v3/TwingleDonation/Cancel.php)
for more insight into this API action.

View file

@ -0,0 +1,27 @@
# Twingle account settings on the Twingle website
The use of the Twingle API extension requires that you already have a Twingle
account and that you make some settings in this account for the connection.
You will have to provide the following settings in your Twingle account settings
in order to send donations to CiviCRM:
1. API key from your Twingle user
2. Site key
3. URL
Important: The URL must always be the complete URL to the CiviCRM REST API
endpoint.
Examples:
- Drupal (with the *AuthX* extension): https://example.org/civicrm/ajax/rest
- Drupal (legacy
method): https://example.org/sites/all/modules/civicrm/extern/rest.php
- Wordpress (with CiviCRM <
5.25): https://example.org/wp-content/plugins/civicrm/civicrm/extern/rest.php
- Wordpress (with CiviCRM
5.25+): https://example.org/wp-json/civicrm/v3/rest
For detailled information, please see
the [Twingle documentation](https://support.twingle.de/faq/de-de/9-anbindung-externer-systeme/46-wie-kann-ich-civicrm-mit-twingle-nutzen) (
in German language only).

View file

@ -0,0 +1,32 @@
# Activating CiviSEPA integration
The Twingle API extension provides integration with the [
*CiviSEPA*](https://civicrm.org/extensions/civisepa-sepa-direct-debit-extension)
extension. This allows for managing SEPA mandates and collections with
*CiviSEPA* for donations being initiated via a *Twingle* form.
1. In CiviCRM, go to **Administer**.
2. Choose **Twingle API configuration**.
![](../img/Konso.jpg)
3. Then click on **Configure extension settings**.
![](../img/SepaKon.jpg)
4. Tick the boxes **Use CiviSEPA** and **Use CiviSEPA generated reference**.
These options can only be activated if CiviSEPA is installed and used. If it
is not activated, the administration of SEPA mandates will have to take place
in Twingle, which is subject to configuration of your available payment
methods.
5. Write **TW-** in the **Twingle ID Prefix** field.
To avoid overlaps when assigning CiviCRM IDs and Twingle transaction IDs, a
prefix should be assigned here, e.g. "TWNGL" or "Twingle" or similar.
Attention: The prefix should not be changed later, otherwise problems may
occur.
6. In the **Protect Recurring Contributions** field select **No**.
If you choose Yes, all recurring donations created by Twingle can no longer
be changed in CiviCRM, but must then be changed accordingly in Twingle. If no
recurring payments are processed via Twingle, but only one-off donations,
then this does not need to be activated. Otherwise, we strongly recommend
setting the button here to **Yes** so that there are no discrepancies between
CiviCRM and Twingle.
![](../img/Sepa.jpg)

View file

@ -0,0 +1,30 @@
# Configuring the Twingle Profile in CiviCRM
The Twingle API extension is being configured through configuration profiles.
This allows you to have different sets of configuration, as you might want to
handle donation submissions differently, depending on which form was used.
Each profile can react to one ore more form IDs being submitted along with
donation data.
1. In CiviCRM, go to **Administer**.
2. Choose **Twingle API configuration**.
![](../img/Konso.jpg)
3. Then click on **Configure profiles**.
![](../img/SepaKon.jpg)
4. The Twingle configuration is always done with the help of a profile. Please
use the Twingle default profile and click on **Edit**.
![](../img/Prof.jpg)
5. Then you will identify the Twingle API profile window. Start by entering the
corresponding information in the **General settings** section.
![](../img/GenSet.jpg)
6. Define the different payment methods in the Payments section.
![](../img/twpay.jpg)
7. Make the settings for the groups.
![](../img/Twgrou.jpg)
8. When you have made all the settings, please press the **Save** button.

View file

@ -0,0 +1,82 @@
# User, permissions, and API authentication
After the installation of the Twingle API extension, various configuration steps
must be carried out so that the connection functions smoothly. Among other
things, certain configurations must be made on the CMS platform CiviCRM is
implemented on.
Connecting to the Twingle API via CiviCRM's REST interface requires a user with
appropriate permissions in your CMS-system.
You might want to create a specific user role to assign permissions necessary
for calling the Twingle API only. This section describes how to accomplish this
in *Drupal* and *Wordpress*:
## New User Role in Drupal
1. In Drupal, go to **Administration/People/Permissions/Roles**.
2. Type Twingle API in the text box and select **Add role**. To the right of
your role there will be a *edit role* function and an *edit permissions*
button. The *edit permissions* selection will show only the permission
selections for the individual role.
3. As Permission you only have to select the following entry: **Twingle API:
Access Twingle API**.
## New User Role in Wordpress
1. In CiviCRM, go to **Administer/User and Permissions (Access Control)**.
2. Then select the **WordPress Access Control** link.
Here you can adjust the CiviCRM settings for each of the predefined User
Roles from WordPress.
3. Scroll down. As Permission you only have to select the following entry: *
*Twingle API: Access Twingle API**.
![](../img/Twin_per.png)
## New User in Drupal
1. In Drupal, go to **Administration/People**.
2. Then select **Add user**.
3. In user name field enter something like **Twingle API**
5. In Roles select **Twingle API**.
## Take over user
The Twingle API only works correctly if a contact connected to the permissioned
user exists in CiviCRM.
Here, the corresponding steps are described by way of example when using Drupal.
1. In CiviCRM, go to **Administer**.
2. In the **Users and Permissions** section, choose **Synchronize Users to
Contacts**.
![](../img/Kon_syn.jpg)
This function checks each user record in Drupal for a contact record in CiviCRM.
If there is no corresponding contact record for a user, a new one will be
generated. Check this in your CiviCRM contact management.
![](../img/civiuser_tw.jpg)
## Assign API key for the Twingle API user
The Twingle API contact in CiviCRM needs their own API key for authenticating
against CiviCRM's REST API endpoint. The API key is assigned with the help of
the API Explorer in CiviCRM.
1. Select the Twingle API contact in CiviCRM.
2. Look for the corresponding **CiviCRM ID** and remember the ID.
3. Go to **Support/Developper/API Explorer v4**.
4. Enter **Contact** in the entity field, **create** in action field and the
**ID** of the Twingle User in the **index** field.
5. In the values field, select **api_key**.
6. Enter the API key for the Twingle API user in the **add value** field.
7. Click on **Execute**.
![](../img/apikey.jpg)
!!!note
You can also create API keys for contacts by using the [*API
Key*](https://civicrm.org/extensions/api-key) extension or with administrator
tools like *cv* or *drush*.

75
docs/configuration/xcm.md Normal file
View file

@ -0,0 +1,75 @@
# Configuring the Extended Contact Manager extension (XCM)
After the installation of the Twingle API extension, various configuration steps
must be carried out so that the connection functions smoothly. Twingle API
depends on the *Extended Contact Manager (XCM)* extension.
Taking over contact data using the Twingle API means that they may produce
duplicates in your CiviCRM contact management. Before contacts are added or
updated in CiviCRM a data check should take place to avoid this problem. This
data check is handled by the *Extended Contact Manager (XCM)* extension. This
extension must be configured accordingly for use with Twingle by defining a
corresponding profile.
## Creating an XCM Profile
Your first task regarding the Extended Contact Manager extension (XCM)
configuration will be to create an XCM profile to be used for the Twingle API.
This works best if you copy the *Default* profile.
1. In CiviCRM, go to **Administer**.
2. Select **Xtended Contact Matcher (XCM) Configuration** in the **System
Settings** section.
![](../img/XCMAdmin.jpg)
3. Click on **Copy** in the **Default** profile.
4. Rename the new profile with **Twingle** in the **Profile name** field.
![](../img/ProNam.jpg)
5. Click **Save** at the bottom of this window. In the Profiles overview you can
find your new Twingle profile.
![](../img/XCM_Profile.jpg)
## Set up the Extended Contact Manager extension
After you have created the XCM profile, you must enter the configuration
settings for the Twingle connection to CiviCRM in this profile. Generally, you
will find a description of all the settings in
the [Extended Contact Manager (XCM) documentation](https://docs.civicrm.org/xcm/en/latest/configuration/).
Here you will find as support screenshots of the various sections of the
Extended Contact Manager extension (XCM). The settings are only an example.
Please adapt the settings to your individual requirements or environnement.
#### General section
![](../img/XCMGen.jpg)
#### Update section
![](../img/XCMUpda.jpg)
#### Assignment rules section
![](../img/XCMReg.jpg)
#### Identified contacts section
![](../img/XCMIde.jpg)
#### New contact section
![](../img/XCMNeu.jpg)
#### Duplicate section
![](../img/XCMDup.jpg)
#### Difference Handling section
![](../img/xcmdif.jpg)

BIN
docs/img/GenSet.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
docs/img/Kon_syn.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
docs/img/Konso.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
docs/img/NewUser_Tw.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
docs/img/ProNam.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
docs/img/Prof.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
docs/img/Role_Twingle.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
docs/img/Sepa.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
docs/img/SepaKon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
docs/img/Twgrou.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
docs/img/Twin_per.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
docs/img/XCMAdmin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
docs/img/XCMAkt.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
docs/img/XCMDup.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
docs/img/XCMGen.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
docs/img/XCMIde.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
docs/img/XCMNeu.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
docs/img/XCMPro.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
docs/img/XCMReg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
docs/img/XCMUpda.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
docs/img/XCM_Profile.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
docs/img/apikey.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
docs/img/civiuser_tw.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
docs/img/twpay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
docs/img/xcmdif.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -1 +0,0 @@
../README.md

15
docs/index.md Normal file
View file

@ -0,0 +1,15 @@
# Twingle API
Twingle is a payment service provider that makes it possible to create donation forms with various payment options and embed them on websites or integrate them into your homepage. Interested parties can donate via known payment options ( e.g. credit card, PayPal). The procedure is also set up and optimised for mobile devices. If you want to use the Twingle fundraising service, you have to set up a corresponding online account.
For further information about Twingle fundraising see the [Twingle](https://www.twingle.de) website.
Twingle as fundraising service can be connected to CiviCRM via its API with the extension Twingle API.
## Features
* Donations from Twingle can be automatically created as contributions in CiviCRM and assigned to existing or new contacts and administered in CiviCRM.
* Supporters and contacts of donations can be managed in CiviCRM.
* Donations can be submitted with different payment statuses depending on the payment type
* SEPA mandates can be created for one-off and recurring payments.
* Donors can be added to groups for receiving newsletters, mailings and donation receipts.
* A membership can be set up for a donor.
* Data can be entered in user-defined fields

19
docs/installation.md Normal file
View file

@ -0,0 +1,19 @@
# Installation
You can find an official release archive from
the [release page](https://github.com/systopia/de.systopia.twingle).
1. First, download and then unpack the archive and move the directory into your
CiviCRM extensions folder (e.g.,`.../civicrm/ext/`.
If you don't know where your extensions folder is, just have a look in your
CiviCRM settings ( **Administer**/**System Settings**/**Directories**)).
2. Next, open the extensions page in the CiviCRM settings (**Administer**/*
*System Settings**/**Extensions**).
3. Find the extension Twingle API in the*Extensions*tab and click on**Install**.
The extension will be set up.
## Extended Contact Matcher (XCM)
Please note that for the correct working of Twingle API you still need to
install the extension Extended Contact Matcher (XCM), see
the [documentation](https://docs.civicrm.org/xcm).

View file

@ -10,22 +10,33 @@
</maintainer> </maintainer>
<urls> <urls>
<url desc="Main Extension Page">https://github.com/systopia/de.systopia.twingle</url> <url desc="Main Extension Page">https://github.com/systopia/de.systopia.twingle</url>
<url desc="Documentation">https://github.com/systopia/de.systopia.twingle/blob/master/README.md</url> <url desc="Documentation">https://docs.civicrm.org/twingle/en/latest</url>
<url desc="Support">https://github.com/systopia/de.systopia.twingle/issues</url> <url desc="Support">https://github.com/systopia/de.systopia.twingle/issues</url>
<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.2-dev</version> <version>1.6-dev</version>
<develStage>dev</develStage> <develStage>dev</develStage>
<compatibility> <compatibility>
<ver>5.0</ver> <ver>5.58</ver>
<ver>5.19</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>23.02.1</format>
</civix> </civix>
<mixins>
<mixin>menu-xml@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>
<upgrader>CRM_Twingle_Upgrader</upgrader>
</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

@ -0,0 +1,93 @@
<?php
/*
* Copyright (C) 2023 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/>.
*/
use CRM_Twingle_ExtensionUtil as E;
return [
[
'name' => 'Navigation__twingle_configuration',
'entity' => 'Navigation',
'cleanup' => 'unused',
'update' => 'unmodified',
'params' => [
'version' => 4,
'values' => [
'domain_id' => 'current_domain',
'label' => E::ts('Twingle API Configuration'),
'name' => 'twingle_configuration',
'url' => 'civicrm/admin/settings/twingle',
'icon' => NULL,
'permission' => [
'administer CiviCRM',
],
'permission_operator' => 'OR',
'parent_id.name' => 'CiviContribute',
'is_active' => TRUE,
'has_separator' => 0,
],
'match' => ['name', 'parent_id'],
],
],
[
'name' => 'Navigation__twingle_settings',
'entity' => 'Navigation',
'cleanup' => 'unused',
'update' => 'unmodified',
'params' => [
'version' => 4,
'values' => [
'domain_id' => 'current_domain',
'label' => E::ts('Twingle API Settings'),
'name' => 'twingle_settings',
'url' => 'civicrm/admin/settings/twingle/settings',
'icon' => NULL,
'permission' => [
'administer CiviCRM',
],
'permission_operator' => 'OR',
'parent_id.name' => 'twingle_configuration',
'is_active' => TRUE,
'has_separator' => 0,
],
'match' => ['name', 'parent_id'],
],
],
[
'name' => 'Navigation__twingle_profiles',
'entity' => 'Navigation',
'cleanup' => 'unused',
'update' => 'unmodified',
'params' => [
'version' => 4,
'values' => [
'domain_id' => 'current_domain',
'label' => E::ts('Twingle API Profiles'),
'name' => 'twingle_profiles',
'url' => 'civicrm/admin/settings/twingle/profiles',
'icon' => NULL,
'permission' => [
'administer CiviCRM',
],
'permission_operator' => 'OR',
'parent_id.name' => 'twingle_configuration',
'is_active' => TRUE,
'has_separator' => 0,
],
'match' => ['name', 'parent_id'],
],
],
];

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

View file

@ -1,20 +1,33 @@
site_name: Twingle API site_name: Twingle API
repo_url: https://github.com/systopia/de.systopia.twingle repo_url: https://github.com/systopia/de.systopia.twingle
theme: theme:
name: material name: material
nav: nav:
- 'Home': index.md - Introduction: index.md
- Installation: installation.md
- Configuration:
- User, permissions and API authentication: configuration/user_permissions.md
- Extended Contact Manager (XCM) extension: configuration/xcm.md
- CiviSEPA integration: configuration/civisepa.md
- Twingle Profiles: configuration/profiles.md
- Configuring the Twingle Account on the Twingle website: configuration/account.md
- API documentation: api.md
markdown_extensions: markdown_extensions:
- attr_list - attr_list
- admonition - admonition
- def_list - def_list
- codehilite - pymdownx.highlight:
- toc: guess_lang: false
permalink: true - toc:
- pymdownx.superfences permalink: true
- pymdownx.inlinehilite - pymdownx.superfences
- pymdownx.tilde - pymdownx.inlinehilite
- pymdownx.betterem - pymdownx.tilde
- pymdownx.mark - pymdownx.betterem
- pymdownx.mark
plugins:
- search:
lang: en

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,16 @@
-- /*******************************************************
-- ** civicrm_twingle_profile
-- **
-- ** stores twingle profile data v1.4+
-- ********************************************************/
CREATE TABLE IF NOT EXISTS `civicrm_twingle_profile`(
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`name` varchar(255) COMMENT 'configuration name, i.e. internal ID',
`config` text COMMENT 'JSON encoded configuration',
`last_access` datetime COMMENT 'timestamp of the last access (through the api)',
`access_counter` int unsigned COMMENT 'number of accesses (through the api)',
PRIMARY KEY (`id`),
UNIQUE INDEX (`name`)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;

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;

Some files were not shown because too many files have changed in this diff Show more