PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.29
Booktics – Appointment Booking Calendar for Service Businesses v1.0.29
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
booktics / core / module / woocommerce-addon / hook / order-sync-hook.php

order-sync-hook.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.29, at core/module/woocommerce-addon/hook/order-sync-hook.php

341 lines 11.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Booktics\Module\Woocommerce_Addon\Hook;
4
5 if ( ! defined( 'ABSPATH' ) ) exit;
6
7 use Booktics\Abstracts\Booktics_Database;
8 use Booktics\Contracts\Hookable_Service_Contract;
9 use Booktics\Models\Appointment_Model;
10 use Booktics\Models\Order_Model;
11 use Booktics\Module\Woocommerce_Addon\Service\Status_Mapper;
12 use Booktics\Order\Services\Abandoned_Booking_Cleanup;
13 use Booktics\Order\Services\Order_Notifier;
14 use Booktics\Payment\Payment_Status;
15
16 /**
17 * Keeps the Booktics order's payment state in step with WooCommerce.
18 *
19 * Whether a booking is paid is never decided here. WooCommerce already has an
20 * answer — wc_get_is_paid_statuses(), which a store can filter — and this asks
21 * it rather than keeping a second opinion. That is why a cash-on-delivery order
22 * counts as paid the moment WooCommerce moves it to processing, and a bank
23 * transfer sitting at on-hold does not.
24 *
25 * Listening on both payment_complete and status_changed is deliberate: gateways
26 * that settle later, an admin marking an order paid by hand, and a store that
27 * redefines its paid statuses all arrive through the second one.
28 */
29 class Order_Sync_Hook implements Hookable_Service_Contract {
30
31 /**
32 * Register WordPress hooks.
33 *
34 * @return void
35 */
36 public function register() {
37 add_action( 'woocommerce_payment_complete', array( $this, 'sync_payment_state' ) );
38 add_action( 'woocommerce_order_status_changed', array( $this, 'sync_payment_state' ) );
39
40 add_action( 'woocommerce_order_status_changed', array( $this, 'sync_order_state' ), 20, 3 );
41 add_action( 'booktics_order_status_updated', array( $this, 'push_status_to_woocommerce' ), 10, 3 );
42
43 add_action( 'woocommerce_order_refunded', array( $this, 'sync_refund' ), 10, 2 );
44 }
45
46 /**
47 * React to a refund issued in WooCommerce.
48 *
49 * Refunds are initiated in WooCommerce admin only, so this is the single
50 * place money coming back reaches the booking.
51 *
52 * A full refund ends the booking: the payment is marked refunded, the order
53 * is canceled and the slot goes back on sale. A partial refund does not —
54 * one WooCommerce order carries exactly one booking, so there is no half of
55 * it to give back, and cancelling on a partial refund would take the slot
56 * away from a customer who still holds a valid appointment.
57 *
58 * WooCommerce usually moves a fully refunded order to the refunded status,
59 * which the status listener already handles. This runs anyway because a
60 * refund made through the API can leave the status untouched, and doing the
61 * work twice is harmless: every step below is idempotent.
62 *
63 * @param int $wc_order_id WooCommerce order id.
64 * @param int $refund_id Refund id.
65 *
66 * @return void
67 */
68 public function sync_refund( $wc_order_id, $refund_id = 0 ) {
69 $wc_order = $this->wc_order( $wc_order_id );
70
71 if ( ! $wc_order ) {
72 return;
73 }
74
75 $booktics_order_id = (int) $wc_order->get_meta( '_booktics_order_id' );
76
77 if ( ! $booktics_order_id || Status_Mapper::is_syncing( $booktics_order_id, (int) $wc_order_id ) ) {
78 return;
79 }
80
81 // A partial refund leaves the booking alone. Recording it is
82 // WooCommerce's job; it owns the money for these orders.
83 if ( (float) $wc_order->get_remaining_refund_amount() > 0 ) {
84 return;
85 }
86
87 $db = new Booktics_Database();
88 $order_model = new Order_Model( $db );
89 $order = $order_model->find( array( 'id' => $booktics_order_id ) );
90
91 if ( ! $order || is_wp_error( $order ) ) {
92 return;
93 }
94
95 Status_Mapper::begin_sync( $booktics_order_id, (int) $wc_order_id );
96
97 try {
98 $update = array();
99
100 if ( Payment_Status::REFUNDED !== ( $order->payment_status ?? '' ) ) {
101 $update['payment_status'] = Payment_Status::REFUNDED;
102 }
103
104 if ( ! Status_Mapper::is_canceled( (string) ( $order->status ?? '' ) ) ) {
105 $update['status'] = 'canceled';
106 }
107
108 if ( $update ) {
109 $order_model->update( $booktics_order_id, $update );
110 }
111
112 ( new Abandoned_Booking_Cleanup() )->release_appointments( $booktics_order_id );
113 } finally {
114 Status_Mapper::end_sync( $booktics_order_id, (int) $wc_order_id );
115 }
116 }
117
118 /**
119 * Pull a WooCommerce status change onto the Booktics order and its slots.
120 *
121 * @param int $wc_order_id WooCommerce order id.
122 * @param string $old_status Previous WooCommerce status.
123 * @param string $new_status New WooCommerce status.
124 *
125 * @return void
126 */
127 public function sync_order_state( $wc_order_id, $old_status = '', $new_status = '' ) {
128 $wc_order = $this->wc_order( $wc_order_id );
129
130 if ( ! $wc_order ) {
131 return;
132 }
133
134 $booktics_order_id = (int) $wc_order->get_meta( '_booktics_order_id' );
135
136 if ( ! $booktics_order_id || Status_Mapper::is_syncing( $booktics_order_id, (int) $wc_order_id ) ) {
137 return;
138 }
139
140 $target = Status_Mapper::to_booktics_status( (string) $new_status );
141
142 if ( null === $target ) {
143 return;
144 }
145
146 $db = new Booktics_Database();
147 $order_model = new Order_Model( $db );
148 $order = $order_model->find( array( 'id' => $booktics_order_id ) );
149
150 if ( ! $order || is_wp_error( $order ) ) {
151 return;
152 }
153
154 Status_Mapper::begin_sync( $booktics_order_id, (int) $wc_order_id );
155
156 try {
157 // Cancelling or refunding in WooCommerce is terminal: the slot has to
158 // go back on sale. release_appointments() is idempotent and skips
159 // appointments that already reached a terminal state, so it is safe
160 // to run even when the order status itself has not moved.
161 if ( Status_Mapper::is_canceled( $target ) ) {
162 ( new Abandoned_Booking_Cleanup() )->release_appointments( $booktics_order_id );
163 }
164
165 if ( (string) $order->status === $target ) {
166 return;
167 }
168
169 $order_model->update( $booktics_order_id, array( 'status' => $target ) );
170 } finally {
171 Status_Mapper::end_sync( $booktics_order_id, (int) $wc_order_id );
172 }
173 }
174
175 /**
176 * Push a Booktics status change back onto the WooCommerce order.
177 *
178 * Only the terminal states cross: `open` covers everything from unpaid to
179 * paid, so pushing it would drag a processing order backwards.
180 *
181 * @param int $booktics_order_id Booktics order id.
182 * @param string $new_status New Booktics order status.
183 * @param string $old_status Previous status.
184 *
185 * @return void
186 */
187 public function push_status_to_woocommerce( $booktics_order_id, $new_status, $old_status = '' ) {
188 $booktics_order_id = (int) $booktics_order_id;
189 $target = Status_Mapper::to_wc_status( (string) $new_status );
190
191 if ( ! $booktics_order_id || null === $target ) {
192 return;
193 }
194
195 $db = new Booktics_Database();
196 $order = ( new Order_Model( $db ) )->find( array( 'id' => $booktics_order_id ) );
197
198 if ( ! $order || 'woocommerce' !== ( $order->payment_method ?? '' ) ) {
199 return;
200 }
201
202 $wc_order_id = (int) ( $order->payment_intent_id ?? 0 );
203 $wc_order = $this->wc_order( $wc_order_id );
204
205 if ( ! $wc_order || Status_Mapper::is_syncing( $booktics_order_id, $wc_order_id ) ) {
206 return;
207 }
208
209 if ( $wc_order->get_status() === $target ) {
210 return;
211 }
212
213 // A paid order is never cancelled outright: the money has to be returned
214 // first, which is the refund flow's job, not a status write.
215 if ( 'cancelled' === $target && $wc_order->is_paid() ) {
216 return;
217 }
218
219 Status_Mapper::begin_sync( $booktics_order_id, $wc_order_id );
220
221 try {
222 $wc_order->set_status( $target, __( 'Updated from the Booktics booking.', 'booktics' ) );
223 $wc_order->save();
224 } finally {
225 Status_Mapper::end_sync( $booktics_order_id, $wc_order_id );
226 }
227 }
228
229 /**
230 * Resolve a WooCommerce order defensively.
231 *
232 * @param int $wc_order_id WooCommerce order id.
233 *
234 * @return \WC_Order|null
235 */
236 private function wc_order( $wc_order_id ) {
237 if ( ! $wc_order_id || ! function_exists( 'wc_get_order' ) ) {
238 return null;
239 }
240
241 $wc_order = wc_get_order( $wc_order_id );
242
243 return $wc_order instanceof \WC_Order ? $wc_order : null;
244 }
245
246 /**
247 * Pull the WooCommerce payment state onto the Booktics order.
248 *
249 * @param int $wc_order_id WooCommerce order id.
250 *
251 * @return void
252 */
253 public function sync_payment_state( $wc_order_id ) {
254 if ( ! function_exists( 'wc_get_order' ) ) {
255 return;
256 }
257
258 $wc_order = wc_get_order( $wc_order_id );
259
260 if ( ! $wc_order instanceof \WC_Order ) {
261 return;
262 }
263
264 $booktics_order_id = (int) $wc_order->get_meta( '_booktics_order_id' );
265
266 if ( ! $booktics_order_id ) {
267 return;
268 }
269
270 $db = new Booktics_Database();
271 $order_model = new Order_Model( $db );
272 $order = $order_model->find( array( 'id' => $booktics_order_id ) );
273
274 if ( ! $order || is_wp_error( $order ) ) {
275 return;
276 }
277
278 $previous = (string) ( $order->payment_status ?? '' );
279 $current = $this->payment_status_for( $wc_order );
280
281 if ( $previous === $current ) {
282 return;
283 }
284
285 $order_model->update(
286 $booktics_order_id,
287 array(
288 'payment_status' => $current,
289 'payment_transaction_id' => $wc_order->get_transaction_id() ?: null,
290 )
291 );
292
293 // Only the first crossing into a money-received state (processing or
294 // fully paid) sends a confirmation, so a later processing -> completed
295 // move on an already paid order cannot mail the customer twice.
296 if ( ! Status_Mapper::is_paid_payment_status( $current )
297 || Status_Mapper::is_paid_payment_status( $previous ) ) {
298 return;
299 }
300
301 $this->notify( $db, $order, $booktics_order_id );
302 }
303
304 /**
305 * Map a WooCommerce order onto a Booktics payment status.
306 *
307 * @param \WC_Order $wc_order WooCommerce order.
308 *
309 * @return string
310 */
311 private function payment_status_for( $wc_order ): string {
312 return Status_Mapper::to_payment_status( (string) $wc_order->get_status() );
313 }
314
315 /**
316 * Send the booking notifications for a newly paid order.
317 *
318 * A failure here must not bubble into WooCommerce's status transition: the
319 * payment is real and recorded either way, and throwing would leave the
320 * store owner staring at a checkout error for a booking that succeeded.
321 *
322 * @param Booktics_Database $db Database instance.
323 * @param object $order Booktics order row.
324 * @param int $booktics_order_id Booktics order id.
325 *
326 * @return void
327 */
328 private function notify( $db, $order, int $booktics_order_id ): void {
329 try {
330 $appointments = ( new Appointment_Model() )->where( 'order_id', $booktics_order_id );
331
332 Order_Notifier::order_created( $db, $order, $order->customer_id ?? 0, $appointments );
333 } catch ( \Throwable $e ) {
334 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
335 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
336 error_log( 'Booktics: WooCommerce paid notification failed for order ' . $booktics_order_id . ': ' . $e->getMessage() );
337 }
338 }
339 }
340 }
341