package com.android.trisolarisserver.controller.booking import com.android.trisolarisserver.controller.common.computeExpectedPay import com.android.trisolarisserver.controller.common.nowForProperty import com.android.trisolarisserver.controller.common.parseOffset import com.android.trisolarisserver.controller.common.requireMember import com.android.trisolarisserver.controller.common.requireRole import com.android.trisolarisserver.component.booking.BookingEvents import com.android.trisolarisserver.component.auth.PropertyAccess import com.android.trisolarisserver.component.room.RoomBoardEvents import com.android.trisolarisserver.controller.dto.booking.BookingCancelRequest import com.android.trisolarisserver.controller.dto.booking.BookingBulkCheckInRequest import com.android.trisolarisserver.controller.dto.booking.BookingCheckOutRequest import com.android.trisolarisserver.controller.dto.booking.BookingCreateRequest import com.android.trisolarisserver.controller.dto.booking.BookingCreateResponse import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse import com.android.trisolarisserver.controller.dto.booking.BookingExpectedDatesUpdateRequest import com.android.trisolarisserver.controller.dto.booking.BookingLinkGuestRequest import com.android.trisolarisserver.controller.dto.booking.BookingNoShowRequest import com.android.trisolarisserver.controller.dto.booking.BookingListItem import com.android.trisolarisserver.repo.booking.BookingRepo import com.android.trisolarisserver.repo.guest.GuestDocumentRepo import com.android.trisolarisserver.repo.guest.GuestRepo import com.android.trisolarisserver.repo.guest.GuestRatingRepo import com.android.trisolarisserver.models.booking.BookingStatus import com.android.trisolarisserver.models.booking.BookingRoomRequestStatus import com.android.trisolarisserver.models.booking.MemberRelation import com.android.trisolarisserver.models.booking.TransportMode import com.android.trisolarisserver.models.room.RoomStayAuditLog import com.android.trisolarisserver.models.room.RoomStay import com.android.trisolarisserver.models.room.RateSource import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.repo.property.AppUserRepo import com.android.trisolarisserver.repo.guest.GuestVehicleRepo import com.android.trisolarisserver.repo.booking.PaymentRepo import com.android.trisolarisserver.repo.booking.BookingRoomRequestRepo import com.android.trisolarisserver.repo.property.PropertyRepo import com.android.trisolarisserver.repo.room.RoomRepo import com.android.trisolarisserver.repo.room.RoomStayAuditLogRepo import com.android.trisolarisserver.repo.room.RoomStayRepo import com.android.trisolarisserver.security.MyPrincipal import jakarta.servlet.http.HttpServletResponse import org.springframework.http.HttpStatus import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException import java.time.OffsetDateTime import java.util.UUID @RestController @RequestMapping("/properties/{propertyId}/bookings") class BookingFlow( private val propertyAccess: PropertyAccess, private val bookingRepo: BookingRepo, private val guestRepo: GuestRepo, private val roomRepo: RoomRepo, private val roomStayRepo: RoomStayRepo, private val appUserRepo: AppUserRepo, private val propertyRepo: PropertyRepo, private val roomBoardEvents: RoomBoardEvents, private val bookingEvents: BookingEvents, private val guestVehicleRepo: GuestVehicleRepo, private val guestRatingRepo: GuestRatingRepo, private val guestDocumentRepo: GuestDocumentRepo, private val paymentRepo: PaymentRepo, private val bookingSnapshotBuilder: BookingSnapshotBuilder, private val roomStayAuditLogRepo: RoomStayAuditLogRepo, private val bookingRoomRequestRepo: BookingRoomRequestRepo ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @Transactional fun createBooking( @PathVariable propertyId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingCreateRequest ): BookingCreateResponse { val actor = requireActor(propertyId, principal) val property = propertyRepo.findById(propertyId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") } val expectedCheckInAt = parseOffset(request.expectedCheckInAt) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expectedCheckInAt required") val expectedCheckOutAt = parseOffset(request.expectedCheckOutAt) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expectedCheckOutAt required") if (!expectedCheckOutAt.isAfter(expectedCheckInAt)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range") } val now = nowForProperty(property.timezone) val phone = request.guestPhoneE164?.trim()?.takeIf { it.isNotBlank() } val guest = resolveGuestForBooking(propertyId, property, actor, now, phone) val fromCity = request.fromCity?.trim()?.ifBlank { null } val toCity = request.toCity?.trim()?.ifBlank { null } val memberRelation = parseMemberRelation(request.memberRelation) val hasGuestCounts = request.maleCount != null || request.femaleCount != null || request.childCount != null val adultCount = if (hasGuestCounts) { (request.maleCount ?: 0) + (request.femaleCount ?: 0) } else { null } val totalGuestCount = if (hasGuestCounts && adultCount!=null) { adultCount + (request.childCount ?: 0) } else { null } val booking = com.android.trisolarisserver.models.booking.Booking( property = property, primaryGuest = guest, status = BookingStatus.OPEN, source = request.source?.trim().takeIf { !it.isNullOrBlank() } ?: "WALKIN", checkinAt = null, expectedCheckinAt = expectedCheckInAt, expectedCheckoutAt = expectedCheckOutAt, transportMode = request.transportMode?.let { val mode = parseTransportMode(it) if (!isTransportModeAllowed(property, mode)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled") } mode }, adultCount = adultCount, childCount = request.childCount, maleCount = request.maleCount, femaleCount = request.femaleCount, totalGuestCount = totalGuestCount, expectedGuestCount = request.expectedGuestCount, fromCity = fromCity, toCity = toCity, memberRelation = memberRelation, notes = request.notes, createdBy = actor, updatedAt = now ) val saved = bookingRepo.save(booking) bookingEvents.emit(propertyId, saved.id!!) return BookingCreateResponse( id = saved.id!!, status = saved.status.name, guestId = guest.id, checkInAt = saved.checkinAt?.toString(), expectedCheckInAt = saved.expectedCheckinAt?.toString(), expectedCheckOutAt = saved.expectedCheckoutAt?.toString() ) } @GetMapping fun listBookings( @PathVariable propertyId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestParam(required = false) status: String? ): List { requireRole( propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE ) val property = propertyRepo.findById(propertyId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") } val statuses = parseStatuses(status) val bookings = if (statuses.isEmpty()) { bookingRepo.findByPropertyIdOrderByCreatedAtDesc(propertyId) } else { bookingRepo.findByPropertyIdAndStatusInOrderByCreatedAtDesc(propertyId, statuses) } val bookingIds = bookings.mapNotNull { it.id } val roomNumbersByBooking = if (bookingIds.isEmpty()) { emptyMap() } else { roomStayRepo.findActiveRoomNumbersByBookingIds(bookingIds) .groupBy { it.bookingId } .mapValues { (_, rows) -> rows.map { it.roomNumber }.distinct().sorted() } } val staysByBooking = if (bookingIds.isEmpty()) { emptyMap() } else { roomStayRepo.findByBookingIdIn(bookingIds).groupBy { it.booking.id!! } } val paymentsByBooking = if (bookingIds.isEmpty()) { emptyMap() } else { paymentRepo.sumAmountByBookingIds(bookingIds) .associate { it.bookingId to it.total } } return bookings.map { booking -> val guest = booking.primaryGuest val stays = staysByBooking[booking.id].orEmpty() val expectedPay = if (stays.isEmpty()) { null } else { computeExpectedPay(stays, property.timezone) } val collected = paymentsByBooking[booking.id] ?: 0L val pending = expectedPay?.let { it - collected } BookingListItem( id = booking.id!!, status = booking.status.name, guestId = guest?.id, guestName = guest?.name, guestPhone = guest?.phoneE164, roomNumbers = roomNumbersByBooking[booking.id] ?: emptyList(), source = booking.source, expectedCheckInAt = booking.expectedCheckinAt?.toString(), expectedCheckOutAt = booking.expectedCheckoutAt?.toString(), checkInAt = booking.checkinAt?.toString(), checkOutAt = booking.checkoutAt?.toString(), adultCount = booking.adultCount, childCount = booking.childCount, maleCount = booking.maleCount, femaleCount = booking.femaleCount, totalGuestCount = booking.totalGuestCount, expectedGuestCount = booking.expectedGuestCount, notes = booking.notes, pending = pending ) } } @GetMapping("/{bookingId}") @Transactional fun getBooking( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal? ): BookingDetailResponse { requireRole( propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE ) val booking = requireBooking(propertyId, bookingId) return bookingSnapshotBuilder.build(propertyId, bookingId) } @GetMapping("/{bookingId}/stream") fun streamBooking( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, response: HttpServletResponse ): org.springframework.web.servlet.mvc.method.annotation.SseEmitter { requireRole( propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE ) requireBooking(propertyId, bookingId) prepareSse(response) return bookingEvents.subscribe(propertyId, bookingId) } @PostMapping("/{bookingId}/link-guest") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional fun linkGuest( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingLinkGuestRequest ) { requireMember(propertyAccess, propertyId, principal) val booking = requireBooking(propertyId, bookingId) val guest = guestRepo.findById(request.guestId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found") } if (guest.property.id != propertyId) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property") } val previous = booking.primaryGuest booking.primaryGuest = guest booking.updatedAt = OffsetDateTime.now() bookingRepo.save(booking) bookingEvents.emit(propertyId, bookingId) if (previous != null && previous.id != guest.id && isPlaceholderGuest(previous) && isSafeToDelete(previous)) { guestRepo.delete(previous) } } @PostMapping("/{bookingId}/check-in/bulk") @ResponseStatus(HttpStatus.CREATED) @Transactional fun bulkCheckIn( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingBulkCheckInRequest ) { val actor = requireActor(propertyId, principal) if (request.stays.isEmpty()) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "stays required") } val booking = requireBooking(propertyId, bookingId) if (booking.status != BookingStatus.OPEN) { throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open") } val roomIds = request.stays.map { it.roomId } if (roomIds.distinct().size != roomIds.size) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate roomId in stays") } val rooms = request.stays.associate { stay -> val room = roomRepo.findByIdAndPropertyId(stay.roomId, propertyId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found") if (!room.active || room.maintenance) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available") } stay.roomId to room } val occupied = roomStayRepo.findActiveRoomIds(propertyId, roomIds) if (occupied.isNotEmpty()) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied") } val now = OffsetDateTime.now() val checkInTimes = mutableListOf() request.stays.forEach { stay -> val checkInAt = parseOffset(stay.checkInAt) ?: now checkInTimes.add(checkInAt) val room = rooms.getValue(stay.roomId) val newStay = RoomStay( property = booking.property, booking = booking, room = room, fromAt = checkInAt, toAt = null, rateSource = parseRateSource(stay.rateSource), nightlyRate = stay.nightlyRate, ratePlanCode = stay.ratePlanCode, currency = stay.currency ?: booking.property.currency, createdBy = actor ) roomStayRepo.save(newStay) fulfillRoomRequestIfAny(booking.id!!, room.roomType.id!!, checkInAt) } val bookingCheckInAt = checkInTimes.minOrNull() ?: now booking.status = BookingStatus.CHECKED_IN booking.checkinAt = bookingCheckInAt booking.transportMode = request.transportMode?.let { val mode = parseTransportMode(it) if (!isTransportModeAllowed(booking.property, mode)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled") } mode } if (request.notes != null) booking.notes = request.notes booking.updatedAt = now bookingRepo.save(booking) roomBoardEvents.emit(propertyId) bookingEvents.emit(propertyId, bookingId) } @PostMapping("/{bookingId}/expected-dates") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional fun updateExpectedDates( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingExpectedDatesUpdateRequest ) { requireActor(propertyId, principal) val booking = requireBooking(propertyId, bookingId) when (booking.status) { BookingStatus.OPEN -> { if (request.expectedCheckInAt != null) { booking.expectedCheckinAt = parseOffset(request.expectedCheckInAt) } if (request.expectedCheckOutAt != null) { booking.expectedCheckoutAt = parseOffset(request.expectedCheckOutAt) } } BookingStatus.CHECKED_IN -> { if (request.expectedCheckInAt != null) { throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot change expected check-in after check-in") } if (request.expectedCheckOutAt != null) { booking.expectedCheckoutAt = parseOffset(request.expectedCheckOutAt) } } BookingStatus.CHECKED_OUT, BookingStatus.CANCELLED, BookingStatus.NO_SHOW -> { throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed") } } val expectedIn = booking.expectedCheckinAt val expectedOut = booking.expectedCheckoutAt if (expectedIn != null && expectedOut != null && !expectedOut.isAfter(expectedIn)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range") } booking.updatedAt = OffsetDateTime.now() bookingRepo.save(booking) bookingEvents.emit(propertyId, bookingId) } @PostMapping("/{bookingId}/check-out") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional fun checkOut( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingCheckOutRequest ) { val actor = requireActor(propertyId, principal) val booking = requireBooking(propertyId, bookingId) if (booking.status != BookingStatus.CHECKED_IN) { throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not checked in") } val now = OffsetDateTime.now() val checkOutAt = parseOffset(request.checkOutAt) ?: now val stays = roomStayRepo.findActiveByBookingId(bookingId) if (stays.any { !isCheckoutAmountValid(it) || !isMinimumStayDurationValid(it, checkOutAt) }) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay amount is outside allowed range") } val oldStates = stays.associate { it.id!! to it.toAt } stays.forEach { it.toAt = checkOutAt } roomStayRepo.saveAll(stays) stays.forEach { logStayAudit( stay = it, action = "CHECK_OUT", actor = actor, oldToAt = oldStates[it.id], oldIsVoided = it.isVoided, newToAt = checkOutAt, newIsVoided = false, reason = request.notes ) } booking.status = BookingStatus.CHECKED_OUT booking.checkoutAt = checkOutAt if (request.notes != null) booking.notes = request.notes booking.updatedAt = now bookingRepo.save(booking) roomBoardEvents.emit(propertyId) bookingEvents.emit(propertyId, bookingId) } @PostMapping("/{bookingId}/room-stays/{roomStayId}/check-out") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional fun checkOutRoomStay( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @PathVariable roomStayId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingCheckOutRequest ) { val actor = requireActor(propertyId, principal) val booking = requireBooking(propertyId, bookingId) if (booking.status != BookingStatus.CHECKED_IN) { throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not checked in") } val staysForBooking = roomStayRepo.findByBookingId(bookingId) val singleStayBooking = staysForBooking.size == 1 val stay = roomStayRepo.findByIdAndBookingId(roomStayId, bookingId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for booking") if (stay.toAt != null) { if (singleStayBooking) { booking.status = BookingStatus.CHECKED_OUT booking.checkoutAt = stay.toAt booking.updatedAt = OffsetDateTime.now() bookingRepo.save(booking) bookingEvents.emit(propertyId, bookingId) } return } val now = OffsetDateTime.now() val checkOutAt = parseOffset(request.checkOutAt) ?: now if (!isCheckoutAmountValid(stay)) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay amount is outside allowed range") } if (!isMinimumStayDurationValid(stay, checkOutAt)) { throw ResponseStatusException(HttpStatus.CONFLICT, "Minimum stay duration is 1 hour") } val oldToAt = stay.toAt val oldIsVoided = stay.isVoided stay.toAt = checkOutAt roomStayRepo.save(stay) logStayAudit( stay = stay, action = "CHECK_OUT", actor = actor, oldToAt = oldToAt, oldIsVoided = oldIsVoided, newToAt = checkOutAt, newIsVoided = false, reason = request.notes ) val remainingActive = roomStayRepo.findActiveByBookingId(bookingId) if (singleStayBooking || remainingActive.isEmpty()) { booking.status = BookingStatus.CHECKED_OUT booking.checkoutAt = checkOutAt booking.updatedAt = now bookingRepo.save(booking) } roomBoardEvents.emit(propertyId) bookingEvents.emit(propertyId, bookingId) } private fun resolveGuestForBooking( propertyId: UUID, property: com.android.trisolarisserver.models.property.Property, actor: com.android.trisolarisserver.models.property.AppUser?, now: OffsetDateTime, phone: String? ): com.android.trisolarisserver.models.booking.Guest { if (phone != null) { val existing = guestRepo.findByPropertyIdAndPhoneE164(propertyId, phone) if (existing != null) { return existing } } val guest = com.android.trisolarisserver.models.booking.Guest( property = property, phoneE164 = phone, createdBy = actor, updatedAt = now ) return guestRepo.save(guest) } private fun parseStatuses(raw: String?): Set { if (raw.isNullOrBlank()) return emptySet() return raw.split(",") .map { it.trim() } .filter { it.isNotEmpty() } .map { value -> try { BookingStatus.valueOf(value.uppercase()) } catch (_: IllegalArgumentException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid status: $value") } } .toSet() } @PostMapping("/{bookingId}/cancel") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional fun cancel( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingCancelRequest ) { requireActor(propertyId, principal) val booking = requireBooking(propertyId, bookingId) if (booking.status == BookingStatus.CHECKED_IN) { val active = roomStayRepo.findActiveByBookingId(bookingId) if (active.isNotEmpty()) { throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel checked-in booking") } } booking.status = BookingStatus.CANCELLED if (request.reason != null) booking.notes = request.reason booking.updatedAt = OffsetDateTime.now() bookingRepo.save(booking) bookingEvents.emit(propertyId, bookingId) } @PostMapping("/{bookingId}/no-show") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional fun noShow( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingNoShowRequest ) { requireActor(propertyId, principal) val booking = requireBooking(propertyId, bookingId) if (booking.status != BookingStatus.OPEN) { throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open") } booking.status = BookingStatus.NO_SHOW if (request.reason != null) booking.notes = request.reason booking.updatedAt = OffsetDateTime.now() bookingRepo.save(booking) bookingEvents.emit(propertyId, bookingId) } private fun requireBooking(propertyId: UUID, bookingId: UUID): com.android.trisolarisserver.models.booking.Booking { val booking = bookingRepo.findById(bookingId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found") } if (booking.property.id != propertyId) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property") } return booking } private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser { val resolved = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF) return appUserRepo.findById(resolved.userId).orElseThrow { ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") } } private fun parseTransportMode(value: String): TransportMode { return try { TransportMode.valueOf(value) } catch (_: IllegalArgumentException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode") } } private fun parseMemberRelation(value: String?): MemberRelation? { if (value.isNullOrBlank()) return null return try { MemberRelation.valueOf(value.trim().uppercase()) } catch (_: IllegalArgumentException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown member relation") } } private fun parseRateSource(value: String?): RateSource? { if (value.isNullOrBlank()) return null return try { RateSource.valueOf(value.trim().uppercase()) } catch (_: IllegalArgumentException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown rate source") } } private fun fulfillRoomRequestIfAny(bookingId: UUID, roomTypeId: UUID, checkInAt: OffsetDateTime) { val requests = bookingRoomRequestRepo.findActiveForFulfillment(bookingId, roomTypeId, checkInAt) for (request in requests) { if (request.fulfilledQuantity >= request.quantity) continue request.fulfilledQuantity += 1 if (request.fulfilledQuantity >= request.quantity) { request.status = BookingRoomRequestStatus.FULFILLED } bookingRoomRequestRepo.save(request) return } } private fun isCheckoutAmountValid(stay: RoomStay): Boolean { val base = stay.room.roomType.defaultRate ?: return true val nightly = stay.nightlyRate ?: return false val low = (base * 80L) / 100L val high = (base * 120L) / 100L return nightly in low..high } private fun isMinimumStayDurationValid(stay: RoomStay, checkOutAt: OffsetDateTime): Boolean { return java.time.Duration.between(stay.fromAt, checkOutAt).toMinutes() >= 60 } private fun logStayAudit( stay: RoomStay, action: String, actor: com.android.trisolarisserver.models.property.AppUser?, oldToAt: OffsetDateTime?, oldIsVoided: Boolean, newToAt: OffsetDateTime?, newIsVoided: Boolean, reason: String? ) { roomStayAuditLogRepo.save( RoomStayAuditLog( property = stay.property, booking = stay.booking, roomStay = stay, action = action, oldToAt = oldToAt, newToAt = newToAt, oldIsVoided = oldIsVoided, newIsVoided = newIsVoided, reason = reason, actor = actor ) ) } private fun isTransportModeAllowed( property: com.android.trisolarisserver.models.property.Property, mode: TransportMode ): Boolean { val allowed = property.allowedTransportModes.ifEmpty { TransportMode.entries.toSet() } return allowed.contains(mode) } private fun isPlaceholderGuest(guest: com.android.trisolarisserver.models.booking.Guest): Boolean { return guest.phoneE164.isNullOrBlank() && guest.name.isNullOrBlank() && guest.nationality.isNullOrBlank() && guest.addressText.isNullOrBlank() && guest.signaturePath.isNullOrBlank() } private fun isSafeToDelete(guest: com.android.trisolarisserver.models.booking.Guest): Boolean { val id = guest.id ?: return false if (bookingRepo.countByPrimaryGuestId(id) > 0) return false if (guestVehicleRepo.existsByGuestId(id)) return false if (guestDocumentRepo.existsByGuestId(id)) return false if (guestRatingRepo.existsByGuestId(id)) return false return true } private fun prepareSse(response: HttpServletResponse) { response.setHeader("Cache-Control", "no-cache") response.setHeader("Connection", "keep-alive") response.setHeader("X-Accel-Buffering", "no") } }