array( $this, 'portal' ) ) ); wp_set_script_translations( 'bm-ap-portal-block', 'buildmerce-agent-orders', BM_AP_DIR . 'languages' ); } public static function activate() { BM_AP_Settings::initialize( (int) get_option( 'bm_ap_page_id' ) > 0 ); add_role( self::ROLE, __( 'Payment Agent', 'buildmerce-agent-orders' ), array( 'read' => true, self::CAP => true ) ); $role = get_role( self::ROLE ); if ( $role ) { $role->add_cap( self::CAP ); } $page_id = (int) get_option( 'bm_ap_page_id' ); if ( ! $page_id || 'page' !== get_post_type( $page_id ) ) { $id = wp_insert_post( array( 'post_title' => __( 'Agent Payment Requests', 'buildmerce-agent-orders' ), 'post_name' => 'agent-payments', 'post_type' => 'page', 'post_status' => 'publish', 'post_content' => '', 'comment_status' => 'closed', 'ping_status' => 'closed', ), true ); if ( ! is_wp_error( $id ) ) { update_option( 'bm_ap_page_id', $id, false ); } } if ( ! wp_next_scheduled( 'bm_ap_cleanup_requests' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'bm_ap_cleanup_requests' ); } } public static function deactivate() { wp_clear_scheduled_hook( 'bm_ap_cleanup_requests' ); // Orders, agent accounts and the portal page remain intact. } public static function cleanup() { global $wpdb; $like = $wpdb->esc_like( 'bm_ap_req_' ) . '%'; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 2000", $like ) ); foreach ( $rows as $row ) { $value = maybe_unserialize( $row->option_value ); // Request nonces cannot remain valid beyond 24 hours; keep records for at least 48. if ( is_array( $value ) && isset( $value['time'] ) && (int) $value['time'] < time() - 2 * DAY_IN_SECONDS ) { delete_option( $row->option_name ); } } } private function portal_url() { $page = (int) BM_AP_Settings::get( 'portal_page' ) ?: (int) get_option( 'bm_ap_page_id' ); $url = get_permalink( $page ); return $url ? $url : home_url( '/agent-payments/' ); } private function allowed() { return is_user_logged_in() && ( current_user_can( self::CAP ) || current_user_can( 'manage_woocommerce' ) ); } private function agent_only() { return current_user_can( self::CAP ) && ! current_user_can( 'manage_woocommerce' ) && ! current_user_can( 'manage_options' ); } public function deny() { wp_die( esc_html__( 'You must sign in with an authorized agent account.', 'buildmerce-agent-orders' ), esc_html__( 'Access denied', 'buildmerce-agent-orders' ), array( 'response' => 403 ) ); } public function admin_bar( $show ) { return $this->agent_only() ? false : $show; } public function block_agent_admin() { $script = isset( $_SERVER['SCRIPT_NAME'] ) && is_string( $_SERVER['SCRIPT_NAME'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) : ''; if ( $this->agent_only() && ! wp_doing_ajax() && 'admin-post.php' !== basename( $script ) ) { wp_safe_redirect( $this->portal_url() ); exit; } } public function login_redirect( $redirect, $requested, $user ) { if ( $user instanceof WP_User && $user->has_cap( self::CAP ) && ! $user->has_cap( 'manage_woocommerce' ) ) { return $this->portal_url(); } return $redirect; } private function is_portal() { $post = get_post(); $page_id = (int) BM_AP_Settings::get( 'portal_page' ) ?: (int) get_option( 'bm_ap_page_id' ); return ( $page_id > 0 && is_page( $page_id ) ) || ( $post && ( has_shortcode( $post->post_content, 'buildmerce_agent_paylater' ) || has_block( 'buildmerce/agent-portal', $post ) ) ); } public function protect_page() { $is_portal = $this->is_portal(); $is_agent_payment = is_wc_endpoint_url( 'order-pay' ) && $this->pay_order(); $is_agent_confirmation = is_wc_endpoint_url( 'order-received' ) && false === $this->email_verification_required( true, wc_get_order( absint( get_query_var( 'order-received' ) ) ), 'order-received' ); $is_agent_link = $is_agent_payment || $is_agent_confirmation; if ( ! $is_portal && ! $is_agent_link ) { return; } if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); } nocache_headers(); header( 'Cache-Control: no-store, private, max-age=0' ); header( 'X-Robots-Tag: noindex, nofollow', true ); header( 'Referrer-Policy: ' . ( $is_agent_link ? 'no-referrer' : 'same-origin' ) ); if ( $is_portal && 'portal' === BM_AP_Settings::get( 'login' ) && ! is_user_logged_in() && $this->is_post_request() ) { $this->login(); } } private function login() { if ( ! $this->is_post_request() || ! is_ssl() ) { $this->login_error( __( 'Use the HTTPS version of this page to sign in.', 'buildmerce-agent-orders' ) ); return; } $nonce = isset( $_POST['bm_ap_login_nonce'] ) && is_string( $_POST['bm_ap_login_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bm_ap_login_nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'bm_ap_login' ) ) { $this->login_error( __( 'Your sign-in form expired. Reload this page and try again.', 'buildmerce-agent-orders' ) ); return; } $ip = isset( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $key = 'bm_ap_login_' . hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ); $attempts = (int) get_transient( $key ); if ( $attempts >= 10 ) { $this->login_error( __( 'Too many attempts. Please try again in 15 minutes.', 'buildmerce-agent-orders' ) ); return; } $login = isset( $_POST['log'] ) && is_string( $_POST['log'] ) ? sanitize_text_field( wp_unslash( $_POST['log'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Passwords must remain unchanged; nonce verification and wp_authenticate() handle this credential. $pass = isset( $_POST['pwd'] ) && is_string( $_POST['pwd'] ) ? wp_unslash( $_POST['pwd'] ) : ''; $user = wp_authenticate( $login, $pass ); if ( is_wp_error( $user ) || ! ( $user->has_cap( self::CAP ) || $user->has_cap( 'manage_woocommerce' ) ) ) { set_transient( $key, $attempts + 1, 15 * MINUTE_IN_SECONDS ); $this->login_error( __( 'Unable to sign in. Check your agent username and password.', 'buildmerce-agent-orders' ) ); return; } delete_transient( $key ); wp_set_current_user( $user->ID ); wp_set_auth_cookie( $user->ID, ! empty( $_POST['rememberme'] ), true ); do_action( 'wp_login', $user->user_login, $user ); wp_safe_redirect( $this->portal_url() ); exit; } private function login_error( $message ) { $GLOBALS['bm_ap_login_error'] = $message; } public function assets() { if ( $this->is_portal() ) { wp_enqueue_style( 'bm-agent-paylater', plugins_url( 'assets/portal.css', BM_AP_FILE ), array(), BM_AP_VERSION ); wp_enqueue_script( 'bm-agent-paylater', plugins_url( 'assets/portal.js', BM_AP_FILE ), array( 'wp-i18n' ), BM_AP_VERSION, true ); wp_set_script_translations( 'bm-agent-paylater', 'buildmerce-agent-orders', BM_AP_DIR . 'languages' ); } if ( is_wc_endpoint_url( 'order-pay' ) ) { wp_enqueue_script( 'wc-country-select' ); wp_enqueue_script( 'wc-address-i18n' ); if ( $this->pay_order() ) { wp_enqueue_style( 'bm-agent-payment', plugins_url( 'assets/payment.css', BM_AP_FILE ), array(), BM_AP_VERSION ); wp_enqueue_script( 'bm-agent-payment', plugins_url( 'assets/payment.js', BM_AP_FILE ), array( 'jquery', 'wc-country-select', 'wc-address-i18n' ), BM_AP_VERSION, true ); } } } private function flash( $message, $type = 'success', $fields = array() ) { $token = wp_generate_uuid4(); set_transient( 'bm_ap_flash_' . get_current_user_id() . '_' . $token, array( 'message' => $message, 'type' => $type, 'fields' => $fields ), 10 * MINUTE_IN_SECONDS ); return $token; } private function finish( $message, $type = 'success', $order_id = 0, $fields = array() ) { $args = array( 'notice' => $this->flash( $message, $type, $fields ) ); if ( $order_id ) { $args['request'] = $order_id; } wp_safe_redirect( add_query_arg( $args, $this->portal_url() ) ); exit; } private function is_post_request() { $method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; return 'POST' === $method; } private function owns( $order ) { return $order && $order->get_meta( '_bm_ap_agent_id' ) && ( (int) $order->get_meta( '_bm_ap_agent_id' ) === get_current_user_id() || current_user_can( 'manage_woocommerce' ) ); } public function create() { if ( ! is_user_logged_in() || ( ! current_user_can( self::CAP ) && ! current_user_can( 'manage_woocommerce' ) ) ) { $this->deny(); return; } if ( ! $this->is_post_request() || ! is_ssl() ) { wp_die( esc_html__( 'Use a secure HTTPS form to submit payment requests.', 'buildmerce-agent-orders' ), esc_html__( 'Invalid request', 'buildmerce-agent-orders' ), array( 'response' => 400 ) ); return; } $token = isset( $_POST['request_token'] ) && is_string( $_POST['request_token'] ) ? sanitize_text_field( wp_unslash( $_POST['request_token'] ) ) : ''; $nonce = isset( $_POST['_wpnonce'] ) && is_string( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'bm_ap_create_' . $token ) ) { wp_die( esc_html__( 'Your form expired. Reload the agent page and try again.', 'buildmerce-agent-orders' ), esc_html__( 'Invalid request', 'buildmerce-agent-orders' ), array( 'response' => 403 ) ); return; } if ( ! preg_match( '/^[a-f0-9-]{36}$/', $token ) ) { wp_die( esc_html__( 'Invalid submission token.', 'buildmerce-agent-orders' ), '', array( 'response' => 400 ) ); } $fields = array(); try { $fields = BM_AP_Input::request( wp_unslash( $_POST ), wc_get_price_decimals() ); if ( $fields['quantity'] > (int) BM_AP_Settings::get( 'max_quantity' ) ) { /* translators: %d: maximum quantity allowed by the store. */ throw new InvalidArgumentException( esc_html( sprintf( __( 'Quantity exceeds this store’s maximum of %d.', 'buildmerce-agent-orders' ), (int) BM_AP_Settings::get( 'max_quantity' ) ) ) ); } $maximum = BM_AP_Settings::get( 'max_amount' ); if ( '' !== $maximum && BM_AP_Input::money( $fields['total'], wc_get_price_decimals() ) > BM_AP_Input::money( $maximum, wc_get_price_decimals() ) ) { throw new InvalidArgumentException( esc_html__( 'The request total exceeds this store’s configured maximum.', 'buildmerce-agent-orders' ) ); } } catch ( InvalidArgumentException $e ) { // Only retain validated scalar fields; never persist passwords or raw request contents. foreach ( array( 'product_name', 'price', 'sku', 'client_email', 'quantity', 'first_name', 'last_name', 'company', 'po', 'customer_note', 'internal_note' ) as $field ) { if ( isset( $_POST[ $field ] ) && is_string( $_POST[ $field ] ) ) { $fields[ $field ] = substr( sanitize_textarea_field( wp_unslash( $_POST[ $field ] ) ), 0, 2000 ); } } $this->finish( $e->getMessage(), 'error', 0, $fields ); } $key = 'bm_ap_req_' . hash( 'sha256', get_current_user_id() . ':' . $token ); $record = array( 'time' => time(), 'order_id' => 0, 'state' => 'creating' ); // Atomic unique option insert prevents two concurrent POSTs from creating two orders. if ( ! add_option( $key, $record, '', false ) ) { $existing = get_option( $key ); $existing_order = wc_get_order( isset( $existing['order_id'] ) ? $existing['order_id'] : 0 ); if ( $this->owns( $existing_order ) ) { $this->finish( __( 'This submission already created an order. Use the existing request below; you can resend its email.', 'buildmerce-agent-orders' ), 'success', $existing_order->get_id() ); } $this->finish( __( 'This submission is being processed or needs review. Refresh the request list before submitting again.', 'buildmerce-agent-orders' ), 'error' ); } $rate_key = 'bm_ap_new_request_' . get_current_user_id(); if ( WC_Rate_Limiter::retried_too_soon( $rate_key ) ) { delete_option( $key ); $this->finish( __( 'Please wait a few seconds before creating another request.', 'buildmerce-agent-orders' ), 'error', 0, $fields ); } WC_Rate_Limiter::set_rate_limit( $rate_key, (int) BM_AP_Settings::get( 'submission_delay' ) ); $order = null; try { $order = wc_create_order( array( 'status' => 'pending', 'customer_id' => 0, 'created_via' => 'buildmerce_agent_paylater' ) ); if ( is_wp_error( $order ) ) { throw new RuntimeException( esc_html__( 'Order creation failed.', 'buildmerce-agent-orders' ) ); } $order->set_currency( get_woocommerce_currency() ); $order->set_prices_include_tax( false ); $order->set_billing_email( $fields['client_email'] ); $order->set_billing_first_name( $fields['first_name'] ); $order->set_billing_last_name( $fields['last_name'] ); $order->set_billing_company( $fields['company'] ); $order->set_billing_country( WC()->countries->get_base_country() ); $order->set_customer_note( $fields['customer_note'] ); $order->update_meta_data( '_bm_ap_agent_id', get_current_user_id() ); $order->update_meta_data( '_bm_ap_po', $fields['po'] ); $order->update_meta_data( '_bm_ap_request_token', $token ); // Snapshot line, not a catalog product: a typed SKU never changes catalog prices or inventory. $item = new WC_Order_Item_Product(); $item->set_name( $fields['product_name'] ); $item->set_quantity( $fields['quantity'] ); $item->set_subtotal( $fields['total'] ); $item->set_total( $fields['total'] ); $item->set_tax_class( '' ); $item->add_meta_data( 'SKU', $fields['sku'], true ); $item->add_meta_data( 'Agreed unit price', $fields['price'], true ); $order->add_item( $item ); // Agreed final charge: no automatic tax, shipping, coupon or cart recalculation. $order->calculate_totals( false ); $order->save(); $record['order_id'] = $order->get_id(); $record['state'] = 'created'; update_option( $key, $record, false ); $agent = wp_get_current_user(); $order->add_order_note( __( 'Payment request created by agent ', 'buildmerce-agent-orders' ) . $agent->display_name . __( ' (user #', 'buildmerce-agent-orders' ) . $agent->ID . __( '). Agreed final total; no automatic tax or shipping.', 'buildmerce-agent-orders' ) ); if ( $fields['internal_note'] ) { $order->add_order_note( $fields['internal_note'] ); } } catch ( Throwable $e ) { if ( $order instanceof WC_Order ) { $order->update_status( 'cancelled', __( 'Incomplete agent request. Review before retrying; do not reopen for payment.', 'buildmerce-agent-orders' ) ); $record['order_id'] = $order->get_id(); } $record['state'] = 'failed'; update_option( $key, $record, false ); wc_get_logger()->error( 'Agent request creation failed (' . get_class( $e ) . ').', array( 'source' => 'buildmerce-agent-paylater' ) ); $this->finish( __( 'The request could not be completed. Check the request list and ask the store manager to review before retrying.', 'buildmerce-agent-orders' ), 'error' ); } $sent = $this->send_email( $order ); $this->finish( $sent ? __( 'Order created. The payment email was accepted by the mail service.', 'buildmerce-agent-orders' ) : __( 'Order created, but the email could not be confirmed as sent. Use Resend email below after checking your email configuration.', 'buildmerce-agent-orders' ), $sent ? 'success' : 'error', $order->get_id() ); } public function resend() { if ( ! is_user_logged_in() || ( ! current_user_can( self::CAP ) && ! current_user_can( 'manage_woocommerce' ) ) ) { $this->deny(); return; } if ( ! $this->is_post_request() || ! is_ssl() ) { wp_die( esc_html__( 'Use a secure HTTPS form to submit payment requests.', 'buildmerce-agent-orders' ), esc_html__( 'Invalid request', 'buildmerce-agent-orders' ), array( 'response' => 400 ) ); return; } $id = isset( $_POST['order_id'] ) && is_scalar( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0; $nonce = isset( $_POST['_wpnonce'] ) && is_string( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'bm_ap_resend_' . $id ) ) { wp_die( esc_html__( 'Your form expired. Reload the agent page and try again.', 'buildmerce-agent-orders' ), esc_html__( 'Invalid request', 'buildmerce-agent-orders' ), array( 'response' => 403 ) ); return; } $order = wc_get_order( $id ); if ( ! $this->owns( $order ) ) { $this->deny(); } if ( ! $order->needs_payment() ) { $this->finish( __( 'This order no longer needs payment. Its payment email was not resent.', 'buildmerce-agent-orders' ), 'error', $id ); } $lock = 'bm_ap_send_' . $id; if ( get_transient( $lock ) ) { $this->finish( __( 'Please wait at least one minute before resending this request.', 'buildmerce-agent-orders' ), 'error', $id ); } $send_lock = 'bm_ap_req_send_' . $id; $previous_lock = get_option( $send_lock ); if ( is_array( $previous_lock ) && $previous_lock['time'] < time() - 15 * MINUTE_IN_SECONDS ) { delete_option( $send_lock ); } if ( ! add_option( $send_lock, array( 'time' => time() ), '', false ) ) { $this->finish( __( 'An email send is already in progress. Please try again later.', 'buildmerce-agent-orders' ), 'error', $id ); } try { $sent = $this->send_email( $order ); } finally { delete_option( $send_lock ); } $this->finish( $sent ? __( 'The payment email was accepted by the mail service.', 'buildmerce-agent-orders' ) : __( 'Email could not be confirmed as sent. Ask the store manager to check email configuration.', 'buildmerce-agent-orders' ), $sent ? 'success' : 'error', $id ); } public function mail_result( $ok, $email_id, $email ) { if ( 'customer_invoice' === $email_id && $email->object instanceof WC_Order && $email->object->get_id() === $this->email_order ) { $this->mail_attempted = true; $this->mail_ok = (bool) $ok; } } private function send_email( $order ) { set_transient( 'bm_ap_send_' . $order->get_id(), 1, MINUTE_IN_SECONDS ); $this->email_order = $order->get_id(); $this->mail_ok = false; $this->mail_attempted = false; add_action( 'woocommerce_email_sent', array( $this, 'mail_result' ), 10, 3 ); try { $emails = WC()->mailer()->get_emails(); if ( isset( $emails['WC_Email_Customer_Invoice'] ) ) { $emails['WC_Email_Customer_Invoice']->trigger( $order->get_id(), $order ); } } catch ( Throwable $e ) { wc_get_logger()->error( __( 'Payment email failed for order #', 'buildmerce-agent-orders' ) . $order->get_id(), array( 'source' => 'buildmerce-agent-paylater' ) ); } finally { remove_action( 'woocommerce_email_sent', array( $this, 'mail_result' ), 10 ); $this->email_order = 0; } $status = $this->mail_attempted && $this->mail_ok ? 'accepted' : 'failed_or_unconfirmed'; $order->update_meta_data( '_bm_ap_email_status', $status ); $order->update_meta_data( '_bm_ap_email_at', time() ); $order->save(); $order->add_order_note( __( 'Agent payment request email: ', 'buildmerce-agent-orders' ) . $status . __( '. Mail acceptance does not confirm inbox delivery.', 'buildmerce-agent-orders' ) ); set_transient( 'bm_ap_send_' . $order->get_id(), 1, MINUTE_IN_SECONDS ); return 'accepted' === $status; } public function legacy_agent_query( $args, $query_vars ) { if ( isset( $query_vars['bm_ap_agent'] ) ) { $args['meta_query'][] = array( 'key' => '_bm_ap_agent_id', 'value' => (int) $query_vars['bm_ap_agent'], 'compare' => '=' ); } return $args; } public function paid_status( $status, $order_id, $order ) { // Ad-hoc items have no catalog product, so WooCommerce would otherwise auto-complete them. return $order->get_meta( '_bm_ap_agent_id' ) && in_array( $status, array( 'processing', 'completed' ), true ) ? BM_AP_Settings::get( 'paid_status' ) : $status; } private function history_status() { $status = isset( $_GET['bm_ap_status'] ) && is_string( $_GET['bm_ap_status'] ) ? sanitize_key( wp_unslash( $_GET['bm_ap_status'] ) ) : ''; return isset( wc_get_order_statuses()[ $status ] ) ? $status : ''; } private function requests() { $args = array( 'limit' => (int) BM_AP_Settings::get( 'per_page' ), 'page' => max( 1, isset( $_GET['bm_ap_page'] ) && is_scalar( $_GET['bm_ap_page'] ) ? min( 100000, absint( $_GET['bm_ap_page'] ) ) : 1 ), 'paginate' => true, 'orderby' => 'date', 'order' => 'DESC', 'type' => 'shop_order', ); if ( \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { $args['meta_query'] = array( array( 'key' => '_bm_ap_agent_id', 'value' => get_current_user_id(), 'compare' => '=' ) ); } else { // Legacy storage uses the registered custom-query adapter, not the HPOS-only meta_query argument. $args['bm_ap_agent'] = get_current_user_id(); } if ( $this->history_status() ) { $args['status'] = $this->history_status(); } return wc_get_orders( $args ); } private function history_filter() { echo '
'; } private function history_navigation( $result ) { if ( $result->max_num_pages < 2 ) { return; } $base = add_query_arg( array( 'bm_ap_status' => $this->history_status(), 'bm_ap_page' => 999999999 ), $this->portal_url() ); $links = paginate_links( array( 'base' => str_replace( '999999999', '%#%', esc_url( $base ) ), 'format' => '', 'current' => max( 1, isset( $_GET['bm_ap_page'] ) && is_scalar( $_GET['bm_ap_page'] ) ? absint( $_GET['bm_ap_page'] ) : 1 ), 'total' => (int) $result->max_num_pages, 'prev_text' => __( 'Previous', 'buildmerce-agent-orders' ), 'next_text' => __( 'Next', 'buildmerce-agent-orders' ), 'type' => 'list', ) ); echo ''; } public function portal() { ob_start(); echo '
'; if ( ! is_user_logged_in() ) { echo ( '

' . esc_html__( 'AGENT PORTAL', 'buildmerce-agent-orders' ) . '

' . esc_html__( 'Sign in to send payment requests', 'buildmerce-agent-orders' ) . '

' . esc_html__( 'Use the agent account provided by your store manager.', 'buildmerce-agent-orders' ) . '

' ); if ( 'wordpress' === BM_AP_Settings::get( 'login' ) ) { if ( is_ssl() ) { echo '

' . esc_html__( 'Sign in with your agent account', 'buildmerce-agent-orders' ) . '

'; } else { echo '

' . esc_html__( 'Open this page using HTTPS before signing in.', 'buildmerce-agent-orders' ) . '

'; } echo '
'; return ob_get_clean(); } if ( ! empty( $GLOBALS['bm_ap_login_error'] ) ) { echo ''; } if ( ! is_ssl() ) { echo ( '
' . esc_html__( 'Open this page using HTTPS before signing in.', 'buildmerce-agent-orders' ) . '
' ); } else { echo '
'; wp_nonce_field( 'bm_ap_login', 'bm_ap_login_nonce' ); echo ''; $this->field( 'log', __( 'Username or email', 'buildmerce-agent-orders' ), '', 'text', true, 'username' ); $this->field( 'pwd', __( 'Password', 'buildmerce-agent-orders' ), '', 'password', true, 'current-password' ); echo ( '

' . esc_html__( 'Forgot password?', 'buildmerce-agent-orders' ) . '

' ); } echo ''; return ob_get_clean(); } if ( ! $this->allowed() ) { echo ( '

' . esc_html__( 'Agent access required', 'buildmerce-agent-orders' ) . '

' . esc_html__( 'This account does not have permission to send payment requests.', 'buildmerce-agent-orders' ) . '

' . esc_html__( 'Sign out', 'buildmerce-agent-orders' ) . '' ); return ob_get_clean(); } $fields = array(); $notice = isset( $_GET['notice'] ) && is_string( $_GET['notice'] ) ? sanitize_text_field( wp_unslash( $_GET['notice'] ) ) : ''; if ( preg_match( '/^[a-f0-9-]{36}$/', $notice ) ) { $flash_key = 'bm_ap_flash_' . get_current_user_id() . '_' . $notice; $flash = get_transient( $flash_key ); if ( is_array( $flash ) ) { $fields = $flash['fields']; echo '
' . esc_html( $flash['message'] ) . '
'; } } $agent = wp_get_current_user(); echo ( '

' . esc_html__( 'AGENT PORTAL', 'buildmerce-agent-orders' ) . '

' . esc_html( BM_AP_Settings::get( 'heading' ) === 'Send a payment request' ? __( 'Send a payment request', 'buildmerce-agent-orders' ) : BM_AP_Settings::get( 'heading' ) ) . '

' . esc_html__( 'Signed in as', 'buildmerce-agent-orders' ) . ' ' ) . esc_html( $agent->display_name ) . '

' . esc_html__( 'Sign out', 'buildmerce-agent-orders' ) . '
' ); $receipt_id = isset( $_GET['request'] ) && is_scalar( $_GET['request'] ) ? absint( $_GET['request'] ) : 0; $receipt = $receipt_id ? wc_get_order( $receipt_id ) : false; if ( $this->owns( $receipt ) ) { echo ( '
' . esc_html__( 'Order #', 'buildmerce-agent-orders' ) ) . esc_html( $receipt->get_order_number() ) . ' · ' . esc_html( wc_get_order_status_name( $receipt->get_status() ) ) . ' · ' . wp_kses_post( $receipt->get_formatted_order_total() ); if ( $receipt->needs_payment() ) { echo ( '

'; } echo '
'; } if ( ! is_ssl() ) { echo ( '
' . esc_html__( 'Open this page using HTTPS to create requests.', 'buildmerce-agent-orders' ) . '
' ); return ob_get_clean(); } $token = wp_generate_uuid4(); $decimals = wc_get_price_decimals(); $step = 0 === $decimals ? '1' : '0.' . str_repeat( '0', $decimals - 1 ) . '1'; echo '
'; echo ''; wp_nonce_field( 'bm_ap_create_' . $token ); echo '
'; foreach ( array( 'product_name' => __( 'Product name', 'buildmerce-agent-orders' ), 'sku' => 'SKU', 'price' => __( 'Unit price (', 'buildmerce-agent-orders' ) . get_woocommerce_currency() . ')', 'client_email' => __( 'Client email', 'buildmerce-agent-orders' ) ) as $key => $label ) { $this->field( $key, $label, isset( $fields[ $key ] ) ? $fields[ $key ] : '', 'client_email' === $key ? 'email' : ( 'price' === $key ? 'number' : 'text' ), true, 'off', 'price' === $key ? array( 'min' => $step, 'step' => $step ) : array() ); } echo ( '
' . esc_html__( 'Optional details — PO, quantity, company and notes', 'buildmerce-agent-orders' ) . '
' ); foreach ( array( 'quantity' => __( 'Quantity', 'buildmerce-agent-orders' ), 'po' => __( 'PO / reference number', 'buildmerce-agent-orders' ), 'first_name' => __( 'Client first name', 'buildmerce-agent-orders' ), 'last_name' => __( 'Client last name', 'buildmerce-agent-orders' ), 'company' => __( 'Company', 'buildmerce-agent-orders' ) ) as $key => $label ) { $this->field( $key, $label, isset( $fields[ $key ] ) ? $fields[ $key ] : ( 'quantity' === $key ? '1' : '' ), 'quantity' === $key ? 'number' : 'text', false, 'off', 'quantity' === $key ? array( 'min' => 1, 'max' => BM_AP_Settings::get( 'max_quantity' ), 'step' => 1 ) : array() ); } echo '
'; foreach ( array( 'customer_note' => __( 'Message for client (included in the email)', 'buildmerce-agent-orders' ), 'internal_note' => __( 'Internal note (store team only)', 'buildmerce-agent-orders' ) ) as $key => $label ) { echo ''; } echo ( '
' . esc_html__( 'Client will be charged', 'buildmerce-agent-orders' ) . '' . esc_html__( 'Price × quantity', 'buildmerce-agent-orders' ) . '

' . esc_html__( 'This is the agreed final charge. Include any agreed tax or delivery charge in the unit price; no additional tax or shipping is calculated. The client enters their billing details when paying.', 'buildmerce-agent-orders' ) . '

' ); echo '

' . esc_html__( 'Your payment requests', 'buildmerce-agent-orders' ) . '

' . esc_html__( 'Filter requests by status or browse earlier pages. Reload to refresh payment status.', 'buildmerce-agent-orders' ) . '

'; $this->history_filter(); echo ( '
' ); $history = $this->requests(); $orders = $history->orders; foreach ( $orders as $order ) { if ( ! $this->owns( $order ) ) { continue; } echo ''; } if ( ! $orders ) { echo ( '' ); } echo '
' . esc_html__( 'Order', 'buildmerce-agent-orders' ) . '' . esc_html__( 'Client', 'buildmerce-agent-orders' ) . '' . esc_html__( 'Total', 'buildmerce-agent-orders' ) . '' . esc_html__( 'Status', 'buildmerce-agent-orders' ) . '' . esc_html__( 'Email', 'buildmerce-agent-orders' ) . '' . esc_html__( 'Action', 'buildmerce-agent-orders' ) . '
#' . esc_html( $order->get_order_number() ) . '
' . esc_html( $order->get_date_created() ? wc_format_datetime( $order->get_date_created() ) : '' ) . '
' . esc_html( $order->get_billing_email() ) . '' . wp_kses_post( $order->get_formatted_order_total() ) . '' . esc_html( wc_get_order_status_name( $order->get_status() ) ) . '' . esc_html( 'accepted' === $order->get_meta( '_bm_ap_email_status' ) ? __( 'Accepted by mail service', 'buildmerce-agent-orders' ) : __( 'Failed / unconfirmed', 'buildmerce-agent-orders' ) ) . ''; if ( $order->needs_payment() ) { echo '
'; wp_nonce_field( 'bm_ap_resend_' . $order->get_id() ); echo ( '
' . esc_html__( 'View link', 'buildmerce-agent-orders' ) . '' ); } else { echo '—'; } echo '
' . esc_html__( 'No requests match this view.', 'buildmerce-agent-orders' ) . '
'; $this->history_navigation( $history ); echo '
'; return ob_get_clean(); } private function field( $name, $label, $value = '', $type = 'text', $required = false, $autocomplete = 'off', $attributes = array() ) { echo ''; } public function email_details( $order, $sent_to_admin, $plain_text, $email ) { if ( ! $order->get_meta( '_bm_ap_agent_id' ) ) { return; } $po = $order->get_meta( '_bm_ap_po' ); if ( $po ) { if ( $plain_text ) { // Plain-text email has no HTML execution context. Preserve literal ampersands and quotes. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Plain-text mail; tags and control whitespace are removed, HTML entities would corrupt the PO reference. echo "\n" . wp_strip_all_tags( __( 'PO / Reference:', 'buildmerce-agent-orders' ), true ) . ' ' . sanitize_text_field( $po ) . "\n"; } else { echo '

' . esc_html__( 'PO / Reference:', 'buildmerce-agent-orders' ) . ' ' . esc_html( $po ) . '

'; } } if ( ! $sent_to_admin && 'customer_invoice' === $email->id && $order->needs_payment() && ! $plain_text ) { echo '

' . esc_html__( 'Pay now', 'buildmerce-agent-orders' ) . '

' . esc_html__( 'Enter your billing details and pay securely on our website.', 'buildmerce-agent-orders' ) . '

' ); } } public function admin_details( $order ) { if ( ! $order->get_meta( '_bm_ap_agent_id' ) ) { return; } $agent = get_userdata( (int) $order->get_meta( '_bm_ap_agent_id' ) ); echo ( '

' . esc_html__( 'Agent payment request:', 'buildmerce-agent-orders' ) . ' ' ) . esc_html( $agent ? $agent->display_name : __( 'Deleted agent', 'buildmerce-agent-orders' ) ) . esc_html__( ' (user #', 'buildmerce-agent-orders' ) . esc_html( $order->get_meta( '_bm_ap_agent_id' ) ) . ( ')
' . esc_html__( 'PO:', 'buildmerce-agent-orders' ) . ' ' ) . esc_html( $order->get_meta( '_bm_ap_po' ) ) . ( '
' . esc_html__( 'Email:', 'buildmerce-agent-orders' ) . ' ' ) . esc_html( $order->get_meta( '_bm_ap_email_status' ) ) . '

'; } /** Full order key grants direct access to agent-created guest payment requests only. */ public function email_verification_required( $required, $order, $context = '' ) { if ( 'direct' !== BM_AP_Settings::get( 'verification' ) ) { return $required; } if ( ! in_array( $context, array( 'order-pay', 'order-received' ), true ) || ! $order instanceof WC_Order || ! $order->get_meta( '_bm_ap_agent_id' ) || $order->get_user_id() ) { return $required; } if ( 'order-pay' === $context && ! $order->needs_payment() ) { return $required; } $key = isset( $_GET['key'] ) && is_string( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; if ( $order->get_id() !== absint( get_query_var( $context ) ) || '' === $key || ! hash_equals( $order->get_order_key(), $key ) ) { return $required; } return false; } public function available_gateways( $gateways ) { if ( ! is_wc_endpoint_url( 'order-pay' ) || ! $this->pay_order() ) { return $gateways; } $selected = (array) BM_AP_Settings::get( 'gateways' ); return $selected ? array_intersect_key( $gateways, array_fill_keys( $selected, true ) ) : $gateways; } private function pay_order() { if ( ! is_wc_endpoint_url( 'order-pay' ) ) { return false; } $id = absint( get_query_var( 'order-pay' ) ); $key = isset( $_GET['key'] ) && is_string( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; $order = wc_get_order( $id ); // Render only within WooCommerce's order-pay form after its order/key/status checks. return $order && $order->get_meta( '_bm_ap_agent_id' ) && '' !== $key && hash_equals( $order->get_order_key(), $key ) && $order->needs_payment() && current_user_can( 'pay_for_order', $id ) ? $order : false; } /** Bind guest nonces to the order's secret key as well as its ID. */ private function billing_nonce_action( $order ) { return 'bm_ap_billing_' . $order->get_id() . '_' . hash( 'sha256', $order->get_order_key() ); } /** Only callers that have verified the form nonce may provide posted data. */ private function address_value( $data, $key, $fallback = '' ) { return isset( $data[ $key ] ) && is_string( $data[ $key ] ) ? sanitize_text_field( $data[ $key ] ) : $fallback; } private function render_address( $order, $prefix, $fallback_country, $data = array() ) { $getter = 'get_' . $prefix . 'country'; $country = $this->address_value( $data, $prefix . 'country', $order->$getter() ?: $fallback_country ); $countries = WC()->countries->get_countries(); if ( ! isset( $countries[ $country ] ) ) { $country = $fallback_country; } $fields = WC()->countries->get_address_fields( $country, $prefix ); if ( 'billing_' === $prefix ) { unset( $fields['billing_email'] ); // The intended recipient remains the agent-entered email. } foreach ( $fields as $key => $definition ) { $getter = 'get_' . $key; $value = is_callable( array( $order, $getter ) ) ? $order->$getter() : ''; if ( $key === $prefix . 'country' && ! $value ) { $value = $country; } woocommerce_form_field( $key, $definition, $this->address_value( $data, $key, $value ) ); } } public function billing_fields() { $order = $this->pay_order(); if ( ! $order ) { return; } $data = array(); $nonce = isset( $_POST['bm_ap_billing_nonce'] ) && is_string( $_POST['bm_ap_billing_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bm_ap_billing_nonce'] ) ) : ''; if ( $this->is_post_request() && is_ssl() && wp_verify_nonce( $nonce, $this->billing_nonce_action( $order ) ) ) { $data = wp_unslash( $_POST ); } $base_country = $order->get_billing_country() ?: WC()->countries->get_base_country(); $same = isset( $data['woocommerce_pay'] ) ? '1' === $this->address_value( $data, 'bm_ap_ship_to_billing' ) : 'no' !== $order->get_meta( '_bm_ap_shipping_same_as_billing' ); echo '
'; echo ( '

' . esc_html__( 'Billing details', 'buildmerce-agent-orders' ) . '

' . esc_html__( 'Use the billing address associated with your payment method.', 'buildmerce-agent-orders' ) . '

' ); $this->render_address( $order, 'billing_', $base_country, $data ); echo ( '

' ) . esc_html( $order->get_billing_email() ) . '

'; echo '

' ); // Disabled fields cannot participate in browser validation or be posted while copying billing. echo '
' . esc_html__( 'Shipping address', 'buildmerce-agent-orders' ) . '
' ); $this->render_address( $order, 'shipping_', $base_country, $data ); echo ( '
' ); wp_nonce_field( $this->billing_nonce_action( $order ), 'bm_ap_billing_nonce' ); echo '
'; } /** Validate country-specific billing or shipping fields without changing order data. */ private function validated_address( $prefix, $data ) { $kind = 'shipping_' === $prefix ? 'shipping' : 'billing'; $country = $this->address_value( $data, $prefix . 'country' ); $countries = 'shipping_' === $prefix ? WC()->countries->get_shipping_countries() : WC()->countries->get_allowed_countries(); if ( ! isset( $countries[ $country ] ) ) { wc_add_notice( __( 'Select a valid ', 'buildmerce-agent-orders' ) . $kind . ' country.', 'error' ); return false; } $fields = WC()->countries->get_address_fields( $country, $prefix ); if ( 'billing_' === $prefix ) { unset( $fields['billing_email'] ); } $values = array(); $valid = true; foreach ( $fields as $key => $definition ) { $value = $this->address_value( $data, $key ); if ( isset( $data[ $key ] ) && ! is_string( $data[ $key ] ) ) { wc_add_notice( 'Invalid ' . $kind . ' field: ' . $definition['label'], 'error' ); $valid = false; } if ( strlen( $value ) > 200 ) { wc_add_notice( ucfirst( $kind ) . __( ' field is too long: ', 'buildmerce-agent-orders' ) . $definition['label'], 'error' ); $valid = false; } if ( ! empty( $definition['required'] ) && '' === $value ) { wc_add_notice( ucfirst( $kind ) . ' ' . $definition['label'] . __( ' is required.', 'buildmerce-agent-orders' ), 'error' ); $valid = false; } $values[ $key ] = $value; } $postcode = isset( $values[ $prefix . 'postcode' ] ) ? $values[ $prefix . 'postcode' ] : ''; if ( '' !== $postcode && ! WC_Validation::is_postcode( $postcode, $country ) ) { wc_add_notice( __( 'Enter a valid ', 'buildmerce-agent-orders' ) . $kind . __( ' postcode / ZIP.', 'buildmerce-agent-orders' ), 'error' ); $valid = false; } elseif ( '' !== $postcode ) { $values[ $prefix . 'postcode' ] = wc_format_postcode( $postcode, $country ); } $states = WC()->countries->get_states( $country ); $state_key = $prefix . 'state'; $state = isset( $values[ $state_key ] ) ? $values[ $state_key ] : ''; if ( is_array( $states ) && $states && '' !== $state && ! isset( $states[ $state ] ) ) { wc_add_notice( __( 'Select a valid ', 'buildmerce-agent-orders' ) . $kind . __( ' state / province.', 'buildmerce-agent-orders' ), 'error' ); $valid = false; } $phone = isset( $values[ $prefix . 'phone' ] ) ? $values[ $prefix . 'phone' ] : ''; if ( '' !== $phone && ! WC_Validation::is_phone( $phone ) ) { wc_add_notice( __( 'Enter a valid ', 'buildmerce-agent-orders' ) . $kind . __( ' phone number.', 'buildmerce-agent-orders' ), 'error' ); $valid = false; } return $valid ? $values : false; } public function save_billing( $order ) { if ( ! $order instanceof WC_Order || ! $order->get_meta( '_bm_ap_agent_id' ) ) { return; } // Enforce authorization locally too, even if another component invokes this hook directly. // WooCommerce grants pay_for_order to guest orders; no agent account is required to pay. if ( ! $this->is_post_request() || ! is_ssl() || ! is_wc_endpoint_url( 'order-pay' ) || $order->get_id() !== absint( get_query_var( 'order-pay' ) ) || ! $order->needs_payment() || ! current_user_can( 'pay_for_order', $order->get_id() ) ) { wc_add_notice( __( 'Open the valid payment link again before updating this order.', 'buildmerce-agent-orders' ), 'error' ); return; } $key = isset( $_GET['key'] ) && is_string( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; if ( '' === $key || ! hash_equals( $order->get_order_key(), $key ) ) { wc_add_notice( __( 'Open the valid payment link again before updating this order.', 'buildmerce-agent-orders' ), 'error' ); return; } $pay_nonce = isset( $_POST['woocommerce-pay-nonce'] ) && is_string( $_POST['woocommerce-pay-nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['woocommerce-pay-nonce'] ) ) : ''; if ( ! wp_verify_nonce( $pay_nonce, 'woocommerce-pay' ) ) { wc_add_notice( __( 'Please reload this payment page and enter your billing details.', 'buildmerce-agent-orders' ), 'error' ); return; } $nonce = isset( $_POST['bm_ap_billing_nonce'] ) && is_string( $_POST['bm_ap_billing_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bm_ap_billing_nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, $this->billing_nonce_action( $order ) ) ) { wc_add_notice( __( 'Please reload this payment page and enter your billing details.', 'buildmerce-agent-orders' ), 'error' ); return; } $data = wp_unslash( $_POST ); $same_value = $this->address_value( $data, 'bm_ap_ship_to_billing' ); if ( ( isset( $data['bm_ap_ship_to_billing'] ) && ! is_string( $data['bm_ap_ship_to_billing'] ) ) || ! in_array( $same_value, array( '', '1' ), true ) ) { wc_add_notice( __( 'Choose a valid shipping address option.', 'buildmerce-agent-orders' ), 'error' ); return; } $same = '1' === $same_value; $billing = $this->validated_address( 'billing_', $data ); $shipping = $same ? array() : $this->validated_address( 'shipping_', $data ); if ( false === $billing || false === $shipping ) { return; // A partial or invalid address must never be saved or sent to a payment gateway. } if ( $same ) { $shipping_countries = WC()->countries->get_shipping_countries(); if ( ! isset( $shipping_countries[ $billing['billing_country'] ] ) ) { wc_add_notice( __( 'Shipping to the billing country is unavailable. Enter a separate shipping address.', 'buildmerce-agent-orders' ), 'error' ); return; } foreach ( array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'phone' ) as $suffix ) { $shipping[ 'shipping_' . $suffix ] = isset( $billing[ 'billing_' . $suffix ] ) ? $billing[ 'billing_' . $suffix ] : ''; } } // Explicit empty optional fields remove an old address_2/company when changing addresses. foreach ( array( 'company', 'address_2', 'state', 'postcode', 'phone' ) as $suffix ) { if ( ! isset( $shipping[ 'shipping_' . $suffix ] ) ) { $shipping[ 'shipping_' . $suffix ] = ''; } } $order->set_props( array_merge( $billing, $shipping ) ); $order->update_meta_data( '_bm_ap_shipping_same_as_billing', $same ? 'yes' : 'no' ); $order->save(); // Keep the agreed total unchanged. WooCommerce invokes the selected payment gateway. } }