Add rate plans, room stay rates, and payments ledger
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s

This commit is contained in:
androidlover5842
2026-01-29 04:56:37 +05:30
parent b6ac87d277
commit 71c70c8554
24 changed files with 902 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@@ -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<RatePlanResponse> {
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<RateCalendarUpsertRequest>
): List<RateCalendarResponse> {
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<RateCalendarResponse> {
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
)
}

View File

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

View File

@@ -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<Role>): Boolean {
if (!roles.contains(Role.AGENT)) return false
val privileged = setOf(

View File

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

View File

@@ -6,6 +6,10 @@ data class BookingCheckInRequest(
val roomIds: List<UUID>,
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
)

View File

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

View File

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

View File

@@ -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<String>? = null,
val amenityIds: Set<UUID>? = 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<String>,
val amenities: Set<AmenityResponse>

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package com.android.trisolarisserver.models.booking
enum class PaymentMethod {
CASH,
CARD,
UPI,
BANK,
ONLINE
}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.android.trisolarisserver.models.room
enum class RateSource {
PRESET,
NEGOTIATED,
OTA
}

View File

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

View File

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

View File

@@ -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<Payment, UUID> {
fun findByBookingIdOrderByReceivedAtDesc(bookingId: UUID): List<Payment>
@Query(
"""
select coalesce(sum(p.amount), 0)
from Payment p
where p.booking.id = :bookingId
"""
)
fun sumAmountByBookingId(@Param("bookingId") bookingId: UUID): Long
}

View File

@@ -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<RateCalendar, UUID> {
fun findByRatePlanIdAndRateDateBetweenOrderByRateDateAsc(
ratePlanId: UUID,
from: LocalDate,
to: LocalDate
): List<RateCalendar>
fun findByRatePlanIdAndRateDate(ratePlanId: UUID, rateDate: LocalDate): RateCalendar?
fun findByRatePlanIdAndRateDateIn(ratePlanId: UUID, dates: List<LocalDate>): List<RateCalendar>
fun findByRatePlanId(ratePlanId: UUID): List<RateCalendar>
}

View File

@@ -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<RatePlan, UUID> {
fun findByPropertyIdOrderByCode(propertyId: UUID): List<RatePlan>
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): RatePlan?
fun existsByPropertyIdAndCode(propertyId: UUID, code: String): Boolean
}

View File

@@ -36,6 +36,13 @@ interface RoomStayRepo : JpaRepository<RoomStay, UUID> {
""")
fun findActiveByBookingId(@Param("bookingId") bookingId: UUID): List<RoomStay>
@Query("""
select rs
from RoomStay rs
where rs.booking.id = :bookingId
""")
fun findByBookingId(@Param("bookingId") bookingId: UUID): List<RoomStay>
@Query("""
select rs.room.id
from RoomStay rs