Okay
  Public Ticket #4539250
Partial refund
Open

Comments

  •  2
    Bruno started the conversation

    Hi!

    Due to a customer's ordering error, I had to refund two tickets from an order of four, which means two seats need to be returned to stock. The order is in a completed status, but part of it has indeed been refunded. When I go to the manager, the refunded seats are still linked to the order (despite being refunded).

    How can I relist the seats for sale?

    As always, thank you!

  •  982
    Zhivko replied

    Hi,

    Honestly I didn't think of that. I will fix this in the next update.

    Meanwhile you can manually patch it by opening file:

    includes/woocommerce/product/filters/class.auditorium-product-order-status.php

    and replace the file content with this one:

    <?php
    
    namespace Stachethemes\SeatPlanner;
    
    if (!defined('ABSPATH')) {
        exit;
    }
    
    class Auditorium_Product_Order_Status {
    
        private static $did_init = false;
    
        public static function init() {
            if (self::$did_init) { // Prevent double initialization
                return;
            }
    
            self::$did_init = true;
    
            add_action('woocommerce_order_status_changed', [__CLASS__, 'order_status_changed'], 10, 4);
            add_action('woocommerce_delete_order_items', [__CLASS__, 'before_delete_order_items'], 10);
            add_action('woocommerce_before_trash_order', [__CLASS__, 'before_trash_order'], 10, 2);
            add_action('woocommerce_refund_created', [__CLASS__, 'handle_partial_refund'], 10, 2);
    
        }
    
        private static function get_status_groups() {
            return [
                'reserve' => ['pending'],
                'confirm' => ['completed', 'processing', 'on-hold'],
                'cancel'  => ['cancelled', 'failed', 'refunded']
            ];
        }
    
        /**
         * Check if a specific order item has been refunded
         * 
         * @param \WC_Order $order The order object
         * @param int $item_id The order item ID
         * @return bool True if the item has been refunded
         */
        private static function is_item_refunded($order, $item_id) {
            $qty_refunded = $order->get_qty_refunded_for_item($item_id);
            // qty_refunded returns negative value, so we check if it's less than 0
            return $qty_refunded < 0;
        }
    
        private static function release_order_items($order) {
    
            $order = is_numeric($order) ? wc_get_order($order) : $order;
    
            $items = $order->get_items();
    
            foreach ($items as $item) {
    
                $seat_data = Utils::normalize_seat_data_meta($item->get_meta('seat_data'));
    
                if (empty($seat_data)) {
                    continue;
                }
    
                if (!method_exists($item, 'get_product_id')) {
                    continue;
                }
    
                /** @var \WC_Order_Item_Product $item */
                $selected_date = $item->get_meta('selected_date');
                $product_id    = $item->get_product_id();
                
                /** @var Auditorium_Product $product */
                $product    = wc_get_product($product_id);
                $seat_id    = $seat_data['seatId'] ?? '';
    
                if (!$product || !$product->is_type('auditorium') || !$seat_id) {
                    continue;
                }
    
                $product->delete_meta_taken_seat($seat_id, $selected_date);
                $product->save_meta_data();
            }
        }
    
        public static function order_status_changed($id, $status_transition_from, $status_transition_to, $that) {
            if (!$that->get_meta('has_auditorium_product')) {
                return;
            }
    
            if ($status_transition_from === $status_transition_to) {
                return;
            }
    
            $items = $that->get_items();
    
            foreach ($items as $item) {
                
                $seat_data = Utils::normalize_seat_data_meta($item->get_meta('seat_data'));
    
                if (empty($seat_data)) {
                    continue;
                }
    
                $product_id    = $item->get_product_id();
                /** @var Auditorium_Product $product */
                $product       = wc_get_product($product_id);
                $seat_id       = $seat_data['seatId'];
                $selected_date = $seat_data['selectedDate'] ?? '';
    
                if (!$product || !$product->is_type('auditorium') || !$seat_id) {
                    continue;
                }
    
                $status_groups = self::get_status_groups();
    
                $target_group = null;
                foreach ($status_groups as $group => $statuses) {
                    if (in_array($status_transition_to, $statuses)) {
                        $target_group = $group;
                        break;
                    }
                }
    
                switch ($target_group) {
                    case 'reserve':
                        // Skip items that have been refunded - don't create reservation for them
                        if (self::is_item_refunded($that, $item->get_id())) {
                            continue 2;
                        }
    
                        $product->delete_meta_taken_seat($seat_id, $selected_date);
                        $product->save_meta_data();
    
                        Slot_Reservation::release_transient($product_id, $seat_id, $selected_date);
                        Slot_Reservation::insert_transient($product_id, $seat_id, [
                            'session_id'    => 'system',
                            'reserve_time'  => 1440 * 7, // lock for 7 days
                            'selected_date' => $selected_date
                        ]);
    
                        break;
    
                    case 'confirm':
    
                        // Skip items that have been refunded - don't re-add them as taken
                        if (self::is_item_refunded($that, $item->get_id())) {
                            continue 2;
                        }
    
                        $product->add_meta_taken_seat($seat_id, $selected_date);
                        $product->save_meta_data();
                        Slot_Reservation::release_transient($product_id, $seat_id, $selected_date);
    
                        // Automatically complete paid orders if the option is enabled
                        if (Settings::get_setting('stachesepl_auto_confirm_paid_orders') === 'yes') {
                            if ($that->is_paid() && $that->get_status() !== 'completed') {
                                $that->update_status('completed');
                            }
                        }
    
                        break;
    
                    case 'cancel':
                        $booking_data                = new Bookings_Data($product_id);
                        $orders_with_seat_id         = $booking_data->get_orders_with_seat($seat_id, $selected_date);
                        $other_orders_with_this_seat = array_diff($orders_with_seat_id, [$that->get_id()]);
    
                        if (!empty($other_orders_with_this_seat)) {
                            // Prevent release of a seat if it is already booked by another order
                            // Otherwise we risk to release a seat that was already confirmed by another order
                            continue 2;
                        }
    
                        $product->delete_meta_taken_seat($seat_id, $selected_date);
                        $product->save_meta_data();
                        Slot_Reservation::release_transient($product_id, $seat_id, $selected_date);
    
                        break;
                }
            }
        }
    
        public static function before_delete_order_items($order_id) {
            $order = wc_get_order($order_id);
    
            if (!$order || !($order instanceof \WC_Order) || !$order->get_meta('has_auditorium_product')) {
                return;
            }
    
            $status_groups = self::get_status_groups();
    
            // Do nothing if the status is not in the 'confirm' group
            // Otherwise we risk to release a seat that was already confirmed by another order
            if (!in_array($order->get_status(), $status_groups['confirm'])) {
                return;
            }
    
            self::release_order_items($order);
        }
    
        /**
         * Handle partial refunds - release only the specific seats that were refunded
         * 
         * @param int $refund_id The refund order ID
         * @param array $args Refund arguments
         */
        public static function handle_partial_refund($refund_id, $args) {
            $refund = wc_get_order($refund_id);
    
            if (!$refund || !($refund instanceof \WC_Order_Refund)) {
                return;
            }
    
            $parent_order = wc_get_order($refund->get_parent_id());
    
            if (!$parent_order || !($parent_order instanceof \WC_Order)) {
                return;
            }
    
            if (!$parent_order->get_meta('has_auditorium_product')) {
                return;
            }
    
            $status_groups = self::get_status_groups();
    
            // Only process if the parent order is in a confirmed state
            // If the order is already cancelled/refunded, the order_status_changed handler will take care of it
            if (!in_array($parent_order->get_status(), $status_groups['confirm'])) {
                return;
            }
    
            // Get all refunded items
            $refund_items = $refund->get_items();
    
            foreach ($refund_items as $refund_item) {
                // Get the original order item ID that was refunded
                $refunded_item_id = $refund_item->get_meta('_refunded_item_id');
    
                if (!$refunded_item_id) {
                    continue;
                }
    
                // Get the original order item
                $original_item = $parent_order->get_item($refunded_item_id);
    
                if (!$original_item) {
                    continue;
                }
    
                if (!method_exists($original_item, 'get_product_id')) {
                    continue;
                }
    
                /** @var \WC_Order_Item_Product $original_item */
                $seat_data = Utils::normalize_seat_data_meta($original_item->get_meta('seat_data'));
    
                if (empty($seat_data)) {
                    continue;
                }
    
                $product_id    = $original_item->get_product_id();
                /** @var Auditorium_Product $product */
                $product       = wc_get_product($product_id);
                $seat_id       = $seat_data['seatId'] ?? '';
                $selected_date = $seat_data['selectedDate'] ?? '';
    
                if (!$product || !$product->is_type('auditorium') || !$seat_id) {
                    continue;
                }
    
                // Check if this seat is booked by another order before releasing
                $booking_data                = new Bookings_Data($product_id);
                $orders_with_seat_id         = $booking_data->get_orders_with_seat($seat_id, $selected_date);
                $other_orders_with_this_seat = array_diff($orders_with_seat_id, [$parent_order->get_id()]);
    
                if (!empty($other_orders_with_this_seat)) {
                    // Seat is also in another confirmed order, don't release
                    continue;
                }
    
                // Release the seat
                $product->delete_meta_taken_seat($seat_id, $selected_date);
                $product->save_meta_data();
                Slot_Reservation::release_transient($product_id, $seat_id, $selected_date);
            }
        }
    
        // Notes
        // Trashing confirmed order will make the seats available again
        public static function before_trash_order($order_id, $prev_state) {
    
            $order = wc_get_order($order_id);
    
            if (!$order || !($order instanceof \WC_Order) || !$order->get_meta('has_auditorium_product')) {
                return;
            }
    
            $status_groups = self::get_status_groups();
    
            // Do nothing if the status is not in the 'confirm' group
            // Otherwise we risk to release a seat that was already confirmed by another order
            if (!in_array($prev_state->get_status(), $status_groups['confirm'])) {
                return;
            }
    
            self::release_order_items($order);
        }
    }
    

    Then edit the order, set it to "Cancelled" and back to "Completed". It should now take into an account the partially refunded seats and release them.


    Stachethemes Developer

  •   Bruno replied privately
  •  982
    Zhivko replied

    No, there is one new method your version is missing in the Utils class.

    Open file:

    includes/class.utils.php

    and replace the content with:

    <?php
    
    namespace Stachethemes\SeatPlanner;
    
    if (! defined('ABSPATH')) {
        exit;
    }
    
    class Utils {
    
        /**
         * Normalize seat_data meta to an array. Use when reading seat_data from order item meta or cart.
         * Ensures consistent array access regardless of whether data was stored as object or array.
         *
         * @param mixed $seat_data Raw seat_data meta (object, array, or null).
         * @return array<string, mixed> Associative array; empty array if null/invalid. customFields is normalized to array.
         */
        public static function normalize_seat_data_meta(mixed $seat_data): array {
            if ($seat_data === null || $seat_data === '') {
                return [];
            }
            if (is_array($seat_data)) {
                $out = $seat_data;
            } elseif (is_object($seat_data)) {
                $out = (array) $seat_data;
            } else {
                return [];
            }
            if (isset($out['customFields'])) {
                $cf = $out['customFields'];
                $out['customFields'] = is_array($cf) ? $cf : (is_object($cf) ? (array) $cf : []);
            }
            return $out;
        }
    
        public static function get_formatted_date_time(string|int $date_time): string {
            $dt_format = get_option('date_format') . ' ' . get_option('time_format');
            return date_i18n($dt_format, strtotime((string) $date_time));
        }
    
    
        public static function darken(string $hex, int|float $percent): string {
            $hex = str_replace('#', '', $hex);
            if (strlen($hex) === 3) {
                $hex = str_split($hex);
                $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
            }
            $amt = round(2.55 * $percent);
            $r = max(0, hexdec(substr($hex, 0, 2)) - $amt);
            $g = max(0, hexdec(substr($hex, 2, 2)) - $amt);
            $b = max(0, hexdec(substr($hex, 4, 2)) - $amt);
            return "rgb({$r}, {$g}, {$b})";
        }
    
        public static function hexToRgba(string $hex, int|float $alpha): string {
            $hex = str_replace('#', '', $hex);
            if (strlen($hex) === 3) {
                $hex = str_split($hex);
                $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
            }
            $r = hexdec(substr($hex, 0, 2));
            $g = hexdec(substr($hex, 2, 2));
            $b = hexdec(substr($hex, 4, 2));
            return "rgba({$r}, {$g}, {$b}, {$alpha})";
        }
    
    }
    

    Let me know if this resolves the error.


    Stachethemes Developer

  •  2
    Bruno replied

    The files are updated. I'm on the order with the "Cancelled" status; I changed it back to "Completed," and now there are no more fatal errors. However, the refunded places are still attached to the order.

  •  982
    Zhivko replied

    Were they released on the front-end?


    Stachethemes Developer

  •  2
  •  982
    Zhivko replied

    Could you try to cancel it again and back to completed. It's possible the crash didn't release the seat properly. 


    Stachethemes Developer

  •  2
    Bruno replied

    Of course!

    And you are right! Everything is working fine. The places are released; I can see it in the manager and on the frontend. Do I have to do something to keep that with your next update ?

    Ps : and nothing in woo log !

  •  982
    Zhivko replied

    No, this code will be included in the next update. You may get false positive warning of double booking in the tools now because it doesn't check at the moment whether the seat is refunded.


    Stachethemes Developer

  •  2
    Bruno replied

    Okay for the update. 

    Yes, with the ghost tools, the places appear, but nothing shows in the double booking tool. I imagine that when someone takes one, the double booking alert will notify me. Do I need to do anything to restore a "normal" state?

  •  982
    Zhivko replied

    Yes, when someone book that refunded seats the tool will trigger false positive. This warning will be gone in the next update. You don't have to do anything.


    Stachethemes Developer

  •  2
    Bruno replied

    Perfect. I will eagerly await this update.

    Your quick help and solutions are greatly appreciated. If only all developers could be like you!

    Best,  
    Bruno.