Add booking billable nights preview API and detail field
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
This commit is contained in:
@@ -1600,6 +1600,7 @@ BOOKING APIS
|
||||
What it does:
|
||||
|
||||
- Returns booking snapshot details with stays, billing, ledger.
|
||||
- Includes `billableNights` computed from booking timeline + booking billing policy.
|
||||
|
||||
Request body:
|
||||
|
||||
@@ -1614,6 +1615,46 @@ BOOKING APIS
|
||||
- 404 Not Found
|
||||
|
||||
|
||||
- Preview booking billable nights API is this one:
|
||||
|
||||
POST /properties/{propertyId}/bookings/{bookingId}/billable-nights
|
||||
|
||||
What it does:
|
||||
|
||||
- Returns billable nights for the booking based on billing policy.
|
||||
- OPEN booking: request must include both expectedCheckInAt and expectedCheckOutAt.
|
||||
- CHECKED_IN booking: request must include expectedCheckOutAt only.
|
||||
- CHECKED_OUT / CANCELLED / NO_SHOW booking: request must not send expected dates; server uses saved booking timeline.
|
||||
|
||||
Request body (BookingBillableNightsRequest):
|
||||
|
||||
{
|
||||
"expectedCheckInAt": "2026-02-05T12:00:00+05:30",
|
||||
"expectedCheckOutAt": "2026-02-06T11:00:00+05:30"
|
||||
}
|
||||
|
||||
Response body (BookingBillableNightsResponse):
|
||||
|
||||
{
|
||||
"bookingId": "uuid",
|
||||
"status": "CHECKED_IN",
|
||||
"billableNights": 2
|
||||
}
|
||||
|
||||
- Allowed roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE
|
||||
|
||||
Error Codes
|
||||
|
||||
- 400 Bad Request
|
||||
- Invalid timestamp format ("Invalid timestamp")
|
||||
- Invalid range ("Invalid date range")
|
||||
- Missing required expected date by status
|
||||
- expected dates provided for closed booking
|
||||
- 401 Unauthorized
|
||||
- 403 Forbidden
|
||||
- 404 Not Found
|
||||
|
||||
|
||||
- Booking stream API is this one:
|
||||
|
||||
GET /properties/{propertyId}/bookings/{bookingId}/stream
|
||||
|
||||
@@ -10,6 +10,8 @@ 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.BookingBulkCheckInRequest
|
||||
import com.android.trisolarisserver.controller.dto.booking.BookingCheckOutRequest
|
||||
import com.android.trisolarisserver.controller.dto.booking.BookingCreateRequest
|
||||
@@ -298,6 +300,44 @@ class BookingFlow(
|
||||
return bookingSnapshotBuilder.build(propertyId, bookingId)
|
||||
}
|
||||
|
||||
@PostMapping("/{bookingId}/billable-nights")
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/{bookingId}/stream")
|
||||
fun streamBooking(
|
||||
@PathVariable propertyId: UUID,
|
||||
@@ -753,6 +793,43 @@ class BookingFlow(
|
||||
}
|
||||
}
|
||||
|
||||
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 parseTransportMode(value: String): TransportMode {
|
||||
return try {
|
||||
TransportMode.valueOf(value)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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.computeExpectedPayTotal
|
||||
import com.android.trisolarisserver.controller.common.nowForProperty
|
||||
|
||||
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
|
||||
import com.android.trisolarisserver.models.booking.BookingStatus
|
||||
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||
import com.android.trisolarisserver.repo.booking.ChargeRepo
|
||||
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
|
||||
@@ -50,6 +53,7 @@ class BookingSnapshotBuilder(
|
||||
}
|
||||
|
||||
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
|
||||
val billableNights = computeBookingBillableNights(booking)
|
||||
val expectedPay = computeExpectedPayTotal(
|
||||
stays,
|
||||
booking.expectedCheckoutAt,
|
||||
@@ -103,9 +107,39 @@ class BookingSnapshotBuilder(
|
||||
registeredByName = booking.createdBy?.name,
|
||||
registeredByPhone = booking.createdBy?.phoneE164,
|
||||
totalNightlyRate = totalNightlyRate,
|
||||
billableNights = billableNights,
|
||||
expectedPay = expectedPay + extraCharges,
|
||||
amountCollected = amountCollected,
|
||||
pending = pending
|
||||
)
|
||||
}
|
||||
|
||||
private fun computeBookingBillableNights(booking: com.android.trisolarisserver.models.booking.Booking): Long? {
|
||||
val startAt: java.time.OffsetDateTime
|
||||
val endAt: java.time.OffsetDateTime
|
||||
when (booking.status) {
|
||||
BookingStatus.OPEN -> {
|
||||
startAt = booking.expectedCheckinAt ?: return null
|
||||
endAt = booking.expectedCheckoutAt ?: return null
|
||||
}
|
||||
BookingStatus.CHECKED_IN -> {
|
||||
startAt = booking.checkinAt ?: booking.expectedCheckinAt ?: return null
|
||||
endAt = booking.expectedCheckoutAt ?: nowForProperty(booking.property.timezone)
|
||||
}
|
||||
BookingStatus.CHECKED_OUT,
|
||||
BookingStatus.CANCELLED,
|
||||
BookingStatus.NO_SHOW -> {
|
||||
startAt = booking.checkinAt ?: booking.expectedCheckinAt ?: return null
|
||||
endAt = booking.checkoutAt ?: booking.expectedCheckoutAt ?: return null
|
||||
}
|
||||
}
|
||||
return billableNights(
|
||||
startAt = startAt,
|
||||
endAt = endAt,
|
||||
timezone = booking.property.timezone,
|
||||
billingMode = booking.billingMode,
|
||||
billingCheckinTime = booking.billingCheckinTime,
|
||||
billingCheckoutTime = booking.billingCheckoutTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ data class BookingDetailResponse(
|
||||
val registeredByName: String?,
|
||||
val registeredByPhone: String?,
|
||||
val totalNightlyRate: Long,
|
||||
val billableNights: Long?,
|
||||
val expectedPay: Long,
|
||||
val amountCollected: Long,
|
||||
val pending: Long
|
||||
@@ -133,6 +134,17 @@ data class BookingCheckOutRequest(
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class BookingBillableNightsRequest(
|
||||
val expectedCheckInAt: String? = null,
|
||||
val expectedCheckOutAt: String? = null
|
||||
)
|
||||
|
||||
data class BookingBillableNightsResponse(
|
||||
val bookingId: UUID,
|
||||
val status: String,
|
||||
val billableNights: Long
|
||||
)
|
||||
|
||||
data class BookingCancelRequest(
|
||||
val cancelledAt: String? = null,
|
||||
val reason: String? = null
|
||||
|
||||
Reference in New Issue
Block a user