| @@ -8,12 +8,8 @@ | ||
| 8 | 8 | namespace SureDonation\Inc\Payments; |
| 9 | 9 | |
| 10 | 10 | use SureDonation\Inc\Database\Tables\Donations; |
| 11 | 11 | use SureDonation\Inc\Helper; |
| 12 | -use SureDonation\Inc\Payments\Offline\Offline_Helper; | |
| 13 | -use SureDonation\Inc\Payments\PayPal\PayPal_Helper; | |
| 14 | -use SureDonation\Inc\Payments\Stripe\Stripe_Helper; | |
| 15 | -use SureDonation\Inc\Post_Types\Donation_Form; | |
| 16 | 12 | use WP_Error; |
| 17 | 13 | |
| 18 | 14 | // Exit if accessed directly. |
| 19 | 15 | if ( ! defined( 'ABSPATH' ) ) { |
| @@ -34,17 +30,8 @@ | ||
| 34 | 30 | */ |
| 35 | 31 | public const OPTION_KEY = 'payment_settings'; |
| 36 | 32 | |
| 37 | 33 | /** |
| 38 | - * Allowed currency sign positions. Single source of truth for the getter | |
| 39 | - * and every REST write handler that validates the setting. | |
| 40 | - * | |
| 41 | - * @since 1.3.0 | |
| 42 | - * @var array<int, string> | |
| 43 | - */ | |
| 44 | - public const ALLOWED_SIGN_POSITIONS = [ 'auto', 'left', 'right', 'left_space', 'right_space' ]; | |
| 45 | - | |
| 46 | - /** | |
| 47 | 34 | * Get all payment settings |
| 48 | 35 | * |
| 49 | 36 | * @return array<string, mixed> Payment settings. |
| 50 | 37 | * @since 0.0.1 |
| @@ -53,25 +40,16 @@ | ||
| 53 | 40 | $options = Helper::get_suredonation_option( self::OPTION_KEY, [] ); |
| 54 | 41 | |
| 55 | 42 | // Ensure default structure. |
| 56 | 43 | $defaults = [ |
| 57 | - 'currency' => 'USD', | |
| 58 | - 'payment_mode' => 'test', // Valid values: test or live. | |
| 59 | - // Currency symbol placement for displayed amounts. 'auto' preserves | |
| 60 | - // the historical behavior on every surface (locale-aware in the admin | |
| 61 | - // dashboard, symbol-left on donor-facing output); an explicit value | |
| 62 | - // overrides it everywhere. See get_currency_sign_position(). | |
| 63 | - 'currency_sign_position' => 'auto', | |
| 64 | - 'stripe' => [], | |
| 65 | - // Intentionally no 'instructions' key: leaving it unset lets | |
| 66 | - // Offline_Helper::get_all_offline_settings() fill the default template | |
| 67 | - // for a never-configured install, while a deliberately-cleared value is | |
| 68 | - // stored as '' and preserved. Seeding '' here would make the two | |
| 69 | - // indistinguishable and permanently mask the default. | |
| 70 | - 'offline' => [ | |
| 71 | - 'enabled' => false, | |
| 44 | + 'currency' => 'USD', | |
| 45 | + 'payment_mode' => 'test', // Valid values: test or live. | |
| 46 | + 'stripe' => [], | |
| 47 | + 'offline' => [ | |
| 48 | + 'enabled' => false, | |
| 49 | + 'instructions' => '', | |
| 72 | 50 | ], |
| 73 | - 'fee_recovery' => [ | |
| 51 | + 'fee_recovery' => [ | |
| 74 | 52 | 'fee_percentage' => 2.9, |
| 75 | 53 | 'fee_fixed' => 0.30, |
| 76 | 54 | 'fee_mode' => 'all_gateways', |
| 77 | 55 | 'gateways' => [ |
| @@ -174,256 +152,8 @@ | ||
| 174 | 152 | return ! empty( $response ) && is_string( $response ) ? $response : 'test'; |
| 175 | 153 | } |
| 176 | 154 | |
| 177 | 155 | /** |
| 178 | - * Check the payment mode a donor's page was rendered in against the current one. | |
| 179 | - * | |
| 180 | - * The gateway configuration (the Stripe publishable key, the PayPal | |
| 181 | - * merchant) is resolved when the form is rendered, and a full-page cache | |
| 182 | - * stores that rendering, so a form can outlive a test/live switch. The | |
| 183 | - * client then holds one mode's key while the server would mint the other | |
| 184 | - * mode's intent, and the gateway rejects the confirmation with a message | |
| 185 | - * that helps nobody. Catching the mismatch here turns a silent failure into | |
| 186 | - * an actionable one, before any Stripe, PayPal or database work is done. | |
| 187 | - * | |
| 188 | - * A request that carries no mode passes: scripts that predate this check do | |
| 189 | - * not send one, and the pro subscription paths adopt it separately. | |
| 190 | - * | |
| 191 | - * @return true|WP_Error True when the modes agree or none was sent; WP_Error on a mismatch. | |
| 192 | - * @since 1.5.1 | |
| 193 | - */ | |
| 194 | - public static function verify_submitted_payment_mode() { | |
| 195 | - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The nonce is verified by the calling endpoint before this runs. | |
| 196 | - $client_mode = isset( $_POST['payment_mode'] ) ? sanitize_key( wp_unslash( $_POST['payment_mode'] ) ) : ''; | |
| 197 | - | |
| 198 | - if ( '' === $client_mode ) { | |
| 199 | - return true; | |
| 200 | - } | |
| 201 | - | |
| 202 | - $current_mode = self::get_payment_mode(); | |
| 203 | - | |
| 204 | - if ( $client_mode === $current_mode ) { | |
| 205 | - return true; | |
| 206 | - } | |
| 207 | - | |
| 208 | - $message = __( 'This page was loaded with outdated payment settings. Please reload the page and try again.', 'suredonation' ); | |
| 209 | - | |
| 210 | - // Tell the site owner what actually happened: the donor's page is a | |
| 211 | - // cached copy from before the mode switch, and only a purge fixes it. | |
| 212 | - if ( current_user_can( 'manage_options' ) ) { | |
| 213 | - $message .= ' ' . sprintf( | |
| 214 | - /* translators: 1: payment mode the page was rendered in, 2: current payment mode. */ | |
| 215 | - __( 'Site owner: this page was cached in %1$s mode but payments now run in %2$s mode. Clear your page cache so visitors receive the updated form.', 'suredonation' ), | |
| 216 | - $client_mode, | |
| 217 | - $current_mode | |
| 218 | - ); | |
| 219 | - } | |
| 220 | - | |
| 221 | - return new WP_Error( | |
| 222 | - 'payment_mode_mismatch', | |
| 223 | - $message, | |
| 224 | - [ | |
| 225 | - 'client_mode' => $client_mode, | |
| 226 | - 'current_mode' => $current_mode, | |
| 227 | - ] | |
| 228 | - ); | |
| 229 | - } | |
| 230 | - | |
| 231 | - /** | |
| 232 | - * Gateway configuration the donation form needs at runtime. | |
| 233 | - * | |
| 234 | - * Resolved on request rather than at render time so a full-page cache | |
| 235 | - * cannot pin a form to the Stripe key, PayPal merchant or currency of | |
| 236 | - * whichever payment mode was current when the page was stored. Nothing | |
| 237 | - * here is secret: every value used to be written into the public markup. | |
| 238 | - * | |
| 239 | - * @param int $form_id Donation form post ID; selects the Stripe account the form charges to. | |
| 240 | - * @return array<string, mixed> Configuration keyed for the frontend script. | |
| 241 | - * @since 1.5.1 | |
| 242 | - */ | |
| 243 | - public static function get_frontend_gateway_config( $form_id ) { | |
| 244 | - $form_id = absint( $form_id ); | |
| 245 | - $mode = self::get_payment_mode(); | |
| 246 | - | |
| 247 | - // Only a published donation form selects a Stripe account or shapes the | |
| 248 | - // PayPal SDK arguments. The id is caller-supplied on a public endpoint, | |
| 249 | - // so anything else is treated as "no form": the filters below must never | |
| 250 | - // be handed an arbitrary post to inspect, and a form's account wiring | |
| 251 | - // is not readable by visitors before the form goes live. Users who can | |
| 252 | - // edit the form still get its configuration, which is what the block | |
| 253 | - // editor preview of a draft relies on. | |
| 254 | - if ( $form_id > 0 ) { | |
| 255 | - $is_form = Donation_Form::POST_TYPE === get_post_type( $form_id ); | |
| 256 | - $is_available = 'publish' === get_post_status( $form_id ) || current_user_can( 'edit_post', $form_id ); | |
| 257 | - | |
| 258 | - if ( ! $is_form || ! $is_available ) { | |
| 259 | - $form_id = 0; | |
| 260 | - } | |
| 261 | - } | |
| 262 | - | |
| 263 | - $config = [ | |
| 264 | - 'paymentMode' => $mode, | |
| 265 | - 'currency' => self::get_currency(), | |
| 266 | - 'stripe' => null, | |
| 267 | - ]; | |
| 268 | - | |
| 269 | - if ( Stripe_Helper::is_stripe_connected() ) { | |
| 270 | - $publishable_key = Stripe_Helper::get_stripe_publishable_key( $mode, Stripe_Helper::resolve_account_for_form( $form_id ) ); | |
| 271 | - if ( '' !== $publishable_key ) { | |
| 272 | - $config['stripe'] = [ 'publishableKey' => $publishable_key ]; | |
| 273 | - } | |
| 274 | - } | |
| 275 | - | |
| 276 | - /** | |
| 277 | - * Filter the gateway configuration served to the donation form at runtime. | |
| 278 | - * | |
| 279 | - * Gateways that register through filters (PayPal) add their own section | |
| 280 | - * here. Nothing in this array may be secret: it is served to anyone who | |
| 281 | - * can load the form. | |
| 282 | - * | |
| 283 | - * @param array<string, mixed> $config Configuration keyed for the frontend script. | |
| 284 | - * @param int $form_id Donation form post ID. | |
| 285 | - * @param string $mode Current payment mode, 'test' or 'live'. | |
| 286 | - * @since 1.5.1 | |
| 287 | - */ | |
| 288 | - return apply_filters( 'suredonation_frontend_gateway_config', $config, $form_id, $mode ); | |
| 289 | - } | |
| 290 | - | |
| 291 | - /** | |
| 292 | - * Whether this site can create recurring donations. | |
| 293 | - * | |
| 294 | - * Subscription creation lives in the Pro add-on, so a form configured for | |
| 295 | - * recurring (or for the donor's choice of both) can only offer it when Pro is | |
| 296 | - * present. Single source of truth for that gate, so the editor, the rendered | |
| 297 | - * markup and the submission paths cannot disagree. | |
| 298 | - * | |
| 299 | - * @return bool | |
| 300 | - * @since 1.5.1 | |
| 301 | - */ | |
| 302 | - public static function is_recurring_available() { | |
| 303 | - // The Stripe Elements group is now always built in mode: 'payment' | |
| 304 | - // (never 'subscription' — see stripe.js init()), and confirming a | |
| 305 | - // subscription requires pro to call elevateSetupFutureUsage() itself | |
| 306 | - // before confirmPayment(). An older pro build still calls | |
| 307 | - // confirmSetup() unconditionally, which is a hard Stripe integration | |
| 308 | - // error against a mode: 'payment' Elements group. Gating on the | |
| 309 | - // minimum compatible version — the same way presence is already | |
| 310 | - // gated — keeps that combination from ever reaching a donor as | |
| 311 | - // "recurring", falling back to the same safe one-time-only path an | |
| 312 | - // absent Pro already takes. | |
| 313 | - $pro_compatible = defined( 'SUREDONATION_PRO_VER' ) | |
| 314 | - && self::pro_version_supports_recurring( SUREDONATION_PRO_VER ); | |
| 315 | - | |
| 316 | - /** | |
| 317 | - * Filter whether recurring donations are available. | |
| 318 | - * | |
| 319 | - * Only gates what is offered — the server still validates the submitted type | |
| 320 | - * against the stored block configuration, and subscription creation still | |
| 321 | - * requires the Pro add-on to be handling the request. | |
| 322 | - * | |
| 323 | - * @param bool $available Whether recurring donations can be offered. | |
| 324 | - * @since 1.5.1 | |
| 325 | - */ | |
| 326 | - return (bool) apply_filters( 'suredonation_is_recurring_available', $pro_compatible ); | |
| 327 | - } | |
| 328 | - | |
| 329 | - /** | |
| 330 | - * Whether a given Pro version is new enough to confirm subscriptions against | |
| 331 | - * the always-'payment'-mode Elements group (see is_recurring_available()). | |
| 332 | - * | |
| 333 | - * Split out from is_recurring_available() so the threshold itself is | |
| 334 | - * testable without defining SUREDONATION_PRO_VER — a real constant, once | |
| 335 | - * defined, cannot be undefined again for the rest of the test suite. | |
| 336 | - * | |
| 337 | - * @param string $version Pro plugin version string. | |
| 338 | - * @return bool | |
| 339 | - * @since 1.5.1 | |
| 340 | - */ | |
| 341 | - public static function pro_version_supports_recurring( $version ) { | |
| 342 | - return version_compare( $version, '1.1.1-beta', '>=' ); | |
| 343 | - } | |
| 344 | - | |
| 345 | - /** | |
| 346 | - * Get the admin URL for the SureDonation payment settings screen. | |
| 347 | - * | |
| 348 | - * Centralizes the (hash-routed) payment-settings URL so every "go to | |
| 349 | - * payment settings" link across the plugin resolves to the same valid | |
| 350 | - * location, rather than each caller hardcoding its own — and possibly | |
| 351 | - * stale — path. | |
| 352 | - * | |
| 353 | - * Query args go in the real query string, before the `#`. The screen is | |
| 354 | - * hash-routed, so anything appended after the fragment is invisible to | |
| 355 | - * `window.location.search` and the React app would never see it. | |
| 356 | - * | |
| 357 | - * @param string $subpage Optional gateway subpage slug (e.g. 'stripe') to deep-link into. | |
| 358 | - * @param array<string,string> $query_args Optional query args to carry to the screen (e.g. which notice sent the admin here). | |
| 359 | - * @return string The admin payment-settings URL. | |
| 360 | - * @since 1.3.0 | |
| 361 | - */ | |
| 362 | - public static function get_settings_url( $subpage = '', $query_args = [] ) { | |
| 363 | - $query = 'page=suredonation'; | |
| 364 | - | |
| 365 | - if ( is_array( $query_args ) && ! empty( $query_args ) ) { | |
| 366 | - $query .= '&' . http_build_query( $query_args ); | |
| 367 | - } | |
| 368 | - | |
| 369 | - $path = 'admin.php?' . $query . '#/settings?tab=payments'; | |
| 370 | - if ( is_string( $subpage ) && '' !== $subpage ) { | |
| 371 | - $path .= '&subpage=' . rawurlencode( $subpage ); | |
| 372 | - } | |
| 373 | - return admin_url( $path ); | |
| 374 | - } | |
| 375 | - | |
| 376 | - /** | |
| 377 | - * Whether any real payment gateway (Stripe or PayPal) is connected. | |
| 378 | - * | |
| 379 | - * Stripe's connection is mode-agnostic; PayPal's is per-mode, so both | |
| 380 | - * PayPal modes are checked. Offline is intentionally excluded — it is a | |
| 381 | - * manual method, not a live/test payment gateway, so it never counts as | |
| 382 | - * "a gateway is connected" for test-mode/live-mode prompts. | |
| 383 | - * | |
| 384 | - * @return bool True when at least one gateway is connected in any mode. | |
| 385 | - * @since 1.3.0 | |
| 386 | - */ | |
| 387 | - public static function is_any_gateway_connected() { | |
| 388 | - return Stripe_Helper::is_stripe_connected() | |
| 389 | - || PayPal_Helper::is_paypal_connected( 'live' ) | |
| 390 | - || PayPal_Helper::is_paypal_connected( 'test' ); | |
| 391 | - } | |
| 392 | - | |
| 393 | - /** | |
| 394 | - * Whether at least one payment gateway is usable on the site right now — a | |
| 395 | - * gateway a payment block would actually render if it selected it. | |
| 396 | - * | |
| 397 | - * Stricter than is_any_gateway_connected(): it is scoped to the current | |
| 398 | - * mode. Stripe must also have a publishable key for the current mode, PayPal | |
| 399 | - * must be connected for the current mode, and Offline must be enabled. Used | |
| 400 | - * to decide whether a "no gateway available" state is a missing site-wide | |
| 401 | - * gateway (nothing usable) or merely a form/block that hasn't selected an | |
| 402 | - * already-usable gateway. | |
| 403 | - * | |
| 404 | - * @return bool | |
| 405 | - * @since 1.3.0 | |
| 406 | - */ | |
| 407 | - public static function has_usable_gateway() { | |
| 408 | - // Connected is not the same as able to take money: an account Stripe has | |
| 409 | - // restricted still holds valid keys. Counting it as usable sends the | |
| 410 | - // admin to the form editor to "pick a gateway" when the gateway itself | |
| 411 | - // is the problem. | |
| 412 | - if ( Stripe_Helper::is_stripe_connected() | |
| 413 | - && '' !== Stripe_Helper::get_stripe_publishable_key() | |
| 414 | - && ! Stripe_Helper::is_card_capability_blocked() ) { | |
| 415 | - return true; | |
| 416 | - } | |
| 417 | - | |
| 418 | - if ( PayPal_Helper::is_paypal_connected() ) { | |
| 419 | - return true; | |
| 420 | - } | |
| 421 | - | |
| 422 | - return Offline_Helper::is_offline_enabled(); | |
| 423 | - } | |
| 424 | - | |
| 425 | - /** | |
| 426 | 156 | * Get the currency list formatted for select inputs. |
| 427 | 157 | * |
| 428 | 158 | * Returns a map of currency code to a "CODE - Name" display label, shared |
| 429 | 159 | * by the REST currencies endpoint and the admin bootstrap data so the |
| @@ -742,37 +472,8 @@ | ||
| 742 | 472 | return $currency_data && 0 === $currency_data['decimal_places']; |
| 743 | 473 | } |
| 744 | 474 | |
| 745 | 475 | /** |
| 746 | - * Get the comparison tolerance (epsilon) for a currency, in major units. | |
| 747 | - * | |
| 748 | - * Amount comparisons need a small tolerance to absorb floating-point | |
| 749 | - * rounding. The correct tolerance is one minor unit of the currency: | |
| 750 | - * 0.01 for 2-decimal currencies (e.g. USD) and 1 for zero-decimal | |
| 751 | - * currencies (e.g. JPY) — rather than a hardcoded 0.01, which is | |
| 752 | - * meaningless for zero-decimal currencies. | |
| 753 | - * | |
| 754 | - * @param string $currency Currency code. Defaults to the configured currency. | |
| 755 | - * @return float Tolerance in major currency units. | |
| 756 | - * @since 1.1.1 | |
| 757 | - */ | |
| 758 | - public static function get_amount_epsilon( $currency = '' ) { | |
| 759 | - if ( empty( $currency ) ) { | |
| 760 | - $currency = self::get_currency(); | |
| 761 | - } | |
| 762 | - | |
| 763 | - $currency = is_string( $currency ) ? strtoupper( $currency ) : ''; | |
| 764 | - $currencies = self::get_all_currencies_data(); | |
| 765 | - $currency_data = $currencies[ $currency ] ?? null; | |
| 766 | - | |
| 767 | - $decimal_places = ( is_array( $currency_data ) && isset( $currency_data['decimal_places'] ) && is_numeric( $currency_data['decimal_places'] ) ) | |
| 768 | - ? (int) $currency_data['decimal_places'] | |
| 769 | - : 2; | |
| 770 | - | |
| 771 | - return (float) pow( 10, -$decimal_places ); | |
| 772 | - } | |
| 773 | - | |
| 774 | - /** | |
| 775 | 476 | * Format amount for display |
| 776 | 477 | * |
| 777 | 478 | * @param float $amount Amount. |
| 778 | 479 | * @param string $currency Currency code. |
| @@ -785,72 +486,13 @@ | ||
| 785 | 486 | } |
| 786 | 487 | |
| 787 | 488 | $symbol = self::get_currency_symbol( $currency ); |
| 788 | 489 | $decimal_places = self::is_zero_decimal_currency( $currency ) ? 0 : 2; |
| 789 | - $formatted = number_format( (float) $amount, $decimal_places, '.', ',' ); | |
| 790 | 490 | |
| 791 | - // Fall back to the uppercased currency code when we have no symbol for | |
| 792 | - // this currency (e.g. a historical/imported donation in a currency not | |
| 793 | - // in our list). Positioning a multi-letter code isn't meaningful, so | |
| 794 | - // keep the legacy "CODE 100.00" form rather than routing an empty | |
| 795 | - // symbol through the switch (which would emit a bare number and stray | |
| 796 | - // spaces under the *_space positions). | |
| 797 | - if ( '' === $symbol ) { | |
| 798 | - $code = strtoupper( (string) $currency ); | |
| 799 | - return '' === $code ? $formatted : $code . ' ' . $formatted; | |
| 800 | - } | |
| 801 | - | |
| 802 | - return self::position_currency_symbol( $symbol, $formatted ); | |
| 491 | + return $symbol . number_format( (float) $amount, $decimal_places, '.', ',' ); | |
| 803 | 492 | } |
| 804 | 493 | |
| 805 | 494 | /** |
| 806 | - * Place a currency symbol relative to an already-formatted amount per the | |
| 807 | - * global sign position setting. Kept separate from format_amount() so | |
| 808 | - * callers that format the number themselves (e.g. raw preset labels that | |
| 809 | - * must not gain decimals) can still honor the setting. | |
| 810 | - * | |
| 811 | - * 'auto' (and the historical 'left') keep the symbol on the left, so | |
| 812 | - * existing output is unchanged. | |
| 813 | - * | |
| 814 | - * @param string $symbol Currency symbol. | |
| 815 | - * @param string $formatted_amount Amount already formatted for display. | |
| 816 | - * @return string Amount with the symbol positioned per the setting. | |
| 817 | - * @since 1.3.0 | |
| 818 | - */ | |
| 819 | - public static function position_currency_symbol( $symbol, $formatted_amount ) { | |
| 820 | - switch ( self::get_currency_sign_position() ) { | |
| 821 | - case 'right': | |
| 822 | - return $formatted_amount . $symbol; | |
| 823 | - case 'left_space': | |
| 824 | - return $symbol . ' ' . $formatted_amount; | |
| 825 | - case 'right_space': | |
| 826 | - return $formatted_amount . ' ' . $symbol; | |
| 827 | - case 'left': | |
| 828 | - case 'auto': | |
| 829 | - default: | |
| 830 | - return $symbol . $formatted_amount; | |
| 831 | - } | |
| 832 | - } | |
| 833 | - | |
| 834 | - /** | |
| 835 | - * Get the configured currency sign position. | |
| 836 | - * | |
| 837 | - * Controls where the currency symbol sits relative to the amount in | |
| 838 | - * displayed values. 'auto' preserves the historical behavior (symbol on | |
| 839 | - * the left for server-rendered donor-facing output; locale-aware in the | |
| 840 | - * admin dashboard, which formats via Intl). An explicit value overrides | |
| 841 | - * this consistently across every surface. | |
| 842 | - * | |
| 843 | - * @return string One of 'auto', 'left', 'right', 'left_space', 'right_space'. | |
| 844 | - * @since 1.3.0 | |
| 845 | - */ | |
| 846 | - public static function get_currency_sign_position() { | |
| 847 | - $position = self::get_global_setting( 'currency_sign_position', 'auto' ); | |
| 848 | - | |
| 849 | - return is_string( $position ) && in_array( $position, self::ALLOWED_SIGN_POSITIONS, true ) ? $position : 'auto'; | |
| 850 | - } | |
| 851 | - | |
| 852 | - /** | |
| 853 | 495 | * Convert amount to Stripe format (cents) |
| 854 | 496 | * |
| 855 | 497 | * @param float $amount Amount in dollars. |
| 856 | 498 | * @param string $currency Currency code. |
| @@ -1124,14 +766,11 @@ | ||
| 1124 | 766 | * @param string $currency Currency code (e.g., 'USD', 'EUR'). |
| 1125 | 767 | * @param int $form_id WordPress post ID of the donation form. |
| 1126 | 768 | * @param string $block_id Block identifier for the payment block. |
| 1127 | 769 | * @param string $gateway Payment gateway identifier (default 'stripe'). |
| 1128 | - * @param string $payment_type Payment type the caller is processing ('one-time' or | |
| 1129 | - * 'subscription'). Only consulted for blocks configured | |
| 1130 | - * as 'both', where each choice has its own amount config. | |
| 1131 | 770 | * @return array<mixed> Validation result. |
| 1132 | 771 | */ |
| 1133 | - public static function validate_payment_amount( $amount, $currency, $form_id, $block_id, $gateway = 'stripe', $payment_type = '' ) { | |
| 772 | + public static function validate_payment_amount( $amount, $currency, $form_id, $block_id, $gateway = 'stripe' ) { | |
| 1134 | 773 | // Retrieve block configuration from post meta. |
| 1135 | 774 | $block_config = \SureDonation\Inc\Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id ); |
| 1136 | 775 | |
| 1137 | 776 | // Check if block config exists. |
| @@ -1151,59 +790,8 @@ | ||
| 1151 | 790 | } |
| 1152 | 791 | |
| 1153 | 792 | $payment_config = $block_config[ $block_id ]; |
| 1154 | 793 | |
| 1155 | - // The submitted block_id must reference an actual payment block. Every | |
| 1156 | - // other field block (input/email/number/dropdown/phone/url/donation- | |
| 1157 | - // amount/cover-fees) also has a config entry but carries no amount_type/ | |
| 1158 | - // fixed_amount — validating against one would silently collapse the | |
| 1159 | - // checks below to their fallback defaults and let a caller pay an | |
| 1160 | - // arbitrary (default) amount regardless of the block's real configuration. | |
| 1161 | - if ( ! isset( $payment_config['block_name'] ) || 'suredonation/payment' !== $payment_config['block_name'] ) { | |
| 1162 | - return [ | |
| 1163 | - 'valid' => false, | |
| 1164 | - 'message' => __( 'Payment configuration not found for this form.', 'suredonation' ), | |
| 1165 | - ]; | |
| 1166 | - } | |
| 1167 | - | |
| 1168 | - // A 'both' block stores an independent amount config per choice. Overlay the | |
| 1169 | - // selected choice's config over the shared one so every check below runs | |
| 1170 | - // against the amount the donor was actually offered — without this, a form | |
| 1171 | - // with one-time $100 / subscription $10 would validate either choice against | |
| 1172 | - // the shared (one-time) config and let a donor pay the cheaper mode's amount. | |
| 1173 | - if ( 'both' === ( $payment_config['payment_type'] ?? '' ) ) { | |
| 1174 | - // Fail closed on an unrecognised type rather than defaulting to one-time. | |
| 1175 | - // Every current caller passes a literal ('one-time' / 'subscription'), but a | |
| 1176 | - // version-skewed Pro (older than the dual-mode change) omits the argument, | |
| 1177 | - // leaving it '' — which must not silently price a recurring charge against | |
| 1178 | - // the (typically cheaper) one-time config. | |
| 1179 | - if ( ! in_array( $payment_type, [ 'one-time', 'subscription' ], true ) ) { | |
| 1180 | - return [ | |
| 1181 | - 'valid' => false, | |
| 1182 | - 'message' => __( 'Payment configuration is incomplete for this form.', 'suredonation' ), | |
| 1183 | - ]; | |
| 1184 | - } | |
| 1185 | - | |
| 1186 | - $mode_key = 'subscription' === $payment_type ? 'subscription' : 'one_time'; | |
| 1187 | - | |
| 1188 | - // Fail closed: a 'both' block with no config for the chosen mode cannot be | |
| 1189 | - // validated, and falling back to the shared keys is exactly the hole above. | |
| 1190 | - if ( ! isset( $payment_config[ $mode_key ] ) || ! is_array( $payment_config[ $mode_key ] ) ) { | |
| 1191 | - return [ | |
| 1192 | - 'valid' => false, | |
| 1193 | - 'message' => __( 'Payment configuration is incomplete for this form.', 'suredonation' ), | |
| 1194 | - ]; | |
| 1195 | - } | |
| 1196 | - | |
| 1197 | - // Drop the shared variable-amount field before overlaying the choice's | |
| 1198 | - // config, so a choice that did not set its own dynamic field cannot inherit | |
| 1199 | - // the top-level one. build_amount_config() only emits these when the prefixed | |
| 1200 | - // attribute is present, so their absence for a choice is meaningful. | |
| 1201 | - unset( $payment_config['variable_amount_field'], $payment_config['variable_amount_field_block_name'] ); | |
| 1202 | - | |
| 1203 | - $payment_config = array_merge( $payment_config, $payment_config[ $mode_key ] ); | |
| 1204 | - } | |
| 1205 | - | |
| 1206 | 794 | // Validate currency matches global setting. |
| 1207 | 795 | $global_currency = strtolower( self::get_currency() ); |
| 1208 | 796 | $submitted_currency = strtolower( $currency ); |
| 1209 | 797 | if ( $global_currency !== $submitted_currency ) { |
| @@ -1219,21 +807,13 @@ | ||
| 1219 | 807 | $amount_type = $payment_config['amount_type'] ?? 'fixed'; |
| 1220 | 808 | |
| 1221 | 809 | // Validate based on amount type. |
| 1222 | 810 | if ( 'fixed' === $amount_type ) { |
| 1223 | - // Fixed amount validation - must match exactly. A payment block with | |
| 1224 | - // no configured fixed_amount fails closed rather than defaulting to a | |
| 1225 | - // chargeable amount. | |
| 1226 | - if ( ! isset( $payment_config['fixed_amount'] ) ) { | |
| 1227 | - return [ | |
| 1228 | - 'valid' => false, | |
| 1229 | - 'message' => __( 'Payment configuration is incomplete for this form.', 'suredonation' ), | |
| 1230 | - ]; | |
| 1231 | - } | |
| 1232 | - $configured_amount = floatval( Helper::get_string_value( $payment_config['fixed_amount'] ) ); | |
| 811 | + // Fixed amount validation - must match exactly. | |
| 812 | + $configured_amount = isset( $payment_config['fixed_amount'] ) ? floatval( Helper::get_string_value( $payment_config['fixed_amount'] ) ) : 10.00; | |
| 1233 | 813 | |
| 1234 | - // Allow one minor currency unit of tolerance for float rounding. | |
| 1235 | - if ( abs( $amount - $configured_amount ) > self::get_amount_epsilon( $currency ) ) { | |
| 814 | + // Allow small floating point difference (0.01) due to rounding. | |
| 815 | + if ( abs( $amount - $configured_amount ) > 0.01 ) { | |
| 1236 | 816 | return [ |
| 1237 | 817 | 'valid' => false, |
| 1238 | 818 | /* translators: %s: expected amount with currency */ |
| 1239 | 819 | 'message' => sprintf( __( 'Payment amount must be exactly %s.', 'suredonation' ), self::format_amount( $configured_amount, $currency ) ), |
| @@ -1290,54 +870,13 @@ | ||
| 1290 | 870 | ]; |
| 1291 | 871 | } |
| 1292 | 872 | |
| 1293 | 873 | /** |
| 1294 | - * The payment type a form actually renders, which is not always the stored one. | |
| 1295 | - * | |
| 1296 | - * A block configured for a recurring path renders as one-time when Pro is | |
| 1297 | - * absent or too old: the handlers that could create a subscription are not | |
| 1298 | - * registered, or cannot confirm one, so offering it would be a dead end. The | |
| 1299 | - * stored config still says 'subscription' or 'both' — | |
| 1300 | - * `process_payment_block()` records the raw attribute and | |
| 1301 | - * `get_or_migrate_block_config_for_legacy_form()` returns existing meta | |
| 1302 | - * verbatim — so anything comparing a request against that config has to apply | |
| 1303 | - * the same downgrade, or it rejects the request its own markup invited. | |
| 1304 | - * | |
| 1305 | - * Shared with `Payment_Markup` so the two cannot drift apart again. | |
| 1306 | - * | |
| 1307 | - * @param mixed $configured_type The payment type stored on the block. | |
| 1308 | - * @return string Either 'one-time' or the configured type. | |
| 1309 | - * @since 1.5.1 | |
| 1310 | - */ | |
| 1311 | - public static function effective_payment_type( $configured_type ) { | |
| 1312 | - $configured_type = is_string( $configured_type ) ? $configured_type : 'one-time'; | |
| 1313 | - | |
| 1314 | - // 'both' needs Pro as much as 'subscription' does — it is the donor-choice | |
| 1315 | - // mode and half of what it offers is a subscription. Collapsing it here is | |
| 1316 | - // also what hides the chooser, which only renders while the type is 'both'. | |
| 1317 | - $needs_pro = in_array( $configured_type, [ 'subscription', 'both' ], true ); | |
| 1318 | - | |
| 1319 | - // is_recurring_available() rather than defined( 'SUREDONATION_PRO_VER' ): | |
| 1320 | - // it also rejects a Pro build too old to confirm a subscription against the | |
| 1321 | - // current Elements setup, and carries the filter that lets a site turn | |
| 1322 | - // recurring off. Testing only for presence would render a form as recurring | |
| 1323 | - // that cannot complete one. | |
| 1324 | - if ( $needs_pro && ! self::is_recurring_available() ) { | |
| 1325 | - return 'one-time'; | |
| 1326 | - } | |
| 1327 | - | |
| 1328 | - return $configured_type; | |
| 1329 | - } | |
| 1330 | - | |
| 1331 | - /** | |
| 1332 | 874 | * Validate that the submitted payment type matches the block configuration. |
| 1333 | 875 | * |
| 1334 | 876 | * Prevents attackers from requesting a subscription on a block configured |
| 1335 | 877 | * for one-time payments (or vice versa). |
| 1336 | 878 | * |
| 1337 | - * A block configured as 'both' offers the donor a choice, so it legitimately | |
| 1338 | - * accepts either type — but still only those two, never an arbitrary value. | |
| 1339 | - * | |
| 1340 | 879 | * @param string $expected_type Expected payment type ('one-time' or 'subscription'). |
| 1341 | 880 | * @param int $form_id Form ID. |
| 1342 | 881 | * @param string $block_id Block ID. |
| 1343 | 882 | * @return array{valid: bool, message: string} Validation result. |
| @@ -1360,33 +899,14 @@ | ||
| 1360 | 899 | 'message' => '', |
| 1361 | 900 | ]; |
| 1362 | 901 | } |
| 1363 | 902 | |
| 1364 | - $payment_config = $block_config[ $block_id ]; | |
| 903 | + $payment_config = $block_config[ $block_id ]; | |
| 904 | + $configured_type = $payment_config['payment_type'] ?? 'one-time'; | |
| 1365 | 905 | |
| 1366 | - // Every field block has a config entry and none carry a payment_type, so | |
| 1367 | - // without this the guard resolves any other block's id to 'one-time' and | |
| 1368 | - // waves it through. validate_payment_amount() happens to fail closed on | |
| 1369 | - // the same input today, but this is a shared primitive and must not | |
| 1370 | - // depend on a sibling running after it. | |
| 1371 | - if ( ! isset( $payment_config['block_name'] ) || 'suredonation/payment' !== $payment_config['block_name'] ) { | |
| 906 | + if ( $configured_type !== $expected_type ) { | |
| 1372 | 907 | return [ |
| 1373 | 908 | 'valid' => false, |
| 1374 | - 'message' => __( 'Payment configuration not found for this form.', 'suredonation' ), | |
| 1375 | - ]; | |
| 1376 | - } | |
| 1377 | - | |
| 1378 | - $configured_type = self::effective_payment_type( $payment_config['payment_type'] ?? 'one-time' ); | |
| 1379 | - | |
| 1380 | - // 'both' lets the donor choose, so either real type is acceptable. Anything | |
| 1381 | - // outside that pair is still rejected. | |
| 1382 | - $allowed_types = 'both' === $configured_type | |
| 1383 | - ? [ 'one-time', 'subscription' ] | |
| 1384 | - : [ $configured_type ]; | |
| 1385 | - | |
| 1386 | - if ( ! in_array( $expected_type, $allowed_types, true ) ) { | |
| 1387 | - return [ | |
| 1388 | - 'valid' => false, | |
| 1389 | 909 | 'message' => __( 'Payment type mismatch. This form does not support the requested payment type.', 'suredonation' ), |
| 1390 | 910 | ]; |
| 1391 | 911 | } |
| 1392 | 912 | |
| @@ -1396,76 +916,8 @@ | ||
| 1396 | 916 | ]; |
| 1397 | 917 | } |
| 1398 | 918 | |
| 1399 | 919 | /** |
| 1400 | - * Read the billing cadence a payment block was configured with. | |
| 1401 | - * | |
| 1402 | - * The interval and billing cycles decide how often a donor is charged and for | |
| 1403 | - * how long, so the values the admin saved are the source of truth on submit — | |
| 1404 | - * not whatever the request carries. Returns empty strings when the block has no | |
| 1405 | - * stored cadence (a form saved before it was persisted), letting the caller | |
| 1406 | - * fall back to its previous behaviour. | |
| 1407 | - * | |
| 1408 | - * @param int $form_id Donation form post ID. | |
| 1409 | - * @param string $block_id Payment block identifier. | |
| 1410 | - * @return array{interval: string, billing_cycles: string} Stored cadence, or empty strings. | |
| 1411 | - * @since 1.5.1 | |
| 1412 | - */ | |
| 1413 | - public static function get_subscription_cadence( $form_id, $block_id ) { | |
| 1414 | - $cadence = [ | |
| 1415 | - 'interval' => '', | |
| 1416 | - 'billing_cycles' => '', | |
| 1417 | - ]; | |
| 1418 | - | |
| 1419 | - if ( empty( $form_id ) || empty( $block_id ) ) { | |
| 1420 | - return $cadence; | |
| 1421 | - } | |
| 1422 | - | |
| 1423 | - $block_config = \SureDonation\Inc\Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id ); | |
| 1424 | - | |
| 1425 | - if ( ! is_array( $block_config ) || ! isset( $block_config[ $block_id ] ) || ! is_array( $block_config[ $block_id ] ) ) { | |
| 1426 | - return $cadence; | |
| 1427 | - } | |
| 1428 | - | |
| 1429 | - $payment_config = $block_config[ $block_id ]; | |
| 1430 | - | |
| 1431 | - // Only read cadence off an actual payment block — mirrors the block_name assert | |
| 1432 | - // in validate_payment_amount() so a non-payment block id can never resolve a | |
| 1433 | - // cadence (defence in depth alongside the caller's own block-id validation). | |
| 1434 | - if ( ! isset( $payment_config['block_name'] ) || 'suredonation/payment' !== $payment_config['block_name'] ) { | |
| 1435 | - return $cadence; | |
| 1436 | - } | |
| 1437 | - | |
| 1438 | - if ( isset( $payment_config['subscription_interval'] ) ) { | |
| 1439 | - $cadence['interval'] = Helper::get_string_value( $payment_config['subscription_interval'] ); | |
| 1440 | - } | |
| 1441 | - | |
| 1442 | - if ( isset( $payment_config['subscription_billing_cycles'] ) ) { | |
| 1443 | - $cadence['billing_cycles'] = Helper::get_string_value( $payment_config['subscription_billing_cycles'] ); | |
| 1444 | - } | |
| 1445 | - | |
| 1446 | - // Legacy forms saved before cadence was persisted carry no cadence keys in | |
| 1447 | - // stored meta (the config only rebuilds on save_post). Returning empty here | |
| 1448 | - // would let the caller assume month / ongoing and silently rewrite the | |
| 1449 | - // admin's real plan. Re-derive from the parsed post content instead — still | |
| 1450 | - // server-side and untamperable, never the request. | |
| 1451 | - if ( '' === $cadence['interval'] || '' === $cadence['billing_cycles'] ) { | |
| 1452 | - $resolved = \SureDonation\Inc\Field_Validation::resolve_subscription_cadence_from_content( $form_id ); | |
| 1453 | - | |
| 1454 | - if ( is_array( $resolved ) ) { | |
| 1455 | - if ( '' === $cadence['interval'] ) { | |
| 1456 | - $cadence['interval'] = Helper::get_string_value( $resolved['subscription_interval'] ); | |
| 1457 | - } | |
| 1458 | - if ( '' === $cadence['billing_cycles'] ) { | |
| 1459 | - $cadence['billing_cycles'] = Helper::get_string_value( $resolved['subscription_billing_cycles'] ); | |
| 1460 | - } | |
| 1461 | - } | |
| 1462 | - } | |
| 1463 | - | |
| 1464 | - return $cadence; | |
| 1465 | - } | |
| 1466 | - | |
| 1467 | - /** | |
| 1468 | 920 | * Validate a full donation submission server-side. |
| 1469 | 921 | * |
| 1470 | 922 | * Centralizes the two server-side checks every donation-creation entry point |
| 1471 | 923 | * must run before any payment intent / record is created: |
| @@ -1479,14 +931,11 @@ | ||
| 1479 | 931 | * @param string $currency Currency code (e.g. 'USD'). |
| 1480 | 932 | * @param int $form_id Donation form post ID. |
| 1481 | 933 | * @param string $block_id Payment block identifier. |
| 1482 | 934 | * @param string $gateway Payment gateway identifier (default 'stripe'). |
| 1483 | - * @param string $payment_type Payment type being processed ('one-time' or | |
| 1484 | - * 'subscription'); selects the amount config | |
| 1485 | - * on blocks configured as 'both'. | |
| 1486 | 935 | * @return array{valid: bool, message: string, field_errors: array<string, string>} Combined result. |
| 1487 | 936 | */ |
| 1488 | - public static function validate_submission( $fields, $amount, $currency, $form_id, $block_id, $gateway = 'stripe', $payment_type = '' ) { | |
| 937 | + public static function validate_submission( $fields, $amount, $currency, $form_id, $block_id, $gateway = 'stripe' ) { | |
| 1489 | 938 | $result = [ |
| 1490 | 939 | 'valid' => true, |
| 1491 | 940 | 'message' => '', |
| 1492 | 941 | 'field_errors' => [], |
| @@ -1499,41 +948,10 @@ | ||
| 1499 | 948 | $result['field_errors'] = $field_errors; |
| 1500 | 949 | $result['message'] = __( 'Please correct the highlighted fields and try again.', 'suredonation' ); |
| 1501 | 950 | } |
| 1502 | 951 | |
| 1503 | - // Contact-consent requirement (Privacy settings). Enforced here at the shared | |
| 1504 | - // validation choke point so it applies to every gateway (stripe/paypal/ | |
| 1505 | - // offline/ajax) before any donor/intent is persisted. | |
| 1506 | - $consent_error = \SureDonation\Inc\Privacy\Privacy_Frontend::validate_consent(); | |
| 1507 | - if ( '' !== $consent_error ) { | |
| 1508 | - $result['valid'] = false; | |
| 1509 | - // Key by the consent input's data-slug so the client renders it inline | |
| 1510 | - // against the checkbox (showServerFieldErrors), like other field errors. | |
| 1511 | - $result['field_errors'][ \SureDonation\Inc\Privacy\Privacy_Frontend::CONSENT_FIELD ] = $consent_error; | |
| 1512 | - if ( '' === $result['message'] ) { | |
| 1513 | - $result['message'] = __( 'Please correct the highlighted fields and try again.', 'suredonation' ); | |
| 1514 | - } | |
| 1515 | - } | |
| 1516 | - | |
| 1517 | - // The persisted donor email comes from the POST donor_email param, which | |
| 1518 | - // is separate from the validation-only fields[] copy inspected above and | |
| 1519 | - // is never run through validate_form_data(). Length-cap it here too, or a | |
| 1520 | - // crafted request could store an oversized value against the VARCHAR(255) | |
| 1521 | - // donor-email columns. | |
| 1522 | - $donor_email = self::get_submitted_donor_email(); | |
| 1523 | - if ( '' !== $donor_email ) { | |
| 1524 | - $email_error = \SureDonation\Inc\Field_Validation::validate_email_length( $donor_email ); | |
| 1525 | - if ( '' !== $email_error ) { | |
| 1526 | - $result['valid'] = false; | |
| 1527 | - $result['field_errors']['donor_email'] = $email_error; | |
| 1528 | - if ( '' === $result['message'] ) { | |
| 1529 | - $result['message'] = __( 'Please correct the highlighted fields and try again.', 'suredonation' ); | |
| 1530 | - } | |
| 1531 | - } | |
| 1532 | - } | |
| 1533 | - | |
| 1534 | 952 | // Payment amount validation (prevents amount/type tampering). |
| 1535 | - $amount_result = self::validate_payment_amount( $amount, $currency, $form_id, $block_id, $gateway, $payment_type ); | |
| 953 | + $amount_result = self::validate_payment_amount( $amount, $currency, $form_id, $block_id, $gateway ); | |
| 1536 | 954 | if ( empty( $amount_result['valid'] ) ) { |
| 1537 | 955 | $result['valid'] = false; |
| 1538 | 956 | // Surface the specific amount message only when no field errors took precedence. |
| 1539 | 957 | if ( empty( $result['field_errors'] ) ) { |
| @@ -1561,25 +979,20 @@ | ||
| 1561 | 979 | if ( ! isset( $_POST['fields'] ) || ! is_array( $_POST['fields'] ) ) { |
| 1562 | 980 | return []; |
| 1563 | 981 | } |
| 1564 | 982 | |
| 1565 | - // The outer array can contain nested arrays (`['label'=>.., 'value'=>..]`), | |
| 1566 | - // so each value is sanitized individually rather than with array_map() on | |
| 1567 | - // the whole structure. Field slugs (keys) are sanitized below. | |
| 1568 | - // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce/HMAC verified by the calling handler; each value sanitized individually below. | |
| 1569 | - $raw = wp_unslash( $_POST['fields'] ); | |
| 983 | + // Sanitize every value on access; field slugs (keys) are sanitized below. | |
| 984 | + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler. | |
| 985 | + $raw = array_map( 'sanitize_text_field', wp_unslash( $_POST['fields'] ) ); | |
| 1570 | 986 | $fields = []; |
| 1571 | 987 | |
| 1572 | - foreach ( $raw as $slug => $field ) { | |
| 988 | + foreach ( $raw as $slug => $value ) { | |
| 1573 | 989 | $slug = sanitize_text_field( (string) $slug ); |
| 1574 | 990 | if ( '' === $slug ) { |
| 1575 | 991 | continue; |
| 1576 | 992 | } |
| 1577 | 993 | |
| 1578 | - // New nested shape: ['label'=>.., 'value'=>..]. Backward-compat: plain string. | |
| 1579 | - $value = is_array( $field ) ? ( $field['value'] ?? '' ) : $field; | |
| 1580 | - | |
| 1581 | - $fields[ $slug ] = is_string( $value ) ? sanitize_text_field( $value ) : ''; | |
| 994 | + $fields[ $slug ] = is_string( $value ) ? $value : ''; | |
| 1582 | 995 | } |
| 1583 | 996 | |
| 1584 | 997 | return $fields; |
| 1585 | 998 | } |
| @@ -1584,218 +997,8 @@ | ||
| 1584 | 997 | return $fields; |
| 1585 | 998 | } |
| 1586 | 999 | |
| 1587 | 1000 | /** |
| 1588 | - * Read the submitted donor email from the request. | |
| 1589 | - * | |
| 1590 | - * Mirrors get_submitted_fields(): the value is used for validation, and the | |
| 1591 | - * caller is responsible for nonce/token checks. sanitize_email() matches how | |
| 1592 | - * the gateway handlers extract donor_email before persisting it. | |
| 1593 | - * | |
| 1594 | - * @since 1.1.1 | |
| 1595 | - * @return string Sanitized donor email, or '' when absent. | |
| 1596 | - */ | |
| 1597 | - public static function get_submitted_donor_email() { | |
| 1598 | - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler. | |
| 1599 | - if ( ! isset( $_POST['donor_email'] ) ) { | |
| 1600 | - return ''; | |
| 1601 | - } | |
| 1602 | - | |
| 1603 | - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler. | |
| 1604 | - return sanitize_email( wp_unslash( $_POST['donor_email'] ) ); | |
| 1605 | - } | |
| 1606 | - | |
| 1607 | - /** | |
| 1608 | - * Read submitted form fields as label/value pairs for storage. | |
| 1609 | - * | |
| 1610 | - * Mirrors get_submitted_fields() but preserves each field's visible label so | |
| 1611 | - * the submission can be persisted in a human-readable form. Handles both the | |
| 1612 | - * new nested POST shape (`fields[slug][label]`, `fields[slug][value]`) and the | |
| 1613 | - * legacy plain-string shape (`fields[slug]`), in which case the label is empty. | |
| 1614 | - * The caller is responsible for nonce/token checks. | |
| 1615 | - * | |
| 1616 | - * @since 1.1.1 | |
| 1617 | - * @return array<string, array{label: string, value: string}> Map of field slug => label/value. | |
| 1618 | - */ | |
| 1619 | - public static function get_submitted_field_data() { | |
| 1620 | - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler. | |
| 1621 | - if ( ! isset( $_POST['fields'] ) || ! is_array( $_POST['fields'] ) ) { | |
| 1622 | - return []; | |
| 1623 | - } | |
| 1624 | - | |
| 1625 | - // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce/HMAC verified by the calling handler; each label/value sanitized individually below. | |
| 1626 | - $raw = wp_unslash( $_POST['fields'] ); | |
| 1627 | - $fields = []; | |
| 1628 | - | |
| 1629 | - // Core donor fields (name, email, amount) are stored in their own | |
| 1630 | - // columns, so they are omitted from the stored "additional" set. Their | |
| 1631 | - // slugs are derived server-side from the form's saved payment block | |
| 1632 | - // (not trusted from the request) so the exclusion can't be bypassed. | |
| 1633 | - // Empty values are skipped too. Neither affects validation, which reads | |
| 1634 | - // the full set via get_submitted_fields(). | |
| 1635 | - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler; form_id is a lookup key, the slugs/labels come from the saved form. | |
| 1636 | - $form_id = isset( $_POST['form_id'] ) ? absint( wp_unslash( $_POST['form_id'] ) ) : 0; | |
| 1637 | - $core_slugs = \SureDonation\Inc\Field_Validation::get_core_field_slugs( $form_id ); | |
| 1638 | - | |
| 1639 | - // Resolve each field's label from the saved form (authoritative) rather | |
| 1640 | - // than the request, so the stored label can't be tampered with and does | |
| 1641 | - // not depend on the rendered markup. Slugs absent here (e.g. labels left | |
| 1642 | - // at their Gutenberg default, which are not persisted) fall back to the | |
| 1643 | - // submitted label below. | |
| 1644 | - $field_labels = \SureDonation\Inc\Field_Validation::get_field_labels_map( $form_id ); | |
| 1645 | - | |
| 1646 | - // Checkbox fields are resolved from the saved form too. A checkbox posts | |
| 1647 | - // "1" when ticked and "" when not, neither of which reads as anything on | |
| 1648 | - // the entry screen, in an export or in an email — so both states are | |
| 1649 | - // rendered as Yes/No, and the unticked one is kept rather than dropped by | |
| 1650 | - // the empty-value skip below (a declined consent is a meaningful record). | |
| 1651 | - $checkbox_slugs = \SureDonation\Inc\Field_Validation::get_checkbox_field_slugs( $form_id ); | |
| 1652 | - | |
| 1653 | - foreach ( $raw as $slug => $field ) { | |
| 1654 | - $slug = sanitize_text_field( (string) $slug ); | |
| 1655 | - if ( '' === $slug || in_array( $slug, $core_slugs, true ) ) { | |
| 1656 | - continue; | |
| 1657 | - } | |
| 1658 | - | |
| 1659 | - if ( is_array( $field ) ) { | |
| 1660 | - $label = isset( $field['label'] ) && is_string( $field['label'] ) ? $field['label'] : ''; | |
| 1661 | - $value = isset( $field['value'] ) && is_string( $field['value'] ) ? $field['value'] : ''; | |
| 1662 | - $group = isset( $field['group'] ) && is_string( $field['group'] ) ? $field['group'] : ''; | |
| 1663 | - } else { | |
| 1664 | - // Legacy plain-string shape — no label/group available. | |
| 1665 | - $label = ''; | |
| 1666 | - $value = is_string( $field ) ? $field : ''; | |
| 1667 | - $group = ''; | |
| 1668 | - } | |
| 1669 | - | |
| 1670 | - $value = sanitize_text_field( $value ); | |
| 1671 | - | |
| 1672 | - // Multi-select dropdown values arrive '|'-delimited (an option label | |
| 1673 | - // may contain a comma). Re-join with ', ' for a readable stored/ | |
| 1674 | - // displayed value. The flag is client-sent and only affects display | |
| 1675 | - // formatting here — server-side validation is unaffected. | |
| 1676 | - if ( is_array( $field ) && isset( $field['multiple'] ) && 'true' === $field['multiple'] ) { | |
| 1677 | - $value = implode( ', ', array_filter( array_map( 'trim', explode( '|', $value ) ), 'strlen' ) ); | |
| 1678 | - } | |
| 1679 | - | |
| 1680 | - $is_checkbox = in_array( $slug, $checkbox_slugs, true ); | |
| 1681 | - | |
| 1682 | - if ( $is_checkbox ) { | |
| 1683 | - // Canonical, locale-independent tokens. Translating here would bake | |
| 1684 | - // the admin's language at submission time into a permanent record | |
| 1685 | - // that is later exported to CSV and can be re-imported on another | |
| 1686 | - // site — a locale switch or an updated .mo would leave one column | |
| 1687 | - // holding "Ja" for old rows and "Yes" for new ones, uncomparable by | |
| 1688 | - // any spreadsheet filter or CRM mapping. Display layers translate | |
| 1689 | - // via Helper::format_checkbox_field_value(). | |
| 1690 | - $value = \SureDonation\Inc\Field_Validation::CHECKBOX_VALUES[ '' === trim( $value ) ? 'no' : 'yes' ]; | |
| 1691 | - } elseif ( '' === trim( $value ) ) { | |
| 1692 | - // Skip empty values so blank/optional fields don't clutter the entry. | |
| 1693 | - continue; | |
| 1694 | - } | |
| 1695 | - | |
| 1696 | - // Prefer the saved-form label; fall back to the submitted one only | |
| 1697 | - // when the slug has no persisted (customized) label. | |
| 1698 | - $resolved_label = isset( $field_labels[ $slug ] ) ? $field_labels[ $slug ] : sanitize_text_field( $label ); | |
| 1699 | - | |
| 1700 | - $fields[ $slug ] = [ | |
| 1701 | - 'label' => $resolved_label, | |
| 1702 | - 'value' => $value, | |
| 1703 | - // Parent block label (e.g. "Address") used to nest sub-fields on | |
| 1704 | - // the entry screen; '' for standalone fields. | |
| 1705 | - 'group' => sanitize_text_field( $group ), | |
| 1706 | - ]; | |
| 1707 | - } | |
| 1708 | - | |
| 1709 | - return $fields; | |
| 1710 | - } | |
| 1711 | - | |
| 1712 | - /** | |
| 1713 | - * Resolve the donor phone for storage from the submitted form fields. | |
| 1714 | - * | |
| 1715 | - * When a Phone field is mapped to the donor phone on the payment block, its | |
| 1716 | - * value is read here from the already-validated submitted field set (keyed by | |
| 1717 | - * the mapped slug, derived server-side) rather than from a separate, unchecked | |
| 1718 | - * $_POST['donor_phone']. The value is length-capped to the donor_phone column | |
| 1719 | - * width (VARCHAR(50)) so an over-long number cannot truncate or abort the | |
| 1720 | - * write. Returns '' when no phone field is mapped. The caller verifies the | |
| 1721 | - * nonce/HMAC token. | |
| 1722 | - * | |
| 1723 | - * @since 1.1.1 | |
| 1724 | - * @param int $form_id The donation form post ID. | |
| 1725 | - * @return string The donor phone value, or '' when unmapped/absent. | |
| 1726 | - */ | |
| 1727 | - public static function get_mapped_donor_phone( $form_id ) { | |
| 1728 | - $form_id = (int) $form_id; | |
| 1729 | - if ( $form_id <= 0 ) { | |
| 1730 | - return ''; | |
| 1731 | - } | |
| 1732 | - | |
| 1733 | - $phone_slug = \SureDonation\Inc\Field_Validation::get_mapped_phone_slug( $form_id ); | |
| 1734 | - if ( '' === $phone_slug ) { | |
| 1735 | - return ''; | |
| 1736 | - } | |
| 1737 | - | |
| 1738 | - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler. | |
| 1739 | - if ( ! isset( $_POST['fields'] ) || ! is_array( $_POST['fields'] ) ) { | |
| 1740 | - return ''; | |
| 1741 | - } | |
| 1742 | - | |
| 1743 | - // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Token verified by caller; value sanitized below. | |
| 1744 | - $raw = wp_unslash( $_POST['fields'] ); | |
| 1745 | - $field = $raw[ $phone_slug ] ?? ''; | |
| 1746 | - $value = is_array( $field ) ? ( $field['value'] ?? '' ) : $field; | |
| 1747 | - $value = sanitize_text_field( is_string( $value ) ? $value : '' ); | |
| 1748 | - | |
| 1749 | - // Cap to the donor_phone column width to avoid truncation/abort on write. | |
| 1750 | - return mb_substr( $value, 0, 50 ); | |
| 1751 | - } | |
| 1752 | - | |
| 1753 | - /** | |
| 1754 | - * Resolve the anonymous-donation flag for storage from the request. | |
| 1755 | - * | |
| 1756 | - * The Anonymous Donation checkbox renders with a per-block name and no | |
| 1757 | - * data-slug, so it is not part of the submitted field set; the gateway JS | |
| 1758 | - * forwards it as a dedicated `is_anonymous` key instead (see | |
| 1759 | - * GatewayBase.appendAnonymousFlag). The flag is a display-only marker — the | |
| 1760 | - * donor's real name, email and phone are still stored and processed as usual, | |
| 1761 | - * and only the public donor wall / recent donations / top donors mask them. | |
| 1762 | - * | |
| 1763 | - * Whether the form offers the option is resolved from the saved form rather | |
| 1764 | - * than trusted from the request, matching how the mapped phone field and the | |
| 1765 | - * cover-fees configuration are derived server-side. A flag posted against a | |
| 1766 | - * form with no Anonymous Donation block is therefore ignored. The caller | |
| 1767 | - * verifies the nonce/HMAC token. | |
| 1768 | - * | |
| 1769 | - * @since 1.5.1 | |
| 1770 | - * @param int $form_id The donation form post ID. | |
| 1771 | - * @return bool True when the donation should be flagged anonymous. | |
| 1772 | - */ | |
| 1773 | - public static function get_submitted_is_anonymous( $form_id ) { | |
| 1774 | - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler. | |
| 1775 | - if ( empty( $_POST['is_anonymous'] ) ) { | |
| 1776 | - return false; | |
| 1777 | - } | |
| 1778 | - | |
| 1779 | - $form_id = (int) $form_id; | |
| 1780 | - if ( $form_id <= 0 || ! function_exists( 'parse_blocks' ) ) { | |
| 1781 | - return false; | |
| 1782 | - } | |
| 1783 | - | |
| 1784 | - // form_id is attacker-chosen on a public endpoint, so confirm it really is | |
| 1785 | - // a donation form before parsing its content — otherwise the request can | |
| 1786 | - // aim a full block parse at any post in the database. | |
| 1787 | - $post = get_post( $form_id ); | |
| 1788 | - if ( ! ( $post instanceof \WP_Post ) | |
| 1789 | - || \SureDonation\Inc\Post_Types\Donation_Form::POST_TYPE !== $post->post_type | |
| 1790 | - || empty( $post->post_content ) ) { | |
| 1791 | - return false; | |
| 1792 | - } | |
| 1793 | - | |
| 1794 | - return Helper::block_tree_contains( parse_blocks( $post->post_content ), 'suredonation/anonymous-donation' ); | |
| 1795 | - } | |
| 1796 | - | |
| 1797 | - /** | |
| 1798 | 1001 | * Store payment intent metadata for verification. |
| 1799 | 1002 | * |
| 1800 | 1003 | * This stores the expected payment amount when creating a payment intent, |
| 1801 | 1004 | * allowing the webhook to verify the actual charged amount matches. |
| @@ -1901,12 +1104,9 @@ | ||
| 1901 | 1104 | ) |
| 1902 | 1105 | ); |
| 1903 | 1106 | } |
| 1904 | 1107 | |
| 1905 | - // Amounts here are already in the gateway's minor units (cents for | |
| 1906 | - // 2-decimal currencies, whole units for zero-decimal ones), so a | |
| 1907 | - // tolerance of 1 is one minor currency unit for rounding regardless | |
| 1908 | - // of currency. | |
| 1108 | + // Verify amount matches (allow 1 cent tolerance for rounding). | |
| 1909 | 1109 | if ( abs( $actual_amount - $expected_amount ) > 1 ) { |
| 1910 | 1110 | return new \WP_Error( |
| 1911 | 1111 | 'amount_mismatch', |
| 1912 | 1112 | sprintf( |
| @@ -1938,30 +1138,9 @@ | ||
| 1938 | 1138 | // Check if variable amount field block name is set. |
| 1939 | 1139 | $dynamic_amount_field_block_name = isset( $payment_config['variable_amount_field_block_name'] ) && is_string( $payment_config['variable_amount_field_block_name'] ) ? $payment_config['variable_amount_field_block_name'] : ''; |
| 1940 | 1140 | |
| 1941 | 1141 | if ( empty( $dynamic_amount_field_block_name ) ) { |
| 1942 | - // A config that explicitly declares a 'variable' amount but resolves no | |
| 1943 | - // field block is a misconfiguration — Dynamic Amount was chosen but no | |
| 1944 | - // "Choose Amount Field" was picked (Gutenberg omits the empty default), | |
| 1945 | - // or the picked slug no longer resolves to a block. Fail closed: with | |
| 1946 | - // no field to re-resolve against, only the gateway floor would be left, | |
| 1947 | - // so an unauthenticated visitor could post any amount (e.g. $0.50/month | |
| 1948 | - // on a $100 form). Reject rather than accept an unverifiable amount. | |
| 1949 | - $declares_variable = isset( $payment_config['amount_type'] ) | |
| 1950 | - && is_string( $payment_config['amount_type'] ) | |
| 1951 | - && 'variable' === $payment_config['amount_type']; | |
| 1952 | - | |
| 1953 | - if ( $declares_variable ) { | |
| 1954 | - return [ | |
| 1955 | - 'valid' => false, | |
| 1956 | - 'message' => __( 'Unable to verify the donation amount for this form. Please reload the page and try again.', 'suredonation' ), | |
| 1957 | - ]; | |
| 1958 | - } | |
| 1959 | - | |
| 1960 | - // No amount_type declared at all — a genuinely older layout where the | |
| 1961 | - // donor's amount was an intentional free choice. The amount-type, | |
| 1962 | - // minimum and gateway-minimum checks in validate_payment_amount() still | |
| 1963 | - // apply, so allow it through here. | |
| 1142 | + // Return null because it can be old form configuration. | |
| 1964 | 1143 | return null; |
| 1965 | 1144 | } |
| 1966 | 1145 | |
| 1967 | 1146 | // Get the slug of the variable amount field. |
| @@ -1969,19 +1148,8 @@ | ||
| 1969 | 1148 | |
| 1970 | 1149 | // Find the block config for the variable amount field by matching slug and block name. |
| 1971 | 1150 | $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug ); |
| 1972 | 1151 | |
| 1973 | - // The form declares a variable-amount field but its block config cannot be | |
| 1974 | - // resolved. Fail-safe reject instead of allowing an unvalidated amount | |
| 1975 | - // through: never trust a client-supplied amount we cannot re-resolve | |
| 1976 | - // server-side (mirrors SureForms #2855 hardening). | |
| 1977 | - if ( empty( $variable_amount_block_config ) || ! is_array( $variable_amount_block_config ) ) { | |
| 1978 | - return [ | |
| 1979 | - 'valid' => false, | |
| 1980 | - 'message' => __( 'Unable to verify the donation amount for this form. Please reload the page and try again.', 'suredonation' ), | |
| 1981 | - ]; | |
| 1982 | - } | |
| 1983 | - | |
| 1984 | 1152 | // Handle number block validation. |
| 1985 | 1153 | if ( 'suredonation/number' === $dynamic_amount_field_block_name ) { |
| 1986 | 1154 | return self::validate_number_field_amount( $variable_amount_block_config, $amount, $currency ); |
| 1987 | 1155 | } |
| @@ -1990,15 +1158,10 @@ | ||
| 1990 | 1158 | if ( 'suredonation/donation-amount' === $dynamic_amount_field_block_name ) { |
| 1991 | 1159 | return self::validate_multi_choice_amount( $variable_amount_block_config, $amount, $currency ); |
| 1992 | 1160 | } |
| 1993 | 1161 | |
| 1994 | - // A variable-amount field is declared with a block type we have no | |
| 1995 | - // validator for. We cannot re-resolve the payable amount, so fail-safe | |
| 1996 | - // reject rather than fall through as accepted. | |
| 1997 | - return [ | |
| 1998 | - 'valid' => false, | |
| 1999 | - 'message' => __( 'Unsupported variable amount field configuration.', 'suredonation' ), | |
| 2000 | - ]; | |
| 1162 | + // Unknown block type - allow but log for debugging. | |
| 1163 | + return null; | |
| 2001 | 1164 | } |
| 2002 | 1165 | |
| 2003 | 1166 | /** |
| 2004 | 1167 | * Validate amount from number field against configured min/max. |
| @@ -2009,25 +1172,17 @@ | ||
| 2009 | 1172 | * @return array<string, mixed>|null Validation result array or null if validation passes. |
| 2010 | 1173 | * @since 0.0.1 |
| 2011 | 1174 | */ |
| 2012 | 1175 | private static function validate_number_field_amount( $number_block_config, $amount, $currency ) { |
| 2013 | - // Fail-safe reject when the field config is missing. The caller already | |
| 2014 | - // rejects an unresolvable config, so this is defensive: a configured | |
| 2015 | - // number field must never validate an amount against no constraints. | |
| 1176 | + // If no config found, allow the amount (old forms). | |
| 2016 | 1177 | if ( empty( $number_block_config ) || ! is_array( $number_block_config ) ) { |
| 2017 | - return [ | |
| 2018 | - 'valid' => false, | |
| 2019 | - 'message' => __( 'Variable amount field configuration not found.', 'suredonation' ), | |
| 2020 | - ]; | |
| 1178 | + return null; | |
| 2021 | 1179 | } |
| 2022 | 1180 | |
| 2023 | - // One minor currency unit of tolerance for float rounding. | |
| 2024 | - $epsilon = self::get_amount_epsilon( $currency ); | |
| 2025 | - | |
| 2026 | 1181 | // Validate min value if configured. |
| 2027 | 1182 | if ( isset( $number_block_config['min'] ) && is_numeric( $number_block_config['min'] ) ) { |
| 2028 | 1183 | $min_value = (float) $number_block_config['min']; |
| 2029 | - if ( $amount < $min_value - $epsilon ) { | |
| 1184 | + if ( $amount < $min_value - 0.01 ) { // Allow small tolerance. | |
| 2030 | 1185 | return [ |
| 2031 | 1186 | 'valid' => false, |
| 2032 | 1187 | /* translators: %s: minimum amount with currency */ |
| 2033 | 1188 | 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $min_value, $currency ) ), |
| @@ -2037,9 +1192,9 @@ | ||
| 2037 | 1192 | |
| 2038 | 1193 | // Validate max value if configured. |
| 2039 | 1194 | if ( isset( $number_block_config['max'] ) && is_numeric( $number_block_config['max'] ) ) { |
| 2040 | 1195 | $max_value = (float) $number_block_config['max']; |
| 2041 | - if ( $amount > $max_value + $epsilon ) { | |
| 1196 | + if ( $amount > $max_value + 0.01 ) { // Allow small tolerance. | |
| 2042 | 1197 | return [ |
| 2043 | 1198 | 'valid' => false, |
| 2044 | 1199 | /* translators: %s: maximum amount with currency */ |
| 2045 | 1200 | 'message' => sprintf( __( 'Payment amount cannot exceed %s.', 'suredonation' ), self::format_amount( $max_value, $currency ) ), |
| @@ -2067,11 +1222,8 @@ | ||
| 2067 | 1222 | 'message' => __( 'Variable amount field configuration not found.', 'suredonation' ), |
| 2068 | 1223 | ]; |
| 2069 | 1224 | } |
| 2070 | 1225 | |
| 2071 | - // One minor currency unit of tolerance for float rounding. | |
| 2072 | - $epsilon = self::get_amount_epsilon( $currency ); | |
| 2073 | - | |
| 2074 | 1226 | // Donation Amount is a single-select radio group. Extract the preset |
| 2075 | 1227 | // option values and check whether the submitted amount matches one. |
| 2076 | 1228 | $allowed_options = $multi_choice_config['options'] ?? []; |
| 2077 | 1229 | $allowed_values = []; |
| @@ -2082,10 +1234,11 @@ | ||
| 2082 | 1234 | } |
| 2083 | 1235 | } |
| 2084 | 1236 | } |
| 2085 | 1237 | |
| 1238 | + // Allow a small floating point difference (0.01) due to rounding. | |
| 2086 | 1239 | foreach ( $allowed_values as $allowed_value ) { |
| 2087 | - if ( abs( $amount - $allowed_value ) <= $epsilon ) { | |
| 1240 | + if ( abs( $amount - $allowed_value ) <= 0.01 ) { | |
| 2088 | 1241 | return null; // Matches a configured preset — valid. |
| 2089 | 1242 | } |
| 2090 | 1243 | } |
| 2091 | 1244 | |
| @@ -2112,9 +1265,9 @@ | ||
| 2112 | 1265 | $max = isset( $multi_choice_config['custom_amount_max'] ) && is_numeric( $multi_choice_config['custom_amount_max'] ) |
| 2113 | 1266 | ? (float) $multi_choice_config['custom_amount_max'] |
| 2114 | 1267 | : 0.0; |
| 2115 | 1268 | |
| 2116 | - if ( $min > 0 && $amount < $min - $epsilon ) { | |
| 1269 | + if ( $min > 0 && $amount < $min - 0.01 ) { | |
| 2117 | 1270 | return [ |
| 2118 | 1271 | 'valid' => false, |
| 2119 | 1272 | /* translators: %s: minimum amount with currency */ |
| 2120 | 1273 | 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $min, $currency ) ), |
| @@ -2120,9 +1273,9 @@ | ||
| 2120 | 1273 | 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $min, $currency ) ), |
| 2121 | 1274 | ]; |
| 2122 | 1275 | } |
| 2123 | 1276 | |
| 2124 | - if ( $max > 0 && $amount > $max + $epsilon ) { | |
| 1277 | + if ( $max > 0 && $amount > $max + 0.01 ) { | |
| 2125 | 1278 | return [ |
| 2126 | 1279 | 'valid' => false, |
| 2127 | 1280 | /* translators: %s: maximum amount with currency */ |
| 2128 | 1281 | 'message' => sprintf( __( 'Payment amount cannot exceed %s.', 'suredonation' ), self::format_amount( $max, $currency ) ), |