customers->requireAdmin( $actor ); BookingValidator::fields( $input, array_merge( BookingService::PUBLIC_FIELDS, BookingService::ADMIN_FIELDS ) ); foreach(['customer_user_id','customer_member_id','external_contact_id'] as $field)if(array_key_exists($field,$input))$input[$field]=CatalogValidator::integer($input[$field],$field,1,PHP_INT_MAX,true); $old = $this->service->owned( $id, $actor ); $target = CatalogValidator::integer( $input['resource_id'] ?? $old['resource_id'], 'resource_id', 1, PHP_INT_MAX ); $locks = [ $old['resource_id'], $target ]; $result = $this->bookings->customerTransaction($id, $locks, function () use ( $id, $input, $actor, $target, $locks, $quote, $seriesEdit ): ?array { $b = $this->service->lockOwned( $id, $actor, $locks ); if ( array_key_exists( 'payment_method', $input ) && $input['payment_method'] !== $b['payment_method'] ) { CatalogValidator::invalid( 'payment_method', __( 'Use the payment actions to change a booking payment.', 'abece-booking' ) ); } if ( 'confirmed' !== $b['status'] ) { throw new CatalogException( 'abece_booking_edit_unavailable', __( 'Only confirmed bookings can be edited.', 'abece-booking' ), 409 ); } $start = Time::timestamp( Time::fromSql( $b['starts_at'] ) ); $fields = array_replace( [ 'resource_id' => $target, 'date' => $start->format( 'Y-m-d' ), 'start_time' => $start->format( 'H:i' ), 'start_offset' => $start->format( 'P' ), 'duration_minutes' => (int) ( ( Time::fromSql( $b['ends_at'] ) - $start->getTimestamp() ) / 60 ), 'participant_count' => $b['participant_count'], 'customer_type' => BookingCustomerService::type( $b ), 'customer_user_id' => $b['customer_user_id'], 'customer_member_id' => $b['customer_member_id'], 'booking_source' => $b['booking_source'], 'customer_note' => $b['customer_note'], 'admin_note' => $b['admin_note'] ], $input ); $fields['date'] = MembershipValidator::date( $fields['date'], 'date' ); // A different date may have a different DST offset; do not carry it unless explicitly selected. if ( isset( $input['date'] ) && $input['date'] !== $start->format( 'Y-m-d' ) && ! isset( $input['start_offset'] ) ) { $fields['start_offset'] = null; } // Existing web source is immutable unless an explicit admin customer change occurs. $customer = $this->customers->forEdit( $fields, $b, $fields['date'], $actor ); $identity = $this->service->external->prepare( $input, $customer, $b, ! $quote ); $previous = $this->bookings->participants( $id ); $existing_ids = array_values( array_map( static fn( $p ) => $p['member_id'], array_filter( $previous, static fn( $p ) => 'member' === $p['participant_type'] && null !== $p['member_id'] ) ) ); if ( ! array_key_exists( 'member_ids', $input ) ) { $fields['member_ids'] = $existing_ids; if ( array_key_exists( 'booker_participates', $input ) ) { $fields['member_ids'] = array_values( array_diff( $existing_ids, [ $customer['customer_member_id'] ] ) ); } else { $fields['booker_participates'] = false; } } elseif ( ! array_key_exists( 'booker_participates', $input ) ) { $fields['booker_participates'] = in_array( $customer['customer_member_id'], $existing_ids, true ); } $term_changed = $target !== $b['resource_id'] || $fields['date'] !== $start->format( 'Y-m-d' ) || substr( ScheduleValidator::clock( $fields['start_time'], 'start_time', false ), 0, 5 ) !== $start->format( 'H:i' ) || $fields['duration_minutes'] !== (int) ( ( Time::fromSql( $b['ends_at'] ) - $start->getTimestamp() ) / 60 ) || ( null !== $fields['start_offset'] && $fields['start_offset'] !== $start->format( 'P' ) ); $customer_changed = $customer !== array_intersect_key( $b, $customer ); $interval = $term_changed || $customer_changed ? $this->service->interval( $fields, $target, $fields['date'], $actor, $customer, $id, true, $quote ) : [ 'starts_at' => $b['starts_at'], 'ends_at' => $b['ends_at'] ]; $players = $this->participants->prepare( $fields, $customer, $fields['date'], $this->resources->find( $target )['capacity'], $existing_ids ); $notes = [ 'customer_note' => BookingValidator::note( $fields['customer_note'], 'customer_note' ), 'admin_note' => BookingValidator::note( $fields['admin_note'], 'admin_note' ) ]; $stamp = current_time( 'mysql', true ); $changed = false; $old_members = array_values( array_filter( $previous, static fn( $p ) => 'member' === $p['participant_type'] && null !== $p['member_id'] ) ); $old_ids = array_column( $old_members, 'member_id' ); $new_ids = array_column( $players['participants'], 'member_id' ); $added = array_values( array_diff( $new_ids, $old_ids ) ); $removed = array_values( array_diff( $old_ids, $new_ids ) ); $price_changes = []; if ( $term_changed ) { $price_changes[] = 'interval'; } if ( $customer['customer_user_id'] !== $b['customer_user_id'] || $customer['customer_member_id'] !== $b['customer_member_id'] ) { $price_changes[] = 'customer'; } if ( $players['participant_count'] !== $b['participant_count'] ) { $price_changes[] = 'participant_count'; } if ( $added || $removed ) { $price_changes[] = 'member_ids'; } $assessment=[]; if($price_changes||$customer_changed||($identity['external_contact_id']??null)!==($b['external_contact_id']??null)||$quote){ $this->service->lockPolicyMembers($customer,$players); $assessment=$this->service->policies($customer+$identity,$players,$interval,$target,true,$input,!$quote,$id); } $price = null; if ( $price_changes ) { $this->service->payments->requireEditablePrice( $b ); $interval = $this->service->interval( $fields, $target, $fields['date'], $actor, $customer, $id, true, $quote ); $price = $this->service->price( $target, $interval, $customer, $players ); } if ( $quote ) { unset($assessment['audits']);return ($price ?? ( $b['pricing_snapshot'] ? json_decode( $b['pricing_snapshot'], true ) : array_intersect_key( $b, array_flip( [ 'base_amount', 'discount_amount', 'final_amount', 'currency', 'participant_count' ] ) ) + [ 'segments' => [], 'member_benefits' => [], 'historical_price' => true ] ))+$assessment+['availability_warning'=>$interval['availability_warning']??null]; } $policy_change = []; if ( $target !== $b['resource_id'] ) { $old_policy = CancellationPolicyService::terms( $b ); $new_policy = $this->service->cancellationPolicies->snapshot( $target ); if ( ( $old_policy['policy_id'] ?? null ) !== $new_policy['policy_id'] ) { $policy_change = [ 'cancellation_policy_snapshot' => wp_json_encode( $new_policy ) ]; $this->service->recordHistory( $id, 'cancellation_policy_changed', $b['status'], $b['status'], $actor, $stamp, null, [ 'old_policy' => $old_policy, 'new_policy' => $new_policy ] ); } } if ( $target !== $b['resource_id'] || $interval['starts_at'] !== $b['starts_at'] || $interval['ends_at'] !== $b['ends_at'] ) { $this->service->recordHistory( $id, 'rescheduled', $b['status'], $b['status'], $actor, $stamp, null, [ 'old_resource_id' => $b['resource_id'], 'new_resource_id' => $target, 'old_starts_at' => $b['starts_at'], 'new_starts_at' => $interval['starts_at'], 'old_ends_at' => $b['ends_at'], 'new_ends_at' => $interval['ends_at'] ] ); $changed = true; } if ( $added || $removed || $players['participant_count'] !== $b['participant_count'] ) { $this->service->recordHistory( $id, 'participants_changed', $b['status'], $b['status'], $actor, $stamp, null, [ 'members_added' => $added, 'members_removed' => $removed, 'old_participant_count' => $b['participant_count'], 'new_participant_count' => $players['participant_count'], 'old_members' => $old_members, 'new_members' => $players['participants'] ] ); $changed = true; } $old_identity = array_intersect_key( $b, $identity ); if ( $old_identity !== $identity ) { $this->service->recordHistory( $id, 'external_contact_changed', $b['status'], $b['status'], $actor, $stamp, null, [ 'old_external' => $old_identity, 'new_external' => $identity ] ); $changed = true; } $old_customer = array_intersect_key( $b, $customer ); $old_notes = array_intersect_key( $b, $notes ); if ( $old_customer !== $customer || $old_notes !== $notes ) { $this->service->recordHistory( $id, 'updated', $b['status'], $b['status'], $actor, $stamp, null, [ 'old_customer' => $old_customer, 'new_customer' => $customer, 'old_notes' => $old_notes, 'new_notes' => $notes ] ); $changed = true; } if ( $price ) { $this->service->recordHistory( $id, 'repriced', $b['status'], $b['status'], $actor, $stamp, null, [ 'old_price' => array_intersect_key( $b, array_flip( [ 'base_amount', 'discount_amount', 'final_amount', 'currency' ] ) ), 'new_price' => $price, 'changed_fields' => $price_changes ] ); } if ( ! $changed ) { return null; } if($b['series_id']&&!$seriesEdit)$this->service->recordHistory($id,'single_occurrence_edited',$b['status'],$b['status'],$actor,$stamp,null,['series_id'=>$b['series_id'],'occurrence_key'=>$b['series_occurrence_key']]); $this->service->policyHistory($id,$assessment,$actor,$stamp); if(!empty($input['confirm_availability']))$this->service->recordHistory($id,'availability_overridden','confirmed','confirmed',$actor,$stamp); $this->bookings->removeParticipants( $id, $removed ); foreach ( $players['participants'] as $p ) { if ( in_array( $p['member_id'], $added, true ) ) { $this->bookings->addParticipant( $p + [ 'booking_id' => $id, 'created_at' => $stamp ] ); } } $this->bookings->update( $id, ($b['series_id']&&!$seriesEdit ? ['series_is_exception'=>1] : []) + $customer + $identity + $interval + $notes + $policy_change + ( $price ? $this->service->priceFields( $price ) + $this->service->payments->unpaidReprice( array_replace($b,['resource_id'=>$target]), $price ) : [] ) + [ 'resource_id' => $target, 'participant_count' => $players['participant_count'], 'updated_at' => $stamp ] ); if($price)$this->service->payments->refreshBankInstructions($b,$this->bookings->find($id),$actor); return null; } , $input ); return $quote ? $result : $this->service->detail( $id, $actor ); } private function removalFinancial( array $b, ?string $reason, int $actor ): array { if ( 'voided' === $b['status'] ) { throw new CatalogException( 'abece_booking_already_voided', __( 'This booking has already been removed.', 'abece-booking' ), 409 ); } $financial = $this->service->payments->settlement->calculate( $b ); if ( $financial['net_paid_amount'] > 0 || ( 'confirmed' !== $b['status'] && $financial['required_amount'] > 0 ) ) { BookingCancellationService::requireOverride( $actor ); if ( ! $reason ) { throw new CatalogException( 'abece_booking_void_payment_blocked', __( 'Enter a reason for removing this booking and waiving its financial obligation.', 'abece-booking' ), 409 ); } } return $financial; } public function voidQuote( int $id, array $input, int $actor ): array { $this->customers->requireAdmin( $actor ); BookingValidator::fields( $input, [ 'reason' ] ); $reason = BookingValidator::note( $input['reason'] ?? null, 'reason' ); $resource = $this->service->owned( $id, $actor )['resource_id']; return $this->bookings->customerTransaction( $id, [ $resource ], function () use ( $id, $actor, $resource, $reason ): array { $b = $this->service->lockOwned( $id, $actor, [ $resource ] ); return [ 'before_financial' => $this->removalFinancial( $b, $reason, $actor ), 'financial' => $this->service->payments->settlement->calculate( array_replace( $b, [ 'status' => 'voided' ] ) ) ]; } ); } public function void( int $id, array $input, int $actor ): array { $this->customers->requireAdmin( $actor ); BookingValidator::fields( $input, [ 'reason' ] ); $reason = BookingValidator::note( $input['reason'] ?? null, 'reason' ); $resource = $this->service->owned( $id, $actor )['resource_id']; $this->bookings->customerTransaction($id, [ $resource ], function () use ( $id, $actor, $resource, $reason ): void { $b = $this->service->lockOwned( $id, $actor, [ $resource ] ); $financial = $this->removalFinancial( $b, $reason, $actor ); if ( $reason ) { $reason = MembershipValidator::text( $reason, 'reason', 255 ); } $stamp = current_time( 'mysql', true ); $this->bookings->update( $id, [ 'status' => 'voided', 'updated_at' => $stamp ] ); $this->service->payments->settle( array_replace( $b, [ 'status' => 'voided' ] ), $actor, $reason ); $this->service->recordHistory( $id, 'voided', $b['status'], 'voided', $actor, $stamp, $reason, [ 'voided_at' => $stamp, 'voided_by_user_id' => $actor, 'old_financial' => $financial, 'settled' => $this->service->payments->settlement->calculate( array_replace( $b, [ 'status' => 'voided' ] ) ) ] ); } ); return $this->service->detail( $id, $actor ); } }