# booktics/1.0.24/core/module/woocommerce-addon/hook/checkout-hook.php

Booktics – Appointment Booking Calendar for Service Businesses, version 1.0.24. 386 lines.

- Page: https://pluginprobe.com/plugins/booktics/1.0.24/code/core/module/woocommerce-addon/hook/checkout-hook.php
- Raw: https://pluginprobe.com/plugins/booktics/1.0.24/raw/core/module/woocommerce-addon/hook/checkout-hook.php
- Modified: 2026-08-25T17:58:46+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/booktics/1.0.24/code/core/module/woocommerce-addon/hook/checkout-hook.php#L10-L20`.

```php
<?php

namespace Booktics\Module\Woocommerce_Addon\Hook;

if ( ! defined( 'ABSPATH' ) ) exit;

use Booktics\Abstracts\Booktics_Database;
use Booktics\Contracts\Hookable_Service_Contract;
use Booktics\Models\Order_Model;
use Booktics\Module\Woocommerce_Addon\Service\Cart_Handoff;
use Booktics\Module\Woocommerce_Addon\Service\Totals_Writer;

/**
 * Carries a booking through the WooCommerce cart and checkout.
 *
 * Three jobs: make the cart usable from the REST request that starts the
 * hand-off, charge the Booktics price rather than the product price, and put
 * the booking details where the customer, the store owner and any invoice
 * plugin can see them.
 */
class Checkout_Hook implements Hookable_Service_Contract {

    /**
     * Register WordPress hooks.
     *
     * @return void
     */
    public function register() {
        add_action( 'woocommerce_init', array( $this, 'load_cart_for_rest' ) );

        add_action( 'woocommerce_before_calculate_totals', array( $this, 'apply_booking_price' ), 20 );

        add_filter( 'woocommerce_get_item_data', array( $this, 'cart_item_details' ), 10, 2 );
        add_filter( 'woocommerce_cart_item_name', array( $this, 'cart_item_name' ), 10, 3 );

        add_action( 'woocommerce_checkout_create_order_line_item', array( $this, 'add_line_item_meta' ), 10, 4 );
        add_filter( 'woocommerce_hidden_order_itemmeta', array( $this, 'hide_internal_item_meta' ) );

        // Classic checkout fires the first pair; the checkout block goes through
        // the Store API, which fires none of them and its own instead. Both are
        // hooked so a booking is linked, assigned and reconciled the same way on
        // either checkout. update_order_from_request runs before the order is
        // saved, so set_customer_id there still takes, exactly like create_order.
        add_action( 'woocommerce_checkout_create_order', array( $this, 'link_order' ), 10, 2 );
        add_action( 'woocommerce_checkout_order_processed', array( $this, 'store_wc_order_id' ), 10, 3 );
        add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( $this, 'link_order' ), 10, 2 );
        add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'store_wc_order_id_block' ), 10, 1 );
    }

    /**
     * Store API counterpart of store_wc_order_id().
     *
     * The block's order-processed hook hands over only the order, where the
     * classic one leads with the order id, so this adapts the arguments and
     * shares the one implementation.
     *
     * @param \WC_Order $order Order just placed through the checkout block.
     *
     * @return void
     */
    public function store_wc_order_id_block( $order ) {
        if ( $order instanceof \WC_Order ) {
            $this->store_wc_order_id( $order->get_id(), array(), $order );
        }
    }

    /**
     * Make the cart available inside REST requests.
     *
     * WooCommerce only boots the cart for regular front-end requests, but the
     * hand-off runs while the booking REST endpoint is being served. Loading it
     * here also starts the session, and the cookie set on that response is what
     * carries the cart over to the checkout page.
     *
     * @return void
     */
    public function load_cart_for_rest() {
        if ( ! function_exists( 'WC' ) || ! WC()->is_rest_api_request() ) {
            return;
        }

        WC()->frontend_includes();

        if ( null === WC()->session && function_exists( 'wc_load_cart' ) ) {
            wc_load_cart();

            return;
        }

        if ( null === WC()->cart && function_exists( 'wc_load_cart' ) ) {
            wc_load_cart();
        }
    }

    /**
     * Charge the Booktics total instead of the product price.
     *
     * The price is set on the cart item's product clone, never written back to
     * the product itself: rewriting product meta would let two people booking
     * the same service at the same moment overwrite each other's price.
     *
     * @param \WC_Cart $cart Cart being calculated.
     *
     * @return void
     */
    public function apply_booking_price( $cart ) {
        if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
            return;
        }

        foreach ( $cart->get_cart() as $cart_item ) {
            $booking = $cart_item[ Cart_Handoff::CART_KEY ] ?? null;

            if ( ! $booking || ! isset( $booking['price'] ) ) {
                continue;
            }

            $cart_item['data']->set_price( (float) $booking['price'] );
        }
    }

    /**
     * Show the booking details under the item in cart and checkout.
     *
     * @param array $item_data Existing display rows.
     * @param array $cart_item Cart item.
     *
     * @return array
     */
    public function cart_item_details( $item_data, $cart_item ) {
        $booking = $cart_item[ Cart_Handoff::CART_KEY ] ?? null;

        if ( ! $booking ) {
            return $item_data;
        }

        foreach ( $this->display_fields( $booking ) as $label => $value ) {
            $item_data[] = array(
                'key'   => $label,
                'value' => $value,
            );
        }

        return $item_data;
    }

    /**
     * Keep the service name readable even if the product was renamed.
     *
     * @param string $name      Item name markup.
     * @param array  $cart_item Cart item.
     * @param string $cart_item_key Cart item key.
     *
     * @return string
     */
    public function cart_item_name( $name, $cart_item, $cart_item_key ) {
        $booking = $cart_item[ Cart_Handoff::CART_KEY ] ?? null;

        if ( ! $booking || empty( $booking['service'] ) ) {
            return $name;
        }

        return esc_html( $booking['service'] );
    }

    /**
     * Persist the booking onto the order line item.
     *
     * Hidden keys link the line back to Booktics; the readable ones show up on
     * the order screen, in WooCommerce emails and in invoice plugins, which is
     * the detail Timetics' integration never carries across.
     *
     * @param \WC_Order_Item_Product $item          Line item.
     * @param string                 $cart_item_key Cart item key.
     * @param array                  $values        Cart item values.
     * @param \WC_Order              $order         Order being created.
     *
     * @return void
     */
    public function add_line_item_meta( $item, $cart_item_key, $values, $order ) {
        $booking = $values[ Cart_Handoff::CART_KEY ] ?? null;

        if ( ! $booking ) {
            return;
        }

        $item->add_meta_data( '_booktics_order_id', $booking['order_id'] ?? 0, true );
        $item->add_meta_data( '_booktics_appointment_id', $booking['appointment_id'] ?? 0, true );
        $item->add_meta_data( '_booktics_service_id', $booking['service_id'] ?? 0, true );

        foreach ( $this->display_fields( $booking ) as $label => $value ) {
            $item->add_meta_data( $label, $value, true );
        }
    }

    /**
     * Keep the linking keys out of the order screen's item table.
     *
     * They are ours to resolve bookings with, not something a store owner
     * should have to read past to reach the date and time.
     *
     * @param array $hidden Meta keys WooCommerce already hides.
     *
     * @return array
     */
    public function hide_internal_item_meta( $hidden ) {
        $hidden[] = '_booktics_order_id';
        $hidden[] = '_booktics_appointment_id';
        $hidden[] = '_booktics_service_id';

        return $hidden;
    }

    /**
     * Stamp the Booktics order id onto the WooCommerce order as it is created.
     *
     * @param \WC_Order $order Order being created.
     * @param array     $data  Posted checkout data.
     *
     * @return void
     */
    public function link_order( $order, $data ) {
        $booking = $this->booking_in_cart();

        if ( ! $booking ) {
            return;
        }

        $order->update_meta_data( '_booktics_order_id', (int) ( $booking['order_id'] ?? 0 ) );
        $order->update_meta_data( '_booktics_order_no', (string) ( $booking['order_no'] ?? '' ) );

        // Assign the order to the booking's customer, not whoever is logged in.
        // WooCommerce defaults a new order to the current user, so a store owner
        // booking for a customer would otherwise own the order while the
        // customer's email sits in billing. Booktics creates a WP account for
        // every booking customer, so this is their id; 0 leaves it a guest
        // order, which is still correct and never the admin.
        $order->set_customer_id( (int) ( $booking['customer']['wp_user_id'] ?? 0 ) );

        // Kept so the thank-you setting can return the customer to the page they
        // booked from once the cart no longer exists.
        $order->update_meta_data( '_booktics_return_url', (string) ( $booking['return_url'] ?? '' ) );

        $this->stamp_billing_contact( $order, $booking );
    }

    /**
     * Put the booking's contact details onto the order's billing.
     *
     * With the billing fields hidden on the checkout block, the customer never
     * types a name or phone and the block submits none, so the order would carry
     * only an email. The booking already holds these, so they are written here,
     * server side, where it does not matter whether a field was on screen. Only
     * blanks are filled, so a checkout that did collect the fields keeps whatever
     * the customer entered.
     *
     * @param \WC_Order $order   Order being created.
     * @param array     $booking Booking payload from the cart.
     *
     * @return void
     */
    private function stamp_billing_contact( $order, array $booking ): void {
        $customer = $booking['customer'] ?? array();

        if ( empty( $customer ) ) {
            return;
        }

        if ( ! $order->get_billing_first_name() && ! empty( $customer['first_name'] ) ) {
            $order->set_billing_first_name( (string) $customer['first_name'] );
        }

        if ( ! $order->get_billing_last_name() && ! empty( $customer['last_name'] ) ) {
            $order->set_billing_last_name( (string) $customer['last_name'] );
        }

        if ( ! $order->get_billing_email() && ! empty( $customer['email'] ) ) {
            $order->set_billing_email( (string) $customer['email'] );
        }

        if ( ! $order->get_billing_phone() && ! empty( $customer['phone'] ) ) {
            $order->set_billing_phone( (string) $customer['phone'] );
        }
    }

    /**
     * Record the WooCommerce order id on the Booktics order.
     *
     * Stored in payment_intent_id, which the abandoned-booking sweep already
     * reads as "an online payment was attempted", so a checkout the customer
     * walks away from is reconciled rather than left holding a slot forever.
     *
     * @param int      $wc_order_id WooCommerce order id.
     * @param array    $posted_data Posted checkout data.
     * @param \WC_Order $order      Order object.
     *
     * @return void
     */
    public function store_wc_order_id( $wc_order_id, $posted_data = array(), $order = null ) {
        $booktics_order_id = 0;

        if ( $order instanceof \WC_Order ) {
            $booktics_order_id = (int) $order->get_meta( '_booktics_order_id' );
        }

        if ( ! $booktics_order_id ) {
            $booking           = $this->booking_in_cart();
            $booktics_order_id = (int) ( $booking['order_id'] ?? 0 );
        }

        if ( ! $booktics_order_id ) {
            return;
        }

        ( new Order_Model( new Booktics_Database() ) )->update(
            $booktics_order_id,
            array( 'payment_intent_id' => (int) $wc_order_id )
        );

        // WooCommerce owns coupons and tax, so the figures it settled on are the
        // ones the invoice and the revenue report have to show.
        if ( $order instanceof \WC_Order ) {
            Totals_Writer::write( $booktics_order_id, $order );
        }
    }

    /**
     * The booking payload currently sitting in the cart, if any.
     *
     * @return array|null
     */
    private function booking_in_cart() {
        if ( ! function_exists( 'WC' ) || ! WC()->cart instanceof \WC_Cart ) {
            return null;
        }

        foreach ( WC()->cart->get_cart() as $cart_item ) {
            if ( ! empty( $cart_item[ Cart_Handoff::CART_KEY ] ) ) {
                return $cart_item[ Cart_Handoff::CART_KEY ];
            }
        }

        return null;
    }

    /**
     * Human readable booking rows, shared by the cart display and the line item.
     *
     * @param array $booking Booking payload.
     *
     * @return array<string, string>
     */
    private function display_fields( array $booking ): array {
        $fields = array();

        if ( ! empty( $booking['date'] ) ) {
            $timestamp = strtotime( $booking['date'] );

            $fields[ __( 'Date', 'booktics' ) ] = $timestamp
                ? wp_date( get_option( 'date_format' ), $timestamp )
                : $booking['date'];
        }

        if ( ! empty( $booking['start_time'] ) ) {
            $fields[ __( 'Time', 'booktics' ) ] = empty( $booking['end_time'] )
                ? $booking['start_time']
                : $booking['start_time'] . ' - ' . $booking['end_time'];
        }

        if ( ! empty( $booking['team_member'] ) ) {
            $fields[ __( 'Team member', 'booktics' ) ] = $booking['team_member'];
        }

        if ( ! empty( $booking['duration'] ) ) {
            /* translators: %d: duration in minutes */
            $fields[ __( 'Duration', 'booktics' ) ] = sprintf( __( '%d min', 'booktics' ), (int) $booking['duration'] );
        }

        if ( ! empty( $booking['timezone'] ) ) {
            $fields[ __( 'Timezone', 'booktics' ) ] = $booking['timezone'];
        }

        return $fields;
    }
}

```
