diff --git a/AGENTS.md b/AGENTS.md index 24ff05a..4a44bef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,14 +48,16 @@ Security/Auth Domain entities - Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes. - AppUser (global, superAdmin), PropertyUser (roles per property). -- RoomType: code/name/occupancy + otaAliases. +- RoomType: code/name/occupancy + otaAliases + defaultRate. - Room: roomNumber, floor, hasNfc, active, maintenance, notes. - Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode. - Guest (property-scoped). -- RoomStay. +- RoomStay (rate fields stored on stay). - RoomStayChange (idempotent room move). - IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt). - PropertyCardCounter (per-property cardIndex counter). +- RatePlan + RateCalendar. +- Payment (ledger). - GuestDocument (files + AI-extracted json). - GuestVehicle (property-scoped vehicle numbers). - InboundEmail (audit PDF + raw EML, extracted json, status). @@ -88,6 +90,7 @@ Rooms / inventory Room types - POST /properties/{propertyId}/room-types - GET /properties/{propertyId}/room-types +- GET /properties/{propertyId}/room-types/{roomTypeCode}/rate?date=YYYY-MM-DD&ratePlanCode=optional - PUT /properties/{propertyId}/room-types/{roomTypeId} - DELETE /properties/{propertyId}/room-types/{roomTypeId} @@ -117,6 +120,23 @@ Guest APIs - /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=... - /properties/{propertyId}/guests/{guestId}/vehicles (add vehicle) +Room stays +- POST /properties/{propertyId}/room-stays/{roomStayId}/change-rate + +Rate plans +- POST /properties/{propertyId}/rate-plans +- GET /properties/{propertyId}/rate-plans +- PUT /properties/{propertyId}/rate-plans/{ratePlanId} +- DELETE /properties/{propertyId}/rate-plans/{ratePlanId} +- POST /properties/{propertyId}/rate-plans/{ratePlanId}/calendar +- GET /properties/{propertyId}/rate-plans/{ratePlanId}/calendar?from=YYYY-MM-DD&to=YYYY-MM-DD +- DELETE /properties/{propertyId}/rate-plans/{ratePlanId}/calendar/{rateDate} + +Payments +- POST /properties/{propertyId}/bookings/{bookingId}/payments +- GET /properties/{propertyId}/bookings/{bookingId}/payments +- GET /properties/{propertyId}/bookings/{bookingId}/balance + Guest documents - /properties/{propertyId}/guests/{guestId}/documents (upload/list) - /properties/{propertyId}/guests/{guestId}/documents/{documentId}/file diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/BookingBalances.kt b/src/main/kotlin/com/android/trisolarisserver/controller/BookingBalances.kt new file mode 100644 index 0000000..039910e --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/BookingBalances.kt @@ -0,0 +1,72 @@ +package com.android.trisolarisserver.controller + +import com.android.trisolarisserver.component.PropertyAccess +import com.android.trisolarisserver.controller.dto.BookingBalanceResponse +import com.android.trisolarisserver.db.repo.BookingRepo +import com.android.trisolarisserver.repo.PaymentRepo +import com.android.trisolarisserver.repo.RoomStayRepo +import com.android.trisolarisserver.security.MyPrincipal +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.time.LocalDate +import java.util.UUID + +@RestController +@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/balance") +class BookingBalances( + private val propertyAccess: PropertyAccess, + private val bookingRepo: BookingRepo, + private val roomStayRepo: RoomStayRepo, + private val paymentRepo: PaymentRepo +) { + + @GetMapping + fun getBalance( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @AuthenticationPrincipal principal: MyPrincipal? + ): BookingBalanceResponse { + requireMember(propertyAccess, propertyId, principal) + val booking = bookingRepo.findById(bookingId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found") + } + if (booking.property.id != propertyId) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property") + } + val expected = computeExpectedPay(bookingId, booking.property.timezone) + val collected = paymentRepo.sumAmountByBookingId(bookingId) + val pending = expected - collected + return BookingBalanceResponse( + expectedPay = expected, + amountCollected = collected, + pending = pending + ) + } + + private fun computeExpectedPay(bookingId: UUID, timezone: String?): Long { + val stays = roomStayRepo.findByBookingId(bookingId) + if (stays.isEmpty()) return 0 + val now = nowForProperty(timezone) + var total = 0L + stays.forEach { stay -> + val rate = stay.nightlyRate ?: 0L + if (rate == 0L) return@forEach + val start = stay.fromAt.toLocalDate() + val endAt = stay.toAt ?: now + val end = endAt.toLocalDate() + val nights = daysBetweenInclusive(start, end) + total += rate * nights + } + return total + } + + private fun daysBetweenInclusive(start: LocalDate, end: LocalDate): Long { + val diff = end.toEpochDay() - start.toEpochDay() + return if (diff <= 0) 1L else diff + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt index f24b5c5..27f3380 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt @@ -13,6 +13,7 @@ import com.android.trisolarisserver.db.repo.BookingRepo import com.android.trisolarisserver.models.booking.BookingStatus import com.android.trisolarisserver.models.booking.TransportMode import com.android.trisolarisserver.models.room.RoomStay +import com.android.trisolarisserver.models.room.RateSource import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.repo.AppUserRepo import com.android.trisolarisserver.repo.PropertyRepo @@ -142,6 +143,10 @@ class BookingFlow( room = room, fromAt = checkInAt, toAt = null, + rateSource = parseRateSource(request.rateSource), + nightlyRate = request.nightlyRate, + ratePlanCode = request.ratePlanCode, + currency = request.currency ?: booking.property.currency, createdBy = actor ) roomStayRepo.save(stay) @@ -273,6 +278,10 @@ class BookingFlow( room = room, fromAt = fromAt, toAt = toAt, + rateSource = parseRateSource(request.rateSource), + nightlyRate = request.nightlyRate, + ratePlanCode = request.ratePlanCode, + currency = request.currency ?: booking.property.currency, createdBy = actor ) roomStayRepo.save(stay) @@ -303,6 +312,15 @@ class BookingFlow( } } + private fun parseRateSource(value: String?): RateSource? { + if (value.isNullOrBlank()) return null + return try { + RateSource.valueOf(value.trim()) + } catch (_: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown rate source") + } + } + private fun isTransportModeAllowed( property: com.android.trisolarisserver.models.property.Property, mode: TransportMode diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/Payments.kt b/src/main/kotlin/com/android/trisolarisserver/controller/Payments.kt new file mode 100644 index 0000000..48d5a03 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/Payments.kt @@ -0,0 +1,115 @@ +package com.android.trisolarisserver.controller + +import com.android.trisolarisserver.component.PropertyAccess +import com.android.trisolarisserver.controller.dto.PaymentCreateRequest +import com.android.trisolarisserver.controller.dto.PaymentResponse +import com.android.trisolarisserver.db.repo.BookingRepo +import com.android.trisolarisserver.models.booking.Payment +import com.android.trisolarisserver.models.booking.PaymentMethod +import com.android.trisolarisserver.models.property.Role +import com.android.trisolarisserver.repo.AppUserRepo +import com.android.trisolarisserver.repo.PaymentRepo +import com.android.trisolarisserver.repo.PropertyRepo +import com.android.trisolarisserver.security.MyPrincipal +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.time.LocalDate +import java.time.OffsetDateTime +import java.util.UUID + +@RestController +@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/payments") +class Payments( + private val propertyAccess: PropertyAccess, + private val propertyRepo: PropertyRepo, + private val bookingRepo: BookingRepo, + private val paymentRepo: PaymentRepo, + private val appUserRepo: AppUserRepo +) { + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: PaymentCreateRequest + ): PaymentResponse { + val actor = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF) + val property = propertyRepo.findById(propertyId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") + } + val booking = bookingRepo.findById(bookingId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found") + } + if (booking.property.id != propertyId) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property") + } + if (request.amount <= 0) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "amount must be > 0") + } + + val method = parseMethod(request.method) + val receivedAt = parseOffset(request.receivedAt) ?: OffsetDateTime.now() + val payment = Payment( + property = property, + booking = booking, + amount = request.amount, + currency = request.currency ?: property.currency, + method = method, + reference = request.reference?.trim()?.ifBlank { null }, + notes = request.notes?.trim()?.ifBlank { null }, + receivedAt = receivedAt, + receivedBy = appUserRepo.findById(actor.userId).orElse(null) + ) + return paymentRepo.save(payment).toResponse() + } + + @GetMapping + fun list( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @AuthenticationPrincipal principal: MyPrincipal? + ): List { + requireMember(propertyAccess, propertyId, principal) + val booking = bookingRepo.findById(bookingId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found") + } + if (booking.property.id != propertyId) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property") + } + return paymentRepo.findByBookingIdOrderByReceivedAtDesc(bookingId).map { it.toResponse() } + } + + private fun parseMethod(value: String): PaymentMethod { + return try { + PaymentMethod.valueOf(value.trim()) + } catch (_: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown payment method") + } + } +} + +private fun Payment.toResponse(): PaymentResponse { + val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Payment id missing") + val bookingId = booking.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Booking id missing") + return PaymentResponse( + id = id, + bookingId = bookingId, + amount = amount, + currency = currency, + method = method.name, + reference = reference, + notes = notes, + receivedAt = receivedAt.toString(), + receivedByUserId = receivedBy?.id + ) +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/RatePlans.kt b/src/main/kotlin/com/android/trisolarisserver/controller/RatePlans.kt new file mode 100644 index 0000000..0961a2c --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/RatePlans.kt @@ -0,0 +1,222 @@ +package com.android.trisolarisserver.controller + +import com.android.trisolarisserver.component.PropertyAccess +import com.android.trisolarisserver.controller.dto.RateCalendarResponse +import com.android.trisolarisserver.controller.dto.RateCalendarUpsertRequest +import com.android.trisolarisserver.controller.dto.RatePlanCreateRequest +import com.android.trisolarisserver.controller.dto.RatePlanResponse +import com.android.trisolarisserver.controller.dto.RatePlanUpdateRequest +import com.android.trisolarisserver.repo.PropertyRepo +import com.android.trisolarisserver.repo.RateCalendarRepo +import com.android.trisolarisserver.repo.RatePlanRepo +import com.android.trisolarisserver.repo.RoomTypeRepo +import com.android.trisolarisserver.models.property.Role +import com.android.trisolarisserver.models.room.RateCalendar +import com.android.trisolarisserver.models.room.RatePlan +import com.android.trisolarisserver.security.MyPrincipal +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.time.LocalDate +import java.time.OffsetDateTime +import java.util.UUID + +@RestController +@RequestMapping("/properties/{propertyId}/rate-plans") +class RatePlans( + private val propertyAccess: PropertyAccess, + private val propertyRepo: PropertyRepo, + private val roomTypeRepo: RoomTypeRepo, + private val ratePlanRepo: RatePlanRepo, + private val rateCalendarRepo: RateCalendarRepo +) { + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create( + @PathVariable propertyId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: RatePlanCreateRequest + ): RatePlanResponse { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) + if (ratePlanRepo.existsByPropertyIdAndCode(propertyId, request.code.trim())) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Rate plan code already exists") + } + val property = propertyRepo.findById(propertyId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") + } + val roomType = roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, request.roomTypeCode) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found") + + val plan = RatePlan( + property = property, + roomType = roomType, + code = request.code.trim(), + name = request.name.trim(), + baseRate = request.baseRate, + currency = request.currency ?: property.currency, + updatedAt = OffsetDateTime.now() + ) + return ratePlanRepo.save(plan).toResponse() + } + + @GetMapping + fun list( + @PathVariable propertyId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestParam(required = false) roomTypeCode: String? + ): List { + requireMember(propertyAccess, propertyId, principal) + val plans = if (roomTypeCode.isNullOrBlank()) { + ratePlanRepo.findByPropertyIdOrderByCode(propertyId) + } else { + ratePlanRepo.findByPropertyIdOrderByCode(propertyId) + .filter { it.roomType.code.equals(roomTypeCode, ignoreCase = true) } + } + return plans.map { it.toResponse() } + } + + @PutMapping("/{ratePlanId}") + fun update( + @PathVariable propertyId: UUID, + @PathVariable ratePlanId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: RatePlanUpdateRequest + ): RatePlanResponse { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) + val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found") + plan.name = request.name.trim() + plan.baseRate = request.baseRate + plan.currency = request.currency ?: plan.currency + plan.updatedAt = OffsetDateTime.now() + return ratePlanRepo.save(plan).toResponse() + } + + @DeleteMapping("/{ratePlanId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + fun delete( + @PathVariable propertyId: UUID, + @PathVariable ratePlanId: UUID, + @AuthenticationPrincipal principal: MyPrincipal? + ) { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) + val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found") + rateCalendarRepo.findByRatePlanId(plan.id!!).forEach { rateCalendarRepo.delete(it) } + ratePlanRepo.delete(plan) + } + + @PostMapping("/{ratePlanId}/calendar") + @ResponseStatus(HttpStatus.CREATED) + @Transactional + fun upsertCalendar( + @PathVariable propertyId: UUID, + @PathVariable ratePlanId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody requests: List + ): List { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) + val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found") + if (requests.isEmpty()) return emptyList() + + val updates = requests.map { req -> + val date = parseDate(req.rateDate) + val existing = rateCalendarRepo.findByRatePlanIdAndRateDate(plan.id!!, date) + if (existing != null) { + existing.rate = req.rate + existing + } else { + RateCalendar( + property = plan.property, + ratePlan = plan, + rateDate = date, + rate = req.rate + ) + } + } + return rateCalendarRepo.saveAll(updates).map { it.toResponse() } + } + + @GetMapping("/{ratePlanId}/calendar") + fun listCalendar( + @PathVariable propertyId: UUID, + @PathVariable ratePlanId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestParam from: String, + @RequestParam to: String + ): List { + requireMember(propertyAccess, propertyId, principal) + val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found") + val fromDate = parseDate(from) + val toDate = parseDate(to) + val items = rateCalendarRepo.findByRatePlanIdAndRateDateBetweenOrderByRateDateAsc(plan.id!!, fromDate, toDate) + return items.map { it.toResponse() } + } + + @DeleteMapping("/{ratePlanId}/calendar/{rateDate}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteCalendar( + @PathVariable propertyId: UUID, + @PathVariable ratePlanId: UUID, + @PathVariable rateDate: String, + @AuthenticationPrincipal principal: MyPrincipal? + ) { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) + val plan = ratePlanRepo.findByIdAndPropertyId(ratePlanId, propertyId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found") + val date = parseDate(rateDate) + val existing = rateCalendarRepo.findByRatePlanIdAndRateDate(plan.id!!, date) + ?: return + rateCalendarRepo.delete(existing) + } + + private fun parseDate(value: String): LocalDate { + return try { + LocalDate.parse(value.trim()) + } catch (_: Exception) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date") + } + } +} + +private fun RatePlan.toResponse(): RatePlanResponse { + val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Rate plan id missing") + val propertyId = property.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Property id missing") + val roomTypeId = roomType.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Room type id missing") + return RatePlanResponse( + id = id, + propertyId = propertyId, + roomTypeId = roomTypeId, + roomTypeCode = roomType.code, + code = code, + name = name, + baseRate = baseRate, + currency = currency + ) +} + +private fun RateCalendar.toResponse(): RateCalendarResponse { + val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Rate calendar id missing") + val planId = ratePlan.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Rate plan id missing") + return RateCalendarResponse( + id = id, + ratePlanId = planId, + rateDate = rateDate, + rate = rate + ) +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt index f9bc14c..0935a8b 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt @@ -86,6 +86,10 @@ class RoomStayFlow( room = newRoom, fromAt = movedAt, toAt = null, + rateSource = stay.rateSource, + nightlyRate = stay.nightlyRate, + ratePlanCode = stay.ratePlanCode, + currency = stay.currency, createdBy = actor ) val savedNewStay = roomStayRepo.save(newStay) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/RoomStays.kt b/src/main/kotlin/com/android/trisolarisserver/controller/RoomStays.kt index e078e5f..b71345c 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/RoomStays.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/RoomStays.kt @@ -2,7 +2,11 @@ package com.android.trisolarisserver.controller import com.android.trisolarisserver.component.PropertyAccess import com.android.trisolarisserver.controller.dto.ActiveRoomStayResponse +import com.android.trisolarisserver.controller.dto.RoomStayRateChangeRequest +import com.android.trisolarisserver.controller.dto.RoomStayRateChangeResponse import com.android.trisolarisserver.models.property.Role +import com.android.trisolarisserver.models.room.RateSource +import com.android.trisolarisserver.models.room.RoomStay import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.RoomStayRepo import com.android.trisolarisserver.security.MyPrincipal @@ -10,8 +14,11 @@ import org.springframework.http.HttpStatus import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException +import java.time.OffsetDateTime import java.util.UUID @RestController @@ -57,6 +64,60 @@ class RoomStays( } } + @PostMapping("/properties/{propertyId}/room-stays/{roomStayId}/change-rate") + fun changeRate( + @PathVariable propertyId: UUID, + @PathVariable roomStayId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: RoomStayRateChangeRequest + ): RoomStayRateChangeResponse { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) + val stay = requireRoomStayForProperty(roomStayRepo, propertyId, roomStayId) + + val effectiveAt = parseOffset(request.effectiveAt) + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "effectiveAt required") + if (!effectiveAt.isAfter(stay.fromAt)) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "effectiveAt must be after fromAt") + } + if (stay.toAt != null && !effectiveAt.isBefore(stay.toAt)) { + throw ResponseStatusException(HttpStatus.CONFLICT, "effectiveAt outside stay range") + } + + val newStay = splitStay(stay, effectiveAt, request) + roomStayRepo.save(stay) + roomStayRepo.save(newStay) + return RoomStayRateChangeResponse( + oldRoomStayId = stay.id!!, + newRoomStayId = newStay.id!!, + effectiveAt = effectiveAt.toString() + ) + } + + private fun splitStay(stay: RoomStay, effectiveAt: OffsetDateTime, request: RoomStayRateChangeRequest): RoomStay { + val oldToAt = stay.toAt + stay.toAt = effectiveAt + return RoomStay( + property = stay.property, + booking = stay.booking, + room = stay.room, + fromAt = effectiveAt, + toAt = oldToAt, + rateSource = parseRateSource(request.rateSource), + nightlyRate = request.nightlyRate, + ratePlanCode = request.ratePlanCode, + currency = request.currency ?: stay.property.currency, + createdBy = stay.createdBy + ) + } + + private fun parseRateSource(value: String): RateSource { + return try { + RateSource.valueOf(value.trim()) + } catch (_: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown rate source") + } + } + private fun isAgentOnly(roles: Set): Boolean { if (!roles.contains(Role.AGENT)) return false val privileged = setOf( diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/RoomTypes.kt b/src/main/kotlin/com/android/trisolarisserver/controller/RoomTypes.kt index c2ec5d1..ed4e6e6 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/RoomTypes.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/RoomTypes.kt @@ -1,9 +1,12 @@ package com.android.trisolarisserver.controller import com.android.trisolarisserver.component.PropertyAccess +import com.android.trisolarisserver.controller.dto.RateResolveResponse import com.android.trisolarisserver.controller.dto.RoomTypeResponse import com.android.trisolarisserver.controller.dto.RoomTypeUpsertRequest import com.android.trisolarisserver.repo.PropertyRepo +import com.android.trisolarisserver.repo.RateCalendarRepo +import com.android.trisolarisserver.repo.RatePlanRepo import com.android.trisolarisserver.repo.RoomAmenityRepo import com.android.trisolarisserver.repo.RoomRepo import com.android.trisolarisserver.repo.RoomTypeRepo @@ -20,9 +23,11 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException +import java.time.LocalDate import java.util.UUID @RestController @@ -32,7 +37,9 @@ class RoomTypes( private val roomTypeRepo: RoomTypeRepo, private val roomAmenityRepo: RoomAmenityRepo, private val roomRepo: RoomRepo, - private val propertyRepo: PropertyRepo + private val propertyRepo: PropertyRepo, + private val ratePlanRepo: RatePlanRepo, + private val rateCalendarRepo: RateCalendarRepo ) { @GetMapping @@ -46,6 +53,52 @@ class RoomTypes( return roomTypeRepo.findByPropertyIdOrderByCode(propertyId).map { it.toResponse() } } + @GetMapping("/{roomTypeCode}/rate") + fun resolveRate( + @PathVariable propertyId: UUID, + @PathVariable roomTypeCode: String, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestParam date: String, + @RequestParam(required = false) ratePlanCode: String? + ): RateResolveResponse { + if (principal != null) { + propertyAccess.requireMember(propertyId, principal.userId) + } + val property = propertyRepo.findById(propertyId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") + } + val roomType = roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, roomTypeCode) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found") + val rateDate = parseDate(date) + + if (!ratePlanCode.isNullOrBlank()) { + val plan = ratePlanRepo.findByPropertyIdOrderByCode(propertyId) + .firstOrNull { it.code.equals(ratePlanCode, ignoreCase = true) } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rate plan not found") + if (plan.roomType.id != roomType.id) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Rate plan not for room type") + } + val override = rateCalendarRepo.findByRatePlanIdAndRateDate(plan.id!!, rateDate) + val rate = override?.rate ?: plan.baseRate + return RateResolveResponse( + roomTypeCode = roomType.code, + rateDate = rateDate, + rate = rate, + currency = plan.currency, + ratePlanCode = plan.code + ) + } + + val rate = roomType.defaultRate + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Default rate not set") + return RateResolveResponse( + roomTypeCode = roomType.code, + rateDate = rateDate, + rate = rate, + currency = property.currency + ) + } + @PostMapping @ResponseStatus(HttpStatus.CREATED) fun createRoomType( @@ -72,6 +125,7 @@ class RoomTypes( maxOccupancy = request.maxOccupancy ?: 3, sqFeet = request.sqFeet, bathroomSqFeet = request.bathroomSqFeet, + defaultRate = request.defaultRate, active = request.active ?: true, otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf() ) @@ -116,6 +170,7 @@ class RoomTypes( roomType.maxOccupancy = request.maxOccupancy ?: roomType.maxOccupancy roomType.sqFeet = request.sqFeet ?: roomType.sqFeet roomType.bathroomSqFeet = request.bathroomSqFeet ?: roomType.bathroomSqFeet + roomType.defaultRate = request.defaultRate ?: roomType.defaultRate roomType.active = request.active ?: roomType.active if (request.otaAliases != null) { roomType.otaAliases = request.otaAliases.toMutableSet() @@ -148,6 +203,14 @@ class RoomTypes( throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") } } + + private fun parseDate(value: String): LocalDate { + return try { + LocalDate.parse(value.trim()) + } catch (_: Exception) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date") + } + } } private fun RoomType.toResponse(): RoomTypeResponse { @@ -162,6 +225,7 @@ private fun RoomType.toResponse(): RoomTypeResponse { maxOccupancy = maxOccupancy, sqFeet = sqFeet, bathroomSqFeet = bathroomSqFeet, + defaultRate = defaultRate, active = active, otaAliases = otaAliases.toSet(), amenities = amenities.map { it.toResponse() }.toSet() diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt index efac36a..2e1966e 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt @@ -6,6 +6,10 @@ data class BookingCheckInRequest( val roomIds: List, val checkInAt: String? = null, val transportMode: String? = null, + val nightlyRate: Long? = null, + val rateSource: String? = null, + val ratePlanCode: String? = null, + val currency: String? = null, val notes: String? = null ) @@ -60,6 +64,10 @@ data class RoomStayPreAssignRequest( val roomId: UUID, val fromAt: String, val toAt: String, + val nightlyRate: Long? = null, + val rateSource: String? = null, + val ratePlanCode: String? = null, + val currency: String? = null, val notes: String? = null ) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/PaymentDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/PaymentDtos.kt new file mode 100644 index 0000000..4524a40 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/PaymentDtos.kt @@ -0,0 +1,30 @@ +package com.android.trisolarisserver.controller.dto + +import java.util.UUID + +data class PaymentCreateRequest( + val amount: Long, + val method: String, + val currency: String? = null, + val reference: String? = null, + val notes: String? = null, + val receivedAt: String? = null +) + +data class PaymentResponse( + val id: UUID, + val bookingId: UUID, + val amount: Long, + val currency: String, + val method: String, + val reference: String?, + val notes: String?, + val receivedAt: String, + val receivedByUserId: UUID? +) + +data class BookingBalanceResponse( + val expectedPay: Long, + val amountCollected: Long, + val pending: Long +) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/RateDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/RateDtos.kt new file mode 100644 index 0000000..2afb8eb --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/RateDtos.kt @@ -0,0 +1,63 @@ +package com.android.trisolarisserver.controller.dto + +import java.time.LocalDate +import java.util.UUID + +data class RatePlanCreateRequest( + val code: String, + val name: String, + val roomTypeCode: String, + val baseRate: Long, + val currency: String? = null +) + +data class RatePlanUpdateRequest( + val name: String, + val baseRate: Long, + val currency: String? = null +) + +data class RatePlanResponse( + val id: UUID, + val propertyId: UUID, + val roomTypeId: UUID, + val roomTypeCode: String, + val code: String, + val name: String, + val baseRate: Long, + val currency: String +) + +data class RateCalendarUpsertRequest( + val rateDate: String, + val rate: Long +) + +data class RateCalendarResponse( + val id: UUID, + val ratePlanId: UUID, + val rateDate: LocalDate, + val rate: Long +) + +data class RoomStayRateChangeRequest( + val effectiveAt: String, + val nightlyRate: Long, + val rateSource: String, + val ratePlanCode: String? = null, + val currency: String? = null +) + +data class RoomStayRateChangeResponse( + val oldRoomStayId: UUID, + val newRoomStayId: UUID, + val effectiveAt: String +) + +data class RateResolveResponse( + val roomTypeCode: String, + val rateDate: LocalDate, + val rate: Long, + val currency: String, + val ratePlanCode: String? = null +) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomTypeDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomTypeDtos.kt index 65e7bed..c01133d 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomTypeDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomTypeDtos.kt @@ -9,6 +9,7 @@ data class RoomTypeUpsertRequest( val maxOccupancy: Int? = null, val sqFeet: Int? = null, val bathroomSqFeet: Int? = null, + val defaultRate: Long? = null, val active: Boolean? = null, val otaAliases: Set? = null, val amenityIds: Set? = null @@ -23,6 +24,7 @@ data class RoomTypeResponse( val maxOccupancy: Int, val sqFeet: Int?, val bathroomSqFeet: Int?, + val defaultRate: Long?, val active: Boolean, val otaAliases: Set, val amenities: Set diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/Guest.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/Guest.kt index 8773414..eef3dfe 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/booking/Guest.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/Guest.kt @@ -4,7 +4,7 @@ import com.android.trisolarisserver.models.property.Property import jakarta.persistence.* import java.time.OffsetDateTime import java.util.UUID - +//TODO:store signature image of guest @Entity @Table( name = "guest", diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/Payment.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/Payment.kt new file mode 100644 index 0000000..85c47d2 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/Payment.kt @@ -0,0 +1,47 @@ +package com.android.trisolarisserver.models.booking + +import com.android.trisolarisserver.models.property.AppUser +import com.android.trisolarisserver.models.property.Property +import jakarta.persistence.* +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table(name = "payment") +class Payment( + @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, + + @Column(name = "amount", nullable = false) + var amount: Long, + + @Column(name = "currency", nullable = false) + var currency: String, + + @Enumerated(EnumType.STRING) + @Column(name = "method", nullable = false) + var method: PaymentMethod, + + @Column(name = "reference") + var reference: String? = null, + + @Column(name = "notes") + var notes: String? = null, + + @Column(name = "received_at", nullable = false, columnDefinition = "timestamptz") + var receivedAt: OffsetDateTime = OffsetDateTime.now(), + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "received_by") + var receivedBy: AppUser? = null +) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/PaymentMethod.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/PaymentMethod.kt new file mode 100644 index 0000000..a1c5019 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/PaymentMethod.kt @@ -0,0 +1,9 @@ +package com.android.trisolarisserver.models.booking + +enum class PaymentMethod { + CASH, + CARD, + UPI, + BANK, + ONLINE +} diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/RateCalendar.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/RateCalendar.kt new file mode 100644 index 0000000..73452a1 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/RateCalendar.kt @@ -0,0 +1,38 @@ +package com.android.trisolarisserver.models.room + +import com.android.trisolarisserver.models.property.Property +import jakarta.persistence.* +import java.time.LocalDate +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table( + name = "rate_calendar", + uniqueConstraints = [ + UniqueConstraint(columnNames = ["rate_plan_id", "rate_date"]) + ] +) +class RateCalendar( + @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 = "rate_plan_id", nullable = false) + var ratePlan: RatePlan, + + @Column(name = "rate_date", nullable = false) + var rateDate: LocalDate, + + @Column(name = "rate", nullable = false) + var rate: Long, + + @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") + val createdAt: OffsetDateTime = OffsetDateTime.now() +) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/RatePlan.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/RatePlan.kt new file mode 100644 index 0000000..bf0df6e --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/RatePlan.kt @@ -0,0 +1,46 @@ +package com.android.trisolarisserver.models.room + +import com.android.trisolarisserver.models.property.Property +import jakarta.persistence.* +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table( + name = "rate_plan", + uniqueConstraints = [ + UniqueConstraint(columnNames = ["property_id", "code"]) + ] +) +class RatePlan( + @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 = "room_type_id", nullable = false) + var roomType: RoomType, + + @Column(nullable = false) + var code: String, + + @Column(nullable = false) + var name: String, + + @Column(name = "base_rate", nullable = false) + var baseRate: Long, + + @Column(nullable = false) + var currency: String, + + @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") + val createdAt: OffsetDateTime = OffsetDateTime.now(), + + @Column(name = "updated_at", nullable = false, columnDefinition = "timestamptz") + var updatedAt: OffsetDateTime = OffsetDateTime.now() +) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/RateSource.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/RateSource.kt new file mode 100644 index 0000000..5714411 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/RateSource.kt @@ -0,0 +1,7 @@ +package com.android.trisolarisserver.models.room + +enum class RateSource { + PRESET, + NEGOTIATED, + OTA +} 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 c184db7..a91d2d3 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,19 @@ class RoomStay( @Column(name = "to_at", columnDefinition = "timestamptz") var toAt: OffsetDateTime? = null, // null = active + @Enumerated(EnumType.STRING) + @Column(name = "rate_source") + var rateSource: RateSource? = null, + + @Column(name = "nightly_rate") + var nightlyRate: Long? = null, + + @Column(name = "rate_plan_code") + var ratePlanCode: String? = null, + + @Column(name = "currency") + var currency: String? = null, + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "created_by") var createdBy: AppUser? = null, diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/RoomType.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomType.kt index 1b2e188..cf6c70e 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/room/RoomType.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomType.kt @@ -38,6 +38,9 @@ class RoomType( @Column(name = "bathroom_sq_feet") var bathroomSqFeet: Int? = null, + @Column(name = "default_rate") + var defaultRate: Long? = null, + @Column(name = "is_active", nullable = false) var active: Boolean = true, diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/PaymentRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/PaymentRepo.kt new file mode 100644 index 0000000..3f47c7d --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/PaymentRepo.kt @@ -0,0 +1,20 @@ +package com.android.trisolarisserver.repo + +import com.android.trisolarisserver.models.booking.Payment +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.util.UUID + +interface PaymentRepo : JpaRepository { + fun findByBookingIdOrderByReceivedAtDesc(bookingId: UUID): List + + @Query( + """ + select coalesce(sum(p.amount), 0) + from Payment p + where p.booking.id = :bookingId + """ + ) + fun sumAmountByBookingId(@Param("bookingId") bookingId: UUID): Long +} diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/RateCalendarRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/RateCalendarRepo.kt new file mode 100644 index 0000000..5b0ca8f --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/RateCalendarRepo.kt @@ -0,0 +1,18 @@ +package com.android.trisolarisserver.repo + +import com.android.trisolarisserver.models.room.RateCalendar +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDate +import java.util.UUID + +interface RateCalendarRepo : JpaRepository { + fun findByRatePlanIdAndRateDateBetweenOrderByRateDateAsc( + ratePlanId: UUID, + from: LocalDate, + to: LocalDate + ): List + + fun findByRatePlanIdAndRateDate(ratePlanId: UUID, rateDate: LocalDate): RateCalendar? + fun findByRatePlanIdAndRateDateIn(ratePlanId: UUID, dates: List): List + fun findByRatePlanId(ratePlanId: UUID): List +} diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/RatePlanRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/RatePlanRepo.kt new file mode 100644 index 0000000..1c2dc39 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/RatePlanRepo.kt @@ -0,0 +1,11 @@ +package com.android.trisolarisserver.repo + +import com.android.trisolarisserver.models.room.RatePlan +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface RatePlanRepo : JpaRepository { + fun findByPropertyIdOrderByCode(propertyId: UUID): List + fun findByIdAndPropertyId(id: UUID, propertyId: UUID): RatePlan? + fun existsByPropertyIdAndCode(propertyId: UUID, code: String): Boolean +} diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt index dbc403a..3061803 100644 --- a/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt @@ -36,6 +36,13 @@ interface RoomStayRepo : JpaRepository { """) fun findActiveByBookingId(@Param("bookingId") bookingId: UUID): List + @Query(""" + select rs + from RoomStay rs + where rs.booking.id = :bookingId + """) + fun findByBookingId(@Param("bookingId") bookingId: UUID): List + @Query(""" select rs.room.id from RoomStay rs