PluginProbe
The Innovative Form Builder – IvyForms / 1.5
The Innovative Form Builder – IvyForms v1.5
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
← All changes | backend/src/Services/Submission/FormSubmissionService.php +229 -4 1.4 → 1.5 View file →
@@ -23,11 +23,14 @@
23 23 use IvyForms\Services\Field\FieldService;
24 24 use IvyForms\Services\Form\FormService;
25 25 use IvyForms\Services\Mailer\MailerService;
26 26 use IvyForms\Services\Notification\NotificationService;
27 +use IvyForms\Services\Payment\FormSubmissionPaymentService;
28 +use IvyForms\Services\Payment\MoneyMinorUnits;
27 29 use IvyForms\Services\Placeholder\PlaceholderService;
28 30 use IvyForms\Services\Security\SecurityService;
29 31 use IvyForms\Services\Translations\BackendStrings;
32 +use WP_REST_Response;
30 33
31 34 /**
32 35 * Validates and processes form submissions (embed and public API).
33 36 */
@@ -41,8 +44,9 @@
41 44 private EntryService $entryService;
42 45 private NotificationService $notificationService;
43 46 private MailerService $mailerService;
44 47 private ConfirmationService $confirmationService;
48 + private FormSubmissionPaymentService $formSubmissionPaymentService;
45 49
46 50 public function __construct(
47 51 SubmissionUploadService $submissionUploadService,
48 52 FormService $formService,
@@ -51,9 +55,10 @@
51 55 SecurityService $securityService,
52 56 EntryService $entryService,
53 57 NotificationService $notificationService,
54 58 MailerService $mailerService,
55 - ConfirmationService $confirmationService
59 + ConfirmationService $confirmationService,
60 + FormSubmissionPaymentService $formSubmissionPaymentService
56 61 ) {
57 62 $this->submissionUploadService = $submissionUploadService;
58 63 $this->formService = $formService;
59 64 $this->fieldService = $fieldService;
@@ -62,8 +67,9 @@
62 67 $this->entryService = $entryService;
63 68 $this->notificationService = $notificationService;
64 69 $this->mailerService = $mailerService;
65 70 $this->confirmationService = $confirmationService;
71 + $this->formSubmissionPaymentService = $formSubmissionPaymentService;
66 72 }
67 73
68 74 /**
69 75 * @param array<string, mixed> $params Normalized submission payload
@@ -91,14 +97,54 @@
91 97 $context['submissionData'],
92 98 $context['formFields']
93 99 );
94 100
95 - $entry = $this->storeEntryIfEnabled(
101 + $paymentOutcome = $this->formSubmissionPaymentService->processWhenRequired(
102 + $context['params'],
96 103 $context['form'],
104 + $context['formFields'],
105 + $context['submissionData'],
106 + $this->formSubmissionPaymentService->findPaymentMethodField($context['formFields'])
107 + );
108 +
109 + $earlyPaymentResult = $this->buildEarlyPaymentResult($paymentOutcome);
110 + if ($earlyPaymentResult !== null) {
111 + return $earlyPaymentResult;
112 + }
113 +
114 + $entry = $this->persistEntryWithOrphanProtection(
115 + $context['form'],
116 + $context['formFields'],
117 + $context['submissionData'],
97 118 $context['formId'],
98 - $context['formFields'],
99 - $context['submissionData']
119 + $paymentOutcome
100 120 );
121 + if (isset($entry['payment_error'])) {
122 + return $entry;
123 + }
124 +
125 + $paymentPersistenceResult = $this->persistPaymentWithOrphanProtection(
126 + $entry,
127 + $context['formId'],
128 + $paymentOutcome
129 + );
130 + if ($paymentPersistenceResult !== null) {
131 + return $paymentPersistenceResult;
132 + }
133 +
134 + $entryId = (int) ($entry['id'] ?? 0);
135 + if ($entryId > 0 && !empty($paymentOutcome['squareOrderId'])) {
136 + $this->formSubmissionPaymentService->recordOrderForEntry(
137 + $entryId,
138 + (string) $paymentOutcome['squareOrderId'],
139 + isset($paymentOutcome['squareCustomerId']) ? (string) $paymentOutcome['squareCustomerId'] : null,
140 + MoneyMinorUnits::toMinor(
141 + (float) $paymentOutcome['paymentTotal'],
142 + (string) $paymentOutcome['paymentCurrency']
143 + ),
144 + (string) $paymentOutcome['paymentCurrency']
145 + );
146 + }
101 147 $entryId = $entry['id'] ?? null;
102 148 $placeholderData = $this->buildPlaceholderData($context['formFields'], $context['submissionData'], $entryId);
103 149 $notificationsResult = $this->dispatchNotifications($context, $placeholderData);
104 150 $confirmationPayload = $this->confirmationService->sanitizeProcessedPayload(
@@ -150,8 +196,9 @@
150 196 * formId: int,
151 197 * form: Form,
152 198 * formFields: array<int, mixed>,
153 199 * submissionData: array<string, mixed>,
200 + * params: array<string, mixed>,
154 201 * fieldLabels: array<string, string>
155 202 * }
156 203 *
157 204 * @throws InvalidArgumentException|NotFoundException|ForbiddenException|ValidationException
@@ -186,8 +233,13 @@
186 233 $fieldLabels = PlaceholderService::buildFieldLabels($formFields);
187 234 $this->securityService->validateFormSubmission($params, $formFields);
188 235
189 236 $params = $this->mergeUploadedFilesIntoValues($params, $uploadedFiles, $formFields);
237 + foreach (['sourceId', 'idempotencyKey', 'paymentMethodId', 'paymentIntentId', 'paypalOrderId'] as $key) {
238 + if (isset($params[$key])) {
239 + $params[$key] = sanitize_text_field((string) $params[$key]);
240 + }
241 + }
190 242 $submissionData = Sanitizer::sanitizeFormSubmissionData($params, $formFields);
191 243
192 244 do_action('ivyforms/form/validate_submission', $submissionData, $formFields);
193 245
@@ -195,8 +247,9 @@
195 247 'formId' => $formId,
196 248 'form' => $form,
197 249 'formFields' => $formFields,
198 250 'submissionData' => $submissionData,
251 + 'params' => $params,
199 252 'fieldLabels' => $fieldLabels,
200 253 ];
201 254 }
202 255
@@ -205,8 +258,9 @@
205 258 * formId: int,
206 259 * form: Form,
207 260 * formFields: array<int, mixed>,
208 261 * submissionData: array<string, mixed>,
262 + * params: array<string, mixed>,
209 263 * fieldLabels: array<string, string>
210 264 * } $context
211 265 *
212 266 * @return array<string, mixed>|null
@@ -278,8 +332,9 @@
278 332 * formId: int,
279 333 * form: Form,
280 334 * formFields: array<int, mixed>,
281 335 * submissionData: array<string, mixed>,
336 + * params: array<string, mixed>,
282 337 * fieldLabels: array<string, string>
283 338 * } $context
284 339 * @param array{fieldData: array<string, mixed>, generalData: array<string, mixed>} $placeholderData
285 340 *
@@ -314,7 +369,177 @@
314 369 'success' => $notificationsResult,
315 370 'entry' => $entry,
316 371 'confirmation' => $confirmationPayload['message'],
317 372 'confirmationResult' => $confirmationPayload['result'],
373 + ];
374 + }
375 +
376 + /**
377 + * @param array<string, mixed> $paymentOutcome
378 + * @return array<string, mixed>|null
379 + */
380 + private function buildEarlyPaymentResult(array $paymentOutcome): ?array
381 + {
382 + $errorResponse = $paymentOutcome['errorResponse'] ?? null;
383 + if ($errorResponse instanceof WP_REST_Response) {
384 + return $this->normalizePaymentErrorResult($errorResponse);
385 + }
386 +
387 + if (empty($paymentOutcome['requiresAction'])) {
388 + return null;
389 + }
390 +
391 + return [
392 + 'requires_action' => true,
393 + 'client_secret' => $paymentOutcome['clientSecret'] ?? null,
394 + 'payment_intent_id' => $paymentOutcome['paymentIntentId'] ?? null,
395 + 'payment_method' => $paymentOutcome['paymentMethod'] ?? 'stripe',
396 + 'success' => false,
397 + ];
398 + }
399 +
400 + /**
401 + * @param array<int, mixed> $formFields
402 + * @param array<string, mixed> $submissionData
403 + * @param array<string, mixed> $paymentOutcome
404 + * @return array<string, mixed>
405 + */
406 + private function persistEntryWithOrphanProtection(
407 + Form $form,
408 + array $formFields,
409 + array $submissionData,
410 + int $formId,
411 + array $paymentOutcome
412 + ): array {
413 + $transactionId = (string) ($paymentOutcome['paymentResult']['transactionId'] ?? '');
414 + if (
415 + $transactionId !== ''
416 + && $this->formSubmissionPaymentService->paymentAlreadyLinkedToEntry($transactionId)
417 + ) {
418 + return $this->normalizePaymentErrorResult(
419 + $this->formSubmissionPaymentService->duplicatePaymentErrorResponse()
420 + );
421 + }
422 +
423 + try {
424 + $entry = $this->storeEntryIfEnabled($form, $formId, $formFields, $submissionData);
425 + } catch (\Throwable $exception) {
426 + $this->logPersistFailure('entry', $exception);
427 + $this->recordOrphanedPaymentSafely($formId, $paymentOutcome);
428 +
429 + return $this->orphanedPaymentResult();
430 + }
431 +
432 + if (!empty($entry['stored']) && empty($entry['id']) && $paymentOutcome['paymentResult'] !== null) {
433 + $this->recordOrphanedPaymentSafely($formId, $paymentOutcome);
434 +
435 + return $this->orphanedPaymentResult();
436 + }
437 +
438 + return $entry;
439 + }
440 +
441 + /**
442 + * @param array<string, mixed> $entry
443 + * @param array<string, mixed> $paymentOutcome
444 + * @return array<string, mixed>|null
445 + */
446 + private function persistPaymentWithOrphanProtection(
447 + array $entry,
448 + int $formId,
449 + array $paymentOutcome
450 + ): ?array {
451 + try {
452 + $recorded = $this->formSubmissionPaymentService->recordSubmissionPayment(
453 + (int) ($entry['id'] ?? 0),
454 + $formId,
455 + $paymentOutcome
456 + );
457 + } catch (\Throwable $exception) {
458 + $this->logPersistFailure('payment', $exception);
459 + $this->rollbackEntryIfNeeded($entry);
460 + $this->recordOrphanedPaymentSafely($formId, $paymentOutcome);
461 +
462 + return $this->orphanedPaymentResult();
463 + }
464 +
465 + if ($recorded) {
466 + return null;
467 + }
468 +
469 + $this->rollbackEntryIfNeeded($entry);
470 +
471 + return $this->normalizePaymentErrorResult(
472 + $this->formSubmissionPaymentService->duplicatePaymentErrorResponse()
473 + );
474 + }
475 +
476 + /**
477 + * @param array<string, mixed> $paymentOutcome
478 + */
479 + private function recordOrphanedPaymentSafely(int $formId, array $paymentOutcome): void
480 + {
481 + try {
482 + $this->formSubmissionPaymentService->recordOrphanedPayment($formId, $paymentOutcome);
483 + } catch (\Throwable $exception) {
484 + $this->logPersistFailure('orphaned payment', $exception);
485 + }
486 + }
487 +
488 + private function logPersistFailure(string $stage, \Throwable $exception): void
489 + {
490 + error_log(sprintf(
491 + '[IvyForms] Failed to persist %s after payment: %s in %s:%d',
492 + $stage,
493 + $exception->getMessage(),
494 + $exception->getFile(),
495 + $exception->getLine()
496 + ));
497 + }
498 +
499 + /**
500 + * @return array<string, mixed>
501 + */
502 + private function orphanedPaymentResult(): array
503 + {
504 + return $this->normalizePaymentErrorResult(new WP_REST_Response([
505 + 'message' => BackendStrings::getPaymentStrings()['payment_failed'],
506 + 'data' => [
507 + 'success' => false,
508 + 'payment_error' => true,
509 + ],
510 + ], 200));
511 + }
512 +
513 + /**
514 + * @param array{stored: bool, id?: int} $entry
515 + */
516 + private function rollbackEntryIfNeeded(array $entry): void
517 + {
518 + if (empty($entry['stored']) || empty($entry['id'])) {
519 + return;
520 + }
521 +
522 + $entryId = (int) $entry['id'];
523 + $this->entryService->getDeletionManager()->deleteEntryFieldsByEntryIds([$entryId]);
524 + $this->entryService->getDeletionManager()->deleteEntry($entryId);
525 + }
526 +
527 + /**
528 + * @return array<string, mixed>
529 + */
530 + private function normalizePaymentErrorResult(WP_REST_Response $errorResponse): array
531 + {
532 + $payload = $errorResponse->get_data();
533 + $payload = is_array($payload) ? $payload : [];
534 +
535 + return [
536 + 'payment_error' => true,
537 + 'message' => (string) (
538 + $payload['message'] ?? BackendStrings::getPaymentStrings()['payment_failed']
539 + ),
540 + 'success' => false,
541 + 'entry' => ['stored' => false],
542 + 'confirmation' => null,
318 543 ];
319 544 }
320 545 }