package com.android.trisolarisserver.controller.booking import com.android.trisolarisserver.controller.common.billableNights 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.BookingBillableNightsRequest import com.android.trisolarisserver.controller.dto.booking.BookingBillableNightsResponse import com.android.trisolarisserver.controller.dto.booking.BookingExpectedCheckoutPreviewRequest import com.android.trisolarisserver.controller.dto.booking.BookingExpectedCheckoutPreviewResponse 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.BookingBillingPolicyUpdateRequest 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.BookingBillingMode import com.android.trisolarisserver.models.booking.BookingRoomRequestStatus import com.android.trisolarisserver.models.booking.BookingBillingPolicyAuditLog import com.android.trisolarisserver.models.booking.Charge import com.android.trisolarisserver.models.booking.ChargeType 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.models.property.CancellationPenaltyMode 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.booking.BookingBillingPolicyAuditLogRepo import com.android.trisolarisserver.repo.booking.ChargeRepo import com.android.trisolarisserver.repo.property.PropertyRepo import com.android.trisolarisserver.repo.property.PropertyCancellationPolicyRepo 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.LocalTime import java.time.OffsetDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import kotlin.math.abs 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 chargeRepo: ChargeRepo, private val bookingSnapshotBuilder: BookingSnapshotBuilder, private val roomStayAuditLogRepo: RoomStayAuditLogRepo, private val bookingRoomRequestRepo: BookingRoomRequestRepo, private val bookingBillingPolicyAuditLogRepo: BookingBillingPolicyAuditLogRepo, private val propertyCancellationPolicyRepo: PropertyCancellationPolicyRepo ) { @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 (billingMode, billingCheckinTime, billingCheckoutTime) = resolveBookingBillingPolicy( property = property, modeRaw = request.billingMode, billingCheckoutTimeRaw = request.billingCheckoutTime ) 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() } ?: "DIRECT", checkinAt = null, expectedCheckinAt = expectedCheckInAt, expectedCheckoutAt = expectedCheckOutAt, billingMode = billingMode, billingCheckinTime = billingCheckinTime, billingCheckoutTime = billingCheckoutTime, 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, billingMode = saved.billingMode.name, billingCheckinTime = saved.billingCheckinTime, billingCheckoutTime = saved.billingCheckoutTime, guestId = guest.id, checkInAt = saved.checkinAt?.toString(), expectedCheckInAt = saved.expectedCheckinAt?.toString(), expectedCheckOutAt = saved.expectedCheckoutAt?.toString() ) } @GetMapping @Transactional(readOnly = true) 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 } } val chargesByBooking = if (bookingIds.isEmpty()) { emptyMap() } else { chargeRepo.sumAmountByBookingIds(bookingIds) .associate { it.bookingId to it.total } } val guestIds = bookings.mapNotNull { it.primaryGuest?.id }.distinct() val vehicleNumbersByGuest = if (guestIds.isEmpty()) { emptyMap() } else { guestVehicleRepo.findRowsByGuestIds(guestIds) .groupBy { it.guestId } .mapValues { (_, rows) -> rows.map { it.vehicleNumber }.distinct().sorted() } } 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, booking.billingMode, booking.billingCheckinTime, booking.billingCheckoutTime ) } val collected = paymentsByBooking[booking.id] ?: 0L val extraCharges = chargesByBooking[booking.id] ?: 0L val pending = expectedPay?.let { it + extraCharges - collected } ?: (extraCharges - collected) BookingListItem( id = booking.id!!, status = booking.status.name, guestId = guest?.id, guestName = guest?.name, guestPhone = guest?.phoneE164, vehicleNumbers = guest?.id?.let { vehicleNumbersByGuest[it] } ?: emptyList(), roomNumbers = roomNumbersByBooking[booking.id] ?: emptyList(), source = booking.source, billingMode = booking.billingMode.name, billingCheckinTime = booking.billingCheckinTime, billingCheckoutTime = booking.billingCheckoutTime, 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) } @PostMapping("/{bookingId}/billable-nights") @Transactional(readOnly = true) fun previewBillableNights( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody(required = false) request: BookingBillableNightsRequest? ): BookingBillableNightsResponse { requireRole( propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE ) val booking = requireBooking(propertyId, bookingId) val resolvedRequest = request ?: BookingBillableNightsRequest() val (startAt, endAt) = resolveBillableNightsWindow(booking, resolvedRequest) if (!endAt.isAfter(startAt)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range") } val nights = billableNights( startAt = startAt, endAt = endAt, timezone = booking.property.timezone, billingMode = booking.billingMode, billingCheckinTime = booking.billingCheckinTime, billingCheckoutTime = booking.billingCheckoutTime ) return BookingBillableNightsResponse( bookingId = bookingId, status = booking.status.name, billableNights = nights ) } @PostMapping("/expected-checkout-preview") @Transactional(readOnly = true) fun previewExpectedCheckout( @PathVariable propertyId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingExpectedCheckoutPreviewRequest ): BookingExpectedCheckoutPreviewResponse { 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 checkInAt = parseOffset(request.checkInAt) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "checkInAt required") val nights = request.billableNights ?: 1L if (nights <= 0L) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "billableNights must be > 0") } val mode = parseBillingMode(request.billingMode) val billingCheckinTime = when (mode) { BookingBillingMode.FULL_24H -> "00:00" else -> normalizeBillingTime(request.billingCheckinTime ?: property.billingCheckinTime, "billingCheckinTime") } val billingCheckoutTime = when (mode) { BookingBillingMode.FULL_24H -> "23:59" else -> normalizeBillingTime(request.billingCheckoutTime ?: property.billingCheckoutTime, "billingCheckoutTime") } val expectedCheckoutAt = computeExpectedCheckoutAtFromPolicy( checkInAt = checkInAt, timezone = property.timezone, billableNights = nights, billingMode = mode, billingCheckinTime = billingCheckinTime, billingCheckoutTime = billingCheckoutTime ) return BookingExpectedCheckoutPreviewResponse( expectedCheckOutAt = expectedCheckoutAt.toString(), billableNights = nights, billingMode = mode.name, billingCheckinTime = billingCheckinTime, billingCheckoutTime = billingCheckoutTime ) } @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 propertyZone = resolveZoneId(booking.property.timezone) val today = OffsetDateTime.now(propertyZone).toLocalDate() val expectedCheckInDate = booking.expectedCheckinAt?.atZoneSameInstant(propertyZone)?.toLocalDate() if (expectedCheckInDate != null && expectedCheckInDate != today) { throw ResponseStatusException(HttpStatus.CONFLICT, "Future booking can't be checked in") } 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") } validateRoomStayBoundsForExpectedDatesUpdate(booking, expectedIn, expectedOut) booking.updatedAt = OffsetDateTime.now() bookingRepo.save(booking) bookingEvents.emit(propertyId, bookingId) } private fun validateRoomStayBoundsForExpectedDatesUpdate( booking: com.android.trisolarisserver.models.booking.Booking, expectedIn: OffsetDateTime?, expectedOut: OffsetDateTime? ) { if (expectedIn == null || expectedOut == null) return val bookingId = booking.id ?: return val stays = roomStayRepo.findByBookingId(bookingId) when (booking.status) { BookingStatus.OPEN -> { stays.forEach { stay -> if (stay.fromAt.isBefore(expectedIn) || stay.fromAt.isAfter(expectedOut)) { throw ResponseStatusException( HttpStatus.CONFLICT, "Room stay start must be within expected check-in/check-out window for OPEN booking" ) } val toAt = stay.toAt if (toAt != null && (toAt.isBefore(expectedIn) || toAt.isAfter(expectedOut))) { throw ResponseStatusException( HttpStatus.CONFLICT, "Room stay end must be within expected check-in/check-out window for OPEN booking" ) } } } BookingStatus.CHECKED_IN -> { val checkInAt = booking.checkinAt ?: expectedIn stays.forEach { stay -> if (stay.fromAt.isBefore(checkInAt) || !stay.fromAt.isBefore(expectedOut)) { throw ResponseStatusException( HttpStatus.CONFLICT, "Room stay start must be >= check-in and < expected check-out for CHECKED_IN booking" ) } val toAt = stay.toAt if (toAt != null && toAt.isAfter(expectedOut)) { throw ResponseStatusException( HttpStatus.CONFLICT, "Room stay end cannot be after expected check-out for CHECKED_IN booking" ) } } } BookingStatus.CHECKED_OUT, BookingStatus.CANCELLED, BookingStatus.NO_SHOW -> { // Already blocked by status guard in updateExpectedDates. } } } @PostMapping("/{bookingId}/billing-policy") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional fun updateBillingPolicy( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingBillingPolicyUpdateRequest ) { val actor = requireActor(propertyId, principal) val booking = requireBooking(propertyId, bookingId) when (booking.status) { BookingStatus.CHECKED_OUT, BookingStatus.CANCELLED, BookingStatus.NO_SHOW -> throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed") else -> {} } val oldMode = booking.billingMode val oldCheckin = booking.billingCheckinTime val oldCheckout = booking.billingCheckoutTime val (newMode, newCheckin, newCheckout) = resolveBookingBillingPolicy( property = booking.property, modeRaw = request.billingMode, billingCheckoutTimeRaw = request.billingCheckoutTime ) booking.billingMode = newMode booking.billingCheckinTime = newCheckin booking.billingCheckoutTime = newCheckout booking.updatedAt = OffsetDateTime.now() bookingRepo.save(booking) if (oldMode != newMode || oldCheckin != newCheckin || oldCheckout != newCheckout) { bookingBillingPolicyAuditLogRepo.save( BookingBillingPolicyAuditLog( property = booking.property, booking = booking, actor = actor, oldBillingMode = oldMode, newBillingMode = newMode, oldBillingCheckinTime = oldCheckin, newBillingCheckinTime = newCheckin, oldBillingCheckoutTime = oldCheckout, newBillingCheckoutTime = newCheckout ) ) } bookingEvents.emit(propertyId, bookingId) } @PostMapping("/{bookingId}/profile") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional fun updateBookingProfile( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: Map ) { requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) val allowedFields = setOf( "transportMode", "childCount", "maleCount", "femaleCount", "fromCity", "toCity", "memberRelation" ) val fieldNames = request.keys.toList() val unknownFields = fieldNames.filter { it !in allowedFields } if (unknownFields.isNotEmpty()) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown fields: ${unknownFields.joinToString(",")}") } if (fieldNames.isEmpty()) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one field is required") } val booking = requireBooking(propertyId, bookingId) if (request.containsKey("transportMode")) { booking.transportMode = parseNullableTransportMode(booking, request["transportMode"]) } if (request.containsKey("fromCity")) { booking.fromCity = parseNullableText(request["fromCity"], "fromCity") } if (request.containsKey("toCity")) { booking.toCity = parseNullableText(request["toCity"], "toCity") } if (request.containsKey("memberRelation")) { booking.memberRelation = parseNullableMemberRelation(request["memberRelation"]) } val hasGuestCountPatch = request.containsKey("maleCount") || request.containsKey("femaleCount") || request.containsKey("childCount") if (hasGuestCountPatch) { val maleCount = if (request.containsKey("maleCount")) { parseNullableNonNegativeInt(request["maleCount"], "maleCount") } else { booking.maleCount } val femaleCount = if (request.containsKey("femaleCount")) { parseNullableNonNegativeInt(request["femaleCount"], "femaleCount") } else { booking.femaleCount } val childCount = if (request.containsKey("childCount")) { parseNullableNonNegativeInt(request["childCount"], "childCount") } else { booking.childCount } val hasAnyGuestCount = maleCount != null || femaleCount != null || childCount != null val adultCount = if (hasAnyGuestCount) (maleCount ?: 0) + (femaleCount ?: 0) else null val totalGuestCount = if (hasAnyGuestCount) (maleCount ?: 0) + (femaleCount ?: 0) + (childCount ?: 0) else null booking.maleCount = maleCount booking.femaleCount = femaleCount booking.childCount = childCount booking.adultCount = adultCount booking.totalGuestCount = totalGuestCount } 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 validateCheckoutPrerequisites(booking, checkOutAt) val stays = roomStayRepo.findActiveByBookingId(bookingId) if (stays.any { !isCheckoutAmountValid(it) || !isMinimumStayDurationValid(it, checkOutAt) }) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay amount is outside allowed range") } ensureLedgerToleranceForCheckout( bookingId = bookingId, timezone = booking.property.timezone, billingMode = booking.billingMode, billingCheckinTime = booking.billingCheckinTime, billingCheckoutTime = booking.billingCheckoutTime, stays = stays, checkOutOverrides = stays.associate { it.id!! to checkOutAt }, restrictToClosedStaysOnly = false ) 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 validateCheckoutPrerequisites(booking, checkOutAt) 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") } ensureLedgerToleranceForCheckout( bookingId = bookingId, timezone = booking.property.timezone, billingMode = booking.billingMode, billingCheckinTime = booking.billingCheckinTime, billingCheckoutTime = booking.billingCheckoutTime, stays = staysForBooking, checkOutOverrides = mapOf(stay.id!! to checkOutAt), restrictToClosedStaysOnly = true ) 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 ) { val actor = 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") } } createCancellationPenaltyIfApplicable(propertyId, booking, actor, ChargeType.CANCELLATION_PENALTY, request.reason) 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 ) { val actor = requireActor(propertyId, principal) val booking = requireBooking(propertyId, bookingId) if (booking.status != BookingStatus.OPEN) { throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open") } createCancellationPenaltyIfApplicable(propertyId, booking, actor, ChargeType.NO_SHOW_PENALTY, request.reason) 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 resolveBillableNightsWindow( booking: com.android.trisolarisserver.models.booking.Booking, request: BookingBillableNightsRequest ): Pair { return when (booking.status) { BookingStatus.OPEN -> { val expectedCheckIn = parseOffset(request.expectedCheckInAt) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expectedCheckInAt required for OPEN booking") val expectedCheckOut = parseOffset(request.expectedCheckOutAt) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expectedCheckOutAt required for OPEN booking") Pair(expectedCheckIn, expectedCheckOut) } BookingStatus.CHECKED_IN -> { if (request.expectedCheckInAt != null) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expectedCheckInAt not allowed for CHECKED_IN booking") } val checkInAt = booking.checkinAt ?: booking.expectedCheckinAt ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "checkInAt missing for booking") val expectedCheckOut = parseOffset(request.expectedCheckOutAt) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expectedCheckOutAt required for CHECKED_IN booking") Pair(checkInAt, expectedCheckOut) } BookingStatus.CHECKED_OUT, BookingStatus.CANCELLED, BookingStatus.NO_SHOW -> { if (request.expectedCheckInAt != null || request.expectedCheckOutAt != null) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expected dates not accepted for closed booking") } val startAt = booking.checkinAt ?: booking.expectedCheckinAt ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "checkInAt missing for booking") val endAt = booking.checkoutAt ?: booking.expectedCheckoutAt ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "checkOutAt missing for booking") Pair(startAt, endAt) } } } private fun computeExpectedCheckoutAtFromPolicy( checkInAt: OffsetDateTime, timezone: String?, billableNights: Long, billingMode: BookingBillingMode, billingCheckinTime: String, billingCheckoutTime: String ): OffsetDateTime { if (billingMode == BookingBillingMode.FULL_24H) { return checkInAt.plusDays(billableNights) } val zone = resolveZoneId(timezone) val localCheckIn = checkInAt.atZoneSameInstant(zone) val checkinPolicy = LocalTime.parse(billingCheckinTime, BILLING_TIME_FORMATTER) val checkoutPolicy = LocalTime.parse(billingCheckoutTime, BILLING_TIME_FORMATTER) val billStartDate = if (localCheckIn.toLocalTime().isBefore(checkinPolicy)) { localCheckIn.toLocalDate().minusDays(1) } else { localCheckIn.toLocalDate() } val targetDate = billStartDate.plusDays(billableNights) val targetCheckout = java.time.ZonedDateTime.of(targetDate, checkoutPolicy, zone).toOffsetDateTime() if (targetCheckout.isAfter(checkInAt)) { return targetCheckout } return targetCheckout.plusDays(1) } private fun resolveZoneId(timezone: String?): ZoneId { return try { if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone) } catch (_: Exception) { ZoneId.of("Asia/Kolkata") } } private fun parseTransportMode(value: String): TransportMode { return try { TransportMode.valueOf(value) } catch (_: IllegalArgumentException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode") } } private fun parseNullableTransportMode( booking: com.android.trisolarisserver.models.booking.Booking, value: Any? ): TransportMode? { if (value == null) return null if (value !is String) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "transportMode must be string or null") } val raw = value.trim() if (raw.isBlank()) return null val mode = parseTransportMode(raw.uppercase()) if (!isTransportModeAllowed(booking.property, mode)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled") } return 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 parseNullableMemberRelation(value: Any?): MemberRelation? { if (value == null) return null if (value !is String) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "memberRelation must be string or null") } return parseMemberRelation(value) } private fun parseNullableText(value: Any?, fieldName: String): String? { if (value == null) return null if (value !is String) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be string or null") } return value.trim().ifBlank { null } } private fun parseNullableNonNegativeInt(value: Any?, fieldName: String): Int? { if (value == null) return null val intValue = when (value) { is Int -> value is Long -> value.toInt() is Number -> { val asDouble = value.toDouble() if (asDouble % 1.0 != 0.0) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be integer or null") } asDouble.toInt() } else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be integer or null") } if (intValue < 0) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be >= 0") } return intValue } 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 createCancellationPenaltyIfApplicable( propertyId: UUID, booking: com.android.trisolarisserver.models.booking.Booking, actor: com.android.trisolarisserver.models.property.AppUser, chargeType: ChargeType, notes: String? ) { if (!isAdvanceBooking(booking)) return val policy = propertyCancellationPolicyRepo.findByPropertyId(propertyId) ?: return val checkInAt = booking.expectedCheckinAt ?: return val now = nowForProperty(booking.property.timezone) val daysUntilCheckIn = java.time.Duration.between(now, checkInAt).toDays() if (daysUntilCheckIn >= policy.freeDaysBeforeCheckin.toLong()) return val amount = when (policy.penaltyMode) { CancellationPenaltyMode.NO_CHARGE -> 0L CancellationPenaltyMode.ONE_NIGHT -> computeOneNightAmount(booking.id!!) CancellationPenaltyMode.FULL_STAY -> computeFullStayAmount(booking.id!!, booking.expectedCheckinAt, booking.expectedCheckoutAt) } if (amount <= 0L) return chargeRepo.save( Charge( property = booking.property, booking = booking, type = chargeType, amount = amount, currency = booking.property.currency, notes = notes, createdBy = actor ) ) } private fun isAdvanceBooking(booking: com.android.trisolarisserver.models.booking.Booking): Boolean { val expected = booking.expectedCheckinAt ?: return false return expected.isAfter(booking.createdAt.plusHours(1)) } private fun computeOneNightAmount(bookingId: UUID): Long { val stays = roomStayRepo.findByBookingIdWithRoom(bookingId) if (stays.isNotEmpty()) { return stays.filter { !it.isVoided }.sumOf { it.nightlyRate ?: it.room.roomType.defaultRate ?: 0L } } val requests = bookingRoomRequestRepo.findByBookingIdOrderByCreatedAtAsc(bookingId) .filter { it.status == BookingRoomRequestStatus.ACTIVE || it.status == BookingRoomRequestStatus.FULFILLED } if (requests.isNotEmpty()) { return requests.sumOf { (it.roomType.defaultRate ?: 0L) * it.quantity.toLong() } } return 0L } private fun computeFullStayAmount( bookingId: UUID, expectedCheckinAt: OffsetDateTime?, expectedCheckoutAt: OffsetDateTime? ): Long { val oneNight = computeOneNightAmount(bookingId) if (oneNight <= 0L) return 0L val nights = if (expectedCheckinAt != null && expectedCheckoutAt != null && expectedCheckoutAt.isAfter(expectedCheckinAt)) { val diff = expectedCheckoutAt.toLocalDate().toEpochDay() - expectedCheckinAt.toLocalDate().toEpochDay() if (diff <= 0L) 1L else diff } else { 1L } return oneNight * nights } 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 validateCheckoutPrerequisites( booking: com.android.trisolarisserver.models.booking.Booking, checkOutAt: OffsetDateTime ) { val guest = booking.primaryGuest ?: throw ResponseStatusException(HttpStatus.CONFLICT, "Primary guest required before checkout") if (guest.name.isNullOrBlank()) { throw ResponseStatusException(HttpStatus.CONFLICT, "Guest name required before checkout") } if (guest.phoneE164.isNullOrBlank()) { throw ResponseStatusException(HttpStatus.CONFLICT, "Guest phone required before checkout") } if (guest.signaturePath.isNullOrBlank()) { throw ResponseStatusException(HttpStatus.CONFLICT, "Guest signature required before checkout") } val checkInAt = booking.checkinAt ?: booking.expectedCheckinAt ?: throw ResponseStatusException(HttpStatus.CONFLICT, "Check-in time missing for booking") if (!checkOutAt.isAfter(checkInAt)) { throw ResponseStatusException(HttpStatus.CONFLICT, "checkOutAt must be after checkInAt") } } private fun ensureLedgerToleranceForCheckout( bookingId: UUID, timezone: String?, billingMode: BookingBillingMode, billingCheckinTime: String?, billingCheckoutTime: String?, stays: List, checkOutOverrides: Map, restrictToClosedStaysOnly: Boolean ) { val now = nowForProperty(timezone) val expectedStayAmount = stays .asSequence() .filter { !it.isVoided } .mapNotNull { stay -> val effectiveToAt = checkOutOverrides[stay.id] ?: stay.toAt if (restrictToClosedStaysOnly && effectiveToAt == null) { return@mapNotNull null } val endAt = effectiveToAt ?: now val rate = stay.nightlyRate ?: 0L if (rate <= 0L) { return@mapNotNull 0L } rate * billableNights( stay.fromAt, endAt, timezone, billingMode, billingCheckinTime, billingCheckoutTime ) } .sum() val extraCharges = chargeRepo.sumAmountByBookingId(bookingId) val collected = paymentRepo.sumAmountByBookingId(bookingId) val expectedTotal = expectedStayAmount + extraCharges val tolerance = (expectedTotal * 20L) / 100L val delta = collected - expectedTotal if (abs(delta) > tolerance) { throw ResponseStatusException( HttpStatus.CONFLICT, "Ledger mismatch: collected amount must be within 20% of expected amount before checkout" ) } } 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") } private fun resolveBookingBillingPolicy( property: com.android.trisolarisserver.models.property.Property, modeRaw: String?, billingCheckoutTimeRaw: String? ): Triple { val mode = parseBillingMode(modeRaw) return when (mode) { BookingBillingMode.PROPERTY_POLICY -> Triple( BookingBillingMode.PROPERTY_POLICY, property.billingCheckinTime, property.billingCheckoutTime ) BookingBillingMode.CUSTOM_WINDOW -> Triple( BookingBillingMode.CUSTOM_WINDOW, normalizeBillingTime(billingCheckoutTimeRaw, "billingCheckoutTime"), normalizeBillingTime(billingCheckoutTimeRaw, "billingCheckoutTime") ) BookingBillingMode.FULL_24H -> Triple( BookingBillingMode.FULL_24H, "00:00", "23:59" ) } } private fun parseBillingMode(raw: String?): BookingBillingMode { if (raw.isNullOrBlank()) return BookingBillingMode.PROPERTY_POLICY return try { BookingBillingMode.valueOf(raw.trim().uppercase()) } catch (_: IllegalArgumentException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown billing mode") } } private fun normalizeBillingTime(raw: String?, fieldName: String): String { val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName required") return try { LocalTime.parse(value, BILLING_TIME_FORMATTER).format(BILLING_TIME_FORMATTER) } catch (_: DateTimeParseException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be HH:mm") } } companion object { private val BILLING_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") } }