diff --git a/docs/API_REFERENCE.txt b/docs/API_REFERENCE.txt index b513d92..c2a9f39 100644 --- a/docs/API_REFERENCE.txt +++ b/docs/API_REFERENCE.txt @@ -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 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 8c59197..b4baa94 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt @@ -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 { + 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) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingSnapshotBuilder.kt b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingSnapshotBuilder.kt index 314c0bd..af8e79d 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingSnapshotBuilder.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingSnapshotBuilder.kt @@ -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 + ) + } } 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 398103d..db0b763 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 @@ -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