false, 'auto_cancel_enabled' => false, 'window_hours' => 2, 'window_minutes' => 0, 'cancel_statuses' => array('pending', 'on-hold', 'processing'), 'reason_optional' => true, 'guest_form_page_id' => 0, 'email_intro' => '', 'survey_enabled' => false, 'survey_url' => '', 'survey_delay_days' => 3, 'request_status_enabled' => false, 'return_charge_party' => '', 'return_carrier' => '', ); $stored = array(); if (class_exists('AIEO_DMM_Settings')) { $all = AIEO_DMM_Settings::get_all(); if (isset($all['withdrawal']) && is_array($all['withdrawal'])) { $stored = $all['withdrawal']; } } $out = array_merge($defaults, $stored); $out['window_hours'] = max(0, (int) $out['window_hours']); $out['window_minutes'] = max(0, min(59, (int) $out['window_minutes'])); if (!is_array($out['cancel_statuses'])) { $out['cancel_statuses'] = $defaults['cancel_statuses']; } return $out; } public static function window_seconds() { $s = self::settings(); if (empty($s['auto_cancel_enabled'])) { return 0; } return ((int) $s['window_hours'] * HOUR_IN_SECONDS) + ((int) $s['window_minutes'] * MINUTE_IN_SECONDS); } public static function auto_cancel_allowed() { $s = self::settings(); return !empty($s['auto_cancel_enabled']) && self::window_seconds() > 0; } public static function is_enabled() { $s = self::settings(); return !empty($s['enabled']); } public static function register() { if (!empty($GLOBALS['aieo_withdrawal_engine_booted'])) { return; } $GLOBALS['aieo_withdrawal_engine_booted'] = true; add_action('init', array(__CLASS__, 'register_request_status')); add_filter('wc_order_statuses', array(__CLASS__, 'filter_order_statuses')); add_filter('aieo_withdrawal_delivery_timestamp', array(__CLASS__, 'delivered_from_vouchers'), 20, 2); } public static function delivered_from_vouchers($timestamp, $order) { if (is_numeric($timestamp) && (int) $timestamp > 0) { return $timestamp; } if (!is_object($order) || !method_exists($order, 'get_id')) { return $timestamp; } global $wpdb; $t = $wpdb->prefix . 'aieo_dmm_fulfillment_vouchers'; if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($t))) !== $t) { return $timestamp; } $row = $wpdb->get_row($wpdb->prepare( "SELECT COUNT(*) AS parcels, SUM(delivered_at IS NULL) AS on_the_way, MAX(delivered_at) AS last_delivered FROM {$t} WHERE order_id = %d AND canceled_at IS NULL", (int) $order->get_id() )); if (!$row || (int) $row->parcels < 1 || (int) $row->on_the_way > 0 || empty($row->last_delivered)) { return $timestamp; } $when = strtotime($row->last_delivered . ' UTC'); return false !== $when && $when > 0 ? $when : $timestamp; } public static function request_status_enabled() { $s = self::settings(); return !empty($s['request_status_enabled']); } public static function register_request_status() { if (!self::request_status_enabled()) { return; } register_post_status('wc-' . self::REQUEST_STATUS, array( 'label' => __('Withdrawal requested', 'ai-eshop-optimizer'), 'public' => false, 'exclude_from_search' => false, 'show_in_admin_all_list' => true, 'show_in_admin_status_list' => true, 'label_count' => _n_noop( 'Withdrawal requested (%s)', 'Withdrawals requested (%s)', 'ai-eshop-optimizer' ), )); } public static function filter_order_statuses($statuses) { if (!is_array($statuses) || !self::request_status_enabled()) { return $statuses; } $key = 'wc-' . self::REQUEST_STATUS; $label = __('Withdrawal requested', 'ai-eshop-optimizer'); $out = array(); $inserted = false; foreach ($statuses as $k => $v) { $out[$k] = $v; if ('wc-on-hold' === $k) { $out[$key] = $label; $inserted = true; } } if (!$inserted) { $out[$key] = $label; } return $out; } public static function evaluate($order) { if (!$order instanceof WC_Order) { return self::none('invalid_order', __('Order not found.', 'ai-eshop-optimizer')); } $status = $order->get_status(); if (in_array($status, array('cancelled', 'refunded'), true)) { return self::none('already_cancelled', __('This order has already been cancelled.', 'ai-eshop-optimizer')); } if ($status === 'failed') { return self::none('failed_order', __('This order was never completed, so there is nothing to withdraw from.', 'ai-eshop-optimizer')); } foreach (self::declarations_for_order((int) $order->get_id()) as $prior) { if (empty($prior->is_full)) { continue; } $prior_status = (string) $prior->status; if (in_array($prior_status, array(self::STATUS_AWAITING_APPROVAL, self::STATUS_PENDING, self::STATUS_RECEIVED), true)) { return self::none('declaration_open', __('You have already asked to withdraw from this order. Its progress is shown on the order page.', 'ai-eshop-optimizer')); } if (self::STATUS_REJECTED === $prior_status) { return self::none('declaration_refused', __('Your withdrawal from this order was not accepted. The reasons are shown on the order page.', 'ai-eshop-optimizer')); } } $items = self::withdrawable_items($order); if (empty($items)) { return self::none( 'all_exempt', __('The items in this order are exempt from the right of withdrawal.', 'ai-eshop-optimizer') ); } $settings = self::settings(); $now = time(); $created = $order->get_date_created(); $window_expires = null; if ($created && self::auto_cancel_allowed()) { $window_expires = $created->getTimestamp() + self::window_seconds(); } $unshipped = in_array($status, (array) $settings['cancel_statuses'], true); if ($unshipped && $window_expires !== null && $now <= $window_expires) { return array( 'tier' => self::TIER_AUTO_CANCEL, 'code' => 'auto_cancel', 'message' => __('Your order has not been dispatched yet and can be cancelled immediately.', 'ai-eshop-optimizer'), 'deadline_gmt' => null, 'receipt_basis' => '', 'items' => $items, 'window_expires_gmt' => gmdate('Y-m-d H:i:s', $window_expires), ); } $receipt = self::receipt_moment($order); $deadline = self::statutory_deadline($receipt['timestamp']); if ($now <= $deadline) { return array( 'tier' => self::TIER_DECLARATION, 'code' => 'declaration', 'message' => __('You are within the 14-day withdrawal period.', 'ai-eshop-optimizer'), 'deadline_gmt' => gmdate('Y-m-d H:i:s', $deadline), 'receipt_basis' => $receipt['basis'], 'items' => $items, 'window_expires_gmt' => $window_expires ? gmdate('Y-m-d H:i:s', $window_expires) : null, ); } $out = self::none( 'period_expired', __('The 14-day withdrawal period for this order has passed.', 'ai-eshop-optimizer') ); $out['deadline_gmt'] = gmdate('Y-m-d H:i:s', $deadline); $out['receipt_basis'] = $receipt['basis']; $out['items'] = $items; return $out; } public static function receipt_moment($order) { $delivered = apply_filters('aieo_withdrawal_delivery_timestamp', null, $order); if (is_numeric($delivered) && (int) $delivered > 0) { return array('timestamp' => (int) $delivered, 'basis' => 'delivered'); } $completed = $order->get_date_completed(); if ($completed) { return array('timestamp' => $completed->getTimestamp(), 'basis' => 'completed'); } $paid = $order->get_date_paid(); if ($paid) { return array('timestamp' => $paid->getTimestamp(), 'basis' => 'paid'); } $created = $order->get_date_created(); if ($created) { return array('timestamp' => $created->getTimestamp(), 'basis' => 'created'); } return array('timestamp' => time(), 'basis' => 'unknown'); } public static function statutory_deadline($receipt_ts) { $tz = wp_timezone(); $local = (new DateTimeImmutable('@' . (int) $receipt_ts))->setTimezone($tz); $end = $local->modify('+' . self::STATUTORY_DAYS . ' days')->setTime(23, 59, 59); return $end->getTimestamp(); } public static function withdrawable_items($order) { $out = array(); foreach ($order->get_items() as $item_id => $item) { if (!$item instanceof WC_Order_Item_Product) { continue; } $product_id = (int) ($item->get_variation_id() ?: $item->get_product_id()); if (apply_filters('aieo_withdrawal_item_is_exempt', false, $item, $order)) { continue; } $out[] = array( 'item_id' => (int) $item_id, 'product_id' => $product_id, 'name' => $item->get_name(), 'qty' => (int) $item->get_quantity(), 'total' => (float) $order->get_line_total($item, true), ); } return $out; } public static function execute($order, array $args = array()) { if (!$order instanceof WC_Order) { return new WP_Error('aieo_wd_invalid', __('Order not found.', 'ai-eshop-optimizer')); } $eval = self::evaluate($order); if ($eval['tier'] === self::TIER_NONE) { return new WP_Error('aieo_wd_ineligible', $eval['message'], array('evaluation' => $eval)); } $args = array_merge(array( 'item_ids' => array(), 'channel' => 'account', 'note' => '', 'reason' => '', 'actor_id' => 0, 'defer_ack' => false, ), $args); $allowed = array(); foreach ($eval['items'] as $row) { $allowed[$row['item_id']] = $row; } $selected_ids = array_values(array_filter(array_map('intval', (array) $args['item_ids']))); $selected = array(); foreach ($selected_ids as $id) { if (isset($allowed[$id])) { $selected[$id] = $allowed[$id]; } } if (empty($selected)) { $selected = $allowed; } $qty_in = isset($args['item_qty']) && is_array($args['item_qty']) ? $args['item_qty'] : array(); $product_ids = array(); $declared_total = 0.0; $qty_map = array(); $any_part_line = false; foreach ($selected as $id => $row) { $ordered = max(1, (int) $row['qty']); $want = isset($qty_in[$id]) ? (int) $qty_in[$id] : $ordered; $want = max(1, min($ordered, $want)); $selected[$id]['withdraw_qty'] = $want; $qty_map[$id] = $want; if ($want < $ordered) { $any_part_line = true; } $product_ids[] = (int) $row['product_id']; $declared_total += ((float) $row['total'] / $ordered) * $want; } $product_ids = array_values(array_unique(array_filter($product_ids))); $is_full = (count($selected) === count($allowed)) && !$any_part_line; $declaration_id = self::record_declaration($order, $eval, array( 'item_ids' => $qty_map, 'product_ids' => $product_ids, 'is_full' => $is_full, 'declared_total' => $declared_total, 'channel' => (string) $args['channel'], 'note' => (string) $args['note'], 'reason' => (string) $args['reason'], )); if (!$declaration_id) { return new WP_Error('aieo_wd_store_failed', __('We could not record your withdrawal. Please try again.', 'ai-eshop-optimizer')); } $cancelled = false; if ($eval['tier'] === self::TIER_AUTO_CANCEL && $is_full) { self::stamp_cancellation_meta($order, $product_ids, $is_full, $args); $order->update_status( 'cancelled', __('Cancelled by the customer via the withdrawal button.', 'ai-eshop-optimizer'), true ); $cancelled = true; } else { self::stamp_cancellation_meta($order, $product_ids, $is_full, $args, false); $note = sprintf( __('Withdrawal declaration received from the customer (%1$s, %2$d item(s)). Awaiting processing.', 'ai-eshop-optimizer'), $is_full ? __('full order', 'ai-eshop-optimizer') : __('partial', 'ai-eshop-optimizer'), count($selected) ); if (self::request_status_enabled() && function_exists('wc_get_order_statuses') && array_key_exists('wc-' . self::REQUEST_STATUS, wc_get_order_statuses())) { $order->update_status(self::REQUEST_STATUS, $note, true); } else { $order->add_order_note($note, false); } } $order->update_meta_data(self::META_HAS_DECLARATION, '1'); $order->save(); do_action('aieo_withdrawal_recorded', $declaration_id, $order, array( 'tier' => $eval['tier'], 'is_full' => $is_full, 'items' => array_values($selected), 'cancelled' => $cancelled, 'channel' => $args['channel'], 'defer_ack' => !empty($args['defer_ack']), )); self::maybe_complete($declaration_id, $order); return array( 'tier' => $eval['tier'], 'declaration_id' => $declaration_id, 'is_full' => $is_full, 'cancelled' => $cancelled, 'items' => array_values($selected), ); } private static function stamp_cancellation_meta($order, array $product_ids, $is_full, array $args, $save = true) { if (!class_exists('AIEO_DMM_Order_Cancellations')) { return; } $reason = (string) $args['reason']; if ($reason === '') { $reason = 'customer_changed_mind'; } $order->update_meta_data(AIEO_DMM_Order_Cancellations::META_REASON_SLUG, sanitize_key($reason)); $note = trim((string) $args['note']); if ($note !== '') { $order->update_meta_data( AIEO_DMM_Order_Cancellations::META_REASON_NOTE, mb_substr(sanitize_textarea_field($note), 0, 500) ); } if (!$is_full && !empty($product_ids)) { $order->update_meta_data( AIEO_DMM_Order_Cancellations::META_RETURNED_PRODUCTS, implode(',', $product_ids) ); } if ($save) { $order->save_meta_data(); } } private static function record_declaration($order, array $eval, array $data) { global $wpdb; $table = $wpdb->prefix . 'aieo_dmm_withdrawal_declarations'; $ok = $wpdb->insert( $table, array( 'order_id' => $order->get_id(), 'user_id' => (int) $order->get_customer_id(), 'billing_email' => mb_substr((string) $order->get_billing_email(), 0, 190), 'tier' => $eval['tier'], 'channel' => sanitize_key($data['channel']), 'is_full' => !empty($data['is_full']) ? 1 : 0, 'product_ids' => wp_json_encode(array_values($data['product_ids'])), 'item_ids' => wp_json_encode((object) $data['item_ids']), 'reason_slug' => $data['reason'] !== '' ? sanitize_key($data['reason']) : null, 'reason_note' => $data['note'] !== '' ? mb_substr(sanitize_textarea_field($data['note']), 0, 500) : null, 'order_total' => (float) $order->get_total(), 'declared_total' => (float) $data['declared_total'], 'status' => self::initial_status($order, $eval), 'receipt_basis' => (string) $eval['receipt_basis'], 'deadline_gmt' => $eval['deadline_gmt'], 'declared_at_gmt' => current_time('mysql', true), 'client_ip_hash' => self::client_ip_hash(), 'received_at_gmt' => ('received' === self::initial_status($order, $eval)) ? current_time('mysql', true) : null, ), array('%d','%d','%s','%s','%s','%d','%s','%s','%s','%s','%f','%f','%s','%s','%s','%s','%s','%s') ); return $ok ? (int) $wpdb->insert_id : 0; } private static function initial_status($order, array $eval) { if ($eval['tier'] !== self::TIER_AUTO_CANCEL) { return self::STATUS_AWAITING_APPROVAL; } $paid = (bool) apply_filters( 'aieo_withdrawal_order_was_paid', (bool) $order->get_date_paid(), $order ); return $paid ? 'received' : 'cancelled'; } public static function maybe_complete($declaration_id, $order = null) { global $wpdb; $declaration_id = (int) $declaration_id; if ($declaration_id <= 0) { return; } $table = $wpdb->prefix . 'aieo_dmm_withdrawal_declarations'; $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $declaration_id)); if (!$row) { return; } $status = (string) $row->status; if (!in_array($status, array(self::STATUS_REFUNDED, self::STATUS_CANCELLED), true)) { return; } if (null === $order && function_exists('wc_get_order')) { $order = wc_get_order((int) $row->order_id); } do_action('aieo_withdrawal_completed', $declaration_id, $order, array( 'outcome' => $status, 'refunded' => self::STATUS_REFUNDED === $status, 'refund_amount' => isset($row->refund_amount) ? (float) $row->refund_amount : 0.0, )); } private static function client_ip_hash() { $ip = ''; if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { $ip = wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']); } elseif (!empty($_SERVER['REMOTE_ADDR'])) { $ip = wp_unslash($_SERVER['REMOTE_ADDR']); } $ip = trim((string) $ip); if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) { return null; } return md5(wp_salt('auth') . $ip); } public static function declarations_for_order($order_id) { global $wpdb; $table = $wpdb->prefix . 'aieo_dmm_withdrawal_declarations'; return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d ORDER BY declared_at_gmt DESC, id DESC", (int) $order_id ) ); } public static function mark_acknowledged($declaration_id) { global $wpdb; $wpdb->update( $wpdb->prefix . 'aieo_dmm_withdrawal_declarations', array('acknowledged_at_gmt' => current_time('mysql', true)), array('id' => (int) $declaration_id), array('%s'), array('%d') ); } private static function none($code, $message) { return array( 'tier' => self::TIER_NONE, 'code' => $code, 'message' => $message, 'deadline_gmt' => null, 'receipt_basis' => '', 'items' => array(), 'window_expires_gmt' => null, ); } public static function refund_methods() { return array( 'original' => array( 'label' => __('Back to the original payment method', 'ai-eshop-optimizer'), 'customer' => __('back to the payment method you used', 'ai-eshop-optimizer'), ), 'bank' => array( 'label' => __('Bank transfer', 'ai-eshop-optimizer'), 'customer' => __('by bank transfer', 'ai-eshop-optimizer'), ), 'credit' => array( 'label' => __('Store credit / voucher', 'ai-eshop-optimizer'), 'customer' => __('as store credit', 'ai-eshop-optimizer'), ), 'cash' => array( 'label' => __('Cash, in store', 'ai-eshop-optimizer'), 'customer' => __('in cash at the shop', 'ai-eshop-optimizer'), ), ); } public static function refund_method_phrase($key) { $all = self::refund_methods(); $key = (string) $key; return isset($all[$key]) ? $all[$key]['customer'] : ''; } public static function customer_status($row) { $status = is_object($row) && isset($row->status) ? (string) $row->status : ''; $total = 4; $free = false; if (class_exists('AIEO_Withdrawal_Returns')) { $policy = AIEO_Withdrawal_Returns::policy(); $free = isset($policy['charge_party']) && 'recipient' === $policy['charge_party']; } switch ($status) { case self::STATUS_AWAITING_APPROVAL: $detail = $free ? __('Please wait while we approve it and email you the courier voucher for the return. The return will cost you nothing.', 'ai-eshop-optimizer') : __('Please wait while we approve it and email you the courier voucher for the return.', 'ai-eshop-optimizer'); return array( 'step' => 1, 'total' => $total, 'title' => __('We have your withdrawal request', 'ai-eshop-optimizer'), 'detail' => $detail, ); case self::STATUS_PENDING: return array( 'step' => 2, 'total' => $total, 'title' => __('Approved — send the items back with the voucher we emailed you', 'ai-eshop-optimizer'), 'detail' => __('Print the voucher, attach it to the parcel and hand it to the courier. Next: we will tell you as soon as the items reach us.', 'ai-eshop-optimizer'), ); case self::STATUS_RECEIVED: return array( 'step' => 3, 'total' => $total, 'title' => __('We have received the items', 'ai-eshop-optimizer'), 'detail' => __('Next: we are issuing your refund, and you will get a confirmation as soon as it is done.', 'ai-eshop-optimizer'), ); case self::STATUS_REFUNDED: $how = self::refund_method_phrase(isset($row->refund_method) ? $row->refund_method : ''); $detail = $how !== '' ? sprintf( __('Your refund has been issued %s. Depending on your bank it can take a few working days to appear. Nothing further is needed from you.', 'ai-eshop-optimizer'), $how ) : __('Your refund has been issued. Depending on your bank it can take a few working days to appear. Nothing further is needed from you.', 'ai-eshop-optimizer'); return array( 'step' => 4, 'total' => $total, 'title' => __('Refunded', 'ai-eshop-optimizer'), 'detail' => $detail, ); case self::STATUS_REJECTED: $grounds = array(); if (class_exists('AIEO_Withdrawal_Rejection_Reasons')) { $ids = json_decode((string) ($row->rejection_reasons ?? ''), true); $grounds = AIEO_Withdrawal_Rejection_Reasons::labels_for_customer(is_array($ids) ? $ids : array()); } $detail = $grounds ? sprintf( __('Your withdrawal request was not accepted, because: %s. If you believe this is wrong, reply to this message and we will look at it again.', 'ai-eshop-optimizer'), implode('; ', $grounds) ) : __('Your withdrawal request was not accepted. If you believe this is wrong, reply to this message and we will look at it again.', 'ai-eshop-optimizer'); return array( 'step' => $total, 'total' => $total, 'title' => __('Not accepted', 'ai-eshop-optimizer'), 'detail' => $detail, ); case self::STATUS_CANCELLED: return array( 'step' => $total, 'total' => $total, 'title' => __('Order cancelled', 'ai-eshop-optimizer'), 'detail' => __('The order was cancelled, so there is nothing to send back. Any amount already paid is refunded to the original payment method. Nothing further is needed from you.', 'ai-eshop-optimizer'), ); } return array( 'step' => 1, 'total' => $total, 'title' => __('We have your withdrawal request', 'ai-eshop-optimizer'), 'detail' => __('It is recorded and being processed. We will email you with the next step.', 'ai-eshop-optimizer'), ); } public static function latest_declaration($order_id) { $rows = self::declarations_for_order($order_id); return !empty($rows) ? $rows[0] : null; } } } AIEO_Withdrawal::register();