PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
better-payment / includes / Classes / PaymentRequestGuard.php

PaymentRequestGuard.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.1, at includes/Classes/PaymentRequestGuard.php

1,103 lines 42.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Server-side authority over what a payment request is allowed to charge.
4 *
5 * @package Better_Payment
6 */
7
8 namespace Better_Payment\Lite\Classes;
9
10 use Better_Payment\Lite\Admin\DB;
11 use Better_Payment\Lite\Campaign\MetaBox;
12 use Better_Payment\Lite\Campaign\Services\RendererService;
13 use Better_Payment\Lite\Controller;
14 use WP_Error;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit;
18 }
19
20 /**
21 * Resolves the amount, currency, Stripe price and campaign a payment request may use.
22 *
23 * Every constraint a merchant configures on a form — a fixed amount, preset amounts,
24 * min/max, a product price, a subscription plan, a campaign minimum — used to exist only
25 * in the browser. The handlers took `primary_payment_amount`, `campaign_currency`, the
26 * Stripe price ids and `campaign_id` straight from a request that any visitor can forge
27 * (the only gate is a nonce printed on the public form). This class re-derives every one
28 * of them from the stored form settings, under one rule:
29 *
30 * the server accepts exactly what the rendered form could have submitted — nothing else.
31 *
32 * Every payment entry point must call it before talking to a gateway: the widget handlers
33 * (`Classes\Actions`), the block handlers (`Blocks\BlockActions`) and the Elementor Pro
34 * form actions. `tests/Unit/Classes/PaymentRequestGuardWiringTest.php` fails if one stops.
35 *
36 * Better Payment Pro's payment forms — Payment Form layouts 4/5/6 and the recurring,
37 * split-payment and mixed payment types — are not resolved by the rules in this class.
38 * PaymentEntitlement refuses them outright without Pro and otherwise asks Pro (or, for
39 * an older Pro, Lite's transitional copy of Pro's code) to resolve them. Those resolvers use
40 * the public checks below (resolve_amount(), coupon_code(), stripe_price(),
41 * product_price(), …), so a Pro form is held to exactly the same amount, price and coupon
42 * rules as a free one.
43 *
44 * @since 2.3.4
45 */
46 class PaymentRequestGuard {
47
48 /**
49 * Two amounts closer than this are the same amount (half a minor unit).
50 */
51 const TOLERANCE = 0.005;
52
53 /**
54 * Stripe price lookups made during this request, keyed by price id.
55 *
56 * @var array<string, float|null>
57 */
58 private $stripe_prices = [];
59
60 /**
61 * Carrier for the Traits\Helper lookups (Stripe prices, Elementor widget settings).
62 *
63 * @var Controller|null
64 */
65 private $helper = null;
66
67 /**
68 * @return Controller
69 */
70 private function helper() {
71 if ( null === $this->helper ) {
72 $this->helper = new Controller();
73 }
74
75 return $this->helper;
76 }
77
78 /**
79 * Resolve a widget or block submission.
80 *
81 * A Pro payment form (a Pro layout, PaymentEntitlement::is_pro_form()) is refused on a free
82 * install before anything else is looked up, and otherwise resolved through
83 * PaymentEntitlement::resolve_form_payment() — which fails closed when nothing resolves it.
84 * A request posting a field only a Pro form renders is refused on a free install too, and
85 * on every install when its form is not a Pro layout. A free layout is a one-time payment,
86 * whatever payment type its saved settings still hold.
87 *
88 * @param array $settings Stored widget/block settings — never request data.
89 * @param array $fields Submitted fields: the AJAX `fields` array, or `$_POST` for PayPal. Unslashed.
90 * @param array $context `page_id` (int) of the form, `gateway` ('stripe'|'paypal'|'paystack').
91 *
92 * @return array|WP_Error {
93 * @type float $amount Total to charge (unit amount × quantity).
94 * @type float $unit_amount Validated amount per unit.
95 * @type int|string $quantity Submitted quantity, or '' when none was sent.
96 * @type int[] $product_quantities Per-product quantities of a product-list form, keyed like its product id setting.
97 * @type string $currency Currency to charge in.
98 * @type string $campaign_id Verified campaign id, or '' (never an unverified request value).
99 * @type string $coupon_code Coupon, only when the form has a visible coupon field.
100 * @type bool $is_recurring Whether this is a Stripe subscription checkout (Pro forms only).
101 * @type string $recurring_price_id Allowed recurring price id (Pro forms only).
102 * @type string $installment_price_id Allowed split-payment installment price id (Pro forms only).
103 * }
104 */
105 public function resolve_form_payment( array $settings, array $fields, array $context ) {
106 $page_id = isset( $context['page_id'] ) ? absint( $context['page_id'] ) : 0;
107 $gateway = isset( $context['gateway'] ) ? sanitize_key( $context['gateway'] ) : '';
108 $is_pro_form = PaymentEntitlement::is_pro_form( $settings );
109 $posts_pro_field = PaymentEntitlement::posts_pro_fields( $fields );
110
111 // Before anything else is read: a free install runs none of a Pro payment.
112 if ( ( $is_pro_form || $posts_pro_field ) && ! ProCompatibility::pro_enabled() ) {
113 return PaymentEntitlement::refusal();
114 }
115
116 // A free layout never renders a plan, an installment or a mixed choice, whatever payment
117 // type its saved settings still hold. Such a request is forged, and never reaches Pro.
118 if ( ! $is_pro_form && $posts_pro_field ) {
119 return self::invalid_price();
120 }
121
122 $campaign = $this->resolve_campaign( self::field( $fields, 'campaign_id' ), $page_id );
123
124 $resolved = [
125 'amount' => 0.0,
126 'unit_amount' => 0.0,
127 'quantity' => '',
128 'product_quantities' => [],
129 'currency' => null !== $campaign && '' !== $campaign['currency'] ? $campaign['currency'] : self::form_currency( $settings ),
130 'campaign_id' => null !== $campaign ? $campaign['id'] : '',
131 'coupon_code' => '',
132 'is_recurring' => false,
133 'recurring_price_id' => '',
134 'installment_price_id' => '',
135 ];
136
137 if ( $is_pro_form ) {
138 return PaymentEntitlement::resolve_form_payment(
139 $this,
140 $resolved,
141 $settings,
142 $fields,
143 [
144 'page_id' => $page_id,
145 'gateway' => $gateway,
146 'campaign' => $campaign,
147 ]
148 );
149 }
150
151 $form_fields = self::repeater( $settings, 'better_payment_form_fields' );
152 $resolved['coupon_code'] = self::coupon_code( $form_fields, $fields );
153
154 $amount = $this->resolve_amount(
155 $settings,
156 $fields,
157 $form_fields,
158 self::preset_amounts( $settings, 'better_payment_show_amount_list', 'better_payment_amount' ),
159 $campaign
160 );
161
162 if ( is_wp_error( $amount ) ) {
163 return $amount;
164 }
165
166 return array_merge( $resolved, $amount );
167 }
168
169 /**
170 * Resolve an Elementor Pro Forms submission (Surface A).
171 *
172 * @param array $form_settings `$record->get( 'form_settings' )`.
173 * @param array $sent_data `$record->get( 'sent_data' )`.
174 *
175 * @return array|WP_Error `amount` (total), `unit_amount`, `quantity` (int ≥ 1), `campaign_id`.
176 */
177 public function resolve_elementor_form_payment( array $form_settings, array $sent_data ) {
178 $page_id = isset( $form_settings['form_post_id'] ) ? absint( $form_settings['form_post_id'] ) : 0;
179
180 // Elementor Pro resolves the form from a post id the request names, published or not.
181 if ( ! $this->helper()->is_payable_form_page( $page_id ) ) {
182 return self::form_unavailable();
183 }
184
185 $amount = self::posted_amount( $sent_data, 'payment_amount' );
186
187 if ( $amount <= 0 || ! self::elementor_form_allows( $form_settings, $amount ) ) {
188 return self::invalid_amount();
189 }
190
191 $raw_quantity = isset( $sent_data['pay_quantity'] ) ? $sent_data['pay_quantity'] : '';
192 if ( is_array( $raw_quantity ) ) {
193 $raw_quantity = isset( $raw_quantity['value'] ) ? $raw_quantity['value'] : '';
194 }
195
196 $quantity = 1;
197 if ( ! empty( $raw_quantity ) ) {
198 $quantity = intval( $raw_quantity );
199 if ( $quantity < 1 ) {
200 return self::invalid_quantity();
201 }
202 }
203
204 $campaign = $this->resolve_campaign( self::field( $sent_data, 'campaign_id' ), $page_id );
205
206 return [
207 'amount' => $amount * $quantity,
208 'unit_amount' => $amount,
209 'quantity' => $quantity,
210 'campaign_id' => null !== $campaign ? $campaign['id'] : '',
211 ];
212 }
213
214 /**
215 * Resolve a submitted campaign id to a campaign that really links to the form page.
216 *
217 * A campaign relaxes the form's amount rules (the donor picks the amount on the
218 * campaign page), so an unverified id must never be honoured: anyone could post
219 * `campaign_id=1` to escape a fixed price. A campaign counts only when it exists, is
220 * live, and its donate link points at `$page_id`.
221 *
222 * @param string $campaign_id Submitted id: a `bp_campaign` post id, or an Elementor
223 * Fundraising Campaign id `bp_campaign_{page}_{postfix}`.
224 * @param int $page_id The form's page.
225 *
226 * @return array|null `id`, `currency` ('' = use the form's), `minimum`, `amounts` (float[]|null = any).
227 */
228 public function resolve_campaign( $campaign_id, $page_id ) {
229 $campaign_id = sanitize_text_field( (string) $campaign_id );
230 $page_id = absint( $page_id );
231
232 if ( '' === $campaign_id || 0 === $page_id ) {
233 return null;
234 }
235
236 if ( ctype_digit( $campaign_id ) ) {
237 return self::resolve_builder_campaign( (int) $campaign_id, $page_id );
238 }
239
240 if ( preg_match( '/^bp_campaign_(\d+)_.+$/', $campaign_id, $matches ) ) {
241 return $this->resolve_widget_campaign( $campaign_id, (int) $matches[1], $page_id );
242 }
243
244 return null;
245 }
246
247 /**
248 * End a plain form POST (the PayPal admin-post handlers) that the guard refused.
249 *
250 * The AJAX handlers answer with wp_send_json_error(); a PayPal submission is a full
251 * page navigation, so it needs a page the visitor can read and go back from.
252 *
253 * @param WP_Error $error The refusal.
254 * @return void
255 */
256 public static function reject_form_post( WP_Error $error ) {
257 wp_die(
258 esc_html( $error->get_error_message() ),
259 esc_html__( 'Payment not accepted', 'better-payment' ),
260 [
261 'response' => 400,
262 'back_link' => true,
263 ]
264 );
265 }
266
267 /**
268 * The currency a widget/block form charges in, from its settings alone.
269 *
270 * @param array $settings Stored form settings.
271 * @return string
272 */
273 public static function form_currency( array $settings ) {
274 $currency = self::setting( $settings, 'better_payment_form_currency', '' );
275
276 if ( 'yes' === self::setting( $settings, 'better_payment_form_currency_use_woocommerce', '' ) ) {
277 $woocommerce_currency = self::setting( $settings, 'better_payment_form_currency_woocommerce', '' );
278 if ( '' !== $woocommerce_currency ) {
279 $currency = $woocommerce_currency;
280 }
281 }
282
283 return $currency;
284 }
285
286 // ------------------------------------------------------------------ one-time amounts
287
288 /**
289 * A one-time amount: a product-priced form, a verified campaign donation, or the amount field's rules.
290 *
291 * Every one-time form goes through this, Pro's included — Pro's resolver passes the
292 * fields repeater and preset amounts its layout renders.
293 *
294 * @param array $settings Form settings.
295 * @param array $fields Submitted fields.
296 * @param array $form_fields The fields repeater the form renders.
297 * @param float[] $presets Preset amounts the form renders as radios (preset_amounts()).
298 * @param array|null $campaign Verified campaign (resolve_campaign()), or null.
299 * @return array|WP_Error `amount`, `unit_amount`, `quantity`, `product_quantities`.
300 */
301 public function resolve_amount( array $settings, array $fields, array $form_fields, array $presets, $campaign = null ) {
302 $posted = self::posted_amount( $fields, 'primary_payment_amount' );
303 $quantity = self::posted_quantity( $fields );
304
305 if ( is_wp_error( $quantity ) ) {
306 return $quantity;
307 }
308
309 // The partial layout-vars.php applies the payment source to every layout, and
310 // hides the amount input whenever a source prices the form — so the price is
311 // not a default the visitor can edit, it is the amount.
312 $source = self::setting( $settings, 'better_payment_form_payment_source', 'manual' );
313
314 if ( in_array( $source, [ 'woocommerce', 'fluentcart', 'stripe' ], true ) ) {
315 $price = $this->source_price( $settings, $source );
316
317 if ( null === $price ) {
318 return self::price_unavailable();
319 }
320
321 if ( $price <= 0 || ! self::same_amount( $posted, $price ) ) {
322 return self::invalid_amount();
323 }
324
325 $posted = $price;
326 } elseif ( null !== $campaign ) {
327 if ( ! self::campaign_allows( $campaign, $posted ) ) {
328 return self::invalid_amount();
329 }
330 } elseif ( ! self::amount_field_allows( $form_fields, $presets, $posted ) ) {
331 return self::invalid_amount();
332 }
333
334 if ( $posted <= 0 ) {
335 return self::invalid_amount();
336 }
337
338 return [
339 'amount' => '' === $quantity ? $posted : $posted * $quantity,
340 'unit_amount' => $posted,
341 'quantity' => $quantity,
342 'product_quantities' => [],
343 ];
344 }
345
346 /**
347 * Whether an amount is one the form's amount field could have submitted.
348 *
349 * Mirrors partials/layout-repeater-vars.php and the layouts that include it:
350 * - a preset amount (radio) is always allowed — clicking one writes it into the input,
351 * even a readonly one;
352 * - a dynamic amount comes from the URL, so only min/max apply;
353 * - a fixed or hidden field can only submit its configured default;
354 * - otherwise the visitor types it, within min (default 1, as rendered) and max.
355 *
356 * @param array $form_fields The fields repeater the form renders.
357 * @param float[] $presets Preset amounts the form renders.
358 * @param float $amount Submitted amount.
359 * @return bool
360 */
361 private static function amount_field_allows( array $form_fields, array $presets, $amount ) {
362 if ( self::amount_in( $amount, $presets ) ) {
363 return true;
364 }
365
366 $field = null;
367 foreach ( $form_fields as $item ) {
368 if ( is_array( $item ) && 'primary_payment_amount' === ( isset( $item['better_payment_primary_field_type'] ) ? $item['better_payment_primary_field_type'] : '' ) ) {
369 $field = $item;
370 break;
371 }
372 }
373
374 if ( null === $field ) {
375 return false;
376 }
377
378 $is_dynamic = 'yes' === ( isset( $field['better_payment_field_name_default_dynamic_enable'] ) ? $field['better_payment_field_name_default_dynamic_enable'] : '' );
379 $is_fixed = ! empty( $field['better_payment_field_name_default_fixed'] );
380
381 if ( ! $is_dynamic && ( $is_fixed || ! self::is_visible( $field ) ) ) {
382 return self::matches_default( $amount, isset( $field['better_payment_field_name_default'] ) ? $field['better_payment_field_name_default'] : '' );
383 }
384
385 $min = ! empty( $field['better_payment_field_name_min'] ) ? (float) $field['better_payment_field_name_min'] : 1.0;
386 $max = isset( $field['better_payment_field_name_max'] ) ? $field['better_payment_field_name_max'] : '';
387
388 return self::within_bounds( $amount, $min, $max );
389 }
390
391 /**
392 * Whether an Elementor Pro Forms submission carries an amount its fields allow.
393 *
394 * @param array $form_settings Form settings.
395 * @param float $amount Submitted amount.
396 * @return bool
397 */
398 private static function elementor_form_allows( array $form_settings, $amount ) {
399 $form_fields = isset( $form_settings['form_fields'] ) && is_array( $form_settings['form_fields'] ) ? $form_settings['form_fields'] : [];
400
401 foreach ( $form_fields as $field ) {
402 if ( ! is_array( $field ) || 'payment_amount' !== ( isset( $field['field_type'] ) ? $field['field_type'] : '' ) ) {
403 continue;
404 }
405
406 // Payment_Amount_Field renders nothing at all while the integration is off.
407 if ( 'yes' !== ( isset( $form_settings['better_payment_payment_amount_enable'] ) ? $form_settings['better_payment_payment_amount_enable'] : '' ) ) {
408 return false;
409 }
410
411 $field_type = isset( $field['bp_field_type'] ) ? $field['bp_field_type'] : '';
412 $list_on = 'yes' === ( isset( $form_settings['better_payment_show_amount_list_enable'] ) ? $form_settings['better_payment_show_amount_list_enable'] : '' )
413 && in_array( $field_type, [ 'amount-list', 'both' ], true );
414
415 if ( $list_on ) {
416 $presets = [];
417 foreach ( (array) ( isset( $form_settings['better_payment_show_amount_list_items'] ) ? $form_settings['better_payment_show_amount_list_items'] : [] ) as $item ) {
418 $presets[] = floatval( is_array( $item ) && isset( $item['better_payment_amount_val'] ) ? $item['better_payment_amount_val'] : 0 );
419 }
420
421 if ( self::amount_in( $amount, $presets ) ) {
422 return true;
423 }
424 }
425
426 if ( ! in_array( $field_type, [ 'input-field', 'both' ], true ) ) {
427 return false;
428 }
429
430 if ( ! empty( $field['bp_field_default_fixed'] ) ) {
431 return self::matches_default( $amount, isset( $field['bp_field_default'] ) ? $field['bp_field_default'] : '' );
432 }
433
434 $min = ! empty( $field['bp_field_min'] ) ? (float) $field['bp_field_min'] : 0.0;
435
436 return self::within_bounds( $amount, $min, isset( $field['bp_field_max'] ) ? $field['bp_field_max'] : '' );
437 }
438
439 // No Better Payment amount field: a plain Elementor field with the ID `payment_amount`.
440 foreach ( $form_fields as $field ) {
441 if ( ! is_array( $field ) || 'payment_amount' !== ( isset( $field['custom_id'] ) ? $field['custom_id'] : '' ) ) {
442 continue;
443 }
444
445 $field_type = isset( $field['field_type'] ) ? $field['field_type'] : 'text';
446
447 switch ( $field_type ) {
448 case 'number':
449 $min = isset( $field['field_min'] ) && '' !== $field['field_min'] ? (float) $field['field_min'] : 0.0;
450 return self::within_bounds( $amount, $min, isset( $field['field_max'] ) ? $field['field_max'] : '' );
451
452 case 'hidden':
453 return self::matches_default( $amount, isset( $field['field_value'] ) ? $field['field_value'] : '' );
454
455 case 'select':
456 case 'radio':
457 return self::amount_in( $amount, self::elementor_option_values( isset( $field['field_options'] ) ? $field['field_options'] : '' ) );
458
459 default:
460 return true;
461 }
462 }
463
464 return false;
465 }
466
467 // ------------------------------------------------------------------ campaigns
468
469 /**
470 * A Campaign Builder campaign (`bp_campaign` post).
471 *
472 * @param int $post_id Campaign post id.
473 * @param int $page_id Form page id.
474 * @return array|null
475 */
476 private static function resolve_builder_campaign( $post_id, $page_id ) {
477 $post = get_post( $post_id );
478
479 if ( ! $post || 'bp_campaign' !== $post->post_type || 'publish' !== $post->post_status ) {
480 return null;
481 }
482
483 $meta = MetaBox::get_all( $post_id );
484 $layout = RendererService::normalize_layout( isset( $meta['bpc_fields_layout'] ) ? $meta['bpc_fields_layout'] : [] );
485
486 $targets_page = (int) ( isset( $meta['bpc_form_page_id'] ) ? $meta['bpc_form_page_id'] : 0 ) === $page_id;
487 $presets = [];
488
489 foreach ( (array) ( isset( $layout['columns'] ) ? $layout['columns'] : [] ) as $column ) {
490 foreach ( (array) ( is_array( $column ) && isset( $column['elements'] ) ? $column['elements'] : [] ) as $element ) {
491 $type = is_array( $element ) && isset( $element['type'] ) ? $element['type'] : '';
492 $settings = is_array( $element ) && isset( $element['settings'] ) && is_array( $element['settings'] ) ? $element['settings'] : [];
493
494 if ( 'donation_form' === $type && ! empty( $settings['url'] ) && self::url_points_to_page( $settings['url'], $page_id ) ) {
495 $targets_page = true;
496 }
497
498 if ( 'donate_amount' === $type ) {
499 $fallback = ! empty( $settings['preset_amounts'] ) ? $settings['preset_amounts'] : '10,25,50,100';
500 foreach ( array_filter( array_map( 'trim', explode( ',', (string) $fallback ) ) ) as $preset ) {
501 $presets[] = floatval( $preset );
502 }
503 }
504 }
505 }
506
507 if ( ! $targets_page ) {
508 return null;
509 }
510
511 $amounts = null;
512
513 // Same reading as RendererService's donate_amount element: without a custom
514 // amount input, the donor can only pick one of the rendered amounts.
515 if ( ! (bool) ( isset( $meta['bpc_allow_custom_amount'] ) ? $meta['bpc_allow_custom_amount'] : 1 ) ) {
516 $amounts = [];
517 foreach ( (array) ( isset( $meta['bpc_suggested_amounts'] ) ? $meta['bpc_suggested_amounts'] : [] ) as $item ) {
518 $amounts[] = floatval( is_array( $item ) && isset( $item['amount'] ) ? $item['amount'] : 0 );
519 }
520
521 if ( empty( $amounts ) ) {
522 $amounts = $presets;
523 }
524 }
525
526 $currency = DB::get_settings( 'better_payment_settings_general_general_currency' );
527
528 return [
529 'id' => (string) $post_id,
530 'currency' => is_string( $currency ) && '' !== $currency ? sanitize_text_field( $currency ) : 'USD',
531 'minimum' => isset( $meta['bpc_minimum_amount'] ) && '' !== $meta['bpc_minimum_amount'] ? (float) $meta['bpc_minimum_amount'] : 0.0,
532 'amounts' => $amounts,
533 ];
534 }
535
536 /**
537 * An Elementor Fundraising Campaign widget, via the `better_payment_campaigns` registry it writes on render.
538 *
539 * @param string $campaign_id Full id `bp_campaign_{page}_{postfix}`.
540 * @param int $campaign_page_id Page the campaign widget lives on.
541 * @param int $page_id Form page id.
542 * @return array|null
543 */
544 private function resolve_widget_campaign( $campaign_id, $campaign_page_id, $page_id ) {
545 $registry = get_option( 'better_payment_campaigns', [] );
546
547 if ( ! is_array( $registry ) ) {
548 return null;
549 }
550
551 foreach ( $registry as $widget_id => $entry ) {
552 $data = is_string( $entry ) ? json_decode( $entry, true ) : $entry;
553
554 if ( ! is_array( $data )
555 || (string) ( isset( $data['campaign_id'] ) ? $data['campaign_id'] : '' ) !== $campaign_id
556 || (int) ( isset( $data['page_id'] ) ? $data['page_id'] : 0 ) !== $campaign_page_id ) {
557 continue;
558 }
559
560 // A campaign on a page visitors cannot see is not a live campaign.
561 if ( ! $this->helper()->is_payable_form_page( $campaign_page_id ) ) {
562 return null;
563 }
564
565 $settings = $this->helper()->get_elementor_widget_settings( $campaign_page_id, (string) $widget_id );
566 $link = isset( $settings['better_payment_campaign_form_button_link']['url'] ) ? $settings['better_payment_campaign_form_button_link']['url'] : '';
567
568 if ( ! self::url_points_to_page( $link, $page_id ) ) {
569 return null;
570 }
571
572 return [
573 'id' => $campaign_id,
574 // An empty campaign currency is never posted by fundraising-campaign.js, so the form's applies.
575 'currency' => isset( $settings['better_payment_campaign_currency'] ) ? sanitize_text_field( $settings['better_payment_campaign_currency'] ) : '',
576 // The widget's custom amount input is rendered with min="1".
577 'minimum' => 1.0,
578 'amounts' => null,
579 ];
580 }
581
582 return null;
583 }
584
585 /**
586 * Whether a verified campaign allows the donated amount.
587 *
588 * @param array $campaign Resolved campaign.
589 * @param float $amount Submitted amount.
590 * @return bool
591 */
592 private static function campaign_allows( array $campaign, $amount ) {
593 if ( $amount + self::TOLERANCE < $campaign['minimum'] ) {
594 return false;
595 }
596
597 return null === $campaign['amounts'] || self::amount_in( $amount, $campaign['amounts'] );
598 }
599
600 /**
601 * Whether a link resolves to the given page.
602 *
603 * @param mixed $url Link.
604 * @param int $page_id Page id.
605 * @return bool
606 */
607 private static function url_points_to_page( $url, $page_id ) {
608 $url = is_string( $url ) ? trim( $url ) : '';
609
610 if ( '' === $url || '#' === $url[0] ) {
611 return false;
612 }
613
614 $resolved = url_to_postid( $url );
615 if ( $resolved > 0 ) {
616 return $resolved === $page_id;
617 }
618
619 // url_to_postid() cannot resolve a static front page.
620 if ( 'page' === get_option( 'show_on_front' ) && (int) get_option( 'page_on_front' ) === $page_id ) {
621 $path = untrailingslashit( (string) wp_parse_url( $url, PHP_URL_PATH ) );
622 $home_path = untrailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) );
623
624 return $path === $home_path;
625 }
626
627 return false;
628 }
629
630 // ------------------------------------------------------------------ prices
631
632 /**
633 * The price a payment source charges.
634 *
635 * @param array $settings Form settings.
636 * @param string $source 'woocommerce' | 'fluentcart' | 'stripe'.
637 * @return float|null Null when it cannot be determined.
638 */
639 private function source_price( array $settings, $source ) {
640 if ( 'stripe' === $source ) {
641 return $this->stripe_price_amount(
642 self::setting( $settings, 'better_payment_form_payment_source_stripe_price_id', '' ),
643 self::stripe_secret_key( $settings )
644 );
645 }
646
647 $product_id = absint( isset( $settings[ "better_payment_form_{$source}_product_id" ] ) ? $settings[ "better_payment_form_{$source}_product_id" ] : 0 );
648
649 return $product_id ? $this->product_price( $source, $product_id ) : null;
650 }
651
652 /**
653 * Unit amount (major units) of a Stripe price, read with the form's own Stripe secret key.
654 *
655 * Looked up once per request. Null when it cannot be read — refuse the payment then; it
656 * is never charged at the visitor's amount.
657 *
658 * @param mixed $price_id Stripe price id; anything but a scalar reads as none.
659 * @param array $settings Form settings (they select the live or test secret key).
660 * @return float|null
661 */
662 public function stripe_price( $price_id, array $settings ) {
663 return $this->stripe_price_amount( is_scalar( $price_id ) ? (string) $price_id : '', self::stripe_secret_key( $settings ) );
664 }
665
666 /**
667 * Current price of a WooCommerce or FluentCart product, read the way the form renders it.
668 *
669 * @param string $platform 'woocommerce' | 'fluentcart'.
670 * @param int $product_id Product id.
671 * @return float|null
672 */
673 public function product_price( $platform, $product_id ) {
674 $price = null;
675
676 if ( 'woocommerce' === $platform && function_exists( 'wc_get_product' ) ) {
677 $product = wc_get_product( $product_id );
678 if ( $product ) {
679 $price = floatval( $product->get_price() );
680 }
681 } elseif ( 'fluentcart' === $platform && class_exists( '\FluentCart\App\Models\Product' ) ) {
682 try {
683 $product = \FluentCart\App\Models\Product::query()->with( [ 'detail', 'variants' ] )->find( $product_id );
684 if ( $product ) {
685 $minor = 0;
686 if ( ! empty( $product->variants ) && count( $product->variants ) > 0 ) {
687 $minor = $product->variants[0]->item_price;
688 } elseif ( ! empty( $product->detail ) ) {
689 $minor = $product->detail->min_price;
690 }
691 $price = floatval( $minor ) / 100;
692 }
693 } catch ( \Exception $e ) {
694 $price = null;
695 }
696 }
697
698 /**
699 * Filters the server-side price of a product a Better Payment form sells.
700 *
701 * Return null when the price cannot be determined; the payment is then refused.
702 *
703 * @since 2.3.4
704 *
705 * @param float|null $price Price in major units, or null.
706 * @param string $platform 'woocommerce' | 'fluentcart'.
707 * @param int $product_id Product id.
708 */
709 $price = apply_filters( 'better_payment/payment_guard/product_price', $price, $platform, $product_id );
710
711 return is_numeric( $price ) ? (float) $price : null;
712 }
713
714 /**
715 * Unit amount (major units) of a Stripe price.
716 *
717 * @param string $price_id Stripe price id.
718 * @param string $secret_key Stripe secret key.
719 * @return float|null
720 */
721 private function stripe_price_amount( $price_id, $secret_key ) {
722 if ( '' === $price_id || '' === $secret_key ) {
723 return null;
724 }
725
726 if ( ! array_key_exists( $price_id, $this->stripe_prices ) ) {
727 $details = $this->helper()->get_stripe_price_details( $price_id, $secret_key );
728
729 $this->stripe_prices[ $price_id ] = isset( $details['unit_amount'] ) && is_numeric( $details['unit_amount'] )
730 ? floatval( $details['unit_amount'] ) / 100
731 : null;
732 }
733
734 return $this->stripe_prices[ $price_id ];
735 }
736
737 /**
738 * The Stripe secret key a form uses.
739 *
740 * @param array $settings Form settings.
741 * @return string
742 */
743 private static function stripe_secret_key( array $settings ) {
744 $is_live = 'yes' === self::setting( $settings, 'better_payment_stripe_live_mode', '' );
745
746 return self::setting( $settings, $is_live ? 'better_payment_stripe_secret_key_live' : 'better_payment_stripe_secret_key', '' );
747 }
748
749 // ------------------------------------------------------------------ form configuration
750
751 /**
752 * A repeater setting (a list of items), or [] when it is unset or not a list.
753 *
754 * @param array $settings Form settings.
755 * @param string $key Setting key, e.g. `better_payment_form_fields`.
756 * @return array
757 */
758 public static function repeater( array $settings, $key ) {
759 return isset( $settings[ $key ] ) && is_array( $settings[ $key ] ) ? $settings[ $key ] : [];
760 }
761
762 /**
763 * Preset amounts a form renders as radios: the items of `$items_key`, while `$toggle_key` is on.
764 *
765 * @param array $settings Form settings.
766 * @param string $toggle_key Show-amount-list switch, e.g. `better_payment_show_amount_list`.
767 * @param string $items_key Amount list repeater, e.g. `better_payment_amount`.
768 * @return float[]
769 */
770 public static function preset_amounts( array $settings, $toggle_key, $items_key ) {
771 if ( 'yes' !== self::setting( $settings, $toggle_key, '' ) || empty( $settings[ $items_key ] ) || ! is_array( $settings[ $items_key ] ) ) {
772 return [];
773 }
774
775 $amounts = [];
776 foreach ( $settings[ $items_key ] as $item ) {
777 if ( is_array( $item ) && isset( $item['better_payment_amount_val'] ) ) {
778 $amounts[] = floatval( $item['better_payment_amount_val'] );
779 }
780 }
781
782 return $amounts;
783 }
784
785 /**
786 * A coupon code, only when the form actually offers a visible coupon field.
787 *
788 * @param array $form_fields The fields repeater the form renders.
789 * @param array $fields Submitted fields.
790 * @return string
791 */
792 public static function coupon_code( array $form_fields, array $fields ) {
793 $code = self::field( $fields, 'primary_coupon_code' );
794
795 if ( '' === $code ) {
796 return '';
797 }
798
799 foreach ( $form_fields as $item ) {
800 if ( is_array( $item )
801 && 'primary_coupon_code' === ( isset( $item['better_payment_primary_field_type'] ) ? $item['better_payment_primary_field_type'] : '' )
802 && self::is_visible( $item ) ) {
803 return $code;
804 }
805 }
806
807 return '';
808 }
809
810 /**
811 * The Stripe discount a typed coupon code may apply.
812 *
813 * Checkout used to attach whatever was typed as `discounts[coupon]`, and a Stripe coupon
814 * id is a shared secret at best — any coupon in the merchant's account applied, internal
815 * 100%-off ones included. Two things are accepted now:
816 * - an active Stripe **promotion code** matching the text (Stripe's customer-facing layer,
817 * which carries its own expiry, redemption limits and restrictions);
818 * - a raw **coupon id**, only when the merchant tagged that coupon in Stripe with the
819 * metadata `better_payment_public = yes`.
820 * Anything else refuses the checkout rather than silently charging full price for a code
821 * the donor believes applied.
822 *
823 * Call this only with a code coupon_code() has let through.
824 *
825 * @since 2.3.4
826 *
827 * @param string $code Coupon code from resolve_form_payment().
828 * @param string $secret_key Stripe secret key of the form.
829 * @return array|WP_Error `[ 'promotion_code' => id ]`, `[ 'coupon' => id ]`, `[]` when no code was typed.
830 */
831 public function resolve_stripe_discount( $code, $secret_key ) {
832 $code = trim( (string) $code );
833 $secret_key = trim( (string) $secret_key );
834
835 if ( '' === $code ) {
836 return [];
837 }
838
839 if ( '' === $secret_key ) {
840 return self::invalid_coupon();
841 }
842
843 $promotion = $this->stripe_get(
844 'promotion_codes',
845 [
846 'code' => $code,
847 'active' => 'true',
848 'limit' => 1,
849 ],
850 $secret_key
851 );
852
853 if ( isset( $promotion['data'][0]['id'] ) && is_string( $promotion['data'][0]['id'] ) ) {
854 return [ 'promotion_code' => sanitize_text_field( $promotion['data'][0]['id'] ) ];
855 }
856
857 $coupon = $this->stripe_get( 'coupons/' . rawurlencode( $code ), [], $secret_key );
858 $is_public = isset( $coupon['metadata']['better_payment_public'] )
859 && is_string( $coupon['metadata']['better_payment_public'] )
860 && 'yes' === strtolower( trim( $coupon['metadata']['better_payment_public'] ) );
861
862 if ( $is_public && isset( $coupon['id'] ) && is_string( $coupon['id'] ) && ! empty( $coupon['valid'] ) ) {
863 return [ 'coupon' => sanitize_text_field( $coupon['id'] ) ];
864 }
865
866 return self::invalid_coupon();
867 }
868
869 /**
870 * GET a Stripe API resource; [] on any failure.
871 *
872 * @param string $path Path under /v1/.
873 * @param array $query Query arguments.
874 * @param string $secret_key Secret key.
875 * @return array
876 */
877 private function stripe_get( $path, array $query, $secret_key ) {
878 $response = wp_safe_remote_get(
879 'https://api.stripe.com/v1/' . $path . ( $query ? '?' . http_build_query( $query ) : '' ),
880 [
881 'headers' => [ 'Authorization' => 'Bearer ' . $secret_key ],
882 'timeout' => 20,
883 ]
884 );
885
886 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
887 return [];
888 }
889
890 $data = json_decode( wp_remote_retrieve_body( $response ), true );
891
892 return is_array( $data ) ? $data : [];
893 }
894
895 /**
896 * Whether a fields-repeater item is shown (the Elementor control defaults to 'yes').
897 *
898 * @param array $item Repeater item.
899 * @return bool
900 */
901 private static function is_visible( array $item ) {
902 return ! isset( $item['better_payment_field_name_show'] ) || 'yes' === $item['better_payment_field_name_show'];
903 }
904
905 /**
906 * Values of an Elementor select/radio field's options (`label|value` per line).
907 *
908 * @param string $options Raw options.
909 * @return float[]
910 */
911 private static function elementor_option_values( $options ) {
912 $values = [];
913
914 foreach ( preg_split( '/\r\n|\r|\n/', (string) $options ) as $line ) {
915 $line = trim( $line );
916 if ( '' === $line ) {
917 continue;
918 }
919 $parts = explode( '|', $line );
920 $values[] = floatval( trim( end( $parts ) ) );
921 }
922
923 return $values;
924 }
925
926 // ------------------------------------------------------------------ request + comparison helpers
927
928 /**
929 * The submitted amount: the amount input, falling back to the preset radio (as every handler reads it).
930 *
931 * @param array $fields Submitted fields.
932 * @param string $key Amount input name.
933 * @return float
934 */
935 public static function posted_amount( array $fields, $key ) {
936 $amount = self::field( $fields, $key );
937
938 if ( '' === $amount || 0.0 === floatval( $amount ) ) {
939 $amount = self::field( $fields, 'primary_payment_amount_radio' );
940 }
941
942 $amount = floatval( $amount );
943
944 return is_finite( $amount ) ? $amount : 0.0;
945 }
946
947 /**
948 * The submitted quantity: '' when absent, otherwise a whole number of at least 1.
949 *
950 * @param array $fields Submitted fields.
951 * @return int|string|WP_Error
952 */
953 private static function posted_quantity( array $fields ) {
954 $raw = self::field( $fields, 'payment_amount_quantity' );
955
956 if ( empty( $raw ) ) {
957 return '';
958 }
959
960 $quantity = intval( $raw );
961
962 return $quantity < 1 ? self::invalid_quantity() : $quantity;
963 }
964
965 /**
966 * A submitted scalar field (arrays yield their first value), sanitized.
967 *
968 * @param array $fields Submitted fields.
969 * @param string $key Field name.
970 * @return string
971 */
972 public static function field( array $fields, $key ) {
973 if ( ! isset( $fields[ $key ] ) ) {
974 return '';
975 }
976
977 $value = $fields[ $key ];
978 if ( is_array( $value ) ) {
979 $value = reset( $value );
980 }
981
982 return is_scalar( $value ) ? trim( sanitize_text_field( (string) $value ) ) : '';
983 }
984
985 /**
986 * A stored setting as a sanitized string.
987 *
988 * @param array $settings Settings.
989 * @param string $key Key.
990 * @param string $fallback Value when unset or empty.
991 * @return string
992 */
993 public static function setting( array $settings, $key, $fallback ) {
994 return isset( $settings[ $key ] ) && is_scalar( $settings[ $key ] ) && '' !== (string) $settings[ $key ]
995 ? sanitize_text_field( (string) $settings[ $key ] )
996 : $fallback;
997 }
998
999 /**
1000 * Whether an amount equals a configured default.
1001 *
1002 * The layouts render the default through intval() while Payment_Amount_Field renders it
1003 * raw, so both readings of the same setting are accepted.
1004 *
1005 * @param float $amount Submitted amount.
1006 * @param mixed $default Configured default.
1007 * @return bool
1008 */
1009 private static function matches_default( $amount, $default ) {
1010 if ( ! is_scalar( $default ) || '' === trim( (string) $default ) ) {
1011 return false;
1012 }
1013
1014 return self::amount_in( $amount, [ floatval( $default ), (float) intval( $default ) ] );
1015 }
1016
1017 /**
1018 * Whether an amount lies within [min, max] (an empty max is unbounded).
1019 *
1020 * @param float $amount Amount.
1021 * @param float $min Minimum.
1022 * @param mixed $max Maximum, '' for none.
1023 * @return bool
1024 */
1025 private static function within_bounds( $amount, $min, $max ) {
1026 if ( $amount <= 0 || $amount + self::TOLERANCE < $min ) {
1027 return false;
1028 }
1029
1030 return ! is_numeric( $max ) || 0.0 === (float) $max || $amount <= (float) $max + self::TOLERANCE;
1031 }
1032
1033 /**
1034 * Whether an amount is one of a list.
1035 *
1036 * @param float $amount Amount.
1037 * @param float[] $amounts Allowed amounts.
1038 * @return bool
1039 */
1040 private static function amount_in( $amount, array $amounts ) {
1041 foreach ( $amounts as $allowed ) {
1042 if ( $allowed > 0 && self::same_amount( $amount, $allowed ) ) {
1043 return true;
1044 }
1045 }
1046
1047 return false;
1048 }
1049
1050 /**
1051 * Whether two amounts are equal to within half a minor unit.
1052 *
1053 * @param float $a Amount.
1054 * @param float $b Amount.
1055 * @return bool
1056 */
1057 public static function same_amount( $a, $b ) {
1058 return abs( (float) $a - (float) $b ) < self::TOLERANCE;
1059 }
1060
1061 /**
1062 * @return WP_Error
1063 */
1064 public static function invalid_amount() {
1065 return new WP_Error( 'bp_invalid_amount', __( 'The payment amount is not allowed for this form.', 'better-payment' ) );
1066 }
1067
1068 /**
1069 * @return WP_Error
1070 */
1071 public static function invalid_quantity() {
1072 return new WP_Error( 'bp_invalid_quantity', __( 'The quantity must be a whole number of at least 1.', 'better-payment' ) );
1073 }
1074
1075 /**
1076 * @return WP_Error
1077 */
1078 public static function invalid_price() {
1079 return new WP_Error( 'bp_invalid_price', __( 'The selected payment plan is not available for this form.', 'better-payment' ) );
1080 }
1081
1082 /**
1083 * @return WP_Error
1084 */
1085 private static function invalid_coupon() {
1086 return new WP_Error( 'bp_invalid_coupon', __( 'This coupon code is not valid.', 'better-payment' ) );
1087 }
1088
1089 /**
1090 * @return WP_Error
1091 */
1092 private static function form_unavailable() {
1093 return new WP_Error( 'bp_form_unavailable', __( 'This payment form is not available.', 'better-payment' ) );
1094 }
1095
1096 /**
1097 * @return WP_Error
1098 */
1099 public static function price_unavailable() {
1100 return new WP_Error( 'bp_price_unavailable', __( 'The price for this payment could not be verified. Please try again later.', 'better-payment' ) );
1101 }
1102 }
1103