Add expected checkout preview API using property billing policy defaults
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user