repository = $repository ?? new Repository(); } public function callbacks(): array { return array( 'validate' => array( $this, 'validate' ), 'write' => array( $this, 'write' ), 'removable' => array( $this, 'removable' ), 'remove' => array( $this, 'remove' ) ); } private static function instant( $value ): \DateTimeImmutable { if ( ! is_string( $value ) ) { throw new \DomainException( 'import_utc' ); } $instant = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $value, new \DateTimeZone( 'UTC' ) ); if ( ! $instant || $instant->format( 'Y-m-d H:i:s' ) !== $value || $value < '1000-01-01 00:00:00' || $value > '9999-12-31 23:59:59' ) { throw new \DomainException( 'import_utc' ); } return $instant; } private static function permitted(): void { if ( ! Service::permitted( 'appointments' ) ) { throw new \DomainException( 'import_permission' ); } } private static function owner( callable $owner ): void { self::permitted(); if ( ! BookingLock::is_held() || $owner() !== true || ! Repository::owned() ) { throw new \RuntimeException( 'import_owner' ); } } /** Pure authoritative preview and fresh per-row validation inside the batch transaction. */ public function validate( array $candidate, bool $historical ): array { self::permitted(); $fields = array( 'customer_id', 'service_id', 'staff_id', 'location_id', 'status_id', 'start_utc', 'end_utc', 'timezone', 'duration_minutes', 'price_cents', 'currency', 'source', 'payment_status', '_booking_quote', '_booking_answers' ); if ( array_diff( array_keys( $candidate ), $fields ) ) { throw new \DomainException( 'import_unknown_field' ); } foreach ( array( 'service_id', 'staff_id', 'location_id', 'status_id' ) as $key ) { Service::integer( $candidate[ $key ] ?? null, 1 ); } $customer = Service::integer( $candidate['customer_id'] ?? null ); if ( $customer && ! $this->repository->find( 'customers', $customer ) ) { throw new \DomainException( 'import_reference' ); } $start = self::instant( $candidate['start_utc'] ?? null ); $end = self::instant( $candidate['end_utc'] ?? null ); $duration = Service::integer( $candidate['duration_minutes'] ?? null, 1, 1440 ); Service::integer( $candidate['price_cents'] ?? null ); if ( $start->modify( '+' . $duration . ' minutes' ) != $end || ! is_string( $candidate['currency'] ?? null ) || ! preg_match( '/^[A-Z]{3}$/D', $candidate['currency'] ) || ( $candidate['source'] ?? '' ) !== 'import' || ( $candidate['payment_status'] ?? '' ) !== 'unpaid' ) { throw new \DomainException( 'import_appointment_validation' ); } $timezone = $candidate['timezone'] ?? null; if ( ! is_string( $timezone ) || ( $timezone !== 'UTC' && ! in_array( $timezone, \DateTimeZone::listIdentifiers(), true ) ) ) { throw new \DomainException( 'import_timezone' ); } $now = new \DateTimeImmutable( 'now', new \DateTimeZone( 'UTC' ) ); if ( $historical !== ( $end <= $now ) || ( ! $historical && $start < $now ) ) { throw new \DomainException( 'import_ongoing' ); } Service::destination( 'staff', (int) $candidate['staff_id'] ); Service::destination( 'locations', (int) $candidate['location_id'] ); $service = $this->repository->find( 'services', (int) $candidate['service_id'] ); $staff = $this->repository->find( 'staff', (int) $candidate['staff_id'] ); $location = $this->repository->find( 'locations', (int) $candidate['location_id'] ); if ( ! $service || ! $staff || ! $location ) { throw new \DomainException( 'import_reference' ); } global $wpdb; $statuses = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d', $this->repository->table( 'booking_statuses' ), (int) $candidate['status_id'] ), ARRAY_A ), 'import_storage' ); if ( count( $statuses ) !== 1 || ! in_array( $statuses[0]['slug'], $historical ? array( 'pending', 'confirmed', 'completed', 'cancelled', 'no_show' ) : array( 'pending', 'confirmed' ), true ) ) { throw new \DomainException( 'import_status' ); } unset( $candidate['_booking_quote'], $candidate['_booking_answers'] ); if ( $historical ) { return $candidate; } if ( ! Service::pro_enabled() ) { $selections = new \Abientot\Repositories\BookingSelectionsRepository(); if ( $selections->present() && Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT service_id FROM %i WHERE service_id=%d LIMIT 1', $this->repository->table( 'service_selection_definitions' ), (int) $service['id'] ), ARRAY_A ), 'import_storage' ) ) { throw new \DomainException( 'import_appointment_validation' ); } if ( ( new \Abientot\Repositories\ResourceCapacityRepository() )->requirements( (int) $service['id'], (int) $location['id'] ) ) { throw new \DomainException( 'import_appointment_validation' ); } } if ( $service['status'] !== 'active' || $service['visibility'] !== 'public' || $staff['status'] !== 'active' || $location['status'] !== 'active' ) { throw new \DomainException( 'import_reference' ); } if ( ! Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id FROM %i WHERE staff_id=%d AND service_id=%d LIMIT 1', $this->repository->table( 'staff_services' ), (int) $staff['id'], (int) $service['id'] ), ARRAY_A ), 'import_storage' ) ) { throw new \DomainException( 'import_reference' ); } $reader = new \Abientot\Repositories\ResourceCapacityRepository(); if ( $reader->exists( 'staff_locations' ) ) { $links = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT location_id FROM %i WHERE staff_id=%d', $this->repository->table( 'staff_locations' ), (int) $staff['id'] ), ARRAY_A ), 'import_storage' ); if ( $links ? ! in_array( (int) $location['id'], array_map( 'intval', array_column( $links, 'location_id' ) ), true ) : empty( $location['is_default'] ) ) { throw new \DomainException( 'import_reference' ); } } elseif ( empty( $location['is_default'] ) ) { throw new \DomainException( 'import_reference' ); } try { $quoteService = \Abientot\Support\BookingQuote::service( $service, (int) $location['id'], array() ); foreach ( array( 'duration_minutes', 'price_cents', 'currency' ) as $key ) { if ( (string) $quoteService[ $key ] !== (string) $candidate[ $key ] ) { throw new \DomainException( 'import_appointment_validation' ); } } if ( isset( $quoteService['_booking_quote'] ) ) { $quote = $quoteService['_booking_quote']; $answers = apply_filters( 'abientot_import_selection_answers', null, (int) $service['id'], $quote ); if ( ! is_array( $answers ) ) { throw new \DomainException( 'import_appointment_adapter' ); } $candidate['_booking_quote'] = $quote; $candidate['_booking_answers'] = $answers; } } catch ( \DomainException $error ) { throw new \DomainException( 'import_appointment_validation' ); } $decision = ( new \Abientot\Availability\AvailabilityService() )->validate_instant( array( 'service_id' => (int) $service['id'], 'staff_id' => (int) $staff['id'], 'location_id' => (int) $location['id'], 'duration_minutes' => $duration, 'booking_quote' => $candidate['_booking_quote'] ?? null ), $start ); if ( empty( $decision['available'] ) ) { throw new \DomainException( 'import_appointment_validation' ); } foreach ( array( 'service_id', 'staff_id', 'location_id', 'timezone' ) as $key ) { if ( (string) $decision[ $key ] !== (string) $candidate[ $key ] ) { throw new \DomainException( 'import_timezone' ); } } return $candidate; } public function write( array $candidate, array $context, callable $owner ): int { self::owner( $owner ); $this->engine( 'appointments' ); $this->engine( 'payment_records' ); if ( count( $context ) !== 3 || array_diff( array_keys( $context ), array( 'historical', 'notify', 'source' ) ) || ! is_bool( $context['historical'] ) || $context['notify'] !== false || $context['source'] !== 'import' ) { throw new \DomainException( 'import_appointment_validation' ); } $fresh = $this->validate( $candidate, $context['historical'] ); if ( ! hash_equals( Token::digest( $candidate ), Token::digest( $fresh ) ) || (int) $fresh['customer_id'] < 1 ) { throw new \DomainException( 'import_stale' ); } $quote = $fresh['_booking_quote'] ?? null; $answers = $fresh['_booking_answers'] ?? null; unset( $fresh['_booking_quote'], $fresh['_booking_answers'] ); $stamp = gmdate( 'Y-m-d H:i:s' ); $data = array_merge( $fresh, array( 'customer_fields_json' => null, 'customer_note' => '', 'internal_note' => '', 'cancelled_at' => null, 'created_at' => $stamp, 'updated_at' => $stamp ) ); global $wpdb; $previous = $wpdb->suppress_errors( true ); try { $this->checked( $wpdb->insert( $this->repository->table( 'appointments' ), $data ) ); $id = (int) $wpdb->insert_id; if ( $id < 1 ) { throw new \RuntimeException( 'import_write' ); } $after = $this->repository->find( 'appointments', $id ); if ( ! $after ) { throw new \RuntimeException( 'import_write' ); } if ( $quote !== null ) { if ( true !== apply_filters( 'abientot_import_selection_persist', false, $after, $quote, $answers, $owner ) ) { throw new \DomainException( 'import_appointment_adapter' ); } } BookingConstraints::apply( $after, null, $owner ); if ( ! ( new \Abientot\Repositories\PaymentRecordsRepository() )->mark_manual_status( $id, 'unpaid', (int) $after['price_cents'], $after['currency'] ) ) { throw new \RuntimeException( 'import_write' ); } $this->checked( true ); self::owner( $owner ); return $id; } finally { $wpdb->suppress_errors( $previous ); } } private function engine( string $name ): void { global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $this->repository->table( $name ) ), ARRAY_A ), 'import_storage' ); if ( count( $rows ) !== 1 || strtoupper( (string) $rows[0]['ENGINE'] ) !== 'INNODB' ) { throw new \RuntimeException( 'import_storage' ); } } private function checked( $value ): void { global $wpdb; if ( $value === false || $wpdb->last_error !== '' ) { throw new \RuntimeException( 'import_write' ); } } /** Inspect every plugin-owned table with a direct canonical booking link; unknown stores veto removal. */ private function links( int $id ): array { global $wpdb; $links = array(); foreach ( $this->repository->appointment_link_metadata() as $table => $metadata ) { $name = $metadata['name']; if ( $name === 'appointments' ) { continue; } $columns = $metadata['columns']; $a = in_array( 'appointment_id', $columns, true ); $b = in_array( 'booking_id', $columns, true ); $o = in_array( 'owner_type', $columns, true ) && in_array( 'owner_id', $columns, true ); $j = in_array( 'object_type', $columns, true ) && in_array( 'object_id', $columns, true ); if ( ! $a && ! $b && ! $o && ! $j ) { continue; } $c = (string) $columns[0]; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE ( %d = 1 AND %i = %d ) OR ( %d = 1 AND %i = %d ) OR ( %d = 1 AND %i = %s AND %i = %d ) OR ( %d = 1 AND %i = %s AND %i = %d ) LIMIT 202', $table, (int) $a, $a ? 'appointment_id' : $c, $id, (int) $b, $b ? 'booking_id' : $c, $id, (int) $o, $o ? 'owner_type' : $c, 'appointment', $o ? 'owner_id' : $c, $id, (int) $j, $j ? 'object_type' : $c, 'appointment', $j ? 'object_id' : $c, $id ), ARRAY_A ), 'import_storage' ); if ( count( $rows ) > 201 ) { throw new \RuntimeException( 'import_storage' ); } if ( $rows ) { usort( $rows, static fn( $a, $b )=>strcmp( wp_json_encode( $a ), wp_json_encode( $b ) ) ); $links[ $name ] = $rows; } } ksort( $links ); return $links; } /** Ledger must persist this exact digest through Repository::owned_revision. */ public function revision( int $id ): string { $row = $this->repository->find( 'appointments', $id ); return $row ? Token::digest( array( 'row' => $row, 'links' => $this->links( $id ) ) ) : ''; } public function removable( int $id, string $revision ): bool { self::permitted(); try { $row = $this->repository->find( 'appointments', $id ); if ( ! $row || $row['source'] !== 'import' || $row['payment_status'] !== 'unpaid' || ! hash_equals( $revision, $this->revision( $id ) ) ) { return false; } $this->engine( 'appointments' ); $links = $this->links( $id ); foreach ( $links as $name => $records ) { if ( ! in_array( $name, array( 'payment_records', 'payment_plans', 'resource_allocations', 'booking_selections' ), true ) ) { return false; } $this->engine( $name ); if ( $name === 'payment_records' ) { if ( count( $records ) !== 1 ) { return false; } $receipt = $records[0]; if ( $receipt['provider'] !== 'manual' || $receipt['method'] !== 'on_site' || $receipt['status'] !== 'unpaid' || $receipt['paid_at'] !== null || (int) $receipt['amount_cents'] !== (int) $row['price_cents'] || $receipt['currency'] !== $row['currency'] || ! empty( $receipt['refunded_cents'] ) || ! empty( $receipt['collection_frozen'] ) || ! empty( $receipt['privacy_erased'] ) || ! empty( $receipt['provider_reference'] ) || ! empty( $receipt['provider_payment_id'] ) ) { return false; } global $wpdb; $read = new \Abientot\Repositories\ResourceCapacityRepository(); if ( $read->exists( 'payment_refunds' ) && Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id FROM %i WHERE payment_record_id=%d LIMIT 1', $this->repository->table( 'payment_refunds' ), (int) $receipt['id'] ), ARRAY_A ), 'import_storage' ) ) { return false; } } if ( $name === 'payment_plans' ) { if ( count( $records ) !== 1 || ! empty( $records[0]['collection_frozen'] ) || ! empty( $records[0]['privacy_erased'] ) || (int) $records[0]['gross_cents'] !== (int) $row['price_cents'] || $records[0]['currency'] !== $row['currency'] ) { return false; } } } return true === apply_filters( 'abientot_import_record_removable', true, 'appointments', $id ); } catch ( \Throwable $error ) { return false; } } public function remove( int $id, callable $owner ): void { self::owner( $owner ); $revision = $this->revision( $id ); global $wpdb; $fragment = '"kind":"appointments","id":' . $id . ','; $ledgers = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT ownership_json FROM %i WHERE state='committed' AND ownership_json LIKE %s LIMIT 2", $this->repository->table( 'import_rows' ), '%' . $wpdb->esc_like( $fragment ) . '%' ), ARRAY_A ), 'import_storage' ); $matches = 0; foreach ( $ledgers as $ledger ) { $data = json_decode( $ledger['ownership_json'], true, 16, JSON_THROW_ON_ERROR ); foreach ( $data['created'] ?? array() as $record ) { if ( $record['kind'] === 'appointments' && (int) $record['id'] === $id && hash_equals( $record['revision'], $revision ) ) { ++$matches; } } } if ( $matches !== 1 || ! $this->removable( $id, $revision ) ) { throw new \DomainException( 'import_rollback_unsafe' ); } $links = $this->links( $id ); foreach ( $links as $name => $records ) { self::owner( $owner ); $where = $name === 'resource_allocations' ? array( 'owner_type' => 'appointment', 'owner_id' => $id ) : array( 'appointment_id' => $id ); $this->checked( $wpdb->delete( $this->repository->table( $name ), $where ) ); } $this->checked( $wpdb->delete( $this->repository->table( 'appointments' ), array( 'id' => $id ) ) ); self::owner( $owner ); } }