PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.5.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.5.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.5.1, at inc/payments/payment-helper.php

2,158 lines 80.4 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 'KRW' => [
525 'name' => __( 'South Korean Won', 'suredonation' ),
526 'symbol' => '₩',
527 'decimal_places' => 0,
528 ],
529 'TRY' => [
530 'name' => __( 'Turkish Lira', 'suredonation' ),
531 'symbol' => '₺',
532 'decimal_places' => 2,
533 ],
534 'RUB' => [
535 'name' => __( 'Russian Ruble', 'suredonation' ),
536 'symbol' => '₽',
537 'decimal_places' => 2,
538 ],
539 'INR' => [
540 'name' => __( 'Indian Rupee', 'suredonation' ),
541 'symbol' => '₹',
542 'decimal_places' => 2,
543 ],
544 'BRL' => [
545 'name' => __( 'Brazilian Real', 'suredonation' ),
546 'symbol' => 'R$',
547 'decimal_places' => 2,
548 ],
549 'ZAR' => [
550 'name' => __( 'South African Rand', 'suredonation' ),
551 'symbol' => 'R',
552 'decimal_places' => 2,
553 ],
554 'AED' => [
555 'name' => __( 'UAE Dirham', 'suredonation' ),
556 'symbol' => 'د.إ',
557 'decimal_places' => 2,
558 ],
559 'PHP' => [
560 'name' => __( 'Philippine Peso', 'suredonation' ),
561 'symbol' => '₱',
562 'decimal_places' => 2,
563 ],
564 'IDR' => [
565 'name' => __( 'Indonesian Rupiah', 'suredonation' ),
566 'symbol' => 'Rp',
567 'decimal_places' => 2,
568 ],
569 'MYR' => [
570 'name' => __( 'Malaysian Ringgit', 'suredonation' ),
571 'symbol' => 'RM',
572 'decimal_places' => 2,
573 ],
574 'THB' => [
575 'name' => __( 'Thai Baht', 'suredonation' ),
576 'symbol' => '฿',
577 'decimal_places' => 2,
578 ],
579 'BIF' => [
580 'name' => __( 'Burundian Franc', 'suredonation' ),
581 'symbol' => 'FBu',
582 'decimal_places' => 0,
583 ],
584 'CLP' => [
585 'name' => __( 'Chilean Peso', 'suredonation' ),
586 'symbol' => '$',
587 'decimal_places' => 0,
588 ],
589 'DJF' => [
590 'name' => __( 'Djiboutian Franc', 'suredonation' ),
591 'symbol' => 'Fdj',
592 'decimal_places' => 0,
593 ],
594 'GNF' => [
595 'name' => __( 'Guinean Franc', 'suredonation' ),
596 'symbol' => 'FG',
597 'decimal_places' => 0,
598 ],
599 'KMF' => [
600 'name' => __( 'Comorian Franc', 'suredonation' ),
601 'symbol' => 'CF',
602 'decimal_places' => 0,
603 ],
604 'MGA' => [
605 'name' => __( 'Malagasy Ariary', 'suredonation' ),
606 'symbol' => 'Ar',
607 'decimal_places' => 0,
608 ],
609 'PYG' => [
610 'name' => __( 'Paraguayan Guaraní', 'suredonation' ),
611 'symbol' => '₲',
612 'decimal_places' => 0,
613 ],
614 'RWF' => [
615 'name' => __( 'Rwandan Franc', 'suredonation' ),
616 'symbol' => 'FRw',
617 'decimal_places' => 0,
618 ],
619 'UGX' => [
620 'name' => __( 'Ugandan Shilling', 'suredonation' ),
621 'symbol' => 'USh',
622 'decimal_places' => 0,
623 ],
624 'VND' => [
625 'name' => __( 'Vietnamese Đồng', 'suredonation' ),
626 'symbol' => '₫',
627 'decimal_places' => 0,
628 ],
629 'VUV' => [
630 'name' => __( 'Vanuatu Vatu', 'suredonation' ),
631 'symbol' => 'VT',
632 'decimal_places' => 0,
633 ],
634 'XAF' => [
635 'name' => __( 'Central African CFA Franc', 'suredonation' ),
636 'symbol' => 'FCFA',
637 'decimal_places' => 0,
638 ],
639 'XOF' => [
640 'name' => __( 'West African CFA Franc', 'suredonation' ),
641 'symbol' => 'CFA',
642 'decimal_places' => 0,
643 ],
644 'XPF' => [
645 'name' => __( 'CFP Franc', 'suredonation' ),
646 'symbol' => '₣',
647 'decimal_places' => 0,
648 ],
649 ];
650 }
651
652 /**
653 * Get currency names for all supported currencies.
654 *
655 * @return array<string, mixed> Array of currency names keyed by currency code.
656 * @since 0.0.1
657 */
658 public static function get_currency_names() {
659 $currencies = self::get_all_currencies_data();
660 $names = [];
661
662 foreach ( $currencies as $code => $data ) {
663 $names[ $code ] = $data['name'];
664 }
665
666 return $names;
667 }
668
669 /**
670 * Get currency
671 *
672 * @return string Currency code (e.g., 'USD').
673 * @since 0.0.1
674 */
675 public static function get_currency() {
676 $currency = self::get_global_setting( 'currency', 'USD' );
677 return is_string( $currency ) ? $currency : 'USD';
678 }
679
680 /**
681 * Get currency symbol.
682 *
683 * @param string $currency Currency code.
684 * @return string Currency symbol or empty string.
685 * @since 0.0.1
686 */
687 public static function get_currency_symbol( $currency = '' ) {
688 if ( empty( $currency ) ) {
689 $currency = self::get_currency();
690 }
691
692 if ( empty( $currency ) || ! is_string( $currency ) ) {
693 return '';
694 }
695
696 $currency = strtoupper( $currency );
697 $currencies = self::get_all_currencies_data();
698 $currency_data = $currencies[ $currency ] ?? null;
699
700 $symbol = ! empty( $currency_data ) ? $currency_data['symbol'] : '';
701 return is_string( $symbol ) ? $symbol : '';
702 }
703
704 /**
705 * Get list of zero-decimal currencies.
706 *
707 * Zero-decimal currencies don't use decimal points in payment APIs.
708 * For these currencies, amounts are passed as-is without multiplying/dividing by 100.
709 *
710 * @return array<string> Array of zero-decimal currency codes.
711 * @since 0.0.1
712 */
713 public static function get_zero_decimal_currencies() {
714 $currencies = self::get_all_currencies_data();
715 $zero_decimal_codes = [];
716
717 foreach ( $currencies as $code => $data ) {
718 if ( 0 === $data['decimal_places'] ) {
719 $zero_decimal_codes[] = $code;
720 }
721 }
722
723 return $zero_decimal_codes;
724 }
725
726 /**
727 * Check if currency is zero-decimal.
728 *
729 * @param string $currency Currency code.
730 * @return bool True if zero-decimal currency.
731 * @since 0.0.1
732 */
733 public static function is_zero_decimal_currency( $currency ) {
734 if ( empty( $currency ) || ! is_string( $currency ) ) {
735 return false;
736 }
737
738 $currency = strtoupper( $currency );
739 $currencies = self::get_all_currencies_data();
740 $currency_data = $currencies[ $currency ] ?? null;
741
742 return $currency_data && 0 === $currency_data['decimal_places'];
743 }
744
745 /**
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 * Format amount for display
776 *
777 * @param float $amount Amount.
778 * @param string $currency Currency code.
779 * @return string Formatted amount.
780 * @since 0.0.1
781 */
782 public static function format_amount( $amount, $currency = '' ) {
783 if ( empty( $currency ) ) {
784 $currency = self::get_currency();
785 }
786
787 $symbol = self::get_currency_symbol( $currency );
788 $decimal_places = self::is_zero_decimal_currency( $currency ) ? 0 : 2;
789 $formatted = number_format( (float) $amount, $decimal_places, '.', ',' );
790
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 );
803 }
804
805 /**
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 * Convert amount to Stripe format (cents)
854 *
855 * @param float $amount Amount in dollars.
856 * @param string $currency Currency code.
857 * @return int Amount in cents.
858 * @since 0.0.1
859 */
860 public static function amount_to_stripe_format( $amount, $currency = '' ) {
861 if ( empty( $currency ) ) {
862 $currency = self::get_currency();
863 }
864
865 $amount = floatval( $amount );
866 return self::is_zero_decimal_currency( $currency )
867 ? (int) round( $amount )
868 : (int) round( $amount * 100 );
869 }
870
871 /**
872 * Convert amount from Stripe format (cents) to dollars
873 *
874 * @param int $amount Amount in cents.
875 * @param string $currency Currency code.
876 * @return float Amount in dollars.
877 * @since 0.0.1
878 */
879 public static function amount_from_stripe_format( $amount, $currency = '' ) {
880 if ( empty( $currency ) ) {
881 $currency = self::get_currency();
882 }
883
884 $amount = floatval( $amount );
885 return self::is_zero_decimal_currency( $currency )
886 ? $amount
887 : $amount / 100;
888 }
889
890 /**
891 * Get fee recovery settings from global payment settings.
892 *
893 * @return array<string, mixed> Fee recovery settings.
894 * @since 1.0.0
895 */
896 public static function get_fee_recovery_settings() {
897 $all_settings = self::get_all_payment_settings();
898 $fee_recovery = isset( $all_settings['fee_recovery'] ) && is_array( $all_settings['fee_recovery'] )
899 ? $all_settings['fee_recovery']
900 : [];
901
902 $defaults = [
903 'fee_percentage' => 2.9,
904 'fee_fixed' => 0.30,
905 'fee_mode' => 'all_gateways',
906 'gateways' => [
907 'stripe' => [
908 'fee_percentage' => 2.9,
909 'fee_fixed' => 0.30,
910 'enabled' => true,
911 ],
912 'paypal' => [
913 'fee_percentage' => 3.49,
914 'fee_fixed' => 0.49,
915 'enabled' => true,
916 ],
917 'offline' => [
918 'fee_percentage' => 0,
919 'fee_fixed' => 0,
920 'enabled' => false,
921 ],
922 ],
923 ];
924
925 return wp_parse_args( $fee_recovery, $defaults );
926 }
927
928 /**
929 * Get fee rates for a specific gateway.
930 *
931 * In 'per_gateway' mode, returns the gateway-specific rates (or zeros if disabled).
932 * In 'all_gateways' mode, returns the single global rate.
933 *
934 * @param string $gateway Gateway identifier (e.g., 'stripe', 'offline').
935 * @param array<string,mixed> $fee_recovery Optional pre-fetched fee recovery settings.
936 * @return array{fee_percentage: float, fee_fixed: float} Fee rates for the gateway.
937 * @since 1.0.0
938 */
939 public static function get_fee_rates_for_gateway( $gateway, $fee_recovery = null ) {
940 if ( null === $fee_recovery ) {
941 $fee_recovery = self::get_fee_recovery_settings();
942 }
943
944 $mode = $fee_recovery['fee_mode'] ?? 'all_gateways';
945
946 $gateways = is_array( $fee_recovery['gateways'] ?? null ) ? $fee_recovery['gateways'] : [];
947 if ( 'per_gateway' === $mode && isset( $gateways[ $gateway ] ) ) {
948 $gw = is_array( $gateways[ $gateway ] ) ? $gateways[ $gateway ] : [];
949
950 if ( ! ( $gw['enabled'] ?? true ) ) {
951 return [
952 'fee_percentage' => 0,
953 'fee_fixed' => 0,
954 ];
955 }
956
957 return [
958 'fee_percentage' => (float) ( $gw['fee_percentage'] ?? 0 ),
959 'fee_fixed' => (float) ( $gw['fee_fixed'] ?? 0 ),
960 ];
961 }
962
963 // All-gateways mode — return the single rate.
964 $pct = $fee_recovery['fee_percentage'] ?? 2.9;
965 $fixed = $fee_recovery['fee_fixed'] ?? 0.30;
966 return [
967 'fee_percentage' => is_numeric( $pct ) ? (float) $pct : 2.9,
968 'fee_fixed' => is_numeric( $fixed ) ? (float) $fixed : 0.30,
969 ];
970 }
971
972 /**
973 * Get cover-fees configuration for a specific gateway from block config, with global fallback.
974 *
975 * Looks up the suredonation/cover-fees block in the form's block config to extract per-gateway or
976 * global fee rates. Falls back to global payment settings if block config is unavailable.
977 *
978 * @param int $form_id Form post ID.
979 * @param string $gateway Gateway identifier (e.g., 'stripe', 'offline').
980 * @return array{enabled: bool, fee_percentage: float, fee_fixed: float} Fee config for the gateway.
981 * @since 1.0.0
982 */
983 public static function get_cover_fees_config( $form_id, $gateway ) {
984 $fee_percentage = null;
985 $fee_fixed = null;
986 $enabled = true;
987
988 // Look up cover-fees block config for the form.
989 if ( $form_id > 0 ) {
990 $block_config = \SureDonation\Inc\Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
991 if ( ! empty( $block_config ) && is_array( $block_config ) ) {
992 foreach ( $block_config as $config ) {
993 if ( ! is_array( $config ) || ! isset( $config['block_name'] ) || 'suredonation/cover-fees' !== $config['block_name'] ) {
994 continue;
995 }
996
997 $block_fee_mode = $config['fee_mode'] ?? 'all_gateways';
998 $gateway_fees = is_array( $config['gateway_fees'] ?? null ) ? $config['gateway_fees'] : [];
999
1000 if ( 'per_gateway' === $block_fee_mode && ! empty( $gateway_fees[ $gateway ] ) ) {
1001 $gw = is_array( $gateway_fees[ $gateway ] ) ? $gateway_fees[ $gateway ] : [];
1002 if ( empty( $gw['enabled'] ) ) {
1003 $enabled = false;
1004 } else {
1005 $fee_percentage = (float) ( $gw['fee_percentage'] ?? 0 );
1006 $fee_fixed = (float) ( $gw['fee_fixed'] ?? 0 );
1007 }
1008 } else {
1009 $fee_percentage = is_numeric( $config['fee_percentage'] ?? null ) ? (float) $config['fee_percentage'] : null;
1010 $fee_fixed = is_numeric( $config['fee_fixed'] ?? null ) ? (float) $config['fee_fixed'] : null;
1011 }
1012 break;
1013 }
1014 }
1015 }
1016
1017 // Fall back to global gateway-specific settings.
1018 if ( $enabled && ( null === $fee_percentage || null === $fee_fixed ) ) {
1019 $rates = self::get_fee_rates_for_gateway( $gateway );
1020 $fee_percentage = null === $fee_percentage ? (float) $rates['fee_percentage'] : $fee_percentage;
1021 $fee_fixed = null === $fee_fixed ? (float) $rates['fee_fixed'] : $fee_fixed;
1022 }
1023
1024 return [
1025 'enabled' => $enabled,
1026 'fee_percentage' => $fee_percentage ?? 0.0,
1027 'fee_fixed' => $fee_fixed ?? 0.0,
1028 ];
1029 }
1030
1031 /**
1032 * Calculate fee using the inclusive (gross-up) formula.
1033 *
1034 * Formula: total = (base + fixed) / (1 - rate), fee = total - base
1035 * This ensures the organization receives exactly the base amount after the gateway takes its cut.
1036 *
1037 * @param float $base_amount The base donation amount.
1038 * @param float|null $fee_percentage Fee percentage (e.g., 2.9 for 2.9%). Null to use global setting.
1039 * @param float|null $fee_fixed Fixed fee amount (e.g., 0.30). Null to use global setting.
1040 * @return float The calculated fee amount, rounded to 2 decimal places.
1041 * @since 1.0.0
1042 */
1043 public static function calculate_fee( $base_amount, $fee_percentage = null, $fee_fixed = null ) {
1044 if ( $base_amount <= 0 ) {
1045 return 0.0;
1046 }
1047
1048 if ( null === $fee_percentage || null === $fee_fixed ) {
1049 $settings = self::get_fee_recovery_settings();
1050 if ( null === $fee_percentage ) {
1051 $pct = $settings['fee_percentage'] ?? 2.9;
1052 $fee_percentage = is_numeric( $pct ) ? (float) $pct : 2.9;
1053 }
1054 if ( null === $fee_fixed ) {
1055 $fixed = $settings['fee_fixed'] ?? 0.30;
1056 $fee_fixed = is_numeric( $fixed ) ? (float) $fixed : 0.30;
1057 }
1058 }
1059
1060 $rate = (float) $fee_percentage / 100;
1061
1062 // Prevent division by zero.
1063 if ( $rate >= 1 ) {
1064 return 0.0;
1065 }
1066
1067 $total = ( $base_amount + (float) $fee_fixed ) / ( 1 - $rate );
1068 $fee = $total - $base_amount;
1069
1070 return round( $fee, 2 );
1071 }
1072
1073 /**
1074 * Get supported payment gateways
1075 *
1076 * @return array<string, array<string, mixed>> Gateway configurations.
1077 * @since 0.0.1
1078 */
1079 public static function get_supported_gateways() {
1080 return apply_filters(
1081 'suredonation_payment_gateways',
1082 [
1083 'stripe' => [
1084 'label' => __( 'Stripe', 'suredonation' ),
1085 'description' => __( 'Accept payments via Stripe', 'suredonation' ),
1086 'enabled' => true,
1087 'supports_recurring' => true,
1088 ],
1089 'offline' => [
1090 'label' => __( 'Offline Donations', 'suredonation' ),
1091 'description' => __( 'Accept offline donations', 'suredonation' ),
1092 'enabled' => true,
1093 'supports_recurring' => false,
1094 ],
1095 ]
1096 );
1097 }
1098
1099 /**
1100 * Check if a gateway is enabled
1101 *
1102 * @param string $gateway Gateway name.
1103 * @return bool True if enabled.
1104 * @since 0.0.1
1105 */
1106 public static function is_gateway_enabled( $gateway ) {
1107 $gateways = self::get_supported_gateways();
1108 return ! empty( $gateways[ $gateway ]['enabled'] );
1109 }
1110
1111 /**
1112 * Validate payment amount against stored form configuration.
1113 *
1114 * This function verifies that the payment amount submitted matches the
1115 * configured values in the form's payment block settings stored in post meta.
1116 * It handles both fixed and variable (minimum) amount validations.
1117 *
1118 * This is the PRIMARY security function that prevents payment amount manipulation.
1119 * It validates against IMMUTABLE configuration stored when the form was saved,
1120 * not against request data which can be manipulated.
1121 *
1122 * @since 0.0.1
1123 * @param float $amount Amount in major currency units (e.g., dollars, not cents).
1124 * @param string $currency Currency code (e.g., 'USD', 'EUR').
1125 * @param int $form_id WordPress post ID of the donation form.
1126 * @param string $block_id Block identifier for the payment block.
1127 * @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 * @return array<mixed> Validation result.
1132 */
1133 public static function validate_payment_amount( $amount, $currency, $form_id, $block_id, $gateway = 'stripe', $payment_type = '' ) {
1134 // Retrieve block configuration from post meta.
1135 $block_config = \SureDonation\Inc\Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
1136
1137 // Check if block config exists.
1138 if ( empty( $block_config ) || ! is_array( $block_config ) ) {
1139 return [
1140 'valid' => false,
1141 'message' => __( 'Invalid form configuration.', 'suredonation' ),
1142 ];
1143 }
1144
1145 // Check if payment block exists in configuration.
1146 if ( ! isset( $block_config[ $block_id ] ) || ! is_array( $block_config[ $block_id ] ) ) {
1147 return [
1148 'valid' => false,
1149 'message' => __( 'Payment configuration not found for this form.', 'suredonation' ),
1150 ];
1151 }
1152
1153 $payment_config = $block_config[ $block_id ];
1154
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 // Validate currency matches global setting.
1207 $global_currency = strtolower( self::get_currency() );
1208 $submitted_currency = strtolower( $currency );
1209 if ( $global_currency !== $submitted_currency ) {
1210 return [
1211 'valid' => false,
1212 /* translators: 1: expected currency, 2: received currency */
1213 'message' => sprintf( __( 'Currency mismatch: expected %1$s, received %2$s.', 'suredonation' ), strtoupper( $global_currency ), strtoupper( $submitted_currency ) ),
1214 ];
1215 }
1216
1217 // Get amount type (fixed or variable).
1218 // Default to 'fixed' if not set - this is the safest default for security.
1219 $amount_type = $payment_config['amount_type'] ?? 'fixed';
1220
1221 // Validate based on amount type.
1222 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'] ) );
1233
1234 // Allow one minor currency unit of tolerance for float rounding.
1235 if ( abs( $amount - $configured_amount ) > self::get_amount_epsilon( $currency ) ) {
1236 return [
1237 'valid' => false,
1238 /* translators: %s: expected amount with currency */
1239 'message' => sprintf( __( 'Payment amount must be exactly %s.', 'suredonation' ), self::format_amount( $configured_amount, $currency ) ),
1240 ];
1241 }
1242 } elseif ( 'variable' === $amount_type ) {
1243 // Variable amount validation — only enforce a minimum when one
1244 // is explicitly configured in the block. Default is no minimum.
1245 $minimum_amount = isset( $payment_config['minimum_amount'] ) ? floatval( Helper::get_string_value( $payment_config['minimum_amount'] ) ) : 0.0;
1246
1247 if ( $minimum_amount > 0 && $amount < $minimum_amount ) {
1248 return [
1249 'valid' => false,
1250 /* translators: %s: minimum amount with currency */
1251 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $minimum_amount, $currency ) ),
1252 ];
1253 }
1254
1255 if ( $amount <= 0 ) {
1256 return [
1257 'valid' => false,
1258 'message' => __( 'Payment amount must be greater than zero.', 'suredonation' ),
1259 ];
1260 }
1261
1262 // Additional validation for donation-amount fields.
1263 $dynamic_validation = self::validate_dynamic_amount_field( $payment_config, $block_config, $amount, $currency );
1264 if ( null !== $dynamic_validation ) {
1265 return $dynamic_validation;
1266 }
1267 }
1268
1269 // Gateway-specific minimum amounts. Offline has no minimum.
1270 $gateway_minimums = [
1271 'stripe' => 0.50,
1272 'paypal' => 1.00,
1273 ];
1274
1275 if ( isset( $gateway_minimums[ $gateway ] ) ) {
1276 $minimum = $gateway_minimums[ $gateway ];
1277 if ( $amount < $minimum ) {
1278 return [
1279 'valid' => false,
1280 /* translators: %s: minimum amount */
1281 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $minimum, $currency ) ),
1282 ];
1283 }
1284 }
1285
1286 // Validation passed.
1287 return [
1288 'valid' => true,
1289 'message' => '',
1290 ];
1291 }
1292
1293 /**
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 * Validate that the submitted payment type matches the block configuration.
1333 *
1334 * Prevents attackers from requesting a subscription on a block configured
1335 * for one-time payments (or vice versa).
1336 *
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 * @param string $expected_type Expected payment type ('one-time' or 'subscription').
1341 * @param int $form_id Form ID.
1342 * @param string $block_id Block ID.
1343 * @return array{valid: bool, message: string} Validation result.
1344 * @since 1.0.0
1345 */
1346 public static function validate_payment_type( $expected_type, $form_id, $block_id ) {
1347 if ( empty( $form_id ) || empty( $block_id ) ) {
1348 // Cannot validate without form/block context — allow to proceed.
1349 return [
1350 'valid' => true,
1351 'message' => '',
1352 ];
1353 }
1354
1355 $block_config = \SureDonation\Inc\Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
1356
1357 if ( empty( $block_config ) || ! is_array( $block_config ) || ! isset( $block_config[ $block_id ] ) ) {
1358 return [
1359 'valid' => true,
1360 'message' => '',
1361 ];
1362 }
1363
1364 $payment_config = $block_config[ $block_id ];
1365
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'] ) {
1372 return [
1373 '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 'message' => __( 'Payment type mismatch. This form does not support the requested payment type.', 'suredonation' ),
1390 ];
1391 }
1392
1393 return [
1394 'valid' => true,
1395 'message' => '',
1396 ];
1397 }
1398
1399 /**
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 * Validate a full donation submission server-side.
1469 *
1470 * Centralizes the two server-side checks every donation-creation entry point
1471 * must run before any payment intent / record is created:
1472 * 1. Field-level validation (required, max length, email format, number
1473 * range) via Field_Validation::validate_form_data().
1474 * 2. Payment amount validation against the immutable block configuration.
1475 *
1476 * @since 1.1.0
1477 * @param array<string, mixed> $fields Submitted field values keyed by field slug.
1478 * @param float $amount Amount in major currency units.
1479 * @param string $currency Currency code (e.g. 'USD').
1480 * @param int $form_id Donation form post ID.
1481 * @param string $block_id Payment block identifier.
1482 * @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 * @return array{valid: bool, message: string, field_errors: array<string, string>} Combined result.
1487 */
1488 public static function validate_submission( $fields, $amount, $currency, $form_id, $block_id, $gateway = 'stripe', $payment_type = '' ) {
1489 $result = [
1490 'valid' => true,
1491 'message' => '',
1492 'field_errors' => [],
1493 ];
1494
1495 // Field-level validation (source of truth for required/format/length/range).
1496 $field_errors = \SureDonation\Inc\Field_Validation::validate_form_data( $fields, (int) $form_id );
1497 if ( ! empty( $field_errors ) ) {
1498 $result['valid'] = false;
1499 $result['field_errors'] = $field_errors;
1500 $result['message'] = __( 'Please correct the highlighted fields and try again.', 'suredonation' );
1501 }
1502
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 // Payment amount validation (prevents amount/type tampering).
1535 $amount_result = self::validate_payment_amount( $amount, $currency, $form_id, $block_id, $gateway, $payment_type );
1536 if ( empty( $amount_result['valid'] ) ) {
1537 $result['valid'] = false;
1538 // Surface the specific amount message only when no field errors took precedence.
1539 if ( empty( $result['field_errors'] ) ) {
1540 $result['message'] = isset( $amount_result['message'] ) && is_string( $amount_result['message'] ) ? $amount_result['message'] : '';
1541 }
1542 }
1543
1544 return $result;
1545 }
1546
1547 /**
1548 * Read submitted form field values from the request, keyed by field slug.
1549 *
1550 * The donation form frontend posts every rendered field's value under the
1551 * `fields[slug]` key so the server can enforce field validation on values
1552 * it would not otherwise receive (text, phone, comment, etc.). Values are
1553 * used for validation only — not persisted — so sanitize_text_field is a
1554 * safe normalizer here. The caller is responsible for nonce/token checks.
1555 *
1556 * @since 1.1.0
1557 * @return array<string, string> Map of field slug => sanitized value.
1558 */
1559 public static function get_submitted_fields() {
1560 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce/HMAC verified by the calling handler.
1561 if ( ! isset( $_POST['fields'] ) || ! is_array( $_POST['fields'] ) ) {
1562 return [];
1563 }
1564
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'] );
1570 $fields = [];
1571
1572 foreach ( $raw as $slug => $field ) {
1573 $slug = sanitize_text_field( (string) $slug );
1574 if ( '' === $slug ) {
1575 continue;
1576 }
1577
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 ) : '';
1582 }
1583
1584 return $fields;
1585 }
1586
1587 /**
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 * Store payment intent metadata for verification.
1799 *
1800 * This stores the expected payment amount when creating a payment intent,
1801 * allowing the webhook to verify the actual charged amount matches.
1802 *
1803 * @param string $payment_intent_id The Stripe payment intent ID.
1804 * @param array<string, mixed> $metadata The metadata to store (amount, currency, campaign_id, donation_id).
1805 * @return bool True on success.
1806 * @since 0.0.1
1807 */
1808 public static function store_payment_intent_metadata( $payment_intent_id, $metadata ) {
1809 $transient_key = 'suredonation_pi_' . $payment_intent_id;
1810 // Store for 24 hours (webhook should arrive within minutes).
1811 return set_transient( $transient_key, $metadata, DAY_IN_SECONDS );
1812 }
1813
1814 /**
1815 * Get stored payment intent metadata.
1816 *
1817 * @param string $payment_intent_id The Stripe payment intent ID.
1818 * @return array<string, mixed>|false The stored metadata or false if not found.
1819 * @since 0.0.1
1820 */
1821 public static function get_payment_intent_metadata( $payment_intent_id ) {
1822 $transient_key = 'suredonation_pi_' . $payment_intent_id;
1823 $metadata = get_transient( $transient_key );
1824 if ( is_array( $metadata ) ) {
1825 return $metadata;
1826 }
1827 return false;
1828 }
1829
1830 /**
1831 * Delete stored payment intent metadata after verification.
1832 *
1833 * @param string $payment_intent_id The Stripe payment intent ID.
1834 * @return bool True on success.
1835 * @since 0.0.1
1836 */
1837 public static function delete_payment_intent_metadata( $payment_intent_id ) {
1838 $transient_key = 'suredonation_pi_' . $payment_intent_id;
1839 return delete_transient( $transient_key );
1840 }
1841
1842 /**
1843 * Verify payment intent amount matches expected amount.
1844 *
1845 * This is called by the webhook handler to detect amount manipulation.
1846 *
1847 * @param string $payment_intent_id The Stripe payment intent ID.
1848 * @param int $actual_amount The actual amount charged (in cents).
1849 * @param string $currency The currency code.
1850 * @return bool|WP_Error True if amounts match, WP_Error if mismatch or not found.
1851 * @since 0.0.1
1852 */
1853 public static function verify_payment_intent_amount( $payment_intent_id, $actual_amount, $currency ) {
1854 $metadata = self::get_payment_intent_metadata( $payment_intent_id );
1855
1856 if ( is_array( $metadata ) ) {
1857 $amount_value = $metadata['amount_cents'] ?? 0;
1858 $expected_amount = is_numeric( $amount_value ) ? (int) $amount_value : 0;
1859 $currency_value = $metadata['currency'] ?? '';
1860 $expected_currency = is_string( $currency_value ) ? strtolower( $currency_value ) : '';
1861 } else {
1862 // The metadata transient is single-use (deleted after the first
1863 // successful verify) and expires in 24h, while Stripe retries
1864 // webhooks for days. Rather than failing open, resolve the expected
1865 // amount durably from the donation record (the amount is fixed
1866 // server-side at intent creation), and fail closed for any payment
1867 // we actually created.
1868 $donation = Donations::get_by_transaction_id( $payment_intent_id );
1869
1870 if ( ! is_array( $donation ) || empty( $donation['id'] ) ) {
1871 // No donation we created matches this intent — a genuinely
1872 // external or legacy payment. Permit, but log explicitly so the
1873 // no-op is never silent.
1874 return true;
1875 }
1876
1877 $donation_amount = isset( $donation['amount'] ) && is_numeric( $donation['amount'] ) ? (float) $donation['amount'] : null;
1878 $donation_currency = isset( $donation['currency'] ) && is_string( $donation['currency'] ) ? strtolower( $donation['currency'] ) : '';
1879
1880 if ( null === $donation_amount || '' === $donation_currency ) {
1881 // Known intent but the expected amount cannot be resolved — fail closed.
1882 return new \WP_Error(
1883 'amount_unverifiable',
1884 __( 'Unable to verify the expected donation amount for this payment. Flagged for manual review.', 'suredonation' )
1885 );
1886 }
1887
1888 $expected_amount = self::amount_to_stripe_format( $donation_amount, $donation_currency );
1889 $expected_currency = $donation_currency;
1890 }
1891
1892 // Verify currency matches.
1893 if ( strtolower( $currency ) !== $expected_currency ) {
1894 return new \WP_Error(
1895 'currency_mismatch',
1896 sprintf(
1897 /* translators: 1: expected currency, 2: actual currency */
1898 __( 'Currency mismatch. Expected %1$s but received %2$s.', 'suredonation' ),
1899 strtoupper( $expected_currency ),
1900 strtoupper( $currency )
1901 )
1902 );
1903 }
1904
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.
1909 if ( abs( $actual_amount - $expected_amount ) > 1 ) {
1910 return new \WP_Error(
1911 'amount_mismatch',
1912 sprintf(
1913 /* translators: 1: expected amount, 2: actual amount */
1914 __( 'Amount mismatch detected. Expected %1$s but received %2$s. Possible payment manipulation.', 'suredonation' ),
1915 self::format_amount( self::amount_from_stripe_format( $expected_amount, $currency ), $currency ),
1916 self::format_amount( self::amount_from_stripe_format( $actual_amount, $currency ), $currency )
1917 )
1918 );
1919 }
1920
1921 // Cleanup after successful verification.
1922 self::delete_payment_intent_metadata( $payment_intent_id );
1923
1924 return true;
1925 }
1926
1927 /**
1928 * Validate dynamic amount field from donation-amount or number block.
1929 *
1930 * @param array<string, mixed> $payment_config Payment block configuration.
1931 * @param array<string, mixed> $block_config All block configurations.
1932 * @param float $amount Submitted amount.
1933 * @param string $currency Currency code.
1934 * @return array<mixed>|null Validation result array or null if validation passes.
1935 * @since 0.0.1
1936 */
1937 private static function validate_dynamic_amount_field( $payment_config, $block_config, $amount, $currency ) {
1938 // Check if variable amount field block name is set.
1939 $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
1941 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.
1964 return null;
1965 }
1966
1967 // Get the slug of the variable amount field.
1968 $variable_amount_field_slug = ! empty( $payment_config['variable_amount_field'] ) && is_string( $payment_config['variable_amount_field'] ) ? $payment_config['variable_amount_field'] : '';
1969
1970 // Find the block config for the variable amount field by matching slug and block name.
1971 $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );
1972
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 // Handle number block validation.
1985 if ( 'suredonation/number' === $dynamic_amount_field_block_name ) {
1986 return self::validate_number_field_amount( $variable_amount_block_config, $amount, $currency );
1987 }
1988
1989 // Handle donation-amount block validation.
1990 if ( 'suredonation/donation-amount' === $dynamic_amount_field_block_name ) {
1991 return self::validate_multi_choice_amount( $variable_amount_block_config, $amount, $currency );
1992 }
1993
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 ];
2001 }
2002
2003 /**
2004 * Validate amount from number field against configured min/max.
2005 *
2006 * @param array<string, mixed>|null $number_block_config Number block configuration.
2007 * @param float $amount Submitted amount.
2008 * @param string $currency Currency code.
2009 * @return array<string, mixed>|null Validation result array or null if validation passes.
2010 * @since 0.0.1
2011 */
2012 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.
2016 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 ];
2021 }
2022
2023 // One minor currency unit of tolerance for float rounding.
2024 $epsilon = self::get_amount_epsilon( $currency );
2025
2026 // Validate min value if configured.
2027 if ( isset( $number_block_config['min'] ) && is_numeric( $number_block_config['min'] ) ) {
2028 $min_value = (float) $number_block_config['min'];
2029 if ( $amount < $min_value - $epsilon ) {
2030 return [
2031 'valid' => false,
2032 /* translators: %s: minimum amount with currency */
2033 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $min_value, $currency ) ),
2034 ];
2035 }
2036 }
2037
2038 // Validate max value if configured.
2039 if ( isset( $number_block_config['max'] ) && is_numeric( $number_block_config['max'] ) ) {
2040 $max_value = (float) $number_block_config['max'];
2041 if ( $amount > $max_value + $epsilon ) {
2042 return [
2043 'valid' => false,
2044 /* translators: %s: maximum amount with currency */
2045 'message' => sprintf( __( 'Payment amount cannot exceed %s.', 'suredonation' ), self::format_amount( $max_value, $currency ) ),
2046 ];
2047 }
2048 }
2049
2050 return null;
2051 }
2052
2053 /**
2054 * Validate amount from donation-amount field against configured options.
2055 *
2056 * @param array<string, mixed>|null $multi_choice_config Multi-choice block configuration.
2057 * @param float $amount Submitted amount.
2058 * @param string $currency Currency code.
2059 * @return array<string, mixed>|null Validation result array or null if validation passes.
2060 * @since 0.0.1
2061 */
2062 private static function validate_multi_choice_amount( $multi_choice_config, $amount, $currency ) {
2063 // Verify the variable amount block config was found.
2064 if ( empty( $multi_choice_config ) || ! is_array( $multi_choice_config ) ) {
2065 return [
2066 'valid' => false,
2067 'message' => __( 'Variable amount field configuration not found.', 'suredonation' ),
2068 ];
2069 }
2070
2071 // One minor currency unit of tolerance for float rounding.
2072 $epsilon = self::get_amount_epsilon( $currency );
2073
2074 // Donation Amount is a single-select radio group. Extract the preset
2075 // option values and check whether the submitted amount matches one.
2076 $allowed_options = $multi_choice_config['options'] ?? [];
2077 $allowed_values = [];
2078 if ( is_array( $allowed_options ) ) {
2079 foreach ( $allowed_options as $option ) {
2080 if ( isset( $option['value'] ) && is_numeric( $option['value'] ) ) {
2081 $allowed_values[] = (float) $option['value'];
2082 }
2083 }
2084 }
2085
2086 foreach ( $allowed_values as $allowed_value ) {
2087 if ( abs( $amount - $allowed_value ) <= $epsilon ) {
2088 return null; // Matches a configured preset — valid.
2089 }
2090 }
2091
2092 // Not a preset value. Only accept it when the custom amount input is
2093 // enabled for this block; otherwise fail closed.
2094 $allow_custom = ! empty( $multi_choice_config['allow_custom_amount'] );
2095 if ( ! $allow_custom ) {
2096 if ( empty( $allowed_values ) ) {
2097 return [
2098 'valid' => false,
2099 'message' => __( 'No payment options are configured for this field.', 'suredonation' ),
2100 ];
2101 }
2102 return [
2103 'valid' => false,
2104 'message' => __( 'Invalid payment amount. Please select a valid amount from the available options.', 'suredonation' ),
2105 ];
2106 }
2107
2108 // Custom amount is enabled — enforce the configured min/max (0 = none).
2109 $min = isset( $multi_choice_config['custom_amount_min'] ) && is_numeric( $multi_choice_config['custom_amount_min'] )
2110 ? (float) $multi_choice_config['custom_amount_min']
2111 : 0.0;
2112 $max = isset( $multi_choice_config['custom_amount_max'] ) && is_numeric( $multi_choice_config['custom_amount_max'] )
2113 ? (float) $multi_choice_config['custom_amount_max']
2114 : 0.0;
2115
2116 if ( $min > 0 && $amount < $min - $epsilon ) {
2117 return [
2118 'valid' => false,
2119 /* translators: %s: minimum amount with currency */
2120 'message' => sprintf( __( 'Payment amount must be at least %s.', 'suredonation' ), self::format_amount( $min, $currency ) ),
2121 ];
2122 }
2123
2124 if ( $max > 0 && $amount > $max + $epsilon ) {
2125 return [
2126 'valid' => false,
2127 /* translators: %s: maximum amount with currency */
2128 'message' => sprintf( __( 'Payment amount cannot exceed %s.', 'suredonation' ), self::format_amount( $max, $currency ) ),
2129 ];
2130 }
2131
2132 // Validation passed for donation-amount field.
2133 return null;
2134 }
2135
2136 /**
2137 * Get block configuration by block name and slug.
2138 *
2139 * @param array<mixed> $block_config All block configurations.
2140 * @param string $block_name Block name to search for.
2141 * @param string $slug Slug to match.
2142 * @return array<string, mixed>|null Block configuration if found, null otherwise.
2143 * @since 0.0.1
2144 */
2145 private static function get_block_config_by_name_and_slug( $block_config, $block_name, $slug ) {
2146 foreach ( $block_config as $config ) {
2147 if ( empty( $config ) || ! is_array( $config ) ) {
2148 continue;
2149 }
2150
2151 if ( isset( $config['slug'] ) && $config['slug'] === $slug && isset( $config['block_name'] ) && $config['block_name'] === $block_name ) {
2152 return $config;
2153 }
2154 }
2155 return null;
2156 }
2157 }
2158