*/ public function get_settings(): array { return $this->repository->get_settings(); } /** * @return array */ public function get_state(): array { return $this->repository->get_setup_state(); } /** * Save one wizard step. Input must already be unslashed by the request controller. * * @param int $step Step number. * @param array $input Raw step input. * @return array */ public function save_step( int $step, array $input, bool $advance = true ): array { $step = max( 1, min( self::TOTAL_STEPS, $step ) ); $settings = $this->repository->get_settings(); switch ( $step ) { case 1: $settings['languages'] = $this->sanitize_languages( $input ); break; case 2: $settings['merchant'] = array_replace( $settings['merchant'], $this->sanitize_merchant( $input ) ); break; case 3: // Preserve dev.1/stable free-form contacts until the merchant replaces them // with the structured dev.2 fields. $input['complaints_contact'] = $input['complaints_contact'] ?? $settings['sales']['complaints_contact']; $input['returns_contact'] = $input['returns_contact'] ?? $settings['sales']['returns_contact']; $input['returns_address'] = $input['returns_address'] ?? $settings['sales']['returns_address']; foreach ( array( 'returns_recipient', 'returns_street', 'returns_postcode', 'returns_city', 'returns_country' ) as $return_field ) { $input[ $return_field ] = $input[ $return_field ] ?? $settings['sales'][ $return_field ]; } $settings['sales'] = array_replace( $settings['sales'], $this->sanitize_sales( $input ) ); $settings['merchant'] = array_replace( $settings['merchant'], $this->sanitize_supplier( $input ) ); break; case 4: $settings['terms'] = array_replace( $settings['terms'], $this->sanitize_terms( $input ) ); break; case 5: $settings['delivery'] = array_replace( $settings['delivery'], $this->sanitize_delivery( $input ) ); $settings['sales']['payment_delivery_information_confirmed'] = SetupInputSchema::boolean( $input['payment_delivery_information_confirmed'] ?? false ); break; case 6: $settings['documents'] = array_replace( $settings['documents'], $this->sanitize_documents( $input ) ); break; case 7: break; } $this->repository->save_settings( $settings ); $this->repository->clear_audit_cache(); $next_step = $advance ? min( self::TOTAL_STEPS, $step + 1 ) : $step; $state = array( 'current_step' => $next_step, 'completed' => $advance && 7 === $step, 'updated_at' => $this->now_mysql(), ); $this->repository->save_setup_state( $state ); return $settings; } /** * Validate submitted step data without changing stored state. * * @param int $step Step number. * @param array $input Unslashed step input. * @param bool $for_completion Include merchant declarations required for readiness. * @return array */ public function validate_step( int $step, array $input, bool $for_completion = false, array $facts = array() ): array { $errors = array(); $step = max( 1, min( self::TOTAL_STEPS, $step ) ); if ( 1 === $step ) { $supported = array_keys( LanguageCatalog::supported() ); foreach ( array( 'admin_language', 'default_document_language' ) as $field ) { if ( ! in_array( $this->text( $input[ $field ] ?? '' ), $supported, true ) ) { $errors[] = array( 'field' => $field, 'code' => 'language', ); } } $enabled = isset( $input['enabled_customer_languages'] ) && is_array( $input['enabled_customer_languages'] ) ? $input['enabled_customer_languages'] : array(); if ( empty( array_intersect( $supported, array_map( array( $this, 'text' ), $enabled ) ) ) ) { $errors[] = array( 'field' => 'enabled_customer_languages', 'code' => 'customer_languages', ); } } if ( 2 === $step ) { foreach ( array( 'legal_name', 'registration_authority', 'address', 'city', 'postcode', 'phone', 'establishment' ) as $field ) { if ( '' === trim( $this->text( $input[ $field ] ?? '' ) ) ) { $errors[] = array( 'field' => $field, 'code' => 'required', ); } } foreach ( array( 'registration_number', 'tax_id' ) as $field ) { $value = trim( $this->text( $input[ $field ] ?? '' ) ); if ( '' === $value ) { $errors[] = array( 'field' => $field, 'code' => 'required', ); } elseif ( ! preg_match( '/^[\p{L}\p{N} .\/\-]{2,64}$/u', $value ) ) { $errors[] = array( 'field' => $field, 'code' => 'identifier', ); } } $email = is_scalar( $input['email'] ?? null ) ? trim( (string) $input['email'] ) : ''; if ( '' === $email ) { $errors[] = array( 'field' => 'email', 'code' => 'required', ); } elseif ( ! $this->is_valid_email( $email ) ) { $errors[] = array( 'field' => 'email', 'code' => 'email', ); } $site = is_scalar( $input['site'] ?? null ) ? trim( (string) $input['site'] ) : ''; if ( '' !== $site && ! filter_var( $site, FILTER_VALIDATE_URL ) ) { $errors[] = array( 'field' => 'site', 'code' => 'url', ); } if ( SetupInputSchema::boolean( $input['vat_registered'] ?? false ) && '' === trim( $this->text( $input['vat_number'] ?? '' ) ) ) { $errors[] = array( 'field' => 'vat_number', 'code' => 'required', ); } } if ( 3 === $step ) { $complaints_email = trim( $this->text( $input['complaints_email'] ?? '' ) ); $complaints_phone = trim( $this->text( $input['complaints_phone'] ?? '' ) ); $legacy_complaint = trim( $this->text( $input['complaints_contact'] ?? '' ) ); if ( '' === $complaints_email && '' === $complaints_phone && '' === $legacy_complaint ) { $errors[] = array( 'field' => 'complaints_email', 'code' => 'complaints_channel', ); } elseif ( '' !== $complaints_email && ! $this->is_valid_email( $complaints_email ) ) { $errors[] = array( 'field' => 'complaints_email', 'code' => 'email', ); } $same_returns = SetupInputSchema::boolean( $input['returns_same_as_complaints'] ?? false ); $returns_email = trim( $this->text( $input['returns_email'] ?? '' ) ); $returns_phone = trim( $this->text( $input['returns_phone'] ?? '' ) ); $returns_address = trim( $this->text( $input['returns_address'] ?? '' ) ); $legacy_returns = trim( $this->text( $input['returns_contact'] ?? '' ) ); $use_merchant = SetupInputSchema::boolean( $input['returns_use_merchant_address'] ?? false ); $address_fields = array( 'returns_recipient', 'returns_street', 'returns_postcode', 'returns_city', 'returns_country' ); $address_values = array(); foreach ( $address_fields as $field ) { $address_values[ $field ] = trim( $this->text( $input[ $field ] ?? '' ) ); } $has_structured_address = ! empty( array_filter( $address_values, static fn( string $value ): bool => '' !== $value ) ); if ( $has_structured_address && ! $use_merchant ) { foreach ( $address_values as $field => $value ) { if ( '' === $value ) { $errors[] = array( 'field' => $field, 'code' => 'required', ); } } } if ( ! $same_returns && '' === $returns_email && '' === $returns_phone && '' === $returns_address && '' === $legacy_returns && ! $use_merchant && ! $has_structured_address ) { $errors[] = array( 'field' => 'returns_email', 'code' => 'returns_channel', ); } elseif ( '' !== $returns_email && ! $this->is_valid_email( $returns_email ) ) { $errors[] = array( 'field' => 'returns_email', 'code' => 'email', ); } $relationship = $this->text( $input['supplier_relationship'] ?? 'SAME_AS_MERCHANT' ); if ( 'SEPARATE_CONTRACT_SUPPLIER' === $relationship ) { $name = trim( $this->text( $input['separate_supplier_name'] ?? '' ) ); $email = is_scalar( $input['separate_supplier_email'] ?? null ) ? trim( (string) $input['separate_supplier_email'] ) : ''; if ( '' === $name || ! $this->is_valid_email( $email ) ) { $errors[] = array( 'field' => 'separate_supplier_name', 'code' => 'supplier', ); } } if ( $for_completion ) { foreach ( array( 'distance_sale_registration_confirmed', 'physical_goods_scope_confirmed', 'offer_validity_configured', 'warranty_information_configured' ) as $field ) { if ( ! SetupInputSchema::boolean( $input[ $field ] ?? false ) ) { $errors[] = array( 'field' => $field, 'code' => 'declaration', ); } } } } if ( 4 === $step ) { $mode = $this->text( $input['contract_formation_mode'] ?? '' ); if ( ! empty( $facts ) && ( empty( $facts['terms_page_id'] ) || 'publish' !== (string) ( $facts['terms_page_status'] ?? '' ) || empty( $facts['terms_page_url_present'] ) ) ) { $errors[] = array( 'field' => 'terms_page', 'code' => 'terms_page', ); } $status = $this->key( $input['contract_formation_status'] ?? '' ); $statuses = isset( $facts['order_statuses'] ) && is_array( $facts['order_statuses'] ) ? $facts['order_statuses'] : array(); if ( 'ORDER_STATUS' === $mode && '' === trim( $status ) ) { $errors[] = array( 'field' => 'contract_formation_status', 'code' => 'contract_status', ); } elseif ( 'ORDER_STATUS' === $mode && ! empty( $facts ) && ! isset( $statuses[ $status ] ) ) { $errors[] = array( 'field' => 'contract_formation_status', 'code' => 'contract_status_missing', ); } if ( 'CUSTOM_PROVIDER' === $mode && '' === trim( $this->key( $input['contract_formation_provider'] ?? '' ) ) ) { $errors[] = array( 'field' => 'contract_formation_provider', 'code' => 'contract_provider', ); } if ( $for_completion ) { foreach ( array( 'order_process_information_confirmed', 'terms_accessible_saveable_confirmed', 'rs_payment_obligation_confirmed' ) as $field ) { if ( ! SetupInputSchema::boolean( $input[ $field ] ?? false ) ) { $errors[] = array( 'field' => $field, 'code' => 'declaration', ); } } } } if ( 5 === $step ) { $email_id = $this->key( $input['customer_email_id'] ?? '' ); if ( '' === trim( $email_id ) ) { $errors[] = array( 'field' => 'customer_email_id', 'code' => 'required', ); } elseif ( ! empty( $facts ) && ! $this->email_is_eligible( $email_id, (array) ( $facts['customer_emails'] ?? array() ) ) ) { $errors[] = array( 'field' => 'customer_email_id', 'code' => 'email_route', ); } if ( $for_completion && ! SetupInputSchema::boolean( $input['payment_delivery_information_confirmed'] ?? false ) ) { $errors[] = array( 'field' => 'payment_delivery_information_confirmed', 'code' => 'declaration', ); } } if ( 6 === $step && SetupInputSchema::boolean( $input['basic_pdf_enabled'] ?? false ) ) { $email_id = $this->key( $input['basic_pdf_email_id'] ?? '' ); if ( '' === trim( $email_id ) ) { $errors[] = array( 'field' => 'basic_pdf_email_id', 'code' => 'required', ); } elseif ( ! empty( $facts ) && ! $this->email_is_eligible( $email_id, (array) ( $facts['customer_emails'] ?? array() ) ) ) { $errors[] = array( 'field' => 'basic_pdf_email_id', 'code' => 'email_route', ); } } return $errors; } /** @return array */ public function validate_completion( array $facts = array() ): array { $settings = $this->repository->get_settings(); $inputs = $this->settings_as_step_inputs( $settings ); $errors = array(); foreach ( range( 1, 6 ) as $step ) { foreach ( $this->validate_step( $step, $inputs[ $step ], true, $facts ) as $error ) { $error['step'] = $step; $errors[] = $error; } } return $errors; } /** @return array Current server-authoritative completion state for steps 1-6. */ public function step_completion( array $facts = array() ): array { $inputs = $this->settings_as_step_inputs( $this->repository->get_settings() ); $status = array(); foreach ( range( 1, 6 ) as $step ) { $status[ $step ] = empty( $this->validate_step( $step, $inputs[ $step ], true, $facts ) ); } return $status; } /** * Mark wizard complete after a successful final save/audit visit. */ public function mark_complete(): void { $state = $this->repository->get_setup_state(); $state['current_step'] = self::TOTAL_STEPS; $state['completed'] = true; $state['updated_at'] = $this->now_mysql(); $this->repository->save_setup_state( $state ); } /** * Seed safe convenience values from Woo/WordPress only when corresponding fields are empty. * Legal identity fields remain merchant-confirmed inputs. * * @param array $environment Detected environment facts. */ public function prefill_from_environment( array $environment, bool $persist = true ): array { $settings = $this->repository->get_settings(); $changed = false; $locale = isset( $environment['site_locale'] ) ? (string) $environment['site_locale'] : ''; if ( '' !== $locale && empty( $settings['languages']['admin_language'] ) ) { $settings['languages']['admin_language'] = LanguageCatalog::normalize_locale( $locale ); $changed = true; } $prefills = array( 'address' => $environment['store_address'] ?? '', 'city' => $environment['store_city'] ?? '', 'postcode' => $environment['store_postcode'] ?? '', 'email' => $environment['admin_email'] ?? '', 'site' => $environment['site_url'] ?? '', ); foreach ( $prefills as $key => $value ) { if ( '' === (string) $settings['merchant'][ $key ] && '' !== (string) $value ) { $settings['merchant'][ $key ] = (string) $value; $changed = true; } } if ( $changed && $persist ) { $this->repository->save_settings( $settings ); } return $settings; } /** @return array */ private function sanitize_languages( array $input ): array { $supported = array_keys( LanguageCatalog::supported() ); $admin = $this->choice( $input['admin_language'] ?? '', $supported, LanguageCatalog::BOSNIAN ); if ( SetupInputSchema::boolean( $input['initialize_language_defaults'] ?? false ) ) { return array( 'admin_language' => $admin, 'default_document_language' => $admin, 'enabled_customer_languages' => array( $admin ), ); } $document = $this->choice( $input['default_document_language'] ?? '', $supported, LanguageCatalog::BOSNIAN ); $enabled = isset( $input['enabled_customer_languages'] ) && is_array( $input['enabled_customer_languages'] ) ? $input['enabled_customer_languages'] : array(); $enabled = array_values( array_unique( array_intersect( $supported, array_map( array( $this, 'text' ), $enabled ) ) ) ); if ( empty( $enabled ) ) { $enabled = array( $document ); } if ( ! in_array( $document, $enabled, true ) ) { $enabled[] = $document; } return array( 'admin_language' => $admin, 'default_document_language' => $document, 'enabled_customer_languages' => $enabled, ); } /** @return array */ private function sanitize_merchant( array $input ): array { $establishment = $this->choice( $input['establishment'] ?? '', array( 'FBIH', 'RS', 'BD' ), '' ); return array( 'legal_name' => $this->text( $input['legal_name'] ?? '' ), 'registered_name' => $this->text( $input['registered_name'] ?? '' ), 'registration_number' => $this->text( $input['registration_number'] ?? '' ), 'registration_authority' => $this->text( $input['registration_authority'] ?? '' ), 'tax_id' => $this->text( $input['tax_id'] ?? '' ), 'vat_registered' => SetupInputSchema::boolean( $input['vat_registered'] ?? false ), 'vat_number' => $this->text( $input['vat_number'] ?? '' ), 'address' => $this->multiline( $input['address'] ?? '' ), 'city' => $this->text( $input['city'] ?? '' ), 'postcode' => $this->text( $input['postcode'] ?? '' ), 'country' => 'BA', 'phone' => $this->text( $input['phone'] ?? '' ), 'email' => $this->email( $input['email'] ?? '' ), 'site' => $this->url( $input['site'] ?? '' ), 'establishment' => $establishment, ); } /** @return array */ private function sanitize_sales( array $input ): array { $complaints_email = $this->email( $input['complaints_email'] ?? '' ); $complaints_phone = $this->text( $input['complaints_phone'] ?? '' ); $legacy_complaint = $this->text( $input['complaints_contact'] ?? '' ); $complaints_contact = $this->join_contact( $complaints_email, $complaints_phone, '', $legacy_complaint ); $same_returns = SetupInputSchema::boolean( $input['returns_same_as_complaints'] ?? false ); $returns_email = $this->email( $input['returns_email'] ?? '' ); $returns_phone = $this->text( $input['returns_phone'] ?? '' ); $returns_address = $this->multiline( $input['returns_address'] ?? '' ); $legacy_returns = $this->text( $input['returns_contact'] ?? '' ); $use_merchant = SetupInputSchema::boolean( $input['returns_use_merchant_address'] ?? false ); $returns_contact = $same_returns ? $complaints_contact : $this->join_contact( $returns_email, $returns_phone, $returns_address, $legacy_returns ); return array( 'distance_sale_registration_confirmed' => SetupInputSchema::boolean( $input['distance_sale_registration_confirmed'] ?? false ), 'physical_goods_scope_confirmed' => SetupInputSchema::boolean( $input['physical_goods_scope_confirmed'] ?? false ), 'complaints_contact' => $complaints_contact, 'returns_contact' => $returns_contact, 'complaints_email' => $complaints_email, 'complaints_phone' => $complaints_phone, 'returns_same_as_complaints' => $same_returns, 'returns_email' => $returns_email, 'returns_phone' => $returns_phone, 'returns_address' => $returns_address, 'returns_use_merchant_address' => $use_merchant, 'returns_recipient' => $this->text( $input['returns_recipient'] ?? '' ), 'returns_street' => $this->multiline( $input['returns_street'] ?? '' ), 'returns_postcode' => $this->text( $input['returns_postcode'] ?? '' ), 'returns_city' => $this->text( $input['returns_city'] ?? '' ), 'returns_country' => $this->text( $input['returns_country'] ?? '' ), 'offer_validity_configured' => SetupInputSchema::boolean( $input['offer_validity_configured'] ?? false ), 'warranty_information_configured' => SetupInputSchema::boolean( $input['warranty_information_configured'] ?? false ), 'sells_across_bih' => SetupInputSchema::boolean( $input['sells_across_bih'] ?? false ), 'cross_entity_mode' => 'PRECAUTIONARY_UNION', ); } /** @return array */ private function sanitize_supplier( array $input ): array { $relationship = $this->choice( $input['supplier_relationship'] ?? 'SAME_AS_MERCHANT', array( 'SAME_AS_MERCHANT', 'SEPARATE_CONTRACT_SUPPLIER' ), 'SAME_AS_MERCHANT' ); return array( 'supplier_relationship' => $relationship, 'separate_supplier_name' => $this->text( $input['separate_supplier_name'] ?? '' ), 'separate_supplier_email' => $this->email( $input['separate_supplier_email'] ?? '' ), ); } /** @return array */ private function sanitize_terms( array $input ): array { $mode = $this->choice( $input['contract_formation_mode'] ?? 'ORDER_CREATED', array( 'ORDER_CREATED', 'PAYMENT_COMPLETED', 'ORDER_STATUS', 'CUSTOM_PROVIDER' ), 'ORDER_CREATED' ); return array( 'contract_formation_mode' => $mode, 'contract_formation_status' => $this->key( $input['contract_formation_status'] ?? '' ), 'contract_formation_provider' => $this->key( $input['contract_formation_provider'] ?? '' ), 'order_process_information_confirmed' => SetupInputSchema::boolean( $input['order_process_information_confirmed'] ?? false ), 'terms_accessible_saveable_confirmed' => SetupInputSchema::boolean( $input['terms_accessible_saveable_confirmed'] ?? false ), 'rs_payment_obligation_confirmed' => SetupInputSchema::boolean( $input['rs_payment_obligation_confirmed'] ?? false ), ); } /** @return array */ private function sanitize_delivery( array $input ): array { return array( 'completed_means_dispatched' => SetupInputSchema::boolean( $input['completed_means_dispatched'] ?? false ), 'customer_email_id' => $this->key( $input['customer_email_id'] ?? 'customer_processing_order' ), ); } /** @return array */ private function sanitize_documents( array $input ): array { return array( 'basic_pdf_enabled' => SetupInputSchema::boolean( $input['basic_pdf_enabled'] ?? false ), 'basic_pdf_email_id' => $this->key( $input['basic_pdf_email_id'] ?? 'customer_processing_order' ), ); } private function choice( $value, array $allowed, string $fallback ): string { $value = $this->text( $value ); return in_array( $value, $allowed, true ) ? $value : $fallback; } private function text( $value ): string { $value = is_scalar( $value ) ? (string) $value : ''; return sanitize_text_field( $value ); } private function key( $value ): string { $value = is_scalar( $value ) ? (string) $value : ''; if ( function_exists( 'sanitize_key' ) ) { return sanitize_key( $value ); } return strtolower( preg_replace( '/[^a-zA-Z0-9_\-]/', '', $value ) ?? '' ); } private function multiline( $value ): string { $value = is_scalar( $value ) ? (string) $value : ''; if ( function_exists( 'sanitize_textarea_field' ) ) { return sanitize_textarea_field( $value ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- Standalone service tests only; WordPress always provides sanitize_textarea_field. return strip_tags( $value ); } private function email( $value ): string { $value = is_scalar( $value ) ? (string) $value : ''; if ( function_exists( 'sanitize_email' ) ) { return sanitize_email( $value ); } return filter_var( $value, FILTER_VALIDATE_EMAIL ) ? $value : ''; } private function is_valid_email( string $value ): bool { if ( function_exists( 'is_email' ) ) { return false !== is_email( $value ); } return false !== filter_var( $value, FILTER_VALIDATE_EMAIL ); } private function email_is_eligible( string $id, array $emails ): bool { foreach ( $emails as $email ) { if ( is_array( $email ) && (string) ( $email['id'] ?? '' ) === $id ) { return WooEmailEligibility::facts_are_eligible( $email ); } } return false; } private function join_contact( string $email, string $phone, string $address, string $legacy ): string { $parts = array_filter( array( $email, $phone, $address ), static fn( string $value ): bool => '' !== trim( $value ) ); return empty( $parts ) ? $legacy : implode( ' ยท ', $parts ); } /** * Project stored settings back to the unchanged step input contract. * * @param array $settings Stored settings. * @return array> */ private function settings_as_step_inputs( array $settings ): array { $languages = (array) $settings['languages']; $merchant = (array) $settings['merchant']; $sales = (array) $settings['sales']; $terms = (array) $settings['terms']; $delivery = (array) $settings['delivery']; $documents = (array) $settings['documents']; return array( 1 => $languages, 2 => $merchant, 3 => array_merge( $sales, $merchant ), 4 => $terms, 5 => array_merge( $sales, $delivery ), 6 => $documents, ); } private function url( $value ): string { $value = is_scalar( $value ) ? (string) $value : ''; if ( function_exists( 'esc_url_raw' ) ) { return esc_url_raw( $value ); } return filter_var( $value, FILTER_VALIDATE_URL ) ? $value : ''; } private function now_mysql(): string { if ( function_exists( 'current_time' ) ) { return (string) current_time( 'mysql' ); } return gmdate( 'Y-m-d H:i:s' ); } }