last_error; } public static function get_instance() { if (null === self::$instance) { self::$instance = new self(); } return self::$instance; } private function __construct() { add_action('init', array($this, 'init')); } public function init() { // Classic checkout hook add_action('woocommerce_checkout_order_processed', array($this, 'process_order_approval'), 10, 3); // Orders waiting for someone who leaves or becomes a Requester go to the next approver. add_action('crewcommerce_team_activity', array($this, 'release_assignments'), 10, 4); // A permanently deleted order takes its approval records with it. add_action('woocommerce_delete_order', array($this, 'forget_order')); // Blocks / Store API checkout hook (WooCommerce 7.2+) add_action('woocommerce_store_api_checkout_order_processed', array($this, 'process_order_approval_blocks'), 10, 1); // Fallback for orders that reach pending or processing without a workflow. add_action('woocommerce_order_status_changed', array($this, 'handle_order_status_change'), 10, 4); // Admin AJAX (crewcommerce_admin_nonce) add_action('wp_ajax_crewcommerce_admin_approve_order', array($this, 'ajax_admin_approve_order')); add_action('wp_ajax_crewcommerce_admin_reject_order', array($this, 'ajax_admin_reject_order')); add_action('wp_ajax_crewcommerce_admin_claim_approval', array($this, 'ajax_admin_claim_approval')); // Register the status here: we're already inside 'init', so hooking 'init' again wouldn't run. // Without it, WooCommerce drops the 'wc-' prefix and the order disappears from the Orders list. $this->register_order_statuses(); add_filter('wc_order_statuses', array($this, 'add_order_statuses')); // Email notifications add_action('crewcommerce_approval_requested', array($this, 'send_approval_request_email'), 10, 2); add_action('crewcommerce_approval_unassigned', array($this, 'send_unassigned_notification_email'), 10, 2); add_action('crewcommerce_order_approved', array($this, 'send_approval_notification_email'), 10, 2); add_action('crewcommerce_order_rejected', array($this, 'send_rejection_notification_email'), 10, 3); // Keep WooCommerce's unpaid-order cleanup away from approved orders waiting for payment. add_filter('woocommerce_cancel_unpaid_order', array($this, 'keep_approved_unpaid_orders'), 10, 2); } /** * Register custom order statuses */ public function register_order_statuses() { register_post_status('wc-pending-approval', array( 'label' => __('Pending Approval', 'crewcommerce'), 'public' => true, 'exclude_from_search' => false, 'show_in_admin_all_list' => true, 'show_in_admin_status_list' => true, /* translators: %s: number of orders */ 'label_count' => _n_noop('Pending Approval (%s)', 'Pending Approval (%s)', 'crewcommerce') )); register_post_status('wc-approval-rejected', array( 'label' => __('Approval Rejected', 'crewcommerce'), 'public' => true, 'exclude_from_search' => false, 'show_in_admin_all_list' => true, 'show_in_admin_status_list' => true, /* translators: %s: number of orders */ 'label_count' => _n_noop('Approval Rejected (%s)', 'Approval Rejected (%s)', 'crewcommerce') )); } /** * Add custom order statuses to WooCommerce */ public function add_order_statuses($order_statuses) { $new_order_statuses = array(); foreach ($order_statuses as $key => $status) { $new_order_statuses[$key] = $status; if ('wc-on-hold' === $key) { $new_order_statuses['wc-pending-approval'] = __('Pending Approval', 'crewcommerce'); $new_order_statuses['wc-approval-rejected'] = __('Approval Rejected', 'crewcommerce'); } } return $new_order_statuses; } /** * Classic checkout. * Hook: woocommerce_checkout_order_processed($order_id, $posted_data, $order) */ public function process_order_approval($order_id, $posted_data, $order) { $this->maybe_create_workflow($order); } /** * Block checkout (Store API). * Hook: woocommerce_store_api_checkout_order_processed($order) */ public function process_order_approval_blocks($order) { $this->maybe_create_workflow($order); } /** * Creates the approval workflow for an order, for both checkouts. * Does nothing if the order already has one. */ private function maybe_create_workflow($order) { if (!$order || !is_a($order, 'WC_Order')) { // Accept order ID too if (is_numeric($order)) { $order = wc_get_order($order); } if (!$order) return; } // Already has one. if ($this->get_order_workflow($order->get_id())) { return; } if (!$this->order_needs_approval($order)) { return; } $requester_id = $order->get_customer_id(); if (!$requester_id) { return; } $team_accounts = CrewCommerce_Team_Accounts::get_instance(); $company_id = $team_accounts->get_user_company($requester_id); if (!$company_id) { return; } $approvers = $this->get_company_approvers($company_id, $requester_id); if (empty($approvers)) { // No Manager or Admin left at this company. Don't let the order through: // park it as unassigned so a store admin, or whoever the company adds, can claim it. $workflow_id = $this->create_approval_workflow($order->get_id(), $requester_id, null, 'unassigned'); if (!$workflow_id) { return; } $order->update_status('pending-approval', __('Held for approval.', 'crewcommerce')); $requester_user = get_user_by('ID', $requester_id); $order->add_order_note(sprintf( /* translators: %s: requester name */ __('Order submitted for approval by %s. This company currently has no Manager or Admin, so no approver could be assigned automatically. The order is on hold until someone claims it from CrewCommerce > Approvals.', 'crewcommerce'), $requester_user ? $requester_user->display_name : __('requester', 'crewcommerce') )); do_action('crewcommerce_approval_unassigned', $order->get_id(), $workflow_id); return; } // The first approver in the list decides. $workflow_id = $this->create_approval_workflow($order->get_id(), $requester_id, $approvers[0]); if (!$workflow_id) { return; } /** * Fires after an order's approval workflow is created and assigned. * * @param int $workflow_id The new workflow's ID. * @param WC_Order $order The order being held. * @param int[] $approvers Approver user IDs, in order; the first one is assigned. */ do_action('crewcommerce_approval_workflow_created', $workflow_id, $order, $approvers); $order->update_status('pending-approval', __('Held for approval.', 'crewcommerce')); $requester_user = get_user_by('ID', $requester_id); $approver_name = __('company admin', 'crewcommerce'); $approver_user = get_user_by('ID', $approvers[0]); if ($approver_user) { $approver_name = $approver_user->display_name; } $order->add_order_note(sprintf( /* translators: 1: requester name, 2: approver name */ __('Order submitted for approval by %1$s. Awaiting approval from %2$s.', 'crewcommerce'), $requester_user ? $requester_user->display_name : __('requester', 'crewcommerce'), $approver_name )); do_action('crewcommerce_approval_requested', $order->get_id(), $workflow_id); } /** * Whether an order needs approval. * * Only members in the roles chosen in Settings are checked, and company * Admins never are. Add rules with the crewcommerce_needs_approval filter. */ public function order_needs_approval($order) { $product_ids = array(); foreach ($order->get_items() as $item) { if (is_a($item, 'WC_Order_Item_Product') && $item->get_product_id()) { $product_ids[] = (int) $item->get_product_id(); } } $needs = $this->needs_approval((int) $order->get_customer_id(), (float) $order->get_total(), $product_ids, $order); return (bool) apply_filters('crewcommerce_order_needs_approval', $needs, $order); } /** * The same decision for a cart that hasn't become an order yet (the * classic checkout validates before the order exists). */ public function cart_needs_approval($cart, $user_id) { $product_ids = array(); foreach ($cart->get_cart() as $item) { if (!empty($item['product_id'])) { $product_ids[] = (int) $item['product_id']; } } return $this->needs_approval((int) $user_id, (float) $cart->get_total('edit'), $product_ids, null); } /** * Shared rule check for orders and carts. * * @param int $requester_id The customer placing the order. * @param float $total Order or cart total. * @param int[] $product_ids Products in it. * @param WC_Order|null $order The order, when there is one. */ public function needs_approval($requester_id, $total, $product_ids, $order = null) { if (!$requester_id) { return false; } $team_accounts = CrewCommerce_Team_Accounts::get_instance(); $company_id = (int) $team_accounts->get_user_company($requester_id); if (!$company_id) { return false; } $role = $team_accounts->get_user_role($requester_id); if ('admin' === $role || !in_array($role, $this->get_roles_checked(), true)) { return false; } $context = array( 'requester_id' => $requester_id, 'company_id' => $company_id, 'role' => $role, 'total' => $total, 'product_ids' => $product_ids, 'order' => $order, ); $needs = $total >= $this->get_threshold($company_id); /** * Whether an order (or cart) needs approval. * * @param bool $needs The decision so far, from the order value threshold. * @param array $context requester_id, company_id, role, total, product_ids and order (or null). */ return (bool) apply_filters('crewcommerce_needs_approval', $needs, $context); } /** * Company roles whose orders are checked against the approval rules. * Admins are always exempt. */ public function get_roles_checked() { $settings = get_option('crewcommerce_settings', array()); $roles = isset($settings['require_approval_roles']) ? (array) $settings['require_approval_roles'] : array('requester'); return array_values(array_intersect($roles, array('requester', 'manager'))); } /** * The order value at or above which a company's orders need approval. */ public function get_threshold($company_id) { $settings = get_option('crewcommerce_settings', array()); $threshold = isset($settings['approval_threshold']) ? (float) $settings['approval_threshold'] : 0.0; /** * The approval threshold for a company. * * @param float $threshold The store-wide threshold from Settings. * @param int $company_id The requester's company. */ return (float) apply_filters('crewcommerce_approval_threshold', $threshold, (int) $company_id); } /** * Hours after which a waiting approval is shown as overdue. */ public function get_stale_hours() { return max(1, (int) apply_filters('crewcommerce_stale_hours', 48)); } /** * Approver user IDs for a company: Managers first, then the Admin, never * the requester. The first one gets the order. */ public function get_company_approvers($company_id, $exclude_user_id = 0) { global $wpdb; // Join wp_users so a row left behind by a deleted account is never // treated as an approver. $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT tm.user_id, tm.role FROM {$wpdb->prefix}crewcommerce_team_members tm JOIN {$wpdb->users} u ON tm.user_id = u.ID WHERE tm.company_id = %d AND tm.role IN ('manager', 'admin') AND tm.user_id <> %d ORDER BY FIELD(tm.role, 'manager', 'admin') ASC, tm.created_at ASC", $company_id, (int) $exclude_user_id )); $ids = array_slice(array_map('intval', wp_list_pluck($rows, 'user_id')), 0, 1); /** * Approvers for an order, in order. The first is assigned the order. * * @param int[] $ids Approver user IDs (by default, just the first available one). * @param int $company_id The requester's company. * @param int $exclude_user_id The requester, who never approves their own order. * @param object[] $rows Every eligible approver, with user_id and role. */ return array_values(array_map('intval', (array) apply_filters('crewcommerce_company_approvers', $ids, (int) $company_id, (int) $exclude_user_id, $rows))); } /** * Create a workflow. Use 'unassigned' when there's no approver yet * (see claim_approval()). */ public function create_approval_workflow($order_id, $requester_id, $approver_id, $status = 'pending') { global $wpdb; $result = $wpdb->insert( $wpdb->prefix . 'crewcommerce_approval_workflows', array( 'order_id' => $order_id, 'requester_id' => $requester_id, 'approver_id' => $approver_id ?: null, 'status' => $status, ), array('%d', '%d', $approver_id ? '%d' : '%s', '%s') ); return $result ? $wpdb->insert_id : false; } /** * Items in $order that no longer have enough stock to be sold, as * human-readable "Name (needs N, M left)" strings. Only products that * manage stock and don't allow backorders are checked, and only while * the order hasn't already reduced its own stock. */ public function get_stock_shortages($order) { $shortages = array(); if (!$order || !function_exists('wc_get_product')) { return $shortages; } $data_store = $order->get_data_store(); if ($data_store && method_exists($data_store, 'get_stock_reduced') && $data_store->get_stock_reduced($order->get_id())) { return $shortages; } $needed = array(); $names = array(); foreach ($order->get_items() as $item) { if (!is_a($item, 'WC_Order_Item_Product')) { continue; } $product = $item->get_product(); if (!$product || !$product->managing_stock() || $product->backorders_allowed()) { continue; } $stock_id = $product->get_stock_managed_by_id(); $needed[$stock_id] = (isset($needed[$stock_id]) ? $needed[$stock_id] : 0) + (int) $item->get_quantity(); $names[$stock_id] = $product->get_name(); } foreach ($needed as $stock_id => $qty) { $stock_product = wc_get_product($stock_id); $available = $stock_product ? (int) $stock_product->get_stock_quantity() : 0; if ($available < $qty) { $shortages[] = sprintf( /* translators: 1: product name, 2: quantity ordered, 3: quantity in stock */ __('%1$s (needs %2$d, %3$d left)', 'crewcommerce'), $names[$stock_id], $qty, max(0, $available) ); } } return $shortages; } /** * Approve order */ /** * Remove the approval records of an order that was permanently deleted. * Moving an order to the trash keeps them, so restoring it works. */ public function forget_order($order_id) { global $wpdb; $table = $wpdb->prefix . 'crewcommerce_approval_workflows'; $ids = array_map('intval', $wpdb->get_col($wpdb->prepare("SELECT id FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE order_id = %d", $order_id))); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if (!$ids) { return; } $wpdb->delete($table, array('order_id' => (int) $order_id), array('%d')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery /** * Fires after an order's approval records were deleted with it. * * @param int $order_id The deleted order. * @param int[] $workflow_ids The approval records removed. */ do_action('crewcommerce_order_workflows_deleted', (int) $order_id, $ids); } /** * Who may approve or reject: store owners, or a current Manager/Admin of * the requester's company. Being the assigned approver isn't enough, so * people who left or were made Requesters can't decide their old orders. */ public function user_can_decide($workflow, $user_id) { $team = CrewCommerce_Team_Accounts::get_instance(); $approver_company = (int) $team->get_user_company($user_id); $requester_company = (int) $team->get_user_company($workflow->requester_id); $allowed = current_user_can('manage_options') || ( $approver_company && $approver_company === $requester_company && in_array($team->get_user_role($user_id), array('admin', 'manager'), true) ); /** * Whether a person may approve or reject a workflow. * * @param bool $allowed Store owner, or a Manager or Admin of the requester's company. * @param object $workflow The workflow row. * @param int $user_id The person deciding. */ return (bool) apply_filters('crewcommerce_can_decide_workflow', $allowed, $workflow, (int) $user_id); } /** * When someone leaves a company or becomes a Requester, pass the orders * waiting for them to the company's next approver, or mark them * unassigned for the store owner to pick up. */ public function release_assignments($user_id, $company_id, $action, $data) { $demoted = 'role_updated' === $action && isset($data['new_role']) && 'requester' === $data['new_role']; if ('removed_from_team' !== $action && !$demoted) { return; } global $wpdb; $team = CrewCommerce_Team_Accounts::get_instance(); $table = $wpdb->prefix . 'crewcommerce_approval_workflows'; $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT * FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE approver_id = %d AND status = 'pending'", $user_id )); foreach ($rows as $workflow) { if ((int) $team->get_user_company($workflow->requester_id) !== (int) $company_id) { continue; } $approvers = $this->get_company_approvers($company_id, $workflow->requester_id); $next = $approvers ? (int) $approvers[0] : 0; if ($next && $next !== (int) $user_id) { $wpdb->update($table, array('approver_id' => $next), array('id' => $workflow->id), array('%d'), array('%d')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery do_action('crewcommerce_approval_requested', (int) $workflow->order_id, (int) $workflow->id); } else { $wpdb->query($wpdb->prepare("UPDATE {$table} SET approver_id = NULL, status = 'unassigned' WHERE id = %d", $workflow->id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared do_action('crewcommerce_approval_unassigned', (int) $workflow->order_id, (int) $workflow->id); } } } public function approve_order($workflow_id, $approver_id, $notes = '') { global $wpdb; $this->last_error = ''; // Get workflow $workflow = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE id = %d", $workflow_id )); if (!$workflow || $workflow->status !== 'pending') { return false; } $is_site_admin = current_user_can('manage_options'); if (!$this->user_can_decide($workflow, $approver_id)) { return false; } // Nobody decides on their own order. The one exception is the store // owner (manage_options), who can always act on any order. if ((int) $approver_id === (int) $workflow->requester_id && !$is_site_admin) { $this->last_error = __('You cannot approve or reject your own order. Another Manager, the company Admin or the store owner has to.', 'crewcommerce'); return false; } // Make sure the order still exists, so a deleted order can't leave the workflow marked approved. $order = wc_get_order($workflow->order_id); if (!$order) { return false; } // Stock isn't reserved while an order waits, so check it's still available before approving. $shortages = $this->get_stock_shortages($order); if (!empty($shortages)) { $this->last_error = sprintf( /* translators: %s: list of products */ __('This order can\'t be approved because there isn\'t enough stock: %s. Restock the item, or ask the buyer to change the order, then approve it.', 'crewcommerce'), implode('; ', $shortages) ); $order->add_order_note(sprintf( /* translators: %s: list of products */ __('Approval refused: not enough stock for %s.', 'crewcommerce'), implode('; ', $shortages) )); return false; } // status = 'pending' in the WHERE stops two simultaneous approvals from both going through. $result = $wpdb->update( $wpdb->prefix . 'crewcommerce_approval_workflows', array( 'status' => 'approved', 'approver_id' => $approver_id, 'notes' => sanitize_textarea_field($notes), ), array('id' => $workflow_id, 'status' => 'pending'), array('%s', '%d', '%s'), array('%d', '%s') ); // 0 rows: another request got there first. if ($result) { /** * Lets an add-on send the order to a further approval step * instead of releasing it. Return true when another step was * started; the order then stays Pending Approval. * * @param bool $continued Whether another step was started. * @param object $workflow The workflow that was just approved. * @param WC_Order $order The order. * @param int $approver_id Who approved it. */ if (apply_filters('crewcommerce_continue_approval', false, $workflow, $order, $approver_id)) { return true; } $approver = get_user_by('ID', $approver_id); $this->release_approved_order($order); $order->add_order_note(trim(sprintf( /* translators: 1: approver name, 2: approver notes */ __('Order approved by %1$s. %2$s', 'crewcommerce'), $approver ? $approver->display_name : __('approver', 'crewcommerce'), /* translators: %s: approver notes */ $notes ? sprintf(__('Notes: %s', 'crewcommerce'), $notes) : '' ))); do_action('crewcommerce_order_approved', $workflow->order_id, $workflow_id); return true; } return false; } /** * Reject order */ public function reject_order($workflow_id, $approver_id, $notes = '') { global $wpdb; $this->last_error = ''; // Get workflow $workflow = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE id = %d", $workflow_id )); if (!$workflow || $workflow->status !== 'pending') { return false; } $is_site_admin = current_user_can('manage_options'); if (!$this->user_can_decide($workflow, $approver_id)) { return false; } // Nobody decides on their own order. The one exception is the store // owner (manage_options), who can always act on any order. if ((int) $approver_id === (int) $workflow->requester_id && !$is_site_admin) { $this->last_error = __('You cannot approve or reject your own order. Another Manager, the company Admin or the store owner has to.', 'crewcommerce'); return false; } // Same deleted-order check as approve_order(). $order = wc_get_order($workflow->order_id); if (!$order) { return false; } // Same race guard as approve_order(). $result = $wpdb->update( $wpdb->prefix . 'crewcommerce_approval_workflows', array( 'status' => 'rejected', 'approver_id' => $approver_id, 'notes' => sanitize_textarea_field($notes), ), array('id' => $workflow_id, 'status' => 'pending'), array('%s', '%d', '%s'), array('%d', '%s') ); // 0 (lost the optimistic-lock race) must fail the same as false. if ($result) { $order->update_status('approval-rejected', __('Approval rejected.', 'crewcommerce')); $approver = get_user_by('ID', $approver_id); $order->add_order_note(trim(sprintf( /* translators: 1: approver name, 2: rejection reason */ __('Order rejected by %1$s. %2$s', 'crewcommerce'), $approver ? $approver->display_name : __('approver', 'crewcommerce'), /* translators: %s: rejection reason */ $notes ? sprintf(__('Reason: %s', 'crewcommerce'), $notes) : '' ))); do_action('crewcommerce_order_rejected', $workflow->order_id, $workflow_id, $notes); return true; } return false; } /** * Sitewide count of pending workflows, for the wp-admin menu bubble. */ public function count_pending_workflows() { global $wpdb; return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE status IN ('pending', 'unassigned')" ); } /** * Workflows nobody is assigned to, because the company had no Manager * or Admin. Shown on the dashboard for someone to claim. */ public function get_unassigned_pending_workflows() { global $wpdb; return $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE status = 'unassigned' ORDER BY created_at ASC" ); } /** * Pending or unassigned workflows older than $hours. Used for the * dashboard's "needs attention" list and by reminder add-ons. */ public function get_stale_pending_workflows($hours = 48) { global $wpdb; return $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE status IN ('pending', 'unassigned') AND created_at <= %s ORDER BY created_at ASC", gmdate('Y-m-d H:i:s', time() - ($hours * HOUR_IN_SECONDS)) )); } /** * Pending and unassigned approvals in the user's company. Any Manager or * Admin there can act on them, not only the assigned one. */ public function get_pending_approvals($approver_id) { global $wpdb; $team_accounts = CrewCommerce_Team_Accounts::get_instance(); $company_id = $team_accounts->get_user_company($approver_id); // Site admins (no company) see all pending/unassigned workflows if (!$company_id) { if (current_user_can('manage_options')) { return $wpdb->get_results( "SELECT aw.* FROM {$wpdb->prefix}crewcommerce_approval_workflows aw WHERE aw.status IN ('pending', 'unassigned') ORDER BY aw.created_at ASC" ); } return array(); } // Everyone in the company except the viewer: nobody approves their own order. $requester_ids = $wpdb->get_col($wpdb->prepare( "SELECT user_id FROM {$wpdb->prefix}crewcommerce_team_members WHERE company_id = %d AND user_id <> %d", $company_id, (int) $approver_id )); if (empty($requester_ids)) { return array(); } $placeholders = implode(',', array_fill(0, count($requester_ids), '%d')); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- only %d placeholders are interpolated; values go through prepare(). $workflows = $wpdb->get_results( $wpdb->prepare( "SELECT aw.* FROM {$wpdb->prefix}crewcommerce_approval_workflows aw WHERE aw.requester_id IN ($placeholders) AND aw.status IN ('pending', 'unassigned') ORDER BY aw.created_at ASC", ...$requester_ids ) ); // phpcs:enable // Old pending workflows with no approver get one. 'unassigned' ones are left for someone to claim. foreach ($workflows as $wf) { if ($wf->status === 'pending' && !$wf->approver_id) { $approvers = $this->get_company_approvers($company_id, $wf->requester_id); if (!empty($approvers)) { $wpdb->update( $wpdb->prefix . 'crewcommerce_approval_workflows', array('approver_id' => $approvers[0]), array('id' => $wf->id), array('%d'), array('%d') ); $wf->approver_id = $approvers[0]; } } } return $workflows; } /** * Allow a requester to cancel a pending approval and reopen the order for editing. * Returns true on success or a WP_Error. */ public function cancel_pending_approval($workflow_id, $requester_id) { global $wpdb; $workflow = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE id = %d", $workflow_id )); if (!$workflow) { return new WP_Error('not_found', __('Approval workflow not found.', 'crewcommerce')); } // Only the original requester (or a site admin) can cancel if ($workflow->requester_id != $requester_id && !current_user_can('manage_options')) { return new WP_Error('forbidden', __('You do not have permission to cancel this request.', 'crewcommerce')); } if ($workflow->status !== 'pending') { return new WP_Error('invalid_status', __('Only pending approvals can be cancelled.', 'crewcommerce')); } $wpdb->update( $wpdb->prefix . 'crewcommerce_approval_workflows', array('status' => 'cancelled'), array('id' => $workflow_id), array('%s'), array('%d') ); $order = wc_get_order($workflow->order_id); if ($order) { $order->update_status('on-hold', __('Approval request cancelled by requester.', 'crewcommerce')); $order->add_order_note(__('Requester cancelled the approval request. Order is on hold and can be edited before resubmitting.', 'crewcommerce')); } do_action('crewcommerce_approval_cancelled', $workflow->order_id, $workflow_id, $requester_id); return true; } /** * Claim an unassigned approval. Store owners can, and so can a Manager or * Admin of the requester's company, for example one added after the company * lost its approvers. Returns true or a WP_Error. */ public function claim_approval($workflow_id, $user_id) { global $wpdb; $workflow = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE id = %d", $workflow_id )); if (!$workflow) { return new WP_Error('not_found', __('Approval workflow not found.', 'crewcommerce')); } if ($workflow->status !== 'unassigned') { return new WP_Error('invalid_status', __('This order has already been assigned or actioned.', 'crewcommerce')); } $team_accounts = CrewCommerce_Team_Accounts::get_instance(); $requester_company = $team_accounts->get_user_company($workflow->requester_id); $claimer_company = $team_accounts->get_user_company($user_id); $claimer_role = $team_accounts->get_user_role($user_id); $is_site_admin = current_user_can('manage_options'); $is_company_approver = ( $requester_company && $claimer_company && $requester_company == $claimer_company && in_array($claimer_role, array('manager', 'admin'), true) ); if (!$is_site_admin && !$is_company_approver) { return new WP_Error('forbidden', __('You do not have permission to claim this order.', 'crewcommerce')); } if ((int) $user_id === (int) $workflow->requester_id && !$is_site_admin) { return new WP_Error('forbidden', __('You cannot approve your own order. Another Manager, the company Admin or the store owner has to.', 'crewcommerce')); } $wpdb->update( $wpdb->prefix . 'crewcommerce_approval_workflows', array('approver_id' => $user_id, 'status' => 'pending'), array('id' => $workflow_id), array('%d', '%s'), array('%d') ); $order = wc_get_order($workflow->order_id); $claimer = get_user_by('ID', $user_id); if ($order) { $order->add_order_note(sprintf( /* translators: %s: name of the person who claimed the order */ __('%s claimed this order for approval.', 'crewcommerce'), $claimer ? $claimer->display_name : __('An administrator', 'crewcommerce') )); } do_action('crewcommerce_approval_requested', $workflow->order_id, $workflow_id); return true; } /** * A requester's approval workflows, for My Requests. Fills in an approver * name when none is assigned yet. */ public function get_requester_workflows($requester_id, $limit = 20) { global $wpdb; $workflows = $wpdb->get_results($wpdb->prepare( "SELECT aw.*, u.display_name AS approver_name FROM {$wpdb->prefix}crewcommerce_approval_workflows aw LEFT JOIN {$wpdb->users} u ON aw.approver_id = u.ID WHERE aw.requester_id = %d ORDER BY aw.created_at DESC LIMIT %d", $requester_id, $limit )); // Fill in the approver name for old pending workflows. Not for 'unassigned' // ones: nobody has picked those up yet. foreach ($workflows as $wf) { if ($wf->status === 'pending' && (!$wf->approver_id || !$wf->approver_name)) { $team_accounts = CrewCommerce_Team_Accounts::get_instance(); $company_id = $team_accounts->get_user_company($requester_id); if ($company_id) { $approvers = $this->get_company_approvers($company_id, $wf->requester_id); if (!empty($approvers)) { // Assign the first available approver to this workflow $wpdb->update( $wpdb->prefix . 'crewcommerce_approval_workflows', array('approver_id' => $approvers[0]), array('id' => $wf->id), array('%d'), array('%d') ); $approver = get_user_by('ID', $approvers[0]); $wf->approver_id = $approvers[0]; $wf->approver_name = $approver ? $approver->display_name : null; } } } } return $workflows; } /** * Get approval workflow for order */ /** * The sentence shown to the approver after they approve or reject an * order, e.g. "Order #322 approved. Marcus Chen has been emailed a link * to pay." $decision is 'approved' or 'rejected'. */ public function get_decision_message($workflow_id, $decision) { global $wpdb; $workflow = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE id = %d", $workflow_id )); $order = $workflow ? wc_get_order($workflow->order_id) : null; if (!$workflow || !$order) { return $decision === 'rejected' ? __('Order rejected.', 'crewcommerce') : __('Order approved.', 'crewcommerce'); } $number = $order->get_order_number(); $requester = get_user_by('ID', $workflow->requester_id); $name = $requester ? $requester->display_name : __('The buyer', 'crewcommerce'); if ($decision === 'rejected') { /* translators: 1: order number, 2: buyer name */ return sprintf(__('Order #%1$s rejected. %2$s has been emailed with your reason.', 'crewcommerce'), $number, $name); } if ($order->needs_payment()) { /* translators: 1: order number, 2: buyer name */ $message = sprintf(__('Order #%1$s approved. %2$s has been emailed a link to pay.', 'crewcommerce'), $number, $name); } else { /* translators: 1: order number, 2: buyer name */ $message = sprintf(__('Order #%1$s approved. %2$s has been emailed, and the order has gone to the store.', 'crewcommerce'), $number, $name); } /** * The confirmation shown to an approver after they approve an order. * * @param string $message The message. * @param object $workflow The workflow that was approved. * @param WC_Order $order The order. */ return apply_filters('crewcommerce_approved_message', $message, $workflow, $order); } public function get_order_workflow($order_id) { global $wpdb; return $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE order_id = %d ORDER BY created_at DESC LIMIT 1", $order_id )); } /** * Get a workflow row by its own id (as opposed to get_order_workflow(), * which looks up by order_id). */ public function get_workflow_by_id($workflow_id) { global $wpdb; return $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}crewcommerce_approval_workflows WHERE id = %d", $workflow_id )); } /** * Who can see and comment on a workflow: the requester, the company's * Managers and Admin, and store owners. */ public function user_can_view_workflow($workflow, $user_id) { if (!$workflow) { return false; } if ((int) $workflow->requester_id === (int) $user_id) { return true; } if (current_user_can('manage_options')) { return true; } $team_accounts = CrewCommerce_Team_Accounts::get_instance(); $requester_company = $team_accounts->get_user_company($workflow->requester_id); $user_company = $team_accounts->get_user_company($user_id); return ( $requester_company && $user_company && $requester_company == $user_company && in_array($team_accounts->get_user_role($user_id), array('admin', 'manager'), true) ); } /** * Creates a missing workflow if an order skipped the checkout hooks, and * notes who moved a held order on by hand. */ public function handle_order_status_change($order_id, $old_status, $new_status, $order) { // Safety net: new order arrived without a workflow if (in_array($new_status, array('pending', 'processing'), true)) { $existing = $this->get_order_workflow($order_id); if (!$existing) { $this->maybe_create_workflow($order); return; // maybe_create_workflow sets status to pending-approval if needed } } // Audit: admin manually pushed a pending-approval order past the approval step if (in_array($new_status, array('processing', 'completed'), true)) { $workflow = $this->get_order_workflow($order_id); if ($workflow && $workflow->status === 'pending') { global $wpdb; $actor_id = get_current_user_id(); $actor = $actor_id ? get_user_by('ID', $actor_id) : null; $actor_name = $actor ? $actor->display_name : 'System'; $wpdb->update( $wpdb->prefix . 'crewcommerce_approval_workflows', array( 'status' => 'approved', 'approver_id' => $actor_id ?: null, 'notes' => sprintf( /* translators: 1: new order status, 2: name of the person who changed it */ __('Manually overridden to %1$s by %2$s', 'crewcommerce'), $new_status, $actor_name ), ), array('id' => $workflow->id), array('%s', '%d', '%s'), array('%d') ); $order->add_order_note(sprintf( /* translators: 1: new order status, 2: name of the person who changed it */ __('Approval workflow overridden: order moved to "%1$s" manually by %2$s.', 'crewcommerce'), wc_get_order_status_name($new_status), $actor_name )); } } } /** * After the final approval, send the order where checkout would have: * - already paid, or nothing to pay: Processing * - COD, bank transfer, check: whatever that gateway sets (its filters apply) * - anything else, like cards: Pending payment, and the buyer gets a pay link * * Map other offline gateways with the crewcommerce_status_after_approval filter. */ private function release_approved_order($order) { $status = $this->status_after_approval($order); $order->update_status($status, __('Approved.', 'crewcommerce')); // WooCommerce doesn't send "New order" when an order leaves Pending Approval, // so send it here. Orders waiting for payment get it once they're paid. if ($status !== 'pending') { $this->send_new_order_email($order); } } /** * The status an order moves to once its final approval is granted. * See release_approved_order(). */ public function status_after_approval($order) { if ((float) $order->get_total() <= 0 || $order->get_date_paid()) { $status = 'processing'; } else { switch ($order->get_payment_method()) { case 'cod': $status = apply_filters('woocommerce_cod_process_payment_order_status', $order->has_downloadable_item() ? 'on-hold' : 'processing', $order); break; case 'bacs': $status = apply_filters('woocommerce_bacs_process_payment_order_status', 'on-hold', $order); break; case 'cheque': $status = apply_filters('woocommerce_cheque_process_payment_order_status', 'on-hold', $order); break; default: $status = 'pending'; } } return apply_filters('crewcommerce_status_after_approval', $status, $order); } /** * Keep WooCommerce from auto-cancelling an approved order that is still * waiting for the requester to pay. */ public function keep_approved_unpaid_orders($cancel, $order) { if ($cancel && $order instanceof WC_Order) { $workflow = $this->get_order_workflow($order->get_id()); if ($workflow && $workflow->status === 'approved') { return false; } } return $cancel; } /** * Send WooCommerce's own "New order" email to the store. * WC_Email_New_Order marks the order as notified, so this never * duplicates an email already sent. */ private function send_new_order_email($order) { if (!function_exists('WC') || !WC()->mailer()) { return; } $emails = WC()->mailer()->get_emails(); if (!empty($emails['WC_Email_New_Order'])) { $emails['WC_Email_New_Order']->trigger($order->get_id(), $order); } } /** * Send approval request email */ public function send_approval_request_email($order_id, $workflow_id) { // The step this email is about; an order can have more than one. $workflow = $workflow_id ? $this->get_workflow_by_id($workflow_id) : null; if (!$workflow) { $workflow = $this->get_order_workflow($order_id); } if (!$workflow) { return; } $order = wc_get_order($order_id); $approver = get_user_by('ID', $workflow->approver_id); $requester = get_user_by('ID', $workflow->requester_id); if (!$order || !$approver || !$requester) { return; } /* translators: %s: order number */ $subject = sprintf(__('Order Approval Required - Order #%s', 'crewcommerce'), $order->get_order_number()); $message = sprintf( /* translators: 1: approver name, 2: order number, 3: requester name, 4: order total, 5: list of items */ __("Hello %1\$s,\n\nAn order requires your approval:\n\nOrder #%2\$s\nRequested by: %3\$s\nTotal: %4\$s\n\nPlease review and approve/reject this order in your dashboard.\n\nOrder Details:\n%5\$s", 'crewcommerce'), $approver->display_name, $order->get_order_number(), $requester->display_name, $this->plain_text_price($order->get_formatted_order_total()), $this->get_order_items_text($order) ); wp_mail($approver->user_email, $subject, $message); } /** * Tell the store owner (admin_email) about an order nobody at the * company can approve. */ public function send_unassigned_notification_email($order_id, $workflow_id) { // The step this email is about; an order can have more than one. $workflow = $workflow_id ? $this->get_workflow_by_id($workflow_id) : null; if (!$workflow) { $workflow = $this->get_order_workflow($order_id); } if (!$workflow) { return; } $order = wc_get_order($order_id); $requester = get_user_by('ID', $workflow->requester_id); if (!$order || !$requester) { return; } /* translators: %s: order number */ $subject = sprintf(__('[Action needed] Order #%s needs an approver assigned', 'crewcommerce'), $order->get_order_number()); $message = sprintf( /* translators: 1: order number, 2: requester name, 3: order total, 4: list of items */ __("Hello,\n\nOrder #%1\$s requires approval, but the requester's company currently has no Manager or Admin, so CrewCommerce couldn't assign it to anyone automatically.\n\nRequested by: %2\$s\nTotal: %3\$s\n\nThe order is on hold. Assign it to yourself or another administrator from CrewCommerce > Approvals in wp-admin, or add a Manager/Admin to the company so its own team can handle it.\n\nOrder Details:\n%4\$s", 'crewcommerce'), $order->get_order_number(), $requester->display_name, $this->plain_text_price($order->get_formatted_order_total()), $this->get_order_items_text($order) ); wp_mail(get_option('admin_email'), $subject, $message); } /** * Send approval notification email */ public function send_approval_notification_email($order_id, $workflow_id) { // The step this email is about; an order can have more than one. $workflow = $workflow_id ? $this->get_workflow_by_id($workflow_id) : null; if (!$workflow) { $workflow = $this->get_order_workflow($order_id); } if (!$workflow) { return; } $order = wc_get_order($order_id); $requester = get_user_by('ID', $workflow->requester_id); $approver = get_user_by('ID', $workflow->approver_id); if (!$order || !$requester || !$approver) { return; } /* translators: %s: order number */ $subject = sprintf(__('Order Approved - Order #%s', 'crewcommerce'), $order->get_order_number()); $message = sprintf( /* translators: 1: requester name, 2: approver name, 3: order number, 4: order total, 5: order status, 6: approver notes */ __("Hello %1\$s,\n\nYour order has been approved by %2\$s:\n\nOrder #%3\$s\nTotal: %4\$s\nStatus: %5\$s\n\n%6\$s", 'crewcommerce'), $requester->display_name, $approver->display_name, $order->get_order_number(), $this->plain_text_price($order->get_formatted_order_total()), wc_get_order_status_name($order->get_status()), /* translators: %s: approver notes */ $workflow->notes ? sprintf(__('Notes: %s', 'crewcommerce'), $workflow->notes) : '' ); // Approved but unpaid: they still have to pay. if ($order->needs_payment()) { $message .= "\n\n" . sprintf( /* translators: %s: payment link */ __("This order still needs to be paid before it is processed. Pay for it here:\n%s", 'crewcommerce'), $order->get_checkout_payment_url() ); } wp_mail($requester->user_email, $subject, $message); } /** * Send rejection notification email */ public function send_rejection_notification_email($order_id, $workflow_id, $notes) { // The step this email is about; an order can have more than one. $workflow = $workflow_id ? $this->get_workflow_by_id($workflow_id) : null; if (!$workflow) { $workflow = $this->get_order_workflow($order_id); } if (!$workflow) { return; } $order = wc_get_order($order_id); $requester = get_user_by('ID', $workflow->requester_id); $approver = get_user_by('ID', $workflow->approver_id); if (!$order || !$requester || !$approver) { return; } /* translators: %s: order number */ $subject = sprintf(__('Order Rejected - Order #%s', 'crewcommerce'), $order->get_order_number()); $message = sprintf( /* translators: 1: requester name, 2: approver name, 3: order number, 4: order total, 5: rejection reason */ __("Hello %1\$s,\n\nYour order has been rejected by %2\$s:\n\nOrder #%3\$s\nTotal: %4\$s\nReason: %5\$s\n\nPlease contact your approver for more information.", 'crewcommerce'), $requester->display_name, $approver->display_name, $order->get_order_number(), $this->plain_text_price($order->get_formatted_order_total()), $notes ?: __('No reason provided', 'crewcommerce') ); wp_mail($requester->user_email, $subject, $message); } /** * Get order items as text */ private function get_order_items_text($order) { $items_text = ''; foreach ($order->get_items() as $item) { $items_text .= sprintf( "- %s x %d = %s\n", $item->get_name(), $item->get_quantity(), $this->plain_text_price(wc_price($item->get_total())) ); } return $items_text; } /** * Plain-text version of a wc_price() string, for our plain-text emails. * Takes the formatted HTML so the order's own currency is kept. */ private function plain_text_price($price_html) { return html_entity_decode(wp_strip_all_tags($price_html), ENT_QUOTES); } /** * Admin AJAX: approve. */ public function ajax_admin_approve_order() { check_ajax_referer('crewcommerce_admin_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(__('Insufficient permissions.', 'crewcommerce')); } $workflow_id = isset($_POST['workflow_id']) ? absint(wp_unslash($_POST['workflow_id'])) : 0; $notes = isset($_POST['notes']) ? sanitize_textarea_field(wp_unslash($_POST['notes'])) : ''; $user_id = get_current_user_id(); if ($this->approve_order($workflow_id, $user_id, $notes)) { wp_send_json_success(__('Order approved successfully', 'crewcommerce')); } else { wp_send_json_error($this->get_last_error() ?: __('Failed to approve order. It may have already been actioned.', 'crewcommerce')); } } /** * Admin AJAX: reject. */ public function ajax_admin_reject_order() { check_ajax_referer('crewcommerce_admin_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(__('Insufficient permissions.', 'crewcommerce')); } $workflow_id = isset($_POST['workflow_id']) ? absint(wp_unslash($_POST['workflow_id'])) : 0; $notes = isset($_POST['notes']) ? sanitize_textarea_field(wp_unslash($_POST['notes'])) : ''; $user_id = get_current_user_id(); if ('' === $notes) { wp_send_json_error(__('A rejection reason is required.', 'crewcommerce')); } if ($this->reject_order($workflow_id, $user_id, $notes)) { wp_send_json_success(__('Order rejected successfully', 'crewcommerce')); } else { wp_send_json_error($this->get_last_error() ?: __('Failed to reject order. It may have already been actioned.', 'crewcommerce')); } } /** * Admin AJAX: claim an unassigned approval. */ public function ajax_admin_claim_approval() { check_ajax_referer('crewcommerce_admin_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(__('Insufficient permissions.', 'crewcommerce')); } $workflow_id = isset($_POST['workflow_id']) ? absint(wp_unslash($_POST['workflow_id'])) : 0; $result = $this->claim_approval($workflow_id, get_current_user_id()); if (is_wp_error($result)) { wp_send_json_error($result->get_error_message()); } wp_send_json_success(__('Order claimed. You can now approve or reject it.', 'crewcommerce')); } }