I18n::t( 'Customer notes and preferences' ), 'callback' => array(__CLASS__, 'export'), ); return $exporters; } ); add_filter( 'wp_privacy_personal_data_erasers', static function ( $erasers ) { $erasers['storesuit-customer'] = array( 'eraser_friendly_name' => I18n::t( 'Customer notes and preferences' ), 'callback' => array(__CLASS__, 'erase'), ); return $erasers; } ); // WooCommerce's own customer eraser empties the billing phone, which the credit check keeps as an earlier phone; whichever // eraser runs last, none is left behind (review of S13 and S17, 6). add_filter( 'woocommerce_privacy_erase_personal_data_customer', array(__CLASS__, 'erase_phones'), 10, 2 ); } /** * woocommerce_privacy_erase_personal_data_customer: after WooCommerce's customer eraser saved the account, the earlier billing * phones its emptied number was just added to are removed too. * @param mixed $response * @return mixed */ public static function erase_phones( $response, $customer ) { if ( !$customer instanceof \WC_Customer || !$customer->get_id() || !get_user_meta( $customer->get_id(), self::PHONES, true ) ) { return $response; } delete_user_meta( $customer->get_id(), self::PHONES ); if ( is_array( $response ) ) { $response['items_removed'] = true; } return $response; } /** WordPress privacy export: the team's tags, contact preferences and notes kept on the account with this email. */ public static function export( string $email, int $page = 1 ) : array { $user = get_user_by( 'email', $email ); if ( !$user ) { return array( 'data' => array(), 'done' => true, ); } $raw = get_user_meta( $user->ID, self::PROFILE, true ); $raw = ( is_array( $raw ) ? $raw : array() ); $group = array( 'group_id' => 'storesuit-customer', 'group_label' => I18n::t( 'Customer notes and preferences' ), ); $fields = array(array( 'name' => I18n::t( 'Customer tags' ), 'value' => implode( ', ', array_map( 'sanitize_text_field', (array) ($raw['tags'] ?? array()) ) ), )); foreach ( self::CONTACTS as $channel ) { $fields[] = array( 'name' => I18n::format( 'Contact preference: %s', $channel ), 'value' => sanitize_key( (string) ($raw['contact'][$channel]['status'] ?? 'unknown') ), ); } // The B2B account's company (credit terms, paid build), which erase() empties too (security audit S17). $trade = get_user_meta( $user->ID, self::TRADE, true ); if ( is_array( $trade ) && is_scalar( $trade['company'] ?? null ) && trim( (string) $trade['company'] ) !== '' ) { $fields[] = array( 'name' => I18n::t( 'B2B account company' ), 'value' => sanitize_text_field( (string) $trade['company'] ), ); } // The billing phones the account held before (the paid build's credit check keeps them), which erase() removes too. $phones = get_user_meta( $user->ID, self::PHONES, true ); $phones = ( is_array( $phones ) ? array_values( array_filter( array_map( static fn( $p ) => ( is_scalar( $p ) ? sanitize_text_field( (string) $p ) : '' ), $phones ) ) ) : array() ); if ( $phones ) { $fields[] = array( 'name' => I18n::t( 'Earlier billing phones' ), 'value' => implode( ', ', $phones ), ); } $data = array($group + array( 'item_id' => 'storesuit-customer-' . $user->ID, 'data' => $fields, )); foreach ( self::stored_notes( $user->ID ) as $note ) { $note = self::public_note( $note ); $data[] = $group + array( 'item_id' => 'storesuit-customer-note-' . $note['id'], 'data' => array(array( 'name' => I18n::t( 'Team note' ), 'value' => $note['text'], ), array( 'name' => I18n::t( 'Date' ), 'value' => $note['at'], )), ); } return array( 'data' => $data, 'done' => true, ); } /** * WordPress privacy erasure: removes the team's tags, contact preferences and notes from the account, and the B2B account's * company (security audit S17) and the billing phones it held before (its review, 6). The B2B credit terms (payment term, limit) * stay with the account, and the answer says so. */ public static function erase( string $email, int $page = 1 ) : array { $user = get_user_by( 'email', $email ); $had = $user && (get_user_meta( $user->ID, self::PROFILE, true ) || get_user_meta( $user->ID, self::NOTES, true ) || get_user_meta( $user->ID, self::PHONES, true )); $trade = ( $user ? get_user_meta( $user->ID, self::TRADE, true ) : null ); $company = is_array( $trade ) && is_scalar( $trade['company'] ?? null ) && (string) $trade['company'] !== ''; if ( $company ) { $trade['company'] = ''; update_user_meta( $user->ID, self::TRADE, $trade ); } if ( $had ) { delete_user_meta( $user->ID, self::PROFILE ); delete_user_meta( $user->ID, self::NOTES ); delete_user_meta( $user->ID, self::PHONES ); } if ( $had || $company ) { Store::audit( 'customer', $user->ID, 'customer_privacy_erased' ); } $terms = is_array( $trade ); return array( 'items_removed' => $had || $company, 'items_retained' => $terms, 'messages' => ( $terms ? array(I18n::t( 'The B2B credit terms (payment term and credit limit) stay with the account.' )) : array() ), 'done' => true, ); } private static function user( int $id ) : \WP_User { $user = get_user_by( 'id', $id ); if ( !$user ) { throw new \InvalidArgumentException(I18n::t( 'Customer not found.' )); } return $user; } private static function stored_notes( int $id ) : array { $notes = get_user_meta( $id, self::NOTES, true ); return ( is_array( $notes ) ? array_values( array_filter( $notes, 'is_array' ) ) : array() ); } /** * A stored note as shown and exported. Notes are saved with the GMT time (add_note()); 'at' is the store's own time, so a * note written at 00:45 in Madrid shows that day, not the day before (KIT-4, P3-15). Storage stays GMT. */ private static function public_note( array $note ) : array { return array( 'id' => sanitize_text_field( (string) ($note['id'] ?? '') ), 'text' => sanitize_textarea_field( (string) ($note['text'] ?? '') ), 'actor' => absint( $note['actor'] ?? 0 ), 'at' => self::store_time( (string) ($note['at'] ?? '') ), ); } /** A GMT "Y-m-d H:i:s" as the store's wall time in the same format; '' stays ''. */ private static function store_time( string $gmt ) : string { $gmt = sanitize_text_field( $gmt ); return ( preg_match( '/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}(:\\d{2})?$/', $gmt ) ? get_date_from_gmt( $gmt, 'Y-m-d H:i:s' ) : $gmt ); } public static function notes( int $id, array $data = array() ) : array { Store::require_cap( 'manage_woocommerce' ); self::user( $id ); $page = max( 1, absint( $data['page'] ?? 1 ) ); $all = array_reverse( self::stored_notes( $id ) ); $offset = ($page - 1) * self::NOTE_PAGE_SIZE; // Who wrote each note (its stored actor), for the note's footer: date · Internal note · name. $items = array_map( static function ( array $note ) : array { $note = self::public_note( $note ); $user = ( $note['actor'] ? get_userdata( $note['actor'] ) : false ); return $note + array( 'author' => ( $user ? (string) $user->display_name : '' ), ); }, array_slice( $all, $offset, self::NOTE_PAGE_SIZE ) ); return array( 'items' => $items, 'page' => $page, 'total' => count( $all ), 'has_more' => $offset + count( $items ) < count( $all ), ); } private static function profile( int $id, bool $stored = false ) : array { $raw = get_user_meta( $id, self::PROFILE, true ); $raw = ( is_array( $raw ) ? $raw : array() ); $contact = array(); foreach ( self::CONTACTS as $key ) { $value = ( is_array( $raw['contact'][$key] ?? null ) ? $raw['contact'][$key] : array() ); $status = sanitize_key( (string) ($value['status'] ?? 'unknown') ); $contact[$key] = array( 'status' => ( in_array( $status, array('unknown', 'allowed', 'declined'), true ) ? $status : 'unknown' ), 'at' => ( $stored ? sanitize_text_field( (string) ($value['at'] ?? '') ) : self::store_time( (string) ($value['at'] ?? '') ) ), 'actor' => absint( $value['actor'] ?? 0 ), ); } $tags = array_values( array_unique( array_filter( array_map( 'sanitize_text_field', (array) ($raw['tags'] ?? array()) ) ) ) ); $notes = self::notes( $id ); return array( 'tags' => array_slice( $tags, 0, 12 ), 'contact' => $contact, 'notes' => $notes['items'], 'notes_total' => $notes['total'], 'notes_has_more' => $notes['has_more'], ); } private static function version( int $id ) : string { return hash( 'sha256', wp_json_encode( array(get_user_meta( $id, self::PROFILE, true ), get_user_meta( $id, self::NOTES, true )) ) ); } /** Current account label and WooCommerce billing contact, never an old order's details. */ private static function details( int $id, ?\WC_Customer $customer = null ) : array { $user = self::user( $id ); $customer = ( $customer ?: new \WC_Customer($id) ); return array( 'display_name' => $user->display_name, 'first_name' => $customer->get_billing_first_name(), 'last_name' => $customer->get_billing_last_name(), 'company' => $customer->get_billing_company(), 'email' => $customer->get_billing_email(), 'phone' => $customer->get_billing_phone(), 'country' => $customer->get_billing_country(), 'city' => $customer->get_billing_city(), 'state' => $customer->get_billing_state(), 'postcode' => $customer->get_billing_postcode(), 'address' => $customer->get_billing_address_1(), 'address_2' => $customer->get_billing_address_2(), 'account_email' => $user->user_email, ); } /** * A state or province as WooCommerce's code (KIT-2). Where WooCommerce lists the country's states, a valid code is kept and * a name written exactly, in any case ("istanbul", "İSTANBUL"), becomes its code (TR34): the label providers need the code. * Anything else is refused. An empty value stays empty; a country without a list keeps the text as typed. */ public static function state_code( string $country, string $value ) : string { $value = trim( $value ); $country = strtoupper( trim( $country ) ); if ( $value === '' || $country === '' ) { return $value; } $states = WC()->countries->get_states( $country ); if ( !is_array( $states ) || !$states ) { return $value; } if ( isset( $states[$value] ) ) { return $value; } if ( isset( $states[strtoupper( $value )] ) ) { return strtoupper( $value ); } // Case and the Turkish dots do not matter: mb_strtolower('İ') is 'i' with a combining dot, and 'ı' is read as 'i'. $key = static fn( string $text ): string => str_replace( array("̇", "ı"), array('', 'i'), mb_strtolower( trim( html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ) ), 'UTF-8' ) ); foreach ( $states as $code => $name ) { if ( $key( (string) $name ) === $key( $value ) ) { return (string) $code; } } throw new \InvalidArgumentException(I18n::t( 'Choose the province of the delivery address.' )); } private static function details_version( array $details ) : string { return hash( 'sha256', wp_json_encode( $details ) ); } /** Pro-only methods live under premium-suffixed names; old callers keep their names (paid build only). */ public static function __callStatic( string $name, array $args ) { throw new \BadMethodCallException(self::class . '::' . $name); } /** Keep the customer meta and its audit event in one database transaction. */ private static function write_meta_event( int $id, string $meta_key, array $value, string $event, array $payload = array() ) : bool { $before = get_user_meta( $id, $meta_key, true ); if ( $before === $value ) { return false; } global $wpdb; if ( false === $wpdb->query( 'START TRANSACTION' ) ) { throw new \RuntimeException(I18n::t( 'The customer update could not be started.' )); } try { if ( false === update_user_meta( $id, $meta_key, $value ) && get_user_meta( $id, $meta_key, true ) !== $value ) { throw new \RuntimeException(I18n::t( 'Customer information could not be saved.' )); } $inserted = $wpdb->insert( $wpdb->prefix . 'storesuit_events', array( 'object_type' => 'customer', 'object_id' => $id, 'event' => sanitize_key( $event ), 'actor' => get_current_user_id(), 'payload' => wp_json_encode( $payload ), 'created_at' => current_time( 'mysql', true ), ) ); if ( false === $inserted ) { throw new \RuntimeException(I18n::t( 'Customer activity history could not be saved.' )); } if ( false === $wpdb->query( 'COMMIT' ) ) { throw new \RuntimeException(I18n::t( 'The customer update could not be completed.' )); } return true; } catch ( \Throwable $e ) { $wpdb->query( 'ROLLBACK' ); clean_user_cache( $id ); throw $e; } } public static function search( array $data ) : array { return CustomerDirectory::search( $data ); } /** Set while the till opens an account (till_account()): no order permission is asked and the e-mail address may be left out. */ private static bool $till_account = false; /** * An account opened at the till (audit R4-4, 29 Oct 2026). A sale on account needs a registered customer, and a cashier * had to leave the till to open one. A name and a phone number nobody else holds are enough: the e-mail address may be * left out and nothing is e-mailed. The till's permission is enough; the cashier gets the account's id and name, not its * details (a cashier sees no customer profiles). */ public static function till_account( array $data ) : array { Store::require_cap( Roles::POS ); if ( trim( Store::text( $data, 'phone' ) ) === '' ) { throw new \InvalidArgumentException(I18n::t( 'Enter the customer\'s phone number.' )); } self::$till_account = true; try { $made = self::create( array( 'send_email' => '0', 'company' => '', ) + $data ); } finally { self::$till_account = false; } return array( 'id' => (int) $made['id'], 'name' => (string) $made['name'], ); } /** * A new customer account made in StoreRuler (owner decision OD-9, both builds): before, only WordPress › Users › Add New made * one, which a Store Owner without administrator rights may not reach. WooCommerce's own wc_create_new_customer() makes it, * as the checkout would (role customer, its hooks and its username rule), with a password nobody sees. No e-mail is sent * unless the team asks for one; then WordPress's own "set your password" e-mail goes out, never WooCommerce's "your account * was created" e-mail, which would carry no way to sign in. The company, phone and address (the address block, KIT-2) become * the account's billing and delivery details. The same request key answers with the same account (one record, the id only). */ public static function create( array $data ) : array { if ( !self::$till_account ) { Store::require_cap( 'storesuit_orders' ); } $created = false; $done = Store::once_request( 'customer-create', $data, static function () use($data, &$created) { // Identity, addresses, the audit and the request marker commit together. An interrupted save leaves no half-account. global $wpdb; foreach ( array($wpdb->users, $wpdb->usermeta) as $table ) { $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( strtoupper( (string) $engine ) !== 'INNODB' ) { throw new \RuntimeException(I18n::format( 'Safe transactions require an InnoDB table: %s', $table )); } } $id = self::new_account( $data ); $created = true; return array( 'id' => $id, ); } ); $id = absint( $done['id'] ?? 0 ); if ( !$id || !get_userdata( $id ) ) { throw new \RuntimeException(I18n::t( 'The customer account could not be created.' )); } // A notification is sent after commit, once. A rollback or a repeated click cannot email an uncommitted account. if ( $created && !empty( $data['send_email'] ) && !in_array( (string) $data['send_email'], array('0', 'false', 'no'), true ) ) { wp_new_user_notification( $id, null, 'user' ); } $details = self::details( $id ); return array( 'id' => $id, 'name' => ( trim( $details['first_name'] . ' ' . $details['last_name'] ) ?: $details['display_name'] ), 'details' => $details, 'url' => admin_url( 'admin.php?page=storesuit-customers&id=' . $id ), ); } /** The error code of a phone number another customer account already holds (guard_phone()); the router names it phone_taken. */ public const PHONE_TAKEN = 4031; /** The account the last guard_phone() refusal named, for the router's answer. */ private static $phone_holder = array(); public static function phone_holder() : array { return self::$phone_holder; } /** * The other registered customers whose billing phone is this number, as the shared account reads phones (Store::normalize_phone()): * id, name and the address of their card. Guests are not accounts: their orders join the account that holds the number. */ public static function phone_holders( string $phone, int $except = 0 ) : array { $key = Store::normalize_phone( $phone ); if ( strlen( $key ) < 7 ) { return array(); } global $wpdb; $out = array(); foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT user_id,meta_value FROM %i WHERE meta_key=%s', $wpdb->usermeta, 'billing_phone' ) ) as $row ) { $id = (int) $row->user_id; if ( $id === $except || isset( $out[$id] ) || Store::normalize_phone( (string) $row->meta_value ) !== $key ) { continue; } $user = get_userdata( $id ); if ( !$user ) { continue; } $out[$id] = array( 'id' => $id, 'name' => $user->display_name, 'url' => admin_url( 'admin.php?page=storesuit-customers&id=' . $id ), ); } return array_values( $out ); } /** * A new or changed customer phone that another account already holds is refused once, naming that customer (denetim R3-1): * two accounts with one number make the shared account unclear, and sales on account, debts and collections for the first * one were refused from then on. Sent again with shared_phone the number is kept on both; the card then asks which account * owns it (phone_owner_save()). */ private static function guard_phone( string $phone, int $except, array $data ) : void { if ( !empty( $data['shared_phone'] ) || trim( $phone ) === '' ) { return; } $holders = self::phone_holders( $phone, $except ); if ( !$holders ) { return; } self::$phone_holder = $holders[0]; throw new \InvalidArgumentException(I18n::format( 'This phone number already belongs to the customer %s.', $holders[0]['name'] ), self::PHONE_TAKEN); } private static function new_account( array $data ) : int { $limits = array( 'first_name' => 100, 'last_name' => 100, 'company' => 200, 'email' => 254, 'phone' => 40, 'country' => 2, 'city' => 100, 'state' => 100, 'postcode' => 32, 'address' => 250, 'address_2' => 250, ); $v = array(); foreach ( $limits as $field => $limit ) { $v[$field] = trim( Store::text( $data, $field ) ); if ( mb_strlen( $v[$field] ) > $limit ) { throw new \InvalidArgumentException(I18n::t( 'One of the customer details is too long.' )); } } // One "Customer name" field (the new sale) is split at its last space: "Ayşe Nur Yılmaz" is Ayşe Nur, Yılmaz. $name = trim( Store::text( $data, 'name' ) ); if ( $v['first_name'] === '' && $v['last_name'] === '' && $name !== '' ) { if ( mb_strlen( $name ) > 200 ) { throw new \InvalidArgumentException(I18n::t( 'One of the customer details is too long.' )); } $at = mb_strrpos( $name, ' ' ); $v['first_name'] = ( $at === false ? $name : trim( mb_substr( $name, 0, $at ) ) ); $v['last_name'] = ( $at === false ? '' : trim( mb_substr( $name, $at + 1 ) ) ); } if ( $v['first_name'] === '' && $v['last_name'] === '' && $v['company'] === '' ) { throw new \InvalidArgumentException(I18n::t( 'Enter the customer\'s name or company.' )); } $email = sanitize_email( $v['email'] ); // At the till an account may have no e-mail address (till_account()); one that is typed must be valid. $no_email = self::$till_account && $v['email'] === ''; if ( !$no_email && !is_email( $email ) ) { throw new \InvalidArgumentException(I18n::t( 'Enter the customer\'s email address: an account needs one.' )); } if ( !$no_email && email_exists( $email ) ) { throw new \InvalidArgumentException(I18n::t( 'An account with this email address already exists. Find the customer in the search.' )); } self::guard_phone( $v['phone'], 0, $data ); $v['country'] = strtoupper( $v['country'] ); $place = $v['city'] . $v['address'] . $v['address_2'] . $v['postcode'] . $v['state']; if ( $v['country'] === '' && $place !== '' ) { $v['country'] = WC()->countries->get_base_country(); } if ( $v['country'] !== '' && !isset( WC()->countries->get_countries()[$v['country']] ) ) { throw new \InvalidArgumentException(I18n::t( 'Choose a valid country for the customer.' )); } $v['state'] = self::state_code( $v['country'], $v['state'] ); $display = ( trim( $v['first_name'] . ' ' . $v['last_name'] ) ?: $v['company'] ); $send = !empty( $data['send_email'] ) && !in_array( (string) $data['send_email'], array('0', 'false', 'no'), true ); // WooCommerce's "New account" e-mail is held for this address only, for the rest of the request (a store may send its // e-mails at the end of the request). add_filter( 'woocommerce_email_enabled_customer_new_account', static function ( $enabled, $user = null ) use($email) { return ( $user instanceof \WP_User && strcasecmp( (string) $user->user_email, $email ) === 0 ? false : $enabled ); }, PHP_INT_MAX, 2 ); $username = ( function_exists( 'wc_create_new_customer_username' ) ? wc_create_new_customer_username( $email, array( 'first_name' => $v['first_name'], 'last_name' => $v['last_name'], ) ) : sanitize_user( strtok( $email, '@' ), true ) ); // WooCommerce's own call needs an address: without one WordPress makes the account, with WooCommerce's customer role // (not its "account created" hook, whose e-mail would have no address to go to). if ( $no_email ) { if ( $username === '' ) { $username = 'customer-' . strtolower( wp_generate_password( 8, false ) ); } $id = wp_insert_user( array( 'user_login' => $username, 'user_pass' => wp_generate_password( 32, true, true ), 'user_email' => '', 'first_name' => $v['first_name'], 'last_name' => $v['last_name'], 'display_name' => $display, 'role' => 'customer', ) ); } else { $id = wc_create_new_customer( $email, $username, wp_generate_password( 32, true, true ), array( 'first_name' => $v['first_name'], 'last_name' => $v['last_name'], 'display_name' => $display, ) ); } if ( is_wp_error( $id ) ) { throw new \InvalidArgumentException(wp_strip_all_tags( $id->get_error_message() )); } $id = (int) $id; $customer = new \WC_Customer($id); foreach ( array('billing', 'shipping') as $type ) { $customer->{"set_{$type}_first_name"}( $v['first_name'] ); $customer->{"set_{$type}_last_name"}( $v['last_name'] ); $customer->{"set_{$type}_company"}( $v['company'] ); $customer->{"set_{$type}_country"}( $v['country'] ); $customer->{"set_{$type}_state"}( $v['state'] ); $customer->{"set_{$type}_city"}( $v['city'] ); $customer->{"set_{$type}_postcode"}( $v['postcode'] ); $customer->{"set_{$type}_address_1"}( $v['address'] ); $customer->{"set_{$type}_address_2"}( $v['address_2'] ); if ( is_callable( array($customer, "set_{$type}_phone") ) ) { $customer->{"set_{$type}_phone"}( $v['phone'] ); } } $customer->set_billing_email( $email ); $customer->save(); Store::audit( 'customer', $id, 'created', array( 'source' => ( self::$till_account ? 'till' : 'storeruler' ), 'emailed' => $send, ) ); global $wpdb; if ( $wpdb->last_error !== '' ) { throw new \RuntimeException(I18n::t( 'Customer activity history could not be saved.' )); } return $id; } public static function recent() : array { Store::require_cap( 'manage_woocommerce' ); $seen = array(); $rows = array(); $page = 1; do { $batch = wc_get_orders( array( 'type' => 'shop_order', 'status' => SaleStatus::orders(), 'limit' => 50, 'page' => $page++, 'paginate' => true, 'orderby' => 'date', 'order' => 'DESC', ) ); foreach ( $batch->orders as $order ) { $id = $order->get_customer_id(); if ( !$id || isset( $seen[$id] ) ) { continue; } $user = get_user_by( 'id', $id ); if ( !$user ) { continue; } $seen[$id] = true; $customer = new \WC_Customer($id); $rows[] = array( 'id' => $id, 'name' => $user->display_name, 'email' => ( $customer->get_billing_email() ?: $user->user_email ), 'phone' => $customer->get_billing_phone(), 'last_order' => ( $order->get_date_created() ? $order->get_date_created()->date( 'c' ) : '' ), 'last_channel' => (string) Orders::channel( $order ), ); if ( count( $rows ) >= 8 ) { break 2; } } } while ( $page <= $batch->max_num_pages && $page <= 4 ); return $rows; } public static function save_profile( int $id, array $data ) : array { Store::require_cap( 'manage_woocommerce' ); self::user( $id ); if ( empty( $data['confirmed'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Confirm the customer profile changes.' )); } $key = Store::text( $data, 'request_key' ); self::once( 'profile', $id, $key, static function () use($id, $data) { return Store::lock( 'customer-profile-' . $id, static function () use($id, $data) { if ( !hash_equals( self::version( $id ), Store::text( $data, 'version' ) ) ) { throw new \RuntimeException(I18n::t( 'The customer profile changed in another action. Open the latest profile and try again.' )); } $tags_raw = $data['tags'] ?? array(); if ( is_string( $tags_raw ) ) { $tags_raw = preg_split( '/[,\\n]+/', $tags_raw ); } $tags = array(); foreach ( (array) $tags_raw as $tag ) { $tag = sanitize_text_field( ( is_scalar( $tag ) ? (string) $tag : '' ) ); if ( $tag === '' ) { continue; } if ( mb_strlen( $tag ) > 32 ) { throw new \InvalidArgumentException(I18n::t( 'Each customer tag can contain at most 32 characters.' )); } $tags[mb_strtolower( $tag )] = $tag; } if ( count( $tags ) > 12 ) { throw new \InvalidArgumentException(I18n::t( 'Add no more than 12 clear tags to a customer.' )); } $before = self::profile( $id, true ); $contact = array(); foreach ( self::CONTACTS as $channel ) { $status = sanitize_key( (string) ($data['contact'][$channel] ?? $data['contact_' . $channel] ?? 'unknown') ); if ( !in_array( $status, array('unknown', 'allowed', 'declined'), true ) ) { throw new \InvalidArgumentException(I18n::t( 'Invalid contact preference.' )); } $old = $before['contact'][$channel]; $contact[$channel] = ( $status === $old['status'] ? $old : array( 'status' => $status, 'at' => current_time( 'mysql', true ), 'actor' => get_current_user_id(), ) ); } self::write_meta_event( $id, self::PROFILE, array( 'tags' => array_values( $tags ), 'contact' => $contact, ), 'customer_profile_updated', array( 'tags' => count( $tags ), 'contact' => array_column( $contact, 'status' ), ) ); return array( 'saved' => true, ); } ); } ); // A repeated request keeps only a marker; the card is always read fresh, so no copy of it is stored. return self::card( $id ); } /** Edit future-sales contact details in place; past orders and login email stay untouched. */ public static function save_details( int $id, array $data ) : array { Store::require_cap( 'manage_woocommerce' ); self::user( $id ); if ( empty( $data['confirmed'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Confirm the customer details before saving.' )); } // A team member's or an administrator's account is changed in Users, where WordPress decides who may edit whom. if ( !current_user_can( 'edit_user', $id ) && (user_can( $id, 'manage_woocommerce' ) || user_can( $id, 'storesuit_orders' ) || user_can( $id, 'edit_posts' )) ) { throw new \InvalidArgumentException(I18n::t( 'This account belongs to a team member. Change it in Users.' )); } $key = Store::text( $data, 'request_key' ); $done = self::once( 'details', $id, $key, static function () use($id, $data) { return Store::lock( 'customer-profile-' . $id, static function () use($id, $data) { $customer = new \WC_Customer($id); $old = self::details( $id, $customer ); if ( !hash_equals( self::details_version( $old ), Store::text( $data, 'details_version' ) ) ) { throw new \RuntimeException(I18n::t( 'Customer details changed in another screen. Reopen the current details and try again.' )); } $limits = array( 'display_name' => 100, 'first_name' => 100, 'last_name' => 100, 'company' => 200, 'email' => 254, 'phone' => 40, 'country' => 2, 'city' => 100, 'state' => 100, 'postcode' => 32, 'address' => 250, 'address_2' => 250, ); $new = $old; foreach ( $limits as $field => $limit ) { if ( !array_key_exists( $field, $data ) || !is_scalar( $data[$field] ) ) { throw new \InvalidArgumentException(I18n::t( 'Complete the customer details form before saving.' )); } $value = trim( Store::text( $data, $field ) ); if ( mb_strlen( $value ) > $limit ) { throw new \InvalidArgumentException(I18n::t( 'One of the customer details is too long.' )); } $new[$field] = $value; } if ( $new['display_name'] === '' ) { throw new \InvalidArgumentException(I18n::t( 'Enter a customer display name.' )); } if ( $new['email'] !== '' && !is_email( $new['email'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Enter a valid sales contact email or leave it empty.' )); } $new['country'] = strtoupper( $new['country'] ); if ( $new['country'] !== '' && !isset( WC()->countries->get_countries()[$new['country']] ) ) { throw new \InvalidArgumentException(I18n::t( 'Choose a valid country for the customer.' )); } // A province saved as free text before the list ("Kadıköy") stays while neither it nor the country changes: an edit of the phone // alone is never refused for it (review of KIT-2). A new province, or a new country, is checked against WooCommerce's list. if ( $new['state'] !== $old['state'] || $new['country'] !== strtoupper( (string) $old['country'] ) ) { $new['state'] = self::state_code( $new['country'], $new['state'] ); } $changed = array(); foreach ( $limits as $field => $limit ) { if ( $new[$field] !== $old[$field] ) { $changed[] = $field; } } if ( !$changed ) { return array( 'changed' => false, ); } if ( Store::normalize_phone( $new['phone'] ) !== Store::normalize_phone( (string) $old['phone'] ) ) { self::guard_phone( $new['phone'], $id, $data ); } $customer->set_display_name( $new['display_name'] ); $customer->set_billing_first_name( $new['first_name'] ); $customer->set_billing_last_name( $new['last_name'] ); $customer->set_billing_company( $new['company'] ); $customer->set_billing_email( $new['email'] ); $customer->set_billing_phone( $new['phone'] ); $customer->set_billing_country( $new['country'] ); $customer->set_billing_city( $new['city'] ); $customer->set_billing_state( $new['state'] ); $customer->set_billing_postcode( $new['postcode'] ); $customer->set_billing_address_1( $new['address'] ); $customer->set_billing_address_2( $new['address_2'] ); global $wpdb; if ( false === $wpdb->query( 'START TRANSACTION' ) ) { throw new \RuntimeException(I18n::t( 'Customer details could not be saved.' )); } try { if ( !$customer->save() ) { throw new \RuntimeException(I18n::t( 'Customer details could not be saved.' )); } if ( false === $wpdb->insert( $wpdb->prefix . 'storesuit_events', array( 'object_type' => 'customer', 'object_id' => $id, 'event' => 'customer_details_updated', 'actor' => get_current_user_id(), 'payload' => wp_json_encode( array( 'fields' => $changed, ) ), 'created_at' => current_time( 'mysql', true ), ) ) ) { throw new \RuntimeException(I18n::t( 'Customer details could not be saved.' )); } if ( false === $wpdb->query( 'COMMIT' ) ) { throw new \RuntimeException(I18n::t( 'Customer details could not be saved.' )); } } catch ( \Throwable $e ) { $wpdb->query( 'ROLLBACK' ); clean_user_cache( $id ); wp_cache_delete( $id, 'user_meta' ); throw $e; } return array( 'changed' => true, ); } ); } ); $saved = self::details( $id ); return array( 'details' => $saved, 'details_version' => self::details_version( $saved ), 'changed' => !empty( $done['changed'] ), ); } public static function add_note( int $id, array $data ) : array { Store::require_cap( 'manage_woocommerce' ); self::user( $id ); if ( empty( $data['confirmed'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Confirm that the note will be visible to the customer team.' )); } $text = Store::text( $data, 'note' ); if ( mb_strlen( $text ) < 3 || mb_strlen( $text ) > 1000 ) { throw new \InvalidArgumentException(I18n::t( 'The customer note must contain 3–1,000 characters.' )); } $key = Store::text( $data, 'request_key' ); self::once( 'note', $id, $key, static function () use($id, $text) { return Store::lock( 'customer-profile-' . $id, static function () use($id, $text) { $notes = self::stored_notes( $id ); $notes[] = array( 'id' => wp_generate_uuid4(), 'text' => $text, 'actor' => get_current_user_id(), 'at' => current_time( 'mysql', true ), ); self::write_meta_event( $id, self::NOTES, $notes, 'customer_note_added' ); return array( 'saved' => true, ); } ); } ); return self::card( $id ); } private static function once( string $operation, int $id, string $key, callable $callback ) : array { if ( !preg_match( '/^[a-zA-Z0-9_-]{16,80}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'The action key is missing. Refresh the page.' )); } return Store::idempotency( hash( 'sha256', 'customer:' . $operation . ':' . $id . ':' . get_current_user_id() . ':' . $key ), $callback ); } /** * A customer's purchases, counted up to $limit: orders except cancelled, failed and binned ones and except the store's * checkout orders still waiting for their payment (pending: an abandoned card or bank payment is not a purchase); a * pending order the team made (a manual or counter sale) counts, as before (Codex CUST-01). */ public static function purchases( int $user_id, int $limit ) : int { $settled = array_values( array_diff( array_keys( wc_get_order_statuses() ), array( 'wc-cancelled', 'wc-failed', 'wc-trash', 'wc-pending', 'wc-checkout-draft', 'wc-qg-request', 'wc-qg-quoted', 'wc-qg-declined' ) ) ); $count = count( wc_get_orders( array( 'customer_id' => $user_id, 'type' => 'shop_order', 'status' => $settled, 'limit' => $limit, 'return' => 'ids', ) ) ); // A pending order counts unless it is an abandoned web checkout (the same rule as balances and queues). if ( $count < $limit ) { foreach ( wc_get_orders( array( 'customer_id' => $user_id, 'type' => 'shop_order', 'status' => array('wc-pending'), 'limit' => 50, ) ) as $pending ) { if ( $count >= $limit ) { break; } if ( $pending instanceof \WC_Order && !Orders::abandoned_checkout( $pending ) ) { ++$count; } } } return $count; } public static function card( int $id ) : array { Store::require_cap( 'manage_woocommerce' ); $user = self::user( $id ); $customer = new \WC_Customer($id); $page = 1; $totals = array(); $recent = array(); $all_ids = array(); $count = 0; $returns = 0; $channels = array(); $last = ''; do { $batch = wc_get_orders( array( 'customer_id' => $id, 'type' => 'shop_order', 'status' => SaleStatus::orders(), 'limit' => 100, 'page' => $page++, 'paginate' => true, 'orderby' => 'date', 'order' => 'DESC', ) ); foreach ( $batch->orders as $order ) { $all_ids[] = $order->get_id(); $created = ( $order->get_date_created() ? $order->get_date_created()->date( 'c' ) : '' ); $channel = (string) Orders::channel( $order ); // An abandoned checkout payment (the store's checkout order still pending) is no purchase: the card's figures // leave it out, as purchases() does for the segment (review of 0.57). if ( !$order->has_status( array('cancelled', 'failed', 'trash') ) && !Orders::abandoned_checkout( $order ) ) { $currency = $order->get_currency(); if ( !isset( $totals[$currency] ) ) { $totals[$currency] = array( 'orders' => 0, 'outstanding' => 0, 'collected' => 0, 'sales' => 0, ); } ++$totals[$currency]['orders']; ++$count; $sale = max( 0, (float) $order->get_total() - (float) $order->get_total_refunded() ); $totals[$currency]['sales'] += $sale; $totals[$currency]['outstanding'] += Orders::outstanding( $order ); $totals[$currency]['collected'] += Orders::collected( $order ); $channels[$channel] = ($channels[$channel] ?? 0) + 1; if ( $last === '' ) { $last = $created; } } $known_refunds = array(); foreach ( (array) $order->get_meta( '_storesuit_returns' ) as $return ) { if ( !is_array( $return ) || ($return['status'] ?? '') === 'rejected' ) { continue; } ++$returns; if ( !empty( $return['refund_id'] ) ) { $known_refunds[absint( $return['refund_id'] )] = true; } } foreach ( $order->get_refunds() as $refund ) { if ( !isset( $known_refunds[$refund->get_id()] ) ) { ++$returns; } } if ( count( $recent ) < 20 ) { // The order desk's stage (New, Preparing, Ready, Completed) beside WooCommerce's status, and whether a payment is // awaited; an abandoned checkout is named as such (KIT-4). Only the already loaded order's meta is read. $flow = OrderFlow::read( $order ); $abandoned = Orders::abandoned_checkout( $order ); $recent[] = array( 'id' => $order->get_id(), 'number' => $order->get_order_number(), 'total' => $order->get_total(), 'currency' => $order->get_currency(), 'status' => $order->get_status(), 'stage' => $flow['stage'], 'stage_label' => $flow['label'], 'awaiting_payment' => !$abandoned && Orders::outstanding( $order ) > 0, 'abandoned' => $abandoned, 'channel' => $channel, 'created' => $created, ); } } } while ( $page <= $batch->max_num_pages ); foreach ( $totals as &$total ) { $total['sales'] = round( $total['sales'], wc_get_price_decimals() ); $total['outstanding'] = round( $total['outstanding'], wc_get_price_decimals() ); $total['collected'] = round( $total['collected'], wc_get_price_decimals() ); } unset($total); $timeline = array(array( 'type' => 'customer_registered', 'at' => ( $user->user_registered ? get_date_from_gmt( $user->user_registered, 'c' ) : '' ), 'order_id' => 0, 'detail' => I18n::t( 'Customer account created' ), )); foreach ( $recent as $order ) { $timeline[] = array( 'type' => 'order', 'at' => $order['created'], 'order_id' => $order['id'], 'detail' => I18n::format( 'Order #%s · %s', $order['number'], $order['status'] ), ); } global $wpdb; if ( $all_ids ) { $ids = array_slice( array_map( 'absint', $all_ids ), 0, 100 ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT object_id,event,payload,created_at FROM %i WHERE object_type='order' AND object_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') ORDER BY id DESC LIMIT 60', array_merge( array($wpdb->prefix . 'storesuit_events'), $ids ) ), ARRAY_A ); $shown = array_map( 'intval', array_column( $recent, 'id' ) ); foreach ( $rows as $row ) { if ( $row['event'] === 'created' && in_array( (int) $row['object_id'], $shown, true ) ) { continue; } $payload = json_decode( $row['payload'], true ); $timeline[] = array( 'type' => $row['event'], 'at' => get_date_from_gmt( $row['created_at'], 'c' ), 'order_id' => (int) $row['object_id'], 'detail' => ( is_array( $payload ) ? $payload : array() ), ); } } $customer_events = $wpdb->get_results( $wpdb->prepare( 'SELECT event,created_at FROM %i WHERE object_type=%s AND object_id=%d ORDER BY id DESC LIMIT 30', $wpdb->prefix . 'storesuit_events', 'customer', $id ), ARRAY_A ); foreach ( $customer_events as $row ) { $timeline[] = array( 'type' => $row['event'], 'at' => get_date_from_gmt( $row['created_at'], 'c' ), 'order_id' => 0, 'detail' => array(), ); } foreach ( $timeline as &$entry ) { $entry['event_label'] = ( $entry['type'] === 'customer_registered' ? I18n::t( 'Customer account opened' ) : Activity::event_label( ( $entry['type'] === 'order' ? 'created' : $entry['type'] ), ( $entry['order_id'] ? 'order' : 'customer' ) ) ); } unset($entry); usort( $timeline, static fn( $a, $b ) => strcmp( (string) $b['at'], (string) $a['at'] ) ); $timeline = array_slice( $timeline, 0, 60 ); $profile = self::profile( $id ); // Credit terms, the price group and the shared account (legacy till debt, manual balance) are the paid build's; the free // build shows what this customer's orders still owe, in the same shape. $terms = array( 'company' => '', 'days' => 0, 'limit' => 0, 'enabled' => false, ); $group = null; $due = array(); foreach ( $totals as $currency => $total ) { if ( $total['outstanding'] > 0 ) { $due[$currency] = $total['outstanding']; } } $account = array( 'user_id' => $id, 'phone' => '', 'ambiguous' => false, 'totals' => $due, 'manual' => 0, 'orders' => array(), ); $details = self::details( $id, $customer ); // First-time or repeat by purchases, not by every order: an abandoned checkout payment is no purchase (Codex CUST-01). $bought = self::purchases( $id, 2 ); $segments = array(I18n::t( ( $bought > 1 ? 'Repeat customer' : (( $bought === 1 ? 'First-time customer' : 'No valid orders yet' )) ) )); if ( array_sum( $account['totals'] ) > 0 ) { $segments[] = I18n::t( 'Payment pending' ); } if ( $returns > 0 ) { $segments[] = I18n::t( 'Has return history' ); } // Suggestions are deliberately separate from account totals. A matching email is not proof of identity: // only the existing preview/confirmation flow may attach a guest order to the WooCommerce account. Linking guest orders is the paid build's. $guest_candidates = array(); // What the customer owes and paid is for those with the money permission or Roles::COLLECT (Store Staff, owner, 2 Nov 2026; // with it off for a person, as critic P2-10 had it for all of them: no balance, collections or shared account). $finance = current_user_can( 'storesuit_finance' ) || current_user_can( Roles::COLLECT ); if ( !$finance ) { $account = array( 'user_id' => $id, 'phone' => '', 'ambiguous' => false, 'totals' => array(), 'manual' => 0, 'orders' => array(), ); foreach ( $totals as &$total ) { unset($total['outstanding'], $total['collected']); } unset($total); } // Denetim R3-1: the other accounts that hold this phone and the one chosen to own it, for the card's choice (finance only). // Beside the shared account, which stays Finance::account() as it is. $phone = array( 'shared' => array(), 'owner' => 0, ); $collections = array(); return array( 'id' => $id, 'name' => $user->display_name, 'finance' => $finance, 'email' => ( $details['email'] ?: $details['account_email'] ), 'phone' => $details['phone'], 'company' => $details['company'], 'details' => $details, 'details_version' => self::details_version( $details ), 'group' => $group, 'terms' => $terms, 'terms_saved' => metadata_exists( 'user', $id, self::TRADE ), 'account' => $account, 'collections' => $collections, 'phone_shared' => $phone, 'totals' => $totals, 'orders' => $recent, 'guest_candidates' => $guest_candidates, 'timeline' => $timeline, 'count' => $count, 'returns' => $returns, 'channels' => $channels, 'last_order' => $last, 'segments' => $segments, 'profile' => $profile, 'version' => self::version( $id ), 'edit_url' => admin_url( 'user-edit.php?user_id=' . $id ), ); } }