PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / trunk
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder vtrunk
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 trunk, at app/Modules/Payments/Classes/PaymentAction.php

1,228 lines 46.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 public function getPaymentType()
744 {
745 return count($this->getSubscriptionItems()) ? 'subscription' : 'product'; // return value product|subscription|donation
746 }
747
748 private function getCurrency()
749 {
750 if ($this->currency !== null) {
751 return $this->currency;
752 }
753 $this->currency = 'usd';
754
755 return $this->currency;
756 }
757
758 public function getSubscriptionItems()
759 {
760 if ($this->subscriptionItems) {
761 return $this->subscriptionItems;
762 }
763
764 $data = $this->submissionData['response'];
765 $subscriptionInputs = $this->subscriptionInputs;
766
767 if (!$subscriptionInputs) {
768 return [];
769 }
770
771 foreach ($subscriptionInputs as $subscriptionInput) {
772 $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
773 $quantity = $this->getQuantity($name);
774
775 if (!$name || $quantity === 0) {
776 continue;
777 }
778
779 $label = ArrayHelper::get($subscriptionInput, 'settings.label', $name);
780
781 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
782
783 $planKey = $this->resolvePlanKey($subscriptionInput, $subscriptionOptions);
784
785 if (is_null($planKey)) {
786 continue;
787 }
788
789 $plan = ArrayHelper::get($subscriptionOptions, $planKey);
790
791 if (!$plan) {
792 continue;
793 }
794
795 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
796 $plan['subscription_amount'] = $this->getCustomSubscriptionAmount($data, $name, $planKey);
797 }
798
799 $noTrial = ArrayHelper::get($plan, 'has_trial_days') === 'no' ||
800 !ArrayHelper::get($plan, 'trial_days');
801
802 if (!$plan['subscription_amount'] && $noTrial) {
803 continue;
804 }
805
806 if (ArrayHelper::get($plan, 'bill_times') == 1 && ArrayHelper::get($plan, 'has_trial_days') != 'yes') {
807 // Since the billing times is 1 and no trial days,
808 // the subscription acts like as an one time payment.
809 // We'll convert this as a payment item.
810 $signupFee = 0;
811
812 if ($plan['has_signup_fee'] === 'yes') {
813 $signupFee = PaymentHelper::convertToCents($plan['signup_fee']);
814 }
815
816 $onetimeTotal = $signupFee + PaymentHelper::convertToCents($plan['subscription_amount']);
817
818 $this->pushItem([
819 'parent_holder' => $name,
820 'item_name' => $label,
821 'quantity' => $quantity,
822 'item_price' => $onetimeTotal,
823 'line_total' => $quantity * $onetimeTotal,
824 'created_at' => current_time('mysql'),
825 'updated_at' => current_time('mysql')
826 ]);
827 } else {
828 $billTimes = (isset($plan['bill_times'])) ? $plan['bill_times'] : 0;
829
830 // If end date is set, dynamically calculate bill_times from today
831 if (
832 ArrayHelper::get($plan, 'has_end_date') === 'yes'
833 && ($endDateStr = ArrayHelper::get($plan, 'subscription_end_date'))
834 ) {
835 $endDate = strtotime($endDateStr . ' +1 day');
836 $now = current_time('timestamp');
837 if (!$endDate || $endDate <= $now) {
838 if (ArrayHelper::get($plan, 'expire_behavior') === 'hide') {
839 continue;
840 }
841 wp_send_json([
842 'errors' => [__('This subscription plan was expired', 'fluentform')]
843 ], 423);
844 }
845 $diffDays = max(1, ceil(($endDate - $now) / 86400));
846 $intervalMap = ['day' => 1, 'week' => 7, 'month' => 30, 'year' => 365];
847 $interval = isset($intervalMap[$plan['billing_interval']]) ? $intervalMap[$plan['billing_interval']] : 30;
848 $billTimes = max(1, ceil($diffDays / $interval));
849 }
850
851 $subscription = array(
852 'element_id' => $name,
853 'item_name' => $label,
854 'form_id' => $this->form->id,
855 'plan_name' => $plan['name'],
856 'billing_interval' => $plan['billing_interval'],
857 'trial_days' => 0,
858 'recurring_amount' => PaymentHelper::convertToCents($plan['subscription_amount']),
859 'bill_times' => $billTimes,
860 'initial_amount' => 0,
861 'status' => 'pending',
862 'original_plan' => maybe_serialize($plan),
863 'created_at' => current_time('mysql'),
864 'updated_at' => current_time('mysql'),
865 );
866
867 if (ArrayHelper::get($plan, 'has_signup_fee') === 'yes' && ArrayHelper::get($plan, 'signup_fee')) {
868 $subscription['initial_amount'] = PaymentHelper::convertToCents($plan['signup_fee']);
869 }
870
871 if (ArrayHelper::get($plan, 'has_trial_days') === 'yes' && ArrayHelper::get($plan, 'trial_days')) {
872 $subscription['trial_days'] = $plan['trial_days'];
873 $dateTime = current_datetime();
874 $localtime = $dateTime->getTimestamp() + $dateTime->getOffset();
875 $expirationDate = date('Y-m-d H:i:s', $localtime + absint($plan['trial_days']) * 86400);
876 $subscription['expiration_at'] = $expirationDate;
877 }
878
879 if ($quantity > 1) {
880 $subscription['quantity'] = $quantity;
881 }
882
883 $this->subscriptionItems[] = $subscription;
884 }
885 }
886 $this->subscriptionItems = apply_filters_deprecated(
887 'fluentform_submission_subscription_items',
888 [
889 $this->subscriptionItems,
890 $this->submissionData,
891 $this->form
892 ],
893 FLUENTFORM_FRAMEWORK_UPGRADE,
894 'fluentform/submission_subscription_items',
895 'Use fluentform/submission_subscription_items instead of fluentform_submission_subscription_items.'
896 );
897 $this->subscriptionItems = apply_filters('fluentform/submission_subscription_items', $this->subscriptionItems, $this->submissionData, $this->form);
898
899 return $this->subscriptionItems;
900 }
901
902 private function checkForExistingSubmission()
903 {
904 $entryUid = ArrayHelper::get($this->submissionData, 'response.__entry_intermediate_hash');
905
906 if (!$entryUid) {
907 return false;
908 }
909
910 $meta = SubmissionMeta::where('meta_key', '__entry_intermediate_hash')
911 ->where('value', $entryUid)
912 ->where('form_id', $this->form->id)
913 ->first();
914
915 if (!$meta) {
916 return false;
917 }
918
919 $submission = Submission::find($meta->response_id);
920
921 if ($submission && ($submission->payment_status == 'failed' || $submission->payment_status == 'pending' || $submission->payment_status == 'draft')) {
922 // A settled charge means the earlier attempt went through after the error
923 // was shown; reusing the row would delete that ledger entry.
924 $hasPaidOrProcessingCharge = Transaction::where('submission_id', $submission->id)
925 ->whereIn('status', ['paid', 'processing'])
926 ->exists();
927 if ($hasPaidOrProcessingCharge) {
928 return new \WP_Error('payment_retry_blocked');
929 }
930
931 return $submission;
932 }
933
934 return false;
935 }
936
937 /**
938 * Update a retry in place while retaining its transaction rows for
939 * processor reuse and delayed webhook settlement.
940 */
941 private function updateExistingSubmission($existingSubmission, $submission)
942 {
943 $submissionId = $existingSubmission->id;
944 Submission::where('id', $submissionId)->update($submission);
945 Transaction::where('submission_id', $submissionId)
946 ->where('status', 'failed')
947 ->delete();
948
949 return $submissionId;
950 }
951
952 private function insertOrderItems($items, $existing = false)
953 {
954 if (!$existing) {
955 foreach ($items as $item) {
956 OrderItem::create($item);
957 }
958 return true;
959 }
960
961 if (!$items && $existing) {
962 OrderItem::where('submission_id', $existing->id)->delete();
963 return true;
964 }
965
966 $exitingItems = OrderItem::where('submission_id', $existing->id)->get();
967
968 if (!$exitingItems || count($exitingItems) === 0) {
969 foreach ($items as $item) {
970 OrderItem::create($item);
971 }
972 return true;
973 }
974
975 $existingHashes = [];
976 foreach ($exitingItems as $exitingItem) {
977 $hash = md5($exitingItem->type . ':' . $exitingItem->parent_holder . ':' . $exitingItem->item_name . ':' . $exitingItem->quantity . ':' . $exitingItem->item_price);
978 $existingHashes[$exitingItem->id] = $hash;
979 }
980
981 $verifiedIds = [];
982 $newIds = [];
983 foreach ($items as $item) {
984 $hash = md5($item['type'] . ':' . $item['parent_holder'] . ':' . $item['item_name'] . ':' . $item['quantity'] . ':' . $item['item_price']);
985 if (in_array($hash, $existingHashes)) {
986 // already exist no need to add
987 $verifiedIds[] = array_search($hash, $existingHashes);
988 } else {
989 $newId = OrderItem::create($item)->id;
990 $verifiedIds[] = $newId;
991 $newIds[] = $newId;
992 }
993 }
994
995 if ($verifiedIds) {
996 // SECURITY (PRO-06): scope this stale-item cleanup to the current submission; the
997 // unscoped whereNotIn deleted every other submission's order_items site-wide (and
998 // fired on ordinary payment retries — a live data-loss bug).
999 OrderItem::where('submission_id', $existing->id)
1000 ->whereNotIn('id', $verifiedIds)
1001 ->delete();
1002 }
1003
1004 return true;
1005 }
1006
1007 private function getCustomSubscriptionAmount($data, $name, $planKey)
1008 {
1009 $amount = ArrayHelper::get($data, $name . '_custom_' . $planKey) ?: 0;
1010
1011 // Past PHP_INT_MAX cents, convertToCents() returns 0 or wraps negative, which drops the plan and leaves the order unpaid.
1012 if (abs((float) $amount) >= PHP_INT_MAX / 100) {
1013 wp_send_json([
1014 'errors' => [__('This subscription plan value is invalid', 'fluentform')]
1015 ], 423);
1016 }
1017
1018 return $amount;
1019 }
1020
1021 private function validateSubscriptionInputs()
1022 {
1023 $subscriptionInputs = $this->subscriptionInputs;
1024 if (!$subscriptionInputs) {
1025 return;
1026 }
1027 $data = $this->submissionData['response'];
1028
1029 $discountCodes = $this->discountCodes;
1030
1031 foreach ($subscriptionInputs as $inputIndex => $subscriptionInput) {
1032 $name = ArrayHelper::get($subscriptionInput, 'attributes.name');
1033 $quantity = $this->getQuantity($name);
1034
1035 if (!$quantity) {
1036 continue;
1037 }
1038
1039 if (!$name) {
1040 continue;
1041 }
1042
1043 $subscriptionOptions = ArrayHelper::get($subscriptionInput, 'settings.subscription_options');
1044
1045 $planKey = $this->resolvePlanKey($subscriptionInput, $subscriptionOptions);
1046
1047 if (is_null($planKey)) {
1048 continue;
1049 }
1050
1051 $plan = ArrayHelper::get($subscriptionOptions, $planKey);
1052
1053 if (!$plan) {
1054 continue;
1055 }
1056
1057 if ($discountCodes) {
1058
1059 }
1060
1061 if (ArrayHelper::get($plan, 'has_trial_days') == 'yes' && ArrayHelper::get($plan, 'trial_days')) {
1062 continue; // this is a valid subscription
1063 }
1064
1065 if (ArrayHelper::get($plan, 'bill_times') != 1) {
1066 continue;
1067 }
1068
1069 // We have bill times 1 so we have to remove this and push to hooked inputs and later merged to payment inputs
1070
1071 if (ArrayHelper::get($plan, 'user_input') === 'yes') {
1072 $plan['subscription_amount'] = $this->getCustomSubscriptionAmount($data, $name, $planKey);
1073 }
1074
1075 $amount = PaymentHelper::convertToCents($plan['subscription_amount']);
1076
1077 // Bypasses pushItem()'s positive-price guard, so a negative custom amount would
1078 // otherwise become a line item that drags the order total down. Checked before
1079 // the signup fee so the fee cannot mask it.
1080 if ($amount < 0) {
1081 continue;
1082 }
1083
1084 if (ArrayHelper::get($plan, 'has_signup_fee') === 'yes' && ArrayHelper::get($plan, 'signup_fee')) {
1085 $amount += PaymentHelper::convertToCents($plan['signup_fee']);
1086 }
1087
1088 $this->hookedOrderItems[] = [
1089 'type' => 'single',
1090 'form_id' => $this->form->id,
1091 'parent_holder' => $name,
1092 'item_name' => ArrayHelper::get($subscriptionInput, 'admin_label') . ' (' . $plan['name'] . ')',
1093 'item_price' => $amount,
1094 'quantity' => $quantity,
1095 'line_total' => $quantity * $amount,
1096 'created_at' => current_time('mysql'),
1097 'updated_at' => current_time('mysql'),
1098 ];
1099
1100 unset($this->subscriptionInputs[$inputIndex]);
1101 }
1102
1103 }
1104
1105 protected function applyDiscountCodes()
1106 {
1107 if (!$this->discountCodes) {
1108 return false;
1109 }
1110
1111 $orderItems = $this->getOrderItems(true);
1112
1113
1114 $subTotal = array_sum(array_column($orderItems, 'line_total')) / 100;
1115
1116 $subscriptionItems = $this->getSubscriptionItems();
1117
1118 $subInitialTotal = 0;
1119 foreach ($subscriptionItems as $subscriptionItem) {
1120 if ($subscriptionItem['trial_days']) {
1121 continue; // it's a trial
1122 }
1123 $subInitialTotal += $subscriptionItem['recurring_amount'] + $subscriptionItem['initial_amount'];
1124 }
1125
1126 $grandTotal = $subTotal;
1127 if ($subInitialTotal) {
1128 $grandTotal += ($subInitialTotal / 100);
1129 }
1130
1131 $fixedAmountApplied = 0; // in cents
1132
1133 if (Helper::hasPro() && class_exists('FluentFormPro\Payments\Classes\CouponModel')) {
1134 $couponModel = new \FluentFormPro\Payments\Classes\CouponModel();
1135 $this->discountCodes = $couponModel->getValidCoupons($this->discountCodes, $this->form->id, $grandTotal);
1136 } else {
1137 $this->discountCodes = [];
1138 }
1139
1140 foreach ($this->discountCodes as $coupon) {
1141 $discountAmount = $coupon->amount;
1142 if ($coupon->coupon_type == 'percent') {
1143 $discountAmount = (floatval($coupon->amount) / 100) * $subTotal;
1144 } else {
1145 if ($subTotal >= $discountAmount) {
1146 $fixedAmountApplied += $discountAmount;
1147 } else {
1148 $discountAmount = $subTotal;
1149 $fixedAmountApplied += $subTotal;
1150 }
1151 }
1152
1153 $this->pushItem([
1154 'parent_holder' => ArrayHelper::get($this->couponField, 'attributes.name'),
1155 'item_name' => $coupon->title,
1156 'item_price' => $discountAmount, // this is not cent. We convert to cent at pushItem method
1157 'quantity' => 1,
1158 'type' => 'discount'
1159 ]);
1160
1161 $subTotal = $subTotal - $discountAmount;
1162 }
1163
1164
1165 // let's convert to cents now as all subscriptions calculations are on cents
1166 $fixedAmountApplied = intval($fixedAmountApplied * 100);
1167
1168 if (!$subscriptionItems) {
1169 return true;
1170 }
1171
1172 $fixedMaxTotal = 0;
1173 $hasFixedDiscounts = false;
1174 foreach ($this->discountCodes as $discountCode) {
1175 if($discountCode->coupon_type == 'fixed') {
1176 $fixedMaxTotal += intval($coupon->amount * 100);
1177 $hasFixedDiscounts = true;
1178 }
1179 }
1180
1181 $fixedMaxTotal = $fixedMaxTotal - $fixedAmountApplied;
1182
1183 foreach ($subscriptionItems as $subIndex => $subscriptionItem) {
1184 $recurringAmount = $subscriptionItem['recurring_amount'];
1185 $signupFee = 0;
1186 if ($subscriptionItem['initial_amount']) {
1187 $signupFee = $subscriptionItem['initial_amount'];
1188 }
1189 // Let's process the percentile discounts first
1190 foreach ($this->discountCodes as $coupon) {
1191 $discountAmount = $coupon->amount;
1192 if ($coupon->coupon_type == 'percent') {
1193 $discountRecurringAmount = floatval((floatval($discountAmount) / 100) * $recurringAmount);
1194 $recurringAmount -= $discountRecurringAmount;
1195 if ($signupFee) {
1196 $discountSignupDiscountAmount = floatval((floatval($discountAmount) / 100) * $signupFee);
1197 $signupFee -= $discountSignupDiscountAmount;
1198 }
1199 }
1200 }
1201
1202 if($hasFixedDiscounts && $fixedMaxTotal > 0) {
1203 if($fixedMaxTotal >= $subInitialTotal) {
1204 $recurringAmount = 0;
1205 $signupFee = 0;
1206 } else {
1207 $recurringAmount = $recurringAmount - ($fixedMaxTotal / $subInitialTotal) * $recurringAmount;
1208 if($signupFee > 0) {
1209 $signupFee = $signupFee - ($fixedMaxTotal / $subInitialTotal) * $signupFee;
1210 }
1211 }
1212 }
1213
1214 $subscriptionItems[$subIndex]['recurring_amount'] = intval($recurringAmount);
1215 $subscriptionItems[$subIndex]['initial_amount'] = intval($signupFee);
1216
1217 $originalPlan = Helper::safeUnserialize($subscriptionItem['original_plan']);
1218
1219 $originalPlan['subscription_amount'] = round($recurringAmount / 100, 2);
1220 $originalPlan['signup_fee'] = round($recurringAmount / 100, 2);
1221 $subscriptionItems[$subIndex]['original_plan'] = maybe_serialize($originalPlan);
1222 }
1223
1224 $this->subscriptionItems = $subscriptionItems;
1225 return true;
1226 }
1227 }
1228