class-avcf-wc-abilities.php
1 month ago
class-avcf-wc-attributes.php
1 month ago
class-avcf-wc-catalog.php
1 month ago
class-avcf-wc-checkout-email.php
1 month ago
class-avcf-wc-coupons.php
1 month ago
class-avcf-wc-customers.php
1 month ago
class-avcf-wc-detector.php
1 month ago
class-avcf-wc-orders.php
1 month ago
class-avcf-wc-product-relations.php
1 month ago
class-avcf-wc-products.php
1 month ago
class-avcf-wc-shipping.php
1 month ago
class-avcf-wc-tax.php
1 month ago
class-avcf-wc-variations.php
1 month ago
class-avcf-wc-customers.php
538 lines
| 1 | <?php |
| 2 | /** |
| 3 | * WooCommerce — Customers MCP abilities. |
| 4 | * |
| 5 | * Read access to registered customers (users with the customer role) and |
| 6 | * guest customers (unique billing emails across orders). For mutation, |
| 7 | * exposes default billing and shipping address writes — the most common |
| 8 | * customer-level edit. General user CRUD is in the users cluster |
| 9 | * (atarim/create-user / update-user / delete-user / change-user-role); |
| 10 | * a "customer" in WooCommerce is just a user with the customer role. |
| 11 | * |
| 12 | * Exposed abilities: |
| 13 | * atarim/list-customers Registered customers with stats; optionally include guest emails from orders. |
| 14 | * atarim/get-customer Single customer by ID or email. |
| 15 | * atarim/update-customer-billing Update default billing address fields. |
| 16 | * atarim/update-customer-shipping Update default shipping address fields. |
| 17 | * |
| 18 | * @package atarim-visual-collaboration |
| 19 | */ |
| 20 | |
| 21 | if ( ! defined('ABSPATH') ) { |
| 22 | exit; |
| 23 | } |
| 24 | |
| 25 | class AVCF_WC_Customers extends AVCF_Abilities_Base { |
| 26 | |
| 27 | /** |
| 28 | * @var AVCF_WC_Detector |
| 29 | */ |
| 30 | private $detector; |
| 31 | |
| 32 | /** |
| 33 | * Writeable billing fields (WooCommerce convention). |
| 34 | */ |
| 35 | private $billing_fields = [ |
| 36 | 'first_name', 'last_name', 'company', |
| 37 | 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', |
| 38 | 'email', 'phone', |
| 39 | ]; |
| 40 | |
| 41 | /** |
| 42 | * Writeable shipping fields (no email — that's customer-level). |
| 43 | */ |
| 44 | private $shipping_fields = [ |
| 45 | 'first_name', 'last_name', 'company', |
| 46 | 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', |
| 47 | 'phone', |
| 48 | ]; |
| 49 | |
| 50 | public function __construct() { |
| 51 | $this->detector = new AVCF_WC_Detector(); |
| 52 | } |
| 53 | |
| 54 | public function register() { |
| 55 | if ( ! $this->detector->avcf_wc_is_available() ) { |
| 56 | return; |
| 57 | } |
| 58 | |
| 59 | // ---- list-customers ---- |
| 60 | wp_register_ability( 'atarim/list-customers', [ |
| 61 | 'label' => 'List Customers', |
| 62 | 'description' => 'Returns registered customers (users with the customer role) with per-customer stats: order count, total spent, last order date. Use include_guests: true to also surface unique billing emails from orders that have no associated user account (guest checkouts). Filters: search across email/name, registered date range, total-spent range, has-orders flag.', |
| 63 | 'category' => 'atarim', |
| 64 | 'input_schema' => [ |
| 65 | 'type' => 'object', |
| 66 | 'properties' => [ |
| 67 | 'search' => [ 'type' => 'string', 'description' => 'Match login / email / display name.' ], |
| 68 | 'registered_after' => [ 'type' => 'string', 'description' => 'Only customers registered on or after this date.' ], |
| 69 | 'registered_before' => [ 'type' => 'string' ], |
| 70 | 'min_total_spent' => [ 'type' => 'number', 'description' => 'Minimum lifetime spend.' ], |
| 71 | 'has_orders' => [ 'type' => 'boolean', 'description' => 'Only customers with at least one order.' ], |
| 72 | 'orderby' => [ 'type' => 'string', 'enum' => [ 'ID', 'registered', 'total_spent', 'order_count' ], 'default' => 'registered' ], |
| 73 | 'order' => [ 'type' => 'string', 'enum' => [ 'ASC', 'DESC' ], 'default' => 'DESC' ], |
| 74 | 'limit' => [ 'type' => 'integer', 'minimum' => -1, 'default' => 50 ], |
| 75 | 'offset' => [ 'type' => 'integer', 'minimum' => 0, 'default' => 0 ], |
| 76 | 'include_guests' => [ |
| 77 | 'type' => 'boolean', |
| 78 | 'description' => 'In addition to registered customers, return unique billing emails from orders that don\'t belong to a registered user (guest checkouts). Default false. Guests are returned with id=0 and a "guest" flag.', |
| 79 | 'default' => false, |
| 80 | ], |
| 81 | ], |
| 82 | 'additionalProperties' => false, |
| 83 | ], |
| 84 | 'output_schema' => [ |
| 85 | 'type' => 'object', |
| 86 | 'properties' => [ |
| 87 | 'total' => [ 'type' => 'integer' ], |
| 88 | 'returned' => [ 'type' => 'integer' ], |
| 89 | 'customers' => [ 'type' => 'array' ], |
| 90 | 'guest_count' => [ 'type' => 'integer' ], |
| 91 | ], |
| 92 | 'required' => [ 'total', 'returned', 'customers' ], |
| 93 | ], |
| 94 | 'execute_callback' => function( $input = [] ) { |
| 95 | $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 50; |
| 96 | $offset = isset( $input['offset'] ) ? max( 0, (int) $input['offset'] ) : 0; |
| 97 | $orderby_in = isset( $input['orderby'] ) ? sanitize_key( $input['orderby'] ) : 'registered'; |
| 98 | $order = ( isset( $input['order'] ) && strtoupper( $input['order'] ) === 'ASC' ) ? 'ASC' : 'DESC'; |
| 99 | |
| 100 | // For ID/registered we can use WP_User_Query directly. For total_spent/order_count |
| 101 | // we have to post-process because those are computed from order data. |
| 102 | $needs_post_sort = in_array( $orderby_in, [ 'total_spent', 'order_count' ], true ); |
| 103 | |
| 104 | $args = [ |
| 105 | 'role' => 'customer', |
| 106 | 'number' => $needs_post_sort ? -1 : ( $limit > 0 ? $limit : -1 ), |
| 107 | 'offset' => $needs_post_sort ? 0 : $offset, |
| 108 | 'orderby' => $needs_post_sort ? 'registered' : ( $orderby_in === 'registered' ? 'user_registered' : $orderby_in ), |
| 109 | 'order' => $order, |
| 110 | ]; |
| 111 | if ( ! empty( $input['search'] ) ) { |
| 112 | $args['search'] = '*' . esc_attr( (string) $input['search'] ) . '*'; |
| 113 | $args['search_columns'] = [ 'user_login', 'user_email', 'display_name' ]; |
| 114 | } |
| 115 | if ( ! empty( $input['registered_after'] ) || ! empty( $input['registered_before'] ) ) { |
| 116 | $dq = [ 'inclusive' => true, 'column' => 'user_registered' ]; |
| 117 | if ( ! empty( $input['registered_after'] ) ) { |
| 118 | list( $after, , $err ) = $this->avcf_normalize_post_date( (string) $input['registered_after'] ); |
| 119 | if ( $err === null ) $dq['after'] = $after; |
| 120 | } |
| 121 | if ( ! empty( $input['registered_before'] ) ) { |
| 122 | list( $before, , $err ) = $this->avcf_normalize_post_date( (string) $input['registered_before'] ); |
| 123 | if ( $err === null ) $dq['before'] = $before; |
| 124 | } |
| 125 | if ( count( $dq ) > 2 ) { |
| 126 | $args['date_query'] = [ $dq ]; |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | $query = new \WP_User_Query( $args ); |
| 131 | $users = $query->get_results(); |
| 132 | |
| 133 | $rows = []; |
| 134 | foreach ( $users as $user ) { |
| 135 | $oc = (int) wc_get_customer_order_count( $user->ID ); |
| 136 | $ts = (float) wc_get_customer_total_spent( $user->ID ); |
| 137 | |
| 138 | if ( ! empty( $input['has_orders'] ) && $oc === 0 ) continue; |
| 139 | if ( isset( $input['min_total_spent'] ) && $ts < (float) $input['min_total_spent'] ) continue; |
| 140 | |
| 141 | $last_order_id = (int) wc_get_customer_last_order( $user->ID ) ?: 0; |
| 142 | $last_order_date = ''; |
| 143 | if ( $last_order_id > 0 ) { |
| 144 | $lo = wc_get_order( $last_order_id ); |
| 145 | if ( $lo && $lo->get_date_created() ) { |
| 146 | $last_order_date = $lo->get_date_created()->date( 'Y-m-d H:i:s' ); |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | $rows[] = [ |
| 151 | 'id' => (int) $user->ID, |
| 152 | 'email' => (string) $user->user_email, |
| 153 | 'login' => (string) $user->user_login, |
| 154 | 'display_name' => (string) $user->display_name, |
| 155 | 'first_name' => (string) get_user_meta( $user->ID, 'first_name', true ), |
| 156 | 'last_name' => (string) get_user_meta( $user->ID, 'last_name', true ), |
| 157 | 'registered' => $user->user_registered, |
| 158 | 'order_count' => $oc, |
| 159 | 'total_spent' => round( $ts, 2 ), |
| 160 | 'last_order_id' => $last_order_id, |
| 161 | 'last_order_date'=> $last_order_date, |
| 162 | 'guest' => false, |
| 163 | ]; |
| 164 | } |
| 165 | |
| 166 | // Sort post-fetch if needed. |
| 167 | if ( $needs_post_sort ) { |
| 168 | usort( $rows, function( $a, $b ) use ( $orderby_in, $order ) { |
| 169 | $av = $orderby_in === 'total_spent' ? $a['total_spent'] : $a['order_count']; |
| 170 | $bv = $orderby_in === 'total_spent' ? $b['total_spent'] : $b['order_count']; |
| 171 | $cmp = ( $av <=> $bv ); |
| 172 | return $order === 'ASC' ? $cmp : -$cmp; |
| 173 | } ); |
| 174 | $total_count = count( $rows ); |
| 175 | if ( $offset > 0 ) { |
| 176 | $rows = array_slice( $rows, $offset ); |
| 177 | } |
| 178 | if ( $limit > 0 ) { |
| 179 | $rows = array_slice( $rows, 0, $limit ); |
| 180 | } |
| 181 | } else { |
| 182 | $total_count = (int) $query->get_total(); |
| 183 | } |
| 184 | |
| 185 | $guest_count = 0; |
| 186 | if ( ! empty( $input['include_guests'] ) ) { |
| 187 | global $wpdb; |
| 188 | $hpos = $this->detector->avcf_wc_is_hpos_enabled(); |
| 189 | if ( $hpos ) { |
| 190 | // HPOS path: query wc_orders for distinct billing_email where customer_id = 0 |
| 191 | $table_orders = $wpdb->prefix . 'wc_orders'; |
| 192 | $emails = $wpdb->get_col( $wpdb->prepare( |
| 193 | "SELECT DISTINCT billing_email FROM {$table_orders} WHERE customer_id = 0 AND billing_email != '' LIMIT %d", |
| 194 | 500 |
| 195 | ) ); |
| 196 | } else { |
| 197 | // Legacy path: pull billing_email post_meta for shop_order posts with author = 0 |
| 198 | $emails = $wpdb->get_col( $wpdb->prepare( |
| 199 | "SELECT DISTINCT pm.meta_value |
| 200 | FROM {$wpdb->postmeta} pm |
| 201 | INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id |
| 202 | WHERE p.post_type = 'shop_order' |
| 203 | AND p.post_author = 0 |
| 204 | AND pm.meta_key = '_billing_email' |
| 205 | AND pm.meta_value != '' |
| 206 | LIMIT %d", |
| 207 | 500 |
| 208 | ) ); |
| 209 | } |
| 210 | $emails = is_array( $emails ) ? array_values( array_unique( $emails ) ) : []; |
| 211 | foreach ( $emails as $email ) { |
| 212 | if ( $email === '' ) continue; |
| 213 | // Skip if a registered user has this email (already in the list). |
| 214 | if ( get_user_by( 'email', $email ) ) continue; |
| 215 | $rows[] = [ |
| 216 | 'id' => 0, |
| 217 | 'email' => (string) $email, |
| 218 | 'login' => '', |
| 219 | 'display_name' => '', |
| 220 | 'first_name' => '', |
| 221 | 'last_name' => '', |
| 222 | 'registered' => '', |
| 223 | 'order_count' => 0, |
| 224 | 'total_spent' => 0.0, |
| 225 | 'last_order_id' => 0, |
| 226 | 'last_order_date'=> '', |
| 227 | 'guest' => true, |
| 228 | ]; |
| 229 | $guest_count++; |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | return [ |
| 234 | 'total' => $total_count + $guest_count, |
| 235 | 'returned' => count( $rows ), |
| 236 | 'customers' => $rows, |
| 237 | 'guest_count' => $guest_count, |
| 238 | ]; |
| 239 | }, |
| 240 | 'permission_callback' => function() { |
| 241 | return current_user_can( 'list_users' ) || current_user_can( 'manage_woocommerce' ); |
| 242 | }, |
| 243 | 'meta' => [ |
| 244 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 245 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 246 | ], |
| 247 | ] ); |
| 248 | |
| 249 | // ---- get-customer ---- |
| 250 | wp_register_ability( 'atarim/get-customer', [ |
| 251 | 'label' => 'Get Customer', |
| 252 | 'description' => 'Full detail for one customer by user ID or email. Includes profile info, lifetime stats, default billing and shipping addresses. Works for both registered customers (full detail) and guests (limited detail — only what\'s on their orders).', |
| 253 | 'category' => 'atarim', |
| 254 | 'input_schema' => [ |
| 255 | 'type' => 'object', |
| 256 | 'properties' => [ |
| 257 | 'id' => [ 'type' => 'integer', 'minimum' => 1 ], |
| 258 | 'email' => [ 'type' => 'string', 'description' => 'Customer email. Works for both registered users and guests.' ], |
| 259 | ], |
| 260 | 'additionalProperties' => false, |
| 261 | ], |
| 262 | 'output_schema' => [ |
| 263 | 'type' => 'object', |
| 264 | 'properties' => [ |
| 265 | 'success' => [ 'type' => 'boolean' ], |
| 266 | 'customer' => [ 'type' => 'object' ], |
| 267 | 'message' => [ 'type' => 'string' ], |
| 268 | ], |
| 269 | 'required' => [ 'success', 'message' ], |
| 270 | ], |
| 271 | 'execute_callback' => function( $input = [] ) { |
| 272 | $id = isset( $input['id'] ) ? (int) $input['id'] : 0; |
| 273 | $email = isset( $input['email'] ) ? sanitize_email( (string) $input['email'] ) : ''; |
| 274 | |
| 275 | if ( $id <= 0 && $email === '' ) { |
| 276 | return [ 'success' => false, 'message' => 'Pass id or email.' ]; |
| 277 | } |
| 278 | if ( $id <= 0 && $email !== '' ) { |
| 279 | $u = get_user_by( 'email', $email ); |
| 280 | if ( $u ) $id = (int) $u->ID; |
| 281 | } |
| 282 | |
| 283 | if ( $id <= 0 ) { |
| 284 | // Guest path — query orders by billing_email and return aggregate-only detail. |
| 285 | if ( $email === '' ) { |
| 286 | return [ 'success' => false, 'message' => 'Customer not found.' ]; |
| 287 | } |
| 288 | $orders = wc_get_orders( [ |
| 289 | 'limit' => -1, |
| 290 | 'billing_email' => $email, |
| 291 | 'return' => 'objects', |
| 292 | ] ); |
| 293 | if ( empty( $orders ) ) { |
| 294 | return [ 'success' => false, 'message' => sprintf( 'No customer or orders found for email "%s".', $email ) ]; |
| 295 | } |
| 296 | $first_order = $orders[ count( $orders ) - 1 ]; |
| 297 | $last_order = $orders[0]; |
| 298 | $total = 0.0; |
| 299 | foreach ( $orders as $o ) { |
| 300 | if ( in_array( $o->get_status(), [ 'completed', 'processing', 'on-hold' ], true ) ) { |
| 301 | $total += (float) $o->get_total(); |
| 302 | } |
| 303 | } |
| 304 | return [ |
| 305 | 'success' => true, |
| 306 | 'customer' => [ |
| 307 | 'id' => 0, |
| 308 | 'guest' => true, |
| 309 | 'email' => $email, |
| 310 | 'first_name' => (string) $first_order->get_billing_first_name(), |
| 311 | 'last_name' => (string) $first_order->get_billing_last_name(), |
| 312 | 'order_count' => count( $orders ), |
| 313 | 'total_spent' => round( $total, 2 ), |
| 314 | 'last_order_id' => (int) $last_order->get_id(), |
| 315 | 'last_order_date'=> $last_order->get_date_created() ? $last_order->get_date_created()->date( 'Y-m-d H:i:s' ) : '', |
| 316 | 'billing' => $first_order->get_address( 'billing' ), |
| 317 | 'shipping' => $first_order->get_address( 'shipping' ), |
| 318 | ], |
| 319 | 'message' => sprintf( 'Guest customer with %d order(s).', count( $orders ) ), |
| 320 | ]; |
| 321 | } |
| 322 | |
| 323 | $user = get_userdata( $id ); |
| 324 | if ( ! $user ) { |
| 325 | return [ 'success' => false, 'message' => sprintf( 'User %d not found.', $id ) ]; |
| 326 | } |
| 327 | |
| 328 | $oc = (int) wc_get_customer_order_count( $id ); |
| 329 | $ts = (float) wc_get_customer_total_spent( $id ); |
| 330 | $last_order_id = (int) wc_get_customer_last_order( $id ) ?: 0; |
| 331 | $last_order_date = ''; |
| 332 | if ( $last_order_id > 0 ) { |
| 333 | $lo = wc_get_order( $last_order_id ); |
| 334 | if ( $lo && $lo->get_date_created() ) { |
| 335 | $last_order_date = $lo->get_date_created()->date( 'Y-m-d H:i:s' ); |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | $billing = []; |
| 340 | $shipping = []; |
| 341 | foreach ( $this->billing_fields as $f ) { |
| 342 | $billing[ $f ] = (string) get_user_meta( $id, 'billing_' . $f, true ); |
| 343 | } |
| 344 | foreach ( $this->shipping_fields as $f ) { |
| 345 | $shipping[ $f ] = (string) get_user_meta( $id, 'shipping_' . $f, true ); |
| 346 | } |
| 347 | |
| 348 | return [ |
| 349 | 'success' => true, |
| 350 | 'customer' => [ |
| 351 | 'id' => $id, |
| 352 | 'guest' => false, |
| 353 | 'email' => (string) $user->user_email, |
| 354 | 'login' => (string) $user->user_login, |
| 355 | 'display_name' => (string) $user->display_name, |
| 356 | 'first_name' => (string) get_user_meta( $id, 'first_name', true ), |
| 357 | 'last_name' => (string) get_user_meta( $id, 'last_name', true ), |
| 358 | 'registered' => $user->user_registered, |
| 359 | 'order_count' => $oc, |
| 360 | 'total_spent' => round( $ts, 2 ), |
| 361 | 'last_order_id' => $last_order_id, |
| 362 | 'last_order_date'=> $last_order_date, |
| 363 | 'billing' => $billing, |
| 364 | 'shipping' => $shipping, |
| 365 | ], |
| 366 | 'message' => 'OK.', |
| 367 | ]; |
| 368 | }, |
| 369 | 'permission_callback' => function() { |
| 370 | return current_user_can( 'list_users' ) || current_user_can( 'manage_woocommerce' ); |
| 371 | }, |
| 372 | 'meta' => [ |
| 373 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 374 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 375 | ], |
| 376 | ] ); |
| 377 | |
| 378 | // ---- update-customer-billing ---- |
| 379 | wp_register_ability( 'atarim/update-customer-billing', [ |
| 380 | 'label' => 'Update Customer Billing Address', |
| 381 | 'description' => 'Update a registered customer\'s default billing address. Partial — pass only fields you want to change. country is validated against WooCommerce\'s known country codes; state is validated when the country has a known state list (US, CA, IN, etc.) and free-form otherwise. Does NOT apply to past orders — those have their own billing snapshots. Affects future checkouts where the customer doesn\'t override the address.', |
| 382 | 'category' => 'atarim', |
| 383 | 'input_schema' => [ |
| 384 | 'type' => 'object', |
| 385 | 'properties' => [ |
| 386 | 'customer_id' => [ 'type' => 'integer', 'minimum' => 1 ], |
| 387 | 'first_name' => [ 'type' => 'string' ], |
| 388 | 'last_name' => [ 'type' => 'string' ], |
| 389 | 'company' => [ 'type' => 'string' ], |
| 390 | 'address_1' => [ 'type' => 'string' ], |
| 391 | 'address_2' => [ 'type' => 'string' ], |
| 392 | 'city' => [ 'type' => 'string' ], |
| 393 | 'state' => [ 'type' => 'string', 'description' => 'State / region code (e.g. "CA"). Free-form if the country has no known state list.' ], |
| 394 | 'postcode' => [ 'type' => 'string' ], |
| 395 | 'country' => [ 'type' => 'string', 'description' => 'ISO-3166 alpha-2 country code (e.g. "US", "GB").' ], |
| 396 | 'email' => [ 'type' => 'string', 'description' => 'Billing-specific email. May differ from the customer\'s account email.' ], |
| 397 | 'phone' => [ 'type' => 'string' ], |
| 398 | ], |
| 399 | 'required' => [ 'customer_id' ], |
| 400 | 'additionalProperties' => false, |
| 401 | ], |
| 402 | 'output_schema' => [ |
| 403 | 'type' => 'object', |
| 404 | 'properties' => [ |
| 405 | 'success' => [ 'type' => 'boolean' ], |
| 406 | 'customer_id' => [ 'type' => 'integer' ], |
| 407 | 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 408 | 'message' => [ 'type' => 'string' ], |
| 409 | ], |
| 410 | 'required' => [ 'success', 'message' ], |
| 411 | ], |
| 412 | 'execute_callback' => function( $input = [] ) { |
| 413 | return $this->avcf_update_address( $input, 'billing', $this->billing_fields ); |
| 414 | }, |
| 415 | 'permission_callback' => function() { |
| 416 | return current_user_can( 'edit_users' ) || current_user_can( 'manage_woocommerce' ); |
| 417 | }, |
| 418 | 'meta' => [ |
| 419 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 420 | 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => false ], |
| 421 | ], |
| 422 | ] ); |
| 423 | |
| 424 | // ---- update-customer-shipping ---- |
| 425 | wp_register_ability( 'atarim/update-customer-shipping', [ |
| 426 | 'label' => 'Update Customer Shipping Address', |
| 427 | 'description' => 'Update a registered customer\'s default shipping address. Partial — pass only fields you want to change. Same country/state validation as billing. No email field (shipping addresses don\'t have one — billing carries the customer email).', |
| 428 | 'category' => 'atarim', |
| 429 | 'input_schema' => [ |
| 430 | 'type' => 'object', |
| 431 | 'properties' => [ |
| 432 | 'customer_id' => [ 'type' => 'integer', 'minimum' => 1 ], |
| 433 | 'first_name' => [ 'type' => 'string' ], |
| 434 | 'last_name' => [ 'type' => 'string' ], |
| 435 | 'company' => [ 'type' => 'string' ], |
| 436 | 'address_1' => [ 'type' => 'string' ], |
| 437 | 'address_2' => [ 'type' => 'string' ], |
| 438 | 'city' => [ 'type' => 'string' ], |
| 439 | 'state' => [ 'type' => 'string' ], |
| 440 | 'postcode' => [ 'type' => 'string' ], |
| 441 | 'country' => [ 'type' => 'string' ], |
| 442 | 'phone' => [ 'type' => 'string' ], |
| 443 | ], |
| 444 | 'required' => [ 'customer_id' ], |
| 445 | 'additionalProperties' => false, |
| 446 | ], |
| 447 | 'output_schema' => [ |
| 448 | 'type' => 'object', |
| 449 | 'properties' => [ |
| 450 | 'success' => [ 'type' => 'boolean' ], |
| 451 | 'customer_id' => [ 'type' => 'integer' ], |
| 452 | 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 453 | 'message' => [ 'type' => 'string' ], |
| 454 | ], |
| 455 | 'required' => [ 'success', 'message' ], |
| 456 | ], |
| 457 | 'execute_callback' => function( $input = [] ) { |
| 458 | return $this->avcf_update_address( $input, 'shipping', $this->shipping_fields ); |
| 459 | }, |
| 460 | 'permission_callback' => function() { |
| 461 | return current_user_can( 'edit_users' ) || current_user_can( 'manage_woocommerce' ); |
| 462 | }, |
| 463 | 'meta' => [ |
| 464 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 465 | 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => false ], |
| 466 | ], |
| 467 | ] ); |
| 468 | } |
| 469 | |
| 470 | /** |
| 471 | * Shared helper for billing and shipping address updates. |
| 472 | * |
| 473 | * @param array $input Input arguments from the ability call. |
| 474 | * @param string $prefix 'billing' or 'shipping'. |
| 475 | * @param array $allowed_fields Whitelist of field names for this prefix. |
| 476 | */ |
| 477 | private function avcf_update_address( $input, $prefix, $allowed_fields ) { |
| 478 | $customer_id = isset( $input['customer_id'] ) ? (int) $input['customer_id'] : 0; |
| 479 | if ( $customer_id <= 0 ) { |
| 480 | return [ 'success' => false, 'message' => 'customer_id is required.' ]; |
| 481 | } |
| 482 | $user = get_userdata( $customer_id ); |
| 483 | if ( ! $user ) { |
| 484 | return [ 'success' => false, 'message' => sprintf( 'User %d not found.', $customer_id ) ]; |
| 485 | } |
| 486 | |
| 487 | // Country/state validation. |
| 488 | if ( array_key_exists( 'country', $input ) && $input['country'] !== '' ) { |
| 489 | $country = strtoupper( wc_clean( (string) $input['country'] ) ); |
| 490 | $countries = WC()->countries->get_countries(); |
| 491 | if ( ! isset( $countries[ $country ] ) ) { |
| 492 | return [ 'success' => false, 'customer_id' => $customer_id, 'message' => sprintf( 'Country code "%s" is not recognised by WooCommerce.', $country ) ]; |
| 493 | } |
| 494 | $input['country'] = $country; |
| 495 | |
| 496 | if ( array_key_exists( 'state', $input ) && $input['state'] !== '' ) { |
| 497 | $states = WC()->countries->get_states( $country ); |
| 498 | if ( is_array( $states ) && ! empty( $states ) ) { |
| 499 | $state = strtoupper( wc_clean( (string) $input['state'] ) ); |
| 500 | if ( ! isset( $states[ $state ] ) ) { |
| 501 | return [ 'success' => false, 'customer_id' => $customer_id, 'message' => sprintf( 'State code "%s" is not valid for country "%s".', $state, $country ) ]; |
| 502 | } |
| 503 | $input['state'] = $state; |
| 504 | } |
| 505 | // If country has no known states list, accept free-form. |
| 506 | } |
| 507 | } |
| 508 | |
| 509 | if ( array_key_exists( 'email', $input ) && $input['email'] !== '' ) { |
| 510 | $email = sanitize_email( (string) $input['email'] ); |
| 511 | if ( ! is_email( $email ) ) { |
| 512 | return [ 'success' => false, 'customer_id' => $customer_id, 'message' => sprintf( 'Email "%s" is not a valid address.', $input['email'] ) ]; |
| 513 | } |
| 514 | $input['email'] = $email; |
| 515 | } |
| 516 | |
| 517 | $updated = []; |
| 518 | foreach ( $allowed_fields as $field ) { |
| 519 | if ( ! array_key_exists( $field, $input ) ) continue; |
| 520 | $value = (string) $input[ $field ]; |
| 521 | $meta_key = $prefix . '_' . $field; |
| 522 | update_user_meta( $customer_id, $meta_key, sanitize_text_field( $value ) ); |
| 523 | $updated[] = $field; |
| 524 | } |
| 525 | |
| 526 | if ( empty( $updated ) ) { |
| 527 | return [ 'success' => false, 'customer_id' => $customer_id, 'message' => 'No fields provided to update.' ]; |
| 528 | } |
| 529 | |
| 530 | return [ |
| 531 | 'success' => true, |
| 532 | 'customer_id' => $customer_id, |
| 533 | 'updated' => $updated, |
| 534 | 'message' => sprintf( '%s address updated: %s.', ucfirst( $prefix ), implode( ', ', $updated ) ), |
| 535 | ]; |
| 536 | } |
| 537 | } |
| 538 |