diff --git a/.gitignore b/.gitignore index b74ef03..ef3c5f9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ *.swp *.swo /.idea +.DS_Store diff --git a/src/Actions/Checkout/VerifyAvailabilityAction.php b/src/Actions/Checkout/VerifyAvailabilityAction.php index d464dd0..e0c15e4 100644 --- a/src/Actions/Checkout/VerifyAvailabilityAction.php +++ b/src/Actions/Checkout/VerifyAvailabilityAction.php @@ -20,6 +20,26 @@ public function run(CheckoutParamsDto $params, ItinerarySummary $itinerary): boo { $dto = $this->getVerifyAvailabilityResponse($params); + return $this->applyAvailabilityResponse($dto, $itinerary); + } + + /** + * Verify availability and return both the bookable result and on-request status. + * + * @return array{bookable: bool, isOnRequest: bool} + */ + public function runWithSummary(CheckoutParamsDto $params, ItinerarySummary $itinerary): array + { + $dto = $this->getVerifyAvailabilityResponse($params); + + return [ + 'bookable' => $this->applyAvailabilityResponse($dto, $itinerary), + 'isOnRequest' => $dto->summary->isOnRequest, + ]; + } + + private function applyAvailabilityResponse(VerifyAvailabilityResponse $dto, ItinerarySummary $itinerary): bool + { /** @var Collection $statuses */ $statuses = new Collection; diff --git a/src/Integrations/Nezasa/Dtos/Responses/Entities/AvailabilitySummaryResponseEntity.php b/src/Integrations/Nezasa/Dtos/Responses/Entities/AvailabilitySummaryResponseEntity.php index 8b33df0..378134d 100644 --- a/src/Integrations/Nezasa/Dtos/Responses/Entities/AvailabilitySummaryResponseEntity.php +++ b/src/Integrations/Nezasa/Dtos/Responses/Entities/AvailabilitySummaryResponseEntity.php @@ -25,6 +25,7 @@ public function __construct( public bool $nonBookable, public bool $bookingWindowEnd, public PriceResponse $prices, - public array $remarks = [] + public array $remarks = [], + public bool $isOnRequest = false, ) {} } diff --git a/src/Integrations/Nezasa/Dtos/Responses/Entities/OnRequestResponseEntity.php b/src/Integrations/Nezasa/Dtos/Responses/Entities/OnRequestResponseEntity.php new file mode 100644 index 0000000..83e2cac --- /dev/null +++ b/src/Integrations/Nezasa/Dtos/Responses/Entities/OnRequestResponseEntity.php @@ -0,0 +1,30 @@ + $this->confirmationText, + 'remarks' => $this->remarks, + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } +} diff --git a/src/Integrations/Nezasa/Dtos/Responses/RegulatoryInformationResponse.php b/src/Integrations/Nezasa/Dtos/Responses/RegulatoryInformationResponse.php index 21b872e..f53dbf6 100644 --- a/src/Integrations/Nezasa/Dtos/Responses/RegulatoryInformationResponse.php +++ b/src/Integrations/Nezasa/Dtos/Responses/RegulatoryInformationResponse.php @@ -6,6 +6,7 @@ use Nezasa\Checkout\Dtos\BaseDto; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\EuPrrlResponseEntity; +use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\OnRequestResponseEntity; class RegulatoryInformationResponse extends BaseDto { @@ -17,8 +18,11 @@ class RegulatoryInformationResponse extends BaseDto public function __construct( public ?string $paymentExplainer = null, public ?EuPrrlResponseEntity $euPrrl = null, + public ?OnRequestResponseEntity $onRequest = null, - ) {} + ) { + $this->onRequest = new OnRequestResponseEntity; + } /** * Determine whether EU-PRRL package compliance should block checkout. diff --git a/src/Livewire/PaymentOptionsSection.php b/src/Livewire/PaymentOptionsSection.php index 13d98a7..f5d6abf 100644 --- a/src/Livewire/PaymentOptionsSection.php +++ b/src/Livewire/PaymentOptionsSection.php @@ -4,9 +4,11 @@ use Illuminate\Contracts\View\View; use Livewire\Attributes\On; +use Livewire\Attributes\Reactive; use Nezasa\Checkout\Actions\Checkout\GetPaymentProviderAction; use Nezasa\Checkout\Dtos\View\PaymentOption; use Nezasa\Checkout\Enums\Section; +use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\OnRequestResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\PriceResponse; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\RegulatoryInformationResponse; @@ -29,6 +31,22 @@ class PaymentOptionsSection extends BaseCheckoutComponent */ public PriceResponse $price; + /** + * Whether the latest availability check marked the itinerary as on-request. + */ + #[Reactive] + public bool $isOnRequest = false; + + /** + * Whether the customer accepted the on-request confirmation. + */ + public bool $acceptedOnRequestTerms = false; + + /** + * Whether to show the on-request confirmation validation error. + */ + public bool $showOnRequestTermsError = false; + /** * Create a new instance of the component. */ @@ -39,6 +57,8 @@ public function mount(GetPaymentProviderAction $getPaymentProviderAction): void if ($this->model->rest_payment) { $this->isExpanded = true; } + + $this->acceptedOnRequestTerms = $this->hasAcceptedOnRequestTerms(); } /** @@ -50,6 +70,57 @@ public function render(): View return view('checkout::blades.payment-options-section'); } + public function requiresOnRequestConfirmation(): bool + { + return $this->isOnRequest + && isset($this->regulatoryInformation) + && $this->regulatoryInformation->onRequest?->confirmationEnabled === true; + } + + /** + * Persist and validate the on-request confirmation checkbox. + */ + public function toggleOnRequestTerms(bool $value): void + { + $this->acceptedOnRequestTerms = $value; + + if ($this->regulatoryInformation->onRequest instanceof OnRequestResponseEntity) { + $this->model->updateData([ + 'acceptedTerms.'.$this->regulatoryInformation->onRequest->getConfirmationKey() => $value, + ]); + } + + $this->showOnRequestTermsError = $value === false && $this->requiresOnRequestConfirmation(); + + $this->dispatch('on-request-confirmation-updated', accepted: $value); + } + + #[On('on-request-confirmation-required')] + public function showOnRequestConfirmationError(): void + { + if (! $this->requiresOnRequestConfirmation()) { + return; + } + + $this->showOnRequestTermsError = true; + } + + private function hasAcceptedOnRequestTerms(): bool + { + if (! isset($this->regulatoryInformation)) { + return false; + } + + if (! $this->regulatoryInformation->onRequest instanceof OnRequestResponseEntity) { + return false; + } + + $data = json_decode(json_encode($this->model->data, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + $acceptedTerms = is_array($data) ? data_get($data, 'acceptedTerms', []) : []; + + return ($acceptedTerms[$this->regulatoryInformation->onRequest->getConfirmationKey()] ?? false) === true; + } + /** * Listen for the 'traveller-processed' event to determine if the promo code section should be expanded or completed. */ diff --git a/src/Livewire/TripDetailsPage.php b/src/Livewire/TripDetailsPage.php index 2192e23..d7e0608 100644 --- a/src/Livewire/TripDetailsPage.php +++ b/src/Livewire/TripDetailsPage.php @@ -14,6 +14,7 @@ use Nezasa\Checkout\Dtos\Planner\ItinerarySummary; use Nezasa\Checkout\Dtos\Planner\RequiredResponses; use Nezasa\Checkout\Enums\Section; +use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\OnRequestResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\PriceResponse; use Throwable; use URL; @@ -45,6 +46,11 @@ class TripDetailsPage extends BaseCheckoutComponent */ public RequiredResponses $result; + /** + * Indicates whether the latest availability check marked the itinerary as on-request. + */ + public bool $isOnRequest = false; + public function mount( CallTripDetailsAction $callTripDetails, FindCheckoutModelAction $findCheckoutModelAction, @@ -99,6 +105,7 @@ public function render(): View public function createPaymentPageUrl(string $gateway): void { $this->gateway = $gateway; + $this->paymentPageUrl = null; if ($this->model->rest_payment) { $this->generatePaymentPageUrl(result: true); @@ -127,8 +134,18 @@ public function createPaymentPageUrl(string $gateway): void } #[On('availability-verified')] - public function generatePaymentPageUrl(bool $result): void + public function generatePaymentPageUrl(bool $result, bool $isOnRequest = false): void { + $this->isOnRequest = $isOnRequest; + + if ($result && $this->requiresOnRequestConfirmation() && ! $this->hasAcceptedOnRequestTerms()) { + $this->checkingAvailability = false; + $this->paymentPageUrl = null; + $this->dispatch('on-request-confirmation-required'); + + return; + } + if ($result) { $this->paymentPageUrl = URL::temporarySignedRoute( name: 'payment', @@ -143,6 +160,40 @@ public function generatePaymentPageUrl(bool $result): void $this->checkingAvailability = false; } + #[On('on-request-confirmation-updated')] + public function onRequestConfirmationUpdated(bool $accepted): void + { + if ($accepted) { + if ($this->gateway !== null && $this->isOnRequest) { + $this->generatePaymentPageUrl(result: true, isOnRequest: true); + } + + return; + } + + $this->paymentPageUrl = null; + } + + public function requiresOnRequestConfirmation(): bool + { + return $this->isOnRequest + && $this->result->regulatoryInformation->onRequest?->confirmationEnabled === true; + } + + private function hasAcceptedOnRequestTerms(): bool + { + if (! $this->result->regulatoryInformation->onRequest instanceof OnRequestResponseEntity) { + return false; + } + + $this->model->refresh(); + + $data = json_decode(json_encode($this->model->data, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + $acceptedTerms = is_array($data) ? data_get($data, 'acceptedTerms', []) : []; + + return ($acceptedTerms[$this->result->regulatoryInformation->onRequest->getConfirmationKey()] ?? false) === true; + } + /** * Handle the promo code applied event. * diff --git a/src/Livewire/TripSummary.php b/src/Livewire/TripSummary.php index 315c094..806bc57 100644 --- a/src/Livewire/TripSummary.php +++ b/src/Livewire/TripSummary.php @@ -96,9 +96,12 @@ public function summaryUpdated(): void #[On('payment-selected')] public function verifyAvailability(): void { + $availability = resolve(VerifyAvailabilityAction::class)->runWithSummary($this->getParams(), $this->itinerary); + $this->dispatch( event: 'availability-verified', - result: resolve(VerifyAvailabilityAction::class)->run($this->getParams(), $this->itinerary), + result: $availability['bookable'], + isOnRequest: $availability['isOnRequest'], ); } diff --git a/src/Resources/Views/blades/index.blade.php b/src/Resources/Views/blades/index.blade.php index 993206a..82a63d4 100644 --- a/src/Resources/Views/blades/index.blade.php +++ b/src/Resources/Views/blades/index.blade.php @@ -63,6 +63,7 @@ :$model :price="$itinerary->price" :$regulatoryInformation + :is-on-request="$isOnRequest" :is-completed="$model->isCompleted(Section::PaymentOptions)" :is-expanded="$model->isExpanded(Section::PaymentOptions)" /> diff --git a/src/Resources/Views/blades/payment-options-section.blade.php b/src/Resources/Views/blades/payment-options-section.blade.php index 66e14e6..a61802e 100644 --- a/src/Resources/Views/blades/payment-options-section.blade.php +++ b/src/Resources/Views/blades/payment-options-section.blade.php @@ -113,6 +113,43 @@ class="peer sr-only" + @if($this->requiresOnRequestConfirmation() && $regulatoryInformation->onRequest !== null) +
+ @if(filled($regulatoryInformation->onRequest->remarks)) +
+ {!! $regulatoryInformation->onRequest->remarks !!} +
+ @endif + +
+ + + + @if($showOnRequestTermsError) +

