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
ivyforms / backend / src / Services / Submission / FormSubmissionService.php

FormSubmissionService.php in The Innovative Form Builder – IvyForms 1.5, at backend/src/Services/Submission/FormSubmissionService.php

546 lines 18.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © Melograno Venture Studio. All rights reserved.
5 * @licence See COPYING.md for license details.
6 */
7
8 declare(strict_types=1);
9
10 namespace IvyForms\Services\Submission;
11
12 use IvyForms\Common\Exceptions\ForbiddenException;
13 use IvyForms\Common\Exceptions\InvalidArgumentException;
14 use IvyForms\Common\Exceptions\NotFoundException;
15 use IvyForms\Common\Exceptions\ValidationException;
16 use IvyForms\Common\Helpers\EntryHelper;
17 use IvyForms\Common\Sanitizer\Sanitizer;
18 use IvyForms\Entity\Form\Form;
19 use IvyForms\Factory\Entry\EntryFactory;
20 use IvyForms\Services\Confirmation\ConfirmationService;
21 use IvyForms\Services\Entry\EntryService;
22 use IvyForms\Services\Field\FieldDuplicateValidationService;
23 use IvyForms\Services\Field\FieldService;
24 use IvyForms\Services\Form\FormService;
25 use IvyForms\Services\Mailer\MailerService;
26 use IvyForms\Services\Notification\NotificationService;
27 use IvyForms\Services\Payment\FormSubmissionPaymentService;
28 use IvyForms\Services\Payment\MoneyMinorUnits;
29 use IvyForms\Services\Placeholder\PlaceholderService;
30 use IvyForms\Services\Security\SecurityService;
31 use IvyForms\Services\Translations\BackendStrings;
32 use WP_REST_Response;
33
34 /**
35 * Validates and processes form submissions (embed and public API).
36 */
37 class FormSubmissionService
38 {
39 private SubmissionUploadService $submissionUploadService;
40 private FormService $formService;
41 private FieldService $fieldService;
42 private FieldDuplicateValidationService $fieldDuplicateValidationService;
43 private SecurityService $securityService;
44 private EntryService $entryService;
45 private NotificationService $notificationService;
46 private MailerService $mailerService;
47 private ConfirmationService $confirmationService;
48 private FormSubmissionPaymentService $formSubmissionPaymentService;
49
50 public function __construct(
51 SubmissionUploadService $submissionUploadService,
52 FormService $formService,
53 FieldService $fieldService,
54 FieldDuplicateValidationService $fieldDuplicateValidationService,
55 SecurityService $securityService,
56 EntryService $entryService,
57 NotificationService $notificationService,
58 MailerService $mailerService,
59 ConfirmationService $confirmationService,
60 FormSubmissionPaymentService $formSubmissionPaymentService
61 ) {
62 $this->submissionUploadService = $submissionUploadService;
63 $this->formService = $formService;
64 $this->fieldService = $fieldService;
65 $this->fieldDuplicateValidationService = $fieldDuplicateValidationService;
66 $this->securityService = $securityService;
67 $this->entryService = $entryService;
68 $this->notificationService = $notificationService;
69 $this->mailerService = $mailerService;
70 $this->confirmationService = $confirmationService;
71 $this->formSubmissionPaymentService = $formSubmissionPaymentService;
72 }
73
74 /**
75 * @param array<string, mixed> $params Normalized submission payload
76 * @param array<string, mixed> $uploadedFiles Multipart files from the transport layer
77 *
78 * @return array<string, mixed>
79 *
80 * @throws InvalidArgumentException|NotFoundException|ForbiddenException|ValidationException
81 */
82 public function processSubmission(
83 array $params,
84 array $uploadedFiles = [],
85 ?int $formIdOverride = null
86 ): array {
87 $context = $this->buildSubmissionContext($params, $uploadedFiles, $formIdOverride);
88
89 $duplicateResult = $this->buildDuplicateResultIfNeeded($context);
90 if ($duplicateResult !== null) {
91 return $duplicateResult;
92 }
93
94 do_action(
95 'ivyforms/form/before_submission',
96 $context['formId'],
97 $context['submissionData'],
98 $context['formFields']
99 );
100
101 $paymentOutcome = $this->formSubmissionPaymentService->processWhenRequired(
102 $context['params'],
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'],
118 $context['formId'],
119 $paymentOutcome
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 }
147 $entryId = $entry['id'] ?? null;
148 $placeholderData = $this->buildPlaceholderData($context['formFields'], $context['submissionData'], $entryId);
149 $notificationsResult = $this->dispatchNotifications($context, $placeholderData);
150 $confirmationPayload = $this->confirmationService->sanitizeProcessedPayload(
151 $this->confirmationService->processConfirmations(
152 $context['formId'],
153 $context['submissionData'],
154 $context['formFields'],
155 $placeholderData['fieldData'],
156 $placeholderData['generalData'],
157 $context['fieldLabels']
158 )
159 );
160
161 do_action(
162 'ivyforms/form/after_submission',
163 $context['formId'],
164 $context['submissionData'],
165 $context['formFields'],
166 $entryId
167 );
168
169 return $this->buildSuccessResult($entry, $notificationsResult, $confirmationPayload);
170 }
171
172 /**
173 * @param array<string, mixed> $params
174 * @param array<string, mixed> $uploadedFiles
175 * @param array<\IvyForms\Entity\Field\Field> $formFields
176 *
177 * @return array<string, mixed>
178 */
179 public function mergeUploadedFilesIntoValues(
180 array $params,
181 array $uploadedFiles,
182 array $formFields
183 ): array {
184 return $this->submissionUploadService->mergeUploadedFilesIntoValues(
185 $params,
186 $uploadedFiles,
187 $formFields
188 );
189 }
190
191 /**
192 * @param array<string, mixed> $params
193 * @param array<string, mixed> $uploadedFiles
194 *
195 * @return array{
196 * formId: int,
197 * form: Form,
198 * formFields: array<int, mixed>,
199 * submissionData: array<string, mixed>,
200 * params: array<string, mixed>,
201 * fieldLabels: array<string, string>
202 * }
203 *
204 * @throws InvalidArgumentException|NotFoundException|ForbiddenException|ValidationException
205 */
206 private function buildSubmissionContext(
207 array $params,
208 array $uploadedFiles,
209 ?int $formIdOverride = null
210 ): array {
211 if ($formIdOverride !== null) {
212 $params['formId'] = $formIdOverride;
213 }
214
215 if (empty($params) || !isset($params['values']) || !is_array($params['values'])) {
216 throw new InvalidArgumentException(
217 BackendStrings::getExceptionStrings()['invalid_request_data']
218 );
219 }
220
221 $formId = Sanitizer::sanitizeId((int) ($params['formId'] ?? 0));
222
223 if ($formId <= 0) {
224 throw new InvalidArgumentException(
225 BackendStrings::getExceptionStrings()['invalid_form_id']
226 );
227 }
228
229 $form = $this->formService->getFormById($formId);
230 $formFields = $this->fieldService->getAllFields($form->getId());
231 $this->fieldService->validateFieldsType($formFields);
232
233 $fieldLabels = PlaceholderService::buildFieldLabels($formFields);
234 $this->securityService->validateFormSubmission($params, $formFields);
235
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 }
242 $submissionData = Sanitizer::sanitizeFormSubmissionData($params, $formFields);
243
244 do_action('ivyforms/form/validate_submission', $submissionData, $formFields);
245
246 return [
247 'formId' => $formId,
248 'form' => $form,
249 'formFields' => $formFields,
250 'submissionData' => $submissionData,
251 'params' => $params,
252 'fieldLabels' => $fieldLabels,
253 ];
254 }
255
256 /**
257 * @param array{
258 * formId: int,
259 * form: Form,
260 * formFields: array<int, mixed>,
261 * submissionData: array<string, mixed>,
262 * params: array<string, mixed>,
263 * fieldLabels: array<string, string>
264 * } $context
265 *
266 * @return array<string, mixed>|null
267 */
268 private function buildDuplicateResultIfNeeded(array $context): ?array
269 {
270 [$isDuplicate, $duplicateErrors] = $this->fieldDuplicateValidationService->checkDuplicateFieldValues(
271 $context['formFields'],
272 $context['submissionData'],
273 $context['formId']
274 );
275
276 if (!$isDuplicate) {
277 return null;
278 }
279
280 return [
281 'success' => null,
282 'entry' => ['stored' => false],
283 'confirmation' => null,
284 'is_duplicate' => true,
285 'duplicate_errors' => $duplicateErrors,
286 ];
287 }
288
289 /**
290 * @param array<int, mixed> $formFields
291 * @param array<string, mixed> $submissionData
292 *
293 * @return array{stored: bool, id?: int}
294 */
295 private function storeEntryIfEnabled(
296 Form $form,
297 int $formId,
298 array $formFields,
299 array $submissionData
300 ): array {
301 if (!$form->isStoreEntries()) {
302 return ['stored' => false];
303 }
304
305 $entryData = EntryHelper::buildEntryData($formId);
306 $entryObj = EntryFactory::create($entryData);
307 $entryId = $this->entryService->getEntryManager()->createEntry($entryObj);
308 $this->entryService->getEntryFieldManager()->addEntryFields($formFields, $entryId, $submissionData);
309
310 return [
311 'stored' => true,
312 'id' => $entryId,
313 ];
314 }
315
316 /**
317 * @param array<int, mixed> $formFields
318 * @param array<string, mixed> $submissionData
319 *
320 * @return array{fieldData: array<string, mixed>, generalData: array<string, mixed>}
321 */
322 private function buildPlaceholderData(array $formFields, array $submissionData, ?int $entryId): array
323 {
324 return [
325 'fieldData' => PlaceholderService::buildFieldData($formFields, $submissionData),
326 'generalData' => PlaceholderService::buildGeneralData($entryId ?? 0, $submissionData),
327 ];
328 }
329
330 /**
331 * @param array{
332 * formId: int,
333 * form: Form,
334 * formFields: array<int, mixed>,
335 * submissionData: array<string, mixed>,
336 * params: array<string, mixed>,
337 * fieldLabels: array<string, string>
338 * } $context
339 * @param array{fieldData: array<string, mixed>, generalData: array<string, mixed>} $placeholderData
340 *
341 * @return mixed
342 */
343 private function dispatchNotifications(array $context, array $placeholderData)
344 {
345 return $this->notificationService->processNotifications(
346 $context['formId'],
347 $context['submissionData'],
348 $context['formFields'],
349 $placeholderData['fieldData'],
350 $placeholderData['generalData'],
351 $this->mailerService,
352 $context['fieldLabels']
353 );
354 }
355
356 /**
357 * @param array{stored: bool, id?: int} $entry
358 * @param mixed $notificationsResult
359 * @param array{message: string, result: array<string, mixed>|null} $confirmationPayload
360 *
361 * @return array<string, mixed>
362 */
363 private function buildSuccessResult(
364 array $entry,
365 $notificationsResult,
366 array $confirmationPayload
367 ): array {
368 return [
369 'success' => $notificationsResult,
370 'entry' => $entry,
371 'confirmation' => $confirmationPayload['message'],
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,
543 ];
544 }
545 }
546