PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.12
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.12
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 / Classes / PaymentAction.php

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

987 lines 36.9 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 public function __construct($form, $insertData, $data)
56 {
57 $this->form = $form;
58 $this->data = $data;
59 $this->setSubmissionData($insertData);
60 $this->setupData();
61 }
62
63 private function setSubmissionData($insertData)
64 {
65 $insertData = (array)$insertData;
66 $insertData['response'] = json_decode($insertData['response'], true);
67 $this->submissionData = $insertData;
68 }
69
70 private function setupData()
71 {
72 $formFields = FormFieldsParser::getPaymentFields($this->form, ['admin_label', 'attributes', 'settings']);
73
74 $paymentInputElements = ['custom_payment_component', 'multi_payment_component'];
75 $quantityItems = [];
76 $paymentInputs = [];
77 $subscriptionInputs = [];
78 $paymentMethod = false;
79 $couponField = false;
80 foreach ($formFields as $fieldKey => $field) {
81 $element = ArrayHelper::get($field, 'element');
82 if (in_array($element, $paymentInputElements)) {
83 $paymentInputs[$fieldKey] = $field;
84 } else if ($element == 'item_quantity_component' || $element == 'rangeslider') {
85 if ('rangeslider' == $element && 'yes' != ArrayHelper::get($field, 'settings.enable_target_product')) {
86 continue;
87 }
88 if ($targetProductName = ArrayHelper::get($field, 'settings.target_product')) {
89 $quantityItems[$targetProductName] = ArrayHelper::get($field, 'attributes.name');
90 }
91 } else if ($element == 'payment_method') {
92 $paymentMethod = $field;
93 } else if ($element == 'payment_coupon' && Helper::hasPro()) {
94 $couponField = $field;
95 } else if ($element === 'subscription_payment_component') {
96 $subscriptionInputs[$fieldKey] = $field;
97 }
98 }
99
100 $this->paymentInputs = $paymentInputs;
101 $this->quantityItems = $quantityItems;
102 $this->subscriptionInputs = $subscriptionInputs;
103
104 if ($paymentMethod) {
105 $this->methodField = $paymentMethod;
106 if ($this->isConditionPass()) {
107 $methodName = ArrayHelper::get($paymentMethod, 'attributes.name');
108 $this->selectedPaymentMethod = ArrayHelper::get($this->data, $methodName);
109 $this->methodSettings = ArrayHelper::get($paymentMethod, 'settings.payment_methods.' . $this->selectedPaymentMethod);
110 }
111 }
112
113 if ($couponField && $this->isCouponFieldVisible($couponField)) {
114 $couponCodes = ArrayHelper::get($this->data, '__ff_all_applied_coupons', '');
115 if ($couponCodes) {
116 $couponCodes = \json_decode($couponCodes, true);
117 if ($couponCodes && class_exists('FluentFormPro\Payments\Classes\CouponModel')) {
118 $couponCodes = array_unique($couponCodes);
119 $this->discountCodes = (new \FluentFormPro\Payments\Classes\CouponModel())->getCouponsByCodes($couponCodes);
120 $this->couponField = $couponField;
121 }
122 }
123 }
124
125 if ($this->subscriptionInputs) {
126 // Maybe we have subscription items with bill times = 1
127 // Or if we have discount codes then we have to apply the discount codes
128 $this->validateSubscriptionInputs();
129 }
130
131 $this->applyDiscountCodes();
132 }
133
134 public function isConditionPass()
135 {
136 $conditionSettings = ArrayHelper::get($this->methodField, 'settings.conditional_logics', []);
137 if (
138 !$conditionSettings ||
139 !ArrayHelper::isTrue($conditionSettings, 'status')
140 ) {
141 return true;
142 }
143
144 $conditionFeed = ['conditionals' => $conditionSettings];
145 return ConditionAssesor::evaluate($conditionFeed, $this->data);
146 }
147
148 public function isFieldConditionPass($field)
149 {
150 $conditionSettings = ArrayHelper::get($field, 'settings.conditional_logics', []);
151 if (
152 !$conditionSettings ||
153 !ArrayHelper::isTrue($conditionSettings, 'status')
154 ) {
155 return true;
156 }
157
158 $conditionFeed = ['conditionals' => $conditionSettings];
159 return ConditionAssesor::evaluate($conditionFeed, $this->data);
160 }
161
162 /**
163 * Visible only when the coupon field's own conditions and every ancestor
164 * container's conditions pass — so a coupon inside a hidden container is
165 * not honored.
166 */
167 public function isCouponFieldVisible($couponField)
168 {
169 if (!$this->isFieldConditionPass($couponField)) {
170 return false;
171 }
172
173 $formFields = $this->form->form_fields;
174 if (is_string($formFields)) {
175 $formFields = json_decode($formFields, true);
176 }
177 $couponName = ArrayHelper::get($couponField, 'attributes.name');
178 $ancestors = $this->getFieldAncestorContainers(ArrayHelper::get($formFields, 'fields', []), $couponName);
179
180 foreach ((array) $ancestors as $container) {
181 if (!$this->isFieldConditionPass($container)) {
182 return false;
183 }
184 }
185
186 return true;
187 }
188
189 /**
190 * Return the ancestor container fields wrapping $targetName (containers nest
191 * children under columns[].fields[]), or null if not found in this branch.
192 */
193 public function getFieldAncestorContainers($fields, $targetName, $ancestors = [])
194 {
195 foreach ($fields as $field) {
196 if (ArrayHelper::get($field, 'attributes.name') === $targetName) {
197 return $ancestors;
198 }
199 foreach (ArrayHelper::get($field, 'columns', []) as $column) {
200 $found = $this->getFieldAncestorContainers(
201 ArrayHelper::get($column, 'fields', []),
202 $targetName,
203 array_merge($ancestors, [$field])
204 );
205 if (!is_null($found)) {
206 return $found;
207 }
208 }
209 }
210
211 return null;
212 }
213
214 public function draftFormEntry()
215 {
216 // Record Payment Items
217 $subscriptionItems = $this->getSubscriptionItems();
218
219 if (count($subscriptionItems) >= 2) {
220 // We are not supporting multiple subscription items at this moment
221 wp_send_json_error([
222 'message' => __('Sorry, multiple subscription item is not supported', 'fluentform')
223 ]);
224 }
225
226 $items = $this->getOrderItems();
227
228 $existingSubmission = $this->checkForExistingSubmission();
229
230 $formSettings = PaymentHelper::getFormSettings($this->form->id, 'public');
231 $submission = $this->submissionData;
232 $submission['payment_status'] = 'pending';
233 $submission['payment_method'] = $this->selectedPaymentMethod;
234 $submission['payment_type'] = $this->getPaymentType();
235 $submission['currency'] = $formSettings['currency'];
236 $submission['response'] = json_encode($submission['response']);
237 $submission['payment_total'] = $this->getCalculatedAmount();
238 $submission = apply_filters_deprecated(
239 'fluentform_with_payment_submission_data',
240 [
241 $submission,
242 $this->form
243 ],
244 FLUENTFORM_FRAMEWORK_UPGRADE,
245 'fluentform/payment_submission_data',
246 'Use fluentform/payment_submission_data instead of fluentform_with_payment_submission_data.'
247 );
248 $submission = apply_filters('fluentform/payment_submission_data', $submission, $this->form);
249
250 if ($existingSubmission) {
251 $insertId = $existingSubmission->id;
252 Submission::where('id', $insertId)->update($submission);
253
254 // delete the existing transactions here if any
255 Transaction::where('submission_id', $insertId)->delete();
256 } else {
257 $insertId = Submission::create($submission)->id;
258 $uidHash = md5(wp_generate_uuid4() . $insertId);
259 Helper::setSubmissionMeta($insertId, '_entry_uid_hash', $uidHash, $this->form->id);
260 $intermediatePaymentHash = md5('payment_' . wp_generate_uuid4() . '_' . $insertId . '_' . $this->form->id);
261 Helper::setSubmissionMeta($insertId, '__entry_intermediate_hash', $intermediatePaymentHash, $this->form->id);
262 }
263
264 $submission['id'] = $insertId;
265 $this->setSubmissionData($submission);
266 $this->submissionId = $insertId;
267
268
269 $paymentTotal = 0;
270 if ($items) {
271 foreach ($items as $index => $item) {
272 if ($item['type'] == 'discount') {
273 $paymentTotal -= $item['line_total'];
274 } else {
275 $paymentTotal += $item['line_total'];
276 }
277 $items[$index]['submission_id'] = $insertId;
278 $items[$index]['form_id'] = $submission['form_id'];
279 }
280 }
281
282 $this->insertOrderItems($items, $existingSubmission);
283
284 $subsTotal = 0;
285 if ($subscriptionItems && $existingSubmission) {
286 Subscription::where('submission_id', $existingSubmission->id)->delete();
287 }
288
289 foreach ($subscriptionItems as $subscriptionItem) {
290 $quantity = isset($subscriptionItem['quantity']) ? $subscriptionItem['quantity'] : 1;
291 $linePrice = $subscriptionItem['recurring_amount'] * $quantity;
292 $subsTotal += intval($linePrice);
293 $subscriptionItem['submission_id'] = $insertId;
294 Subscription::create($subscriptionItem);
295 }
296
297 do_action('fluentform/notify_on_form_submit', $this->submissionId, $this->submissionData['response'], $this->form);
298
299 $totalPayable = $paymentTotal + $subsTotal;
300
301 // We should make a transaction for subscription
302
303 if ($this->selectedPaymentMethod) {
304 Helper::setSubmissionMeta($insertId, '_selected_payment_method', $this->selectedPaymentMethod);
305 do_action_deprecated(
306 'fluentform_process_payment',
307 [
308 $this->submissionId,
309 $this->submissionData,
310 $this->form,
311 $this->methodSettings,
312 !!$subscriptionItems,
313 $totalPayable
314 ],
315 FLUENTFORM_FRAMEWORK_UPGRADE,
316 'fluentform/process_payment',
317 'Use fluentform/process_payment instead of fluentform_process_payment.'
318 );
319 do_action('fluentform/process_payment', $this->submissionId, $this->submissionData, $this->form, $this->methodSettings, !!$subscriptionItems, $totalPayable);
320
321 do_action_deprecated(
322 'fluentform_process_payment_' . $this->selectedPaymentMethod,
323 [
324 $this->submissionId,
325 $this->submissionData,
326 $this->form,
327 $this->methodSettings,
328 !!$subscriptionItems,
329 $totalPayable
330 ],
331 FLUENTFORM_FRAMEWORK_UPGRADE,
332 'fluentform/process_payment_' . $this->selectedPaymentMethod,
333 'Use fluentform/process_payment_' . $this->selectedPaymentMethod . ' instead of fluentform_process_payment_' . $this->selectedPaymentMethod
334 );
335 do_action('fluentform/process_payment_' . $this->selectedPaymentMethod, $this->submissionId, $this->submissionData, $this->form, $this->methodSettings, !!$subscriptionItems, $totalPayable);
336 }
337
338 /*
339 * The following code will run only if no payment method catch and process the payment
340 * In the payment method, ideally they will send the response. But if no payment method exist then
341 * we will handle here
342 */
343 $submission = Submission::find($insertId);
344
345 $returnData = (new SubmissionHandlerService())->processSubmissionData(
346 $submission->id, $this->submissionData['response'], $this->form
347 );
348
349 wp_send_json_success($returnData, 200);
350 }
351
352 public function getOrderItems($forced = false)
353 {
354 if ($forced) {
355 $this->orderItems = [];
356 }
357
358 if ($this->orderItems) {
359 return $this->orderItems;
360 }
361
362 $paymentInputs = $this->paymentInputs;
363
364 if (!$paymentInputs && !$this->hookedOrderItems) {
365 return [];
366 }
367
368 $data = $this->submissionData['response'];
369
370 foreach ($paymentInputs as $paymentInput) {
371 $name = ArrayHelper::get($paymentInput, 'attributes.name');
372 if (!$name || !isset($data[$name])) {
373 continue;
374 }
375 $price = 0;
376 $inputType = ArrayHelper::get($paymentInput, 'attributes.type');
377
378 if (!$data[$name]) {
379 continue;
380 }
381
382 if ($inputType == 'number') {
383 $price = $data[$name];
384 } else if ($inputType == 'single') {
385 $price = ArrayHelper::get($paymentInput, 'attributes.value');
386 if (ArrayHelper::get($paymentInput, 'settings.dynamic_default_value')) {
387 $price = $data[$name];
388 }
389 } else if ($inputType == 'radio' || $inputType == 'select') {
390 $item = $this->getItemFromVariables($paymentInput, $data[$name]);
391 if ($item) {
392 $quantity = $this->getQuantity($item['parent_holder']);
393 if (!$quantity) {
394 continue;
395 }
396 $item['quantity'] = $quantity;
397 $this->pushItem($item);
398 }
399 continue;
400 } else if (ArrayHelper::get($paymentInput, 'attributes.type') == 'checkbox') {
401 $selectedItems = $data[$name];
402 foreach ($selectedItems as $selectedItem) {
403 $item = $this->getItemFromVariables($paymentInput, $selectedItem);
404 if ($item) {
405 $quantity = $this->getQuantity($item['parent_holder']);
406 if (!$quantity) {
407 continue;
408 }
409 $item['quantity'] = $quantity;
410 $this->pushItem($item);
411 }
412 }
413 continue;
414 }
415
416 if (!is_numeric($price) || !$price) {
417 continue;
418 }
419
420 $productName = ArrayHelper::get($paymentInput, 'attributes.name');
421 $quantity = $this->getQuantity($productName);
422 if (!$quantity) {
423 continue;
424 }
425
426 $this->pushItem([
427 'parent_holder' => $productName,
428 'item_name' => ArrayHelper::get($paymentInput, 'admin_label'),
429 'item_price' => $price,
430 'quantity' => $quantity
431 ]);
432 }
433
434 // We may have initial amount from the subscription
435 if ($this->hookedOrderItems) {
436 $this->orderItems = array_merge($this->orderItems, $this->hookedOrderItems);
437 }
438
439 $this->orderItems = apply_filters_deprecated(
440 'fluentform_submission_order_items',
441 [
442 $this->orderItems,
443 $this->submissionData,
444 $this->form
445 ],
446 FLUENTFORM_FRAMEWORK_UPGRADE,
447 'fluentform/submission_order_items',
448 'Use fluentform/submission_order_items instead of fluentform_submission_order_items.'
449 );
450
451 $this->orderItems = apply_filters('fluentform/submission_order_items', $this->orderItems, $this->submissionData, $this->form, $this->selectedPaymentMethod);
452
453 return $this->orderItems;
454 }
455
456 private function getQuantity($productName)
457 {
458 $quantity = 1;
459 if (!$this->quantityItems) {
460 return $quantity;
461 }
462 if (!isset($this->quantityItems[$productName])) {
463 return $quantity;
464 }
465 $inputName = $this->quantityItems[$productName];
466 $quantity = ArrayHelper::get($this->submissionData['response'], $inputName);
467 if (!$quantity) {
468 return 0;
469 }
470 // SECURITY (FINDING-22): clamp a user-supplied quantity to a non-negative integer so a
471 // negative quantity cannot flip a line total and subtract from the order.
472 return max(0, intval($quantity));
473 }
474
475 private function pushItem($data)
476 {
477 // SECURITY (FINDING-22): reject non-positive prices. A user-controlled "name your price"
478 // / donation amount (or a dynamic-default numeric field) is otherwise taken verbatim, and
479 // a negative value subtracts from the order total — forcing it to exactly 0 makes
480 // maybeHandlePayment() skip the gateway entirely, yielding a free fulfilled order.
481 if (!is_numeric($data['item_price']) || floatval($data['item_price']) <= 0) {
482 return;
483 }
484 $data['item_price'] = floatval($data['item_price'] * 100);
485
486 $defaults = [
487 'type' => 'single',
488 'form_id' => $this->form->id,
489 'quantity' => !empty($data['quantity']) ? $data['quantity'] : 1,
490 'created_at' => current_time('mysql'),
491 'updated_at' => current_time('mysql')
492 ];
493
494 $item = wp_parse_args($data, $defaults);
495
496 $item['line_total'] = $item['item_price'] * $item['quantity'];
497
498 if (!$this->orderItems) {
499 $this->orderItems = [];
500 }
501
502 $this->orderItems[] = $item;
503 }
504
505 private function getItemFromVariables($item, $key)
506 {
507 $elementName = $item['element'];
508 $pricingOptions = ArrayHelper::get($item, 'settings.pricing_options');
509 $pricingOptions = apply_filters_deprecated(
510 'fluentform_payment_field_' . $elementName . '_pricing_options',
511 [
512 $pricingOptions,
513 $item,
514 $this->form
515 ],
516 FLUENTFORM_FRAMEWORK_UPGRADE,
517 'fluentform/payment_field_' . $elementName . '_pricing_options',
518 'Use fluentform/payment_field_' . $elementName . '_pricing_options instead of fluentform_payment_field_' . $elementName . '_pricing_options.'
519 );
520 $pricingOptions = apply_filters('fluentform/payment_field_' . $elementName . '_pricing_options', $pricingOptions, $item, $this->form);
521
522 $selectedOption = [];
523 foreach ($pricingOptions as $priceOption) {
524 $label = sanitize_text_field($priceOption['label']);
525 $value = sanitize_text_field($priceOption['value']);
526 if ($label == $key || $value == $key) {
527 $selectedOption = $priceOption;
528 }
529 }
530
531 if (!$selectedOption || empty($selectedOption['value']) || !is_numeric($selectedOption['value'])) {
532 return false;
533 }
534
535 return [
536 'parent_holder' => ArrayHelper::get($item, 'attributes.name'),
537 'item_name' => $selectedOption['label'],
538 'item_price' => $selectedOption['value']
539 ];
540 }
541
542 public function getCalculatedAmount()
543 {
544 $items = $this->getOrderItems();
545
546 $total = 0;
547 foreach ($items as $item) {
548 if ($item['type'] == 'discount') {
549 $total -= $item['line_total'];
550 } else {
551 $total += $item['line_total'];
552 }
553 }
554 return $total;
555 }
556
557 public function getPaymentType()
558 {
559 return count($this->getSubscriptionItems()) ? 'subscription' : 'product'; // return value product|subscription|donation
560 }
561
562 private function getCurrency()
563 {
564 if ($this->currency !== null) {
565 return $this->currency;
566 }
567 $this->currency = 'usd';
568
569 return $this->currency;
570 }
571
572 public function getSubscriptionItems()
573 {
574 if ($this->subscriptionItems) {
575 return $this->subscriptionItems;
576 }
577
578 $data = $this->submissionData['response'];
579 $subscriptionInputs = $this->subscriptionInputs;
580
581 if (!$subscriptionInputs) {
582 return [];
583 }
584
585 foreach ($subscriptionInputs as $subscriptionInput) {
586 $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
587 $quantity = $this->getQuantity($name);
588
589 if (!$name || !isset($data[$name]) || $quantity === 0) {
590 continue;
591 }
592
593 $label = ArrayHelper::get($subscriptionInput, 'settings.label', $name);
594
595 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
596
597 $plan = $subscriptionOptions[$data[$name]];
598
599 if (!$plan) {
600 continue;
601 }
602
603 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
604 $plan['subscription_amount'] = ArrayHelper::get($data, $name . '_custom_' . $data[$name]);
605 $plan['subscription_amount'] = $plan['subscription_amount'] ?: 0;
606 }
607
608 $noTrial = ArrayHelper::get($plan, 'has_trial_days') === 'no' ||
609 !ArrayHelper::get($plan, 'trial_days');
610
611 if (!$plan['subscription_amount'] && $noTrial) {
612 continue;
613 }
614
615 if (ArrayHelper::get($plan, 'bill_times') == 1 && ArrayHelper::get($plan, 'has_trial_days') != 'yes') {
616 // Since the billing times is 1 and no trial days,
617 // the subscription acts like as an one time payment.
618 // We'll convert this as a payment item.
619 $signupFee = 0;
620
621 if ($plan['has_signup_fee'] === 'yes') {
622 $signupFee = PaymentHelper::convertToCents($plan['signup_fee']);
623 }
624
625 $onetimeTotal = $signupFee + PaymentHelper::convertToCents($plan['subscription_amount']);
626
627 $this->pushItem([
628 'parent_holder' => $name,
629 'item_name' => $label,
630 'quantity' => $quantity,
631 'item_price' => $onetimeTotal,
632 'line_total' => $quantity * $onetimeTotal,
633 'created_at' => current_time('mysql'),
634 'updated_at' => current_time('mysql')
635 ]);
636 } else {
637 $billTimes = (isset($plan['bill_times'])) ? $plan['bill_times'] : 0;
638
639 // If end date is set, dynamically calculate bill_times from today
640 if (
641 ArrayHelper::get($plan, 'has_end_date') === 'yes'
642 && ($endDateStr = ArrayHelper::get($plan, 'subscription_end_date'))
643 ) {
644 $endDate = strtotime($endDateStr . ' +1 day');
645 $now = current_time('timestamp');
646 if (!$endDate || $endDate <= $now) {
647 if (ArrayHelper::get($plan, 'expire_behavior') === 'hide') {
648 continue;
649 }
650 wp_send_json([
651 'errors' => [__('This subscription plan was expired', 'fluentform')]
652 ], 423);
653 }
654 $diffDays = max(1, ceil(($endDate - $now) / 86400));
655 $intervalMap = ['day' => 1, 'week' => 7, 'month' => 30, 'year' => 365];
656 $interval = isset($intervalMap[$plan['billing_interval']]) ? $intervalMap[$plan['billing_interval']] : 30;
657 $billTimes = max(1, ceil($diffDays / $interval));
658 }
659
660 $subscription = array(
661 'element_id' => $name,
662 'item_name' => $label,
663 'form_id' => $this->form->id,
664 'plan_name' => $plan['name'],
665 'billing_interval' => $plan['billing_interval'],
666 'trial_days' => 0,
667 'recurring_amount' => PaymentHelper::convertToCents($plan['subscription_amount']),
668 'bill_times' => $billTimes,
669 'initial_amount' => 0,
670 'status' => 'pending',
671 'original_plan' => maybe_serialize($plan),
672 'created_at' => current_time('mysql'),
673 'updated_at' => current_time('mysql'),
674 );
675
676 if (ArrayHelper::get($plan, 'has_signup_fee') === 'yes' && ArrayHelper::get($plan, 'signup_fee')) {
677 $subscription['initial_amount'] = PaymentHelper::convertToCents($plan['signup_fee']);
678 }
679
680 if (ArrayHelper::get($plan, 'has_trial_days') === 'yes' && ArrayHelper::get($plan, 'trial_days')) {
681 $subscription['trial_days'] = $plan['trial_days'];
682 $dateTime = current_datetime();
683 $localtime = $dateTime->getTimestamp() + $dateTime->getOffset();
684 $expirationDate = date('Y-m-d H:i:s', $localtime + absint($plan['trial_days']) * 86400);
685 $subscription['expiration_at'] = $expirationDate;
686 }
687
688 if ($quantity > 1) {
689 $subscription['quantity'] = $quantity;
690 }
691
692 $this->subscriptionItems[] = $subscription;
693 }
694 }
695 $this->subscriptionItems = apply_filters_deprecated(
696 'fluentform_submission_subscription_items',
697 [
698 $this->subscriptionItems,
699 $this->submissionData,
700 $this->form
701 ],
702 FLUENTFORM_FRAMEWORK_UPGRADE,
703 'fluentform/submission_subscription_items',
704 'Use fluentform/submission_subscription_items instead of fluentform_submission_subscription_items.'
705 );
706 $this->subscriptionItems = apply_filters('fluentform/submission_subscription_items', $this->subscriptionItems, $this->submissionData, $this->form);
707
708 return $this->subscriptionItems;
709 }
710
711 private function checkForExistingSubmission()
712 {
713 $entryUid = ArrayHelper::get($this->submissionData, 'response.__entry_intermediate_hash');
714
715 if (!$entryUid) {
716 return false;
717 }
718
719 $meta = SubmissionMeta::where('meta_key', '__entry_intermediate_hash')
720 ->where('value', $entryUid)
721 ->where('form_id', $this->form->id)
722 ->first();
723
724 if (!$meta) {
725 return false;
726 }
727
728 $submission = Submission::find($meta->response_id);
729
730 if ($submission && ($submission->payment_status == 'failed' || $submission->payment_status == 'pending' || $submission->payment_status == 'draft')) {
731 return $submission;
732 }
733
734 return false;
735 }
736
737 private function insertOrderItems($items, $existing = false)
738 {
739 if (!$existing) {
740 foreach ($items as $item) {
741 OrderItem::create($item);
742 }
743 return true;
744 }
745
746 if (!$items && $existing) {
747 OrderItem::where('submission_id', $existing->id)->delete();
748 return true;
749 }
750
751 $exitingItems = OrderItem::where('submission_id', $existing->id)->get();
752
753 if (!$exitingItems || count($exitingItems) === 0) {
754 foreach ($items as $item) {
755 OrderItem::create($item);
756 }
757 return true;
758 }
759
760 $existingHashes = [];
761 foreach ($exitingItems as $exitingItem) {
762 $hash = md5($exitingItem->type . ':' . $exitingItem->parent_holder . ':' . $exitingItem->item_name . ':' . $exitingItem->quantity . ':' . $exitingItem->item_price);
763 $existingHashes[$exitingItem->id] = $hash;
764 }
765
766 $verifiedIds = [];
767 $newIds = [];
768 foreach ($items as $item) {
769 $hash = md5($item['type'] . ':' . $item['parent_holder'] . ':' . $item['item_name'] . ':' . $item['quantity'] . ':' . $item['item_price']);
770 if (in_array($hash, $existingHashes)) {
771 // already exist no need to add
772 $verifiedIds[] = array_search($hash, $existingHashes);
773 } else {
774 $newId = OrderItem::create($item)->id;
775 $verifiedIds[] = $newId;
776 $newIds[] = $newId;
777 }
778 }
779
780 if ($verifiedIds) {
781 // SECURITY (PRO-06): scope this stale-item cleanup to the current submission; the
782 // unscoped whereNotIn deleted every other submission's order_items site-wide (and
783 // fired on ordinary payment retries — a live data-loss bug).
784 OrderItem::where('submission_id', $existing->id)
785 ->whereNotIn('id', $verifiedIds)
786 ->delete();
787 }
788
789 return true;
790 }
791
792 private function validateSubscriptionInputs()
793 {
794 $subscriptionInputs = $this->subscriptionInputs;
795 if (!$subscriptionInputs) {
796 return;
797 }
798 $data = $this->submissionData['response'];
799
800 $discountCodes = $this->discountCodes;
801
802 foreach ($subscriptionInputs as $inputIndex => $subscriptionInput) {
803 $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
804 $quantity = $this->getQuantity($name);
805
806 if (!$quantity) {
807 continue;
808 }
809
810 if (!$name || !isset($data[$name])) {
811 continue;
812 }
813
814 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
815
816 $plan = $subscriptionOptions[$data[$name]];
817
818 if (!$plan) {
819 continue;
820 }
821
822 if ($discountCodes) {
823
824 }
825
826 if (ArrayHelper::get($plan, 'has_trial_days') == 'yes' && ArrayHelper::get($plan, 'trial_days')) {
827 continue; // this is a valid subscription
828 }
829
830 if (ArrayHelper::get($plan, 'bill_times') != 1) {
831 continue;
832 }
833
834 // We have bill times 1 so we have to remove this and push to hooked inputs and later merged to payment inputs
835
836 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
837 $plan['subscription_amount'] = ArrayHelper::get($data, $name . '_custom_' . $data[$name]);
838 $plan['subscription_amount'] = $plan['subscription_amount'] ?: 0;
839 }
840
841 $amount = PaymentHelper::convertToCents($plan['subscription_amount']);
842
843 if (ArrayHelper::get($plan, 'has_signup_fee') === 'yes' && ArrayHelper::get($plan, 'signup_fee')) {
844 $amount += PaymentHelper::convertToCents($plan['signup_fee']);
845 }
846
847 $this->hookedOrderItems[] = [
848 'type' => 'single',
849 'form_id' => $this->form->id,
850 'parent_holder' => $name,
851 'item_name' => ArrayHelper::get($subscriptionInput, 'admin_label') . ' (' . $plan['name'] . ')',
852 'item_price' => $amount,
853 'quantity' => $quantity,
854 'line_total' => $quantity * $amount,
855 'created_at' => current_time('mysql'),
856 'updated_at' => current_time('mysql'),
857 ];
858
859 unset($this->subscriptionInputs[$inputIndex]);
860 }
861
862 }
863
864 protected function applyDiscountCodes()
865 {
866 if (!$this->discountCodes) {
867 return false;
868 }
869
870 $orderItems = $this->getOrderItems(true);
871
872
873 $subTotal = array_sum(array_column($orderItems, 'line_total')) / 100;
874
875 $subscriptionItems = $this->getSubscriptionItems();
876
877 $subInitialTotal = 0;
878 foreach ($subscriptionItems as $subscriptionItem) {
879 if ($subscriptionItem['trial_days']) {
880 continue; // it's a trial
881 }
882 $subInitialTotal += $subscriptionItem['recurring_amount'] + $subscriptionItem['initial_amount'];
883 }
884
885 $grandTotal = $subTotal;
886 if ($subInitialTotal) {
887 $grandTotal += ($subInitialTotal / 100);
888 }
889
890 $fixedAmountApplied = 0; // in cents
891
892 if (Helper::hasPro() && class_exists('FluentFormPro\Payments\Classes\CouponModel')) {
893 $couponModel = new \FluentFormPro\Payments\Classes\CouponModel();
894 $this->discountCodes = $couponModel->getValidCoupons($this->discountCodes, $this->form->id, $grandTotal);
895 } else {
896 $this->discountCodes = [];
897 }
898
899 foreach ($this->discountCodes as $coupon) {
900 $discountAmount = $coupon->amount;
901 if ($coupon->coupon_type == 'percent') {
902 $discountAmount = (floatval($coupon->amount) / 100) * $subTotal;
903 } else {
904 if ($subTotal >= $discountAmount) {
905 $fixedAmountApplied += $discountAmount;
906 } else {
907 $discountAmount = $subTotal;
908 $fixedAmountApplied += $subTotal;
909 }
910 }
911
912 $this->pushItem([
913 'parent_holder' => ArrayHelper::get($this->couponField, 'attributes.name'),
914 'item_name' => $coupon->title,
915 'item_price' => $discountAmount, // this is not cent. We convert to cent at pushItem method
916 'quantity' => 1,
917 'type' => 'discount'
918 ]);
919
920 $subTotal = $subTotal - $discountAmount;
921 }
922
923
924 // let's convert to cents now as all subscriptions calculations are on cents
925 $fixedAmountApplied = intval($fixedAmountApplied * 100);
926
927 if (!$subscriptionItems) {
928 return true;
929 }
930
931 $fixedMaxTotal = 0;
932 $hasFixedDiscounts = false;
933 foreach ($this->discountCodes as $discountCode) {
934 if($discountCode->coupon_type == 'fixed') {
935 $fixedMaxTotal += intval($coupon->amount * 100);
936 $hasFixedDiscounts = true;
937 }
938 }
939
940 $fixedMaxTotal = $fixedMaxTotal - $fixedAmountApplied;
941
942 foreach ($subscriptionItems as $subIndex => $subscriptionItem) {
943 $recurringAmount = $subscriptionItem['recurring_amount'];
944 $signupFee = 0;
945 if ($subscriptionItem['initial_amount']) {
946 $signupFee = $subscriptionItem['initial_amount'];
947 }
948 // Let's process the percentile discounts first
949 foreach ($this->discountCodes as $coupon) {
950 $discountAmount = $coupon->amount;
951 if ($coupon->coupon_type == 'percent') {
952 $discountRecurringAmount = floatval((floatval($discountAmount) / 100) * $recurringAmount);
953 $recurringAmount -= $discountRecurringAmount;
954 if ($signupFee) {
955 $discountSignupDiscountAmount = floatval((floatval($discountAmount) / 100) * $signupFee);
956 $signupFee -= $discountSignupDiscountAmount;
957 }
958 }
959 }
960
961 if($hasFixedDiscounts && $fixedMaxTotal > 0) {
962 if($fixedMaxTotal >= $subInitialTotal) {
963 $recurringAmount = 0;
964 $signupFee = 0;
965 } else {
966 $recurringAmount = $recurringAmount - ($fixedMaxTotal / $subInitialTotal) * $recurringAmount;
967 if($signupFee > 0) {
968 $signupFee = $signupFee - ($fixedMaxTotal / $subInitialTotal) * $signupFee;
969 }
970 }
971 }
972
973 $subscriptionItems[$subIndex]['recurring_amount'] = intval($recurringAmount);
974 $subscriptionItems[$subIndex]['initial_amount'] = intval($signupFee);
975
976 $originalPlan = Helper::safeUnserialize($subscriptionItem['original_plan']);
977
978 $originalPlan['subscription_amount'] = round($recurringAmount / 100, 2);
979 $originalPlan['signup_fee'] = round($recurringAmount / 100, 2);
980 $subscriptionItems[$subIndex]['original_plan'] = maybe_serialize($originalPlan);
981 }
982
983 $this->subscriptionItems = $subscriptionItems;
984 return true;
985 }
986 }
987