PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
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
suredonation / inc / payments / payment-helper.php

payment-helper.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.1, at inc/payments/payment-helper.php

2,281 lines 85.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Payment Helper - Global payment utilities
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Payments;
9
10 use SureDonation\Inc\Database\Tables\Donations;
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 use WP_Error;
17
18 // Exit if accessed directly.
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 /**
24 * Payment_Helper class
25 * Provides gateway-agnostic payment utilities
26 *
27 * @since 0.0.1
28 */
29 class Payment_Helper {
30 /**
31 * Option key for payment settings within consolidated options.
32 *
33 * @since 0.0.1
34 */
35 public const OPTION_KEY = 'payment_settings';
36
37 /**
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 * Get all payment settings
48 *
49 * @return array<string, mixed> Payment settings.
50 * @since 0.0.1
51 */
52 public static function get_all_payment_settings() {
53 $options = Helper::get_suredonation_option( self::OPTION_KEY, [] );
54
55 // Ensure default structure.
56 $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,
72 ],
73 'fee_recovery' => [
74 'fee_percentage' => 2.9,
75 'fee_fixed' => 0.30,
76 'fee_mode' => 'all_gateways',
77 'gateways' => [
78 'stripe' => [
79 'fee_percentage' => 2.9,
80 'fee_fixed' => 0.30,
81 'enabled' => true,
82 ],
83 'paypal' => [
84 'fee_percentage' => 0,
85 'fee_fixed' => 0,
86 'enabled' => false,
87 ],
88 'offline' => [
89 'fee_percentage' => 0,
90 'fee_fixed' => 0,
91 'enabled' => false,
92 ],
93 ],
94 ],
95 ];
96
97 $options_array = is_array( $options ) ? $options : [];
98 return wp_parse_args( $options_array, $defaults );
99 }
100
101 /**
102 * Update all payment settings
103 *
104 * @param array<string, mixed> $settings Payment settings.
105 * @return bool True on success.
106 * @since 0.0.1
107 */
108 public static function update_all_payment_settings( $settings ) {
109 return Helper::update_suredonation_option( self::OPTION_KEY, $settings );
110 }
111
112 /**
113 * Get gateway-specific settings
114 *
115 * @param string $gateway Gateway name (e.g., 'stripe').
116 * @return array<string, mixed> Gateway settings.
117 * @since 0.0.1
118 */
119 public static function get_gateway_settings( $gateway ) {
120 $all_settings = self::get_all_payment_settings();
121 $gateway_settings = $all_settings[ $gateway ] ?? [];
122 return is_array( $gateway_settings ) ? $gateway_settings : [];
123 }
124
125 /**
126 * Update gateway-specific settings
127 *
128 * @param string $gateway Gateway name.
129 * @param array<string, mixed> $settings Gateway settings.
130 * @return bool True on success.
131 * @since 0.0.1
132 */
133 public static function update_gateway_settings( $gateway, $settings ) {
134 $all_settings = self::get_all_payment_settings();
135 $all_settings[ $gateway ] = $settings;
136 return self::update_all_payment_settings( $all_settings );
137 }
138
139 /**
140 * Get global payment setting
141 *
142 * @param string $key Setting key.
143 * @param mixed $default_value Default value.
144 * @return mixed Setting value.
145 * @since 0.0.1
146 */
147 public static function get_global_setting( $key, $default_value = '' ) {
148 $all_settings = self::get_all_payment_settings();
149 return $all_settings[ $key ] ?? $default_value;
150 }
151
152 /**
153 * Update global payment setting
154 *
155 * @param string $key Setting key.
156 * @param mixed $value Setting value.
157 * @return bool True on success.
158 * @since 0.0.1
159 */
160 public static function update_global_setting( $key, $value ) {
161 $all_settings = self::get_all_payment_settings();
162 $all_settings[ $key ] = $value;
163 return self::update_all_payment_settings( $all_settings );
164 }
165
166 /**
167 * Get current payment mode
168 *
169 * @return string 'test' or 'live'.
170 * @since 0.0.1
171 */
172 public static function get_payment_mode() {
173 $response = self::get_global_setting( 'payment_mode', 'test' );
174 return ! empty( $response ) && is_string( $response ) ? $response : 'test';
175 }
176
177 /**
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 * Get the currency list formatted for select inputs.
427 *
428 * Returns a map of currency code to a "CODE - Name" display label, shared
429 * by the REST currencies endpoint and the admin bootstrap data so the
430 * Select renders synchronously without an extra fetch.
431 *
432 * @return array<string, string> Map of currency code to display label.
433 * @since 1.1.0
434 */
435 public static function get_currencies_list() {
436 $currencies = [];
437 foreach ( self::get_all_currencies_data() as $code => $data ) {
438 $currencies[ $code ] = $code . ' - ' . $data['name'];
439 }
440 return $currencies;
441 }
442
443 /**
444 * Get comprehensive currency data for all supported currencies.
445 *
446 * This is the single source of truth for all currency-related data.
447 * Contains currency name, symbol, and decimal places.
448 *
449 * @return array<string, array<string, mixed>> Array of currency data keyed by currency code.
450 * @since 0.0.1
451 */
452 public static function get_all_currencies_data() {
453 return [
454 'USD' => [
455 'name' => __( 'US Dollar', 'suredonation' ),
456 'symbol' => '$',
457 'decimal_places' => 2,
458 ],
459 'EUR' => [
460 'name' => __( 'Euro', 'suredonation' ),
461 'symbol' => '€',
462 'decimal_places' => 2,
463 ],
464 'GBP' => [
465 'name' => __( 'British Pound', 'suredonation' ),
466 'symbol' => '£',
467 'decimal_places' => 2,
468 ],
469 'JPY' => [
470 'name' => __( 'Japanese Yen', 'suredonation' ),
471 'symbol' => '¥',
472 'decimal_places' => 0,
473 ],
474 'AUD' => [
475 'name' => __( 'Australian Dollar', 'suredonation' ),
476 'symbol' => 'A$',
477 'decimal_places' => 2,
478 ],
479 'CAD' => [
480 'name' => __( 'Canadian Dollar', 'suredonation' ),
481 'symbol' => 'C$',
482 'decimal_places' => 2,
483 ],
484 'CHF' => [
485 'name' => __( 'Swiss Franc', 'suredonation' ),
486 'symbol' => 'CHF',
487 'decimal_places' => 2,
488 ],
489 'CNY' => [
490 'name' => __( 'Chinese Yuan', 'suredonation' ),
491 'symbol' => '¥',
492 'decimal_places' => 2,
493 ],
494 'SEK' => [
495 'name' => __( 'Swedish Krona', 'suredonation' ),
496 'symbol' => 'kr',
497 'decimal_places' => 2,
498 ],
499 'NZD' => [
500 'name' => __( 'New Zealand Dollar', 'suredonation' ),
501 'symbol' => 'NZ$',
502 'decimal_places' => 2,
503 ],
504 'MXN' => [
505 'name' => __( 'Mexican Peso', 'suredonation' ),
506 'symbol' => 'MX$',
507 'decimal_places' => 2,
508 ],
509 'SGD' => [
510 'name' => __( 'Singapore Dollar', 'suredonation' ),
511 'symbol' => 'S$',
512 'decimal_places' => 2,
513 ],
514 'HKD' => [
515 'name' => __( 'Hong Kong Dollar', 'suredonation' ),
516 'symbol' => 'HK$',
517 'decimal_places' => 2,
518 ],
519 'NOK' => [
520 'name' => __( 'Norwegian Krone', 'suredonation' ),
521 'symbol' => 'kr',
522 'decimal_places' => 2,
523 ],
524 'PLN' => [
525 'name' => __( 'Polish Złoty', 'suredonation' ),
526 'symbol' => 'zł',
527 'decimal_places' => 2,
528 ],
529 'KRW' => [
530 'name' => __( 'South Korean Won', 'suredonation' ),
531 'symbol' => '₩',
532 'decimal_places' => 0,
533 ],
534 'TRY' => [
535 'name' => __( 'Turkish Lira', 'suredonation' ),
536 'symbol' => '₺',
537 'decimal_places' => 2,
538 ],
539 'RUB' => [
540 'name' => __( 'Russian Ruble', 'suredonation' ),
541 'symbol' => '₽',
542 'decimal_places' => 2,
543 ],
544 'INR' => [
545 'name' => __( 'Indian Rupee', 'suredonation' ),
546 'symbol' => '₹',
547 'decimal_places' => 2,
548 ],
549 'BRL' => [
550 'name' => __( 'Brazilian Real', 'suredonation' ),
551 'symbol' => 'R$',
552 'decimal_places' => 2,
553 ],
554 'ZAR' => [
555 'name' => __( 'South African Rand', 'suredonation' ),
556 'symbol' => 'R',
557 'decimal_places' => 2,
558 ],
559 'AED' => [
560 'name' => __( 'UAE Dirham', 'suredonation' ),
561 'symbol' => 'د.إ',
562 'decimal_places' => 2,
563 ],
564 'PHP' => [
565 'name' => __( 'Philippine Peso', 'suredonation' ),
566 'symbol' => '₱',
567 'decimal_places' => 2,
568 ],
569 'IDR' => [
570 'name' => __( 'Indonesian Rupiah', 'suredonation' ),
571 'symbol' => 'Rp',
572 'decimal_places' => 2,
573 ],
574 'MYR' => [
575 'name' => __( 'Malaysian Ringgit', 'suredonation' ),
576 'symbol' => 'RM',
577 'decimal_places' => 2,
578 ],
579 'THB' => [
580 'name' => __( 'Thai Baht', 'suredonation' ),
581 'symbol' => '฿',
582 'decimal_places' => 2,
583 ],
584 'BIF' => [
585 'name' => __( 'Burundian Franc', 'suredonation' ),
586 'symbol' => 'FBu',
587 'decimal_places' => 0,
588 ],
589 'CLP' => [
590 'name' => __( 'Chilean Peso', 'suredonation' ),
591 'symbol' => '$',
592 'decimal_places' => 0,
593 ],
594 'DJF' => [
595 'name' => __( 'Djiboutian Franc', 'suredonation' ),
596 'symbol' => 'Fdj',
597 'decimal_places' => 0,
598 ],
599 'GNF' => [
600 'name' => __( 'Guinean Franc', 'suredonation' ),
601 'symbol' => 'FG',
602 'decimal_places' => 0,
603 ],
604 'KMF' => [
605 'name' => __( 'Comorian Franc', 'suredonation' ),
606 'symbol' => 'CF',
607 'decimal_places' => 0,
608 ],
609 'MGA' => [
610 'name' => __( 'Malagasy Ariary', 'suredonation' ),
611 'symbol' => 'Ar',
612 'decimal_places' => 0,
613 ],
614 'PYG' => [
615 'name' => __( 'Paraguayan Guaraní', 'suredonation' ),
616 'symbol' => '₲',
617 'decimal_places' => 0,
618 ],
619 'RWF' => [
620 'name' => __( 'Rwandan Franc', 'suredonation' ),
621 'symbol' => 'FRw',
622 'decimal_places' => 0,
623 ],
624 'UGX' => [
625 'name' => __( 'Ugandan Shilling', 'suredonation' ),
626 'symbol' => 'USh',
627 'decimal_places' => 0,
628 ],
629 'VND' => [
630 'name' => __( 'Vietnamese Đồng', 'suredonation' ),
631 'symbol' => '₫',
632 'decimal_places' => 0,
633 ],
634 'VUV' => [
635 'name' => __( 'Vanuatu Vatu', 'suredonation' ),
636 'symbol' => 'VT',
637 'decimal_places' => 0,
638 ],
639 'XAF' => [
640 'name' => __( 'Central African CFA Franc', 'suredonation' ),
641 'symbol' => 'FCFA',
642 'decimal_places' => 0,
643 ],
644 'XOF' => [
645 'name' => __( 'West African CFA Franc', 'suredonation' ),
646 'symbol' => 'CFA',
647 'decimal_places' => 0,
648 ],
649 'XPF' => [
650 'name' => __( 'CFP Franc', 'suredonation' ),
651 'symbol' => '₣',
652 'decimal_places' => 0,
653 ],
654 ];
655 }
656
657 /**
658 * Get currency names for all supported currencies.
659 *
660 * @return array<string, mixed> Array of currency names keyed by currency code.
661 * @since 0.0.1
662 */
663 public static function get_currency_names() {
664 $currencies = self::get_all_currencies_data();
665 $names = [];
666
667 foreach ( $currencies as $code => $data ) {
668 $names[ $code ] = $data['name'];
669 }
670
671 return $names;
672 }
673
674 /**
675 * Get currency
676 *
677 * @return string Currency code (e.g., 'USD').
678 * @since 0.0.1
679 */
680 public static function get_currency() {
681 $currency = self::get_global_setting( 'currency', 'USD' );
682 return is_string( $currency ) ? $currency : 'USD';
683 }
684
685 /**
686 * Get currency symbol.
687 *
688 * @param string $currency Currency code.
689 * @return string Currency symbol or empty string.
690 * @since 0.0.1
691 */
692 public static function get_currency_symbol( $currency = '' ) {
693 if ( empty( $currency ) ) {
694 $currency = self::get_currency();
695 }
696
697 if ( empty( $currency ) || ! is_string( $currency ) ) {
698 return '';
699 }
700
701 $currency = strtoupper( $currency );
702 $currencies = self::get_all_currencies_data();
703 $currency_data = $currencies[ $currency ] ?? null;
704
705 $symbol = ! empty( $currency_data ) ? $currency_data['symbol'] : '';
706 return is_string( $symbol ) ? $symbol : '';
707 }
708
709 /**
710 * Get list of zero-decimal currencies.
711 *
712 * Zero-decimal currencies don't use decimal points in payment APIs.
713 * For these currencies, amounts are passed as-is without multiplying/dividing by 100.
714 *
715 * @return array<string> Array of zero-decimal currency codes.
716 * @since 0.0.1
717 */
718 public static function get_zero_decimal_currencies() {
719 $currencies = self::get_all_currencies_data();
720 $zero_decimal_codes = [];
721
722 foreach ( $currencies as $code => $data ) {
723 if ( 0 === $data['decimal_places'] ) {
724 $zero_decimal_codes[] = $code;
725 }
726 }
727
728 return $zero_decimal_codes;
729 }
730
731 /**
732 * Check if currency is zero-decimal.
733 *
734 * @param string $currency Currency code.
735 * @return bool True if zero-decimal currency.
736 * @since 0.0.1
737 */
738 public static function is_zero_decimal_currency( $currency ) {
739 if ( empty( $currency ) || ! is_string( $currency ) ) {
740 return false;
741 }
742
743 $currency = strtoupper( $currency );
744 $currencies = self::get_all_currencies_data();
745 $currency_data = $currencies[ $currency ] ?? null;
746
747 return $currency_data && 0 === $currency_data['decimal_places'];
748 }
749
750 /**
751 * Get the comparison tolerance (epsilon) for a currency, in major units.
752 *
753 * Amount comparisons need a small tolerance to absorb floating-point
754 * rounding. The correct tolerance is one minor unit of the currency:
755 * 0.01 for 2-decimal currencies (e.g. USD) and 1 for zero-decimal
756 * currencies (e.g. JPY) — rather than a hardcoded 0.01, which is
757 * meaningless for zero-decimal currencies.
758 *
759 * @param string $currency Currency code. Defaults to the configured currency.
760 * @return float Tolerance in major currency units.
761 * @since 1.1.1
762 */
763 public static function get_amount_epsilon( $currency = '' ) {
764 if ( empty( $currency ) ) {
765 $currency = self::get_currency();
766 }
767
768 $currency = is_string( $currency ) ? strtoupper( $currency ) : '';
769 $currencies = self::get_all_currencies_data();
770 $currency_data = $currencies[ $currency ] ?? null;
771
772 $decimal_places = ( is_array( $currency_data ) && isset( $currency_data['decimal_places'] ) && is_numeric( $currency_data['decimal_places'] ) )
773 ? (int) $currency_data['decimal_places']
774 : 2;
775
776 return (float) pow( 10, -$decimal_places );
777 }
778
779 /**
780 * Format amount for display
781 *
782 * @param float $amount Amount.
783 * @param string $currency Currency code.
784 * @return string Formatted amount.
785 * @since 0.0.1
786 */
787 public static function format_amount( $amount, $currency = '' ) {
788 if ( empty( $currency ) ) {
789 $currency = self::get_currency();
790 }
791
792 $symbol = self::get_currency_symbol( $currency );
793 $decimal_places = self::is_zero_decimal_currency( $currency ) ? 0 : 2;
794 $formatted = number_format( (float) $amount, $decimal_places, '.', ',' );
795
796 // Fall back to the uppercased currency code when we have no symbol for
797 // this currency (e.g. a historical/imported donation in a currency not
798 // in our list). Positioning a multi-letter code isn't meaningful, so
799 // keep the legacy "CODE 100.00" form rather than routing an empty
800 // symbol through the switch (which would emit a bare number and stray
801 // spaces under the *_space positions).
802 if ( '' === $symbol ) {
803 $code = strtoupper( (string) $currency );
804 return '' === $code ? $formatted : $code . ' ' . $formatted;
805 }
806
807 return self::position_currency_symbol( $symbol, $formatted );
808 }
809
810 /**
811 * Place a currency symbol relative to an already-formatted amount per the
812 * global sign position setting. Kept separate from format_amount() so
813 * callers that format the number themselves (e.g. raw preset labels that
814 * must not gain decimals) can still honor the setting.
815 *
816 * 'auto' (and the historical 'left') keep the symbol on the left, so
817 * existing output is unchanged.
818 *
819 * @param string $symbol Currency symbol.
820 * @param string $formatted_amount Amount already formatted for display.
821 * @return string Amount with the symbol positioned per the setting.
822 * @since 1.3.0
823 */
824 public static function position_currency_symbol( $symbol, $formatted_amount ) {
825 switch ( self::get_currency_sign_position() ) {
826 case 'right':
827 return $formatted_amount . $symbol;
828 case 'left_space':
829 return $symbol . ' ' . $formatted_amount;
830 case 'right_space':
831 return $formatted_amount . ' ' . $symbol;
832 case 'left':
833 case 'auto':
834 default:
835 return $symbol . $formatted_amount;
836 }
837 }
838
839 /**
840 * Get the configured currency sign position.
841 *
842 * Controls where the currency symbol sits relative to the amount in
843 * displayed values. 'auto' preserves the historical behavior (symbol on
844 * the left for server-rendered donor-facing output; locale-aware in the
845 * admin dashboard, which formats via Intl). An explicit value overrides
846 * this consistently across every surface.
847 *
848 * @return string One of 'auto', 'left', 'right', 'left_space', 'right_space'.
849 * @since 1.3.0
850 */
851 public static function get_currency_sign_position() {
852 $position = self::get_global_setting( 'currency_sign_position', 'auto' );
853
854 return is_string( $position ) && in_array( $position, self::ALLOWED_SIGN_POSITIONS, true ) ? $position : 'auto';
855 }
856
857 /**
858 * Convert amount to Stripe format (cents)
859 *
860 * @param float $amount Amount in dollars.
861 * @param string $currency Currency code.
862 * @return int Amount in cents.
863 * @since 0.0.1
864 */
865 public static function amount_to_stripe_format( $amount, $currency = '' ) {
866 if ( empty( $currency ) ) {
867 $currency = self::get_currency();
868 }
869
870 $amount = floatval( $amount );
871 return self::is_zero_decimal_currency( $currency )
872 ? (int) round( $amount )
873 : (int) round( $amount * 100 );
874 }
875
876 /**
877 * Convert amount from Stripe format (cents) to dollars
878 *
879 * @param int $amount Amount in cents.
880 * @param string $currency Currency code.
881 * @return float Amount in dollars.
882 * @since 0.0.1
883 */
884 public static function amount_from_stripe_format( $amount, $currency = '' ) {
885 if ( empty( $currency ) ) {
886 $currency = self::get_currency();
887 }
888
889 $amount = floatval( $amount );
890 return self::is_zero_decimal_currency( $currency )
891 ? $amount
892 : $amount / 100;
893 }
894
895 /**
896 * Get fee recovery settings from global payment settings.
897 *
898 * @return array<string, mixed> Fee recovery settings.
899 * @since 1.0.0
900 */
901 public static function get_fee_recovery_settings() {
902 $all_settings = self::get_all_payment_settings();
903 $fee_recovery = isset( $all_settings['fee_recovery'] ) && is_array( $all_settings['fee_recovery'] )
904 ? $all_settings['fee_recovery']
905 : [];
906
907 $defaults = [
908 'fee_percentage' => 2.9,
909 'fee_fixed' => 0.30,
910 'fee_mode' => 'all_gateways',
911 'gateways' => [
912 'stripe' => [
913 'fee_percentage' => 2.9,
914 'fee_fixed' => 0.30,
915 'enabled' => true,
916 ],
917 'paypal' => [
918 'fee_percentage' => 3.49,
919 'fee_fixed' => 0.49,
920 'enabled' => true,
921 ],
922 'offline' => [
923 'fee_percentage' => 0,
924 'fee_fixed' => 0,
925 'enabled' => false,
926 ],
927 ],
928 ];
929
930 return wp_parse_args( $fee_recovery, $defaults );
931 }
932
933 /**
934 * Get fee rates for a specific gateway.
935 *
936 * In 'per_gateway' mode, returns the gateway-specific rates (or zeros if disabled).
937 * In 'all_gateways' mode, returns the single global rate.
938 *
939 * @param string $gateway Gateway identifier (e.g., 'stripe', 'offline').
940 * @param array<string,mixed> $fee_recovery Optional pre-fetched fee recovery settings.
941 * @return array{fee_percentage: float, fee_fixed: float} Fee rates for the gateway.
942 * @since 1.0.0
943 */
944 public static function get_fee_rates_for_gateway( $gateway, $fee_recovery = null ) {
945 if ( null === $fee_recovery ) {
946 $fee_recovery = self::get_fee_recovery_settings();
947 }
948
949 $mode = $fee_recovery['fee_mode'] ?? 'all_gateways';
950
951 $gateways = is_array( $fee_recovery['gateways'] ?? null ) ? $fee_recovery['gateways'] : [];
952 if ( 'per_gateway' === $mode && isset( $gateways[ $gateway ] ) ) {
953 $gw = is_array( $gateways[ $gateway ] ) ? $gateways[ $gateway ] : [];
954
955 if ( ! ( $gw['enabled'] ?? true ) ) {
956 return [
957 'fee_percentage' => 0,
958 'fee_fixed' => 0,
959 ];
960 }
961
962 return [
963 'fee_percentage' => (float) ( $gw['fee_percentage'] ?? 0 ),
964 'fee_fixed' => (float) ( $gw['fee_fixed'] ?? 0 ),
965 ];
966 }
967
968 // All-gateways mode — return the single rate.
969 $pct = $fee_recovery['fee_percentage'] ?? 2.9;
970 $fixed = $fee_recovery['fee_fixed'] ?? 0.30;
971 return [
972 'fee_percentage' => is_numeric( $pct ) ? (float) $pct : 2.9,
973 'fee_fixed' => is_numeric( $fixed ) ? (float) $fixed : 0.30,
974 ];
975 }
976
977 /**
978 * Get cover-fees configuration for a specific gateway from block config, with global fallback.
979 *
980 * Looks up the suredonation/cover-fees block in the form's block config to extract per-gateway or
981 * global fee rates. Falls back to global payment settings if block config is unavailable.
982 *
983 * @param int $form_id Form post ID.
984 * @param string $gateway Gateway identifier (e.g., 'stripe', 'offline').
985 * @return array{enabled: bool, fee_percentage: float, fee_fixed: float} Fee config for the gateway.
986 * @since 1.0.0
987 */
988 public static function get_cover_fees_config( $form_id, $gateway ) {
989 $fee_percentage = null;
990 $fee_fixed = null;
991 $enabled = true;
992
993 // Look up cover-fees block config for the form.
994 if ( $form_id > 0 ) {
995 $block_config = \SureDonation\Inc\Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
996 if ( ! empty( $block_config ) && is_array( $block_config ) ) {
997 foreach ( $block_config as $config ) {
998 if ( ! is_array( $config ) || ! isset( $config['block_name'] ) || 'suredonation/cover-fees' !== $config['block_name'] ) {
999 continue;
1000 }
1001
1002 $block_fee_mode = $config['fee_mode'] ?? 'all_gateways';
1003 $gateway_fees = is_array( $config['gateway_fees'] ?? null ) ? $config['gateway_fees'] : [];
1004
1005 if ( 'per_gateway' === $block_fee_mode && ! empty( $gateway_fees[ $gateway ] ) ) {
1006 $gw = is_array( $gateway_fees[ $gateway ] ) ? $gateway_fees[ $gateway ] : [];
1007 if ( empty( $gw['enabled'] ) ) {
1008 $enabled = false;
1009 } else {
1010 $fee_percentage = (float) ( $gw['fee_percentage'] ?? 0 );
1011 $fee_fixed = (float) ( $gw['fee_fixed'] ?? 0 );
1012 }
1013 } else {
1014 $fee_percentage = is_numeric( $config['fee_percentage'] ?? null ) ? (float) $config['fee_percentage'] : null;
1015 $fee_fixed = is_numeric( $config['fee_fixed'] ?? null ) ? (float) $config['fee_fixed'] : null;
1016 }
1017 break;
1018 }
1019 }
1020 }
1021
1022 // Fall back to global gateway-specific settings.
1023 if ( $enabled && ( null === $fee_percentage || null === $fee_fixed ) ) {
1024 $rates = self::get_fee_rates_for_gateway( $gateway );
1025 $fee_percentage = null === $fee_percentage ? (float) $rates['fee_percentage'] : $fee_percentage;
1026 $fee_fixed = null === $fee_fixed ? (float) $rates['fee_fixed'] : $fee_fixed;
1027 }
1028
1029 return [
1030 'enabled' => $enabled,
1031 'fee_percentage' => $fee_percentage ?? 0.0,
1032 'fee_fixed' => $fee_fixed ?? 0.0,
1033 ];
1034 }
1035
1036 /**
1037 * Calculate fee using the inclusive (gross-up) formula.
1038 *
1039 * Formula: total = (base + fixed) / (1 - rate), fee = total - base
1040 * This ensures the organization receives exactly the base amount after the gateway takes its cut.
1041 *
1042 * @param float $base_amount The base donation amount.
1043 * @param float|null $fee_percentage Fee percentage (e.g., 2.9 for 2.9%). Null to use global setting.
1044 * @param float|null $fee_fixed Fixed fee amount (e.g., 0.30). Null to use global setting.
1045 * @return float The calculated fee amount, rounded to 2 decimal places.
1046 * @since 1.0.0
1047 */
1048 public static function calculate_fee( $base_amount, $fee_percentage = null, $fee_fixed = null ) {
1049 if ( $base_amount <= 0 ) {
1050 return 0.0;
1051 }
1052
1053 if ( null === $fee_percentage || null === $fee_fixed ) {
1054 $settings = self::get_fee_recovery_settings();
1055 if ( null === $fee_percentage ) {
1056 $pct = $settings['fee_percentage'] ?? 2.9;
1057 $fee_percentage = is_numeric( $pct ) ? (float) $pct : 2.9;
1058 }
1059 if ( null === $fee_fixed ) {
1060 $fixed = $settings['fee_fixed'] ?? 0.30;
1061 $fee_fixed = is_numeric( $fixed ) ? (float) $fixed : 0.30;
1062 }
1063 }
1064
1065 $rate = (float) $fee_percentage / 100;
1066
1067 // Prevent division by zero.
1068 if ( $rate >= 1 ) {
1069 return 0.0;
1070 }
1071
1072 $total = ( $base_amount + (float) $fee_fixed ) / ( 1 - $rate );
1073 $fee = $total - $base_amount;
1074
1075 return round( $fee, 2 );
1076 }
1077
1078 /**
1079 * Get supported payment gateways
1080 *
1081 * @return array<string, array<string, mixed>> Gateway configurations.
1082 * @since 0.0.1
1083 */
1084 public static function get_supported_gateways() {
1085 return apply_filters(
1086 'suredonation_payment_gateways',
1087 [
1088 'stripe' => [
1089 'label' => __( 'Stripe', 'suredonation' ),
1090 'description' => __( 'Accept payments via Stripe', 'suredonation' ),
1091 'enabled' => true,
1092 'supports_recurring' => true,
1093 ],
1094 'offline' => [
1095 'label' => __( 'Offline Donations', 'suredonation' ),
1096 'description' => __( 'Accept offline donations', 'suredonation' ),
1097 'enabled' => true,
1098 'supports_recurring' => false,
1099 ],
1100 ]
1101 );
1102 }
1103
1104 /**
1105 * Check if a gateway is enabled
1106 *
1107 * @param string $gateway Gateway name.
1108 * @return bool True if enabled.
1109 * @since 0.0.1
1110 */
1111 public static function is_gateway_enabled( $gateway ) {
1112 $gateways = self::get_supported_gateways();
1113 return ! empty( $gateways[ $gateway ]['enabled'] );
1114 }
1115
1116 /**
1117 * Validate payment amount against stored form configuration.
1118 *
1119 * This function verifies that the payment amount submitted matches the
1120 * configured values in the form's payment block settings stored in post meta.
1121 * It handles both fixed and variable (minimum) amount validations.
1122 *
1123 * This is the PRIMARY security function that prevents payment amount manipulation.
1124 * It validates against IMMUTABLE configuration stored when the form was saved,
1125 * not against request data which can be manipulated.
1126 *
1127 * @since 0.0.1
1128 * @param float $amount Amount in major currency units (e.g., dollars, not cents).
1129 * @param string $currency Currency code (e.g., 'USD', 'EUR').
1130 * @param int $form_id WordPress post ID of the donation form.
1131 * @param string $block_id Block identifier for the payment block.
1132 * @param string $gateway Payment gateway identifier (default 'stripe').
1133 * @param string $payment_type Payment type the caller is processing ('one-time' or
1134 * 'subscription'). Only consulted for blocks configured
1135 * as 'both', where each choice has its own amount config.
1136 * @return array<mixed> Validation result.
1137 */
1138 public static function validate_payment_amount( $amount, $currency, $form_id, $block_id, $gateway = 'stripe', $payment_type = '' ) {
1139 // Retrieve block configuration from post meta.
1140 $block_config = \SureDonation\Inc\Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
1141
1142 // Check if block config exists.
1143 if ( empty( $block_config ) || ! is_array( $block_config ) ) {
1144 return [
1145 'valid' => false,
1146 'message' => __( 'Invalid form configuration.', 'suredonation' ),
1147 ];
1148 }
1149
1150 // Check if payment block exists in configuration.
1151 if ( ! isset( $block_config[ $block_id ] ) || ! is_array( $block_config[ $block_id ] ) ) {
1152 return [
1153 'valid' => false,
1154 'message' => __( 'Payment configuration not found for this form.', 'suredonation' ),
1155 ];
1156 }
1157
1158 $payment_config = $block_config[ $block_id ];
1159
1160 // The submitted block_id must reference an actual payment block. Every
1161 // other field block (input/email/number/dropdown/phone/url/donation-
1162 // amount/cover-fees) also has a config entry but carries no amount_type/
1163 // fixed_amount — validating against one would silently collapse the
1164 // checks below to their fallback defaults and let a caller pay an
1165 // arbitrary (default) amount regardless of the block's real configuration.
1166 if ( ! isset( $payment_config['block_name'] ) || 'suredonation/payment' !== $payment_config['block_name'] ) {
1167 return [
1168 'valid' => false,
1169 'message' => __( 'Payment configuration not found for this form.', 'suredonation' ),
1170 ];
1171 }
1172
1173 // A 'both' block stores an independent amount config per choice. Overlay the
1174 // selected choice's config over the shared one so every check below runs
1175 // against the amount the donor was actually offered — without this, a form
1176 // with one-time $100 / subscription $10 would validate either choice against
1177 // the shared (one-time) config and let a donor pay the cheaper mode's amount.
1178 if ( 'both' === ( $payment_config['payment_type'] ?? '' ) ) {
1179 // Fail closed on an unrecognised type rather than defaulting to one-time.
1180 // Every current caller passes a literal ('one-time' / 'subscription'), but a
1181 // version-skewed Pro (older than the dual-mode change) omits the argument,
1182 // leaving it '' — which must not silently price a recurring charge against
1183 // the (typically cheaper) one-time config.
1184 if ( ! in_array( $payment_type, [ 'one-time', 'subscription' ], true ) ) {
1185 return [
1186 'valid' => false,
1187 'message' => __( 'Payment configuration is incomplete for this form.', 'suredonation' ),
1188 ];
1189 }
1190
1191 $mode_key = 'subscription' === $payment_type ? 'subscription' : 'one_time';
1192
1193 // Fail closed: a 'both' block with no config for the chosen mode cannot be
1194 // validated, and falling back to the shared keys is exactly the hole above.
1195 if ( ! isset( $payment_config[ $mode_key ] ) || ! is_array( $payment_config[ $mode_key ] ) ) {
1196 return [
1197 'valid' => false,
1198 'message' => __( 'Payment configuration is incomplete for this form.', 'suredonation' ),
1199 ];
1200 }
1201
1202 // Drop the shared variable-amount field before overlaying the choice's
1203 // config, so a choice that did not set its own dynamic field cannot inherit
1204 // the top-level one. build_amount_config() only emits these when the prefixed
1205 // attribute is present, so their absence for a choice is meaningful.
1206 unset( $payment_config['variable_amount_field'], $payment_config['variable_amount_field_block_name'] );
1207
1208 $payment_config = array_merge( $payment_config, $payment_config[ $mode_key ] );
1209 }
1210
1211 // Validate currency matches global setting.
1212 $global_currency = strtolower( self::get_currency() );
1213 $submitted_currency = strtolower( $currency );
1214 if ( $global_currency !== $submitted_currency ) {
1215 return [
1216 'valid' => false,
1217 /* translators: 1: expected currency, 2: received currency */
1218 'message' => sprintf( __( 'Currency mismatch: expected %1$s, received %2$s.', 'suredonation' ), strtoupper( $global_currency ), strtoupper( $submitted_currency ) ),
1219 ];
1220 }
1221
1222 // Get amount type (fixed or variable).
1223 // Default to 'fixed' if not set - this is the safest default for security.
1224 $amount_type = $payment_config['amount_type'] ?? 'fixed';
1225
1226 // Validate based on amount type.
1227 if ( 'fixed' === $amount_type ) {
1228 // Fixed amount validation - must match exactly. A payment block with
1229 // no configured fixed_amount fails closed rather than defaulting to a
1230 // chargeable amount.
1231 if ( ! isset( $payment_config['fixed_amount'] ) ) {
1232 return [
1233 'valid' => false,
1234 'message' => __( 'Payment configuration is incomplete for this form.', 'suredonation' ),
1235 ];
1236 }
1237 $configured_amount = floatval( Helper::get_string_value( $payment_config['fixed_amount'] ) );
1238
1239 // Allow one minor currency unit of tolerance for float rounding.
1240 if ( abs( $amount - $configured_amount ) > self::get_amount_epsilon( $currency ) ) {
1241 return [
1242 'valid' => false,
1243 /* translators: %s: expected amount with currency */
1244 'message' => sprintf( __( 'Payment amount must be exactly %s.', 'suredonation' ), self::format_amount( $configured_amount, $currency ) ),
1245 ];
1246 }
1247 } elseif ( 'variable' === $amount_type ) {
1248 // Variable amount validation — only enforce a minimum when one
1249 // is explicitly configured in the block. Default is no minimum.
1250 $minimum_amount = isset( $payment_config['minimum_amount'] ) ? floatval( Helper::get_string_value( $payment_config['minimum_amount'] ) ) : 0.0;
1251
1252 if ( $minimum_amount > 0 && $amount < $minimum_amount ) {
1253 return [
1254 'valid' => false,
1255 /* translators: %s: minimum amount with currency */
1256 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $minimum_amount, $currency ) ),
1257 ];
1258 }
1259
1260 if ( $amount <= 0 ) {
1261 return [
1262 'valid' => false,
1263 'message' => __( 'Payment amount must be greater than zero.', 'suredonation' ),
1264 ];
1265 }
1266
1267 // Additional validation for donation-amount fields.
1268 $dynamic_validation = self::validate_dynamic_amount_field( $payment_config, $block_config, $amount, $currency );
1269 if ( null !== $dynamic_validation ) {
1270 return $dynamic_validation;
1271 }
1272 }
1273
1274 // Gateway-specific minimum amounts. Offline has no minimum.
1275 $gateway_minimums = [
1276 'stripe' => 0.50,
1277 'paypal' => 1.00,
1278 ];
1279
1280 if ( isset( $gateway_minimums[ $gateway ] ) ) {
1281 $minimum = $gateway_minimums[ $gateway ];
1282 if ( $amount < $minimum ) {
1283 return [
1284 'valid' => false,
1285 /* translators: %s: minimum amount */
1286 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $minimum, $currency ) ),
1287 ];
1288 }
1289 }
1290
1291 // Validation passed.
1292 return [
1293 'valid' => true,
1294 'message' => '',
1295 ];
1296 }
1297
1298 /**
1299 * The payment type a form actually renders, which is not always the stored one.
1300 *
1301 * A block configured for a recurring path renders as one-time when Pro is
1302 * absent or too old: the handlers that could create a subscription are not
1303 * registered, or cannot confirm one, so offering it would be a dead end. The
1304 * stored config still says 'subscription' or 'both' —
1305 * `process_payment_block()` records the raw attribute and
1306 * `get_or_migrate_block_config_for_legacy_form()` returns existing meta
1307 * verbatim — so anything comparing a request against that config has to apply
1308 * the same downgrade, or it rejects the request its own markup invited.
1309 *
1310 * Shared with `Payment_Markup` so the two cannot drift apart again.
1311 *
1312 * @param mixed $configured_type The payment type stored on the block.
1313 * @return string Either 'one-time' or the configured type.
1314 * @since 1.5.1
1315 */
1316 public static function effective_payment_type( $configured_type ) {
1317 $configured_type = is_string( $configured_type ) ? $configured_type : 'one-time';
1318
1319 // 'both' needs Pro as much as 'subscription' does — it is the donor-choice
1320 // mode and half of what it offers is a subscription. Collapsing it here is
1321 // also what hides the chooser, which only renders while the type is 'both'.
1322 $needs_pro = in_array( $configured_type, [ 'subscription', 'both' ], true );
1323
1324 // is_recurring_available() rather than defined( 'SUREDONATION_PRO_VER' ):
1325 // it also rejects a Pro build too old to confirm a subscription against the
1326 // current Elements setup, and carries the filter that lets a site turn
1327 // recurring off. Testing only for presence would render a form as recurring
1328 // that cannot complete one.
1329 if ( $needs_pro && ! self::is_recurring_available() ) {
1330 return 'one-time';
1331 }
1332
1333 return $configured_type;
1334 }
1335
1336 /**
1337 * Validate that the submitted payment type matches the block configuration.
1338 *
1339 * Prevents attackers from requesting a subscription on a block configured
1340 * for one-time payments (or vice versa).
1341 *
1342 * A block configured as 'both' offers the donor a choice, so it legitimately
1343 * accepts either type — but still only those two, never an arbitrary value.
1344 *
1345 * @param string $expected_type Expected payment type ('one-time' or 'subscription').
1346 * @param int $form_id Form ID.
1347 * @param string $block_id Block ID.
1348 * @return array{valid: bool, message: string} Validation result.
1349 * @since 1.0.0
1350 */
1351 public static function validate_payment_type( $expected_type, $form_id, $block_id ) {
1352 if ( empty( $form_id ) || empty( $block_id ) ) {
1353 // Cannot validate without form/block context — allow to proceed.
1354 return [
1355 'valid' => true,
1356 'message' => '',
1357 ];
1358 }
1359
1360 $block_config = \SureDonation\Inc\Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
1361
1362 if ( empty( $block_config ) || ! is_array( $block_config ) || ! isset( $block_config[ $block_id ] ) ) {
1363 return [
1364 'valid' => true,
1365 'message' => '',
1366 ];
1367 }
1368
1369 $payment_config = $block_config[ $block_id ];
1370
1371 // Every field block has a config entry and none carry a payment_type, so
1372 // without this the guard resolves any other block's id to 'one-time' and
1373 // waves it through. validate_payment_amount() happens to fail closed on
1374 // the same input today, but this is a shared primitive and must not
1375 // depend on a sibling running after it.
1376 if ( ! isset( $payment_config['block_name'] ) || 'suredonation/payment' !== $payment_config['block_name'] ) {
1377 return [
1378 'valid' => false,
1379 'message' => __( 'Payment configuration not found for this form.', 'suredonation' ),
1380 ];
1381 }
1382
1383 $configured_type = self::effective_payment_type( $payment_config['payment_type'] ?? 'one-time' );
1384
1385 // 'both' lets the donor choose, so either real type is acceptable. Anything
1386 // outside that pair is still rejected.
1387 $allowed_types = 'both' === $configured_type
1388 ? [ 'one-time', 'subscription' ]
1389 : [ $configured_type ];
1390
1391 if ( ! in_array( $expected_type, $allowed_types, true ) ) {
1392 return [
1393 'valid' => false,
1394 'message' => __( 'Payment type mismatch. This form does not support the requested payment type.', 'suredonation' ),
1395 ];
1396 }
1397
1398 return [
1399 'valid' => true,
1400 'message' => '',
1401 ];
1402 }
1403
1404 /**
1405 * Read the billing cadence a payment block was configured with.
1406 *
1407 * The interval and billing cycles decide how often a donor is charged and for
1408 * how long, so the values the admin saved are the source of truth on submit —
1409 * not whatever the request carries. Returns empty strings when the block has no
1410 * stored cadence (a form saved before it was persisted), letting the caller
1411 * fall back to its previous behaviour.
1412 *
1413 * @param int $form_id Donation form post ID.
1414 * @param string $block_id Payment block identifier.
1415 * @return array{interval: string, billing_cycles: string} Stored cadence, or empty strings.
1416 * @since 1.5.1
1417 */
1418 public static function get_subscription_cadence( $form_id, $block_id ) {
1419 $cadence = [
1420 'interval' => '',
1421 'billing_cycles' => '',
1422 ];
1423
1424 if ( empty( $form_id ) || empty( $block_id ) ) {
1425 return $cadence;
1426 }
1427
1428 $block_config = \SureDonation\Inc\Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
1429
1430 if ( ! is_array( $block_config ) || ! isset( $block_config[ $block_id ] ) || ! is_array( $block_config[ $block_id ] ) ) {
1431 return $cadence;
1432 }
1433
1434 $payment_config = $block_config[ $block_id ];
1435
1436 // Only read cadence off an actual payment block — mirrors the block_name assert
1437 // in validate_payment_amount() so a non-payment block id can never resolve a
1438 // cadence (defence in depth alongside the caller's own block-id validation).
1439 if ( ! isset( $payment_config['block_name'] ) || 'suredonation/payment' !== $payment_config['block_name'] ) {
1440 return $cadence;
1441 }
1442
1443 if ( isset( $payment_config['subscription_interval'] ) ) {
1444 $cadence['interval'] = Helper::get_string_value( $payment_config['subscription_interval'] );
1445 }
1446
1447 if ( isset( $payment_config['subscription_billing_cycles'] ) ) {
1448 $cadence['billing_cycles'] = Helper::get_string_value( $payment_config['subscription_billing_cycles'] );
1449 }
1450
1451 // Legacy forms saved before cadence was persisted carry no cadence keys in
1452 // stored meta (the config only rebuilds on save_post). Returning empty here
1453 // would let the caller assume month / ongoing and silently rewrite the
1454 // admin's real plan. Re-derive from the parsed post content instead — still
1455 // server-side and untamperable, never the request.
1456 if ( '' === $cadence['interval'] || '' === $cadence['billing_cycles'] ) {
1457 $resolved = \SureDonation\Inc\Field_Validation::resolve_subscription_cadence_from_content( $form_id );
1458
1459 if ( is_array( $resolved ) ) {
1460 if ( '' === $cadence['interval'] ) {
1461 $cadence['interval'] = Helper::get_string_value( $resolved['subscription_interval'] );
1462 }
1463 if ( '' === $cadence['billing_cycles'] ) {
1464 $cadence['billing_cycles'] = Helper::get_string_value( $resolved['subscription_billing_cycles'] );
1465 }
1466 }
1467 }
1468
1469 return $cadence;
1470 }
1471
1472 /**
1473 * Validate a full donation submission server-side.
1474 *
1475 * Centralizes the two server-side checks every donation-creation entry point
1476 * must run before any payment intent / record is created:
1477 * 1. Field-level validation (required, max length, email format, number
1478 * range) via Field_Validation::validate_form_data().
1479 * 2. Payment amount validation against the immutable block configuration.
1480 *
1481 * @since 1.1.0
1482 * @param array<string, mixed> $fields Submitted field values keyed by field slug.
1483 * @param float $amount Amount in major currency units.
1484 * @param string $currency Currency code (e.g. 'USD').
1485 * @param int $form_id Donation form post ID.
1486 * @param string $block_id Payment block identifier.
1487 * @param string $gateway Payment gateway identifier (default 'stripe').
1488 * @param string $payment_type Payment type being processed ('one-time' or
1489 * 'subscription'); selects the amount config
1490 * on blocks configured as 'both'.
1491 * @return array{valid: bool, message: string, field_errors: array<string, string>} Combined result.
1492 */
1493 public static function validate_submission( $fields, $amount, $currency, $form_id, $block_id, $gateway = 'stripe', $payment_type = '' ) {
1494 $result = [
1495 'valid' => true,
1496 'message' => '',
1497 'field_errors' => [],
1498 ];
1499
1500 // Field-level validation (source of truth for required/format/length/range).
1501 $field_errors = \SureDonation\Inc\Field_Validation::validate_form_data( $fields, (int) $form_id );
1502 if ( ! empty( $field_errors ) ) {
1503 $result['valid'] = false;
1504 $result['field_errors'] = $field_errors;
1505 $result['message'] = __( 'Please correct the highlighted fields and try again.', 'suredonation' );
1506 }
1507
1508 // Contact-consent requirement (Privacy settings). Enforced here at the shared
1509 // validation choke point so it applies to every gateway (stripe/paypal/
1510 // offline/ajax) before any donor/intent is persisted.
1511 $consent_error = \SureDonation\Inc\Privacy\Privacy_Frontend::validate_consent();
1512 if ( '' !== $consent_error ) {
1513 $result['valid'] = false;
1514 // Key by the consent input's data-slug so the client renders it inline
1515 // against the checkbox (showServerFieldErrors), like other field errors.
1516 $result['field_errors'][ \SureDonation\Inc\Privacy\Privacy_Frontend::CONSENT_FIELD ] = $consent_error;
1517 if ( '' === $result['message'] ) {
1518 $result['message'] = __( 'Please correct the highlighted fields and try again.', 'suredonation' );
1519 }
1520 }
1521
1522 // The persisted donor email comes from the POST donor_email param, which
1523 // is separate from the validation-only fields[] copy inspected above and
1524 // is never run through validate_form_data(). Length-cap it here too, or a
1525 // crafted request could store an oversized value against the VARCHAR(255)
1526 // donor-email columns.
1527 $donor_email = self::get_submitted_donor_email();
1528 if ( '' !== $donor_email ) {
1529 $email_error = \SureDonation\Inc\Field_Validation::validate_email_length( $donor_email );
1530 if ( '' !== $email_error ) {
1531 $result['valid'] = false;
1532 $result['field_errors']['donor_email'] = $email_error;
1533 if ( '' === $result['message'] ) {
1534 $result['message'] = __( 'Please correct the highlighted fields and try again.', 'suredonation' );
1535 }
1536 }
1537 }
1538
1539 // Payment amount validation (prevents amount/type tampering).
1540 $amount_result = self::validate_payment_amount( $amount, $currency, $form_id, $block_id, $gateway, $payment_type );
1541 if ( empty( $amount_result['valid'] ) ) {
1542 $result['valid'] = false;
1543 // Surface the specific amount message only when no field errors took precedence.
1544 if ( empty( $result['field_errors'] ) ) {
1545 $result['message'] = isset( $amount_result['message'] ) && is_string( $amount_result['message'] ) ? $amount_result['message'] : '';
1546 }
1547 }
1548
1549 return $result;
1550 }
1551
1552 /**
1553 * Read submitted form field values from the request, keyed by field slug.
1554 *
1555 * The donation form frontend posts every rendered field's value under the
1556 * `fields[slug]` key so the server can enforce field validation on values
1557 * it would not otherwise receive (text, phone, comment, etc.). Values are
1558 * used for validation only — not persisted — so sanitize_text_field is a
1559 * safe normalizer here. The caller is responsible for nonce/token checks.
1560 *
1561 * @since 1.1.0
1562 * @return array<string, string> Map of field slug => sanitized value.
1563 */
1564 public static function get_submitted_fields() {
1565 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler.
1566 if ( ! isset( $_POST['fields'] ) || ! is_array( $_POST['fields'] ) ) {
1567 return [];
1568 }
1569
1570 // The outer array can contain nested arrays (`['label'=>.., 'value'=>..]`),
1571 // so each value is sanitized individually rather than with array_map() on
1572 // the whole structure. Field slugs (keys) are sanitized below.
1573 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce/HMAC verified by the calling handler; each value sanitized individually below.
1574 $raw = wp_unslash( $_POST['fields'] );
1575 $fields = [];
1576
1577 foreach ( $raw as $slug => $field ) {
1578 $slug = sanitize_text_field( (string) $slug );
1579 if ( '' === $slug ) {
1580 continue;
1581 }
1582
1583 // New nested shape: ['label'=>.., 'value'=>..]. Backward-compat: plain string.
1584 $value = is_array( $field ) ? ( $field['value'] ?? '' ) : $field;
1585
1586 $fields[ $slug ] = is_string( $value ) ? sanitize_text_field( $value ) : '';
1587 }
1588
1589 return $fields;
1590 }
1591
1592 /**
1593 * Read the submitted donor email from the request.
1594 *
1595 * Mirrors get_submitted_fields(): the value is used for validation, and the
1596 * caller is responsible for nonce/token checks. sanitize_email() matches how
1597 * the gateway handlers extract donor_email before persisting it.
1598 *
1599 * @since 1.1.1
1600 * @return string Sanitized donor email, or '' when absent.
1601 */
1602 public static function get_submitted_donor_email() {
1603 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler.
1604 if ( ! isset( $_POST['donor_email'] ) ) {
1605 return '';
1606 }
1607
1608 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler.
1609 return sanitize_email( wp_unslash( $_POST['donor_email'] ) );
1610 }
1611
1612 /**
1613 * Read submitted form fields as label/value pairs for storage.
1614 *
1615 * Mirrors get_submitted_fields() but preserves each field's visible label so
1616 * the submission can be persisted in a human-readable form. Handles both the
1617 * new nested POST shape (`fields[slug][label]`, `fields[slug][value]`) and the
1618 * legacy plain-string shape (`fields[slug]`), in which case the label is empty.
1619 * The caller is responsible for nonce/token checks.
1620 *
1621 * @since 1.1.1
1622 * @return array<string, array{label: string, value: string}> Map of field slug => label/value.
1623 */
1624 public static function get_submitted_field_data() {
1625 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler.
1626 if ( ! isset( $_POST['fields'] ) || ! is_array( $_POST['fields'] ) ) {
1627 return [];
1628 }
1629
1630 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce/HMAC verified by the calling handler; each label/value sanitized individually below.
1631 $raw = wp_unslash( $_POST['fields'] );
1632 $fields = [];
1633
1634 // Core donor fields (name, email, amount) are stored in their own
1635 // columns, so they are omitted from the stored "additional" set. Their
1636 // slugs are derived server-side from the form's saved payment block
1637 // (not trusted from the request) so the exclusion can't be bypassed.
1638 // Empty values are skipped too. Neither affects validation, which reads
1639 // the full set via get_submitted_fields().
1640 // 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.
1641 $form_id = isset( $_POST['form_id'] ) ? absint( wp_unslash( $_POST['form_id'] ) ) : 0;
1642 $core_slugs = \SureDonation\Inc\Field_Validation::get_core_field_slugs( $form_id );
1643
1644 // Resolve each field's label from the saved form (authoritative) rather
1645 // than the request, so the stored label can't be tampered with and does
1646 // not depend on the rendered markup. Slugs absent here (e.g. labels left
1647 // at their Gutenberg default, which are not persisted) fall back to the
1648 // submitted label below.
1649 $field_labels = \SureDonation\Inc\Field_Validation::get_field_labels_map( $form_id );
1650
1651 // Checkbox fields are resolved from the saved form too. A checkbox posts
1652 // "1" when ticked and "" when not, neither of which reads as anything on
1653 // the entry screen, in an export or in an email — so both states are
1654 // rendered as Yes/No, and the unticked one is kept rather than dropped by
1655 // the empty-value skip below (a declined consent is a meaningful record).
1656 $checkbox_slugs = \SureDonation\Inc\Field_Validation::get_checkbox_field_slugs( $form_id );
1657
1658 // Both the slug and the label/value are attacker-controlled for any
1659 // slug absent from the saved form (e.g. the settings-driven privacy
1660 // consent field, which has no block config by design — so this must
1661 // not filter by "known" slugs). Cap the entry count and the length of
1662 // each stored string so a submission can't inflate donation_data
1663 // without bound.
1664 $max_fields = 50;
1665 $max_value_length = 1000;
1666 $max_label_length = 255;
1667 $max_slug_length = 255;
1668 $stored_field_count = 0;
1669
1670 foreach ( $raw as $slug => $field ) {
1671 if ( $stored_field_count >= $max_fields ) {
1672 break;
1673 }
1674
1675 $slug = mb_substr( sanitize_text_field( (string) $slug ), 0, $max_slug_length );
1676 if ( '' === $slug || in_array( $slug, $core_slugs, true ) ) {
1677 continue;
1678 }
1679
1680 if ( is_array( $field ) ) {
1681 $label = isset( $field['label'] ) && is_string( $field['label'] ) ? $field['label'] : '';
1682 $value = isset( $field['value'] ) && is_string( $field['value'] ) ? $field['value'] : '';
1683 $group = isset( $field['group'] ) && is_string( $field['group'] ) ? $field['group'] : '';
1684 } else {
1685 // Legacy plain-string shape — no label/group available.
1686 $label = '';
1687 $value = is_string( $field ) ? $field : '';
1688 $group = '';
1689 }
1690
1691 $value = sanitize_text_field( $value );
1692
1693 // Multi-select dropdown values arrive '|'-delimited (an option label
1694 // may contain a comma). Re-join with ', ' for a readable stored/
1695 // displayed value. The flag is client-sent and only affects display
1696 // formatting here — server-side validation is unaffected.
1697 if ( is_array( $field ) && isset( $field['multiple'] ) && 'true' === $field['multiple'] ) {
1698 $value = implode( ', ', array_filter( array_map( 'trim', explode( '|', $value ) ), 'strlen' ) );
1699 }
1700
1701 $is_checkbox = in_array( $slug, $checkbox_slugs, true );
1702
1703 if ( $is_checkbox ) {
1704 // Canonical, locale-independent tokens. Translating here would bake
1705 // the admin's language at submission time into a permanent record
1706 // that is later exported to CSV and can be re-imported on another
1707 // site — a locale switch or an updated .mo would leave one column
1708 // holding "Ja" for old rows and "Yes" for new ones, uncomparable by
1709 // any spreadsheet filter or CRM mapping. Display layers translate
1710 // via Helper::format_checkbox_field_value().
1711 $value = \SureDonation\Inc\Field_Validation::CHECKBOX_VALUES[ '' === trim( $value ) ? 'no' : 'yes' ];
1712 } elseif ( '' === trim( $value ) ) {
1713 // Skip empty values so blank/optional fields don't clutter the entry.
1714 continue;
1715 }
1716
1717 // Prefer the saved-form label; fall back to the submitted one only
1718 // when the slug has no persisted (customized) label.
1719 $resolved_label = isset( $field_labels[ $slug ] ) ? $field_labels[ $slug ] : sanitize_text_field( $label );
1720
1721 $fields[ $slug ] = [
1722 'label' => mb_substr( $resolved_label, 0, $max_label_length ),
1723 'value' => mb_substr( $value, 0, $max_value_length ),
1724 // Parent block label (e.g. "Address") used to nest sub-fields on
1725 // the entry screen; '' for standalone fields.
1726 'group' => mb_substr( sanitize_text_field( $group ), 0, $max_label_length ),
1727 ];
1728
1729 ++$stored_field_count;
1730 }
1731
1732 /**
1733 * Filters the submitted fields as they will be stored on the donation.
1734 *
1735 * Runs after sanitisation and label resolution, before the map is
1736 * written to donation_data['fields']. A block that must not keep a
1737 * value the donor withdrew (SureDonation Pro's Gift Aid address when
1738 * the declaration box is unticked) removes it here.
1739 *
1740 * @since 1.6.1
1741 * @param array<string, array{label: string, value: string, group: string}> $fields Fields keyed by slug.
1742 * @param int $form_id Donation form ID from the request.
1743 */
1744 return apply_filters( 'suredonation_submitted_field_data', $fields, $form_id );
1745 }
1746
1747 /**
1748 * Resolve the donor phone for storage from the submitted form fields.
1749 *
1750 * When a Phone field is mapped to the donor phone on the payment block, its
1751 * value is read here from the already-validated submitted field set (keyed by
1752 * the mapped slug, derived server-side) rather than from a separate, unchecked
1753 * $_POST['donor_phone']. The value is length-capped to the donor_phone column
1754 * width (VARCHAR(50)) so an over-long number cannot truncate or abort the
1755 * write. Returns '' when no phone field is mapped. The caller verifies the
1756 * nonce/HMAC token.
1757 *
1758 * @since 1.1.1
1759 * @param int $form_id The donation form post ID.
1760 * @return string The donor phone value, or '' when unmapped/absent.
1761 */
1762 public static function get_mapped_donor_phone( $form_id ) {
1763 $form_id = (int) $form_id;
1764 if ( $form_id <= 0 ) {
1765 return '';
1766 }
1767
1768 $phone_slug = \SureDonation\Inc\Field_Validation::get_mapped_phone_slug( $form_id );
1769 if ( '' === $phone_slug ) {
1770 return '';
1771 }
1772
1773 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler.
1774 if ( ! isset( $_POST['fields'] ) || ! is_array( $_POST['fields'] ) ) {
1775 return '';
1776 }
1777
1778 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Token verified by caller; value sanitized below.
1779 $raw = wp_unslash( $_POST['fields'] );
1780 $field = $raw[ $phone_slug ] ?? '';
1781 $value = is_array( $field ) ? ( $field['value'] ?? '' ) : $field;
1782 $value = sanitize_text_field( is_string( $value ) ? $value : '' );
1783
1784 // Cap to the donor_phone column width to avoid truncation/abort on write.
1785 return mb_substr( $value, 0, 50 );
1786 }
1787
1788 /**
1789 * Resolve the donor comment for storage from the submitted form fields.
1790 *
1791 * The Donor Comment field is an ordinary field block, so its value arrives
1792 * through the standard `fields[slug]` channel that every gateway already
1793 * forwards — there is no separate `donor_comment` request key to trust. The
1794 * slug is derived server-side from the saved form (see
1795 * Field_Validation::get_donor_comment_slug()), so a comment posted against a
1796 * form that offers no comment field is ignored, exactly as
1797 * get_submitted_is_anonymous() ignores an unoffered anonymity flag.
1798 *
1799 * The value is capped to the field's configured maximum length rather than a
1800 * column width — donor_comment is TEXT, so the cap exists to honour the
1801 * author's setting against a client that ignores the maxlength attribute.
1802 * Field-level validation has already rejected an over-long value by the time
1803 * the handlers call this; the cap is the belt-and-braces write guard.
1804 *
1805 * The caller verifies the nonce/HMAC token.
1806 *
1807 * @since 1.6.0
1808 * @param int $form_id The donation form post ID.
1809 * @return string The donor comment, or '' when the form has no comment field.
1810 */
1811 public static function get_mapped_donor_comment( $form_id ) {
1812 $form_id = (int) $form_id;
1813 if ( $form_id <= 0 ) {
1814 return '';
1815 }
1816
1817 $comment_slug = \SureDonation\Inc\Field_Validation::get_donor_comment_slug( $form_id );
1818 if ( '' === $comment_slug ) {
1819 return '';
1820 }
1821
1822 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler.
1823 if ( ! isset( $_POST['fields'] ) || ! is_array( $_POST['fields'] ) ) {
1824 return '';
1825 }
1826
1827 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Token verified by caller; value sanitized below.
1828 $raw = wp_unslash( $_POST['fields'] );
1829 $field = $raw[ $comment_slug ] ?? '';
1830 $value = is_array( $field ) ? ( $field['value'] ?? '' ) : $field;
1831
1832 // sanitize_textarea_field (not sanitize_text_field) so the donor's own line
1833 // breaks survive — this is a message, not a single-line value.
1834 $value = sanitize_textarea_field( is_string( $value ) ? $value : '' );
1835
1836 return mb_substr( $value, 0, self::get_donor_comment_max_length( $form_id ) );
1837 }
1838
1839 /**
1840 * Resolve the configured maximum length of a form's Donor Comment field.
1841 *
1842 * Read from the block configuration persisted on save (never from the
1843 * request), falling back to the block's own default when the form predates
1844 * the stored config or the field carries no explicit setting.
1845 *
1846 * @since 1.6.0
1847 * @param int $form_id The donation form post ID.
1848 * @return int Maximum number of characters allowed.
1849 */
1850 private static function get_donor_comment_max_length( $form_id ) {
1851 $default = 500;
1852 $config = \SureDonation\Inc\Field_Validation::get_or_migrate_block_config_for_legacy_form( (int) $form_id );
1853
1854 if ( ! is_array( $config ) ) {
1855 return $default;
1856 }
1857
1858 foreach ( $config as $block_config ) {
1859 if ( ! is_array( $block_config ) || ! isset( $block_config['max_length'] ) ) {
1860 continue;
1861 }
1862
1863 if ( ! isset( $block_config['block_name'] ) || 'suredonation/donor-comment' !== $block_config['block_name'] ) {
1864 continue;
1865 }
1866
1867 $max = absint( Helper::get_string_value( $block_config['max_length'] ) );
1868 if ( $max > 0 ) {
1869 return $max;
1870 }
1871 }
1872
1873 return $default;
1874 }
1875
1876 /**
1877 * Resolve the anonymous-donation flag for storage from the request.
1878 *
1879 * The Anonymous Donation checkbox renders with a per-block name and no
1880 * data-slug, so it is not part of the submitted field set; the gateway JS
1881 * forwards it as a dedicated `is_anonymous` key instead (see
1882 * GatewayBase.appendAnonymousFlag). The flag is a display-only marker — the
1883 * donor's real name, email and phone are still stored and processed as usual,
1884 * and only the public donor wall / recent donations / top donors mask them.
1885 *
1886 * Whether the form offers the option is resolved from the saved form rather
1887 * than trusted from the request, matching how the mapped phone field and the
1888 * cover-fees configuration are derived server-side. A flag posted against a
1889 * form with no Anonymous Donation block is therefore ignored. The caller
1890 * verifies the nonce/HMAC token.
1891 *
1892 * @since 1.5.1
1893 * @param int $form_id The donation form post ID.
1894 * @return bool True when the donation should be flagged anonymous.
1895 */
1896 public static function get_submitted_is_anonymous( $form_id ) {
1897 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler.
1898 if ( empty( $_POST['is_anonymous'] ) ) {
1899 return false;
1900 }
1901
1902 $form_id = (int) $form_id;
1903 if ( $form_id <= 0 || ! function_exists( 'parse_blocks' ) ) {
1904 return false;
1905 }
1906
1907 // form_id is attacker-chosen on a public endpoint, so confirm it really is
1908 // a donation form before parsing its content — otherwise the request can
1909 // aim a full block parse at any post in the database.
1910 $post = get_post( $form_id );
1911 if ( ! ( $post instanceof \WP_Post )
1912 || \SureDonation\Inc\Post_Types\Donation_Form::POST_TYPE !== $post->post_type
1913 || empty( $post->post_content ) ) {
1914 return false;
1915 }
1916
1917 return Helper::block_tree_contains( parse_blocks( $post->post_content ), 'suredonation/anonymous-donation' );
1918 }
1919
1920 /**
1921 * Store payment intent metadata for verification.
1922 *
1923 * This stores the expected payment amount when creating a payment intent,
1924 * allowing the webhook to verify the actual charged amount matches.
1925 *
1926 * @param string $payment_intent_id The Stripe payment intent ID.
1927 * @param array<string, mixed> $metadata The metadata to store (amount, currency, campaign_id, donation_id).
1928 * @return bool True on success.
1929 * @since 0.0.1
1930 */
1931 public static function store_payment_intent_metadata( $payment_intent_id, $metadata ) {
1932 $transient_key = 'suredonation_pi_' . $payment_intent_id;
1933 // Store for 24 hours (webhook should arrive within minutes).
1934 return set_transient( $transient_key, $metadata, DAY_IN_SECONDS );
1935 }
1936
1937 /**
1938 * Get stored payment intent metadata.
1939 *
1940 * @param string $payment_intent_id The Stripe payment intent ID.
1941 * @return array<string, mixed>|false The stored metadata or false if not found.
1942 * @since 0.0.1
1943 */
1944 public static function get_payment_intent_metadata( $payment_intent_id ) {
1945 $transient_key = 'suredonation_pi_' . $payment_intent_id;
1946 $metadata = get_transient( $transient_key );
1947 if ( is_array( $metadata ) ) {
1948 return $metadata;
1949 }
1950 return false;
1951 }
1952
1953 /**
1954 * Delete stored payment intent metadata after verification.
1955 *
1956 * @param string $payment_intent_id The Stripe payment intent ID.
1957 * @return bool True on success.
1958 * @since 0.0.1
1959 */
1960 public static function delete_payment_intent_metadata( $payment_intent_id ) {
1961 $transient_key = 'suredonation_pi_' . $payment_intent_id;
1962 return delete_transient( $transient_key );
1963 }
1964
1965 /**
1966 * Verify payment intent amount matches expected amount.
1967 *
1968 * This is called by the webhook handler to detect amount manipulation.
1969 *
1970 * @param string $payment_intent_id The Stripe payment intent ID.
1971 * @param int $actual_amount The actual amount charged (in cents).
1972 * @param string $currency The currency code.
1973 * @return bool|WP_Error True if amounts match, WP_Error if mismatch or not found.
1974 * @since 0.0.1
1975 */
1976 public static function verify_payment_intent_amount( $payment_intent_id, $actual_amount, $currency ) {
1977 $metadata = self::get_payment_intent_metadata( $payment_intent_id );
1978
1979 if ( is_array( $metadata ) ) {
1980 $amount_value = $metadata['amount_cents'] ?? 0;
1981 $expected_amount = is_numeric( $amount_value ) ? (int) $amount_value : 0;
1982 $currency_value = $metadata['currency'] ?? '';
1983 $expected_currency = is_string( $currency_value ) ? strtolower( $currency_value ) : '';
1984 } else {
1985 // The metadata transient is single-use (deleted after the first
1986 // successful verify) and expires in 24h, while Stripe retries
1987 // webhooks for days. Rather than failing open, resolve the expected
1988 // amount durably from the donation record (the amount is fixed
1989 // server-side at intent creation), and fail closed for any payment
1990 // we actually created.
1991 $donation = Donations::get_by_transaction_id( $payment_intent_id );
1992
1993 if ( ! is_array( $donation ) || empty( $donation['id'] ) ) {
1994 // No donation we created matches this intent — a genuinely
1995 // external or legacy payment. Permit, but log explicitly so the
1996 // no-op is never silent.
1997 return true;
1998 }
1999
2000 $donation_amount = isset( $donation['amount'] ) && is_numeric( $donation['amount'] ) ? (float) $donation['amount'] : null;
2001 $donation_currency = isset( $donation['currency'] ) && is_string( $donation['currency'] ) ? strtolower( $donation['currency'] ) : '';
2002
2003 if ( null === $donation_amount || '' === $donation_currency ) {
2004 // Known intent but the expected amount cannot be resolved — fail closed.
2005 return new \WP_Error(
2006 'amount_unverifiable',
2007 __( 'Unable to verify the expected donation amount for this payment. Flagged for manual review.', 'suredonation' )
2008 );
2009 }
2010
2011 $expected_amount = self::amount_to_stripe_format( $donation_amount, $donation_currency );
2012 $expected_currency = $donation_currency;
2013 }
2014
2015 // Verify currency matches.
2016 if ( strtolower( $currency ) !== $expected_currency ) {
2017 return new \WP_Error(
2018 'currency_mismatch',
2019 sprintf(
2020 /* translators: 1: expected currency, 2: actual currency */
2021 __( 'Currency mismatch. Expected %1$s but received %2$s.', 'suredonation' ),
2022 strtoupper( $expected_currency ),
2023 strtoupper( $currency )
2024 )
2025 );
2026 }
2027
2028 // Amounts here are already in the gateway's minor units (cents for
2029 // 2-decimal currencies, whole units for zero-decimal ones), so a
2030 // tolerance of 1 is one minor currency unit for rounding regardless
2031 // of currency.
2032 if ( abs( $actual_amount - $expected_amount ) > 1 ) {
2033 return new \WP_Error(
2034 'amount_mismatch',
2035 sprintf(
2036 /* translators: 1: expected amount, 2: actual amount */
2037 __( 'Amount mismatch detected. Expected %1$s but received %2$s. Possible payment manipulation.', 'suredonation' ),
2038 self::format_amount( self::amount_from_stripe_format( $expected_amount, $currency ), $currency ),
2039 self::format_amount( self::amount_from_stripe_format( $actual_amount, $currency ), $currency )
2040 )
2041 );
2042 }
2043
2044 // Cleanup after successful verification.
2045 self::delete_payment_intent_metadata( $payment_intent_id );
2046
2047 return true;
2048 }
2049
2050 /**
2051 * Validate dynamic amount field from donation-amount or number block.
2052 *
2053 * @param array<string, mixed> $payment_config Payment block configuration.
2054 * @param array<string, mixed> $block_config All block configurations.
2055 * @param float $amount Submitted amount.
2056 * @param string $currency Currency code.
2057 * @return array<mixed>|null Validation result array or null if validation passes.
2058 * @since 0.0.1
2059 */
2060 private static function validate_dynamic_amount_field( $payment_config, $block_config, $amount, $currency ) {
2061 // Check if variable amount field block name is set.
2062 $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'] : '';
2063
2064 if ( empty( $dynamic_amount_field_block_name ) ) {
2065 // A config that explicitly declares a 'variable' amount but resolves no
2066 // field block is a misconfiguration — Dynamic Amount was chosen but no
2067 // "Choose Amount Field" was picked (Gutenberg omits the empty default),
2068 // or the picked slug no longer resolves to a block. Fail closed: with
2069 // no field to re-resolve against, only the gateway floor would be left,
2070 // so an unauthenticated visitor could post any amount (e.g. $0.50/month
2071 // on a $100 form). Reject rather than accept an unverifiable amount.
2072 $declares_variable = isset( $payment_config['amount_type'] )
2073 && is_string( $payment_config['amount_type'] )
2074 && 'variable' === $payment_config['amount_type'];
2075
2076 if ( $declares_variable ) {
2077 return [
2078 'valid' => false,
2079 'message' => __( 'Unable to verify the donation amount for this form. Please reload the page and try again.', 'suredonation' ),
2080 ];
2081 }
2082
2083 // No amount_type declared at all — a genuinely older layout where the
2084 // donor's amount was an intentional free choice. The amount-type,
2085 // minimum and gateway-minimum checks in validate_payment_amount() still
2086 // apply, so allow it through here.
2087 return null;
2088 }
2089
2090 // Get the slug of the variable amount field.
2091 $variable_amount_field_slug = ! empty( $payment_config['variable_amount_field'] ) && is_string( $payment_config['variable_amount_field'] ) ? $payment_config['variable_amount_field'] : '';
2092
2093 // Find the block config for the variable amount field by matching slug and block name.
2094 $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );
2095
2096 // The form declares a variable-amount field but its block config cannot be
2097 // resolved. Fail-safe reject instead of allowing an unvalidated amount
2098 // through: never trust a client-supplied amount we cannot re-resolve
2099 // server-side (mirrors SureForms #2855 hardening).
2100 if ( empty( $variable_amount_block_config ) || ! is_array( $variable_amount_block_config ) ) {
2101 return [
2102 'valid' => false,
2103 'message' => __( 'Unable to verify the donation amount for this form. Please reload the page and try again.', 'suredonation' ),
2104 ];
2105 }
2106
2107 // Handle number block validation.
2108 if ( 'suredonation/number' === $dynamic_amount_field_block_name ) {
2109 return self::validate_number_field_amount( $variable_amount_block_config, $amount, $currency );
2110 }
2111
2112 // Handle donation-amount block validation.
2113 if ( 'suredonation/donation-amount' === $dynamic_amount_field_block_name ) {
2114 return self::validate_multi_choice_amount( $variable_amount_block_config, $amount, $currency );
2115 }
2116
2117 // A variable-amount field is declared with a block type we have no
2118 // validator for. We cannot re-resolve the payable amount, so fail-safe
2119 // reject rather than fall through as accepted.
2120 return [
2121 'valid' => false,
2122 'message' => __( 'Unsupported variable amount field configuration.', 'suredonation' ),
2123 ];
2124 }
2125
2126 /**
2127 * Validate amount from number field against configured min/max.
2128 *
2129 * @param array<string, mixed>|null $number_block_config Number block configuration.
2130 * @param float $amount Submitted amount.
2131 * @param string $currency Currency code.
2132 * @return array<string, mixed>|null Validation result array or null if validation passes.
2133 * @since 0.0.1
2134 */
2135 private static function validate_number_field_amount( $number_block_config, $amount, $currency ) {
2136 // Fail-safe reject when the field config is missing. The caller already
2137 // rejects an unresolvable config, so this is defensive: a configured
2138 // number field must never validate an amount against no constraints.
2139 if ( empty( $number_block_config ) || ! is_array( $number_block_config ) ) {
2140 return [
2141 'valid' => false,
2142 'message' => __( 'Variable amount field configuration not found.', 'suredonation' ),
2143 ];
2144 }
2145
2146 // One minor currency unit of tolerance for float rounding.
2147 $epsilon = self::get_amount_epsilon( $currency );
2148
2149 // Validate min value if configured.
2150 if ( isset( $number_block_config['min'] ) && is_numeric( $number_block_config['min'] ) ) {
2151 $min_value = (float) $number_block_config['min'];
2152 if ( $amount < $min_value - $epsilon ) {
2153 return [
2154 'valid' => false,
2155 /* translators: %s: minimum amount with currency */
2156 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $min_value, $currency ) ),
2157 ];
2158 }
2159 }
2160
2161 // Validate max value if configured.
2162 if ( isset( $number_block_config['max'] ) && is_numeric( $number_block_config['max'] ) ) {
2163 $max_value = (float) $number_block_config['max'];
2164 if ( $amount > $max_value + $epsilon ) {
2165 return [
2166 'valid' => false,
2167 /* translators: %s: maximum amount with currency */
2168 'message' => sprintf( __( 'Payment amount cannot exceed %s.', 'suredonation' ), self::format_amount( $max_value, $currency ) ),
2169 ];
2170 }
2171 }
2172
2173 return null;
2174 }
2175
2176 /**
2177 * Validate amount from donation-amount field against configured options.
2178 *
2179 * @param array<string, mixed>|null $multi_choice_config Multi-choice block configuration.
2180 * @param float $amount Submitted amount.
2181 * @param string $currency Currency code.
2182 * @return array<string, mixed>|null Validation result array or null if validation passes.
2183 * @since 0.0.1
2184 */
2185 private static function validate_multi_choice_amount( $multi_choice_config, $amount, $currency ) {
2186 // Verify the variable amount block config was found.
2187 if ( empty( $multi_choice_config ) || ! is_array( $multi_choice_config ) ) {
2188 return [
2189 'valid' => false,
2190 'message' => __( 'Variable amount field configuration not found.', 'suredonation' ),
2191 ];
2192 }
2193
2194 // One minor currency unit of tolerance for float rounding.
2195 $epsilon = self::get_amount_epsilon( $currency );
2196
2197 // Donation Amount is a single-select radio group. Extract the preset
2198 // option values and check whether the submitted amount matches one.
2199 $allowed_options = $multi_choice_config['options'] ?? [];
2200 $allowed_values = [];
2201 if ( is_array( $allowed_options ) ) {
2202 foreach ( $allowed_options as $option ) {
2203 if ( isset( $option['value'] ) && is_numeric( $option['value'] ) ) {
2204 $allowed_values[] = (float) $option['value'];
2205 }
2206 }
2207 }
2208
2209 foreach ( $allowed_values as $allowed_value ) {
2210 if ( abs( $amount - $allowed_value ) <= $epsilon ) {
2211 return null; // Matches a configured preset — valid.
2212 }
2213 }
2214
2215 // Not a preset value. Only accept it when the custom amount input is
2216 // enabled for this block; otherwise fail closed.
2217 $allow_custom = ! empty( $multi_choice_config['allow_custom_amount'] );
2218 if ( ! $allow_custom ) {
2219 if ( empty( $allowed_values ) ) {
2220 return [
2221 'valid' => false,
2222 'message' => __( 'No payment options are configured for this field.', 'suredonation' ),
2223 ];
2224 }
2225 return [
2226 'valid' => false,
2227 'message' => __( 'Invalid payment amount. Please select a valid amount from the available options.', 'suredonation' ),
2228 ];
2229 }
2230
2231 // Custom amount is enabled — enforce the configured min/max (0 = none).
2232 $min = isset( $multi_choice_config['custom_amount_min'] ) && is_numeric( $multi_choice_config['custom_amount_min'] )
2233 ? (float) $multi_choice_config['custom_amount_min']
2234 : 0.0;
2235 $max = isset( $multi_choice_config['custom_amount_max'] ) && is_numeric( $multi_choice_config['custom_amount_max'] )
2236 ? (float) $multi_choice_config['custom_amount_max']
2237 : 0.0;
2238
2239 if ( $min > 0 && $amount < $min - $epsilon ) {
2240 return [
2241 'valid' => false,
2242 /* translators: %s: minimum amount with currency */
2243 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $min, $currency ) ),
2244 ];
2245 }
2246
2247 if ( $max > 0 && $amount > $max + $epsilon ) {
2248 return [
2249 'valid' => false,
2250 /* translators: %s: maximum amount with currency */
2251 'message' => sprintf( __( 'Payment amount cannot exceed %s.', 'suredonation' ), self::format_amount( $max, $currency ) ),
2252 ];
2253 }
2254
2255 // Validation passed for donation-amount field.
2256 return null;
2257 }
2258
2259 /**
2260 * Get block configuration by block name and slug.
2261 *
2262 * @param array<mixed> $block_config All block configurations.
2263 * @param string $block_name Block name to search for.
2264 * @param string $slug Slug to match.
2265 * @return array<string, mixed>|null Block configuration if found, null otherwise.
2266 * @since 0.0.1
2267 */
2268 private static function get_block_config_by_name_and_slug( $block_config, $block_name, $slug ) {
2269 foreach ( $block_config as $config ) {
2270 if ( empty( $config ) || ! is_array( $config ) ) {
2271 continue;
2272 }
2273
2274 if ( isset( $config['slug'] ) && $config['slug'] === $slug && isset( $config['block_name'] ) && $config['block_name'] === $block_name ) {
2275 return $config;
2276 }
2277 }
2278 return null;
2279 }
2280 }
2281