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'));
}
}