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
|
- 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:
|
- Booking stream API is this one:
|
||||||
|
|
||||||
GET /properties/{propertyId}/bookings/{bookingId}/stream
|
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.BookingCancelRequest
|
||||||
import com.android.trisolarisserver.controller.dto.booking.BookingBillableNightsRequest
|
import com.android.trisolarisserver.controller.dto.booking.BookingBillableNightsRequest
|
||||||
import com.android.trisolarisserver.controller.dto.booking.BookingBillableNightsResponse
|
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.BookingBulkCheckInRequest
|
||||||
import com.android.trisolarisserver.controller.dto.booking.BookingCheckOutRequest
|
import com.android.trisolarisserver.controller.dto.booking.BookingCheckOutRequest
|
||||||
import com.android.trisolarisserver.controller.dto.booking.BookingCreateRequest
|
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 org.springframework.web.server.ResponseStatusException
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.DateTimeParseException
|
import java.time.format.DateTimeParseException
|
||||||
import kotlin.math.abs
|
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")
|
@GetMapping("/{bookingId}/stream")
|
||||||
fun streamBooking(
|
fun streamBooking(
|
||||||
@PathVariable propertyId: UUID,
|
@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 {
|
private fun parseTransportMode(value: String): TransportMode {
|
||||||
return try {
|
return try {
|
||||||
TransportMode.valueOf(value)
|
TransportMode.valueOf(value)
|
||||||
|
|||||||
@@ -145,6 +145,22 @@ data class BookingBillableNightsResponse(
|
|||||||
val billableNights: Long
|
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(
|
data class BookingCancelRequest(
|
||||||
val cancelledAt: String? = null,
|
val cancelledAt: String? = null,
|
||||||
val reason: String? = null
|
val reason: String? = null
|
||||||
|
|||||||
Reference in New Issue
Block a user