PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/payments/payment-helper.php +35 -882 1.5.1 → 1.1.0 View file →
@@ -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 ) ),