PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Modules / Payments / AjaxEndpoints.php

AjaxEndpoints.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.14, at app/Modules/Payments/AjaxEndpoints.php

555 lines 23.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Modules\Payments;
4
5 if (!defined('ABSPATH')) {
6 exit; // Exit if accessed directly.
7 }
8
9 use FluentForm\App\Helpers\Helper;
10 use FluentForm\App\Models\Form;
11 use FluentForm\App\Models\Submission;
12 use FluentForm\App\Models\Subscription;
13 use FluentForm\App\Models\Transaction;
14 use FluentForm\App\Modules\Form\FormFieldsParser;
15 use FluentForm\App\Modules\Payments\PaymentMethods\BaseProcessor;
16 use FluentForm\Database\Migrations\Submissions;
17 use FluentForm\Framework\Helpers\ArrayHelper;
18 use FluentForm\App\Modules\Payments\Classes\PaymentManagement;
19 use FluentForm\App\Modules\Payments\Migrations\Migration;
20 use FluentForm\App\Modules\Payments\PaymentMethods\Stripe\ConnectConfig;
21
22 class AjaxEndpoints
23 {
24 public function handleEndpoint($route)
25 {
26 $validRoutes = [
27 'enable_payment' => 'enablePaymentModule',
28 'update_global_settings' => 'updateGlobalSettings',
29 'get_payment_method_settings' => 'getPaymentMethodSettings',
30 'save_payment_method_settings' => 'savePaymentMethodSettings',
31 'get_form_settings' => 'getFormSettings',
32 'save_form_settings' => 'saveFormSettings',
33 'update_transaction' => 'updateTransaction',
34 'get_stripe_connect_config' => 'getStripeConnectConfig',
35 'disconnect_stripe_connection' => 'disconnectStripeConnect',
36 'get_pages' => 'getWpPages',
37 'cancel_subscription' => 'cancelSubscription'
38 ];
39
40 if (isset($validRoutes[$route])) {
41 $this->{$validRoutes[$route]}();
42 } else {
43 do_action('fluentform/handle_payment_ajax_endpoint', $route);
44 }
45
46 die();
47 }
48
49 public function enablePaymentModule()
50 {
51 $this->upgradeDb();
52 // Update settings
53 $settings = PaymentHelper::updatePaymentSettings([
54 'status' => 'yes'
55 ]);
56 // send response to reload the page
57
58 wp_send_json_success([
59 'message' => __('Payment Module successfully enabled!', 'fluentform'),
60 'settings' => $settings,
61 'reload' => 'yes'
62 ]);
63 }
64
65 private function upgradeDB()
66 {
67 global $wpdb;
68 $table = $wpdb->prefix . 'fluentform_transactions';
69 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Checking table structure, %1s is for identifier
70 $cols = $wpdb->get_col($wpdb->prepare("DESC %1s", $table), 0);
71
72 if ($cols && in_array('subscription_id', $cols) && in_array('transaction_hash', $cols)) {
73 // We are good
74 } else {
75 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Migration, dropping table to recreate, %1s is for identifier
76 $wpdb->query($wpdb->prepare("DROP TABLE IF EXISTS %1s", $table));
77 Migration::migrate();
78 // Migrate the database
79 Submissions::migrate(true); // Add payment_total
80 }
81 }
82
83 public function updateGlobalSettings()
84 {
85 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in route registration, sanitized in updatePaymentSettings()
86 $request = wpFluentForm()->request;
87 $settings = wp_unslash($request->get('settings', []));
88
89 $sanitizeMap = [
90 'status' => 'sanitize_text_field',
91 'currency' => 'sanitize_text_field',
92 ];
93 $settings = fluentform_backend_sanitizer($settings, $sanitizeMap);
94
95 // Update settings
96 $settings = PaymentHelper::updatePaymentSettings($settings);
97
98 // send response to reload the page
99 wp_send_json_success([
100 'message' => __('Settings successfully updated!', 'fluentform'),
101 'settings' => $settings,
102 'reload' => 'yes'
103 ]);
104
105 }
106
107 public function getPaymentMethodSettings()
108 {
109 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration
110 $request = wpFluentForm()->request;
111 $method = sanitize_text_field($request->get('method', ''));
112
113 $paymentSettings = apply_filters_deprecated(
114 'fluentform_payment_settings_' . $method,
115 [
116 []
117 ],
118 FLUENTFORM_FRAMEWORK_UPGRADE,
119 'fluentform/payment_settings_' . $method,
120 'Use fluentform/payment_settings_' . $method . ' instead of fluentform_payment_settings_' . $method
121 );
122
123 $settings = apply_filters('fluentform/payment_settings_' . $method, $paymentSettings);
124
125 wp_send_json_success([
126 'settings' => ($settings) ? $settings : false
127 ]);
128 }
129
130 public function savePaymentMethodSettings()
131 {
132 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration
133 $request = wpFluentForm()->request;
134 $method = sanitize_text_field($request->get('method', ''));
135 $settings = wp_unslash($request->get('settings', []));
136
137 $sanitizeMap = [
138 'status' => 'sanitize_text_field',
139 ];
140 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in route registration, sanitized in validation filter
141 $settings = fluentform_backend_sanitizer($settings, $sanitizeMap);
142
143
144 $settingsValidation = apply_filters_deprecated(
145 'fluentform_payment_method_settings_validation_' . $method,
146 [
147 [],
148 $settings
149 ],
150 FLUENTFORM_FRAMEWORK_UPGRADE,
151 'fluentform/payment_method_settings_validation_' . $method,
152 'Use fluentform/payment_method_settings_validation_' . $method . ' instead of fluentform_payment_method_settings_validation_' . $method
153 );
154
155 $validationErrors = apply_filters('fluentform/payment_method_settings_validation_' . $method, $settingsValidation, $settings);
156
157 if ($validationErrors) {
158 wp_send_json_error([
159 'message' => __('Failed to save settings', 'fluentform'),
160 'errors' => $validationErrors
161 ], 423);
162 }
163
164 $settings = apply_filters_deprecated(
165 'fluentform_payment_method_settings_save_' . $method,
166 [
167 $settings
168 ],
169 FLUENTFORM_FRAMEWORK_UPGRADE,
170 'fluentform/payment_method_settings_save_' . $method,
171 'Use fluentform/payment_method_settings_save_' . $method . ' instead of fluentform_payment_method_settings_save_' . $method
172 );
173
174 $settings = apply_filters('fluentform/payment_method_settings_save_' . $method, $settings);
175
176 update_option('fluentform_payment_settings_' . $method, $settings, 'yes');
177
178 wp_send_json_success([
179 'message' => __('Settings successfully updated', 'fluentform')
180 ]);
181 }
182
183 public function getFormSettings()
184 {
185 $request = wpFluentForm()->request;
186 $formId = intval($request->get('form_id', 0));
187 $settings = PaymentHelper::getFormSettings($formId, 'admin');
188 $form = Form::find($formId);
189 $addressFields = array_values(FormFieldsParser::getAddressFields($form));
190
191 $paymentSettings = [
192 'settings' => $settings,
193 'currencies' => PaymentHelper::getCurrencies(),
194 'payment_methods' => PaymentHelper::getFormPaymentMethods($formId),
195 'addressFields' => array_filter($addressFields)
196 ];
197
198 $paymentSettings = apply_filters('fluentform/form_payment_settings', $paymentSettings, $formId);
199
200 wp_send_json_success($paymentSettings, 200);
201 }
202
203 public function saveFormSettings()
204 {
205 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration
206 $request = wpFluentForm()->request;
207 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in route registration, sanitized in setFormMeta()
208 $formId = intval($request->get('form_id', 0));
209 $settings = wp_unslash($request->get('settings', []));
210
211 $sanitizeMap = [
212 'enabled' => 'rest_sanitize_boolean',
213 'currency' => 'sanitize_text_field',
214 ];
215 $settings = fluentform_backend_sanitizer($settings, $sanitizeMap);
216
217 Helper::setFormMeta($formId, '_payment_settings', $settings);
218
219 wp_send_json_success([
220 'message' => __('Settings successfully saved', 'fluentform')
221 ], 200);
222 }
223
224 public function updateTransaction()
225 {
226 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified in route registration, data sanitized below
227 $request = wpFluentForm()->request;
228 $transactionData = $request->get('transaction', []);
229 if (is_array($transactionData)) {
230 $transactionData['id'] = intval(ArrayHelper::get($transactionData, 'id'));
231 $transactionData['status'] = sanitize_text_field(ArrayHelper::get($transactionData, 'status'));
232 $transactionData['payer_name'] = sanitize_text_field(ArrayHelper::get($transactionData, 'payer_name'));
233 $transactionData['payer_email'] = sanitize_email(ArrayHelper::get($transactionData, 'payer_email'));
234 $transactionData['charge_id'] = sanitize_text_field(ArrayHelper::get($transactionData, 'charge_id'));
235 $transactionData['refund_amount'] = floatval(ArrayHelper::get($transactionData, 'refund_amount'));
236 $transactionData['refund_note'] = sanitize_text_field(ArrayHelper::get($transactionData, 'refund_note'));
237 $transactionData['should_run_actions'] = sanitize_text_field(ArrayHelper::get($transactionData, 'should_run_actions'));
238
239 // Handle billing_address and shipping_address
240 if (isset($transactionData['billing_address'])) {
241 $transactionData['billing_address'] = is_array($transactionData['billing_address'])
242 ? array_map('sanitize_text_field', $transactionData['billing_address'])
243 : sanitize_text_field($transactionData['billing_address']);
244 }
245 if (isset($transactionData['shipping_address'])) {
246 $transactionData['shipping_address'] = is_array($transactionData['shipping_address'])
247 ? array_map('sanitize_text_field', $transactionData['shipping_address'])
248 : sanitize_text_field($transactionData['shipping_address']);
249 }
250 }
251
252 // Sanitize subscription_id separately
253 $subscriptionId = intval($request->get('subscription_id', 0));
254
255 $transactionId = $transactionData['id'];
256 $oldTransaction = Transaction::find($transactionId);
257
258 if (!$oldTransaction) {
259 wp_send_json_error(['message' => __('Transaction not found.', 'fluentform')], 404);
260 }
261
262 $changingStatus = $oldTransaction->status != $transactionData['status'];
263
264 // Only a *changed* status is validated; a row may already hold one this build does not
265 // register, e.g. Pro's 'requires_review', and editing other fields must not be blocked.
266 if ($changingStatus && !isset(PaymentHelper::getPaymentStatuses()[$transactionData['status']])) {
267 wp_send_json_error(['message' => __('Invalid payment status.', 'fluentform')], 422);
268 }
269
270 $updateData = ArrayHelper::only($transactionData, [
271 'payer_name',
272 'payer_email',
273 'billing_address',
274 'shipping_address',
275 'charge_id',
276 'status'
277 ]);
278
279 $updateData['updated_at'] = current_time('mysql');
280
281 Transaction::where('id', $transactionId)->update($updateData);
282
283 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration
284 if ($subscriptionId) {
285 // Bind to the transaction's submission; submission_id comes from the row, not the request.
286 $existingSubscription = Subscription::where('id', $subscriptionId)
287 ->where('submission_id', $oldTransaction->submission_id)
288 ->first();
289
290 $changedStatus = ArrayHelper::get($transactionData, 'status');
291
292 // Only mirror real subscription statuses; 'paid' here would block cancellation forever.
293 $isMappable = $existingSubscription
294 && isset(PaymentHelper::getSubscriptionStatuses()[$changedStatus])
295 && $existingSubscription->status != $changedStatus;
296
297 if ($isMappable) {
298 Subscription::where('id', $subscriptionId)
299 ->update([
300 'status' => $changedStatus,
301 'updated_at' => current_time('mysql')
302 ]);
303 }
304 }
305
306 $newStatus = $transactionData['status'];
307
308 // No need abstract method, only need defined method, empty implementation
309 $baseProcessor = new class extends BaseProcessor {
310 public function handlePaymentAction($submissionId, $submissionData, $form, $methodSettings, $hasSubscriptions, $totalPayable) {
311 }
312 };
313
314 if (
315 ($changingStatus && ($newStatus == 'refunded' || $newStatus == 'partially-refunded')) ||
316 ($newStatus == 'partially-refunded' && ArrayHelper::get($transactionData, 'refund_amount'))
317 ) {
318 $refundAmount = 0;
319 $refundNote = 'Refunded by Admin';
320
321 if ($newStatus == 'refunded') {
322 // Handle refund here
323 $refundAmount = $oldTransaction->payment_total;
324 } else if ($newStatus == 'partially-refunded') {
325 $refundAmount = ArrayHelper::get($transactionData, 'refund_amount') * 100;
326 $refundNote = ArrayHelper::get($transactionData, 'refund_note') ?: $refundNote;
327 }
328
329 if ($refundAmount) {
330 $baseProcessor->setSubmissionId($oldTransaction->submission_id);
331
332 $submission = $baseProcessor->getSubmission();
333 $baseProcessor->refund($refundAmount, $oldTransaction, $submission, $oldTransaction->payment_method, 'refund_' . time(), $refundNote);
334
335 // refund() derives the real status from the refunded total: an amount covering
336 // the whole charge is a full refund, whatever status was requested.
337 $newStatus = Transaction::find($transactionId)->status;
338 }
339
340 }
341
342 if ($changingStatus) {
343
344 if ($newStatus == 'paid' || $newStatus == 'pending' || $newStatus == 'processing') {
345 // Delete All Refunds, recording them first
346 $this->recordAndRemoveRefundLedger($oldTransaction, $newStatus);
347 }
348
349 $baseProcessor->setSubmissionId($oldTransaction->submission_id);
350 $baseProcessor->changeSubmissionPaymentStatus($newStatus);
351 $baseProcessor->changeTransactionStatus($transactionId, $newStatus);
352 $baseProcessor->recalculatePaidTotal();
353 }
354
355 $shouldRunActions = ArrayHelper::get($transactionData, 'should_run_actions', 'no');
356
357 if (
358 $changingStatus &&
359 $newStatus === 'paid' &&
360 $shouldRunActions === 'yes'
361 ) {
362 do_action(
363 'fluentform/run_actions_after_update_transaction_as_paid',
364 $newStatus,
365 $oldTransaction
366 );
367 }
368
369 wp_send_json_success([
370 'message' => __('Successfully updated data', 'fluentform')
371 ], 200);
372 }
373
374 /**
375 * The record is the compensating control for an irreversible delete, so if it cannot be written the rows must survive.
376 */
377 private function recordAndRemoveRefundLedger($oldTransaction, $newStatus)
378 {
379 $refunds = Transaction::bySubmission($oldTransaction->submission_id)->refunds()->get();
380
381 if (!count($refunds)) {
382 return;
383 }
384
385 $ids = [];
386 $total = 0;
387 $records = [];
388
389 foreach ($refunds as $refund) {
390 $ids[] = $refund->id;
391 $total += $refund->payment_total;
392 $records[] = '#' . $refund->id . ' (' . PaymentHelper::formatMoney($refund->payment_total, $refund->currency) . ')';
393 }
394
395 $description = sprintf(
396 /* translators: 1: previous status, 2: new status, 3: number of refund records, 4: formatted total, 5: the deleted records */
397 __(
398 'Payment status changed from %1$s to %2$s, which removed %3$d refund record(s) totalling %4$s: %5$s',
399 'fluentform'
400 ),
401 $oldTransaction->status,
402 $newStatus,
403 count($refunds),
404 PaymentHelper::formatMoney($total, $oldTransaction->currency),
405 implode(', ', $records)
406 );
407
408 // Record first: if this write fails the rows are still here to try again.
409 do_action('fluentform/log_data', [
410 'parent_source_id' => $oldTransaction->form_id,
411 'source_type' => 'submission_item',
412 'source_id' => $oldTransaction->submission_id,
413 'component' => 'Payment',
414 'status' => 'info',
415 'title' => __('Refund records deleted', 'fluentform'),
416 'description' => $description,
417 ]);
418
419 // Only the rows just recorded, so a refund added meanwhile is not destroyed unrecorded.
420 Transaction::whereIn('id', $ids)->delete();
421 }
422
423 public function getStripeConnectConfig()
424 {
425 wp_send_json_success(ConnectConfig::getConnectConfig());
426 }
427
428 public function disconnectStripeConnect()
429 {
430 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration
431 $request = wpFluentForm()->request;
432 $attributes = $request->all();
433
434 $sanitizeMap = [
435 'mode' => 'sanitize_text_field',
436 ];
437 $attributes = fluentform_backend_sanitizer($attributes, $sanitizeMap);
438
439 return ConnectConfig::disconnect($attributes, true);
440 }
441
442 public function getWpPages()
443 {
444 $pages = wpFluent()->table('posts')
445 ->select(['ID', 'post_title'])
446 ->where('post_status', 'publish')
447 ->where('post_type', 'page')
448 ->orderBy('ID', 'ASC')
449 ->get();
450
451 wp_send_json_success([
452 'pages' => $pages
453 ]);
454 }
455
456 public function cancelSubscription()
457 {
458 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration
459 $request = wpFluentForm()->request;
460 $attributes = $request->all();
461
462 $sanitizeMap = [
463 'subscription_id' => 'intval',
464 'transaction_id' => 'intval',
465 'submission_id' => 'intval',
466 ];
467 $attributes = fluentform_backend_sanitizer($attributes, $sanitizeMap);
468
469 $subscriptionId = ArrayHelper::get($attributes, 'subscription_id');
470
471 $subscription = fluentFormApi('submissions')->getSubscription($subscriptionId);
472
473 if (!$subscription) {
474 wp_send_json_error([
475 'message' => __('Subscription could not be found', 'fluentform')
476 ], 423);
477 }
478
479 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration
480 $transactionId = ArrayHelper::get($attributes, 'transaction_id', 0);
481
482 // SECURITY (FINDING-29): bind the cancelled records to the AUTHORIZED SUBSCRIPTION itself,
483 // not merely its form. The submission is derived from the subscription (not the request), and
484 // the transaction must belong to that submission AND this subscription — so a per-form
485 // payment manager cannot flip an unrelated subscription's transaction/submission from the
486 // same form to cancelled. subscription_id is null on some legacy rows, so it is matched with
487 // a null-safe fallback while submission_id (1:1 with the subscription) does the hard binding.
488 $subscriptionSubmissionId = (int) $subscription->submission_id;
489
490 $subscriptionTxnScope = function ($query) use ($subscription, $subscriptionSubmissionId) {
491 return $query
492 ->where('submission_id', $subscriptionSubmissionId)
493 ->where(function ($q) use ($subscription) {
494 $q->where('subscription_id', $subscription->id)
495 ->orWhereNull('subscription_id')
496 ->orWhere('subscription_id', 0);
497 });
498 };
499
500 // FINDING-29 + review #243: derive the subscription's OWN transaction from the authorized
501 // scope. Prefer the request's transaction_id only when it resolves inside that scope; the id
502 // is optional in the admin UI, so a missing/stale/mismatched value must not leave the local
503 // transaction active while the submission and gateway are cancelled — fall back to the
504 // subscription's transaction.
505 $oldTransaction = $subscriptionTxnScope(
506 $transactionId ? Transaction::where('id', $transactionId) : Transaction::query()
507 )->first();
508
509 if (!$oldTransaction) {
510 $oldTransaction = $subscriptionTxnScope(Transaction::query())->first();
511 }
512
513 $oldSubmission = Submission::where('id', $subscriptionSubmissionId)->first();
514
515 // CORRECTNESS (review #243): cancel at the gateway FIRST — it holds the authoritative state.
516 // Only touch local records after it confirms, so a gateway failure never leaves us showing
517 // "cancelled" locally while the subscription keeps charging.
518 $response = (new PaymentManagement())->cancelSubscription($subscription);
519
520 if (is_wp_error($response)) {
521 wp_send_json_error([
522 'message' => $response->get_error_code() . ' - ' . $response->get_error_message()
523 ], 423);
524 }
525
526 // Gateway cancelled: reconcile each local record independently (so a partially-cancelled
527 // state is completed rather than skipped) and atomically. The transaction stays scoped to
528 // this subscription (FINDING-29), so an unrelated same-form transaction cannot be flipped.
529 $now = current_time('mysql');
530 wpFluent()->transaction(function () use ($subscriptionTxnScope, $subscriptionSubmissionId, $oldTransaction, $oldSubmission, $now) {
531 if ($oldTransaction && $oldTransaction->status !== 'cancelled') {
532 $subscriptionTxnScope(Transaction::where('id', $oldTransaction->id))
533 ->update([
534 'status' => 'cancelled',
535 'updated_at' => $now
536 ]);
537 }
538
539 if ($oldSubmission && $oldSubmission->payment_status !== 'cancelled') {
540 Submission::where('id', $subscriptionSubmissionId)
541 ->update([
542 'payment_status' => 'cancelled',
543 'updated_at' => $now
544 ]);
545 }
546 });
547
548 wp_send_json_success([
549 'message' => $response
550 ]);
551
552 }
553
554 }
555