{{ trans('checkout::input.validations.agree_to_continue') }}

+ @endif +
+
+ @endif + diff --git a/tests/Fixtures/Saloon/regulatory_information_non_compliant_response.json b/tests/Fixtures/Saloon/regulatory_information_non_compliant_response.json index 70b40b3..4f9d5a1 100644 --- a/tests/Fixtures/Saloon/regulatory_information_non_compliant_response.json +++ b/tests/Fixtures/Saloon/regulatory_information_non_compliant_response.json @@ -11,6 +11,11 @@ "prrl.requirementsNotFulfilled.generic" ] } + }, + "onRequest": { + "confirmationEnabled": false, + "confirmationText": null, + "remarks": null } } } diff --git a/tests/Fixtures/Saloon/regulatory_information_response.json b/tests/Fixtures/Saloon/regulatory_information_response.json index b63a37b..2d07821 100644 --- a/tests/Fixtures/Saloon/regulatory_information_response.json +++ b/tests/Fixtures/Saloon/regulatory_information_response.json @@ -9,6 +9,11 @@ "compliant": true, "reasons": [] } + }, + "onRequest": { + "confirmationEnabled": false, + "confirmationText": null, + "remarks": null } } } diff --git a/tests/Fixtures/Saloon/regulatory_information_validation_disabled_response.json b/tests/Fixtures/Saloon/regulatory_information_validation_disabled_response.json index 1ac2b56..39c4f51 100644 --- a/tests/Fixtures/Saloon/regulatory_information_validation_disabled_response.json +++ b/tests/Fixtures/Saloon/regulatory_information_validation_disabled_response.json @@ -11,6 +11,11 @@ "prrl.requirementsNotFulfilled.generic" ] } + }, + "onRequest": { + "confirmationEnabled": false, + "confirmationText": null, + "remarks": null } } } diff --git a/tests/Unit/Actions/TripDetails/CallTripDetailsActionTest.php b/tests/Unit/Actions/TripDetails/CallTripDetailsActionTest.php index a7dc4ae..6a9c8a8 100644 --- a/tests/Unit/Actions/TripDetails/CallTripDetailsActionTest.php +++ b/tests/Unit/Actions/TripDetails/CallTripDetailsActionTest.php @@ -4,6 +4,7 @@ use Nezasa\Checkout\Dtos\Checkout\CheckoutParamsDto; use Nezasa\Checkout\Dtos\Planner\RequiredResponses; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\EuPrrlLinkResponseEntity; +use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\OnRequestResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\RegulatoryInformationResponse; use Nezasa\Checkout\Integrations\Nezasa\Requests\Checkout\GetAvailableUpsellItemsRequest; use Nezasa\Checkout\Integrations\Nezasa\Requests\Checkout\GetRequlatoryInformationRequest; @@ -73,3 +74,24 @@ ->and($response->euPrrl?->links->first())->toBeInstanceOf(EuPrrlLinkResponseEntity::class) ->and($response->euPrrl?->links->first()?->url)->toBe('https://example.com/eu-prrl'); }); + +it('maps on-request confirmation fields from regulatory information', function (): void { + $expected = new OnRequestResponseEntity; + + $response = RegulatoryInformationResponse::from([ + 'paymentExplainer' => 'Payments are handled securely.', + 'onRequest' => [ + 'confirmationEnabled' => true, + 'confirmationText' => 'I understand this booking is on request.', + 'remarks' => '

This booking requires manual confirmation.

', + ], + ]); + + expect($response->onRequest?->confirmationEnabled)->toBeFalse() + ->and($response->onRequest?->confirmationText)->toBe($expected->confirmationText) + ->and($response->onRequest?->remarks)->toBe($expected->remarks) + ->and($response->onRequest?->getConfirmationKey())->toBe(md5(json_encode([ + 'confirmationText' => $expected->confirmationText, + 'remarks' => $expected->remarks, + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))); +}); diff --git a/tests/Unit/Livewire/TripDetailsPageTest.php b/tests/Unit/Livewire/TripDetailsPageTest.php index a60c59c..e1b706d 100644 --- a/tests/Unit/Livewire/TripDetailsPageTest.php +++ b/tests/Unit/Livewire/TripDetailsPageTest.php @@ -7,6 +7,7 @@ use Nezasa\Checkout\Actions\TripDetails\CallTripDetailsAction; use Nezasa\Checkout\Dtos\Checkout\CheckoutParamsDto; use Nezasa\Checkout\Dtos\Planner\ItinerarySummary; +use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\OnRequestResponseEntity; use Nezasa\Checkout\Livewire\TripDetailsPage; use Nezasa\Checkout\Models\Checkout; @@ -334,3 +335,43 @@ public function dispatch($event, ...$params): void expect($component->checkingAvailability)->toBeFalse() ->and($component->paymentPageUrl)->toBeNull(); }); + +it('blocks payment URL generation until on-request confirmation is accepted', function (): void { + $params = new CheckoutParamsDto('co-td-7', 'it-td-7', 'app', 'en'); + $responses = (new CallTripDetailsAction)->run($params); + $responses->regulatoryInformation->onRequest = new OnRequestResponseEntity( + confirmationEnabled: true, + confirmationText: 'I understand this booking is on request.', + remarks: '

This booking requires manual confirmation.

', + ); + $model = Checkout::create([ + 'checkout_id' => 'co-td-7', + 'itinerary_id' => 'it-td-7', + 'origin' => 'app', + 'lang' => 'en', + 'data' => [ + 'acceptedTerms' => [], + ], + ]); + + $component = new TripDetailsPage; + $component->checkoutId = 'co-td-7'; + $component->itineraryId = 'it-td-7'; + $component->origin = 'app'; + $component->lang = 'en'; + $component->model = $model; + $component->result = $responses; + $component->gateway = 'enc-gw'; + + $component->generatePaymentPageUrl(true, true); + + expect($component->paymentPageUrl)->toBeNull() + ->and($component->requiresOnRequestConfirmation())->toBeTrue(); + + $model->updateData([ + 'acceptedTerms.'.$responses->regulatoryInformation->onRequest->getConfirmationKey() => true, + ]); + $component->onRequestConfirmationUpdated(true); + + expect($component->paymentPageUrl)->not->toBeNull(); +}); diff --git a/tests/Unit/Livewire/WorkflowSectionsTest.php b/tests/Unit/Livewire/WorkflowSectionsTest.php index 6069933..ab0335a 100644 --- a/tests/Unit/Livewire/WorkflowSectionsTest.php +++ b/tests/Unit/Livewire/WorkflowSectionsTest.php @@ -15,9 +15,11 @@ use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\EuPrrlResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\ExternallyPaidChargeResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\ExternallyPaidChargesResponseEntity; +use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\OnRequestResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\TermsAndConditionsResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\Entities\TextSectionResponseEntity; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\PriceResponse; +use Nezasa\Checkout\Integrations\Nezasa\Dtos\Responses\RegulatoryInformationResponse; use Nezasa\Checkout\Integrations\Nezasa\Dtos\Shared\Price; use Nezasa\Checkout\Livewire\PaymentOptionsSection; use Nezasa\Checkout\Livewire\Stepper; @@ -132,6 +134,27 @@ function primeBaseCheckoutComponent(TripSummary|PaymentOptionsSection|TermsSecti ->and($component->isExpanded)->toBeFalse(); }); +it('persists on-request confirmation and owns its error state', function (): void { + $onRequest = new OnRequestResponseEntity; + $checkout = livewireWorkflowCheckout(); + $component = new PaymentOptionsSection; + primeBaseCheckoutComponent($component, $checkout); + $component->regulatoryInformation = new RegulatoryInformationResponse(onRequest: $onRequest); + $component->isOnRequest = true; + $component->mount(new PaymentProviderActionForWorkflowTest); + + $component->showOnRequestConfirmationError(); + + expect($component->showOnRequestTermsError)->toBeFalse(); + + $component->toggleOnRequestTerms(true); + $checkout->refresh(); + + expect(data_get($checkout->data, 'acceptedTerms.'.$onRequest->getConfirmationKey()))->toBeTrue() + ->and($component->acceptedOnRequestTerms)->toBeTrue() + ->and($component->showOnRequestTermsError)->toBeFalse(); +}); + it('builds terms validation rules from itinerary and selected insurance terms', function (): void { $section = new TextSectionResponseEntity( header: 'Supplier terms',