From 59a50d43139f5f73e9d5cb43649d607490c967ff Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Wed, 4 Feb 2026 14:27:15 +0530 Subject: [PATCH] Add expected checkout preview API using property billing policy defaults --- docs/API_REFERENCE.txt | 47 +++++++++ .../controller/booking/BookingFlow.kt | 95 +++++++++++++++++++ .../controller/dto/booking/BookingDtos.kt | 16 ++++ 3 files changed, 158 insertions(+) diff --git a/docs/API_REFERENCE.txt b/docs/API_REFERENCE.txt index c2a9f39..54f8f49 100644 --- a/docs/API_REFERENCE.txt +++ b/docs/API_REFERENCE.txt @@ -1655,6 +1655,53 @@ BOOKING APIS - 404 Not Found +- Preview expected checkout API is this one: + + POST /properties/{propertyId}/bookings/expected-checkout-preview + + What it does: + + - Computes expected check-out timestamp from check-in + billing policy. + - `checkInAt` is required. + - `billableNights` is optional (defaults to 1). + - `billingMode` is optional (defaults to property policy mode behavior). + - `billingCheckinTime` and `billingCheckoutTime` are optional; defaults come from property billing policy. + - `propertyId` is required in path. + + Request body (BookingExpectedCheckoutPreviewRequest): + + { + "checkInAt": "2026-02-05T10:30:00+05:30", + "billableNights": 2, + "billingMode": "PROPERTY_POLICY", + "billingCheckinTime": "12:00", + "billingCheckoutTime": "11:00" + } + + Response body (BookingExpectedCheckoutPreviewResponse): + + { + "expectedCheckOutAt": "2026-02-07T11:00+05:30", + "billableNights": 2, + "billingMode": "PROPERTY_POLICY", + "billingCheckinTime": "12:00", + "billingCheckoutTime": "11:00" + } + + - Allowed roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE + + Error Codes + + - 400 Bad Request + - checkInAt required or invalid timestamp + - billableNights must be > 0 + - Unknown billing mode + - billingCheckinTime/billingCheckoutTime invalid format + - 401 Unauthorized + - 403 Forbidden + - 404 Not Found (property) + + - Booking stream API is this one: GET /properties/{propertyId}/bookings/{bookingId}/stream diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt index 6b0d076..898bf9d 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt @@ -12,6 +12,8 @@ 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 @@ -66,6 +68,7 @@ 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 @@ -339,6 +342,60 @@ class BookingFlow( ) } + @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, @@ -831,6 +888,44 @@ class BookingFlow( } } + 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) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/booking/BookingDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/booking/BookingDtos.kt index db0b763..9fcaa0f 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/booking/BookingDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/booking/BookingDtos.kt @@ -145,6 +145,22 @@ data class BookingBillableNightsResponse( val billableNights: Long ) +data class BookingExpectedCheckoutPreviewRequest( + val checkInAt: String, + val billableNights: Long? = null, + val billingMode: String? = null, + val billingCheckinTime: String? = null, + val billingCheckoutTime: String? = null +) + +data class BookingExpectedCheckoutPreviewResponse( + val expectedCheckOutAt: String, + val billableNights: Long, + val billingMode: String, + val billingCheckinTime: String, + val billingCheckoutTime: String +) + data class BookingCancelRequest( val cancelledAt: String? = null, val reason: String? = null