*/ public const DEFAULT_STEPS = FormConfiguration::DEFAULT_STEP_NAMES; /** * Constructor. * * @param OrderIntentService $intents Intent service. * @param StepEngine $engine Step engine. * @param SettingsStore $settings Settings. * @param Clock $clock Clock. * @param LocationRepository $locations Locations. * @param AgentRepository $agents Agents. * @param ServiceRepository $services Services. * @param BookingFormRepository $booking_forms Booking forms. * @param BookingFormScope $form_scope Form catalogue scope. * @param CreateBookingHandler $bookings Booking write path. * @param ServiceExtraRepository|null $extras Extras catalogue. * @param NamedFeatureGate|null $named Paid-plan features; open when omitted. */ public function __construct( private readonly OrderIntentService $intents, private readonly StepEngine $engine, private readonly SettingsStore $settings, private readonly Clock $clock, private readonly LocationRepository $locations, private readonly AgentRepository $agents, private readonly ServiceRepository $services, private readonly BookingFormRepository $booking_forms, private readonly BookingFormScope $form_scope, private readonly CreateBookingHandler $bookings, private readonly ?ServiceExtraRepository $extras = null, private readonly ?NamedFeatureGate $named = null, ) {} /** * Create a session. * * @param int $form_id Form id. * @param string|null $timezone Customer timezone. * @param string|null $locale Locale. * @param string|null $ip Client IP. * @param string|null $user_agent User agent. * @return array */ public function create( int $form_id, ?string $timezone, ?string $locale, ?string $ip, ?string $user_agent ): array { if ( $form_id > 0 ) { $this->form_scope->assert_bookable( $form_id ); } $form = $this->build_form_configuration( $form_id > 0 ? $form_id : 0 ); $created = $this->intents->create( $form_id > 0 ? $form_id : null, $locale, $timezone, $ip, $user_agent, null ); $session = $this->session_from_created( $created, $form ); $first = $this->engine->first_step_name( $session ); $session = $session->with_current_step( $first ); $this->persist( $session ); return $this->payload( $session ); } /** * Create an ephemeral session for the admin form editor preview. * * @param BookingForm $form Merged draft form. * @param string|null $timezone Customer timezone. * @param string|null $locale Locale. * @return array */ public function create_preview_from_form( BookingForm $form, ?string $timezone, ?string $locale ): array { $config = $this->form_configuration_from_entity( $form, true ); $form_id = $form->id() ?? 0; $created = $this->intents->create( $form_id > 0 ? $form_id : null, $locale, $timezone, null, null, null ); $session = $this->session_from_created( $created, $config ); $first = $this->engine->first_step_name( $session ); $session = $session->with_current_step( $first ); $this->persist( $session ); return $this->payload( $session ); } /** * Resume a session by token (refreshes activity). * * @param string $token Session token. * @return array * * @throws DomainException When not found or expired. */ public function get( string $token ): array { $session = $this->load( $token, true ); return $this->payload( $session ); } /** * Submit a step and advance. * * @param string $token Session token. * @param string $name Step name. * @param array $payload Step fields. * @return array * * @throws DomainException When invalid. */ public function submit_step( string $token, string $name, array $payload ): array { $session = $this->load( $token, false ); if ( 'service' === $name ) { $this->assert_attendees_within_capacity( $payload ); } $session = $this->engine->submit( $session, $name, $payload ); $session = $this->enrich_form_from_draft( $session ); $this->persist( $session ); if ( $this->should_convert_on_confirmation( $session ) ) { return $this->submit( $token ); } return $this->payload( $session ); } /** * Move to the previous completed non-skipped step. * * @param string $token Session token. * @return array * * @throws DomainException When cannot go back. */ public function back( string $token ): array { $session = $this->load( $token, false ); $previous = $this->engine->previous_step_name( $session ); if ( null === $previous ) { throw new DomainException( 'session.cannot_go_back', 'There is no previous step.' ); } $completed = $session->completed_steps(); while ( [] !== $completed && end( $completed ) !== $previous ) { array_pop( $completed ); } if ( [] !== $completed && end( $completed ) === $previous ) { array_pop( $completed ); } $session = $session->with_completed_steps( $completed )->with_current_step( $previous ); $this->persist( $session ); return $this->payload( $session ); } /** * Convert the session into a booking order. * * @param string $token Session token. * @return array * * @throws DomainException When incomplete or conversion fails. */ public function submit( string $token ): array { $session = $this->load( $token, false ); $this->assert_ready_to_submit( $session ); $result = $this->intents->convert( $token, function () use ( $session ): int { $booking = $this->bookings->handle( $this->command_from_session( $session ) ); $order_id = $booking->order_id(); if ( null === $order_id ) { throw new DomainException( 'session.submit_order_missing', 'Booking was created without an order id.' ); } return $order_id; } ); return $this->confirmation_payload( $session, $result ); } /** * Load and optionally refresh a session; enforces expiry. * * @param string $token Token. * @param bool $refresh Whether to bump last_activity_at. * * @throws DomainException When expired or missing. */ private function load( string $token, bool $refresh ): BookingSession { $snapshot = $this->intents->resume( $token ); $form_id = $snapshot->intent()->form_id() ?? 0; $form = $this->build_form_configuration( $form_id ); $session = $this->session_from_snapshot( $token, $snapshot, $form ); $session = $this->enrich_form_from_draft( $session ); $idle = (int) $this->settings->get( 'booking.session_idle_minutes' ); if ( $session->is_expired( $this->clock->now(), $idle ) ) { $this->intents->abandon_if_open( $token ); throw new DomainException( 'session.expired', 'This booking session has expired. Please start again.' ); } if ( $refresh ) { $this->persist( $session ); $snapshot = $this->intents->resume( $token ); $session = $this->session_from_snapshot( $token, $snapshot, $session->form() ); } return $session; } /** * Persist session state onto the order intent + cart items. * * @param BookingSession $session Session. */ private function persist( BookingSession $session ): void { $draft_payload = $session->customer()->to_array(); $draft_payload['_prepared'] = $session->draft()->to_array(); $cart_items = null; $lines = $session->cart(); if ( null !== $session->draft()->service_id() && [] === $lines ) { $lines = [ $session->draft() ]; } if ( [] !== $lines ) { $cart_items = []; $sort = 0; $cart_id = (int) ( $session->intent_id() ?? 0 ); // cart_id is resolved inside mutate from the intent; placeholder 1 is replaced by repository replace. foreach ( $lines as $line ) { if ( null === $line->service_id() ) { continue; } $cart_items[] = new CartItem( null, 1, (int) $line->service_id(), $line->duration_id(), $line->agent_id(), $line->location_id(), $line->start_utc(), $line->end_utc(), $line->buffer_start_utc(), $line->buffer_end_utc(), $line->timezone(), $line->attendees(), [] === $line->extras() ? null : [ 'items' => $line->extras() ], [] === $line->custom_fields() ? null : $line->custom_fields(), new Money( $line->price_minor(), $line->currency() ), $line->hold_id(), $sort++, ); unset( $cart_id ); } } $this->intents->mutate( $session->token(), $draft_payload, $session->coupon_code(), $session->current_step(), $session->completed_steps(), $cart_items ); } /** * Build form configuration from settings and catalogue counts. * * @param int $form_id Form id. */ private function build_form_configuration( int $form_id ): FormConfiguration { if ( $form_id > 0 ) { $form = $this->booking_forms->find_by_id( $form_id ); if ( null !== $form ) { return $this->form_configuration_from_entity( $form ); } } $locations = count( $this->locations->find_active( 100, 0 ) ); $agents = count( $this->agents->find_active( 100, 0 ) ); $prepay = (string) $this->settings->get( 'payments.global_prepayment' ); return new FormConfiguration( $form_id, [], (bool) $this->settings->get( 'booking.cart_enabled' ), $locations, $agents, 0, (bool) $this->settings->get( 'payments.any_gateway_configured' ), (bool) $this->settings->get( 'payments.pay_on_site_only' ), false, false, 'required' === $prepay, 'disabled' === $prepay, ); } /** * Build step-engine configuration from a stored booking form. * * Preview sessions keep the payment step visible when the form includes it, * so operators can review the journey even before a live gateway is enabled. * * @param BookingForm $form Form entity. * @param bool $preview Admin live-preview session. */ private function form_configuration_from_entity( BookingForm $form, bool $preview = false ): FormConfiguration { $scoped_form = $preview ? $form : ( BookingFormStatus::Active === $form->status() ? $form : null ); $locations = $this->form_scope->count_eligible_locations( $scoped_form ); $agents = $this->form_scope->count_eligible_agents( $scoped_form ); $prepay = (string) $this->settings->get( 'payments.global_prepayment' ); $settings = $form->settings(); $settings['_scope_service_ids'] = $form->service_ids(); $settings['_scope_agent_ids'] = $form->agent_ids(); $settings['_scope_location_ids'] = $form->location_ids(); $settings['_scope_category_ids'] = $form->category_ids(); $settings['_theme'] = $form->theme(); $cart = array_key_exists( 'cart_enabled', $settings ) ? (bool) $settings['cart_enabled'] : (bool) $this->settings->get( 'booking.cart_enabled' ); $gateway = (bool) $this->settings->get( 'payments.any_gateway_configured' ); $pay_on_site = (bool) $this->settings->get( 'payments.pay_on_site_only' ); $global_required = 'required' === $prepay; $global_disabled = 'disabled' === $prepay; if ( $preview && in_array( 'payment', $form->steps(), true ) ) { $gateway = true; $pay_on_site = false; $global_required = true; $global_disabled = false; } return new FormConfiguration( $form->id() ?? 0, $form->steps(), $cart, $locations, $agents, 0, $gateway, $pay_on_site, false, false, $global_required, $global_disabled, $settings, ); } /** * Update deposit / prepayment flags from the selected service. * * @param BookingSession $session Session. */ private function enrich_form_from_draft( BookingSession $session ): BookingSession { $service_id = $session->draft()->service_id(); if ( null === $service_id ) { return $session; } $service = $this->services->find_by_id( $service_id ); if ( null === $service ) { return $session; } $deposit = DepositType::None !== $service->deposit_type() && $service->deposit_value() > 0; $meta = $service->meta() ?? []; $prepay = isset( $meta['requires_prepayment'] ) && true === $meta['requires_prepayment']; $extra_n = 0; if ( $this->extras_entitled() && $this->extras instanceof ServiceExtraRepository && null !== $service->id() ) { $extra_n = count( $this->extras->find_active_for_service( (int) $service->id() ) ); } $form = $session->form()->with_eligibility( null, null, $extra_n, $deposit, $prepay ); return $session->with_form( $form ); } /** * Hydrate session from a newly created intent. * * @param CreatedOrderIntent $created Created. * @param FormConfiguration $form Form. */ private function session_from_created( CreatedOrderIntent $created, FormConfiguration $form ): BookingSession { $intent = $created->intent(); $now = $this->clock->now(); $expires = $intent->expires_at() ?? $now->modify( '+24 hours' ); return new BookingSession( $created->token(), $form, $intent->locale(), $intent->customer_timezone(), 'service', [], [], PreparedBooking::empty(), CustomerDraft::empty(), null, [ 'subtotal_minor' => 0, 'discount_minor' => 0, 'tax_minor' => 0, 'total_minor' => 0, 'due_now_minor' => 0, 'due_later_minor' => 0, 'currency' => $this->intents->default_currency(), ], $intent->created_at(), $expires, $intent->last_activity_at(), $intent->ip_hash(), $intent->id(), ); } /** * Hydrate session from a snapshot. * * @param string $token Token. * @param IntentSnapshot $snapshot Snapshot. * @param FormConfiguration $form Form. */ private function session_from_snapshot( string $token, IntentSnapshot $snapshot, FormConfiguration $form ): BookingSession { $intent = $snapshot->intent(); $draft_a = $intent->customer_draft() ?? []; $prepared = isset( $draft_a['_prepared'] ) && is_array( $draft_a['_prepared'] ) ? PreparedBooking::from_array( $draft_a['_prepared'] ) : PreparedBooking::empty(); unset( $draft_a['_prepared'] ); $customer = CustomerDraft::from_array( $draft_a ); $cart = []; foreach ( $snapshot->items() as $item ) { $extras = $item->extras(); $cart[] = PreparedBooking::from_array( [ 'service_id' => $item->service_id(), 'duration_id' => $item->duration_id(), 'location_id' => $item->location_id(), 'agent_id' => $item->agent_id(), 'start_utc' => $item->start_utc(), 'end_utc' => $item->end_utc(), 'buffer_start_utc' => $item->buffer_start_utc(), 'buffer_end_utc' => $item->buffer_end_utc(), 'timezone' => $item->timezone(), 'attendees' => $item->attendees(), 'extras' => is_array( $extras ) && isset( $extras['items'] ) && is_array( $extras['items'] ) ? $extras['items'] : ( is_array( $extras ) ? array_values( $extras ) : [] ), 'custom_fields' => $item->custom_fields() ?? [], 'price_minor' => $item->price()->amount_minor(), 'currency' => $item->price()->currency(), 'hold_id' => $item->hold_id(), ] ); } if ( null === $prepared->service_id() && [] !== $cart ) { $prepared = $cart[0]; } $expires = $intent->expires_at() ?? $intent->created_at()->modify( '+24 hours' ); $step = $intent->current_step() ?? 'service'; return new BookingSession( $token, $form, $intent->locale(), $intent->customer_timezone(), $step, $intent->completed_steps() ?? [], $cart, $prepared, $customer, $intent->coupon_code(), $intent->totals() ?? [ 'subtotal_minor' => 0, 'discount_minor' => 0, 'tax_minor' => 0, 'total_minor' => 0, 'due_now_minor' => 0, 'due_later_minor' => 0, 'currency' => $this->intents->default_currency(), ], $intent->created_at(), $expires, $intent->last_activity_at(), $intent->ip_hash(), $intent->id(), ); } /** * API payload. * * @param BookingSession $session Session. * @return array */ private function payload( BookingSession $session ): array { $step_name = $session->current_step(); $step = $this->enrich_descriptor( $session, $this->engine->descriptor( $session, $step_name ) ); $form_entity = $session->form_id() > 0 ? $this->booking_forms->find_by_id( $session->form_id() ) : null; return [ 'token' => $session->token(), 'expires_at' => $session->expires_at()->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'c' ), 'form' => [ 'id' => $session->form_id() > 0 ? $session->form_id() : null, 'service_ids' => $form_entity?->service_ids(), 'agent_ids' => $form_entity?->agent_ids(), 'location_ids' => $form_entity?->location_ids(), 'category_ids' => $form_entity?->category_ids(), 'theme' => $this->theme_from_session( $session, $form_entity ), ], 'steps' => $this->engine->progress_list( $session ), 'step' => $step->to_array(), 'session' => $session->to_array(), ]; } /** * Attach catalogue lists to picker steps. * * @param BookingSession $session Session. * @param StepDescriptor $descriptor Descriptor. */ private function enrich_descriptor( BookingSession $session, StepDescriptor $descriptor ): StepDescriptor { $scope = $this->scope_lists_from_session( $session ); return match ( $descriptor->name() ) { 'agent' => $descriptor->with_data( [ 'agents' => $this->form_scope->agents_for_lists( $scope['agent_ids'] ) ] ), 'location' => $descriptor->with_data( [ 'locations' => $this->form_scope->locations_for_lists( $scope['location_ids'] ) ] ), 'extras' => $descriptor->with_data( [ 'extras' => $this->extras_for_step( $session ) ] ), default => $descriptor, }; } /** * Public extras list for the extras step. * * @param BookingSession $session Session. * @return list> */ private function extras_for_step( BookingSession $session ): array { if ( ! $this->extras_entitled() || ! $this->extras instanceof ServiceExtraRepository ) { return []; } $service_id = $session->draft()->service_id(); if ( null === $service_id ) { return []; } $out = []; foreach ( $this->extras->find_active_for_service( $service_id ) as $extra ) { if ( null === $extra->id() ) { continue; } $out[] = [ 'id' => $extra->id(), 'name' => $extra->name(), 'description' => $extra->description(), 'price_minor' => $extra->price()->amount_minor(), 'currency' => $extra->price()->currency(), 'max_quantity' => $extra->max_quantity(), ]; } return $out; } /** * Theme knobs for the widget. * * Preview sessions store the draft theme on the form configuration so * unsaved editor changes restyle the live preview. Public sessions use * the persisted form. * * @param BookingSession $session Session. * @param BookingForm|null $form_entity Persisted form if any. * @return array */ private function theme_from_session( BookingSession $session, ?BookingForm $form_entity ): array { $defaults = BookingForm::default_theme(); $raw = $session->form()->settings()['_theme'] ?? null; if ( is_array( $raw ) && [] !== $raw ) { $theme = $defaults; foreach ( array_keys( $defaults ) as $key ) { if ( isset( $raw[ $key ] ) && is_string( $raw[ $key ] ) ) { $theme[ $key ] = $raw[ $key ]; } } return $theme; } return $form_entity?->theme() ?? $defaults; } /** * Read scoped catalogue id lists from the session form configuration. * * @param BookingSession $session Session. * @return array{agent_ids: list|null, location_ids: list|null} */ private function scope_lists_from_session( BookingSession $session ): array { $settings = $session->form()->settings(); return [ 'agent_ids' => $this->scope_id_list( $settings['_scope_agent_ids'] ?? null ), 'location_ids' => $this->scope_id_list( $settings['_scope_location_ids'] ?? null ), ]; } /** * Normalise a scope id list from form settings. * * @param mixed $raw Raw value. * @return list|null */ private function scope_id_list( mixed $raw ): ?array { if ( null === $raw || ! is_array( $raw ) ) { return null; } $ids = []; foreach ( $raw as $id ) { $int = (int) $id; if ( $int > 0 ) { $ids[] = $int; } } return [] === $ids ? null : array_values( array_unique( $ids ) ); } /** * Confirmation payload after conversion. * * @param BookingSession $session Session. * @param ConversionResult $result Result. * @return array */ private function confirmation_payload( BookingSession $session, ConversionResult $result ): array { $confirmed = $session->with_current_step( 'confirmation' ); $payload = $this->payload( $confirmed ); $payload['order_id'] = $result->order_id(); $payload['converted'] = true; return $payload; } /** * Pay-on-site and skipped-payment journeys land on confirmation without a * separate POST /submit. Convert then, but never before a card/deposit * payment has been taken. * * @param BookingSession $session Session after the step was applied. */ private function should_convert_on_confirmation( BookingSession $session ): bool { $method = $session->totals()['payment_method'] ?? null; return self::converts_when_reaching_confirmation( $session->current_step(), $method ); } /** * Whether landing on confirmation should create the booking now. * * Card and deposit wait for POST /session/{token}/submit after payment. * * @param string $current_step Current step name. * @param mixed $payment_method Chosen method, if any. */ public static function converts_when_reaching_confirmation( string $current_step, mixed $payment_method ): bool { if ( 'confirmation' !== $current_step ) { return false; } if ( is_string( $payment_method ) && in_array( $payment_method, [ 'card', 'deposit' ], true ) ) { return false; } return true; } /** * Ensure the session has enough data to create a booking. * * @param BookingSession $session Session. * * @throws DomainException When incomplete. */ private function assert_ready_to_submit( BookingSession $session ): void { $draft = $session->draft(); if ( null === $draft->service_id() ) { throw new DomainException( 'session.incomplete', 'A service is required before submit.' ); } if ( null === $draft->start_utc() || null === $draft->end_utc() ) { throw new DomainException( 'session.incomplete', 'A date and time are required before submit.' ); } if ( null === $session->customer()->email() || null === $session->customer()->first_name() ) { throw new DomainException( 'session.incomplete', 'Customer details are required before submit.' ); } $agent_id = $draft->agent_id(); if ( null === $agent_id || $agent_id < 1 ) { $agents = $this->agents->find_active( 1, 0 ); if ( [] === $agents || null === $agents[0]->id() ) { throw new DomainException( 'session.incomplete', 'An agent is required before submit.' ); } } } /** * Build a create-booking command from the session. * * @param BookingSession $session Session. * * @throws DomainException When data is incomplete. */ private function command_from_session( BookingSession $session ): CreateBookingCommand { $draft = $session->draft(); $cust = $session->customer(); $tz = $draft->timezone() ?? $session->customer_timezone() ?? 'Europe/London'; $zone = new TimeZoneId( $tz ); $start = $draft->start_utc(); $end = $draft->end_utc(); if ( null === $start || null === $end || null === $draft->service_id() ) { throw new DomainException( 'session.incomplete', 'Booking draft is incomplete.' ); } $buf_s = $draft->buffer_start_utc() ?? $start; $buf_e = $draft->buffer_end_utc() ?? $end; $agent = $draft->agent_id(); if ( null === $agent ) { $agents = $this->agents->find_active( 1, 0 ); $agent = (int) $agents[0]->id(); } $phone = null; if ( null !== $cust->phone() ) { $phone = new PhoneNumber( $cust->phone() ); } $local = $start->setTimezone( $zone->to_date_time_zone() ); return new CreateBookingCommand( (int) $draft->service_id(), $draft->duration_id(), $agent, $draft->location_id(), $start, $end, $buf_s, $buf_e, $zone, $local, $draft->attendees(), new Money( $draft->price_minor(), $draft->currency() ), (string) $cust->first_name(), $cust->last_name(), new EmailAddress( (string) $cust->email() ), $phone, $cust->notes(), 0, true, ); } /** * Refuse a party size outside the service's capacity_min / capacity_max. * * @param array $payload Service step fields. * * @throws DomainException When attendees are out of range. */ private function assert_attendees_within_capacity( array $payload ): void { $service_id = (int) ( $payload['service_id'] ?? 0 ); if ( $service_id < 1 ) { return; } $service = $this->services->find_by_id( $service_id ); if ( null === $service ) { return; } $attendees = isset( $payload['attendees'] ) ? (int) $payload['attendees'] : 1; if ( $attendees > 1 ) { ( $this->named ?? new OpenNamedFeatureGate() )->assert_allows( NamedFeature::GroupBookings ); } if ( $attendees < $service->capacity_min() || $attendees > $service->capacity_max() ) { throw new DomainException( 'booking.attendees_capacity', 'Attendees outside service capacity.' ); } } /** * Whether this install may offer extras on the widget. */ private function extras_entitled(): bool { return ( $this->named ?? new OpenNamedFeatureGate() )->allows( NamedFeature::ExtrasPackages ); } }