service = $service; $this->fulfilment = $fulfilment; $this->orders = $orders; $this->services = $services; $this->domains = $domains; $this->activity = $activity; $this->settings = $settings; } /** * Registers the standard routes and the order specific ones. * * @since 0.1.0 * * @return void */ public function register_routes() { parent::register_routes(); $permission = Permissions::require_capability( $this->capability() ); $base = '/' . $this->rest_base; register_rest_route( $this->namespace, $base . '/quote', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'quote' ), 'permission_callback' => $permission, ) ); register_rest_route( $this->namespace, $base . '/(?P\d+)/status', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'change_status' ), 'permission_callback' => $permission, 'args' => array( 'status' => array( 'type' => 'string', 'required' => true, 'enum' => OrderStatus::all(), ), 'note' => array( 'type' => 'string', 'default' => '', ), ), ) ); register_rest_route( $this->namespace, $base . '/(?P\d+)/fulfil', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'fulfil' ), 'permission_callback' => $permission, ) ); } /** * Capability that protects every route. * * @since 0.1.0 * * @return string */ protected function capability(): string { return Capabilities::MANAGE_ORDERS; } /** * Arguments of the list route. * * @since 0.1.0 * * @return array> */ protected function collection_args(): array { return array_merge( $this->list_params( array_keys( OrderRepository::ORDERBY ), 'id' ), array( 'status' => array( 'type' => 'string', 'default' => '', 'enum' => array_merge( array( '' ), OrderStatus::all() ), 'validate_callback' => 'rest_validate_request_arg', ), 'customer_id' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), 'queue' => array( 'type' => 'boolean', 'default' => false, ), ) ); } /** * Lists orders. With `queue=1` it lists the setup queue, oldest first. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return array{items: array>, total: int} */ protected function query_items( WP_REST_Request $request ): array { $queue = rest_sanitize_boolean( $request['queue'] ); return $this->orders->query( array( 'search' => $request['search'], 'status' => $queue ? OrderStatus::PAID_AWAITING_SETUP : $request['status'], 'customer_id' => $request['customer_id'], 'orderby' => $queue ? 'created' : $request['orderby'], 'order' => $queue ? 'asc' : $request['order'], 'page' => $request['page'], 'per_page' => $request['per_page'], ) ); } /** * Lists orders and reports the number of orders per status in a header. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_items( $request ) { $response = parent::get_items( $request ); $response->header( 'X-HostKhata-Status-Counts', wp_json_encode( $this->orders->status_counts() ) ); return $response; } /** * Returns an order with its history and the records created from it. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_item( $request ) { $order = $this->orders->find( (int) $request['id'] ); if ( null === $order ) { return $this->error( 'hostkhata_not_found', $this->not_found_message(), 404 ); } return rest_ensure_response( $this->present_detail( $order ) ); } /** * Creates an order and returns it with its lines. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function create_item( $request ) { $order = $this->create_resource( $this->request_body( $request ) ); if ( is_wp_error( $order ) ) { return $order; } $response = rest_ensure_response( $this->present_detail( $order ) ); $response->set_status( 201 ); return $response; } /** * Updates the notes of an order and returns it with its lines. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_item( $request ) { $order = $this->update_resource( (int) $request['id'], $this->request_body( $request ) ); return is_wp_error( $order ) ? $order : rest_ensure_response( $this->present_detail( $order ) ); } /** * Finds one order. * * @since 0.1.0 * * @param int $id Item id. * @return array|null */ protected function find_item( int $id ): ?array { return $this->orders->find( $id ); } /** * Creates an order for a customer. * * @since 0.1.0 * * @param array $body Request body: `customer_id`, `lines`, `coupon_code`, `customer_notes`. * @return array|WP_Error */ protected function create_resource( array $body ) { $customer_id = Sanitizer::absint( $body['customer_id'] ?? 0 ); if ( $customer_id < 1 ) { return Errors::invalid( array( 'customer_id' => __( 'Choose a customer.', 'hostkhata' ) ) ); } return $this->service->place( $body, $customer_id, array( 'customer_notes' => $body['customer_notes'] ?? '' ) ); } /** * Updates the notes of an order. * * @since 0.1.0 * * @param int $id Item id. * @param array $body Request body: `admin_notes`, `customer_notes`. * @return array|WP_Error */ protected function update_resource( int $id, array $body ) { return $this->service->update_notes( $id, $body ); } /** * Orders are never deleted. * * @since 0.1.0 * * @param int $id Item id. * @return \WP_Error */ protected function delete_resource( int $id ) { return new WP_Error( 'hostkhata_not_deletable', __( 'Orders cannot be deleted. Cancel the order instead.', 'hostkhata' ), array( 'status' => 405 ) ); } /** * Message for an order that does not exist. * * @since 0.1.0 * * @return string */ protected function not_found_message(): string { return __( 'Order not found.', 'hostkhata' ); } /** * Calculates a price quote without saving anything. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function quote( $request ) { $body = $this->request_body( $request ); $plan = $this->service->quote( $body, Sanitizer::absint( $body['customer_id'] ?? 0 ) ); if ( is_wp_error( $plan ) ) { return $plan; } return rest_ensure_response( $this->present_plan( $plan ) ); } /** * Changes the status of an order. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function change_status( $request ) { $order = $this->service->transition( (int) $request['id'], (string) $request['status'], (string) $request['note'] ); return is_wp_error( $order ) ? $order : rest_ensure_response( $this->present_detail( $order ) ); } /** * Fulfils an order: creates the hosting and domain records. * * @since 0.1.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function fulfil( $request ) { $order = $this->fulfilment->fulfil( (int) $request['id'], $this->request_body( $request ) ); if ( is_wp_error( $order ) ) { return $order; } $created = $order['created']; $fresh = $this->orders->find( (int) $request['id'] ); $detail = $this->present_detail( $fresh ); $detail['created'] = $created; return rest_ensure_response( $detail ); } /** * Shapes an order for the list view. * * @since 0.1.0 * * @param array $item Item from the repository. * @return array */ protected function present( array $item ): array { return array( 'id' => $item['id'], 'order_number' => $item['order_number'], 'customer_id' => $item['customer_id'], 'customer_name' => $item['customer_name'], 'customer_email' => $item['customer_email'], 'status' => $item['status'], 'subtotal' => $item['subtotal'], 'discount_total' => $item['discount_total'], 'tax_total' => $item['tax_total'], 'total' => $item['total'], 'total_formatted' => $this->settings->format_money( $item['total'] ), 'coupon_code' => $item['coupon_code'], 'payment_method' => $item['payment_method'], 'customer_notes' => (string) $item['customer_notes'], 'admin_notes' => (string) $item['admin_notes'], 'created_at' => $this->iso( (string) $item['created_at_gmt'] ), 'paid_at' => $this->iso( (string) $item['paid_at_gmt'] ), 'fulfilled_at' => $this->iso( (string) $item['fulfilled_at_gmt'] ), 'next_statuses' => OrderStatus::next_from( $item['status'] ), 'can_fulfil' => in_array( $item['status'], OrderStatus::fulfillable(), true ), ); } /** * Shapes an order with its lines, history and created records. * * @param array $order Order with lines. * @return array */ private function present_detail( array $order ): array { $items = array_map( function ( array $item ) { return array( 'id' => $item['id'], 'product_id' => $item['product_id'], 'item_type' => $item['item_type'], 'description' => $item['description'], 'billing_cycle' => $item['billing_cycle'], 'unit_price' => $item['unit_price'], 'unit_formatted' => $this->settings->format_money( $item['unit_price'] ), 'setup_fee' => $item['setup_fee'], 'setup_fee_formatted' => $this->settings->format_money( $item['setup_fee'] ), 'tax_amount' => $item['tax_amount'], 'total' => $item['total'], 'total_formatted' => $this->settings->format_money( $item['total'] ), 'domain_name' => $item['domain_name'], 'discount' => (int) ( $item['meta']['discount'] ?? 0 ), 'answers' => $item['meta']['answers'] ?? array(), ); }, $order['items'] ); $timeline = array_map( function ( array $entry ) { $entry['created_at'] = $this->iso( (string) $entry['created_at_gmt'] ); return $entry; }, $this->activity->for_object( 'order', $order['id'] ) ); return array_merge( $this->present( $order ), array( 'items' => $items, 'subtotal_formatted' => $this->settings->format_money( $order['subtotal'] ), 'discount_formatted' => $this->settings->format_money( $order['discount_total'] ), 'tax_formatted' => $this->settings->format_money( $order['tax_total'] ), 'timeline' => $timeline, 'services' => array_map( static function ( array $service ) { return array( 'id' => $service['id'], 'domain' => $service['domain'], 'username' => $service['username'], 'status' => $service['status'], ); }, $this->services->for_order( $order['id'] ) ), 'domains' => array_map( static function ( array $domain ) { return array( 'id' => $domain['id'], 'domain_name' => $domain['domain_name'], 'status' => $domain['status'], 'expires_on' => $domain['expires_on'], ); }, $this->domains->for_order( $order['id'] ) ), ) ); } /** * Shapes a price plan for the response. * * @param array $plan Plan from the planner. * @return array */ private function present_plan( array $plan ): array { $lines = array_map( function ( array $item ) { return array( 'description' => $item['description'], 'item_type' => $item['item_type'], 'billing_cycle' => $item['billing_cycle'], 'unit_price' => $item['unit_price'], 'setup_fee' => $item['setup_fee'], 'discount' => (int) ( $item['meta']['discount'] ?? 0 ), 'tax_amount' => $item['tax_amount'], 'total' => $item['total'], 'total_formatted' => $this->settings->format_money( $item['total'] ), ); }, $plan['items'] ); return array( 'lines' => $lines, 'subtotal' => $plan['subtotal'], 'subtotal_formatted' => $this->settings->format_money( $plan['subtotal'] ), 'discount_total' => $plan['discount_total'], 'discount_formatted' => $this->settings->format_money( $plan['discount_total'] ), 'tax_total' => $plan['tax_total'], 'tax_formatted' => $this->settings->format_money( $plan['tax_total'] ), 'total' => $plan['total'], 'total_formatted' => $this->settings->format_money( $plan['total'] ), 'tax_inclusive' => $plan['tax_inclusive'], 'coupon_code' => $plan['coupon'] ? $plan['coupon']['code'] : '', ); } /** * Converts a UTC MySQL datetime to ISO 8601, or an empty string. * * @param string $gmt UTC datetime. * @return string */ private function iso( string $gmt ): string { return '' === $gmt || '0000-00-00 00:00:00' === $gmt ? '' : str_replace( ' ', 'T', $gmt ) . 'Z'; } }