← 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 | } |