bookingContext($admin,$actor); if ( $admin ) { $this->customers->requireAdmin( $actor ); } BookingValidator::fields( $input, $admin ? array_merge( self::PUBLIC_FIELDS, self::ADMIN_FIELDS ) : self::PUBLIC_FIELDS ); $resource_id = CatalogValidator::integer( $input['resource_id'] ?? null, 'resource_id', 1, PHP_INT_MAX ); $date = MembershipValidator::date( $input['date'] ?? null, 'date' ); $lockCustomer = ($admin ? $this->customers->admin($input,$date,$actor) : ['customer_user_id'=>$actor]) + ['external_contact_id'=>$admin?CatalogValidator::integer($input['external_contact_id']??null,'external_contact_id',1,PHP_INT_MAX,true):null]; $id = $this->bookings->customerOperation($lockCustomer, fn() => $this->resources->transaction( [ $resource_id ], function () use ( $input, $actor, $admin, $resource_id, $date ): int { if(!$admin)\ABeCe\Booking\Edition\Modules::current()->onlineResource($resource_id,true); $customer = $admin ? $this->customers->admin( $input, $date, $actor ) : $this->customers->self( $actor, $date ); $interval = $this->interval( $input, $resource_id, $date, $actor, $customer, null, $admin ); $players = $this->participants->prepare($input,$customer,$date,$this->resources->find($resource_id)['capacity']); $this->eligibility->lockMembers($customer,$players); $assessment=$this->policies($customer+['external_contact_id'=>$input['external_contact_id']??null],$players,$interval,$resource_id,$admin,$input,true); $note = BookingValidator::note( $input['customer_note'] ?? null, 'customer_note' ); $admin_note = $admin ? BookingValidator::note( $input['admin_note'] ?? null, 'admin_note' ) : null; $price = $this->price( $resource_id, $interval, $customer, $players ); $stamp = current_time( 'mysql', true ); $identity = $admin ? $this->external->prepare( $input, $customer, null, true ) : []; $id = $this->bookings->insert( $customer + $identity + $interval + $this->priceFields( $price ) + [ 'cancellation_policy_snapshot' => wp_json_encode( $this->cancellationPolicies->snapshot( $resource_id ) ), 'reference' => 'SB-PENDING-' . bin2hex( random_bytes( 16 ) ), 'resource_id' => $resource_id, 'created_by_user_id' => $actor, 'participant_count' => $players['participant_count'], 'status' => 'confirmed', 'customer_note' => $note, 'admin_note' => $admin_note, 'confirmed_at' => $stamp, 'created_at' => $stamp, 'updated_at' => $stamp ] ); $this->bookings->update( $id, [ 'reference' => sprintf( 'SB-%s-%06d', substr( $date, 0, 4 ), $id ) ] ); foreach ( $players['participants'] as $p ) { $this->bookings->addParticipant( $p + [ 'booking_id' => $id, 'created_at' => $stamp ] ); } $this->recordHistory( $id, 'created', null, 'confirmed', $actor, $stamp, null, $customer + $identity + [ 'participant_count' => $players['participant_count'], 'members' => $players['participants'], 'price' => $price ] ); $this->policyHistory($id,$assessment,$actor,$stamp); if($admin&&!empty($input['confirm_availability']))$this->recordHistory($id,'availability_overridden','confirmed','confirmed',$actor,$stamp); $this->payments->created( $this->bookings->find( $id ), $input, $actor, $admin ); $this->external->used( $identity['external_contact_id'] ?? null ); return $id; } )); if ($notify) $this->mailer->send( $this->bookings->find( $id ), 'confirmed' ); return $this->detail( $id, $actor ); } /** Client price facts are never authoritative, including a stale or forged snapshot. */ public static function withoutPriceInput( array $input ): array { return array_diff_key( $input, array_flip( [ 'price', 'discount', 'base_amount', 'discount_amount', 'final_amount', 'currency', 'payment_status', 'pricing_snapshot' ] ) ); } public function price( int $resource_id, array $interval, array $customer, array $players ): array { return $this->pricing->calculate( $this->resources->find( $resource_id ), $interval['starts_at'], $interval['ends_at'], BookingCustomerService::type( $customer ), $players['participant_count'], array_column( $players['participants'], 'member_id' ) ); } public function priceFields( array $price ): array { return $this->pricing->fields( $price ); } public function quote( array $input, int $actor, bool $admin = false ): array { $input = self::withoutPriceInput( $input ); BookingValidator::login( $actor ); \ABeCe\Booking\Edition\Modules::current()->bookingContext($admin,$actor); if ( $admin ) { $this->customers->requireAdmin( $actor ); } BookingValidator::fields( $input, $admin ? array_merge( self::PUBLIC_FIELDS, self::ADMIN_FIELDS ) : self::PUBLIC_FIELDS ); $id = CatalogValidator::integer( $input['resource_id'] ?? null, 'resource_id', 1, PHP_INT_MAX ); $date = MembershipValidator::date( $input['date'] ?? null, 'date' ); return $this->resources->transaction( [ $id ], function () use ( $input, $actor, $admin, $id, $date ): array { if(!$admin)\ABeCe\Booking\Edition\Modules::current()->onlineResource($id,true); $customer = $admin ? $this->customers->admin( $input, $date, $actor ) : $this->customers->self( $actor, $date ); if ( $admin ) { $this->external->prepare( $input, $customer ); } $interval = $this->interval( $input, $id, $date, $actor, $customer, null, $admin, true ); $players = $this->participants->prepare( $input, $customer, $date, $this->resources->find( $id )['capacity'] ); $assessment=$this->policies($customer+['external_contact_id'=>$input['external_contact_id']??null],$players,$interval,$id,$admin); unset($assessment['audits']);return $this->price($id,$interval,$customer,$players)+$assessment+['availability_warning'=>$interval['availability_warning']??null]; } ); } public function policies(array $customer,array $players,array $interval,int $resource,bool $admin,array $input=[],bool $enforce=false,?int $exclude=null):array{return $this->eligibility->assess($customer,$players,$interval,$this->resources->find($resource),$admin,$input,$enforce,$exclude);} public function lockPolicyMembers(array $customer,array $players):void{$this->eligibility->lockMembers($customer,$players);} public function policyHistory(int $id,array $assessment,int $actor,string $stamp):void{foreach($assessment['audits']??[]as $audit)$this->recordHistory($id,$audit['event'],'confirmed','confirmed',$actor,$stamp,null,['actor_user_id'=>$actor,'overridden_at'=>$stamp]+$audit['metadata']);} /** Validated customer, already locked resource(s). Only internal admin paths can exclude a booking. */ public function interval( array $input, int $resource_id, string $date, int $actor, array $customer, ?int $exclude = null, bool $admin = false, bool $preview = false ): array { $clock = ScheduleValidator::clock( $input['start_time'] ?? null, 'start_time', false ); $duration = CatalogValidator::integer( $input['duration_minutes'] ?? null, 'duration_minutes', 1, 65535 ); $offset = $input['start_offset'] ?? null; if ( null !== $offset && ( ! is_string( $offset ) || ! preg_match( '/^[+-](?:[01][0-9]|2[0-3]):[0-5][0-9]$/D', $offset ) ) ) { CatalogValidator::invalid( 'start_offset', __( 'Select a valid timezone offset for this start time.', 'abece-booking' ) ); } $r = $this->resources->find( $resource_id ); if ( ! $r ) { throw new CatalogException( 'abece_booking_not_found', __( 'The item was not found.', 'abece-booking' ), 404 ); } $step = $r['booking_step_minutes']; if ( $step < 1 || $duration < $r['min_booking_minutes'] || (! $admin && $duration > $r['max_booking_minutes']) || $duration % $step !== 0 ) { CatalogValidator::invalid( 'duration_minutes', __( 'The duration must be within the resource limits and respect the booking step.', 'abece-booking' ) ); } if ( ( (int) substr( $clock, 0, 2 ) * 60 + (int) substr( $clock, 3, 2 ) ) % $step !== 0 ) { CatalogValidator::invalid( 'start_time', __( 'The start time must respect the resource booking step.', 'abece-booking' ) ); } $requested = $offset !== null ? new DateTimeImmutable($date.' '.$clock.$offset) : Time::local($date,$clock); if($requested && $requested->setTimezone(wp_timezone())->format('Y-m-d H:i:s') === $date.' '.$clock && $requested->getTimestamp() <= time())throw new CatalogException('abece_booking_booking_in_past',__('Bookings must start in the future.','abece-booking'),400,'start_time'); $available = $this->availability->available( $resource_id, $date, $actor, null, $exclude, $this->customers->eligible( $customer, $date ), $admin ); if ($admin) { if (!$r['is_active'] || !$r['is_bookable']) $this->conflict(); if (!$requested || $requested->setTimezone(wp_timezone())->format('Y-m-d H:i:s') !== $date.' '.$clock) CatalogValidator::invalid('start_time', __('This local time does not exist.', 'abece-booking')); $result = ['starts_at'=>Time::sql($requested), 'ends_at'=>Time::sql($requested->modify('+'.$duration.' minutes'))]; if ($this->bookings->collides($resource_id,$result['starts_at'],$result['ends_at'],$exclude)) $this->conflict(); $matching = array_filter($available['intervals'], static fn($s)=>strtotime($s['starts_at'])===$requested->getTimestamp() && $duration <= $s['max_duration_minutes']); if (!$matching) { $warning = __('This occurrence is blocked or outside the available opening hours. Explicit confirmation is required.', 'abece-booking'); if ($preview) return $result+['availability_warning'=>$warning]; if (!CatalogValidator::boolean($input['confirm_availability']??false,'confirm_availability')) throw new CatalogException('abece_booking_availability_confirmation_required',$warning,409); } return $result; } if(isset($available['booking_restriction_code'])&&!$available['intervals'])throw new CatalogException($available['booking_restriction_code'],$available['booking_restriction_message'],409); $slots=$available['intervals']; $matching = array_values( array_filter( $slots, static fn( $s ) => substr( $s['starts_at'], 11, 8 ) === $clock && ( null === $offset || substr( $s['starts_at'], -6 ) === $offset ) ) ); if ( count( $matching ) > 1 ) { CatalogValidator::invalid( 'start_offset', __( 'This time occurs twice. Select its timezone offset.', 'abece-booking' ) ); } if ( ! $matching || $duration < $matching[0]['min_duration_minutes'] || $duration > $matching[0]['max_duration_minutes'] ) { $this->conflict(); } $start = new DateTimeImmutable( $matching[0]['starts_at'] ); $end = $start->modify( '+' . $duration . ' minutes' ); $result = [ 'starts_at' => Time::sql( $start ), 'ends_at' => Time::sql( $end ) ]; if ( $this->bookings->collides( $resource_id, $result['starts_at'], $result['ends_at'], $exclude ) ) { $this->conflict(); } return $result; } /** Potential starts until an admin has selected a customer. Actual writes still recheck that customer. */ public function adminBookingTimes(array $input,int $actor): array { $this->customers->requireAdmin($actor);$date=MembershipValidator::date($input['date']??null,'date'); $id=CatalogValidator::integer($input['resource_id']??null,'resource_id',1,PHP_INT_MAX);$exclude=CatalogValidator::integer($input['booking_id']??null,'booking_id',1,PHP_INT_MAX,true); if($exclude)$this->owned($exclude,$actor);$slots=[]; foreach([false,true] as $member)foreach($this->availability->available($id,$date,$actor,null,$exclude,$member,true)['intervals'] as $slot)$slots[$slot['starts_at']]=$slot; $slots=array_values($slots);usort($slots,static fn($a,$b)=>strtotime($a['starts_at'])<=>strtotime($b['starts_at'])); return ['resource_id'=>$id,'date'=>$date,'timezone'=>wp_timezone()->getName(),'customer_required'=>true,'intervals'=>$slots]; } public function adminAvailability( array $input, int $actor ): array { $this->customers->requireAdmin( $actor ); $date = MembershipValidator::date( $input['date'] ?? null, 'date' ); $resource_id = CatalogValidator::integer( $input['resource_id'] ?? null, 'resource_id', 1, PHP_INT_MAX ); $exclude = CatalogValidator::integer( $input['booking_id'] ?? null, 'booking_id', 1, PHP_INT_MAX, true ); if ( $exclude ) { $b = $this->owned( $exclude, $actor ); $customer = $this->customers->forEdit( $input + [ 'customer_user_id' => null, 'customer_member_id' => null, 'booking_source' => $b['booking_source'] ], $b, $date, $actor ); } else { $customer = $this->customers->admin( $input, $date, $actor ); } return $this->availability->available( $resource_id, $date, $actor, null, $exclude, $this->customers->eligible( $customer, $date ), true ); } public function detail( int|string $key, int $viewer ): array { $b = $this->owned( $key, $viewer ); $result = $this->summary( $b, $viewer ); $result['participants'] = $this->bookings->participants( $b['id'] ); $result['history'] = $this->bookings->history( $b['id'] ); // Audit metadata may contain internal admin notes. Public customers see event/actor/time only. if ( ! user_can( $viewer, Capabilities::MANAGE_BOOKINGS ) ) { foreach ( $result['history'] as &$event ) { $event['metadata'] = null;if(in_array($event['event_type'],['usage_limit_override','customer_restriction_override'],true))$event['performed_by_user_id']=null; } unset( $event ); } if ( user_can( $viewer, Capabilities::MANAGE_BOOKINGS ) ) { foreach ( $result['history'] as &$event ) { $event['performed_by_name'] = CancellationActor::name($event['metadata'],$event['performed_by_user_id'],get_userdata($event['performed_by_user_id'])->display_name??null); } unset( $event ); } if(user_can($viewer,Capabilities::MANAGE_BOOKINGS))foreach($result['history'] as $event)if($event['event_type']==='cancelled'){$result['cancelled_at']??=$event['created_at'];$result['cancellation_reason']??=$event['description'];$result['cancelled_by_name']=$event['performed_by_name'];$result['cancelled_by_role']=CancellationActor::role($event['metadata']);} if(user_can($viewer,Capabilities::MANAGE_BOOKINGS))$result['customer_restriction_warnings']=$this->eligibility->restrictionWarnings($b,['participants'=>$result['participants']],['starts_at'=>$b['starts_at'],'ends_at'=>$b['ends_at']]); $result['payment_transactions'] = $this->payments->history( $b['id'], user_can( $viewer, Capabilities::MANAGE_BOOKINGS ) ); $result['payment_instructions'] = $this->payments->instructions($b); $result['notifications'] = user_can($viewer,Capabilities::MANAGE_BOOKINGS) ? $this->notifications->forBooking( $b['id'] ) : []; return $result; } public function owned( int|string $key, int $viewer ): array { BookingValidator::login( $viewer ); $b = $this->bookings->find( $key ); return $this->checkOwner( $b, $viewer ); } /** The resource may have moved while waiting; do not mutate it under an obsolete resource lock. */ public function lockOwned( int $id, int $viewer, array $locked_resources ): array { $b = $this->checkOwner( $this->bookings->lock( $id ), $viewer ); if ( ! in_array( $b['resource_id'], $locked_resources, true ) ) { throw new CatalogException( 'abece_booking_booking_changed', __( 'This booking changed while you were editing it. Reload and try again.', 'abece-booking' ), 409 ); } return $b; } private function checkOwner( ?array $b, int $viewer ): array { if ( ! $b ) { throw new CatalogException( 'abece_booking_not_found', __( 'The booking was not found.', 'abece-booking' ), 404 ); } if ( $b['customer_user_id'] !== $viewer && ! user_can( $viewer, Capabilities::MANAGE_BOOKINGS ) ) { throw new CatalogException( 'abece_booking_booking_forbidden', __( 'You do not have permission to access this booking.', 'abece-booking' ), 403 ); } return $b; } public function list( int $viewer, bool $admin, string $filter, int $page, int $per_page ): array { BookingValidator::login( $viewer ); if ( $admin ) { $this->customers->requireAdmin( $viewer ); } CatalogValidator::choice( $filter, 'filter', $admin ? [ 'all', 'upcoming', 'past', 'cancelled', 'no_show', 'voided' ] : [ 'upcoming' ] ); $result = $this->bookings->list( $admin ? null : $viewer, $filter, $page, $per_page, current_time( 'mysql', true ) ); $result['items'] = array_map( fn( $b ) => $this->summary( $b, $viewer ), $result['items'] ); return $result; } public function options( int $viewer, ?string $date = null, bool $admin=false ): array { BookingValidator::login( $viewer ); \ABeCe\Booking\Edition\Modules::current()->bookingContext($admin,$viewer); $date ??= wp_date( 'Y-m-d' ); $this->customers->day( $date ); $member = $this->customers->isMember( $viewer, $date ); $resources = $this->resources->bookingOptions($admin); $sports = []; foreach ( $resources as &$r ) { $sport = $r['sport_id'] ? $this->sports->find( $r['sport_id'] ) : null; $r['sport_name'] = $sport['name'] ?? null; if ( $sport ) { $sports[ $sport['id'] ] = [ 'id' => $sport['id'], 'name' => $sport['name'] ]; } } unset( $r ); return [ 'sports' => array_values( $sports ), 'resources' => $resources, 'timezone' => wp_timezone()->getName(), 'today' => wp_date( 'Y-m-d' ), 'date' => $date, 'memberships_enabled' => MembershipModeService::enabled(), 'current_user' => [ 'is_member' => $member, 'member_id' => $this->customers->memberId($viewer,$date), 'profile_complete' => $this->profiles->get( $viewer )['profile_complete'], 'can_search_members' => MembershipModeService::enabled() && ($member || user_can( $viewer, Capabilities::MANAGE_BOOKINGS )), 'can_manage_credit' => user_can( $viewer, Capabilities::MANAGE_CREDIT ), 'can_manage_bookings' => user_can( $viewer, Capabilities::MANAGE_BOOKINGS ) ] ]; } public function recordHistory( int $id, string $event, ?string $old, string $new, int $actor, string $stamp, ?string $reason = null, ?array $metadata = null ): void { /* translators: %d: WordPress user ID. */ if($event==='cancelled')$metadata=($metadata??[])+['actor_display_name'=>get_userdata($actor)->display_name??sprintf(__('User #%d','abece-booking'),$actor),'actor_role'=>user_can($actor,Capabilities::MANAGE_BOOKINGS)?'admin':'client']; $this->bookings->appendHistory( [ 'booking_id' => $id, 'event_type' => $event, 'old_status' => $old, 'new_status' => $new, 'performed_by_type' => 'wp_user', 'performed_by_user_id' => $actor, 'description' => $reason, 'metadata' => null === $metadata ? null : wp_json_encode( $metadata ), 'created_at' => $stamp ] ); } private function summary( array $b, int $viewer ): array { $admin = user_can( $viewer, Capabilities::MANAGE_BOOKINGS ); $players = $this->bookings->participants( $b['id'] ); $input = $this->participants->input( $b, $players ); $snapshot = $b['pricing_snapshot'] ? json_decode( $b['pricing_snapshot'], true ) : null; if ( is_array( $snapshot ) ) { foreach ( [ 'eligible_member_count', 'non_member_count' ] as $field ) { $input[ $field ] = $snapshot[ $field ]; } } $b += $input; $b['financial'] = $this->payments->settlement->calculate( $b ); $b['payment_status'] = $b['financial']['payment_status']; $b['can_override_cancellation_fee'] = $admin && user_can( $viewer, Capabilities::OVERRIDE_CANCELLATION_FEES ); if ( ! $admin ) { unset( $b['admin_note'], $b['external_contact_id'], $b['external_customer_label'], $b['external_contact_snapshot'] ); } $b['resource_name'] = $this->resources->find( $b['resource_id'] )['name'] ?? ''; $member = $b['customer_member_id'] ? $this->members->find( $b['customer_member_id'] ) : null; $b['booker_name'] = $member ? trim( $member['first_name'] . ' ' . $member['last_name'] ) : ( $b['customer_user_id'] ? ( get_userdata( $b['customer_user_id'] )->display_name ?? '' ) : ( $b['external_customer_label'] ?? $b['reference'] ) ); $b['created_by_name'] = get_userdata( $b['created_by_user_id'] )->display_name ?? ''; $b['starts_at_local'] = Time::iso( Time::fromSql( $b['starts_at'] ) ); $b['ends_at_local'] = Time::iso( Time::fromSql( $b['ends_at'] ) ); $b['timezone'] = wp_timezone()->getName(); $b['duration_minutes'] = ( Time::fromSql( $b['ends_at'] ) - Time::fromSql( $b['starts_at'] ) ) / 60; $b['booker_participates'] = $b['customer_member_id'] && in_array( $b['customer_member_id'], array_column( $players, 'member_id' ), true ); $b['can_cancel'] = 'confirmed' === $b['status'] && ( $admin || Time::fromSql( $b['starts_at'] ) > time() ); $b['can_mark_no_show'] = $admin && 'confirmed' === $b['status'] && Time::fromSql( $b['starts_at'] ) <= time(); $b['can_edit'] = $admin && 'confirmed' === $b['status']; $b['can_void'] = $admin && 'voided' !== $b['status']; $b['mark_paid_methods'] = $admin ? array_merge( [ 'cash', 'manual' ], $this->payments->settings()['enable_pay_on_site'] ? [ 'pay_on_site' ] : [] ) : []; $b['can_mark_paid'] = $admin && in_array( $b['status'], [ 'confirmed', 'cancelled', 'no_show' ], true ) && $b['financial']['outstanding_amount'] > 0 && $b['financial']['paid_amount'] === 0 && in_array($b['payment_method'],['pay_on_site','bank_transfer','qr_payment'],true); $b['can_refund'] = $admin && $b['financial']['refund_due'] > 0; $b['can_edit_price'] = ! in_array( $b['payment_status'], [ 'paid', 'refunded', 'partially_paid', 'partially_refunded' ], true ) && ! $this->payments->hasActivity( $b['id'] ); $b['can_void'] = $b['can_void'] && ( $b['can_override_cancellation_fee'] || ( $b['financial']['net_paid_amount'] === 0 && ( 'confirmed' === $b['status'] || $b['financial']['required_amount'] === 0 ) ) ); $b['cancelled_by_name'] = $b['cancelled_by_user_id'] ? CancellationActor::name(null,$b['cancelled_by_user_id'],get_userdata($b['cancelled_by_user_id'])->display_name??null) : null; return $b; } private function conflict(): never { throw new CatalogException( 'abece_booking_booking_conflict', __( 'This time is no longer available. Please select another time.', 'abece-booking' ), 409 ); } }