|null $meta Meta. * @param DateTimeImmutable $created_at Created UTC. * @param DateTimeImmutable $updated_at Updated UTC. * @param DateTimeImmutable|null $deleted_at Soft delete. * * @throws \Bookline\Domain\Shared\DomainException When invariants fail. */ public function __construct( private ?int $id, private Uuid $uuid, private ?int $order_id, private int $customer_id, private int $service_id, private ?int $duration_id, private ?int $agent_id, private ?int $location_id, private DateTimeImmutable $start_utc, private DateTimeImmutable $end_utc, private DateTimeImmutable $buffer_start_utc, private DateTimeImmutable $buffer_end_utc, private TimeZoneId $timezone, private DateTimeImmutable $start_local, private BookingStatus $status, private int $attendees, private Money $price, private ?string $notes, private ?string $internal_notes, private ?string $manage_token_hash, private ?DateTimeImmutable $cancelled_at, private ?CancelledBy $cancelled_by, private ?string $cancel_reason, private ?int $rescheduled_from, private ?array $meta, private DateTimeImmutable $created_at, private DateTimeImmutable $updated_at, private ?DateTimeImmutable $deleted_at = null, ) { $this->assert_invariants(); } /** * Validate entity invariants. * * @throws DomainException On invariant breach. */ private function assert_invariants(): void { if ( $this->customer_id < 1 || $this->service_id < 1 ) { throw new DomainException( 'booking.fk', 'customer_id and service_id must be positive.' ); } if ( $this->end_utc <= $this->start_utc ) { throw new DomainException( 'booking.range', 'end_utc must be after start_utc.' ); } if ( $this->buffer_start_utc > $this->start_utc ) { throw new DomainException( 'booking.buffer_start', 'buffer_start_utc must be on or before start_utc.' ); } if ( $this->buffer_end_utc < $this->end_utc ) { throw new DomainException( 'booking.buffer_end', 'buffer_end_utc must be on or after end_utc.' ); } if ( $this->attendees < 1 ) { throw new DomainException( 'booking.attendees', 'attendees must be at least 1.' ); } if ( $this->price->is_negative() ) { throw new DomainException( 'booking.price_negative', 'Booking price must not be negative.' ); } if ( BookingStatus::Cancelled === $this->status && null === $this->cancelled_at ) { throw new DomainException( 'booking.cancel_timestamp', 'Cancelled bookings require cancelled_at.' ); } } /** * Database id. * * @since 0.1.0 */ public function id(): ?int { return $this->id; } /** * Typed booking id. * * @since 0.1.0 */ public function booking_id(): ?BookingId { return null === $this->id ? null : new BookingId( $this->id ); } /** * Public UUID. * * @since 0.1.0 */ public function uuid(): Uuid { return $this->uuid; } /** * Order id. * * @since 0.1.0 */ public function order_id(): ?int { return $this->order_id; } /** * Customer id. * * @since 0.1.0 */ public function customer_id(): int { return $this->customer_id; } /** * Service id. * * @since 0.1.0 */ public function service_id(): int { return $this->service_id; } /** * Duration id. * * @since 0.1.0 */ public function duration_id(): ?int { return $this->duration_id; } /** * Agent id. * * @since 0.1.0 */ public function agent_id(): ?int { return $this->agent_id; } /** * Location id. * * @since 0.1.0 */ public function location_id(): ?int { return $this->location_id; } /** * Start utc. * * @since 0.1.0 */ public function start_utc(): DateTimeImmutable { return $this->start_utc; } /** * End utc. * * @since 0.1.0 */ public function end_utc(): DateTimeImmutable { return $this->end_utc; } /** * Buffer start utc. * * @since 0.1.0 */ public function buffer_start_utc(): DateTimeImmutable { return $this->buffer_start_utc; } /** * Buffer end utc. * * @since 0.1.0 */ public function buffer_end_utc(): DateTimeImmutable { return $this->buffer_end_utc; } /** * Timezone. * * @since 0.1.0 */ public function timezone(): TimeZoneId { return $this->timezone; } /** * Start local. * * @since 0.1.0 */ public function start_local(): DateTimeImmutable { return $this->start_local; } /** * Status. * * @since 0.1.0 */ public function status(): BookingStatus { return $this->status; } /** * Attendees. * * @since 0.1.0 */ public function attendees(): int { return $this->attendees; } /** * Price. * * @since 0.1.0 */ public function price(): Money { return $this->price; } /** * Notes. * * @since 0.1.0 */ public function notes(): ?string { return $this->notes; } /** * Internal notes. * * @since 0.1.0 */ public function internal_notes(): ?string { return $this->internal_notes; } /** * Replace staff-only notes. * * @param string|null $notes Notes, or null to clear. * @param DateTimeImmutable $when Instant UTC. * * @since 0.1.0 */ public function set_internal_notes( ?string $notes, DateTimeImmutable $when ): void { $trimmed = null === $notes ? null : trim( $notes ); $this->internal_notes = ( null === $trimmed || '' === $trimmed ) ? null : $trimmed; $this->updated_at = $when; } /** * Manage token hash. * * @since 0.1.0 */ public function manage_token_hash(): ?string { return $this->manage_token_hash; } /** * Cancelled at. * * @since 0.1.0 */ public function cancelled_at(): ?DateTimeImmutable { return $this->cancelled_at; } /** * Cancelled by. * * @since 0.1.0 */ public function cancelled_by(): ?CancelledBy { return $this->cancelled_by; } /** * Cancel reason. * * @since 0.1.0 */ public function cancel_reason(): ?string { return $this->cancel_reason; } /** * Rescheduled from. * * @since 0.1.0 */ public function rescheduled_from(): ?int { return $this->rescheduled_from; } /** * Opaque meta payload. * * @return array|null * * @since 0.1.0 */ public function meta(): ?array { return $this->meta; } /** * Created at. * * @since 0.1.0 */ public function created_at(): DateTimeImmutable { return $this->created_at; } /** * Updated at. * * @since 0.1.0 */ public function updated_at(): DateTimeImmutable { return $this->updated_at; } /** * Deleted at. * * @since 0.1.0 */ public function deleted_at(): ?DateTimeImmutable { return $this->deleted_at; } /** * Whether soft-deleted. * * @since 0.1.0 */ public function is_deleted(): bool { return null !== $this->deleted_at; } /** * Statuses that occupy the agent/location busy window. * * @since 0.1.0 */ public function occupies_slot(): bool { return match ( $this->status ) { BookingStatus::Cancelled, BookingStatus::Rescheduled => false, default => null === $this->deleted_at, }; } /** * Assign the database id after insert. * * @param int $id New id. * * @throws \Bookline\Domain\Shared\DomainException When id is already set or invalid. * * @since 0.1.0 */ public function assign_id( int $id ): void { if ( null !== $this->id ) { throw new DomainException( 'booking.id_assigned', 'Booking id is already assigned.' ); } $this->id = $id; } /** * Attach the parent order after the order row is persisted. * * @param int $order_id Order id. * @param DateTimeImmutable $when Instant UTC. * * @throws DomainException When order id is invalid. * * @since 0.1.0 */ public function attach_order( int $order_id, DateTimeImmutable $when ): void { if ( $order_id < 1 ) { throw new DomainException( 'booking.order_id', 'order_id must be positive.' ); } $this->order_id = $order_id; $this->updated_at = $when; } /** * Cancel the booking. * * @param CancelledBy $by Actor. * @param string|null $reason Reason. * @param DateTimeImmutable $when Instant UTC. * * @throws DomainException When already cancelled or not occupying. * * @since 0.1.0 */ public function cancel( CancelledBy $by, ?string $reason, DateTimeImmutable $when ): void { if ( BookingStatus::Cancelled === $this->status ) { throw new DomainException( 'booking.already_cancelled', 'Booking is already cancelled.' ); } $this->status = BookingStatus::Cancelled; $this->cancelled_at = $when; $this->cancelled_by = $by; $this->cancel_reason = $reason; $this->updated_at = $when; $this->clear_hold_meta(); } /** * Change status without cancel/reschedule semantics. * * @param BookingStatus $status New status. * @param DateTimeImmutable $when Instant UTC. * * @throws DomainException When transition is invalid. * * @since 0.1.0 */ public function change_status( BookingStatus $status, DateTimeImmutable $when ): void { if ( BookingStatus::Cancelled === $status || BookingStatus::Rescheduled === $status ) { throw new DomainException( 'booking.status_use_handler', 'Use cancel or reschedule handlers for this status.' ); } if ( BookingStatus::Cancelled === $this->status || BookingStatus::Rescheduled === $this->status ) { throw new DomainException( 'booking.status_terminal', 'Cannot change status of a terminal booking.' ); } $this->status = $status; $this->updated_at = $when; if ( BookingStatus::Confirmed === $status || BookingStatus::Approved === $status ) { $this->clear_hold_meta(); } } /** * Move the booking to a new slot in place (reschedule). * * @param DateTimeImmutable $start_utc New start UTC. * @param DateTimeImmutable $end_utc New end UTC. * @param DateTimeImmutable $buffer_start_utc New buffer start. * @param DateTimeImmutable $buffer_end_utc New buffer end. * @param DateTimeImmutable $start_local New local start. * @param TimeZoneId $timezone Customer zone. * @param DateTimeImmutable $when Instant UTC. * * @throws DomainException When invariants fail. * * @since 0.1.0 */ public function reschedule_in_place( DateTimeImmutable $start_utc, DateTimeImmutable $end_utc, DateTimeImmutable $buffer_start_utc, DateTimeImmutable $buffer_end_utc, DateTimeImmutable $start_local, TimeZoneId $timezone, DateTimeImmutable $when, ): void { if ( ! $this->occupies_slot() ) { throw new DomainException( 'booking.not_reschedulable', 'Booking cannot be rescheduled.' ); } $this->start_utc = $start_utc; $this->end_utc = $end_utc; $this->buffer_start_utc = $buffer_start_utc; $this->buffer_end_utc = $buffer_end_utc; $this->start_local = $start_local; $this->timezone = $timezone; $this->updated_at = $when; $this->clear_hold_meta(); $this->assert_invariants(); } /** * Place or refresh a soft hold expiry in meta. * * @param DateTimeImmutable $expires_at Hold expiry UTC. * @param DateTimeImmutable $when Instant UTC. * * @since 0.1.0 */ public function place_hold( DateTimeImmutable $expires_at, DateTimeImmutable $when ): void { $meta = $this->meta ?? []; $meta['hold_expires_at'] = $expires_at->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); $this->meta = $meta; $this->status = BookingStatus::Pending; $this->updated_at = $when; } /** * Hold expiry instant from meta, if any. * * @since 0.1.0 */ public function hold_expires_at(): ?DateTimeImmutable { $meta = $this->meta; if ( null === $meta || ! isset( $meta['hold_expires_at'] ) || ! is_string( $meta['hold_expires_at'] ) ) { return null; } $dt = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $meta['hold_expires_at'], new DateTimeZone( 'UTC' ) ); return false === $dt ? null : $dt; } /** * Whether this row is an unexpired soft hold. * * @param DateTimeImmutable $now Instant UTC. * * @since 0.1.0 */ public function is_active_hold( DateTimeImmutable $now ): bool { if ( BookingStatus::Pending !== $this->status || null !== $this->deleted_at ) { return false; } $expires = $this->hold_expires_at(); return null !== $expires && $expires > $now; } /** * Whether the soft hold has expired. * * @param DateTimeImmutable $now Instant UTC. * * @since 0.1.0 */ public function is_hold_expired( DateTimeImmutable $now ): bool { if ( BookingStatus::Pending !== $this->status || null !== $this->deleted_at ) { return false; } $expires = $this->hold_expires_at(); return null !== $expires && $expires <= $now; } /** * Soft-delete the booking. * * @param DateTimeImmutable $when Deletion instant UTC. * * @since 0.1.0 */ public function soft_delete( DateTimeImmutable $when ): void { if ( null !== $this->deleted_at ) { return; } $this->deleted_at = $when; $this->updated_at = $when; } /** * Remove hold_expires_at from meta. */ private function clear_hold_meta(): void { if ( null === $this->meta || ! isset( $this->meta['hold_expires_at'] ) ) { return; } $meta = $this->meta; unset( $meta['hold_expires_at'] ); $this->meta = [] === $meta ? null : $meta; } /** * Persist a meeting join URL (or a drafted placeholder). * * @param string $provider zoom|google_meet|microsoft_teams. * @param string $join_url HTTPS join URL, or empty when drafted. * @param string $status drafted|created. * @param string|null $provider_id Remote id. */ public function record_meeting( string $provider, string $join_url, string $status, ?string $provider_id ): void { $meta = $this->meta ?? []; $meeting = [ 'provider' => $provider, 'join_url' => $join_url, 'status' => $status, ]; if ( null !== $provider_id && '' !== $provider_id ) { $meeting['provider_id'] = $provider_id; } $meta['meeting'] = $meeting; $this->meta = $meta; } /** * Stored join URL, if any. */ public function meeting_join_url(): ?string { $meta = $this->meta; if ( null === $meta || ! isset( $meta['meeting'] ) || ! is_array( $meta['meeting'] ) ) { return null; } $url = $meta['meeting']['join_url'] ?? null; if ( ! is_string( $url ) || '' === $url ) { return null; } return $url; } }