diff --git a/AGENTS.md b/AGENTS.md index b96c2f5..871a44a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,6 +196,10 @@ Notes / constraints - Property code is auto-generated (7-char random, no fixed prefix). Property create no longer accepts `code` in request. Join-by-code uses property code, not propertyId. - Property access codes: 6-digit PIN, 1-minute expiry, single-use. Admin generates; staff joins with property code + PIN. - Property user disable is property-scoped (not global); hierarchy applies for who can disable. +- Room stay lifecycle: `RoomStay` now supports soft void (`is_voided`), and room-stay audit events are written to `room_stay_audit_log`. +- Checkout supports both booking-level and specific room-stay checkout; specific checkout endpoint: `POST /properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out`. +- Staff can change/void stays only before first payment on booking; manager/admin can act after payments. +- Checkout validation: nightly rate must be within +/-20% of room type default rate (when default rate exists), and minimum stay duration must be at least 1 hour. Operational notes - Payment provider migrated: PayU removed; Razorpay now used for settings, QR, payment links, and webhooks. 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 0249f0f..dfecb29 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt @@ -27,6 +27,7 @@ import com.android.trisolarisserver.repo.guest.GuestRatingRepo import com.android.trisolarisserver.models.booking.BookingStatus import com.android.trisolarisserver.models.booking.MemberRelation import com.android.trisolarisserver.models.booking.TransportMode +import com.android.trisolarisserver.models.room.RoomStayAuditLog import com.android.trisolarisserver.models.room.RoomStay import com.android.trisolarisserver.models.room.RateSource import com.android.trisolarisserver.models.property.Role @@ -35,6 +36,7 @@ import com.android.trisolarisserver.repo.guest.GuestVehicleRepo import com.android.trisolarisserver.repo.booking.PaymentRepo import com.android.trisolarisserver.repo.property.PropertyRepo import com.android.trisolarisserver.repo.room.RoomRepo +import com.android.trisolarisserver.repo.room.RoomStayAuditLogRepo import com.android.trisolarisserver.repo.room.RoomStayRepo import com.android.trisolarisserver.security.MyPrincipal import jakarta.servlet.http.HttpServletResponse @@ -69,7 +71,8 @@ class BookingFlow( private val guestRatingRepo: GuestRatingRepo, private val guestDocumentRepo: GuestDocumentRepo, private val paymentRepo: PaymentRepo, - private val bookingSnapshotBuilder: BookingSnapshotBuilder + private val bookingSnapshotBuilder: BookingSnapshotBuilder, + private val roomStayAuditLogRepo: RoomStayAuditLogRepo ) { @PostMapping @@ -511,7 +514,7 @@ class BookingFlow( @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingCheckOutRequest ) { - requireActor(propertyId, principal) + val actor = requireActor(propertyId, principal) val booking = requireBooking(propertyId, bookingId) if (booking.status != BookingStatus.CHECKED_IN) { throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not checked in") @@ -520,8 +523,24 @@ class BookingFlow( val checkOutAt = parseOffset(request.checkOutAt) ?: now val stays = roomStayRepo.findActiveByBookingId(bookingId) + if (stays.any { !isCheckoutAmountValid(it) || !isMinimumStayDurationValid(it, checkOutAt) }) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay amount is outside allowed range") + } + val oldStates = stays.associate { it.id!! to it.toAt } stays.forEach { it.toAt = checkOutAt } roomStayRepo.saveAll(stays) + stays.forEach { + logStayAudit( + stay = it, + action = "CHECK_OUT", + actor = actor, + oldToAt = oldStates[it.id], + oldIsVoided = it.isVoided, + newToAt = checkOutAt, + newIsVoided = false, + reason = request.notes + ) + } booking.status = BookingStatus.CHECKED_OUT booking.checkoutAt = checkOutAt @@ -532,6 +551,62 @@ class BookingFlow( bookingEvents.emit(propertyId, bookingId) } + @PostMapping("/{bookingId}/room-stays/{roomStayId}/check-out") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + fun checkOutRoomStay( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @PathVariable roomStayId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: BookingCheckOutRequest + ) { + val actor = requireActor(propertyId, principal) + val booking = requireBooking(propertyId, bookingId) + if (booking.status != BookingStatus.CHECKED_IN) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not checked in") + } + val stay = roomStayRepo.findByIdAndBookingId(roomStayId, bookingId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for booking") + if (stay.toAt != null) { + return + } + + val now = OffsetDateTime.now() + val checkOutAt = parseOffset(request.checkOutAt) ?: now + if (!isCheckoutAmountValid(stay)) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay amount is outside allowed range") + } + if (!isMinimumStayDurationValid(stay, checkOutAt)) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Minimum stay duration is 1 hour") + } + + val oldToAt = stay.toAt + val oldIsVoided = stay.isVoided + stay.toAt = checkOutAt + roomStayRepo.save(stay) + logStayAudit( + stay = stay, + action = "CHECK_OUT", + actor = actor, + oldToAt = oldToAt, + oldIsVoided = oldIsVoided, + newToAt = checkOutAt, + newIsVoided = false, + reason = request.notes + ) + + val remainingActive = roomStayRepo.findActiveByBookingId(bookingId) + if (remainingActive.isEmpty()) { + booking.status = BookingStatus.CHECKED_OUT + booking.checkoutAt = checkOutAt + booking.updatedAt = now + bookingRepo.save(booking) + } + roomBoardEvents.emit(propertyId) + bookingEvents.emit(propertyId, bookingId) + } + private fun resolveGuestForBooking( propertyId: UUID, property: com.android.trisolarisserver.models.property.Property, @@ -706,6 +781,44 @@ class BookingFlow( } } + private fun isCheckoutAmountValid(stay: RoomStay): Boolean { + val base = stay.room.roomType.defaultRate ?: return true + val nightly = stay.nightlyRate ?: return false + val low = (base * 80L) / 100L + val high = (base * 120L) / 100L + return nightly in low..high + } + + private fun isMinimumStayDurationValid(stay: RoomStay, checkOutAt: OffsetDateTime): Boolean { + return java.time.Duration.between(stay.fromAt, checkOutAt).toMinutes() >= 60 + } + + private fun logStayAudit( + stay: RoomStay, + action: String, + actor: com.android.trisolarisserver.models.property.AppUser?, + oldToAt: OffsetDateTime?, + oldIsVoided: Boolean, + newToAt: OffsetDateTime?, + newIsVoided: Boolean, + reason: String? + ) { + roomStayAuditLogRepo.save( + RoomStayAuditLog( + property = stay.property, + booking = stay.booking, + roomStay = stay, + action = action, + oldToAt = oldToAt, + newToAt = newToAt, + oldIsVoided = oldIsVoided, + newIsVoided = newIsVoided, + reason = reason, + actor = actor + ) + ) + } + private fun isTransportModeAllowed( property: com.android.trisolarisserver.models.property.Property, mode: TransportMode diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/common/ControllerLookups.kt b/src/main/kotlin/com/android/trisolarisserver/controller/common/ControllerLookups.kt index 4b08185..8b65d39 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/common/ControllerLookups.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/common/ControllerLookups.kt @@ -58,7 +58,7 @@ internal fun requireRoomStayForProperty( val stay = roomStayRepo.findById(roomStayId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found") } - if (stay.property.id != propertyId) { + if (stay.property.id != propertyId || stay.isVoided) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property") } return stay @@ -108,6 +108,7 @@ internal fun computeExpectedPay(stays: List, timezone: String?): Long val now = nowForProperty(timezone) var total = 0L stays.forEach { stay -> + if (stay.isVoided) return@forEach val rate = stay.nightlyRate ?: 0L if (rate == 0L) return@forEach val start = stay.fromAt.toLocalDate() @@ -128,6 +129,7 @@ internal fun computeExpectedPayTotal( val now = nowForProperty(timezone) var total = 0L stays.forEach { stay -> + if (stay.isVoided) return@forEach val rate = stay.nightlyRate ?: 0L if (rate == 0L) return@forEach val start = stay.fromAt.toLocalDate() 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 83f06d3..ab6b13d 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 @@ -161,6 +161,10 @@ data class RoomStayPreAssignRequest( val notes: String? = null ) +data class RoomStayVoidRequest( + val reason: String? = null +) + data class IssueCardRequest( val cardId: String, val cardIndex: Int, diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/room/RoomStays.kt b/src/main/kotlin/com/android/trisolarisserver/controller/room/RoomStays.kt index bfb59f7..30616c9 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/room/RoomStays.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/room/RoomStays.kt @@ -4,14 +4,19 @@ import com.android.trisolarisserver.controller.common.requireMember import com.android.trisolarisserver.controller.common.requireRoomStayForProperty import com.android.trisolarisserver.component.auth.PropertyAccess +import com.android.trisolarisserver.controller.dto.booking.RoomStayVoidRequest import com.android.trisolarisserver.controller.dto.room.ActiveRoomStayResponse import com.android.trisolarisserver.controller.dto.rate.RoomStayRateChangeRequest import com.android.trisolarisserver.controller.dto.rate.RoomStayRateChangeResponse import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.models.room.RateSource +import com.android.trisolarisserver.models.room.RoomStayAuditLog import com.android.trisolarisserver.models.room.RoomStay +import com.android.trisolarisserver.repo.booking.BookingRepo import com.android.trisolarisserver.repo.booking.PaymentRepo +import com.android.trisolarisserver.repo.property.AppUserRepo import com.android.trisolarisserver.repo.property.PropertyUserRepo +import com.android.trisolarisserver.repo.room.RoomStayAuditLogRepo import com.android.trisolarisserver.repo.room.RoomStayRepo import com.android.trisolarisserver.security.MyPrincipal import org.springframework.http.HttpStatus @@ -29,7 +34,10 @@ import java.util.UUID class RoomStays( private val propertyAccess: PropertyAccess, private val propertyUserRepo: PropertyUserRepo, + private val appUserRepo: AppUserRepo, + private val bookingRepo: BookingRepo, private val paymentRepo: PaymentRepo, + private val roomStayAuditLogRepo: RoomStayAuditLogRepo, private val roomStayRepo: RoomStayRepo ) { @@ -110,6 +118,61 @@ class RoomStays( ) } + @PostMapping("/properties/{propertyId}/room-stays/{roomStayId}/void") + fun voidRoomStay( + @PathVariable propertyId: UUID, + @PathVariable roomStayId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: RoomStayVoidRequest + ) { + val actor = requireMember(propertyAccess, propertyId, principal) + val stay = requireRoomStayForProperty(roomStayRepo, propertyId, roomStayId) + if (stay.isVoided) return + if (stay.toAt != null) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot void checked-out room stay") + } + val roles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, actor.userId) + val hasPrivilegedRole = roles.contains(Role.ADMIN) || roles.contains(Role.MANAGER) + val hasStaffRole = roles.contains(Role.STAFF) + if (!hasPrivilegedRole && !hasStaffRole) { + throw ResponseStatusException(HttpStatus.FORBIDDEN, "Missing role") + } + if (!hasPrivilegedRole && paymentRepo.existsByBookingId(stay.booking.id!!)) { + throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot void stay after first payment") + } + + val appUser = appUserRepo.findById(actor.userId).orElse(null) + val oldToAt = stay.toAt + stay.isVoided = true + stay.toAt = OffsetDateTime.now() + roomStayRepo.save(stay) + roomStayAuditLogRepo.save( + RoomStayAuditLog( + property = stay.property, + booking = stay.booking, + roomStay = stay, + action = "VOID", + oldToAt = oldToAt, + newToAt = stay.toAt, + oldIsVoided = false, + newIsVoided = true, + reason = request.reason, + actor = appUser + ) + ) + + val booking = bookingRepo.findById(stay.booking.id!!).orElse(null) + if (booking != null && booking.status == com.android.trisolarisserver.models.booking.BookingStatus.CHECKED_IN) { + val active = roomStayRepo.findActiveByBookingId(booking.id!!) + if (active.isEmpty()) { + booking.status = com.android.trisolarisserver.models.booking.BookingStatus.CHECKED_OUT + booking.checkoutAt = OffsetDateTime.now() + booking.updatedAt = OffsetDateTime.now() + bookingRepo.save(booking) + } + } + } + private fun splitStay(stay: RoomStay, effectiveAt: OffsetDateTime, request: RoomStayRateChangeRequest): RoomStay { val oldToAt = stay.toAt stay.toAt = effectiveAt diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/RoomStay.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomStay.kt index a91d2d3..fa47a7e 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/room/RoomStay.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomStay.kt @@ -33,6 +33,9 @@ class RoomStay( @Column(name = "to_at", columnDefinition = "timestamptz") var toAt: OffsetDateTime? = null, // null = active + @Column(name = "is_voided", nullable = false) + var isVoided: Boolean = false, + @Enumerated(EnumType.STRING) @Column(name = "rate_source") var rateSource: RateSource? = null, diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/RoomStayAuditLog.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomStayAuditLog.kt new file mode 100644 index 0000000..3919c2e --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomStayAuditLog.kt @@ -0,0 +1,61 @@ +package com.android.trisolarisserver.models.room + +import com.android.trisolarisserver.models.booking.Booking +import com.android.trisolarisserver.models.property.AppUser +import com.android.trisolarisserver.models.property.Property +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table(name = "room_stay_audit_log") +class RoomStayAuditLog( + @Id + @GeneratedValue + @Column(columnDefinition = "uuid") + val id: UUID? = null, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "property_id", nullable = false) + var property: Property, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "booking_id", nullable = false) + var booking: Booking, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "room_stay_id", nullable = false) + var roomStay: RoomStay, + + @Column(name = "action", nullable = false) + var action: String, + + @Column(name = "old_to_at", columnDefinition = "timestamptz") + var oldToAt: OffsetDateTime? = null, + + @Column(name = "new_to_at", columnDefinition = "timestamptz") + var newToAt: OffsetDateTime? = null, + + @Column(name = "old_is_voided", nullable = false) + var oldIsVoided: Boolean = false, + + @Column(name = "new_is_voided", nullable = false) + var newIsVoided: Boolean = false, + + @Column(name = "reason") + var reason: String? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "actor_user_id") + var actor: AppUser? = null, + + @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") + val createdAt: OffsetDateTime = OffsetDateTime.now() +) diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayAuditLogRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayAuditLogRepo.kt new file mode 100644 index 0000000..0bba884 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayAuditLogRepo.kt @@ -0,0 +1,7 @@ +package com.android.trisolarisserver.repo.room + +import com.android.trisolarisserver.models.room.RoomStayAuditLog +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface RoomStayAuditLogRepo : JpaRepository diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayRepo.kt index fa72d05..cf89690 100644 --- a/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayRepo.kt @@ -12,6 +12,7 @@ interface RoomStayRepo : JpaRepository { from RoomStay rs where rs.property.id = :propertyId and rs.toAt is null + and rs.isVoided = false """) fun findOccupiedRoomIds(@Param("propertyId") propertyId: UUID): List @@ -19,6 +20,7 @@ interface RoomStayRepo : JpaRepository { select distinct rs.room.id from RoomStay rs where rs.property.id = :propertyId + and rs.isVoided = false and rs.fromAt < :toAt and (rs.toAt is null or rs.toAt > :fromAt) """) @@ -33,6 +35,7 @@ interface RoomStayRepo : JpaRepository { from RoomStay rs where rs.booking.id = :bookingId and rs.toAt is null + and rs.isVoided = false """) fun findActiveByBookingId(@Param("bookingId") bookingId: UUID): List @@ -40,6 +43,7 @@ interface RoomStayRepo : JpaRepository { select rs from RoomStay rs where rs.booking.id = :bookingId + and rs.isVoided = false """) fun findByBookingId(@Param("bookingId") bookingId: UUID): List @@ -48,6 +52,7 @@ interface RoomStayRepo : JpaRepository { from RoomStay rs join fetch rs.room r where rs.booking.id = :bookingId + and rs.isVoided = false """) fun findByBookingIdWithRoom(@Param("bookingId") bookingId: UUID): List @@ -55,6 +60,7 @@ interface RoomStayRepo : JpaRepository { select rs from RoomStay rs where rs.booking.id in :bookingIds + and rs.isVoided = false """) fun findByBookingIdIn(@Param("bookingIds") bookingIds: List): List @@ -64,6 +70,7 @@ interface RoomStayRepo : JpaRepository { where rs.property.id = :propertyId and rs.room.id in :roomIds and rs.toAt is null + and rs.isVoided = false """) fun findActiveRoomIds( @Param("propertyId") propertyId: UUID, @@ -75,6 +82,7 @@ interface RoomStayRepo : JpaRepository { from RoomStay rs where rs.property.id = :propertyId and rs.room.id = :roomId + and rs.isVoided = false and rs.fromAt < :toAt and (rs.toAt is null or rs.toAt > :fromAt) """) @@ -94,6 +102,7 @@ interface RoomStayRepo : JpaRepository { left join fetch b.primaryGuest g where rs.property.id = :propertyId and rs.toAt is null + and rs.isVoided = false order by r.roomNumber """) fun findActiveByPropertyIdWithDetails(@Param("propertyId") propertyId: UUID): List @@ -111,10 +120,25 @@ interface RoomStayRepo : JpaRepository { join rs.room r where rs.booking.id in :bookingIds and rs.toAt is null + and rs.isVoided = false """) fun findActiveRoomNumbersByBookingIds( @Param("bookingIds") bookingIds: List ): List + + @Query( + """ + select rs + from RoomStay rs + where rs.id = :roomStayId + and rs.booking.id = :bookingId + and rs.isVoided = false + """ + ) + fun findByIdAndBookingId( + @Param("roomStayId") roomStayId: UUID, + @Param("bookingId") bookingId: UUID + ): RoomStay? } interface BookingRoomNumberRow {