wc_order( $wc_order_id ); if ( ! $wc_order ) { return; } $booktics_order_id = (int) $wc_order->get_meta( '_booktics_order_id' ); if ( ! $booktics_order_id || Status_Mapper::is_syncing( $booktics_order_id, (int) $wc_order_id ) ) { return; } // A partial refund leaves the booking alone. Recording it is // WooCommerce's job; it owns the money for these orders. if ( (float) $wc_order->get_remaining_refund_amount() > 0 ) { return; } $db = new Booktics_Database(); $order_model = new Order_Model( $db ); $order = $order_model->find( array( 'id' => $booktics_order_id ) ); if ( ! $order || is_wp_error( $order ) ) { return; } Status_Mapper::begin_sync( $booktics_order_id, (int) $wc_order_id ); try { $update = array(); if ( Payment_Status::REFUNDED !== ( $order->payment_status ?? '' ) ) { $update['payment_status'] = Payment_Status::REFUNDED; } if ( ! Status_Mapper::is_canceled( (string) ( $order->status ?? '' ) ) ) { $update['status'] = 'canceled'; } if ( $update ) { $order_model->update( $booktics_order_id, $update ); } ( new Abandoned_Booking_Cleanup() )->release_appointments( $booktics_order_id ); } finally { Status_Mapper::end_sync( $booktics_order_id, (int) $wc_order_id ); } } /** * Pull a WooCommerce status change onto the Booktics order and its slots. * * @param int $wc_order_id WooCommerce order id. * @param string $old_status Previous WooCommerce status. * @param string $new_status New WooCommerce status. * * @return void */ public function sync_order_state( $wc_order_id, $old_status = '', $new_status = '' ) { $wc_order = $this->wc_order( $wc_order_id ); if ( ! $wc_order ) { return; } $booktics_order_id = (int) $wc_order->get_meta( '_booktics_order_id' ); if ( ! $booktics_order_id || Status_Mapper::is_syncing( $booktics_order_id, (int) $wc_order_id ) ) { return; } $target = Status_Mapper::to_booktics_status( (string) $new_status ); if ( null === $target ) { return; } $db = new Booktics_Database(); $order_model = new Order_Model( $db ); $order = $order_model->find( array( 'id' => $booktics_order_id ) ); if ( ! $order || is_wp_error( $order ) ) { return; } Status_Mapper::begin_sync( $booktics_order_id, (int) $wc_order_id ); try { // Cancelling or refunding in WooCommerce is terminal: the slot has to // go back on sale. release_appointments() is idempotent and skips // appointments that already reached a terminal state, so it is safe // to run even when the order status itself has not moved. if ( Status_Mapper::is_canceled( $target ) ) { ( new Abandoned_Booking_Cleanup() )->release_appointments( $booktics_order_id ); } if ( (string) $order->status === $target ) { return; } $order_model->update( $booktics_order_id, array( 'status' => $target ) ); } finally { Status_Mapper::end_sync( $booktics_order_id, (int) $wc_order_id ); } } /** * Push a Booktics status change back onto the WooCommerce order. * * Only the terminal states cross: `open` covers everything from unpaid to * paid, so pushing it would drag a processing order backwards. * * @param int $booktics_order_id Booktics order id. * @param string $new_status New Booktics order status. * @param string $old_status Previous status. * * @return void */ public function push_status_to_woocommerce( $booktics_order_id, $new_status, $old_status = '' ) { $booktics_order_id = (int) $booktics_order_id; $target = Status_Mapper::to_wc_status( (string) $new_status ); if ( ! $booktics_order_id || null === $target ) { return; } $db = new Booktics_Database(); $order = ( new Order_Model( $db ) )->find( array( 'id' => $booktics_order_id ) ); if ( ! $order || 'woocommerce' !== ( $order->payment_method ?? '' ) ) { return; } $wc_order_id = (int) ( $order->payment_intent_id ?? 0 ); $wc_order = $this->wc_order( $wc_order_id ); if ( ! $wc_order || Status_Mapper::is_syncing( $booktics_order_id, $wc_order_id ) ) { return; } if ( $wc_order->get_status() === $target ) { return; } // A paid order is never cancelled outright: the money has to be returned // first, which is the refund flow's job, not a status write. if ( 'cancelled' === $target && $wc_order->is_paid() ) { return; } Status_Mapper::begin_sync( $booktics_order_id, $wc_order_id ); try { $wc_order->set_status( $target, __( 'Updated from the Booktics booking.', 'booktics' ) ); $wc_order->save(); } finally { Status_Mapper::end_sync( $booktics_order_id, $wc_order_id ); } } /** * Resolve a WooCommerce order defensively. * * @param int $wc_order_id WooCommerce order id. * * @return \WC_Order|null */ private function wc_order( $wc_order_id ) { if ( ! $wc_order_id || ! function_exists( 'wc_get_order' ) ) { return null; } $wc_order = wc_get_order( $wc_order_id ); return $wc_order instanceof \WC_Order ? $wc_order : null; } /** * Pull the WooCommerce payment state onto the Booktics order. * * @param int $wc_order_id WooCommerce order id. * * @return void */ public function sync_payment_state( $wc_order_id ) { if ( ! function_exists( 'wc_get_order' ) ) { return; } $wc_order = wc_get_order( $wc_order_id ); if ( ! $wc_order instanceof \WC_Order ) { return; } $booktics_order_id = (int) $wc_order->get_meta( '_booktics_order_id' ); if ( ! $booktics_order_id ) { return; } $db = new Booktics_Database(); $order_model = new Order_Model( $db ); $order = $order_model->find( array( 'id' => $booktics_order_id ) ); if ( ! $order || is_wp_error( $order ) ) { return; } $previous = (string) ( $order->payment_status ?? '' ); $current = $this->payment_status_for( $wc_order ); if ( $previous === $current ) { return; } $order_model->update( $booktics_order_id, array( 'payment_status' => $current, 'payment_transaction_id' => $wc_order->get_transaction_id() ?: null, ) ); // Only the first crossing into a money-received state (processing or // fully paid) sends a confirmation, so a later processing -> completed // move on an already paid order cannot mail the customer twice. if ( ! Status_Mapper::is_paid_payment_status( $current ) || Status_Mapper::is_paid_payment_status( $previous ) ) { return; } $this->notify( $db, $order, $booktics_order_id ); } /** * Map a WooCommerce order onto a Booktics payment status. * * @param \WC_Order $wc_order WooCommerce order. * * @return string */ private function payment_status_for( $wc_order ): string { return Status_Mapper::to_payment_status( (string) $wc_order->get_status() ); } /** * Send the booking notifications for a newly paid order. * * A failure here must not bubble into WooCommerce's status transition: the * payment is real and recorded either way, and throwing would leave the * store owner staring at a checkout error for a booking that succeeded. * * @param Booktics_Database $db Database instance. * @param object $order Booktics order row. * @param int $booktics_order_id Booktics order id. * * @return void */ private function notify( $db, $order, int $booktics_order_id ): void { try { $appointments = ( new Appointment_Model() )->where( 'order_id', $booktics_order_id ); Order_Notifier::order_created( $db, $order, $order->customer_id ?? 0, $appointments ); } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( 'Booktics: WooCommerce paid notification failed for order ' . $booktics_order_id . ': ' . $e->getMessage() ); } } } }