|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * Gravity Perks // GP Bookings // Daily Service Booking Limit |
| 4 | + * https://gravitywiz.com/documentation/gravity-forms-bookings/ |
| 5 | + * |
| 6 | + * Enforce a daily capacity for one or more booking services. When the selected services |
| 7 | + * meet the limit, dates are marked as unavailable in the calendar and submissions are blocked. |
| 8 | + * List multiple service IDs to share the cap between them. |
| 9 | + * |
| 10 | + * Instructions: |
| 11 | + * |
| 12 | + * 1. Install this snippet by following the steps here: |
| 13 | + * https://gravitywiz.com/documentation/how-do-i-install-a-snippet/ |
| 14 | + * |
| 15 | + * 2. Update the configuration at the bottom of the snippet: |
| 16 | + * - List the GP Bookings service IDs that should share the daily cap in service_ids. |
| 17 | + * - Adjust daily_limit to the maximum combined bookings allowed per day. |
| 18 | + */ |
| 19 | +class GPB_Daily_Service_Limit { |
| 20 | + |
| 21 | + private $service_ids; |
| 22 | + private $daily_limit; |
| 23 | + |
| 24 | + public function __construct( array $args ) { |
| 25 | + $args = wp_parse_args( $args, array( |
| 26 | + 'service_ids' => array(), |
| 27 | + 'daily_limit' => 10, |
| 28 | + )); |
| 29 | + |
| 30 | + $this->service_ids = array_map( 'intval', (array) $args['service_ids'] ); |
| 31 | + $this->daily_limit = (int) $args['daily_limit']; |
| 32 | + |
| 33 | + if ( empty( $this->service_ids ) || $this->daily_limit < 1 ) { |
| 34 | + return; |
| 35 | + } |
| 36 | + |
| 37 | + // Guard creation and REST availability so the cap is enforced everywhere. |
| 38 | + add_action( 'gpb_before_booking_created', array( $this, 'guard_booking_creation' ), 10, 2 ); |
| 39 | + add_filter( 'rest_post_dispatch', array( $this, 'filter_rest_availability' ), 10, 3 ); |
| 40 | + } |
| 41 | + |
| 42 | + public function guard_booking_creation( array $booking_data, $bookable ) { |
| 43 | + if ( ! ( $bookable instanceof \GP_Bookings\Service ) || ! $this->is_tracked_service( $bookable->get_id() ) ) { |
| 44 | + return; |
| 45 | + } |
| 46 | + |
| 47 | + $date = $this->normalize_booking_date( |
| 48 | + $booking_data['start_datetime'] ?? '', |
| 49 | + $booking_data['end_datetime'] ?? ( $booking_data['start_datetime'] ?? '' ), |
| 50 | + $bookable |
| 51 | + ); |
| 52 | + if ( ! $date ) { |
| 53 | + return; |
| 54 | + } |
| 55 | + |
| 56 | + $quantity = isset( $booking_data['quantity'] ) ? max( 1, (int) $booking_data['quantity'] ) : 1; |
| 57 | + |
| 58 | + if ( $this->exceeds_limit( array( $date ), $quantity ) ) { |
| 59 | + // Stop the submission when the shared limit would be exceeded. |
| 60 | + throw new \GP_Bookings\Exceptions\CapacityException( __( 'We are fully booked for that day. Please choose another date.', 'gp-bookings' ) ); |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + public function filter_rest_availability( $response, $server, $request ) { |
| 65 | + if ( ! ( $request instanceof \WP_REST_Request ) || 'GET' !== $request->get_method() ) { |
| 66 | + return $response; |
| 67 | + } |
| 68 | + |
| 69 | + $route = ltrim( $request->get_route(), '/' ); |
| 70 | + if ( 'gp-bookings/v1/availability/days' !== $route ) { |
| 71 | + return $response; |
| 72 | + } |
| 73 | + |
| 74 | + $service_id = (int) $request->get_param( 'serviceId' ); |
| 75 | + if ( ! $service_id || ! $this->is_tracked_service( $service_id ) ) { |
| 76 | + return $response; |
| 77 | + } |
| 78 | + |
| 79 | + if ( is_wp_error( $response ) || ! ( $response instanceof \WP_HTTP_Response ) ) { |
| 80 | + return $response; |
| 81 | + } |
| 82 | + |
| 83 | + $data = $response->get_data(); |
| 84 | + if ( empty( $data['days'] ) || ! is_array( $data['days'] ) ) { |
| 85 | + return $response; |
| 86 | + } |
| 87 | + |
| 88 | + $dates = array_keys( $data['days'] ); |
| 89 | + if ( ! $dates ) { |
| 90 | + return $response; |
| 91 | + } |
| 92 | + |
| 93 | + $exclude_booking_id = (int) $request->get_param( 'exclude_booking_id' ); |
| 94 | + $exclude_booking_id = $exclude_booking_id > 0 ? $exclude_booking_id : null; |
| 95 | + |
| 96 | + $totals = $this->get_daily_totals( $dates, $exclude_booking_id ); |
| 97 | + |
| 98 | + foreach ( $data['days'] as $date => &$day ) { |
| 99 | + if ( ( $totals[ $date ] ?? 0 ) >= $this->daily_limit ) { |
| 100 | + // Flag the day as unavailable in the REST response. |
| 101 | + $day['available'] = false; |
| 102 | + $day['status'] = 'booked'; |
| 103 | + $day['remainingSlots'] = 0; |
| 104 | + } |
| 105 | + } |
| 106 | + unset( $day ); |
| 107 | + |
| 108 | + $response->set_data( $data ); |
| 109 | + return $response; |
| 110 | + } |
| 111 | + |
| 112 | + private function exceeds_limit( array $dates, int $incoming_quantity = 0, $exclude_booking_id = null ): bool { |
| 113 | + $dates = array_filter( array_unique( $dates ) ); |
| 114 | + $totals = $dates ? $this->get_daily_totals( $dates, $exclude_booking_id ) : array(); |
| 115 | + |
| 116 | + foreach ( $dates as $date ) { |
| 117 | + $existing_total = $totals[ $date ] ?? 0; |
| 118 | + if ( $existing_total + $incoming_quantity > $this->daily_limit ) { |
| 119 | + return true; |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + return false; |
| 124 | + } |
| 125 | + |
| 126 | + private function get_daily_totals( array $dates, $exclude_booking_id = null ): array { |
| 127 | + $dates = array_values( array_filter( array_unique( array_map( 'trim', $dates ) ) ) ); |
| 128 | + if ( ! $dates ) { |
| 129 | + return array(); |
| 130 | + } |
| 131 | + |
| 132 | + $start_datetime = min( $dates ) . ' 00:00:00'; |
| 133 | + $end_datetime = max( $dates ) . ' 23:59:59'; |
| 134 | + |
| 135 | + return $this->get_totals_for_range( $start_datetime, $end_datetime, $exclude_booking_id ); |
| 136 | + } |
| 137 | + |
| 138 | + private function get_totals_for_range( string $start_datetime, string $end_datetime, $exclude_booking_id = null ): array { |
| 139 | + if ( '' === $start_datetime || '' === $end_datetime ) { |
| 140 | + return array(); |
| 141 | + } |
| 142 | + |
| 143 | + $bookings = \GP_Bookings\Queries\Booking_Query::get_bookings_in_range( |
| 144 | + $start_datetime, |
| 145 | + $end_datetime, |
| 146 | + array( |
| 147 | + 'object_id' => $this->service_ids, |
| 148 | + 'object_type' => 'service', |
| 149 | + 'status' => array( 'pending', 'confirmed' ), |
| 150 | + 'exclude_service_with_resource' => false, |
| 151 | + 'exclude_booking_id' => $exclude_booking_id, |
| 152 | + ) |
| 153 | + ); |
| 154 | + |
| 155 | + if ( ! $bookings ) { |
| 156 | + return array(); |
| 157 | + } |
| 158 | + |
| 159 | + $totals = array(); |
| 160 | + |
| 161 | + foreach ( $bookings as $booking ) { |
| 162 | + try { |
| 163 | + $service_id = (int) $booking->get_service_id(); |
| 164 | + } catch ( \Throwable $e ) { |
| 165 | + continue; |
| 166 | + } |
| 167 | + |
| 168 | + if ( ! $this->is_tracked_service( $service_id ) ) { |
| 169 | + continue; |
| 170 | + } |
| 171 | + |
| 172 | + $service = \GP_Bookings\Service::get( $service_id ); |
| 173 | + if ( ! $service ) { |
| 174 | + continue; |
| 175 | + } |
| 176 | + |
| 177 | + $date = $this->normalize_booking_date( |
| 178 | + $booking->get_start_datetime(), |
| 179 | + $booking->get_end_datetime(), |
| 180 | + $service |
| 181 | + ); |
| 182 | + |
| 183 | + if ( ! $date ) { |
| 184 | + continue; |
| 185 | + } |
| 186 | + |
| 187 | + $totals[ $date ] = ( $totals[ $date ] ?? 0 ) + (int) $booking->get_quantity(); |
| 188 | + } |
| 189 | + |
| 190 | + return $totals; |
| 191 | + } |
| 192 | + |
| 193 | + private function is_tracked_service( int $service_id ): bool { |
| 194 | + return in_array( $service_id, $this->service_ids, true ); |
| 195 | + } |
| 196 | + |
| 197 | + /** |
| 198 | + * Normalize booking date. |
| 199 | + * |
| 200 | + * @return string|null Returns the normalized start date (Y-m-d) or null on failure. |
| 201 | + */ |
| 202 | + private function normalize_booking_date( $start, $end, $bookable ) { |
| 203 | + try { |
| 204 | + $normalized = \GP_Bookings\Booking::normalize_datetime_values( $start, $end, $bookable ); |
| 205 | + } catch ( \Throwable $e ) { |
| 206 | + return null; |
| 207 | + } |
| 208 | + |
| 209 | + return $normalized['start']->format( 'Y-m-d' ); |
| 210 | + } |
| 211 | + |
| 212 | +} |
| 213 | + |
| 214 | +# Configuration |
| 215 | +new GPB_Daily_Service_Limit( |
| 216 | + array( |
| 217 | + 'service_ids' => array( 123, 456 ), // Enter one or more service IDs |
| 218 | + 'daily_limit' => 10, // Enter the daily limit |
| 219 | + ) |
| 220 | +); |
0 commit comments