Add booking billable nights preview API and detail field
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s

This commit is contained in:
androidlover5842
2026-02-04 13:52:39 +05:30
parent ff911661a4
commit 0694cf0b8a
4 changed files with 164 additions and 0 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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
)
}
}

View File

@@ -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