| @@ -67,11 +67,14 @@ | ||
| 67 | 67 | add_action( 'admin_init', [ $this, 'create_term_timetics_meeting' ] ); |
| 68 | 68 | |
| 69 | 69 | add_action( 'wp_head', [ $this, 'hide_regular_price_checkout_css' ] ); |
| 70 | 70 | |
| 71 | - // Display meeting details on checkout page | |
| 72 | - add_action( 'woocommerce_review_order_before_payment', [ $this, 'display_meeting_details_on_checkout' ] ); | |
| 71 | + // Show meeting details under the product in the cart and on checkout | |
| 72 | + add_filter( 'woocommerce_get_item_data', [ $this, 'add_meeting_details_to_cart_item' ], 10, 2 ); | |
| 73 | 73 | |
| 74 | + // Store meeting details on the order line item ( thank you page, emails, my account, admin ) | |
| 75 | + add_action( 'woocommerce_checkout_create_order_line_item', [ $this, 'add_meeting_details_to_order_item' ], 10, 4 ); | |
| 76 | + | |
| 74 | 77 | // Sync order status when booking status changes (bidirectional sync) |
| 75 | 78 | add_action( 'transition_post_status', [ $this, 'sync_order_status_from_booking' ], 10, 3 ); |
| 76 | 79 | } |
| 77 | 80 | |
| @@ -117,9 +120,8 @@ | ||
| 117 | 120 | if ( ! $order->is_paid() ) { |
| 118 | 121 | // Mark booking as failed if payment was not completed |
| 119 | 122 | $session_data = WC()->session->get( 'timetics_data' ); |
| 120 | 123 | if ( $session_data ) { |
| 121 | - $booking = new Booking( $session_data['booking_id'] ); | |
| 122 | 124 | Status_Mapper::set_order_id_for_booking( $session_data['booking_id'], $order_id ); |
| 123 | 125 | } |
| 124 | 126 | WC()->session->set( 'timetics_data', null ); |
| 125 | 127 | return; |
| @@ -622,32 +624,127 @@ | ||
| 622 | 624 | ]; |
| 623 | 625 | } |
| 624 | 626 | |
| 625 | 627 | /** |
| 626 | - * Display meeting details on checkout page | |
| 628 | + * Meeting details as label => value pairs, ready to display. | |
| 627 | 629 | * |
| 628 | - * @return void | |
| 630 | + * Shared by the cart / checkout item data and the order line item meta so the | |
| 631 | + * customer sees the same details before and after paying. | |
| 632 | + * | |
| 633 | + * @param array $session_data | |
| 634 | + * | |
| 635 | + * @return array | |
| 629 | 636 | */ |
| 630 | - public function display_meeting_details_on_checkout() { | |
| 631 | - if ( ! is_checkout() ) { | |
| 632 | - return; | |
| 637 | + private function get_meeting_detail_pairs( $session_data ) { | |
| 638 | + $details = $this->prepare_meeting_details( $session_data ); | |
| 639 | + | |
| 640 | + if ( ! $details ) { | |
| 641 | + return []; | |
| 633 | 642 | } |
| 634 | 643 | |
| 635 | - $session_data = WC()->session->get( 'timetics_data' ); | |
| 636 | - $details = $this->prepare_meeting_details( $session_data ); | |
| 644 | + $time = $details['formatted_time']; | |
| 637 | 645 | |
| 638 | - if ( ! $details ) { | |
| 646 | + if ( $time && $details['display_timezone'] ) { | |
| 647 | + $time .= ' (' . $details['display_timezone'] . ')'; | |
| 648 | + } | |
| 649 | + | |
| 650 | + return array_filter( [ | |
| 651 | + __( 'Date', 'timetics' ) => $details['formatted_date'], | |
| 652 | + __( 'Time', 'timetics' ) => $time, | |
| 653 | + __( 'Duration', 'timetics' ) => $details['duration'], | |
| 654 | + __( 'Location', 'timetics' ) => $details['location_label'], | |
| 655 | + __( 'Timezone', 'timetics' ) => $details['timezone'], | |
| 656 | + ] ); | |
| 657 | + } | |
| 658 | + | |
| 659 | + /** | |
| 660 | + * Whether the given product is the meeting product this session booked. | |
| 661 | + * | |
| 662 | + * The cart can hold other products, so the details must only be attached to | |
| 663 | + * the meeting line. | |
| 664 | + * | |
| 665 | + * @param array $session_data | |
| 666 | + * @param integer $product_id | |
| 667 | + * | |
| 668 | + * @return bool | |
| 669 | + */ | |
| 670 | + private function is_meeting_product( $session_data, $product_id ) { | |
| 671 | + if ( empty( $session_data['meeting_id'] ) ) { | |
| 672 | + return false; | |
| 673 | + } | |
| 674 | + | |
| 675 | + $meeting = new Appointment( $session_data['meeting_id'] ); | |
| 676 | + | |
| 677 | + return (int) $meeting->get_wc_product_id() === (int) $product_id; | |
| 678 | + } | |
| 679 | + | |
| 680 | + /** | |
| 681 | + * Show meeting details under the product in the cart and on checkout. | |
| 682 | + * | |
| 683 | + * WooCommerce runs this filter for both the classic templates and the Store API, | |
| 684 | + * so one filter covers the classic and the block based cart / checkout. The block | |
| 685 | + * checkout is the default since WooCommerce 8.3, and the old | |
| 686 | + * woocommerce_review_order_before_payment hook this replaced never ran there. | |
| 687 | + * | |
| 688 | + * @param array $item_data | |
| 689 | + * @param array $cart_item | |
| 690 | + * | |
| 691 | + * @return array | |
| 692 | + */ | |
| 693 | + public function add_meeting_details_to_cart_item( $item_data, $cart_item ) { | |
| 694 | + $session_data = WC()->session ? WC()->session->get( 'timetics_data' ) : null; | |
| 695 | + | |
| 696 | + if ( ! $session_data || ! $this->is_meeting_product( $session_data, $cart_item['product_id'] ) ) { | |
| 697 | + return $item_data; | |
| 698 | + } | |
| 699 | + | |
| 700 | + foreach ( $this->get_meeting_detail_pairs( $session_data ) as $label => $value ) { | |
| 701 | + $item_data[] = [ | |
| 702 | + 'key' => $label, | |
| 703 | + 'value' => $value, | |
| 704 | + ]; | |
| 705 | + } | |
| 706 | + | |
| 707 | + return $item_data; | |
| 708 | + } | |
| 709 | + | |
| 710 | + /** | |
| 711 | + * Store meeting details as order line item meta. | |
| 712 | + * | |
| 713 | + * The booking details only live in the WooCommerce session, which is cleared | |
| 714 | + * once the order is placed. Copying them onto the line item makes WooCommerce | |
| 715 | + * render them everywhere an order is shown: the thank you page, the customer | |
| 716 | + * and admin order emails, My Account -> View order, and the admin order screen. | |
| 717 | + * | |
| 718 | + * @param \WC_Order_Item_Product $item | |
| 719 | + * @param string $cart_item_key | |
| 720 | + * @param array $values | |
| 721 | + * @param \WC_Order $order | |
| 722 | + * | |
| 723 | + * @return void | |
| 724 | + */ | |
| 725 | + public function add_meeting_details_to_order_item( $item, $cart_item_key, $values, $order ) { | |
| 726 | + $session_data = WC()->session ? WC()->session->get( 'timetics_data' ) : null; | |
| 727 | + | |
| 728 | + if ( ! $session_data || ! $this->is_meeting_product( $session_data, $item->get_product_id() ) ) { | |
| 639 | 729 | return; |
| 640 | 730 | } |
| 641 | 731 | |
| 642 | - $timetics_formatted_date = $details['formatted_date']; | |
| 643 | - $timetics_formatted_time = $details['formatted_time']; | |
| 644 | - $timetics_duration = $details['duration']; | |
| 645 | - $timetics_location_label = $details['location_label']; | |
| 646 | - $timetics_timezone = $details['timezone']; | |
| 647 | - $timetics_display_timezone = $details['display_timezone']; | |
| 732 | + foreach ( $this->get_meeting_detail_pairs( $session_data ) as $label => $value ) { | |
| 733 | + $item->add_meta_data( $label, $value, true ); | |
| 734 | + } | |
| 648 | 735 | |
| 649 | - include TIMETICS_PLUGIN_DIR . '/templates/woocommerce/meeting-details-checkout.php'; | |
| 736 | + // Link the order back to the booking here rather than on woocommerce_thankyou. | |
| 737 | + // The thank you page only runs for a customer who actually lands on it and, for | |
| 738 | + // an unpaid order, only stored the booking -> order half of the link. Without the | |
| 739 | + // order -> booking half, sync_booking_status_from_order() finds no booking and an | |
| 740 | + // admin later marking the order paid or cancelled silently does nothing. | |
| 741 | + // | |
| 742 | + // The order is not saved here on purpose: in the classic checkout the order has no | |
| 743 | + // ID yet at this point. WooCommerce saves it right after building the line items. | |
| 744 | + if ( ! empty( $session_data['booking_id'] ) ) { | |
| 745 | + $order->update_meta_data( '_tt_booking_id', $session_data['booking_id'] ); | |
| 746 | + } | |
| 650 | 747 | } |
| 651 | 748 | |
| 652 | 749 | /** |
| 653 | 750 | * Sync booking status when WooCommerce order status changes |
| @@ -690,8 +787,28 @@ | ||
| 690 | 787 | $booking_status = Status_Mapper::order_to_booking_status( $new_status ); |
| 691 | 788 | |
| 692 | 789 | // Get current booking status |
| 693 | 790 | $current_status = $booking->get_status(); |
| 791 | + | |
| 792 | + // A cancelled / refunded order is terminal, so release the slot it was | |
| 793 | + // holding and let it appear as free again. | |
| 794 | + // | |
| 795 | + // We deliberately do NOT release on a 'failed' order. A WooCommerce | |
| 796 | + // booking is created in the 'failed' state while awaiting payment, and | |
| 797 | + // a failed order is retryable — the customer can re-pay. Releasing on | |
| 798 | + // 'failed' would free the slot, and a successful retry | |
| 799 | + // ( failed -> processing/approved ) would then revive the booking | |
| 800 | + // without re-holding the slot, causing an overbooking. A genuinely | |
| 801 | + // abandoned order is cancelled by WooCommerce's own unpaid-order | |
| 802 | + // handling ( hold-stock timeout ), which maps to 'cancel' and releases | |
| 803 | + // here. | |
| 804 | + // | |
| 805 | + // This runs before the "status unchanged" short-circuit below so that a | |
| 806 | + // refund after an already-cancelled booking ( cancel -> cancel, | |
| 807 | + // unchanged ) still guarantees a release. release_slot() is idempotent. | |
| 808 | + if ( 'cancel' === $booking_status ) { | |
| 809 | + $booking->release_slot(); | |
| 810 | + } | |
| 694 | 811 | |
| 695 | 812 | // Only update if status has actually changed |
| 696 | 813 | if ( $current_status === $booking_status ) { |
| 697 | 814 | return; |