| @@ -610,8 +610,26 @@ | ||
| 610 | 610 | |
| 611 | 611 | return apply_filters('fluentform/available_payment_statuses', $paymentStatuses); |
| 612 | 612 | } |
| 613 | 613 | |
| 614 | + public static function reversedPaymentStatuses() | |
| 615 | + { | |
| 616 | + return apply_filters('fluentform/reversed_payment_statuses', [ | |
| 617 | + 'refunded', 'partially-refunded', 'cancelled', | |
| 618 | + ]); | |
| 619 | + } | |
| 620 | + | |
| 621 | + public static function isReversedPaymentStatus($status) | |
| 622 | + { | |
| 623 | + $status = is_null($status) ? '' : (string) $status; | |
| 624 | + | |
| 625 | + if ('' === $status) { | |
| 626 | + return false; | |
| 627 | + } | |
| 628 | + | |
| 629 | + return in_array($status, static::reversedPaymentStatuses(), true); | |
| 630 | + } | |
| 631 | + | |
| 614 | 632 | public static function getFormPaymentMethods($formId) |
| 615 | 633 | { |
| 616 | 634 | $inputs = FormFieldsParser::getInputs($formId, ['element', 'settings']); |
| 617 | 635 | foreach ($inputs as $field) { |
| @@ -799,23 +817,47 @@ | ||
| 799 | 817 | |
| 800 | 818 | $stripe = ArrayHelper::get($methods, 'stripe'); |
| 801 | 819 | $stripeInlineStyles = ArrayHelper::get(Helper::getFormMeta($formId, '_ff_form_styles', []), 'stripe_inline_element_style', false); |
| 802 | 820 | if ($stripe) { |
| 803 | - return apply_filters( | |
| 804 | - 'fluentform/stripe_inline_config', | |
| 805 | - [ | |
| 806 | - 'is_inline' => ArrayHelper::get($stripe, 'settings.embedded_checkout.value') == 'yes', | |
| 807 | - 'inline_styles' => $stripeInlineStyles, | |
| 808 | - 'verifyZip' => ArrayHelper::get($methods['stripe'], 'settings.verify_zip_code.value') === 'yes', | |
| 809 | - 'disable_link' => false | |
| 810 | - ], | |
| 811 | - $formId | |
| 812 | - ); | |
| 821 | + $config = [ | |
| 822 | + 'is_inline' => ArrayHelper::get($stripe, 'settings.embedded_checkout.value') == 'yes', | |
| 823 | + 'inline_styles' => $stripeInlineStyles, | |
| 824 | + 'verifyZip' => ArrayHelper::get($methods['stripe'], 'settings.verify_zip_code.value') === 'yes', | |
| 825 | + 'disable_link' => false, | |
| 826 | + 'enable_payment_element' => static::isStripePaymentElement($stripe), | |
| 827 | + 'is_zero_decimal' => static::isZeroDecimal(static::getFormCurrency($formId)), | |
| 828 | + ]; | |
| 829 | + | |
| 830 | + // Classic and conversational forms both create the Payment Element with these options | |
| 831 | + if ($config['enable_payment_element']) { | |
| 832 | + $config['payment_element_options'] = apply_filters( | |
| 833 | + 'fluentform/stripe_payment_element_options', | |
| 834 | + [ | |
| 835 | + 'layout' => 'tabs', | |
| 836 | + 'wallets' => ['applePay' => 'auto', 'googlePay' => 'auto', 'link' => 'never'], | |
| 837 | + 'fields' => ['billingDetails' => ['address' => $config['verifyZip'] ? 'auto' : 'if_required']], | |
| 838 | + 'appearance' => [], | |
| 839 | + ], | |
| 840 | + $formId | |
| 841 | + ); | |
| 842 | + } | |
| 843 | + | |
| 844 | + return apply_filters('fluentform/stripe_inline_config', $config, $formId); | |
| 813 | 845 | } |
| 814 | 846 | |
| 815 | 847 | return []; |
| 816 | 848 | } |
| 817 | 849 | |
| 850 | + /** | |
| 851 | + * Stripe's Payment Element (card plus Apple Pay / Google Pay) replaces the Card Element on the embedded field, | |
| 852 | + * so it only applies with Embedded Checkout on. | |
| 853 | + */ | |
| 854 | + public static function isStripePaymentElement($stripeMethod) | |
| 855 | + { | |
| 856 | + return ArrayHelper::get($stripeMethod, 'settings.embedded_checkout.value') === 'yes' | |
| 857 | + && ArrayHelper::get($stripeMethod, 'settings.enable_payment_element.value') === 'yes'; | |
| 858 | + } | |
| 859 | + | |
| 818 | 860 | public static function log($data, $submission = false, $forceInsert = false) |
| 819 | 861 | { |
| 820 | 862 | if (!$forceInsert) { |
| 821 | 863 | static $paymentSettings; |
| @@ -855,8 +897,12 @@ | ||
| 855 | 897 | } |
| 856 | 898 | |
| 857 | 899 | $form = \FluentForm\App\Models\Form::find($submission->form_id); |
| 858 | 900 | |
| 901 | + if (!apply_filters('fluentform/should_process_submission_actions', true, $submission, $form)) { | |
| 902 | + return false; | |
| 903 | + } | |
| 904 | + | |
| 859 | 905 | $formData = $submission->response; |
| 860 | 906 | if (!is_array($formData)) { |
| 861 | 907 | $formData = json_decode($formData, true); |
| 862 | 908 | } |
| @@ -1005,15 +1051,18 @@ | ||
| 1005 | 1051 | ); |
| 1006 | 1052 | |
| 1007 | 1053 | $billingInterval = $plan['billing_interval']; |
| 1008 | 1054 | $billingInterval = ArrayHelper::get(self::getBillingIntervals(), $billingInterval, $billingInterval); |
| 1055 | + $billingInterval = esc_html($billingInterval); | |
| 1056 | + $trialDays = esc_html(ArrayHelper::get($plan, 'trial_days')); | |
| 1057 | + $billTimes = esc_html(ArrayHelper::get($plan, 'bill_times')); | |
| 1009 | 1058 | $replaces = array( |
| 1010 | 1059 | '{signup_fee}' => '<span class="ff_bs ffbs_signup_fee">' . $signupFee . '</span>', |
| 1011 | 1060 | '{first_interval_total}' => '<span class="ff_bs ffbs_first_interval_total">' . $firstIntervalTotal . '</span>', |
| 1012 | 1061 | '{subscription_amount}' => '<span class="ff_bs ffbs_subscription_amount">' . $subscriptionAmount . '</span>', |
| 1013 | 1062 | '{billing_interval}' => '<span class="ff_bs ffbs_billing_interval">' . $billingInterval . '</span>', |
| 1014 | - '{trial_days}' => '<span class="ff_bs ffbs_trial_days">' . $plan['trial_days'] . '</span>', | |
| 1015 | - '{bill_times}' => '<span class="ff_bs ffbs_bill_times">' . ArrayHelper::get($plan, 'bill_times') . '</span>' | |
| 1063 | + '{trial_days}' => '<span class="ff_bs ffbs_trial_days">' . $trialDays . '</span>', | |
| 1064 | + '{bill_times}' => '<span class="ff_bs ffbs_bill_times">' . $billTimes . '</span>', | |
| 1016 | 1065 | ); |
| 1017 | 1066 | |
| 1018 | 1067 | if (ArrayHelper::get($plan, 'user_input') == 'yes') { |
| 1019 | 1068 | $cases['{subscription_amount}'] = '<span class="ff_dynamic_input_amount">' . $subscriptionAmount . '</span>'; |
| @@ -1023,9 +1072,11 @@ | ||
| 1023 | 1072 | $cases[$textKey] = str_replace(array_keys($replaces), array_values($replaces), $text); |
| 1024 | 1073 | } |
| 1025 | 1074 | |
| 1026 | 1075 | $customText = ''; |
| 1027 | - if ($hasSignupFee) { | |
| 1076 | + if ($hasSignupFee && isset($plan['bill_times']) && $plan['bill_times'] == 1) { | |
| 1077 | + $customText = $cases['onetime_only']; | |
| 1078 | + } else if ($hasSignupFee) { | |
| 1028 | 1079 | $customText = $cases['has_signup_fee']; |
| 1029 | 1080 | } else if ($hasTrial) { |
| 1030 | 1081 | if (ArrayHelper::get($plan, 'bill_times') == 1) { |
| 1031 | 1082 | $customText = $cases['single_trial']; |
| @@ -1042,9 +1093,9 @@ | ||
| 1042 | 1093 | $customText .= $cases['bill_times']; |
| 1043 | 1094 | } |
| 1044 | 1095 | if($withMarkup) { |
| 1045 | 1096 | $class = $plan['is_default'] === 'yes' ? '' : 'hidden_field'; |
| 1046 | - return '<div class="ff_summary_container ff_summary_container_' . $plan['index'] . ' ' . $class . '">' . $customText . '</div>'; | |
| 1097 | + return '<div class="ff_summary_container ff_summary_container_' . esc_attr($plan['index']) . ' ' . $class . '">' . $customText . '</div>'; | |
| 1047 | 1098 | } |
| 1048 | 1099 | return $customText; |
| 1049 | 1100 | } |
| 1050 | 1101 | |
| @@ -1090,8 +1141,36 @@ | ||
| 1090 | 1141 | } |
| 1091 | 1142 | return ''; |
| 1092 | 1143 | } |
| 1093 | 1144 | |
| 1145 | + /** | |
| 1146 | + * Stable encryption key, decoupled from WordPress salts (which some hosts and | |
| 1147 | + * security plugins rotate, silently breaking salt-encrypted payment keys). | |
| 1148 | + * | |
| 1149 | + * For stronger at-rest protection, define FLUENTFORM_ENCRYPTION_KEY in | |
| 1150 | + * wp-config.php so the key lives on the filesystem, not the database next to | |
| 1151 | + * the ciphertext. Set it BEFORE saving keys — defining it after keys are | |
| 1152 | + * stored makes existing values unreadable and requires re-entering them. | |
| 1153 | + * | |
| 1154 | + * @return string | |
| 1155 | + */ | |
| 1156 | + public static function getEncryptionKey() | |
| 1157 | + { | |
| 1158 | + if (defined('FLUENTFORM_ENCRYPTION_KEY') && FLUENTFORM_ENCRYPTION_KEY) { | |
| 1159 | + return FLUENTFORM_ENCRYPTION_KEY; | |
| 1160 | + } | |
| 1161 | + | |
| 1162 | + $key = get_option('_fluentform_encryption_key'); | |
| 1163 | + if (!$key) { | |
| 1164 | + $key = base64_encode(openssl_random_pseudo_bytes(32)); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode | |
| 1165 | + if (!add_option('_fluentform_encryption_key', $key, '', 'no')) { | |
| 1166 | + $key = get_option('_fluentform_encryption_key'); | |
| 1167 | + } | |
| 1168 | + } | |
| 1169 | + | |
| 1170 | + return $key; | |
| 1171 | + } | |
| 1172 | + | |
| 1094 | 1173 | public static function encryptKey($value) |
| 1095 | 1174 | { |
| 1096 | 1175 | if(!$value) { |
| 1097 | 1176 | return $value; |
| @@ -1100,24 +1179,71 @@ | ||
| 1100 | 1179 | if ( ! extension_loaded( 'openssl' ) ) { |
| 1101 | 1180 | return $value; |
| 1102 | 1181 | } |
| 1103 | 1182 | |
| 1104 | - $salt = (defined( 'LOGGED_IN_SALT' ) && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure'; | |
| 1105 | - $key = ( defined( 'LOGGED_IN_KEY' ) && '' !== LOGGED_IN_KEY ) ? LOGGED_IN_KEY : 'this-is-a-fallback-key-but-not-secure'; | |
| 1106 | - | |
| 1183 | + $key = self::getEncryptionKey(); | |
| 1107 | 1184 | $method = 'aes-256-ctr'; |
| 1108 | 1185 | $ivlen = openssl_cipher_iv_length( $method ); |
| 1109 | 1186 | $iv = openssl_random_pseudo_bytes( $ivlen ); |
| 1110 | 1187 | |
| 1111 | - $raw_value = openssl_encrypt( $value . $salt, $method, $key, 0, $iv ); | |
| 1112 | - if ( ! $raw_value ) { | |
| 1188 | + $ciphertext = openssl_encrypt( $value, $method, $key, OPENSSL_RAW_DATA, $iv ); | |
| 1189 | + if ( $ciphertext === false ) { | |
| 1113 | 1190 | return false; |
| 1114 | 1191 | } |
| 1115 | 1192 | |
| 1116 | - return base64_encode( $iv . $raw_value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode | |
| 1193 | + $hmac = hash_hmac( 'sha256', $iv . $ciphertext, $key, true ); | |
| 1194 | + | |
| 1195 | + return 'v2:' . base64_encode( $iv . $hmac . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode | |
| 1117 | 1196 | } |
| 1118 | 1197 | |
| 1119 | 1198 | public static function decryptKey( $raw_value ) { |
| 1199 | + | |
| 1200 | + if(!$raw_value) { | |
| 1201 | + return $raw_value; | |
| 1202 | + } | |
| 1203 | + | |
| 1204 | + if ( strpos( $raw_value, 'v2:' ) !== 0 ) { | |
| 1205 | + return self::legacyDecryptKey( $raw_value ); | |
| 1206 | + } | |
| 1207 | + | |
| 1208 | + // A v2 blob is unrecoverable without openssl, so fail loud instead of | |
| 1209 | + // handing the ciphertext back as if it were the key. | |
| 1210 | + if ( ! extension_loaded( 'openssl' ) ) { | |
| 1211 | + return false; | |
| 1212 | + } | |
| 1213 | + | |
| 1214 | + $key = self::getEncryptionKey(); | |
| 1215 | + $decoded = base64_decode( substr( $raw_value, 3 ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode | |
| 1216 | + $method = 'aes-256-ctr'; | |
| 1217 | + $ivlen = openssl_cipher_iv_length( $method ); | |
| 1218 | + $sha2len = 32; | |
| 1219 | + | |
| 1220 | + if ( $decoded === false || strlen( $decoded ) < $ivlen + $sha2len ) { | |
| 1221 | + return false; | |
| 1222 | + } | |
| 1223 | + | |
| 1224 | + $iv = substr( $decoded, 0, $ivlen ); | |
| 1225 | + $hmac = substr( $decoded, $ivlen, $sha2len ); | |
| 1226 | + $ciphertext = substr( $decoded, $ivlen + $sha2len ); | |
| 1227 | + | |
| 1228 | + // Encrypt-then-MAC: verify integrity (constant time) before decrypting. | |
| 1229 | + $calcmac = hash_hmac( 'sha256', $iv . $ciphertext, $key, true ); | |
| 1230 | + if ( ! hash_equals( $hmac, $calcmac ) ) { | |
| 1231 | + return false; | |
| 1232 | + } | |
| 1233 | + | |
| 1234 | + $value = openssl_decrypt( $ciphertext, $method, $key, OPENSSL_RAW_DATA, $iv ); | |
| 1235 | + | |
| 1236 | + return $value !== false ? $value : false; | |
| 1237 | + } | |
| 1238 | + | |
| 1239 | + /** | |
| 1240 | + * Reads keys encrypted before v2, i.e. tied to LOGGED_IN_KEY / LOGGED_IN_SALT. | |
| 1241 | + * | |
| 1242 | + * @param string $raw_value | |
| 1243 | + * @return string|bool | |
| 1244 | + */ | |
| 1245 | + public static function legacyDecryptKey( $raw_value ) { | |
| 1120 | 1246 | |
| 1121 | 1247 | if(!$raw_value) { |
| 1122 | 1248 | return $raw_value; |
| 1123 | 1249 | } |