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();