PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.15
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.15
6.2.15 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 All 197 releases
fluentform / app / Modules / Payments / Classes / PaymentAction.php

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

1,276 lines 48.4 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\Classes;
4
5 if (!defined('ABSPATH')) {
6 exit; // Exit if accessed directly.
7 }
8
9 use FluentForm\App\Helpers\Helper;
10 use FluentForm\App\Models\OrderItem;
11 use FluentForm\App\Models\Submission;
12 use FluentForm\App\Models\Subscription;
13 use FluentForm\App\Models\SubmissionMeta;
14 use FluentForm\App\Models\Transaction;
15 use FluentForm\App\Modules\Form\FormFieldsParser;
16 use FluentForm\App\Services\ConditionAssesor;
17 use FluentForm\App\Services\Form\SubmissionHandlerService;
18 use FluentForm\Framework\Helpers\ArrayHelper;
19 use FluentForm\App\Modules\Payments\PaymentHelper;
20
21 class PaymentAction
22 {
23 private $form;
24
25 private $data;
26
27 private $submissionData;
28
29 private $submissionId = null;
30
31 private $orderItems = [];
32
33 private $hookedOrderItems = [];
34
35 private $subscriptionItems = [];
36
37 private $quantityItems = [];
38
39 public $selectedPaymentMethod = '';
40
41 public $methodSettings = [];
42
43 protected $paymentInputs = null;
44
45 protected $subscriptionInputs = null;
46
47 protected $currency = null;
48
49 protected $methodField = null;
50
51 protected $discountCodes = [];
52
53 protected $couponField = [];
54
55 private $decodedFormFields = null;
56
57 public function __construct($form, $insertData, $data)
58 {
59 $this->form = $form;
60 $this->data = $data;
61 $this->setSubmissionData($insertData);
62 $this->setupData();
63 }
64
65 private function setSubmissionData($insertData)
66 {
67 $insertData = (array)$insertData;
68 $insertData['response'] = json_decode($insertData['response'], true);
69 $this->submissionData = $insertData;
70 }
71
72 private function setupData()
73 {
74 $formFields = FormFieldsParser::getPaymentFields($this->form, ['admin_label', 'attributes', 'settings']);
75
76 $paymentInputElements = ['custom_payment_component', 'multi_payment_component'];
77 $quantityItems = [];
78 $paymentInputs = [];
79 $subscriptionInputs = [];
80 $paymentMethod = false;
81 $couponField = false;
82 foreach ($formFields as $fieldKey => $field) {
83 $element = ArrayHelper::get($field, 'element');
84 if (in_array($element, $paymentInputElements)) {
85 $paymentInputs[$fieldKey] = $field;
86 } else if ($element == 'item_quantity_component' || $element == 'rangeslider') {
87 if ('rangeslider' == $element && 'yes' != ArrayHelper::get($field, 'settings.enable_target_product')) {
88 continue;
89 }
90 if ($targetProductName = ArrayHelper::get($field, 'settings.target_product')) {
91 $quantityItems[$targetProductName] = [
92 'name' => ArrayHelper::get($field, 'attributes.name'),
93 'field' => $field,
94 ];
95 }
96 } else if ($element == 'payment_method') {
97 $paymentMethod = $field;
98 } else if ($element == 'payment_coupon' && Helper::hasPro()) {
99 $couponField = $field;
100 } else if ($element === 'subscription_payment_component') {
101 $subscriptionInputs[$fieldKey] = $field;
102 }
103 }
104
105 $this->paymentInputs = $paymentInputs;
106 $this->quantityItems = $quantityItems;
107 $this->subscriptionInputs = $subscriptionInputs;
108
109 if ($paymentMethod) {
110 $this->methodField = $paymentMethod;
111 if ($this->isConditionPass()) {
112 $methodName = ArrayHelper::get($paymentMethod, 'attributes.name');
113 $this->selectedPaymentMethod = ArrayHelper::get($this->data, $methodName);
114 $this->methodSettings = ArrayHelper::get($paymentMethod, 'settings.payment_methods.' . $this->selectedPaymentMethod);
115 }
116 }
117
118 if ($couponField && $this->isCouponFieldVisible($couponField)) {
119 $couponCodes = ArrayHelper::get($this->data, '__ff_all_applied_coupons', '');
120 if ($couponCodes) {
121 $couponCodes = \json_decode($couponCodes, true);
122 if ($couponCodes && class_exists('FluentFormPro\Payments\Classes\CouponModel')) {
123 $couponCodes = array_unique($couponCodes);
124 $this->discountCodes = (new \FluentFormPro\Payments\Classes\CouponModel())->getCouponsByCodes($couponCodes);
125 $this->couponField = $couponField;
126 }
127 }
128 }
129
130 if ($this->subscriptionInputs) {
131 // Maybe we have subscription items with bill times = 1
132 // Or if we have discount codes then we have to apply the discount codes
133 $this->validateSubscriptionInputs();
134 }
135
136 $this->applyDiscountCodes();
137 }
138
139 public function isConditionPass()
140 {
141 $conditionSettings = ArrayHelper::get($this->methodField, 'settings.conditional_logics', []);
142 if (
143 !$conditionSettings ||
144 !ArrayHelper::isTrue($conditionSettings, 'status')
145 ) {
146 return true;
147 }
148
149 $conditionFeed = ['conditionals' => $conditionSettings];
150 return ConditionAssesor::evaluate($conditionFeed, $this->data);
151 }
152
153 public function isFieldConditionPass($field)
154 {
155 $conditionSettings = ArrayHelper::get($field, 'settings.conditional_logics', []);
156 if (
157 !$conditionSettings ||
158 !ArrayHelper::isTrue($conditionSettings, 'status')
159 ) {
160 return true;
161 }
162
163 $conditionFeed = ['conditionals' => $conditionSettings];
164 return ConditionAssesor::evaluate($conditionFeed, $this->data);
165 }
166
167 /**
168 * Visible only when the coupon field's own conditions and every ancestor
169 * container's conditions pass — so a coupon inside a hidden container is
170 * not honored.
171 */
172 public function isCouponFieldVisible($couponField)
173 {
174 return $this->isFieldVisible($couponField);
175 }
176
177 /**
178 * Whether a field is actually shown to the submitter: its own conditional
179 * logic has to pass, and so does that of every container it sits inside.
180 */
181 protected function isFieldVisible($field)
182 {
183 if (!$this->isFieldConditionPass($field)) {
184 return false;
185 }
186
187 $fieldName = ArrayHelper::get($field, 'attributes.name');
188 $ancestors = $this->getFieldAncestorContainers($this->getDecodedFormFields(), $fieldName);
189
190 foreach ((array) $ancestors as $container) {
191 if (!$this->isFieldConditionPass($container)) {
192 return false;
193 }
194 }
195
196 return true;
197 }
198
199 /**
200 * The form definition does not change during a request, but this is now
201 * consulted once per payment input and per subscription input, and
202 * `applyDiscountCodes()` forces a full recompute -- so decoding it each time
203 * meant re-parsing the whole form many times over on a multi-item order.
204 */
205 private function getDecodedFormFields()
206 {
207 if (is_null($this->decodedFormFields)) {
208 $formFields = $this->form->form_fields;
209 if (is_string($formFields)) {
210 $formFields = json_decode($formFields, true);
211 }
212 $this->decodedFormFields = ArrayHelper::get($formFields, 'fields', []);
213 }
214
215 return $this->decodedFormFields;
216 }
217
218 /**
219 * Which plan a subscription input is for.
220 *
221 * A submitted choice always wins, including an empty one. When the key never
222 * arrived at all, the plan is inferred only from a plan the form actually
223 * renders pre-selected -- which is exactly `is_default`, and nothing else:
224 * a select leads with a blank "--Select Plan--" option and a radio group
225 * starts unchecked, so on any other form not choosing is a real answer.
226 * Where nothing is pre-selected the input is skipped rather than guessed at.
227 *
228 * @return string|int|null the plan key, or null when there is none to use
229 */
230 private function resolvePlanKey($subscriptionInput, $subscriptionOptions)
231 {
232 if (!$subscriptionOptions) {
233 return null;
234 }
235
236 $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
237 $data = $this->submissionData['response'];
238
239 // An empty submitted value is an answer, not a missing one. A select
240 // renders a blank "--Select Plan--" placeholder first and the field is
241 // optional by default, so choosing it is a genuine "no subscription".
242 // Only a key that never arrived at all can be inferred.
243 if (isset($data[$name])) {
244 if ('' === $data[$name]) {
245 return null;
246 }
247
248 return isset($subscriptionOptions[$data[$name]]) ? $data[$name] : null;
249 }
250
251 if (!$this->isFieldVisible($subscriptionInput)) {
252 return null;
253 }
254
255 // Only a plan the form renders pre-selected may be inferred. The renderer
256 // sets checked/selected solely for is_default and skips expired-hidden plans,
257 // so any other plan is a choice the submitter still had to make -- and not
258 // making it is legitimate, not tampering. Definitions carry no single-default
259 // constraint, so a stale/imported form can mark several defaults or leave the
260 // first default expired-hidden. Collect every inferable default and infer only
261 // when exactly one remains; zero or many is ambiguous and needs an explicit
262 // choice, so it resolves to no subscription rather than the wrong plan.
263 $inferableDefaults = [];
264 foreach ($subscriptionOptions as $planKey => $plan) {
265 if ('yes' === ArrayHelper::get($plan, 'is_default') && $this->isInferablePlan($plan)) {
266 $inferableDefaults[] = $planKey;
267 }
268 }
269
270 return 1 === count($inferableDefaults) ? $inferableDefaults[0] : null;
271 }
272
273 /**
274 * A plan may only be inferred when the form already knows what it costs and
275 * still offers it.
276 *
277 * A "name your price" plan takes its amount from a companion input, so with
278 * nothing submitted there is no amount to charge -- and inferring the plan
279 * anyway would create a subscription at 0. Trial days make that worse: the
280 * zero-amount guard further down is skipped while a trial is configured, so
281 * the result would be a free perpetual subscription.
282 *
283 * An expired plan set to hide is never rendered at all, so its absence is
284 * the admin closing sales, not a dropped input.
285 */
286 private function isInferablePlan($plan)
287 {
288 if ('yes' === ArrayHelper::get($plan, 'user_input')) {
289 return false;
290 }
291
292 return !PaymentHelper::isPlanExpiredAndHidden($plan);
293 }
294
295 /**
296 * Whether the server already knows this item's price, i.e. the request only
297 * ever echoed back a value taken from the form definition.
298 *
299 * Those items must not be skippable by leaving the input out of the request,
300 * because the submitter never supplied the amount in the first place. Items
301 * the submitter genuinely chooses -- an option, a typed amount, a dynamic
302 * default -- are excluded, so leaving those out stays a legitimate
303 * zero-total submission.
304 */
305 private function isServerPricedItem($paymentInput, $inputType)
306 {
307 if ('single' !== $inputType) {
308 return false;
309 }
310
311 if (ArrayHelper::get($paymentInput, 'settings.dynamic_default_value')) {
312 return false;
313 }
314
315 $price = ArrayHelper::get($paymentInput, 'attributes.value');
316 if (!is_numeric($price) || !$price) {
317 return false;
318 }
319
320 if (
321 'yes' === ArrayHelper::get($paymentInput, 'settings.hide_input_when_stockout')
322 && $this->proCannotJudgeHiddenStock()
323 ) {
324 return false;
325 }
326
327 if (!$this->isFieldVisible($paymentInput)) {
328 return false;
329 }
330
331 // Lets an add-on that removes an input at render time -- for reasons the
332 // stored definition cannot express -- keep it out of the order too.
333 return (bool) apply_filters(
334 'fluentform/is_server_priced_payment_item',
335 true,
336 $paymentInput,
337 $this->form
338 );
339 }
340
341 /**
342 * Pro before its renderer-count veto hides a sold-out item at render but judges
343 * stock from a different count on submit, so an omitted hidden item can still read
344 * as in stock and be charged. Until that Pro is updated its sites stay on the
345 * presence check for the hide flag: the fail-open that leaves is the one they
346 * already have, and charging a buyer for an item they never saw is worse.
347 */
348 protected function proCannotJudgeHiddenStock()
349 {
350 return defined('FLUENTFORMPRO')
351 && !method_exists('\FluentFormPro\classes\Inventory\InventoryValidation', 'getRenderedEntryReport');
352 }
353
354 /**
355 * Return the ancestor container fields wrapping $targetName (containers nest
356 * children under columns[].fields[]), or null if not found in this branch.
357 */
358 public function getFieldAncestorContainers($fields, $targetName, $ancestors = [])
359 {
360 foreach ($fields as $field) {
361 if (ArrayHelper::get($field, 'attributes.name') === $targetName) {
362 return $ancestors;
363 }
364 foreach (ArrayHelper::get($field, 'columns', []) as $column) {
365 $found = $this->getFieldAncestorContainers(
366 ArrayHelper::get($column, 'fields', []),
367 $targetName,
368 array_merge($ancestors, [$field])
369 );
370 if (!is_null($found)) {
371 return $found;
372 }
373 }
374 }
375
376 return null;
377 }
378
379 public function draftFormEntry()
380 {
381 // Record Payment Items
382 $subscriptionItems = $this->getSubscriptionItems();
383
384 if (count($subscriptionItems) >= 2) {
385 // We are not supporting multiple subscription items at this moment
386 wp_send_json_error([
387 'message' => __('Sorry, multiple subscription item is not supported', 'fluentform')
388 ]);
389 }
390
391 $items = $this->getOrderItems();
392
393 $existingSubmission = $this->checkForExistingSubmission();
394
395 if (is_wp_error($existingSubmission)) {
396 wp_send_json([
397 'errors' => __('This payment is already complete or still processing. Please wait for confirmation.', 'fluentform'),
398 'append_data' => [
399 '__entry_intermediate_hash' => ArrayHelper::get($this->submissionData, 'response.__entry_intermediate_hash')
400 ]
401 ], 423);
402 }
403
404 $formSettings = PaymentHelper::getFormSettings($this->form->id, 'public');
405 $submission = $this->submissionData;
406 $submission['payment_status'] = 'pending';
407 $submission['payment_method'] = $this->selectedPaymentMethod;
408 $submission['payment_type'] = $this->getPaymentType();
409 $submission['currency'] = $formSettings['currency'];
410 $submission['response'] = json_encode($submission['response']);
411 $submission['payment_total'] = $this->getCalculatedAmount();
412 $submission = apply_filters_deprecated(
413 'fluentform_with_payment_submission_data',
414 [
415 $submission,
416 $this->form
417 ],
418 FLUENTFORM_FRAMEWORK_UPGRADE,
419 'fluentform/payment_submission_data',
420 'Use fluentform/payment_submission_data instead of fluentform_with_payment_submission_data.'
421 );
422 $submission = apply_filters('fluentform/payment_submission_data', $submission, $this->form);
423
424 if ($existingSubmission) {
425 $insertId = $this->updateExistingSubmission($existingSubmission, $submission);
426 } else {
427 $insertId = Submission::create($submission)->id;
428 $uidHash = md5(wp_generate_uuid4() . $insertId);
429 Helper::setSubmissionMeta($insertId, '_entry_uid_hash', $uidHash, $this->form->id);
430 $intermediatePaymentHash = md5('payment_' . wp_generate_uuid4() . '_' . $insertId . '_' . $this->form->id);
431 Helper::setSubmissionMeta($insertId, '__entry_intermediate_hash', $intermediatePaymentHash, $this->form->id);
432 }
433
434 $submission['id'] = $insertId;
435 $this->setSubmissionData($submission);
436 $this->submissionId = $insertId;
437
438
439 $paymentTotal = 0;
440 if ($items) {
441 foreach ($items as $index => $item) {
442 if ($item['type'] == 'discount') {
443 $paymentTotal -= $item['line_total'];
444 } else {
445 $paymentTotal += $item['line_total'];
446 }
447 $items[$index]['submission_id'] = $insertId;
448 $items[$index]['form_id'] = $submission['form_id'];
449 }
450 }
451
452 $this->insertOrderItems($items, $existingSubmission);
453
454 $subsTotal = 0;
455 if ($subscriptionItems && $existingSubmission) {
456 Subscription::where('submission_id', $existingSubmission->id)->delete();
457 }
458
459 foreach ($subscriptionItems as $subscriptionItem) {
460 $quantity = isset($subscriptionItem['quantity']) ? $subscriptionItem['quantity'] : 1;
461 $linePrice = $subscriptionItem['recurring_amount'] * $quantity;
462 $subsTotal += intval($linePrice);
463 $subscriptionItem['submission_id'] = $insertId;
464 Subscription::create($subscriptionItem);
465 }
466
467 do_action('fluentform/notify_on_form_submit', $this->submissionId, $this->submissionData['response'], $this->form);
468
469 $totalPayable = $paymentTotal + $subsTotal;
470
471 // We should make a transaction for subscription
472
473 if ($this->selectedPaymentMethod) {
474 Helper::setSubmissionMeta($insertId, '_selected_payment_method', $this->selectedPaymentMethod);
475 do_action_deprecated(
476 'fluentform_process_payment',
477 [
478 $this->submissionId,
479 $this->submissionData,
480 $this->form,
481 $this->methodSettings,
482 !!$subscriptionItems,
483 $totalPayable
484 ],
485 FLUENTFORM_FRAMEWORK_UPGRADE,
486 'fluentform/process_payment',
487 'Use fluentform/process_payment instead of fluentform_process_payment.'
488 );
489 do_action('fluentform/process_payment', $this->submissionId, $this->submissionData, $this->form, $this->methodSettings, !!$subscriptionItems, $totalPayable);
490
491 do_action_deprecated(
492 'fluentform_process_payment_' . $this->selectedPaymentMethod,
493 [
494 $this->submissionId,
495 $this->submissionData,
496 $this->form,
497 $this->methodSettings,
498 !!$subscriptionItems,
499 $totalPayable
500 ],
501 FLUENTFORM_FRAMEWORK_UPGRADE,
502 'fluentform/process_payment_' . $this->selectedPaymentMethod,
503 'Use fluentform/process_payment_' . $this->selectedPaymentMethod . ' instead of fluentform_process_payment_' . $this->selectedPaymentMethod
504 );
505 do_action('fluentform/process_payment_' . $this->selectedPaymentMethod, $this->submissionId, $this->submissionData, $this->form, $this->methodSettings, !!$subscriptionItems, $totalPayable);
506 }
507
508 /*
509 * The following code will run only if no payment method catch and process the payment
510 * In the payment method, ideally they will send the response. But if no payment method exist then
511 * we will handle here
512 */
513 $submission = Submission::find($insertId);
514
515 $returnData = (new SubmissionHandlerService())->processSubmissionData(
516 $submission->id, $this->submissionData['response'], $this->form
517 );
518
519 wp_send_json_success($returnData, 200);
520 }
521
522 public function getOrderItems($forced = false)
523 {
524 if ($forced) {
525 $this->orderItems = [];
526 }
527
528 if ($this->orderItems) {
529 return $this->orderItems;
530 }
531
532 $paymentInputs = $this->paymentInputs;
533
534 if (!$paymentInputs && !$this->hookedOrderItems) {
535 return [];
536 }
537
538 $data = $this->submissionData['response'];
539
540 foreach ($paymentInputs as $paymentInput) {
541 $name = ArrayHelper::get($paymentInput, 'attributes.name');
542 if (!$name) {
543 continue;
544 }
545 $price = 0;
546 $inputType = ArrayHelper::get($paymentInput, 'attributes.type');
547
548 // A server-priced item is resolved from the form definition, so an
549 // absent or falsy request value must not drop it -- otherwise an
550 // unauthenticated submitter zeroes the order simply by omitting the
551 // input. Every other item still needs a submitted value.
552 if (!$this->isServerPricedItem($paymentInput, $inputType)) {
553 if (!isset($data[$name]) || !$data[$name]) {
554 continue;
555 }
556 }
557
558 if ($inputType == 'number') {
559 $price = $data[$name];
560 } else if ($inputType == 'single') {
561 $price = ArrayHelper::get($paymentInput, 'attributes.value');
562 if (ArrayHelper::get($paymentInput, 'settings.dynamic_default_value')) {
563 $price = $data[$name];
564 }
565 } else if ($inputType == 'radio' || $inputType == 'select') {
566 $item = $this->getItemFromVariables($paymentInput, $data[$name]);
567 if ($item) {
568 $quantity = $this->getQuantity($item['parent_holder']);
569 if (!$quantity) {
570 continue;
571 }
572 $item['quantity'] = $quantity;
573 $this->pushItem($item);
574 }
575 continue;
576 } else if (ArrayHelper::get($paymentInput, 'attributes.type') == 'checkbox') {
577 $selectedItems = $data[$name];
578 foreach ($selectedItems as $selectedItem) {
579 $item = $this->getItemFromVariables($paymentInput, $selectedItem);
580 if ($item) {
581 $quantity = $this->getQuantity($item['parent_holder']);
582 if (!$quantity) {
583 continue;
584 }
585 $item['quantity'] = $quantity;
586 $this->pushItem($item);
587 }
588 }
589 continue;
590 }
591
592 if (!is_numeric($price) || !$price) {
593 continue;
594 }
595
596 $productName = ArrayHelper::get($paymentInput, 'attributes.name');
597 $quantity = $this->getQuantity($productName);
598 if (!$quantity) {
599 continue;
600 }
601
602 $this->pushItem([
603 'parent_holder' => $productName,
604 'item_name' => ArrayHelper::get($paymentInput, 'admin_label'),
605 'item_price' => $price,
606 'quantity' => $quantity
607 ]);
608 }
609
610 // We may have initial amount from the subscription
611 if ($this->hookedOrderItems) {
612 $this->orderItems = array_merge($this->orderItems, $this->hookedOrderItems);
613 }
614
615 $this->orderItems = apply_filters_deprecated(
616 'fluentform_submission_order_items',
617 [
618 $this->orderItems,
619 $this->submissionData,
620 $this->form
621 ],
622 FLUENTFORM_FRAMEWORK_UPGRADE,
623 'fluentform/submission_order_items',
624 'Use fluentform/submission_order_items instead of fluentform_submission_order_items.'
625 );
626
627 $this->orderItems = apply_filters('fluentform/submission_order_items', $this->orderItems, $this->submissionData, $this->form, $this->selectedPaymentMethod);
628
629 return $this->orderItems;
630 }
631
632 private function getQuantity($productName)
633 {
634 $quantity = 1;
635 if (!$this->quantityItems) {
636 return $quantity;
637 }
638 if (!isset($this->quantityItems[$productName])) {
639 return $quantity;
640 }
641 $quantityField = $this->quantityItems[$productName]['field'];
642 $inputName = $this->quantityItems[$productName]['name'];
643 $data = $this->submissionData['response'];
644
645 // An absent input is either hidden by conditional logic or stripped to zero
646 // the order; only the field's own visibility separates the two. A submitted
647 // 0 or blank box still means none.
648 if (!isset($data[$inputName])) {
649 return $this->isFieldVisible($quantityField) ? 1 : 0;
650 }
651
652 $quantity = ArrayHelper::get($data, $inputName);
653 if (!$quantity) {
654 return 0;
655 }
656 // SECURITY (FINDING-22): clamp a user-supplied quantity to a non-negative integer so a
657 // negative quantity cannot flip a line total and subtract from the order.
658 return max(0, intval($quantity));
659 }
660
661 private function pushItem($data)
662 {
663 // SECURITY (FINDING-22): reject non-positive prices. A user-controlled "name your price"
664 // / donation amount (or a dynamic-default numeric field) is otherwise taken verbatim, and
665 // a negative value subtracts from the order total — forcing it to exactly 0 makes
666 // maybeHandlePayment() skip the gateway entirely, yielding a free fulfilled order.
667 if (!is_numeric($data['item_price']) || floatval($data['item_price']) <= 0) {
668 return;
669 }
670 $data['item_price'] = floatval($data['item_price'] * 100);
671
672 $defaults = [
673 'type' => 'single',
674 'form_id' => $this->form->id,
675 'quantity' => !empty($data['quantity']) ? $data['quantity'] : 1,
676 'created_at' => current_time('mysql'),
677 'updated_at' => current_time('mysql')
678 ];
679
680 $item = wp_parse_args($data, $defaults);
681
682 $item['line_total'] = $item['item_price'] * $item['quantity'];
683
684 if (!$this->orderItems) {
685 $this->orderItems = [];
686 }
687
688 $this->orderItems[] = $item;
689 }
690
691 private function getItemFromVariables($item, $key)
692 {
693 $elementName = $item['element'];
694 $pricingOptions = ArrayHelper::get($item, 'settings.pricing_options');
695 $pricingOptions = apply_filters_deprecated(
696 'fluentform_payment_field_' . $elementName . '_pricing_options',
697 [
698 $pricingOptions,
699 $item,
700 $this->form
701 ],
702 FLUENTFORM_FRAMEWORK_UPGRADE,
703 'fluentform/payment_field_' . $elementName . '_pricing_options',
704 'Use fluentform/payment_field_' . $elementName . '_pricing_options instead of fluentform_payment_field_' . $elementName . '_pricing_options.'
705 );
706 $pricingOptions = apply_filters('fluentform/payment_field_' . $elementName . '_pricing_options', $pricingOptions, $item, $this->form);
707
708 $selectedOption = [];
709 foreach ($pricingOptions as $priceOption) {
710 $label = sanitize_text_field($priceOption['label']);
711 $value = sanitize_text_field($priceOption['value']);
712 if ($label == $key || $value == $key) {
713 $selectedOption = $priceOption;
714 }
715 }
716
717 if (!$selectedOption || empty($selectedOption['value']) || !is_numeric($selectedOption['value'])) {
718 return false;
719 }
720
721 return [
722 'parent_holder' => ArrayHelper::get($item, 'attributes.name'),
723 'item_name' => $selectedOption['label'],
724 'item_price' => $selectedOption['value']
725 ];
726 }
727
728 public function getCalculatedAmount()
729 {
730 $items = $this->getOrderItems();
731
732 $total = 0;
733 foreach ($items as $item) {
734 if ($item['type'] == 'discount') {
735 $total -= $item['line_total'];
736 } else {
737 $total += $item['line_total'];
738 }
739 }
740 return $total;
741 }
742
743 // A $0 order is a completed free order when a real priced product was zeroed by a
744 // server-validated discount (both a non-discount and a discount line are present;
745 // discount lines come solely from getValidCoupons), or when the visitor chose one of
746 // the form's own $0 options. Otherwise the $0 total is an omitted or zeroed input.
747 public function isZeroTotalFreeOrder()
748 {
749 $hasProduct = $hasDiscount = false;
750 foreach ($this->getOrderItems() as $item) {
751 if (ArrayHelper::get($item, 'type') === 'discount') {
752 $hasDiscount = true;
753 } else {
754 $hasProduct = true;
755 }
756 }
757 return ($hasProduct && $hasDiscount) || $this->hasSelectedFreeOption();
758 }
759
760 // Validation rejects any option the form does not offer, so a selected $0 option is the site's own.
761 protected function hasSelectedFreeOption()
762 {
763 $data = (array) ArrayHelper::get($this->submissionData, 'response');
764 foreach ((array) $this->paymentInputs as $input) {
765 $selected = (array) ArrayHelper::get($data, ArrayHelper::get($input, 'attributes.name'), []);
766 if (!$selected || !$this->isFieldVisible($input)) {
767 continue;
768 }
769 foreach (ArrayHelper::get($input, 'settings.pricing_options', []) as $option) {
770 if (in_array(sanitize_text_field($option['label']), $selected) && !(float) $option['value']) {
771 return true;
772 }
773 }
774 }
775 return false;
776 }
777
778 // The entry is not inserted yet; stamp a free order the moment it is, before any
779 // payment-success consumer runs. An absent flag means an empty order.
780 public function flagZeroTotalOrder()
781 {
782 if (!$this->isZeroTotalFreeOrder()) {
783 return;
784 }
785
786 add_action('fluentform/notify_on_form_submit', function ($insertId, $formData, $form) {
787 Helper::setSubmissionMeta($insertId, '_ff_zero_total_free_order', 'yes', $form->id);
788 }, 1, 3);
789 }
790
791 public function getPaymentType()
792 {
793 return count($this->getSubscriptionItems()) ? 'subscription' : 'product'; // return value product|subscription|donation
794 }
795
796 private function getCurrency()
797 {
798 if ($this->currency !== null) {
799 return $this->currency;
800 }
801 $this->currency = 'usd';
802
803 return $this->currency;
804 }
805
806 public function getSubscriptionItems()
807 {
808 if ($this->subscriptionItems) {
809 return $this->subscriptionItems;
810 }
811
812 $data = $this->submissionData['response'];
813 $subscriptionInputs = $this->subscriptionInputs;
814
815 if (!$subscriptionInputs) {
816 return [];
817 }
818
819 foreach ($subscriptionInputs as $subscriptionInput) {
820 $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
821 $quantity = $this->getQuantity($name);
822
823 if (!$name || $quantity === 0) {
824 continue;
825 }
826
827 $label = ArrayHelper::get($subscriptionInput, 'settings.label', $name);
828
829 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
830
831 $planKey = $this->resolvePlanKey($subscriptionInput, $subscriptionOptions);
832
833 if (is_null($planKey)) {
834 continue;
835 }
836
837 $plan = ArrayHelper::get($subscriptionOptions, $planKey);
838
839 if (!$plan) {
840 continue;
841 }
842
843 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
844 $plan['subscription_amount'] = $this->getCustomSubscriptionAmount($data, $name, $planKey);
845 }
846
847 $noTrial = ArrayHelper::get($plan, 'has_trial_days') === 'no' ||
848 !ArrayHelper::get($plan, 'trial_days');
849
850 if (!$plan['subscription_amount'] && $noTrial) {
851 continue;
852 }
853
854 if (ArrayHelper::get($plan, 'bill_times') == 1 && ArrayHelper::get($plan, 'has_trial_days') != 'yes') {
855 // Since the billing times is 1 and no trial days,
856 // the subscription acts like as an one time payment.
857 // We'll convert this as a payment item.
858 $signupFee = 0;
859
860 if ($plan['has_signup_fee'] === 'yes') {
861 $signupFee = PaymentHelper::convertToCents($plan['signup_fee']);
862 }
863
864 $onetimeTotal = $signupFee + PaymentHelper::convertToCents($plan['subscription_amount']);
865
866 $this->pushItem([
867 'parent_holder' => $name,
868 'item_name' => $label,
869 'quantity' => $quantity,
870 'item_price' => $onetimeTotal,
871 'line_total' => $quantity * $onetimeTotal,
872 'created_at' => current_time('mysql'),
873 'updated_at' => current_time('mysql')
874 ]);
875 } else {
876 $billTimes = (isset($plan['bill_times'])) ? $plan['bill_times'] : 0;
877
878 // If end date is set, dynamically calculate bill_times from today
879 if (
880 ArrayHelper::get($plan, 'has_end_date') === 'yes'
881 && ($endDateStr = ArrayHelper::get($plan, 'subscription_end_date'))
882 ) {
883 $endDate = strtotime($endDateStr . ' +1 day');
884 $now = current_time('timestamp');
885 if (!$endDate || $endDate <= $now) {
886 if (ArrayHelper::get($plan, 'expire_behavior') === 'hide') {
887 continue;
888 }
889 wp_send_json([
890 'errors' => [__('This subscription plan was expired', 'fluentform')]
891 ], 423);
892 }
893 $diffDays = max(1, ceil(($endDate - $now) / 86400));
894 $intervalMap = ['day' => 1, 'week' => 7, 'month' => 30, 'year' => 365];
895 $interval = isset($intervalMap[$plan['billing_interval']]) ? $intervalMap[$plan['billing_interval']] : 30;
896 $billTimes = max(1, ceil($diffDays / $interval));
897 }
898
899 $subscription = array(
900 'element_id' => $name,
901 'item_name' => $label,
902 'form_id' => $this->form->id,
903 'plan_name' => $plan['name'],
904 'billing_interval' => $plan['billing_interval'],
905 'trial_days' => 0,
906 'recurring_amount' => PaymentHelper::convertToCents($plan['subscription_amount']),
907 'bill_times' => $billTimes,
908 'initial_amount' => 0,
909 'status' => 'pending',
910 'original_plan' => maybe_serialize($plan),
911 'created_at' => current_time('mysql'),
912 'updated_at' => current_time('mysql'),
913 );
914
915 if (ArrayHelper::get($plan, 'has_signup_fee') === 'yes' && ArrayHelper::get($plan, 'signup_fee')) {
916 $subscription['initial_amount'] = PaymentHelper::convertToCents($plan['signup_fee']);
917 }
918
919 if (ArrayHelper::get($plan, 'has_trial_days') === 'yes' && ArrayHelper::get($plan, 'trial_days')) {
920 $subscription['trial_days'] = $plan['trial_days'];
921 $dateTime = current_datetime();
922 $localtime = $dateTime->getTimestamp() + $dateTime->getOffset();
923 $expirationDate = date('Y-m-d H:i:s', $localtime + absint($plan['trial_days']) * 86400);
924 $subscription['expiration_at'] = $expirationDate;
925 }
926
927 if ($quantity > 1) {
928 $subscription['quantity'] = $quantity;
929 }
930
931 $this->subscriptionItems[] = $subscription;
932 }
933 }
934 $this->subscriptionItems = apply_filters_deprecated(
935 'fluentform_submission_subscription_items',
936 [
937 $this->subscriptionItems,
938 $this->submissionData,
939 $this->form
940 ],
941 FLUENTFORM_FRAMEWORK_UPGRADE,
942 'fluentform/submission_subscription_items',
943 'Use fluentform/submission_subscription_items instead of fluentform_submission_subscription_items.'
944 );
945 $this->subscriptionItems = apply_filters('fluentform/submission_subscription_items', $this->subscriptionItems, $this->submissionData, $this->form);
946
947 return $this->subscriptionItems;
948 }
949
950 private function checkForExistingSubmission()
951 {
952 $entryUid = ArrayHelper::get($this->submissionData, 'response.__entry_intermediate_hash');
953
954 if (!$entryUid) {
955 return false;
956 }
957
958 $meta = SubmissionMeta::where('meta_key', '__entry_intermediate_hash')
959 ->where('value', $entryUid)
960 ->where('form_id', $this->form->id)
961 ->first();
962
963 if (!$meta) {
964 return false;
965 }
966
967 $submission = Submission::find($meta->response_id);
968
969 if ($submission && ($submission->payment_status == 'failed' || $submission->payment_status == 'pending' || $submission->payment_status == 'draft')) {
970 // A settled charge means the earlier attempt went through after the error
971 // was shown; reusing the row would delete that ledger entry.
972 $hasPaidOrProcessingCharge = Transaction::where('submission_id', $submission->id)
973 ->whereIn('status', ['paid', 'processing'])
974 ->exists();
975 if ($hasPaidOrProcessingCharge) {
976 return new \WP_Error('payment_retry_blocked');
977 }
978
979 return $submission;
980 }
981
982 return false;
983 }
984
985 /**
986 * Update a retry in place while retaining its transaction rows for
987 * processor reuse and delayed webhook settlement.
988 */
989 private function updateExistingSubmission($existingSubmission, $submission)
990 {
991 $submissionId = $existingSubmission->id;
992 Submission::where('id', $submissionId)->update($submission);
993 Transaction::where('submission_id', $submissionId)
994 ->where('status', 'failed')
995 ->delete();
996
997 return $submissionId;
998 }
999
1000 private function insertOrderItems($items, $existing = false)
1001 {
1002 if (!$existing) {
1003 foreach ($items as $item) {
1004 OrderItem::create($item);
1005 }
1006 return true;
1007 }
1008
1009 if (!$items && $existing) {
1010 OrderItem::where('submission_id', $existing->id)->delete();
1011 return true;
1012 }
1013
1014 $exitingItems = OrderItem::where('submission_id', $existing->id)->get();
1015
1016 if (!$exitingItems || count($exitingItems) === 0) {
1017 foreach ($items as $item) {
1018 OrderItem::create($item);
1019 }
1020 return true;
1021 }
1022
1023 $existingHashes = [];
1024 foreach ($exitingItems as $exitingItem) {
1025 $hash = md5($exitingItem->type . ':' . $exitingItem->parent_holder . ':' . $exitingItem->item_name . ':' . $exitingItem->quantity . ':' . $exitingItem->item_price);
1026 $existingHashes[$exitingItem->id] = $hash;
1027 }
1028
1029 $verifiedIds = [];
1030 $newIds = [];
1031 foreach ($items as $item) {
1032 $hash = md5($item['type'] . ':' . $item['parent_holder'] . ':' . $item['item_name'] . ':' . $item['quantity'] . ':' . $item['item_price']);
1033 if (in_array($hash, $existingHashes)) {
1034 // already exist no need to add
1035 $verifiedIds[] = array_search($hash, $existingHashes);
1036 } else {
1037 $newId = OrderItem::create($item)->id;
1038 $verifiedIds[] = $newId;
1039 $newIds[] = $newId;
1040 }
1041 }
1042
1043 if ($verifiedIds) {
1044 // SECURITY (PRO-06): scope this stale-item cleanup to the current submission; the
1045 // unscoped whereNotIn deleted every other submission's order_items site-wide (and
1046 // fired on ordinary payment retries — a live data-loss bug).
1047 OrderItem::where('submission_id', $existing->id)
1048 ->whereNotIn('id', $verifiedIds)
1049 ->delete();
1050 }
1051
1052 return true;
1053 }
1054
1055 private function getCustomSubscriptionAmount($data, $name, $planKey)
1056 {
1057 $amount = ArrayHelper::get($data, $name . '_custom_' . $planKey) ?: 0;
1058
1059 // Past PHP_INT_MAX cents, convertToCents() returns 0 or wraps negative, which drops the plan and leaves the order unpaid.
1060 if (abs((float) $amount) >= PHP_INT_MAX / 100) {
1061 wp_send_json([
1062 'errors' => [__('This subscription plan value is invalid', 'fluentform')]
1063 ], 423);
1064 }
1065
1066 return $amount;
1067 }
1068
1069 private function validateSubscriptionInputs()
1070 {
1071 $subscriptionInputs = $this->subscriptionInputs;
1072 if (!$subscriptionInputs) {
1073 return;
1074 }
1075 $data = $this->submissionData['response'];
1076
1077 $discountCodes = $this->discountCodes;
1078
1079 foreach ($subscriptionInputs as $inputIndex => $subscriptionInput) {
1080 $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
1081 $quantity = $this->getQuantity($name);
1082
1083 if (!$quantity) {
1084 continue;
1085 }
1086
1087 if (!$name) {
1088 continue;
1089 }
1090
1091 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
1092
1093 $planKey = $this->resolvePlanKey($subscriptionInput, $subscriptionOptions);
1094
1095 if (is_null($planKey)) {
1096 continue;
1097 }
1098
1099 $plan = ArrayHelper::get($subscriptionOptions, $planKey);
1100
1101 if (!$plan) {
1102 continue;
1103 }
1104
1105 if ($discountCodes) {
1106
1107 }
1108
1109 if (ArrayHelper::get($plan, 'has_trial_days') == 'yes' && ArrayHelper::get($plan, 'trial_days')) {
1110 continue; // this is a valid subscription
1111 }
1112
1113 if (ArrayHelper::get($plan, 'bill_times') != 1) {
1114 continue;
1115 }
1116
1117 // We have bill times 1 so we have to remove this and push to hooked inputs and later merged to payment inputs
1118
1119 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
1120 $plan['subscription_amount'] = $this->getCustomSubscriptionAmount($data, $name, $planKey);
1121 }
1122
1123 $amount = PaymentHelper::convertToCents($plan['subscription_amount']);
1124
1125 // Bypasses pushItem()'s positive-price guard, so a negative custom amount would
1126 // otherwise become a line item that drags the order total down. Checked before
1127 // the signup fee so the fee cannot mask it.
1128 if ($amount < 0) {
1129 continue;
1130 }
1131
1132 if (ArrayHelper::get($plan, 'has_signup_fee') === 'yes' && ArrayHelper::get($plan, 'signup_fee')) {
1133 $amount += PaymentHelper::convertToCents($plan['signup_fee']);
1134 }
1135
1136 $this->hookedOrderItems[] = [
1137 'type' => 'single',
1138 'form_id' => $this->form->id,
1139 'parent_holder' => $name,
1140 'item_name' => ArrayHelper::get($subscriptionInput, 'admin_label') . ' (' . $plan['name'] . ')',
1141 'item_price' => $amount,
1142 'quantity' => $quantity,
1143 'line_total' => $quantity * $amount,
1144 'created_at' => current_time('mysql'),
1145 'updated_at' => current_time('mysql'),
1146 ];
1147
1148 unset($this->subscriptionInputs[$inputIndex]);
1149 }
1150
1151 }
1152
1153 protected function applyDiscountCodes()
1154 {
1155 if (!$this->discountCodes) {
1156 return false;
1157 }
1158
1159 $orderItems = $this->getOrderItems(true);
1160
1161
1162 $subTotal = array_sum(array_column($orderItems, 'line_total')) / 100;
1163
1164 $subscriptionItems = $this->getSubscriptionItems();
1165
1166 $subInitialTotal = 0;
1167 foreach ($subscriptionItems as $subscriptionItem) {
1168 if ($subscriptionItem['trial_days']) {
1169 continue; // it's a trial
1170 }
1171 $subInitialTotal += $subscriptionItem['recurring_amount'] + $subscriptionItem['initial_amount'];
1172 }
1173
1174 $grandTotal = $subTotal;
1175 if ($subInitialTotal) {
1176 $grandTotal += ($subInitialTotal / 100);
1177 }
1178
1179 $fixedAmountApplied = 0; // in cents
1180
1181 if (Helper::hasPro() && class_exists('FluentFormPro\Payments\Classes\CouponModel')) {
1182 $couponModel = new \FluentFormPro\Payments\Classes\CouponModel();
1183 $this->discountCodes = $couponModel->getValidCoupons($this->discountCodes, $this->form->id, $grandTotal);
1184 } else {
1185 $this->discountCodes = [];
1186 }
1187
1188 foreach ($this->discountCodes as $coupon) {
1189 $discountAmount = $coupon->amount;
1190 if ($coupon->coupon_type == 'percent') {
1191 $discountAmount = (floatval($coupon->amount) / 100) * $subTotal;
1192 } else {
1193 if ($subTotal >= $discountAmount) {
1194 $fixedAmountApplied += $discountAmount;
1195 } else {
1196 $discountAmount = $subTotal;
1197 $fixedAmountApplied += $subTotal;
1198 }
1199 }
1200
1201 $this->pushItem([
1202 'parent_holder' => ArrayHelper::get($this->couponField, 'attributes.name'),
1203 'item_name' => $coupon->title,
1204 'item_price' => $discountAmount, // this is not cent. We convert to cent at pushItem method
1205 'quantity' => 1,
1206 'type' => 'discount'
1207 ]);
1208
1209 $subTotal = $subTotal - $discountAmount;
1210 }
1211
1212
1213 // let's convert to cents now as all subscriptions calculations are on cents
1214 $fixedAmountApplied = intval($fixedAmountApplied * 100);
1215
1216 if (!$subscriptionItems) {
1217 return true;
1218 }
1219
1220 $fixedMaxTotal = 0;
1221 $hasFixedDiscounts = false;
1222 foreach ($this->discountCodes as $discountCode) {
1223 if($discountCode->coupon_type == 'fixed') {
1224 $fixedMaxTotal += intval($coupon->amount * 100);
1225 $hasFixedDiscounts = true;
1226 }
1227 }
1228
1229 $fixedMaxTotal = $fixedMaxTotal - $fixedAmountApplied;
1230
1231 foreach ($subscriptionItems as $subIndex => $subscriptionItem) {
1232 $recurringAmount = $subscriptionItem['recurring_amount'];
1233 $signupFee = 0;
1234 if ($subscriptionItem['initial_amount']) {
1235 $signupFee = $subscriptionItem['initial_amount'];
1236 }
1237 // Let's process the percentile discounts first
1238 foreach ($this->discountCodes as $coupon) {
1239 $discountAmount = $coupon->amount;
1240 if ($coupon->coupon_type == 'percent') {
1241 $discountRecurringAmount = floatval((floatval($discountAmount) / 100) * $recurringAmount);
1242 $recurringAmount -= $discountRecurringAmount;
1243 if ($signupFee) {
1244 $discountSignupDiscountAmount = floatval((floatval($discountAmount) / 100) * $signupFee);
1245 $signupFee -= $discountSignupDiscountAmount;
1246 }
1247 }
1248 }
1249
1250 if($hasFixedDiscounts && $fixedMaxTotal > 0) {
1251 if($fixedMaxTotal >= $subInitialTotal) {
1252 $recurringAmount = 0;
1253 $signupFee = 0;
1254 } else {
1255 $recurringAmount = $recurringAmount - ($fixedMaxTotal / $subInitialTotal) * $recurringAmount;
1256 if($signupFee > 0) {
1257 $signupFee = $signupFee - ($fixedMaxTotal / $subInitialTotal) * $signupFee;
1258 }
1259 }
1260 }
1261
1262 $subscriptionItems[$subIndex]['recurring_amount'] = intval($recurringAmount);
1263 $subscriptionItems[$subIndex]['initial_amount'] = intval($signupFee);
1264
1265 $originalPlan = Helper::safeUnserialize($subscriptionItem['original_plan']);
1266
1267 $originalPlan['subscription_amount'] = round($recurringAmount / 100, 2);
1268 $originalPlan['signup_fee'] = round($recurringAmount / 100, 2);
1269 $subscriptionItems[$subIndex]['original_plan'] = maybe_serialize($originalPlan);
1270 }
1271
1272 $this->subscriptionItems = $subscriptionItems;
1273 return true;
1274 }
1275 }
1276