PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.0
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 / ProPaymentTypes.php

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

879 lines 44.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\Classes;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 /**
10 * Better Payment Pro's payment types, as Lite's payment handlers ran them inline.
11 *
12 * Transitional copy for Better Payment Pro <= 1.4.3; delete when
13 * ProCompatibility::MIN_PRO_VERSION >= 1.4.4.
14 * Pro copy: better-payment-pro/includes/Frontend/PaymentTypes.php.
15 * Slug: pro_payment_types (PaymentEntitlement::FEATURE).
16 *
17 * What moved here from PaymentRequestGuard, Actions, Handler and the e-mail template:
18 * - what a Pro form may charge: recurring and split-payment price ids (only the ones the
19 * form lists), layout 6's product basket at the store's prices, and layouts 4/5 held to
20 * their own fields repeater and presets;
21 * - the Stripe checkout of recurring, split and mixed forms: subscription mode, the
22 * installment count, the mixed period and its reused recurring Price;
23 * - the fields repeater of layouts 4/5/6 and layout 6's `is_woo_layout` /
24 * `is_fluentcart_layout` transaction fields;
25 * - on return: the subscription details, the recurring / split flags Pro's split-payment
26 * finalizer reads, and the thank-you page's Payment Type and "Paid Amount" rows;
27 * - the split-payment fields an e-mail hides or relabels.
28 *
29 * Only PaymentEntitlement calls it, and only while
30 * ProCompatibility::lite_copy_applies( PaymentEntitlement::FEATURE ) is true: an active
31 * Pro that does not ship this code. A free install never reaches it, because
32 * PaymentEntitlement refuses a Pro form's payment first.
33 *
34 * Each public method takes the arguments of the Lite hook Pro registers its copy on, so
35 * the two class bodies are the same code. Pro's PaymentTypesParityTest fails when they
36 * differ: change both, or neither.
37 *
38 * @since 2.4.0
39 */
40 final class ProPaymentTypes {
41
42 /**
43 * `better_payment/payment_guard/resolve_pro_payment`: what a Pro form's request may charge.
44 *
45 * @param mixed $resolution Null; this is the resolver.
46 * @param array $base The guard's resolution so far: currency, verified campaign_id, nothing charged.
47 * @param array $settings Stored form settings.
48 * @param array $fields Submitted fields, unslashed.
49 * @param array $context `page_id`, `gateway`, `campaign` (verified campaign or null).
50 * @param PaymentRequestGuard $guard The guard whose checks the resolution uses.
51 * @return array|\WP_Error
52 */
53 public static function resolve_form_payment( $resolution, array $base, array $settings, array $fields, array $context, PaymentRequestGuard $guard ) {
54 $layout = PaymentRequestGuard::setting( $settings, 'better_payment_form_layout', 'layout-1' );
55 $payment_type = PaymentRequestGuard::setting( $settings, 'better_payment_form_payment_type', 'one-time' );
56 $gateway = isset( $context['gateway'] ) ? sanitize_key( $context['gateway'] ) : '';
57 $campaign = isset( $context['campaign'] ) && is_array( $context['campaign'] ) ? $context['campaign'] : null;
58 $form_fields = self::form_fields( [], $settings );
59
60 $base['coupon_code'] = PaymentRequestGuard::coupon_code( $form_fields, $fields );
61
62 if ( in_array( $payment_type, [ 'recurring', 'split-payment' ], true ) ) {
63 return self::resolve_subscription( $base, $settings, $fields, $payment_type, $gateway, $guard );
64 }
65
66 // A subscription checkout is only ever rendered for a recurring/split form.
67 if ( 'subscription' === PaymentRequestGuard::field( $fields, 'better_payment_recurring_mode' ) ) {
68 return PaymentRequestGuard::invalid_price();
69 }
70
71 $amount = 'layout-6-pro' === $layout
72 ? self::resolve_product_list_amount( $settings, $fields, $guard )
73 : $guard->resolve_amount( $settings, $fields, $form_fields, self::preset_amounts( $settings, $layout ), $campaign );
74
75 if ( is_wp_error( $amount ) ) {
76 return $amount;
77 }
78
79 return array_merge( $base, $amount );
80 }
81
82 /**
83 * `better_payment/payment_form/pro_form_fields`: the fields repeater a Pro form renders.
84 *
85 * @param mixed $form_fields [] from Lite.
86 * @param array $settings Stored form settings.
87 * @return array
88 */
89 public static function form_fields( $form_fields, array $settings ) {
90 $layout = PaymentRequestGuard::setting( $settings, 'better_payment_form_layout', 'layout-1' );
91 $keys = [
92 'layout-4-pro' => 'better_payment_form_fields_layout_4_5_6',
93 'layout-5-pro' => 'better_payment_form_fields_layout_4_5_6_desc',
94 'layout-6-pro' => 'better_payment_form_fields_layout_4_5_6_woo',
95 ];
96
97 return PaymentRequestGuard::repeater( $settings, isset( $keys[ $layout ] ) ? $keys[ $layout ] : 'better_payment_form_fields' );
98 }
99
100 /**
101 * `better_payment/payment_form/pro_transaction_fields`: layout 6's platform flags.
102 *
103 * Stored on every form's transaction, right after `amount_quantity`, where the handlers
104 * always put them.
105 *
106 * @param array $form_fields_info Fields the handler built.
107 * @param array $settings Stored form settings.
108 * @return array
109 */
110 public static function transaction_fields( array $form_fields_info, array $settings ) {
111 $is_layout_6 = ! empty( $settings['better_payment_form_layout'] ) && 'layout-6-pro' === $settings['better_payment_form_layout'];
112 $is_fluentcart_layout = $is_layout_6 && ! empty( $settings['better_payment_form_layout_6_ecommerce_platform'] ) && 'fluentcart' === $settings['better_payment_form_layout_6_ecommerce_platform'];
113 $is_woo_layout = $is_layout_6 && ( empty( $settings['better_payment_form_layout_6_ecommerce_platform'] ) || 'woocommerce' === $settings['better_payment_form_layout_6_ecommerce_platform'] );
114
115 return self::insert_after(
116 $form_fields_info,
117 'amount_quantity',
118 [
119 'is_woo_layout' => $is_woo_layout,
120 'is_fluentcart_layout' => $is_fluentcart_layout,
121 ]
122 );
123 }
124
125 /**
126 * `better_payment/stripe_checkout/pro_request`: a Pro form's Stripe Checkout Session.
127 *
128 * A one-time Pro form's session is returned as built. Recurring and split forms become
129 * subscription checkouts for the price the guard allowed; a mixed form's recurring
130 * choice needs a period the form offers and subscribes to that plan's reused Price.
131 *
132 * @param mixed $result Null; this builds the checkout.
133 * @param array $checkout `request` (Checkout Session body) and `form_fields` (stored transaction fields).
134 * @param array $context `settings`, `fields` (unslashed), `guarded`, `order_id`, `widget_id`, `header_info`.
135 * @return array|\WP_Error `request` and `form_fields`.
136 */
137 public static function stripe_checkout( $result, array $checkout, array $context ) {
138 $request = isset( $checkout['request'] ) && is_array( $checkout['request'] ) ? $checkout['request'] : [];
139 $better_form_fields = isset( $checkout['form_fields'] ) && is_array( $checkout['form_fields'] ) ? $checkout['form_fields'] : [];
140 $el_settings = isset( $context['settings'] ) && is_array( $context['settings'] ) ? $context['settings'] : [];
141 $fields = isset( $context['fields'] ) && is_array( $context['fields'] ) ? $context['fields'] : [];
142 $guarded = isset( $context['guarded'] ) && is_array( $context['guarded'] ) ? $context['guarded'] : [];
143 $header_info = isset( $context['header_info'] ) && is_array( $context['header_info'] ) ? $context['header_info'] : [];
144 $order_id = isset( $context['order_id'] ) ? (string) $context['order_id'] : '';
145 $widget_id = isset( $context['widget_id'] ) ? (string) $context['widget_id'] : '';
146 $amount = isset( $guarded['amount'] ) ? $guarded['amount'] : 0;
147 $el_settings_currency = isset( $guarded['currency'] ) ? $guarded['currency'] : '';
148
149 $is_payment_split_payment = ! empty( $el_settings['better_payment_form_payment_type'] ) && 'split-payment' === $el_settings['better_payment_form_payment_type'];
150 $is_payment_recurring = ! empty( $guarded['is_recurring'] );
151 $recurring_price_id = isset( $guarded['recurring_price_id'] ) ? $guarded['recurring_price_id'] : '';
152
153 $mixed_payment_selection_data = self::get_mixed_payment_selection_data( $el_settings, $fields );
154
155 if ( is_wp_error( $mixed_payment_selection_data ) ) {
156 return $mixed_payment_selection_data;
157 }
158
159 $is_payment_mixed_recurring = ! empty( $mixed_payment_selection_data['is_mixed_recurring'] );
160 $mixed_dynamic_recurring_price = [];
161
162 if ( $is_payment_mixed_recurring ) {
163 $mixed_dynamic_recurring_price = self::create_mixed_recurring_stripe_price(
164 $header_info,
165 $amount,
166 $el_settings_currency,
167 $order_id,
168 $widget_id,
169 $mixed_payment_selection_data,
170 $el_settings
171 );
172
173 if ( is_wp_error( $mixed_dynamic_recurring_price ) ) {
174 return $mixed_dynamic_recurring_price;
175 }
176
177 // Reuse the recurring checkout flow with the plan's recurring Stripe Price.
178 $is_payment_recurring = true;
179 $recurring_price_id = ! empty( $mixed_dynamic_recurring_price['price_id'] ) ? sanitize_text_field( $mixed_dynamic_recurring_price['price_id'] ) : '';
180 }
181
182 if ( ! empty( $mixed_payment_selection_data['is_payment_mixed'] ) ) {
183 $better_form_fields['is_payment_mixed'] = 1;
184 $better_form_fields['mixed_payment_selected_type'] = ! empty( $mixed_payment_selection_data['selected_type'] ) ? sanitize_text_field( $mixed_payment_selection_data['selected_type'] ) : 'one-time';
185 }
186
187 if ( $is_payment_mixed_recurring ) {
188 $better_form_fields['mixed_payment_selected_type'] = 'recurring';
189 $better_form_fields['mixed_payment_recurring_interval_count'] = ! empty( $mixed_payment_selection_data['interval_count'] ) ? absint( $mixed_payment_selection_data['interval_count'] ) : 1;
190 $better_form_fields['mixed_payment_recurring_interval'] = ! empty( $mixed_payment_selection_data['interval'] ) ? sanitize_text_field( $mixed_payment_selection_data['interval'] ) : 'month';
191 $better_form_fields['mixed_payment_dynamic_price_id'] = ! empty( $mixed_dynamic_recurring_price['price_id'] ) ? sanitize_text_field( $mixed_dynamic_recurring_price['price_id'] ) : '';
192 $better_form_fields['mixed_payment_dynamic_product_id'] = ! empty( $mixed_dynamic_recurring_price['product_id'] ) ? sanitize_text_field( $mixed_dynamic_recurring_price['product_id'] ) : '';
193 }
194
195 $installment_price_id = '';
196
197 if ( $is_payment_split_payment ) {
198 $installment_price_id = isset( $guarded['installment_price_id'] ) ? $guarded['installment_price_id'] : '';
199 $installment_price_iteration = '';
200 $split_payment_installments_data = ! empty( $el_settings['better_payment_split_installment_price_ids'] ) ? $el_settings['better_payment_split_installment_price_ids'] : [];
201
202 if ( is_array( $split_payment_installments_data ) && count( $split_payment_installments_data ) ) {
203 foreach ( $split_payment_installments_data as $split_payment_installment_data ) {
204 $item_price_id = ! empty( $split_payment_installment_data['better_payment_split_installment_price_id'] ) ? sanitize_text_field( $split_payment_installment_data['better_payment_split_installment_price_id'] ) : '';
205
206 if ( $installment_price_id === $item_price_id ) {
207 $installment_price_iteration = ! empty( $split_payment_installment_data['better_payment_split_installment_iteration'] ) ? sanitize_text_field( $split_payment_installment_data['better_payment_split_installment_iteration'] ) : '';
208 break;
209 }
210 }
211 }
212
213 $better_form_fields['is_payment_split_payment'] = 1;
214 $better_form_fields['split_payment_installment_price_id'] = $installment_price_id;
215 $better_form_fields['split_payment_total_amount_price_id'] = $recurring_price_id;
216 $better_form_fields['split_payment_installment_iteration'] = ( ! empty( $installment_price_id ) && ! empty( $installment_price_iteration ) ) ? $installment_price_iteration : 1;
217 $better_form_fields['split_payment_total_amount'] = $amount * $better_form_fields['split_payment_installment_iteration'];
218 }
219
220 if ( $is_payment_recurring || $is_payment_split_payment ) {
221 $request['mode'] = 'subscription';
222 unset( $request['line_items'][0] );
223 unset( $request['payment_intent_data'] );
224 $request['line_items'][0]['price'] = $is_payment_split_payment && ! empty( $installment_price_id ) ? $installment_price_id : $recurring_price_id;
225 $request['line_items'][0]['quantity'] = 1;
226
227 $better_form_fields['mode'] = $request['mode'];
228 $better_form_fields['recurring_price_id'] = $request['line_items'][0]['price'];
229 }
230
231 return [
232 'request' => $request,
233 'form_fields' => $better_form_fields,
234 ];
235 }
236
237 /**
238 * `better_payment/stripe_payment/pro_verification_args`: a subscription session's expansion and flags.
239 *
240 * @param array $args `expand`, `is_payment_recurring`, `is_payment_split_payment`.
241 * @param array $form_fields_info The transaction's stored fields.
242 * @return array
243 */
244 public static function stripe_verification_args( array $args, array $form_fields_info ) {
245 $is_payment_recurring = ! empty( $form_fields_info['mode'] ) && 'subscription' === $form_fields_info['mode'];
246
247 array_unshift( $args['expand'], 'subscription.latest_invoice.payment_intent' );
248
249 $args['is_payment_recurring'] = $is_payment_recurring;
250 $args['is_payment_split_payment'] = ( $is_payment_recurring ) && ( ! empty( $form_fields_info['is_payment_split_payment'] ) && 1 === intval( $form_fields_info['is_payment_split_payment'] ) );
251
252 return $args;
253 }
254
255 /**
256 * `better_payment/stripe_payment/pro_verified_session`: a subscription's details and the thank-you page flags.
257 *
258 * @param array $details `form_fields_info`, `transaction_id`, `email`, `frontend_data`.
259 * @param object $session The retrieved Checkout Session.
260 * @param array $args `is_payment_recurring`, `is_payment_split_payment`.
261 * @return array
262 */
263 public static function stripe_verified_session( array $details, $session, array $args ) {
264 $form_fields_info = $details['form_fields_info'];
265 $is_payment_recurring = ! empty( $args['is_payment_recurring'] );
266 $is_payment_split_payment = ! empty( $args['is_payment_split_payment'] );
267
268 if ( $is_payment_recurring && ! empty( $session->subscription->id ) ) {
269 $transaction_id = sanitize_text_field( $session->subscription->id );
270 $subscription_obj = $session->subscription;
271
272 $form_fields_info['subscription_id'] = sanitize_text_field( $transaction_id );
273 $form_fields_info['subscription_customer_id'] = ! empty( $session->customer ) ? sanitize_text_field( $session->customer ) : '';
274 $form_fields_info['subscription_plan_id'] = ! empty( $subscription_obj->items->data[0]->plan->id ) ? sanitize_text_field( $subscription_obj->items->data[0]->plan->id ) : '';
275 $form_fields_info['subscription_interval'] = ! empty( $subscription_obj->items->data[0]->plan->interval_count ) ? intval( $subscription_obj->items->data[0]->plan->interval_count ) : '';
276 $form_fields_info['subscription_interval'] .= ! empty( $subscription_obj->items->data[0]->plan->interval ) ? ' ' . sanitize_text_field( $subscription_obj->items->data[0]->plan->interval ) : $form_fields_info['subscription_interval'];
277 $form_fields_info['subscription_current_period_start'] = ! empty( $subscription_obj->current_period_start ) ? intval( $subscription_obj->current_period_start ) : 0;
278 $form_fields_info['subscription_current_period_end'] = ! empty( $subscription_obj->current_period_end ) ? intval( $subscription_obj->current_period_end ) : 0;
279 $form_fields_info['subscription_status'] = sanitize_text_field( $session->status );
280 $form_fields_info['subscription_created_date'] = sanitize_text_field( $subscription_obj->created );
281
282 $details['transaction_id'] = $transaction_id;
283 }
284
285 if ( $is_payment_recurring ) {
286 $details['email'] = ! empty( $session->customer_email ) ? sanitize_email( $session->customer_email ) : '';
287 }
288
289 $details['form_fields_info'] = $form_fields_info;
290 $details['frontend_data'] = array_merge(
291 $details['frontend_data'],
292 [
293 'is_payment_recurring' => $is_payment_recurring,
294 'is_payment_split_payment' => $is_payment_split_payment,
295 'is_payment_mixed' => ! empty( $form_fields_info['is_payment_mixed'] ) ? intval( $form_fields_info['is_payment_mixed'] ) : 0,
296 'mixed_payment_selected_type' => ! empty( $form_fields_info['mixed_payment_selected_type'] ) ? sanitize_text_field( $form_fields_info['mixed_payment_selected_type'] ) : '',
297 ]
298 );
299
300 if ( $is_payment_split_payment ) {
301 $details['frontend_data']['total_amount'] = ! empty( $form_fields_info['split_payment_total_amount'] )
302 ? sanitize_text_field( $form_fields_info['split_payment_total_amount'] )
303 : 0;
304 }
305
306 return $details;
307 }
308
309 /**
310 * `better_payment/success_page/pro_rows`: the thank-you page's Payment Type and split "Paid Amount" rows.
311 *
312 * @param array $rows [] from Lite.
313 * @param string $position 'after_payment_method' | 'after_merchant_details'.
314 * @param array $settings Form settings.
315 * @param array $payment The verified payment.
316 * @param array $context `currency_symbol` (escaped).
317 * @return array[]
318 */
319 public static function success_rows( array $rows, $position, array $settings, array $payment, array $context ) {
320 if ( 'after_payment_method' === $position && ! empty( $settings['better_payment_form_payment_type'] ) ) {
321 $pay_type_text = ! empty( $settings['better_payment_form_success_message_pay_type_text'] )
322 ? esc_html( $settings['better_payment_form_success_message_pay_type_text'] )
323 : esc_html__( 'Payment Type', 'better-payment' );
324 $payment_type = esc_html( $settings['better_payment_form_payment_type'] );
325
326 if ( 'one-time' === $payment_type ) {
327 $payment_type_value = esc_html__( 'One Time Payment', 'better-payment' );
328 } elseif ( 'recurring' === $payment_type ) {
329 $payment_type_value = esc_html__( 'Recurring Payment', 'better-payment' );
330 } elseif ( 'mixed' === $payment_type ) {
331 $mixed_payment_selected_type = ! empty( $payment['mixed_payment_selected_type'] ) ? sanitize_text_field( $payment['mixed_payment_selected_type'] ) : '';
332 $is_mixed_recurring = 'recurring' === $mixed_payment_selected_type;
333
334 if ( empty( $mixed_payment_selected_type ) ) {
335 $is_mixed_recurring = ! empty( $payment['is_payment_recurring'] ) && empty( $payment['is_payment_split_payment'] );
336 }
337
338 $payment_type_value = $is_mixed_recurring
339 ? esc_html__( 'Recurring Payment', 'better-payment' )
340 : esc_html__( 'One Time Payment', 'better-payment' );
341 } else {
342 $payment_type_value = esc_html__( 'Split Payment', 'better-payment' );
343 }
344
345 $rows[] = [
346 'label' => $pay_type_text,
347 'value' => $payment_type_value,
348 ];
349 }
350
351 if ( 'after_merchant_details' === $position && ! empty( $payment['is_payment_split_payment'] ) ) {
352 $paid_amount_text = ! empty( $settings['better_payment_form_success_message_paid_amount_text'] )
353 ? esc_html( $settings['better_payment_form_success_message_paid_amount_text'] )
354 : esc_html__( 'Paid Amount', 'better-payment' );
355 $currency_symbol = isset( $context['currency_symbol'] ) && is_scalar( $context['currency_symbol'] ) ? (string) $context['currency_symbol'] : '';
356
357 $rows[] = [
358 'label' => $paid_amount_text,
359 'value' => $currency_symbol . esc_html( isset( $payment['amount'] ) ? $payment['amount'] : '' ) . ' out of ' . $currency_symbol . esc_html( isset( $payment['total_amount'] ) ? $payment['total_amount'] : '' ),
360 ];
361 }
362
363 return $rows;
364 }
365
366 /**
367 * `better_payment/email/pro_form_field_rows`: the split-payment fields an e-mail hides or relabels.
368 *
369 * A split payment's amount is relabelled only once `is_payment_split_payment` has been
370 * seen, in stored order, as the e-mail always read it.
371 *
372 * @param array[] $rows `key`, `label`, `value`, in stored order.
373 * @param string $context 'summary' (the From section) | 'all_fields' (`[bp-all-fields]`).
374 * @param array $form_fields_info The transaction's stored fields.
375 * @return array[]
376 */
377 public static function email_rows( array $rows, $context, array $form_fields_info ) {
378 $is_summary = 'summary' === $context;
379 $hidden = $is_summary
380 ? [ 'is_woo_layout', 'is_fluentcart_layout', 'is_payment_split_payment', 'split_payment_total_amount', 'split_payment_total_amount_price_id', 'split_payment_installment_price_id', 'recurring_price_id' ]
381 : [ 'is_woo_layout', 'is_payment_split_payment', 'split_payment_total_amount', 'split_payment_total_amount_price_id', 'split_payment_installment_price_id' ];
382 $is_split = false;
383 $shown = [];
384
385 foreach ( $rows as $row ) {
386 $key = isset( $row['key'] ) && is_scalar( $row['key'] ) ? (string) $row['key'] : '';
387
388 if ( 'is_payment_split_payment' === $key ) {
389 $is_split = true;
390 }
391
392 if ( in_array( $key, $hidden, true ) ) {
393 continue;
394 }
395
396 if ( $is_summary && 'split_payment_installment_iteration' === $key ) {
397 $row['label'] = ucwords( str_replace( '_', ' ', 'installment_iteration' ) );
398 }
399
400 if ( $is_split && 'Amount' === ucwords( str_replace( '_', ' ', $key ) ) ) {
401 $row['label'] = $is_summary ? __( 'Total Amount', 'better-payment' ) : __( 'Product Price: ', 'better-payment' );
402 }
403
404 $shown[] = $row;
405 }
406
407 return $shown;
408 }
409
410 /**
411 * The presets a layout renders as radios.
412 *
413 * @param array $settings Form settings.
414 * @param string $layout Form layout.
415 * @return float[]
416 */
417 private static function preset_amounts( array $settings, $layout ) {
418 $is_pro_layout = in_array( $layout, [ 'layout-4-pro', 'layout-5-pro', 'layout-6-pro' ], true );
419
420 return PaymentRequestGuard::preset_amounts(
421 $settings,
422 $is_pro_layout ? 'better_payment_show_amount_list_layout_4_5_6' : 'better_payment_show_amount_list',
423 $is_pro_layout ? 'better_payment_amount_layout_4_5_6' : 'better_payment_amount'
424 );
425 }
426
427 /**
428 * Recurring and split-payment forms: the price ids must be ones the form offers.
429 *
430 * The price decides what Stripe charges, so a request-supplied price id is as dangerous
431 * as a request-supplied amount — any price in the merchant's Stripe account would do.
432 *
433 * @param array $resolved Base resolution.
434 * @param array $settings Form settings.
435 * @param array $fields Submitted fields.
436 * @param string $payment_type 'recurring' | 'split-payment'.
437 * @param string $gateway Gateway handling the request.
438 * @param PaymentRequestGuard $guard The guard.
439 * @return array|\WP_Error
440 */
441 private static function resolve_subscription( array $resolved, array $settings, array $fields, $payment_type, $gateway, PaymentRequestGuard $guard ) {
442 // Recurring/split layouts render Stripe only; any other gateway would charge a
443 // one-time amount of the visitor's choosing instead of the plan.
444 if ( 'stripe' !== $gateway ) {
445 return new \WP_Error( 'bp_subscription_gateway', __( 'This payment plan can only be paid with Stripe.', 'better-payment' ) );
446 }
447
448 $is_split = 'split-payment' === $payment_type;
449 $main_price = PaymentRequestGuard::setting( $settings, 'better_payment_form_recurring_price_id', '' );
450 $list_key = $is_split ? 'better_payment_split_installment_price_ids' : 'better_payment_recurring_price_ids';
451 $item_key = $is_split ? 'better_payment_split_installment_price_id' : 'better_payment_recurring_price_id';
452 $allowed = [ $main_price ];
453
454 foreach ( (array) ( isset( $settings[ $list_key ] ) ? $settings[ $list_key ] : [] ) as $item ) {
455 if ( is_array( $item ) && ! empty( $item[ $item_key ] ) ) {
456 $allowed[] = sanitize_text_field( $item[ $item_key ] );
457 }
458 }
459
460 $allowed = array_values( array_filter( $allowed ) );
461 $price_id = PaymentRequestGuard::field( $fields, 'better_payment_recurring_price_id' );
462 $price_id = '' === $price_id ? $main_price : $price_id;
463
464 if ( '' === $price_id || ! in_array( $price_id, $allowed, true ) ) {
465 return PaymentRequestGuard::invalid_price();
466 }
467
468 // A split installment that matches none of the offered prices is refused, never charged at the posted id.
469 $installment_id = $is_split ? PaymentRequestGuard::field( $fields, 'split_payment_installment' ) : '';
470 if ( '' !== $installment_id && ! in_array( $installment_id, $allowed, true ) ) {
471 return PaymentRequestGuard::invalid_price();
472 }
473
474 $charged_price = '' !== $installment_id ? $installment_id : $price_id;
475 $unit_amount = $guard->stripe_price( $charged_price, $settings );
476
477 if ( null === $unit_amount ) {
478 return PaymentRequestGuard::price_unavailable();
479 }
480
481 $resolved['amount'] = $unit_amount;
482 $resolved['unit_amount'] = $unit_amount;
483 $resolved['is_recurring'] = true;
484 $resolved['recurring_price_id'] = $price_id;
485 $resolved['installment_price_id'] = $installment_id;
486
487 return $resolved;
488 }
489
490 /**
491 * Layout 6: the total of the configured WooCommerce/FluentCart products at their real prices.
492 *
493 * Quantities are the visitor's to choose, prices are not. Stripe and Paystack post one
494 * quantity per product; PayPal's plain form POST keeps only the last `payment_amount_quantity`
495 * input, so with several products and a single quantity the total can only be held to its
496 * floor — every product at least once — which is still never less than the listed price.
497 *
498 * @param array $settings Form settings.
499 * @param array $fields Submitted fields.
500 * @param PaymentRequestGuard $guard The guard.
501 * @return array|\WP_Error
502 */
503 private static function resolve_product_list_amount( array $settings, array $fields, PaymentRequestGuard $guard ) {
504 $platform = 'fluentcart' === PaymentRequestGuard::setting( $settings, 'better_payment_form_layout_6_ecommerce_platform', '' ) ? 'fluentcart' : 'woocommerce';
505 $product_ids = isset( $settings[ "better_payment_form_{$platform}_product_ids" ] ) ? (array) $settings[ "better_payment_form_{$platform}_product_ids" ] : [];
506 $product_ids = array_filter( array_map( 'absint', $product_ids ) );
507
508 if ( empty( $product_ids ) ) {
509 return PaymentRequestGuard::price_unavailable();
510 }
511
512 $posted = PaymentRequestGuard::posted_amount( $fields, 'primary_payment_amount' );
513 $raw_quantity = isset( $fields['payment_amount_quantity'] ) ? $fields['payment_amount_quantity'] : null;
514 $expected = 0.0;
515 $exact = true;
516 $quantities = [];
517
518 foreach ( $product_ids as $key => $product_id ) {
519 if ( is_array( $raw_quantity ) ) {
520 $quantity = isset( $raw_quantity[ $key ] ) ? intval( $raw_quantity[ $key ] ) : 1;
521 } elseif ( null !== $raw_quantity && '' !== $raw_quantity && 1 === count( $product_ids ) ) {
522 $quantity = intval( $raw_quantity );
523 } else {
524 $quantity = 1;
525 $exact = 1 === count( $product_ids );
526 }
527
528 if ( $quantity < 1 ) {
529 return PaymentRequestGuard::invalid_quantity();
530 }
531
532 $price = $guard->product_price( $platform, $product_id );
533 if ( null === $price ) {
534 return PaymentRequestGuard::price_unavailable();
535 }
536
537 $expected += $price * $quantity;
538 $quantities[ $key ] = $quantity;
539 }
540
541 $allowed = $exact ? PaymentRequestGuard::same_amount( $posted, $expected ) : $posted + PaymentRequestGuard::TOLERANCE >= $expected;
542
543 if ( $expected <= 0 || ! $allowed ) {
544 return PaymentRequestGuard::invalid_amount();
545 }
546
547 $amount = $exact ? $expected : $posted;
548
549 return [
550 'amount' => $amount,
551 'unit_amount' => $amount,
552 'quantity' => 1,
553 'product_quantities' => $quantities,
554 ];
555 }
556
557 /**
558 * Parse a mixed (one-time or recurring) form's choice.
559 *
560 * @param array $el_settings Form settings.
561 * @param array $fields Submitted fields.
562 * @return array|\WP_Error
563 */
564 private static function get_mixed_payment_selection_data( $el_settings = [], $fields = [] ) {
565 $selection_data = [
566 'is_payment_mixed' => 0,
567 'selected_type' => 'one-time',
568 'is_mixed_recurring' => 0,
569 'interval_count' => 1,
570 'interval' => 'month',
571 ];
572
573 $is_payment_mixed = ! empty( $el_settings['better_payment_form_payment_type'] ) && 'mixed' === sanitize_text_field( $el_settings['better_payment_form_payment_type'] );
574 if ( ! $is_payment_mixed ) {
575 return $selection_data;
576 }
577
578 $selection_data['is_payment_mixed'] = 1;
579 $selected_type = ! empty( $fields['better_payment_mixed_selected_type'] ) ? sanitize_text_field( $fields['better_payment_mixed_selected_type'] ) : '';
580 if ( ! empty( $fields['payment_type'] ) && in_array( $fields['payment_type'], [ 'one-time', 'recurring' ], true ) ) {
581 $selected_type = sanitize_text_field( $fields['payment_type'] );
582 }
583
584 $selected_type = in_array( $selected_type, [ 'one-time', 'recurring' ], true ) ? $selected_type : 'one-time';
585 $selection_data['selected_type'] = $selected_type;
586 $selection_data['is_mixed_recurring'] = 'recurring' === $selected_type ? 1 : 0;
587
588 if ( ! $selection_data['is_mixed_recurring'] ) {
589 return $selection_data;
590 }
591
592 $allowed_intervals = [ 'day', 'week', 'month', 'year' ];
593 $mixed_interval_value = ! empty( $fields['better_payment_mixed_interval'] ) ? sanitize_text_field( $fields['better_payment_mixed_interval'] ) : '';
594
595 if ( empty( $mixed_interval_value ) ) {
596 $interval_type = ! empty( $fields['better_payment_mixed_interval_type'] ) ? sanitize_key( $fields['better_payment_mixed_interval_type'] ) : '';
597
598 if ( empty( $interval_type ) ) {
599 if ( ! empty( $fields['better_payment_mixed_interval_day'] ) ) {
600 $interval_type = 'day';
601 } elseif ( ! empty( $fields['better_payment_mixed_interval_week'] ) ) {
602 $interval_type = 'week';
603 } elseif ( ! empty( $fields['better_payment_mixed_interval_month'] ) ) {
604 $interval_type = 'month';
605 } elseif ( ! empty( $fields['better_payment_mixed_interval_year'] ) ) {
606 $interval_type = 'year';
607 }
608 }
609
610 $interval_type = in_array( $interval_type, $allowed_intervals, true ) ? $interval_type : '';
611 $interval_count = 0;
612
613 switch ( $interval_type ) {
614 case 'day':
615 $interval_count = ! empty( $fields['better_payment_mixed_interval_day'] ) ? absint( $fields['better_payment_mixed_interval_day'] ) : 0;
616 break;
617 case 'week':
618 $interval_count = ! empty( $fields['better_payment_mixed_interval_week'] ) ? absint( $fields['better_payment_mixed_interval_week'] ) : 0;
619 break;
620 case 'year':
621 $interval_count = ! empty( $fields['better_payment_mixed_interval_year'] ) ? absint( $fields['better_payment_mixed_interval_year'] ) : 0;
622 break;
623 case 'month':
624 default:
625 $interval_count = ! empty( $fields['better_payment_mixed_interval_month'] ) ? absint( $fields['better_payment_mixed_interval_month'] ) : 0;
626 break;
627 }
628
629 if ( ! empty( $interval_count ) && ! empty( $interval_type ) ) {
630 $mixed_interval_value = "{$interval_count}|{$interval_type}";
631 }
632 }
633
634 if ( empty( $mixed_interval_value ) || false === strpos( $mixed_interval_value, '|' ) ) {
635 return new \WP_Error( 'mixed_interval_missing', __( 'Recurring time period is required for mixed recurring payment.', 'better-payment' ) );
636 }
637
638 list( $interval_count_raw, $interval_raw ) = array_pad( explode( '|', $mixed_interval_value, 2 ), 2, '' );
639 $interval_count = absint( $interval_count_raw );
640 $interval = sanitize_key( $interval_raw );
641
642 if ( empty( $interval_count ) || ! in_array( $interval, $allowed_intervals, true ) ) {
643 return new \WP_Error( 'mixed_interval_invalid', __( 'Invalid recurring time period selected for mixed payment.', 'better-payment' ) );
644 }
645
646 // The period decides how often the amount is charged, so — like the amount — it must
647 // be one the form offers: a "$10 monthly" plan must not become "$10 yearly".
648 if ( ! in_array( "{$interval_count}|{$interval}", self::mixed_interval_options( $el_settings ), true ) ) {
649 return new \WP_Error( 'mixed_interval_invalid', __( 'Invalid recurring time period selected for mixed payment.', 'better-payment' ) );
650 }
651
652 $selection_data['interval_count'] = $interval_count;
653 $selection_data['interval'] = $interval;
654
655 return $selection_data;
656 }
657
658 /**
659 * The recurring Stripe Price for a mixed form's plan: reused when it exists, created once when not.
660 *
661 * @param array $header_info Stripe request headers.
662 * @param float $amount Amount the guard allowed.
663 * @param string $currency Currency.
664 * @param string $order_id Order id.
665 * @param string $widget_id Widget id.
666 * @param array $selection_data get_mixed_payment_selection_data().
667 * @param array $el_settings Form settings.
668 * @return array|\WP_Error `price_id`, `product_id`.
669 */
670 private static function create_mixed_recurring_stripe_price( $header_info = [], $amount = 0, $currency = '', $order_id = '', $widget_id = '', $selection_data = [], $el_settings = [] ) {
671 $unit_amount = (int) round( floatval( $amount ) * 100 );
672 if ( $unit_amount < 1 ) {
673 return new \WP_Error( 'mixed_amount_invalid', __( 'Payment Amount field is required!', 'better-payment' ) );
674 }
675
676 $product_id = ! empty( $el_settings['better_payment_one_time_recurring_product_id'] )
677 ? sanitize_text_field( $el_settings['better_payment_one_time_recurring_product_id'] )
678 : '';
679
680 if ( empty( $product_id ) ) {
681 return new \WP_Error(
682 'mixed_product_id_missing',
683 __( 'Stripe Product ID is required for mixed recurring payments. Please provide it in the widget settings.', 'better-payment' )
684 );
685 }
686
687 $interval = ! empty( $selection_data['interval'] ) ? sanitize_key( $selection_data['interval'] ) : 'month';
688 $interval_count = ! empty( $selection_data['interval_count'] ) ? absint( $selection_data['interval_count'] ) : 1;
689 $currency = ! empty( $currency ) ? strtolower( sanitize_text_field( $currency ) ) : 'usd';
690
691 $form_name = ! empty( $el_settings['better_payment_form_title'] ) ? sanitize_text_field( $el_settings['better_payment_form_title'] ) : __( 'Better Payment', 'better-payment' );
692
693 $interval_label = $interval;
694 if ( $interval_count > 1 ) {
695 $interval_label = $interval_count . ' ' . $interval . 's';
696 }
697
698 // One Price per plan, not one per request. This runs for anonymous visitors, and a
699 // Price created on every checkout attempt piled objects into the merchant's Stripe
700 // account without limit. A plan is reused through a lookup key derived from exactly
701 // what it charges, so the Price carries no per-order data (the order id lives on the
702 // Checkout Session instead).
703 $lookup_key = self::mixed_price_lookup_key( $product_id, $currency, $unit_amount, $interval, $interval_count );
704 $existing = self::find_mixed_recurring_stripe_price( $header_info, $lookup_key, $product_id, $currency, $unit_amount, $interval, $interval_count );
705
706 if ( '' !== $existing ) {
707 return [
708 'price_id' => $existing,
709 'product_id' => $product_id,
710 ];
711 }
712
713 $price_request = [
714 'currency' => $currency,
715 'unit_amount' => $unit_amount,
716 'recurring' => [
717 'interval' => $interval,
718 'interval_count' => $interval_count,
719 ],
720 'product' => $product_id,
721 'lookup_key' => $lookup_key,
722 'nickname' => sprintf( '%s - %s', $form_name, $interval_label ),
723 'metadata' => [
724 'is_mixed_payment' => '1',
725 'mixed_interval' => $interval,
726 'mixed_interval_count' => strval( $interval_count ),
727 ],
728 ];
729
730 $response = wp_safe_remote_post(
731 'https://api.stripe.com/v1/prices',
732 [
733 'method' => 'POST',
734 'headers' => $header_info,
735 'body' => $price_request,
736 'timeout' => 70,
737 ]
738 );
739
740 if ( is_wp_error( $response ) ) {
741 return new \WP_Error( 'mixed_price_request_failed', $response->get_error_message() );
742 }
743
744 $response_body = wp_remote_retrieve_body( $response );
745 $response_ar = ! empty( $response_body ) ? json_decode( $response_body ) : null;
746 $error_message = ! empty( $response_ar->error->message ) ? sanitize_text_field( $response_ar->error->message ) : __( 'Unable to create recurring Stripe price for mixed payment.', 'better-payment' );
747
748 if ( empty( $response_ar ) || empty( $response_ar->id ) ) {
749 // A concurrent request may have claimed the lookup key a moment earlier.
750 $existing = self::find_mixed_recurring_stripe_price( $header_info, $lookup_key, $product_id, $currency, $unit_amount, $interval, $interval_count );
751
752 if ( '' !== $existing ) {
753 return [
754 'price_id' => $existing,
755 'product_id' => $product_id,
756 ];
757 }
758
759 return new \WP_Error( 'mixed_price_create_failed', $error_message );
760 }
761
762 return [
763 'price_id' => sanitize_text_field( $response_ar->id ),
764 'product_id' => $product_id,
765 ];
766 }
767
768 /**
769 * The periods a mixed-payment form offers, as `{count}|{interval}` — the exact values
770 * layouts 4/5 render into the "Recurring time period" select.
771 *
772 * @param array $el_settings Form settings.
773 * @return string[]
774 */
775 private static function mixed_interval_options( $el_settings = [] ) {
776 $items = ! empty( $el_settings['better_payment_mixed_installment_options'] ) && is_array( $el_settings['better_payment_mixed_installment_options'] )
777 ? $el_settings['better_payment_mixed_installment_options']
778 : [];
779 $options = [];
780
781 foreach ( $items as $option ) {
782 if ( ! is_array( $option ) ) {
783 continue;
784 }
785
786 $type = ! empty( $option['better_payment_mixed_interval_type'] ) ? sanitize_key( $option['better_payment_mixed_interval_type'] ) : 'month';
787 $type = in_array( $type, [ 'day', 'week', 'month', 'year' ], true ) ? $type : 'month';
788
789 $count = ! empty( $option[ "better_payment_mixed_interval_{$type}" ] ) ? absint( $option[ "better_payment_mixed_interval_{$type}" ] ) : 0;
790
791 if ( empty( $count ) && ! empty( $option['better_payment_mixed_interval'] ) ) {
792 $count = absint( $option['better_payment_mixed_interval'] );
793 }
794
795 $options[] = ( $count ? $count : 1 ) . '|' . $type;
796 }
797
798 return $options;
799 }
800
801 /**
802 * Stripe lookup key for a mixed recurring plan.
803 *
804 * @param string $product_id Stripe product id.
805 * @param string $currency Lower-case currency.
806 * @param int $unit_amount Minor units.
807 * @param string $interval day|week|month|year.
808 * @param int $interval_count Interval count.
809 * @return string
810 */
811 private static function mixed_price_lookup_key( $product_id, $currency, $unit_amount, $interval, $interval_count ) {
812 return 'bp_mixed_' . md5( implode( '|', [ $product_id, $currency, (int) $unit_amount, $interval, (int) $interval_count ] ) );
813 }
814
815 /**
816 * An active Stripe Price already created for this exact plan, or ''.
817 *
818 * The lookup key is ours, but a Price is only reused while it still charges exactly this
819 * plan — anything edited or mismatched in Stripe is ignored and a fresh one is created.
820 *
821 * @return string Price id, or '' when there is none to reuse.
822 */
823 private static function find_mixed_recurring_stripe_price( $header_info, $lookup_key, $product_id, $currency, $unit_amount, $interval, $interval_count ) {
824 $response = wp_safe_remote_get(
825 'https://api.stripe.com/v1/prices?' . http_build_query(
826 [
827 'lookup_keys' => [ $lookup_key ],
828 'active' => 'true',
829 'limit' => 1,
830 ]
831 ),
832 [
833 'headers' => $header_info,
834 'timeout' => 70,
835 ]
836 );
837
838 if ( is_wp_error( $response ) ) {
839 return '';
840 }
841
842 $body = json_decode( wp_remote_retrieve_body( $response ) );
843 $price = isset( $body->data ) && is_array( $body->data ) && ! empty( $body->data[0] ) ? $body->data[0] : null;
844
845 if ( ! is_object( $price ) || empty( $price->id ) ) {
846 return '';
847 }
848
849 $price_product = isset( $price->product ) && is_object( $price->product ) ? ( isset( $price->product->id ) ? $price->product->id : '' ) : ( isset( $price->product ) ? $price->product : '' );
850
851 $same_plan = isset( $price->unit_amount, $price->currency, $price->recurring->interval, $price->recurring->interval_count )
852 && (int) $price->unit_amount === (int) $unit_amount
853 && strtolower( (string) $price->currency ) === $currency
854 && (string) $price_product === (string) $product_id
855 && (string) $price->recurring->interval === $interval
856 && (int) $price->recurring->interval_count === (int) $interval_count;
857
858 return $same_plan ? sanitize_text_field( $price->id ) : '';
859 }
860
861 /**
862 * Insert entries into an ordered array right after a key, or at the end when it is absent.
863 *
864 * @param array $items Array.
865 * @param string $after Key to insert after.
866 * @param array $insert Entries.
867 * @return array
868 */
869 private static function insert_after( array $items, $after, array $insert ) {
870 $position = array_search( $after, array_keys( $items ), true );
871
872 if ( false === $position ) {
873 return array_merge( $items, $insert );
874 }
875
876 return array_slice( $items, 0, $position + 1, true ) + $insert + array_slice( $items, $position + 1, null, true );
877 }
878 }
879