'handle_sync_config', 'order-status' => 'handle_order_status', 'order-items' => 'handle_order_items', 'backfill' => 'handle_backfill', 'products' => 'handle_products', 'stock' => 'handle_stock', 'customers' => 'handle_customers' ) as $route => $handler ) { register_rest_route( 'winnyplus-connect/v1', '/' . $route, array( 'methods' => 'POST', 'callback' => array( __CLASS__, $handler ), 'permission_callback' => array( __CLASS__, 'permit' ), ) ); } } /** * The routes' permission: only WinnyPlus, which holds the callback secret (not a WordPress user). Each handler checks again before it acts. * * @return true|WP_Error */ public static function permit( WP_REST_Request $request ) { $refusal = self::refuse_unless_winnyplus( $request ); return null === $refusal ? true : new WP_Error( 'rest_forbidden', $refusal->get_data()['message'], array( 'status' => 403 ) ); } /** Only WinnyPlus may call these routes: connected, and holding the callback secret. @return WP_REST_Response|null the refusal, or null when allowed */ private static function refuse_unless_winnyplus( WP_REST_Request $request ) { if ( ! WinnyPlus_Connect_Connection::is_connected() ) { return new WP_REST_Response( array( 'message' => 'Not connected' ), 403 ); } $auth_header = $request->get_header( 'authorization' ); $provided = ''; if ( $auth_header && 0 === stripos( $auth_header, 'Bearer ' ) ) { $provided = trim( substr( $auth_header, 7 ) ); } $expected = WinnyPlus_Connect_Connection::get_callback_secret(); // hash_equals بدل == العادي تجنبًا لـ timing attack if ( '' === $provided || '' === $expected || ! hash_equals( $expected, $provided ) ) { return new WP_REST_Response( array( 'message' => 'Invalid token' ), 403 ); } return null; } public static function handle_sync_config( WP_REST_Request $request ) { $refusal = self::refuse_unless_winnyplus( $request ); if ( $refusal ) { return $refusal; } $body = $request->get_json_params(); if ( ! is_array( $body ) ) { $body = array(); } // Config shape: { "events": { "order.created": true, "product.updated": false, ... } }. // Unknown keys are ignored; future config sections (catalog, write-back) join this payload. $events = isset( $body['events'] ) && is_array( $body['events'] ) ? $body['events'] : array(); $saved = WinnyPlus_Connect_Events::save_enabled_map( $events ); return new WP_REST_Response( array( 'success' => true, 'events' => $saved, 'capabilities' => WinnyPlus_Connect_Events::get_capabilities() ), 200 ); } /** * WinnyPlus moved an order (it was processing, cancelled, completed...): the store follows. The change is marked so the * status hook does not report it back as if the store had done it. */ public static function handle_order_status( WP_REST_Request $request ) { $refusal = self::refuse_unless_winnyplus( $request ); if ( $refusal ) { return $refusal; } $body = (array) $request->get_json_params(); $order_id = isset( $body['order_id'] ) ? absint( $body['order_id'] ) : 0; $status = isset( $body['status'] ) ? sanitize_key( $body['status'] ) : ''; $order = $order_id ? wc_get_order( $order_id ) : false; if ( ! $order ) { return new WP_REST_Response( array( 'message' => 'Order not found' ), 404 ); } if ( '' === $status || ! array_key_exists( 'wc-' . $status, wc_get_order_statuses() ) ) { return new WP_REST_Response( array( 'message' => 'Unknown status' ), 400 ); } if ( $order->get_status() === $status ) { return new WP_REST_Response( array( 'success' => true, 'changed' => false ), 200 ); } set_transient( 'winnyplus_connect_from_winnyplus_' . $order_id, 1, 2 * MINUTE_IN_SECONDS ); $order->update_status( $status, isset( $body['note'] ) ? sanitize_text_field( $body['note'] ) : '' ); return new WP_REST_Response( array( 'success' => true, 'changed' => true ), 200 ); } /** * Staff changed quantities or removed items in WinnyPlus: the order's own lines follow. Each entry is a line item id of this * order and the quantity it must have (0 removes it). Prices never change: a line keeps its unit price. The change is * marked so the order-saved hook does not report it back as an edit made in the store. */ /** * WinnyPlus holds the count: the store shows and sells by it. Each entry is a product (or variation) id and the quantity to show; * stock management is switched on for it, and it is in or out of stock accordingly. Nothing else about the product changes. */ public static function handle_stock( WP_REST_Request $request ) { $refusal = self::refuse_unless_winnyplus( $request ); if ( $refusal ) { return $refusal; } $body = (array) $request->get_json_params(); $entries = array_slice( (array) ( isset( $body['items'] ) ? $body['items'] : array() ), 0, 100 ); $updated = 0; foreach ( $entries as $entry ) { $id = isset( $entry['id'] ) ? absint( $entry['id'] ) : 0; $product = $id ? wc_get_product( $id ) : false; if ( ! $product ) { continue; } $quantity = max( 0, (int) ( isset( $entry['quantity'] ) ? $entry['quantity'] : 0 ) ); // The change we are about to make is not an edit made in the store: its product event is not sent back set_transient( 'winnyplus_connect_product_lock_' . $id, 1, 15 ); if ( $product->get_parent_id() ) { set_transient( 'winnyplus_connect_product_lock_' . $product->get_parent_id(), 1, 15 ); } $product->set_manage_stock( true ); $product->set_stock_quantity( $quantity ); $product->set_stock_status( $quantity > 0 ? 'instock' : 'outofstock' ); $product->save(); $updated++; } return new WP_REST_Response( array( 'updated' => $updated ), 200 ); } /** * The people the store knows, a page at a time, for WinnyPlus to import as contacts (nothing in the store changes). `kind` says where to look: * `accounts` (the customers who made an account, with the phone of their billing details) or `orders` (the billing details of the orders: * this is where a guest, who has no account, is found). A person without a phone is left out; the phone is returned in international form * (the billing country's calling code added), and the same person appearing again is for WinnyPlus to merge. */ public static function handle_customers( WP_REST_Request $request ) { $refusal = self::refuse_unless_winnyplus( $request ); if ( $refusal ) { return $refusal; } $body = (array) $request->get_json_params(); $kind = isset( $body['kind'] ) && 'orders' === $body['kind'] ? 'orders' : 'accounts'; $page = max( 1, isset( $body['page'] ) ? absint( $body['page'] ) : 1 ); $per_page = min( 50, max( 1, isset( $body['per_page'] ) ? absint( $body['per_page'] ) : 50 ) ); $contacts = array(); if ( 'orders' === $kind ) { $result = wc_get_orders( array( 'type' => 'shop_order', 'status' => array_keys( wc_get_order_statuses() ), 'orderby' => 'ID', 'order' => 'ASC', 'limit' => $per_page, 'page' => $page, 'paginate' => true, ) ); foreach ( $result->orders as $order ) { $contacts[] = array( 'phone' => WinnyPlus_Connect_Events::normalize_phone( $order->get_billing_phone(), $order->get_billing_country() ), 'name' => trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ), ); } $total = (int) $result->total; $has_more = $page < (int) $result->max_num_pages; } else { $query = new WP_User_Query( array( 'role' => 'customer', 'orderby' => 'ID', 'order' => 'ASC', 'number' => $per_page, 'paged' => $page, 'count_total' => true, ) ); foreach ( $query->get_results() as $user ) { $customer = new WC_Customer( $user->ID ); $contacts[] = array( 'phone' => WinnyPlus_Connect_Events::normalize_phone( $customer->get_billing_phone(), $customer->get_billing_country() ), 'name' => trim( $customer->get_first_name() . ' ' . $customer->get_last_name() ), ); } $total = (int) $query->get_total(); $has_more = ( $page * $per_page ) < $total; } return new WP_REST_Response( array( 'success' => true, 'contacts' => array_values( array_filter( $contacts, function ( $contact ) { return '' !== $contact['phone']; } ) ), 'has_more' => $has_more, 'total' => $total, ), 200 ); } public static function handle_order_items( WP_REST_Request $request ) { $refusal = self::refuse_unless_winnyplus( $request ); if ( $refusal ) { return $refusal; } $body = (array) $request->get_json_params(); $order_id = isset( $body['order_id'] ) ? absint( $body['order_id'] ) : 0; $order = $order_id ? wc_get_order( $order_id ) : false; if ( ! $order ) { return new WP_REST_Response( array( 'message' => 'Order not found' ), 404 ); } set_transient( 'winnyplus_connect_items_from_winnyplus_' . $order_id, 1, 2 * MINUTE_IN_SECONDS ); $changed = 0; foreach ( (array) ( isset( $body['items'] ) ? $body['items'] : array() ) as $entry ) { $item = isset( $entry['id'] ) ? $order->get_item( absint( $entry['id'] ) ) : null; if ( ! $item || ! ( $item instanceof WC_Order_Item_Product ) ) { continue; } $quantity = max( 0, (int) ( isset( $entry['quantity'] ) ? $entry['quantity'] : 0 ) ); $old = max( 1, (int) $item->get_quantity() ); if ( 0 === $quantity ) { $order->remove_item( $item->get_id() ); } elseif ( $quantity !== $old ) { // Same unit price: the line totals follow the quantity $item->set_subtotal( (float) $item->get_subtotal() / $old * $quantity ); $item->set_total( (float) $item->get_total() / $old * $quantity ); $item->set_quantity( $quantity ); $item->save(); } else { continue; } $changed++; } if ( $changed > 0 ) { $order->calculate_totals(); $order->add_order_note( 'WinnyPlus: the items of this order were changed.' ); $order->save(); // What the store holds now is the baseline: the next real edit is compared with this set_transient( 'winnyplus_connect_hash_' . $order_id, WinnyPlus_Connect_Events::order_hashes( wc_get_order( $order_id ) ), 30 * DAY_IN_SECONDS ); } delete_transient( 'winnyplus_connect_items_from_winnyplus_' . $order_id ); return new WP_REST_Response( array( 'success' => true, 'changed' => $changed ), 200 ); } /** * Sends the orders of the last N days (at most 200, newest first) as ordinary order.created events marked historical, * a couple of seconds apart through WP-Cron. An order WinnyPlus already has is recognised, so asking twice is harmless. */ public static function handle_backfill( WP_REST_Request $request ) { $refusal = self::refuse_unless_winnyplus( $request ); if ( $refusal ) { return $refusal; } $body = (array) $request->get_json_params(); $days = min( 90, max( 1, isset( $body['days'] ) ? absint( $body['days'] ) : 30 ) ); $ids = wc_get_orders( array( 'limit' => 200, 'type' => 'shop_order', 'orderby' => 'date', 'order' => 'DESC', 'return' => 'ids', 'date_created' => '>' . ( time() - $days * DAY_IN_SECONDS ), ) ); $delay = 0; foreach ( $ids as $order_id ) { set_transient( 'winnyplus_connect_historical_' . $order_id, 1, DAY_IN_SECONDS ); wp_schedule_single_event( time() + $delay, 'winnyplus_connect_dispatch_event', array( 'order.created', 'order', (int) $order_id ) ); $delay += 2; } return new WP_REST_Response( array( 'success' => true, 'queued' => count( $ids ) ), 200 ); } /** * One page of the store's products (simple and variable, with their variations), in the same shape the product events carry, so * WinnyPlus can build its catalog from them. The caller asks page after page until `has_more` is false. Nothing is changed here. */ public static function handle_products( WP_REST_Request $request ) { $refusal = self::refuse_unless_winnyplus( $request ); if ( $refusal ) { return $refusal; } $body = (array) $request->get_json_params(); $page = max( 1, isset( $body['page'] ) ? absint( $body['page'] ) : 1 ); $per_page = min( 50, max( 1, isset( $body['per_page'] ) ? absint( $body['per_page'] ) : 25 ) ); $result = wc_get_products( array( 'status' => array( 'publish', 'draft', 'private' ), 'type' => array( 'simple', 'variable' ), 'orderby' => 'ID', 'order' => 'ASC', 'limit' => $per_page, 'page' => $page, 'paginate' => true, ) ); $products = array(); foreach ( $result->products as $product ) { $data = WinnyPlus_Connect_Events::build_product_data( $product ); $products[] = $data['product']; } return new WP_REST_Response( array( 'success' => true, 'products' => $products, 'has_more' => $page < (int) $result->max_num_pages, 'total' => (int) $result->total, ), 200 ); } } WinnyPlus_Connect_Rest::init();