customers = $customers; $this->contacts = $contacts; $this->notes = $notes; $this->activity = $activity; $this->ownership = $ownership; $this->settings = $settings; } /** * Creates a customer and the WordPress user that goes with it. * * @since 0.1.0 * * @param array $raw Unslashed input. * @param string $password Password for the new user. Empty generates a random one. * @return array|WP_Error The new customer. */ public function create( array $raw, string $password = '' ) { $clean = CustomerInput::clean( $raw, true ); if ( $clean['errors'] ) { return $this->invalid( $clean['errors'] ); } $data = $clean['data']; if ( email_exists( $data['email'] ) ) { return $this->conflict( 'email', __( 'A user with this email address already exists.', 'hostkhata' ) ); } $user_id = wp_insert_user( array( 'user_login' => $this->unique_login( $data['email'] ), 'user_email' => $data['email'], 'user_pass' => '' !== $password ? $password : wp_generate_password( 24 ), 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'display_name' => $this->display_name( $data['first_name'], $data['last_name'] ), 'role' => Capabilities::ROLE_CUSTOMER, ) ); if ( is_wp_error( $user_id ) ) { return new WP_Error( 'hostkhata_user_failed', $user_id->get_error_message(), array( 'status' => 400 ) ); } $id = $this->customers->insert( array_merge( $data, array( 'user_id' => $user_id ) ) ); if ( $id < 1 ) { $this->delete_wp_user( $user_id ); return new WP_Error( 'hostkhata_create_failed', __( 'The customer could not be saved.', 'hostkhata' ), array( 'status' => 500 ) ); } $this->ownership->forget_user( $user_id ); $this->log( 'customer.created', $id, __( 'Customer created.', 'hostkhata' ) ); /** * Fires after a customer was created. * * @since 0.1.0 * * @param int $customer_id Customer id. * @param array $data Cleaned customer data. */ do_action( 'hostkhata_customer_created', $id, $data ); return $this->customers->find( $id ); } /** * Makes an existing WordPress user a customer. No user is created: the customer uses the * user's login and email, and the customer role is added to the roles the user already has. * * @since 0.1.1 * * @param int $user_id WordPress user id. * @param array $raw Unslashed input. The email always comes from the user. * @return array|WP_Error The new customer. */ public function create_from_user( int $user_id, array $raw ) { $user = get_userdata( $user_id ); if ( ! $user instanceof WP_User ) { return $this->user_not_found(); } if ( null !== $this->customers->find_by_user_id( $user_id ) ) { return $this->conflict( 'user_id', __( 'This WordPress user is already a customer.', 'hostkhata' ) ); } $raw['email'] = $user->user_email; foreach ( array( 'first_name', 'last_name' ) as $field ) { if ( ! isset( $raw[ $field ] ) || '' === trim( (string) $raw[ $field ] ) ) { $raw[ $field ] = (string) $user->$field; } } if ( '' === trim( (string) $raw['first_name'] ) ) { $raw['first_name'] = '' !== trim( (string) $user->display_name ) ? $user->display_name : $user->user_login; } $clean = CustomerInput::clean( $raw, true ); if ( $clean['errors'] ) { return $this->invalid( $clean['errors'] ); } $data = $clean['data']; $id = $this->customers->insert( array_merge( $data, array( 'user_id' => $user_id ) ) ); if ( $id < 1 ) { return new WP_Error( 'hostkhata_create_failed', __( 'The customer could not be saved.', 'hostkhata' ), array( 'status' => 500 ) ); } $user->add_role( Capabilities::ROLE_CUSTOMER ); $this->ownership->forget_user( $user_id ); $this->log( 'customer.created', $id, __( 'Customer created from an existing WordPress user.', 'hostkhata' ), array( 'user_id' => $user_id ) ); /** This action is documented in CustomerService::create(). */ do_action( 'hostkhata_customer_created', $id, $data ); return $this->customers->find( $id ); } /** * Links a customer to a different WordPress user. Orders, invoices and other records stay with * the customer. The customer role is taken off the user it was linked to before; that user is * kept, so nothing is deleted. * * @since 0.1.1 * * @param int $id Customer id. * @param int $user_id WordPress user id to link. * @return array|WP_Error The customer. */ public function link_user( int $id, int $user_id ) { $customer = $this->customers->find( $id ); if ( null === $customer ) { return $this->not_found(); } if ( CustomerStatus::ANONYMISED === $customer['status'] ) { return new WP_Error( 'hostkhata_anonymised', __( 'An anonymised customer cannot be edited.', 'hostkhata' ), array( 'status' => 409 ) ); } if ( $user_id === $customer['user_id'] ) { return $customer; } $user = get_userdata( $user_id ); if ( ! $user instanceof WP_User ) { return $this->user_not_found(); } if ( null !== $this->customers->find_by_user_id( $user_id ) ) { return $this->conflict( 'user_id', __( 'This WordPress user is already a customer.', 'hostkhata' ) ); } $old_id = $customer['user_id']; if ( ! $this->customers->update( $id, array( 'user_id' => $user_id ) ) ) { return new WP_Error( 'hostkhata_update_failed', __( 'The customer could not be saved.', 'hostkhata' ), array( 'status' => 500 ) ); } $user->add_role( Capabilities::ROLE_CUSTOMER ); $old = get_userdata( $old_id ); if ( $old instanceof WP_User ) { $old->remove_role( Capabilities::ROLE_CUSTOMER ); } $this->ownership->forget_user( $old_id ); $this->ownership->forget_user( $user_id ); $this->log( 'customer.user_linked', $id, __( 'Customer linked to a different WordPress user.', 'hostkhata' ), array( 'from' => $old_id, 'to' => $user_id, ) ); /** * Fires after a customer was linked to a different WordPress user. * * @since 0.1.1 * * @param int $customer_id Customer id. * @param int $old_user_id Previous user id. * @param int $new_user_id New user id. */ do_action( 'hostkhata_customer_user_linked', $id, $old_id, $user_id ); return $this->customers->find( $id ); } /** * Registers a customer from the public form. Only a fixed set of fields is accepted and the * status is decided by the approval setting, never by the visitor. * * @since 0.1.0 * * @param array $raw Unslashed input. * @param string $password Chosen password. * @return array|WP_Error The new customer. */ public function register( array $raw, string $password ) { $allowed = array_intersect_key( $raw, array_flip( array( 'first_name', 'last_name', 'email', 'company', 'phone' ) ) ); $allowed['status'] = $this->settings->get( 'registration_needs_approval' ) ? CustomerStatus::PENDING : CustomerStatus::ACTIVE; return $this->create( $allowed, $password ); } /** * Updates a customer. Only the fields present in the input change. * * @since 0.1.0 * * @param int $id Customer id. * @param array $raw Unslashed input. * @return array|WP_Error The updated customer. */ public function update( int $id, array $raw ) { $customer = $this->customers->find( $id ); if ( null === $customer ) { return $this->not_found(); } if ( CustomerStatus::ANONYMISED === $customer['status'] ) { return new WP_Error( 'hostkhata_anonymised', __( 'An anonymised customer cannot be edited.', 'hostkhata' ), array( 'status' => 409 ) ); } $clean = CustomerInput::clean( $raw, false ); if ( $clean['errors'] ) { return $this->invalid( $clean['errors'] ); } $data = $clean['data']; $user_changes = $this->user_changes( $customer, $data ); if ( is_wp_error( $user_changes ) ) { return $user_changes; } if ( count( $user_changes ) > 1 ) { $result = $this->update_user( $user_changes ); if ( is_wp_error( $result ) ) { return new WP_Error( 'hostkhata_user_failed', $result->get_error_message(), array( 'status' => 400 ) ); } } if ( $data ) { $this->customers->update( $id, $data ); } if ( isset( $data['status'] ) && $data['status'] !== $customer['status'] ) { $this->log( 'customer.status_changed', $id, /* translators: 1: old status, 2: new status. */ sprintf( __( 'Status changed from %1$s to %2$s.', 'hostkhata' ), $customer['status'], $data['status'] ), array( 'from' => $customer['status'], 'to' => $data['status'], ) ); } if ( isset( $data['status'] ) && $data['status'] !== $customer['status'] ) { /** * Fires after a customer's status changed. * * @since 0.1.0 * * @param int $id Customer id. * @param string $from Old status. * @param string $to New status. */ do_action( 'hostkhata_customer_status_changed', $id, $customer['status'], $data['status'] ); } if ( array() !== $data ) { $this->log( 'customer.updated', $id, __( 'Customer details updated.', 'hostkhata' ), array( 'fields' => array_keys( $data ) ) ); } return $this->customers->find( $id ); } /** * Applies one action to several customers. * * @since 0.1.0 * * @param int[] $ids Customer ids. * @param string $action `set_status`, `add_tag` or `remove_tag`. * @param string $value New status, or the tag. * @return array{updated: int, skipped: int}|WP_Error */ public function bulk( array $ids, string $action, string $value ) { $ids = array_slice( array_values( array_unique( array_filter( array_map( array( Sanitizer::class, 'absint' ), $ids ) ) ) ), 0, self::BULK_LIMIT ); if ( ! in_array( $action, array( 'set_status', 'add_tag', 'remove_tag' ), true ) ) { return new WP_Error( 'hostkhata_bad_action', __( 'Unknown bulk action.', 'hostkhata' ), array( 'status' => 400 ) ); } $tag = Tags::parse( array( $value ) ); if ( 'set_status' === $action && ! in_array( $value, CustomerStatus::editable(), true ) ) { return new WP_Error( 'hostkhata_bad_status', __( 'Choose a valid status.', 'hostkhata' ), array( 'status' => 400 ) ); } if ( 'set_status' !== $action && array() === $tag ) { return new WP_Error( 'hostkhata_bad_tag', __( 'Enter a tag.', 'hostkhata' ), array( 'status' => 400 ) ); } $updated = 0; $skipped = 0; foreach ( $ids as $id ) { $customer = $this->customers->find( $id ); if ( null === $customer || CustomerStatus::ANONYMISED === $customer['status'] ) { ++$skipped; continue; } if ( 'set_status' === $action ) { $changes = array( 'status' => $value ); } elseif ( 'add_tag' === $action ) { $changes = array( 'tags' => Tags::parse( array_merge( $customer['tags'], $tag ) ) ); } else { $changes = array( 'tags' => array_values( array_diff( $customer['tags'], $tag ) ) ); } $this->customers->update( $id, $changes ); if ( 'set_status' === $action && $value !== $customer['status'] ) { /** This action is documented in src/Domain/Customers/CustomerService.php. */ do_action( 'hostkhata_customer_status_changed', $id, $customer['status'], $value ); } $this->log( 'customer.bulk_' . $action, $id, __( 'Changed by a bulk action.', 'hostkhata' ), array( 'value' => $value ) ); ++$updated; } return array( 'updated' => $updated, 'skipped' => $skipped, ); } /** * Deletes a customer that has no billing records. Customers with records must be anonymised instead. * * @since 0.1.0 * * @param int $id Customer id. * @return true|WP_Error */ public function delete( int $id ) { $customer = $this->customers->find( $id ); if ( null === $customer ) { return $this->not_found(); } $counts = $this->customers->related_counts( $id ); if ( array_sum( $counts ) > 0 ) { return new WP_Error( 'hostkhata_has_records', __( 'This customer has orders, invoices or other records. Anonymise the customer instead.', 'hostkhata' ), array( 'status' => 409, 'counts' => $counts, ) ); } $this->log( 'customer.deleted', $id, __( 'Customer deleted.', 'hostkhata' ) ); $this->contacts->delete_for_customer( $id ); $this->notes->delete_for_customer( $id ); $this->activity->scrub_customer( $id ); $this->customers->delete( $id ); $this->ownership->forget_user( $customer['user_id'] ); if ( $this->is_customer_only_user( $customer['user_id'] ) ) { $this->delete_wp_user( $customer['user_id'] ); } else { $user = get_userdata( $customer['user_id'] ); if ( $user ) { $user->remove_role( Capabilities::ROLE_CUSTOMER ); } } /** * Fires after a customer was deleted. * * @since 0.1.0 * * @param int $customer_id Customer id. */ do_action( 'hostkhata_customer_deleted', $id ); return true; } /** * Erases a customer's personal data but keeps the row, so invoices and payments stay valid. * * @since 0.1.0 * * @param int $id Customer id. * @return array|WP_Error The anonymised customer. */ public function anonymise( int $id ) { $customer = $this->customers->find( $id ); if ( null === $customer ) { return $this->not_found(); } if ( CustomerStatus::ANONYMISED === $customer['status'] ) { return $customer; } $user_id = $customer['user_id']; if ( ! $this->is_customer_only_user( $user_id ) ) { return new WP_Error( 'hostkhata_not_customer_user', __( 'This customer is linked to a staff account, which cannot be anonymised here.', 'hostkhata' ), array( 'status' => 409 ) ); } global $wpdb; $result = $this->update_user( array( 'ID' => $user_id, 'user_email' => 'anonymised-' . $id . '@anonymised.invalid', 'first_name' => 'Anonymised', 'last_name' => '', 'nickname' => 'anonymised-' . $id, 'display_name' => 'Anonymised customer #' . $id, 'description' => '', 'user_url' => '', 'user_pass' => wp_generate_password( 32 ), ) ); if ( is_wp_error( $result ) ) { return new WP_Error( 'hostkhata_user_failed', $result->get_error_message(), array( 'status' => 500 ) ); } // The login name often contains the person's name; wp_update_user() cannot change it. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- WordPress has no API to change a login name. $wpdb->update( $wpdb->users, array( 'user_login' => 'anonymised-' . $id, 'user_nicename' => 'anonymised-' . $id, ), array( 'ID' => $user_id ), array( '%s', '%s' ), array( '%d' ) ); clean_user_cache( $user_id ); WP_Session_Tokens::get_instance( $user_id )->destroy_all(); $this->customers->update( $id, array( 'first_name' => 'Anonymised', 'last_name' => '', 'company' => '', 'phone' => '', 'address_1' => '', 'address_2' => '', 'city' => '', 'state' => '', 'postcode' => '', 'country' => '', 'tags' => array(), 'reminders_muted' => true, 'status' => CustomerStatus::ANONYMISED, ) ); $this->contacts->delete_for_customer( $id ); $this->notes->delete_for_customer( $id ); $this->activity->scrub_customer( $id ); $this->log( 'customer.anonymised', $id, __( 'Personal data erased.', 'hostkhata' ) ); /** * Fires after a customer was anonymised. * * @since 0.1.0 * * @param int $customer_id Customer id. */ do_action( 'hostkhata_customer_anonymised', $id ); return $this->customers->find( $id ); } /** * Adds a note. * * @since 0.1.0 * * @param int $id Customer id. * @param string $text Note text. * @param string $visibility `internal` or `customer`. * @return array|WP_Error The new note. */ public function add_note( int $id, string $text, string $visibility ) { if ( null === $this->customers->find( $id ) ) { return $this->not_found(); } $text = Sanitizer::textarea( $text ); if ( '' === $text ) { return $this->invalid( array( 'note' => __( 'Write a note.', 'hostkhata' ) ) ); } if ( mb_strlen( $text ) > self::NOTE_MAX_LENGTH ) { /* translators: %d: maximum number of characters. */ return $this->invalid( array( 'note' => sprintf( __( 'Use at most %d characters.', 'hostkhata' ), self::NOTE_MAX_LENGTH ) ) ); } $visibility = Sanitizer::enum( $visibility, array( 'internal', 'customer' ), 'internal' ); $note_id = $this->notes->insert( $id, $text, $visibility, get_current_user_id() ); if ( $note_id < 1 ) { return new WP_Error( 'hostkhata_create_failed', __( 'The note could not be saved.', 'hostkhata' ), array( 'status' => 500 ) ); } $this->log( 'customer.note_added', $id, __( 'Note added.', 'hostkhata' ), array( 'visibility' => $visibility ) ); return $this->notes->find( $note_id ) + array( 'author_name' => wp_get_current_user()->display_name ); } /** * Deletes a note of a customer. * * @since 0.1.0 * * @param int $id Customer id. * @param int $note_id Note id. * @return true|WP_Error */ public function delete_note( int $id, int $note_id ) { $note = $this->notes->find( $note_id ); if ( null === $note || $note['customer_id'] !== $id ) { return $this->not_found(); } $this->notes->delete( $note_id ); $this->log( 'customer.note_deleted', $id, __( 'Note deleted.', 'hostkhata' ) ); return true; } /** * Adds a contact. * * @since 0.1.0 * * @param int $id Customer id. * @param array $raw Unslashed input. * @return array|WP_Error The new contact. */ public function add_contact( int $id, array $raw ) { if ( null === $this->customers->find( $id ) ) { return $this->not_found(); } $clean = $this->clean_contact( $raw ); if ( $clean['errors'] ) { return $this->invalid( $clean['errors'] ); } $contact_id = $this->contacts->insert( $id, $clean['data'] ); if ( $contact_id < 1 ) { return new WP_Error( 'hostkhata_create_failed', __( 'The contact could not be saved.', 'hostkhata' ), array( 'status' => 500 ) ); } if ( $clean['data']['is_primary'] ) { $this->contacts->clear_primary_except( $id, $contact_id ); } $this->log( 'customer.contact_added', $id, __( 'Contact added.', 'hostkhata' ) ); return $this->contacts->find( $contact_id ); } /** * Updates a contact. * * @since 0.1.0 * * @param int $id Customer id. * @param int $contact_id Contact id. * @param array $raw Unslashed input. * @return array|WP_Error The updated contact. */ public function update_contact( int $id, int $contact_id, array $raw ) { $contact = $this->contacts->find( $contact_id ); if ( null === $contact || $contact['customer_id'] !== $id ) { return $this->not_found(); } $clean = $this->clean_contact( array_merge( $contact, $raw ) ); if ( $clean['errors'] ) { return $this->invalid( $clean['errors'] ); } $this->contacts->update( $contact_id, $clean['data'] ); if ( $clean['data']['is_primary'] ) { $this->contacts->clear_primary_except( $id, $contact_id ); } $this->log( 'customer.contact_updated', $id, __( 'Contact updated.', 'hostkhata' ) ); return $this->contacts->find( $contact_id ); } /** * Deletes a contact. * * @since 0.1.0 * * @param int $id Customer id. * @param int $contact_id Contact id. * @return true|WP_Error */ public function delete_contact( int $id, int $contact_id ) { $contact = $this->contacts->find( $contact_id ); if ( null === $contact || $contact['customer_id'] !== $id ) { return $this->not_found(); } $this->contacts->delete( $contact_id ); $this->log( 'customer.contact_deleted', $id, __( 'Contact deleted.', 'hostkhata' ) ); return true; } /** * Cleans and validates a contact. * * @param array $raw Input. * @return array{data: array, errors: array} */ private function clean_contact( array $raw ): array { $data = array( 'name' => Sanitizer::text( $raw['name'] ?? '' ), 'email' => Sanitizer::email( $raw['email'] ?? '' ), 'phone' => Sanitizer::text( $raw['phone'] ?? '' ), 'job_role' => Sanitizer::text( $raw['job_role'] ?? '' ), 'is_primary' => Sanitizer::boolean( $raw['is_primary'] ?? false ), ); $errors = ( new Validator() )->validate( $data, array( 'name' => 'required|max_length:100', 'email' => 'email|max_length:190', 'phone' => 'max_length:40', 'job_role' => 'max_length:60', ) )->first_per_field(); $sent_email = isset( $raw['email'] ) && is_scalar( $raw['email'] ) && '' !== trim( (string) $raw['email'] ); if ( $sent_email && '' === $data['email'] && ! isset( $errors['email'] ) ) { $errors['email'] = __( 'Enter a valid email address.', 'hostkhata' ); } return array( 'data' => $data, 'errors' => $errors, ); } /** * Works out what must change on the WordPress user. The returned array always holds `ID`. * * @param array $customer Current customer. * @param array $data Cleaned changes. * @return array|WP_Error */ private function user_changes( array $customer, array $data ) { $changes = array( 'ID' => $customer['user_id'] ); $touches_user = isset( $data['email'] ) || isset( $data['first_name'] ) || isset( $data['last_name'] ); if ( ! $touches_user ) { return $changes; } if ( ! $this->is_customer_only_user( $customer['user_id'] ) ) { return new WP_Error( 'hostkhata_not_customer_user', __( 'This customer is linked to a staff account. Change the name or email in the WordPress user profile instead.', 'hostkhata' ), array( 'status' => 409 ) ); } if ( isset( $data['email'] ) && strtolower( $data['email'] ) !== strtolower( (string) $customer['user_email'] ) ) { $owner = email_exists( $data['email'] ); if ( $owner && (int) $owner !== $customer['user_id'] ) { return $this->conflict( 'email', __( 'A user with this email address already exists.', 'hostkhata' ) ); } $changes['user_email'] = $data['email']; } if ( isset( $data['first_name'] ) || isset( $data['last_name'] ) ) { $first = $data['first_name'] ?? (string) $customer['first_name']; $last = $data['last_name'] ?? (string) $customer['last_name']; $changes['first_name'] = $first; $changes['last_name'] = $last; $changes['display_name'] = $this->display_name( $first, $last ); } return $changes; } /** * Tells whether a user exists only as a customer: it holds the customer role and no staff role. * This guards every action that edits or removes a WordPress user. * * @param int $user_id User id. * @return bool */ private function is_customer_only_user( int $user_id ): bool { $user = get_userdata( $user_id ); return $user instanceof WP_User && array( Capabilities::ROLE_CUSTOMER ) === array_values( $user->roles ) && ! user_can( $user, 'manage_options' ); } /** * Updates a WordPress user without WordPress emailing the customer about the change. * * @param array $args Arguments for wp_update_user(), including `ID`. * @return int|WP_Error */ private function update_user( array $args ) { add_filter( 'send_email_change_email', '__return_false' ); add_filter( 'send_password_change_email', '__return_false' ); $result = wp_update_user( $args ); remove_filter( 'send_email_change_email', '__return_false' ); remove_filter( 'send_password_change_email', '__return_false' ); return $result; } /** * Deletes a WordPress user. * * @param int $user_id User id. * @return void */ private function delete_wp_user( int $user_id ): void { require_once ABSPATH . 'wp-admin/includes/user.php'; if ( $this->is_customer_only_user( $user_id ) ) { wp_delete_user( $user_id ); } } /** * Builds a unique login name from an email address. * * @param string $email Email address. * @return string */ private function unique_login( string $email ): string { $base = sanitize_user( (string) strstr( $email, '@', true ), true ); $base = '' === $base ? 'customer' : substr( $base, 0, 40 ); $name = $base; $i = 1; while ( username_exists( $name ) ) { ++$i; $name = $base . $i; } return $name; } /** * Display name from first and last name. * * @param string $first First name. * @param string $last Last name. * @return string */ private function display_name( string $first, string $last ): string { return trim( $first . ' ' . $last ); } /** * Writes an activity entry for a customer. * * @param string $action Action key. * @param int $customer_id Customer id. * @param string $message Message without personal data. * @param array $context Extra data without personal data. * @return void */ private function log( string $action, int $customer_id, string $message, array $context = array() ): void { $this->activity->insert( array( 'actor_id' => get_current_user_id(), 'customer_id' => $customer_id, 'object_type' => 'customer', 'object_id' => $customer_id, 'action' => $action, 'message' => $message, 'context' => $context, 'ip_address' => Helpers::client_ip(), ) ); } /** * Validation error. * * @param array $errors Messages keyed by field. * @return WP_Error */ private function invalid( array $errors ): WP_Error { return new WP_Error( 'hostkhata_invalid_input', __( 'Please check the highlighted fields.', 'hostkhata' ), array( 'status' => 400, 'errors' => $errors, ) ); } /** * Conflict error on one field. * * @param string $field Field name. * @param string $message Message. * @return WP_Error */ private function conflict( string $field, string $message ): WP_Error { return new WP_Error( 'hostkhata_conflict', $message, array( 'status' => 409, 'errors' => array( $field => $message ), ) ); } /** * Not found error. * * @return WP_Error */ private function not_found(): WP_Error { return new WP_Error( 'hostkhata_not_found', __( 'Customer not found.', 'hostkhata' ), array( 'status' => 404 ) ); } /** * Not found error for a WordPress user. * * @return WP_Error */ private function user_not_found(): WP_Error { return new WP_Error( 'hostkhata_user_not_found', __( 'WordPress user not found.', 'hostkhata' ), array( 'status' => 404 ) ); } }