PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.63
Timetics – Appointment Booking Calendar & Scheduling v1.0.63
1.0.62 1.0.63 1.0.61 1.0.60 1.0.59 1.0.58 1.0.57 1.0.56 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.19 1.0.2 1.0.20 1.0.21 1.0.22 All 64 releases
timetics / core / bookings / hooks.php

hooks.php in Timetics – Appointment Booking Calendar & Scheduling 1.0.63, at core/bookings/hooks.php

726 lines 24.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Booking related hooks
4 *
5 * @package Timetics
6 */
7
8 namespace Timetics\Core\Bookings;
9
10 defined( 'ABSPATH' ) || exit;
11
12 use Timetics\Core\Appointments\Appointment;
13 use Timetics\Core\Emails\Customer_Booking_Reminder_Email;
14 use Timetics\Core\Emails\Staff_Booking_Reminder_Email;
15 use Timetics\Core\Integrations\Stripe\StripePayment;
16 use Timetics\Utils\Singleton;
17
18 /**
19 * Class Hooks
20 */
21 class Hooks {
22 use Singleton;
23
24 /**
25 * Initialization
26 *
27 * @return void
28 */
29 public function init() {
30 add_action( 'timetics_after_booking_create', [$this, 'register_schedule'] );
31 add_action( 'timetics_booking_remainder', [$this, 'send_reminder_email'], 10, 2 );
32 add_action( 'timetics_booking_clear_schedule', [$this, 'clear_booking_schedule'] );
33
34 add_action( 'before_delete_post', [$this, 'release_slot_on_delete'] );
35
36 add_action( 'init', [$this, 'register_booking_status'] );
37 add_action( 'init', [$this, 'maybe_migrate_reminder_schedules'], 99 );
38
39 // Covers sites active before this cron existed; no-op once scheduled.
40 add_action( 'init', [$this, 'maybe_schedule_cleanup_cron'] );
41
42 add_action('woocommerce_before_calculate_totals', [ $this, 'timetics_variation_ticket_total_price' ] );
43
44 add_filter( 'woocommerce_add_cart_item_data', [ $this, 'timetics_add_cart_item_data' ], 10, 2 );
45
46 add_filter( 'cron_schedules', [$this, 'register_cron_schedules'] );
47
48 // Was admin_init-triggered, so unpaid bookings only got cleaned up when
49 // someone loaded wp-admin. Now runs on a real WP-Cron schedule.
50 add_action( 'timetics_cleanup_unpaid_bookings', [$this, 'delete_booking_before_paid'] );
51 }
52
53 /**
54 * Add a 5-minute WP-Cron interval for the unpaid-booking cleanup sweep.
55 *
56 * @param array $schedules
57 *
58 * @return array
59 */
60 public function register_cron_schedules( $schedules ) {
61 $schedules['timetics_five_minutes'] = [
62 'interval' => 5 * MINUTE_IN_SECONDS,
63 'display' => __( 'Every 5 Minutes (Timetics)', 'timetics' ),
64 ];
65
66 return $schedules;
67 }
68
69 /**
70 * Schedule the unpaid-booking cleanup cron if it isn't already scheduled.
71 *
72 * @return void
73 */
74 public function maybe_schedule_cleanup_cron() {
75 if ( ! wp_next_scheduled( 'timetics_cleanup_unpaid_bookings' ) ) {
76 wp_schedule_event( time(), 'timetics_five_minutes', 'timetics_cleanup_unpaid_bookings' );
77 }
78 }
79
80 /**
81 * Register cron job for schedule a reminder email
82 *
83 * @param integer $booking_id
84 *
85 * @return void
86 */
87 public function register_schedule( $booking_id ) {
88 // Runs on update as well as create. Any reminder queued for the old
89 // date/time is dropped first, otherwise the `wp_next_scheduled()` guard
90 // below keeps the stale event and the new time is never scheduled.
91 self::clear_reminders( $booking_id );
92
93 $booking = new Booking( $booking_id );
94
95 $date = $booking->get_start_date();
96 $time = $booking->get_start_time();
97
98 $booking_timezone = $booking->get_timezone();
99
100 if ( ! $booking_timezone || ! timetics_is_valid_timezone( $booking_timezone ) ) {
101 $booking_timezone = timetics_reminder_fallback_timezone();
102 }
103
104 $booking_datetime = new \DateTime( $date . ' ' . $time, new \DateTimeZone( $booking_timezone ) );
105 $booking_timestamp = $booking_datetime->getTimestamp();
106
107 $reminder_time = timetics_get_option( 'remainder_time' );
108
109 if ( ! $reminder_time ) {
110 return;
111 }
112
113 $queued = [];
114
115 foreach ( $reminder_time as $reminder ) {
116 $offset = 0;
117 $duration = isset( $reminder['duration-time'] ) ? intval( $reminder['duration-time'] ) : 0;
118 $type = isset( $reminder['custom_duration_type'] ) ? $reminder['custom_duration_type'] : '';
119
120 switch ( $type ) {
121 case 'min':
122 $offset = $duration * MINUTE_IN_SECONDS;
123 break;
124 case 'hour':
125 $offset = $duration * HOUR_IN_SECONDS;
126 break;
127 case 'day':
128 $offset = $duration * DAY_IN_SECONDS;
129 break;
130 }
131
132 $reminder_timestamp = intval( $booking_timestamp ) - $offset;
133
134 // Never schedule a reminder in the past. WP-Cron fires past-due
135 // events on the next page load, which caused reminder emails to be
136 // sent unexpectedly — and in bursts when a backlog flushed — even
137 // though no new booking or action had occurred.
138 if ( $reminder_timestamp <= time() ) {
139 continue;
140 }
141
142 // The same offset configured twice is one reminder, not two.
143 if ( isset( $queued[ $offset ] ) ) {
144 continue;
145 }
146
147 $queued[ $offset ] = true;
148
149 // The offset travels in the cron args so every configured reminder
150 // is a distinct event. Sharing one arg list made WP-Cron treat them
151 // as the same hook: the old `wp_next_scheduled()` guard let only the
152 // first list entry through, and even without it
153 // wp_schedule_single_event() silently drops a duplicate falling
154 // within 10 minutes of one already queued.
155 wp_schedule_single_event( $reminder_timestamp, 'timetics_booking_remainder', [$booking_id, $offset] );
156 }
157 }
158
159 /**
160 * Send booking reminder email
161 *
162 * @param integer $booking_id
163 * @param integer $offset Seconds before the meeting this reminder was queued for.
164 * Part of the cron args only so each configured reminder is
165 * a distinct event; not used when composing the email.
166 *
167 * @return void
168 */
169 public function send_reminder_email( $booking_id, $offset = 0 ) {
170 // The cron event outlives the booking, so re-check it here: a booking
171 // cancelled or deleted after the reminder was scheduled must not get a
172 // reminder for a meeting that no longer exists.
173 $status = get_post_status( $booking_id );
174
175 if ( ! $status || in_array( $status, ['cancel', 'cancelled', 'failed', 'trash'], true ) ) {
176 return;
177 }
178
179 $booking_reminder_customer = timetics_get_option( 'booking_reminder_customer' );
180 $booking_reminder_host = timetics_get_option( 'booking_reminder_host' );
181
182 $booking = new Booking( $booking_id );
183
184 if ( $booking_reminder_customer ) {
185 $customer_reminder = new Customer_Booking_Reminder_Email( $booking );
186 $customer_reminder->send();
187 }
188
189 if ( $booking_reminder_host ) {
190 $staff_reminder = new Staff_Booking_Reminder_Email( $booking );
191 $staff_reminder->send();
192 }
193
194 }
195
196 /**
197 * Remove every reminder cron event queued for a booking.
198 *
199 * @param integer $booking_id
200 *
201 * @return integer Number of events removed.
202 */
203 public static function clear_reminders( $booking_id ) {
204 $removed = 0;
205
206 foreach ( self::find_reminders( $booking_id ) as $timestamp => $args ) {
207 wp_unschedule_event( $timestamp, 'timetics_booking_remainder', $args );
208 $removed++;
209 }
210
211 return $removed;
212 }
213
214 /**
215 * Every reminder cron event queued for a booking, as timestamp => args.
216 *
217 * Walks the cron store rather than calling wp_next_scheduled() with a fixed
218 * arg list: a booking has one event per configured reminder, each carrying
219 * its own offset, so there is no single arg list to look up. Events queued
220 * before the offset was added carry only [ booking_id ], so matching is on
221 * the first argument to cover both shapes.
222 *
223 * @param integer $booking_id
224 *
225 * @return array
226 */
227 private static function find_reminders( $booking_id ) {
228 $booking_id = (int) $booking_id;
229 $cron = _get_cron_array();
230 $found = [];
231
232 if ( ! is_array( $cron ) ) {
233 return $found;
234 }
235
236 foreach ( $cron as $timestamp => $hooks ) {
237 if ( empty( $hooks['timetics_booking_remainder'] ) || ! is_array( $hooks['timetics_booking_remainder'] ) ) {
238 continue;
239 }
240
241 foreach ( $hooks['timetics_booking_remainder'] as $event ) {
242 $args = isset( $event['args'] ) ? (array) $event['args'] : [];
243
244 if ( empty( $args ) || (int) $args[0] !== $booking_id ) {
245 continue;
246 }
247
248 $found[ $timestamp ] = $args;
249 }
250 }
251
252 return $found;
253 }
254
255 /**
256 * Clear cron job schedule
257 *
258 * @return
259 */
260 public function clear_booking_schedule() {
261 $bookins = Booking::all();
262
263 if ( ! $bookins ) {
264 return;
265 }
266
267 // Run cron action.
268 foreach ( $bookins['items'] as $booking ) {
269 // Not wp_next_scheduled() with a fixed arg list: a booking now has one
270 // event per configured reminder, each carrying its own offset, so a
271 // single-arg lookup misses all of them.
272 foreach ( self::find_reminders( $booking->ID ) as $timestamp => $args ) {
273 if ( $timestamp < time() ) {
274 wp_unschedule_event( $timestamp, 'timetics_booking_remainder', $args );
275 }
276 }
277 }
278 }
279
280 /**
281 * Migrate any outstanding cron events scheduled with the legacy
282 * `timetics_booking_remainder_{id}` hook name to the unified
283 * `timetics_booking_remainder` hook with the booking id as an argument.
284 *
285 * Runs once per plugin version.
286 *
287 * @return void
288 */
289 public function maybe_migrate_reminder_schedules() {
290 $version = defined( 'TIMETICS_VERSION' ) ? TIMETICS_VERSION : '0';
291
292 if ( get_option( 'timetics_reminder_cron_migrated' ) === $version ) {
293 return;
294 }
295
296 $cron = _get_cron_array();
297
298 if ( ! is_array( $cron ) ) {
299 update_option( 'timetics_reminder_cron_migrated', $version, false );
300 return;
301 }
302
303 $changed = false;
304
305 foreach ( $cron as $timestamp => $hooks ) {
306 if ( ! is_array( $hooks ) ) {
307 continue;
308 }
309
310 foreach ( $hooks as $hook => $events ) {
311 if ( strpos( $hook, 'timetics_booking_remainder_' ) !== 0 ) {
312 continue;
313 }
314
315 $booking_id = (int) substr( $hook, strlen( 'timetics_booking_remainder_' ) );
316
317 if ( ! $booking_id ) {
318 unset( $cron[ $timestamp ][ $hook ] );
319 $changed = true;
320 continue;
321 }
322
323 $args = [$booking_id];
324 $key = md5( serialize( $args ) );
325
326 $cron[ $timestamp ]['timetics_booking_remainder'][ $key ] = [
327 'schedule' => false,
328 'args' => $args,
329 ];
330
331 unset( $cron[ $timestamp ][ $hook ] );
332 $changed = true;
333 }
334
335 if ( empty( $cron[ $timestamp ] ) ) {
336 unset( $cron[ $timestamp ] );
337 }
338 }
339
340 if ( $changed ) {
341 _set_cron_array( $cron );
342 }
343
344 update_option( 'timetics_reminder_cron_migrated', $version, false );
345
346 $this->maybe_reschedule_reminders( $version );
347 }
348
349 /**
350 * Clear and re-schedule all booking reminder cron events with
351 * corrected timezone-aware timestamps.
352 *
353 * Runs once per plugin version after the timezone fix.
354 *
355 * @param string $version
356 *
357 * @return void
358 */
359 private function maybe_reschedule_reminders( $version ) {
360 $migration_key = 'timetics_reminder_tz_migrated';
361
362 if ( get_option( $migration_key ) === $version ) {
363 return;
364 }
365
366 $cron = _get_cron_array();
367
368 if ( is_array( $cron ) ) {
369 $changed = false;
370
371 foreach ( $cron as $timestamp => $hooks ) {
372 if ( ! is_array( $hooks ) ) {
373 continue;
374 }
375
376 if ( isset( $hooks['timetics_booking_remainder'] ) ) {
377 unset( $cron[ $timestamp ]['timetics_booking_remainder'] );
378 $changed = true;
379 }
380
381 if ( empty( $cron[ $timestamp ] ) ) {
382 unset( $cron[ $timestamp ] );
383 }
384 }
385
386 if ( $changed ) {
387 _set_cron_array( $cron );
388 }
389 }
390
391 $all = Booking::all(
392 [
393 'posts_per_page' => -1,
394 'post_status' => [ 'approved', 'pending' ],
395 'start_date' => gmdate( 'Y-m-d' ),
396 ]
397 );
398
399 if ( ! empty( $all['items'] ) ) {
400 foreach ( $all['items'] as $booking ) {
401 $this->register_schedule( $booking->ID );
402 }
403 }
404
405 update_option( $migration_key, $version, false );
406 }
407
408 /**
409 * Give a booking's slot back when its post is permanently deleted.
410 *
411 * Only the REST controller released the entry; deletes from the posts
412 * screen, WP-CLI or wp_delete_post() left it blocking the slot for good.
413 * Hooked to permanent deletion, not trash, so a restore keeps its slot.
414 *
415 * @param integer $post_id
416 *
417 * @return void
418 */
419 public function release_slot_on_delete( $post_id ) {
420 if ( 'timetics-booking' !== get_post_type( $post_id ) ) {
421 return;
422 }
423
424 ( new Booking( $post_id ) )->release_slot();
425 }
426
427 /**
428 * Update bookked entry if reschedule
429 *
430 * @deprecated 1.0.62 Ran after the booking already held its new time, so it
431 * looked up the slot moved *into*, not the one left behind.
432 * Use Booking::release_slot_at() with the previous slot.
433 *
434 * @param integer $booking_id
435 * @param integer $customer_id
436 * @param integer $meeting_id
437 * @param array $data
438 * @param integer $booking_entry
439 *
440 * @return void
441 */
442 public function reschedule_booking( $booking_id, $customer_id, $meeting_id, $data ) {
443 $reschedule = ! empty( $data['reschedule'] ) ? $data['reschedule'] : false;
444 $booking = new Booking( $booking_id );
445 $meeting = new Appointment( $meeting_id );
446 $booking_entry = new Booking_Entry();
447
448 if ( ! $reschedule ) {
449 return;
450 }
451
452 $entries = $booking_entry->find(
453 [
454 'staff_id' => $booking->get_staff_id(),
455 'meeting_id' => $meeting->get_id(),
456 'date' => $booking->get_start_date(),
457 'start' => $booking->get_start_time(),
458 ]
459 );
460
461 if ( ! $entries ) {
462 return;
463 }
464
465 $entry = $booking_entry->first();
466 $booked_seat = ! empty( $booking->get_seat() ) ? $booking->get_seat() : [];
467 $existing_seat = ! empty( $entry->get_seats() ) ? $entry->get_seats() : [];
468
469 if ( 'one-to-one' === strtolower( $meeting->get_type() ) ) {
470 $entry->delete();
471 } else {
472 $booked = intval( $entry->get_booked() ) - 1;
473
474 $entry->update( [
475 'booked' => $booked,
476 'seats' => array_values( array_diff( $existing_seat, $booked_seat ) ),
477 ] );
478 }
479 }
480
481 /**
482 * Register booking statuses
483 *
484 * @return void
485 */
486 public function register_booking_status() {
487 // Define label_count translations for each status
488 $label_counts = array(
489 /* translators: %s: Number of approved bookings */
490 'approved' => _n_noop(
491 'Approved <span class="count">(%s)</span>',
492 'Approved <span class="count">(%s)</span>',
493 'timetics'
494 ),
495
496 /* translators: %s: Number of pending bookings */
497 'pending' => _n_noop(
498 'Pending <span class="count">(%s)</span>',
499 'Pending <span class="count">(%s)</span>',
500 'timetics'
501 ),
502 /* translators: %s: Number of cancelled bookings */
503 'cancel' => _n_noop(
504 'Cancelled <span class="count">(%s)</span>',
505 'Cancelled <span class="count">(%s)</span>',
506 'timetics'
507 ),
508 /* translators: %s: Number of completed bookings */
509 'completed' => _n_noop(
510 'Completed <span class="count">(%s)</span>',
511 'Completed <span class="count">(%s)</span>',
512 'timetics'
513 ),
514 );
515
516 // Register each status
517 foreach ( $label_counts as $status => $label_count ) {
518 register_post_status( $status, array(
519 'public' => true,
520 'exclude_from_search' => false,
521 'show_in_admin_all_list' => false,
522 'show_in_admin_status_list' => false,
523 'label_count' => $label_count,
524 ) );
525 }
526 }
527
528 /**
529 * Delete bookings if unpaid before the configured expiry window
530 * ('unpaid_booking_expiry_minutes' setting, default 5 mins)
531 *
532 * @return void
533 */
534 public function delete_booking_before_paid() {
535 $args = [
536 'post_type' => 'timetics-booking',
537 // Must be explicit: get_posts() defaults to 'publish', which
538 // bookings never use (custom statuses only), so omitting this
539 // matched nothing. Must NOT be 'any' either — a paid booking sits
540 // at 'approved' (default_booking_status), not 'completed', so
541 // restricting to pending/failed keeps paid bookings out for good.
542 'post_status' => [ 'pending', 'failed' ],
543 'numberposts' => -1,
544 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings by payment method
545 'meta_query' => array(
546 'relation' => 'OR',
547 array(
548 'key' => '_tt_booking_payment_method',
549 'value' => 'stripe',
550 'compare' => '=',
551 ),
552 array(
553 'key' => '_tt_booking_payment_method',
554 'value' => 'paypal',
555 'compare' => '=',
556 ),
557 array(
558 // Abandoned WooCommerce checkout — previously not covered.
559 'key' => '_tt_booking_payment_method',
560 'value' => 'woocommerce',
561 'compare' => '=',
562 ),
563 ),
564 ];
565
566 $bookings = get_posts( $args );
567
568 foreach ( $bookings as $booking ) {
569 $booking = new Booking( $booking->ID );
570
571 // Free ($0) bookings still get payment_method meta set from
572 // whichever gateway is globally active, so they'd otherwise look
573 // like an abandoned checkout. A free booking never needed payment
574 // — skip regardless of that meta.
575 if ( $booking->get_total() <= 0 ) {
576 continue;
577 }
578
579 // Re-check status: may have changed since the query ran above.
580 if ( in_array( $booking->get_status(), [ 'pending', 'failed' ], true ) && $this->is_booking_payment_expire( $booking ) ) {
581 $this->update_booking_entry( $booking->get_id() );
582 }
583 }
584 }
585
586 /**
587 * Check booking payment time expaire or not
588 *
589 * @param Object $booking
590 *
591 * @return bool
592 */
593 public function is_booking_payment_expire( $booking ) {
594 // post_date is site-local time (e.g. Asia/Dhaka), not UTC. Parsing it
595 // with no timezone made PHP treat it as UTC already, pushing expiry
596 // out by the site's UTC offset. post_date_gmt + explicit UTC fixes it.
597 $post = get_post( $booking->get_id() );
598 $booking_datetime = $post->post_date_gmt;
599
600 $booking_datetime_object = new \DateTime( $booking_datetime, new \DateTimeZone( 'UTC' ) );
601
602 // Admin-configurable via Settings > General; defaults to 5 minutes.
603 // Clamped to >= 5: the cleanup cron itself only runs every 5 minutes,
604 // so a lower value can't actually be honored, and 0/negative would
605 // expire bookings instantly.
606 $expiry_minutes = max( 5, (int) timetics_get_option( 'unpaid_booking_expiry_minutes', 5 ) );
607 $target_datetime = clone $booking_datetime_object;
608 $target_datetime->modify( "+{$expiry_minutes} minutes" );
609
610 $current_datetime = new \DateTime( 'now', new \DateTimeZone( 'UTC' ) );
611
612 // Check if the expiry window has passed
613 if ( $current_datetime > $target_datetime ) {
614 return true;
615 }
616
617 return false;
618 }
619
620 /**
621 * Update booking entry if payment time expire
622 *
623 * @param integer $booking_id
624 *
625 * @return void
626 */
627 public function update_booking_entry( $booking_id ) {
628 $booking = new Booking( $booking_id );
629
630 if ( ! $booking->is_booking() ) {
631 return false;
632 }
633
634 // Stripe: a customer may still be completing checkout when this
635 // expires. Cancel the PaymentIntent first so a late confirm can't
636 // charge the card after we release the slot. If Stripe refuses
637 // because it already succeeded, the money is real — leave the
638 // booking pending instead of cancelling a paid customer.
639 if ( 'stripe' === strtolower( (string) $booking->get_payment_method() ) ) {
640 $intent_id = $booking->get_stripe_payment_intent_id();
641
642 if ( '' !== $intent_id ) {
643 $stripe = new StripePayment();
644 $intent = $stripe->retrieve_payment_intent( $intent_id );
645
646 if ( is_array( $intent ) && isset( $intent['status'] ) && 'succeeded' === $intent['status'] ) {
647 return false;
648 }
649
650 $stripe->cancel_payment_intent( $intent_id );
651 }
652 }
653
654 // No permission check: only caller is the WP-Cron sweep, which has no
655 // current user (get_current_user_id() = 0) — the old manage_options
656 // check silently blocked this on every cron run.
657 //
658 // release_slot() is idempotent (_tt_booking_slot_released flag), so a
659 // slot already freed by a real payment is never double-released.
660 $booking->release_slot();
661
662 // PayPal still creates the calendar event before payment confirms
663 // (see api-booking.php $is_awaiting_online_payment). delete_event()
664 // no-ops if no event exists, so safe to call unconditionally.
665 $booking->delete_event();
666
667 // Flip to 'cancel' so the admin list stops showing this as "Pending"
668 // forever. update() directly, not the REST cancel action, so this
669 // stays silent — no cancellation email, no automation hook.
670 $booking->update(
671 [
672 'post_status' => 'cancel',
673 'cancel_reason' => __( 'Automatically cancelled — payment was not completed within the allowed time.', 'timetics' ),
674 ]
675 );
676 }
677
678 /**
679 * Change price for cart item
680 */
681 public function timetics_variation_ticket_total_price( $cart_object ) {
682 foreach ( $cart_object->cart_contents as $key => $value ) {
683 if ( ! empty( $value['booking_id'] ) && $value['booking_id'] !== 0 ) {
684 $order_total = !empty( $value['_timetics_variation_total_price'] ) ? $value['_timetics_variation_total_price'] : 0;
685
686 $value['data']->get_price();
687 $value['data']->set_price($order_total);
688 $value['data']->set_regular_price($order_total);
689 $value['data']->set_sale_price($order_total);
690
691 }
692 }
693
694 }
695
696 /**
697 * add booking_id as cart item data
698 *
699 * @param integer $booking_id
700 *
701 * @return void
702 */
703 public function timetics_add_cart_item_data( $cart_item_data ) {
704 $session_data = WC()->session->get( 'timetics_data' );
705 $booking_id = $session_data['booking_id'];
706 $booking = new Booking( $booking_id );
707 $total_price = floatval($booking->get_total()); // Ensure $total_price is a float
708
709 if ( is_array( $booking->get_seat() ) ) {
710 $total_quantity = count( $booking->get_seat() );
711 } else {
712 $total_quantity = 1;
713 }
714
715 if( ! empty( $booking_id ) && $total_price !== 0 ) {
716 $cart_item_data['_timetics_variation_total_quantity'] = $total_quantity;
717 $cart_item_data['booking_id'] = $booking_id;
718
719 // For balancing the cart item price
720 $cart_item_data['_timetics_variation_total_price'] = $total_price / $total_quantity;
721 }
722
723 return $cart_item_data;
724 }
725 }
726