1426 lines
60 KiB
Kotlin
1426 lines
60 KiB
Kotlin
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<BookingListItem> {
|
|
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<OffsetDateTime>()
|
|
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<String, Any?>
|
|
) {
|
|
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<BookingStatus> {
|
|
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<OffsetDateTime, OffsetDateTime> {
|
|
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<RoomStay>,
|
|
checkOutOverrides: Map<UUID, OffsetDateTime>,
|
|
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<BookingBillingMode, String, String> {
|
|
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")
|
|
}
|
|
|
|
}
|