Add advance-booking cancellation policy engine
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
This commit is contained in:
@@ -114,6 +114,7 @@ Booking flow
|
|||||||
- /properties/{propertyId}/bookings/{bookingId}/room-requests (room-type quantity reservation)
|
- /properties/{propertyId}/bookings/{bookingId}/room-requests (room-type quantity reservation)
|
||||||
- /properties/{propertyId}/bookings/{bookingId}/room-requests/{requestId} (cancel reservation)
|
- /properties/{propertyId}/bookings/{bookingId}/room-requests/{requestId} (cancel reservation)
|
||||||
- /properties/{propertyId}/room-stays/{roomStayId}/void (soft-void active stay)
|
- /properties/{propertyId}/room-stays/{roomStayId}/void (soft-void active stay)
|
||||||
|
- /properties/{propertyId}/cancellation-policy (get/update policy)
|
||||||
|
|
||||||
Card issuing
|
Card issuing
|
||||||
- /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload
|
- /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload
|
||||||
@@ -203,6 +204,7 @@ Notes / constraints
|
|||||||
- Staff can change/void stays only before first payment on booking; manager/admin can act after payments.
|
- 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.
|
- 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.
|
||||||
- Room-type reservations: use booking room requests (`booking_room_request`) for quantity holds without room numbers; availability checks include active requests + occupied stays.
|
- Room-type reservations: use booking room requests (`booking_room_request`) for quantity holds without room numbers; availability checks include active requests + occupied stays.
|
||||||
|
- Cancellation policy engine (advance bookings): policy per property with `freeDaysBeforeCheckin` + `penaltyMode` (`NO_CHARGE`, `ONE_NIGHT`, `FULL_STAY`). On cancel/no-show, penalty charge ledger rows are auto-created (`CANCELLATION_PENALTY` / `NO_SHOW_PENALTY`) when within penalty window.
|
||||||
|
|
||||||
Operational notes
|
Operational notes
|
||||||
- Payment provider migrated: PayU removed; Razorpay now used for settings, QR, payment links, and webhooks.
|
- Payment provider migrated: PayU removed; Razorpay now used for settings, QR, payment links, and webhooks.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.android.trisolarisserver.controller.common.requireMember
|
|||||||
import com.android.trisolarisserver.component.auth.PropertyAccess
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
import com.android.trisolarisserver.controller.dto.payment.BookingBalanceResponse
|
import com.android.trisolarisserver.controller.dto.payment.BookingBalanceResponse
|
||||||
import com.android.trisolarisserver.repo.booking.BookingRepo
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
|
import com.android.trisolarisserver.repo.booking.ChargeRepo
|
||||||
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||||
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
@@ -23,6 +24,7 @@ class BookingBalances(
|
|||||||
private val propertyAccess: PropertyAccess,
|
private val propertyAccess: PropertyAccess,
|
||||||
private val bookingRepo: BookingRepo,
|
private val bookingRepo: BookingRepo,
|
||||||
private val roomStayRepo: RoomStayRepo,
|
private val roomStayRepo: RoomStayRepo,
|
||||||
|
private val chargeRepo: ChargeRepo,
|
||||||
private val paymentRepo: PaymentRepo
|
private val paymentRepo: PaymentRepo
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -43,10 +45,11 @@ class BookingBalances(
|
|||||||
roomStayRepo.findByBookingId(bookingId),
|
roomStayRepo.findByBookingId(bookingId),
|
||||||
booking.property.timezone
|
booking.property.timezone
|
||||||
)
|
)
|
||||||
|
val charges = chargeRepo.sumAmountByBookingId(bookingId)
|
||||||
val collected = paymentRepo.sumAmountByBookingId(bookingId)
|
val collected = paymentRepo.sumAmountByBookingId(bookingId)
|
||||||
val pending = expected - collected
|
val pending = expected + charges - collected
|
||||||
return BookingBalanceResponse(
|
return BookingBalanceResponse(
|
||||||
expectedPay = expected,
|
expectedPay = expected + charges,
|
||||||
amountCollected = collected,
|
amountCollected = collected,
|
||||||
pending = pending
|
pending = pending
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,17 +24,22 @@ import com.android.trisolarisserver.repo.guest.GuestRepo
|
|||||||
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
|
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
|
||||||
import com.android.trisolarisserver.models.booking.BookingStatus
|
import com.android.trisolarisserver.models.booking.BookingStatus
|
||||||
import com.android.trisolarisserver.models.booking.BookingRoomRequestStatus
|
import com.android.trisolarisserver.models.booking.BookingRoomRequestStatus
|
||||||
|
import com.android.trisolarisserver.models.booking.Charge
|
||||||
|
import com.android.trisolarisserver.models.booking.ChargeType
|
||||||
import com.android.trisolarisserver.models.booking.MemberRelation
|
import com.android.trisolarisserver.models.booking.MemberRelation
|
||||||
import com.android.trisolarisserver.models.booking.TransportMode
|
import com.android.trisolarisserver.models.booking.TransportMode
|
||||||
import com.android.trisolarisserver.models.room.RoomStayAuditLog
|
import com.android.trisolarisserver.models.room.RoomStayAuditLog
|
||||||
import com.android.trisolarisserver.models.room.RoomStay
|
import com.android.trisolarisserver.models.room.RoomStay
|
||||||
import com.android.trisolarisserver.models.room.RateSource
|
import com.android.trisolarisserver.models.room.RateSource
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.models.property.CancellationPenaltyMode
|
||||||
import com.android.trisolarisserver.repo.property.AppUserRepo
|
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||||
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
|
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
|
||||||
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||||
import com.android.trisolarisserver.repo.booking.BookingRoomRequestRepo
|
import com.android.trisolarisserver.repo.booking.BookingRoomRequestRepo
|
||||||
|
import com.android.trisolarisserver.repo.booking.ChargeRepo
|
||||||
import com.android.trisolarisserver.repo.property.PropertyRepo
|
import com.android.trisolarisserver.repo.property.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyCancellationPolicyRepo
|
||||||
import com.android.trisolarisserver.repo.room.RoomRepo
|
import com.android.trisolarisserver.repo.room.RoomRepo
|
||||||
import com.android.trisolarisserver.repo.room.RoomStayAuditLogRepo
|
import com.android.trisolarisserver.repo.room.RoomStayAuditLogRepo
|
||||||
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||||
@@ -71,9 +76,11 @@ class BookingFlow(
|
|||||||
private val guestRatingRepo: GuestRatingRepo,
|
private val guestRatingRepo: GuestRatingRepo,
|
||||||
private val guestDocumentRepo: GuestDocumentRepo,
|
private val guestDocumentRepo: GuestDocumentRepo,
|
||||||
private val paymentRepo: PaymentRepo,
|
private val paymentRepo: PaymentRepo,
|
||||||
|
private val chargeRepo: ChargeRepo,
|
||||||
private val bookingSnapshotBuilder: BookingSnapshotBuilder,
|
private val bookingSnapshotBuilder: BookingSnapshotBuilder,
|
||||||
private val roomStayAuditLogRepo: RoomStayAuditLogRepo,
|
private val roomStayAuditLogRepo: RoomStayAuditLogRepo,
|
||||||
private val bookingRoomRequestRepo: BookingRoomRequestRepo
|
private val bookingRoomRequestRepo: BookingRoomRequestRepo,
|
||||||
|
private val propertyCancellationPolicyRepo: PropertyCancellationPolicyRepo
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -199,6 +206,12 @@ class BookingFlow(
|
|||||||
paymentRepo.sumAmountByBookingIds(bookingIds)
|
paymentRepo.sumAmountByBookingIds(bookingIds)
|
||||||
.associate { it.bookingId to it.total }
|
.associate { it.bookingId to it.total }
|
||||||
}
|
}
|
||||||
|
val chargesByBooking = if (bookingIds.isEmpty()) {
|
||||||
|
emptyMap()
|
||||||
|
} else {
|
||||||
|
chargeRepo.sumAmountByBookingIds(bookingIds)
|
||||||
|
.associate { it.bookingId to it.total }
|
||||||
|
}
|
||||||
return bookings.map { booking ->
|
return bookings.map { booking ->
|
||||||
val guest = booking.primaryGuest
|
val guest = booking.primaryGuest
|
||||||
val stays = staysByBooking[booking.id].orEmpty()
|
val stays = staysByBooking[booking.id].orEmpty()
|
||||||
@@ -208,7 +221,8 @@ class BookingFlow(
|
|||||||
computeExpectedPay(stays, property.timezone)
|
computeExpectedPay(stays, property.timezone)
|
||||||
}
|
}
|
||||||
val collected = paymentsByBooking[booking.id] ?: 0L
|
val collected = paymentsByBooking[booking.id] ?: 0L
|
||||||
val pending = expectedPay?.let { it - collected }
|
val extraCharges = chargesByBooking[booking.id] ?: 0L
|
||||||
|
val pending = expectedPay?.let { it + extraCharges - collected } ?: (extraCharges - collected)
|
||||||
BookingListItem(
|
BookingListItem(
|
||||||
id = booking.id!!,
|
id = booking.id!!,
|
||||||
status = booking.status.name,
|
status = booking.status.name,
|
||||||
@@ -583,7 +597,7 @@ class BookingFlow(
|
|||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
@RequestBody request: BookingCancelRequest
|
@RequestBody request: BookingCancelRequest
|
||||||
) {
|
) {
|
||||||
requireActor(propertyId, principal)
|
val actor = requireActor(propertyId, principal)
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
val booking = requireBooking(propertyId, bookingId)
|
||||||
if (booking.status == BookingStatus.CHECKED_IN) {
|
if (booking.status == BookingStatus.CHECKED_IN) {
|
||||||
val active = roomStayRepo.findActiveByBookingId(bookingId)
|
val active = roomStayRepo.findActiveByBookingId(bookingId)
|
||||||
@@ -591,6 +605,7 @@ class BookingFlow(
|
|||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel checked-in booking")
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel checked-in booking")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
createCancellationPenaltyIfApplicable(propertyId, booking, actor, ChargeType.CANCELLATION_PENALTY, request.reason)
|
||||||
booking.status = BookingStatus.CANCELLED
|
booking.status = BookingStatus.CANCELLED
|
||||||
if (request.reason != null) booking.notes = request.reason
|
if (request.reason != null) booking.notes = request.reason
|
||||||
booking.updatedAt = OffsetDateTime.now()
|
booking.updatedAt = OffsetDateTime.now()
|
||||||
@@ -607,11 +622,12 @@ class BookingFlow(
|
|||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
@RequestBody request: BookingNoShowRequest
|
@RequestBody request: BookingNoShowRequest
|
||||||
) {
|
) {
|
||||||
requireActor(propertyId, principal)
|
val actor = requireActor(propertyId, principal)
|
||||||
val booking = requireBooking(propertyId, bookingId)
|
val booking = requireBooking(propertyId, bookingId)
|
||||||
if (booking.status != BookingStatus.OPEN) {
|
if (booking.status != BookingStatus.OPEN) {
|
||||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open")
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open")
|
||||||
}
|
}
|
||||||
|
createCancellationPenaltyIfApplicable(propertyId, booking, actor, ChargeType.NO_SHOW_PENALTY, request.reason)
|
||||||
booking.status = BookingStatus.NO_SHOW
|
booking.status = BookingStatus.NO_SHOW
|
||||||
if (request.reason != null) booking.notes = request.reason
|
if (request.reason != null) booking.notes = request.reason
|
||||||
booking.updatedAt = OffsetDateTime.now()
|
booking.updatedAt = OffsetDateTime.now()
|
||||||
@@ -662,6 +678,73 @@ class BookingFlow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createCancellationPenaltyIfApplicable(
|
||||||
|
propertyId: UUID,
|
||||||
|
booking: com.android.trisolarisserver.models.booking.Booking,
|
||||||
|
actor: com.android.trisolarisserver.models.property.AppUser,
|
||||||
|
chargeType: ChargeType,
|
||||||
|
notes: String?
|
||||||
|
) {
|
||||||
|
if (!isAdvanceBooking(booking)) return
|
||||||
|
val policy = propertyCancellationPolicyRepo.findByPropertyId(propertyId) ?: return
|
||||||
|
val checkInAt = booking.expectedCheckinAt ?: return
|
||||||
|
val now = nowForProperty(booking.property.timezone)
|
||||||
|
val daysUntilCheckIn = java.time.Duration.between(now, checkInAt).toDays()
|
||||||
|
if (daysUntilCheckIn >= policy.freeDaysBeforeCheckin.toLong()) return
|
||||||
|
|
||||||
|
val amount = when (policy.penaltyMode) {
|
||||||
|
CancellationPenaltyMode.NO_CHARGE -> 0L
|
||||||
|
CancellationPenaltyMode.ONE_NIGHT -> computeOneNightAmount(booking.id!!)
|
||||||
|
CancellationPenaltyMode.FULL_STAY -> computeFullStayAmount(booking.id!!, booking.expectedCheckinAt, booking.expectedCheckoutAt)
|
||||||
|
}
|
||||||
|
if (amount <= 0L) return
|
||||||
|
chargeRepo.save(
|
||||||
|
Charge(
|
||||||
|
property = booking.property,
|
||||||
|
booking = booking,
|
||||||
|
type = chargeType,
|
||||||
|
amount = amount,
|
||||||
|
currency = booking.property.currency,
|
||||||
|
notes = notes,
|
||||||
|
createdBy = actor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAdvanceBooking(booking: com.android.trisolarisserver.models.booking.Booking): Boolean {
|
||||||
|
val expected = booking.expectedCheckinAt ?: return false
|
||||||
|
return expected.isAfter(booking.createdAt.plusHours(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeOneNightAmount(bookingId: UUID): Long {
|
||||||
|
val stays = roomStayRepo.findByBookingIdWithRoom(bookingId)
|
||||||
|
if (stays.isNotEmpty()) {
|
||||||
|
return stays.filter { !it.isVoided }.sumOf { it.nightlyRate ?: it.room.roomType.defaultRate ?: 0L }
|
||||||
|
}
|
||||||
|
val requests = bookingRoomRequestRepo.findByBookingIdOrderByCreatedAtAsc(bookingId)
|
||||||
|
.filter { it.status == BookingRoomRequestStatus.ACTIVE || it.status == BookingRoomRequestStatus.FULFILLED }
|
||||||
|
if (requests.isNotEmpty()) {
|
||||||
|
return requests.sumOf { (it.roomType.defaultRate ?: 0L) * it.quantity.toLong() }
|
||||||
|
}
|
||||||
|
return 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeFullStayAmount(
|
||||||
|
bookingId: UUID,
|
||||||
|
expectedCheckinAt: OffsetDateTime?,
|
||||||
|
expectedCheckoutAt: OffsetDateTime?
|
||||||
|
): Long {
|
||||||
|
val oneNight = computeOneNightAmount(bookingId)
|
||||||
|
if (oneNight <= 0L) return 0L
|
||||||
|
val nights = if (expectedCheckinAt != null && expectedCheckoutAt != null && expectedCheckoutAt.isAfter(expectedCheckinAt)) {
|
||||||
|
val diff = expectedCheckoutAt.toLocalDate().toEpochDay() - expectedCheckinAt.toLocalDate().toEpochDay()
|
||||||
|
if (diff <= 0L) 1L else diff
|
||||||
|
} else {
|
||||||
|
1L
|
||||||
|
}
|
||||||
|
return oneNight * nights
|
||||||
|
}
|
||||||
|
|
||||||
private fun fulfillRoomRequestIfAny(bookingId: UUID, roomTypeId: UUID, checkInAt: OffsetDateTime) {
|
private fun fulfillRoomRequestIfAny(bookingId: UUID, roomTypeId: UUID, checkInAt: OffsetDateTime) {
|
||||||
val requests = bookingRoomRequestRepo.findActiveForFulfillment(bookingId, roomTypeId, checkInAt)
|
val requests = bookingRoomRequestRepo.findActiveForFulfillment(bookingId, roomTypeId, checkInAt)
|
||||||
for (request in requests) {
|
for (request in requests) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.android.trisolarisserver.controller.common.computeExpectedPayTotal
|
|||||||
|
|
||||||
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
|
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
|
||||||
import com.android.trisolarisserver.repo.booking.BookingRepo
|
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||||
|
import com.android.trisolarisserver.repo.booking.ChargeRepo
|
||||||
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
|
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
|
||||||
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||||
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||||
@@ -16,6 +17,7 @@ import java.util.UUID
|
|||||||
class BookingSnapshotBuilder(
|
class BookingSnapshotBuilder(
|
||||||
private val bookingRepo: BookingRepo,
|
private val bookingRepo: BookingRepo,
|
||||||
private val roomStayRepo: RoomStayRepo,
|
private val roomStayRepo: RoomStayRepo,
|
||||||
|
private val chargeRepo: ChargeRepo,
|
||||||
private val paymentRepo: PaymentRepo,
|
private val paymentRepo: PaymentRepo,
|
||||||
private val guestVehicleRepo: GuestVehicleRepo
|
private val guestVehicleRepo: GuestVehicleRepo
|
||||||
) {
|
) {
|
||||||
@@ -50,8 +52,9 @@ class BookingSnapshotBuilder(
|
|||||||
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
|
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
|
||||||
val expectedPay = computeExpectedPayTotal(stays, booking.expectedCheckoutAt, booking.property.timezone)
|
val expectedPay = computeExpectedPayTotal(stays, booking.expectedCheckoutAt, booking.property.timezone)
|
||||||
val accruedPay = computeExpectedPay(stays, booking.property.timezone)
|
val accruedPay = computeExpectedPay(stays, booking.property.timezone)
|
||||||
|
val extraCharges = chargeRepo.sumAmountByBookingId(bookingId)
|
||||||
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
|
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
|
||||||
val pending = accruedPay - amountCollected
|
val pending = accruedPay + extraCharges - amountCollected
|
||||||
|
|
||||||
return BookingDetailResponse(
|
return BookingDetailResponse(
|
||||||
id = booking.id!!,
|
id = booking.id!!,
|
||||||
@@ -84,7 +87,7 @@ class BookingSnapshotBuilder(
|
|||||||
registeredByName = booking.createdBy?.name,
|
registeredByName = booking.createdBy?.name,
|
||||||
registeredByPhone = booking.createdBy?.phoneE164,
|
registeredByPhone = booking.createdBy?.phoneE164,
|
||||||
totalNightlyRate = totalNightlyRate,
|
totalNightlyRate = totalNightlyRate,
|
||||||
expectedPay = expectedPay,
|
expectedPay = expectedPay + extraCharges,
|
||||||
amountCollected = amountCollected,
|
amountCollected = amountCollected,
|
||||||
pending = pending
|
pending = pending
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.android.trisolarisserver.controller.dto.property
|
||||||
|
|
||||||
|
data class CancellationPolicyUpsertRequest(
|
||||||
|
val freeDaysBeforeCheckin: Int = 0,
|
||||||
|
val penaltyMode: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CancellationPolicyResponse(
|
||||||
|
val freeDaysBeforeCheckin: Int,
|
||||||
|
val penaltyMode: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.android.trisolarisserver.controller.property
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.common.requireRole
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.CancellationPolicyResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.property.CancellationPolicyUpsertRequest
|
||||||
|
import com.android.trisolarisserver.models.property.CancellationPenaltyMode
|
||||||
|
import com.android.trisolarisserver.models.property.PropertyCancellationPolicy
|
||||||
|
import com.android.trisolarisserver.models.property.Role
|
||||||
|
import com.android.trisolarisserver.repo.property.PropertyCancellationPolicyRepo
|
||||||
|
import com.android.trisolarisserver.repo.property.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.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/cancellation-policy")
|
||||||
|
class CancellationPolicies(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val propertyRepo: PropertyRepo,
|
||||||
|
private val policyRepo: PropertyCancellationPolicyRepo
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun get(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): CancellationPolicyResponse {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.FINANCE)
|
||||||
|
val policy = policyRepo.findByPropertyId(propertyId)
|
||||||
|
return if (policy != null) {
|
||||||
|
CancellationPolicyResponse(
|
||||||
|
freeDaysBeforeCheckin = policy.freeDaysBeforeCheckin,
|
||||||
|
penaltyMode = policy.penaltyMode.name
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CancellationPolicyResponse(
|
||||||
|
freeDaysBeforeCheckin = 0,
|
||||||
|
penaltyMode = CancellationPenaltyMode.FULL_STAY.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun upsert(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: CancellationPolicyUpsertRequest
|
||||||
|
): CancellationPolicyResponse {
|
||||||
|
requireRole(propertyAccess, propertyId, principal, Role.ADMIN)
|
||||||
|
if (request.freeDaysBeforeCheckin < 0) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "freeDaysBeforeCheckin must be >= 0")
|
||||||
|
}
|
||||||
|
val mode = try {
|
||||||
|
CancellationPenaltyMode.valueOf(request.penaltyMode.trim().uppercase())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown penaltyMode")
|
||||||
|
}
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
val existing = policyRepo.findByPropertyId(propertyId)
|
||||||
|
val saved = if (existing != null) {
|
||||||
|
existing.freeDaysBeforeCheckin = request.freeDaysBeforeCheckin
|
||||||
|
existing.penaltyMode = mode
|
||||||
|
existing.updatedAt = OffsetDateTime.now()
|
||||||
|
policyRepo.save(existing)
|
||||||
|
} else {
|
||||||
|
policyRepo.save(
|
||||||
|
PropertyCancellationPolicy(
|
||||||
|
property = property,
|
||||||
|
freeDaysBeforeCheckin = request.freeDaysBeforeCheckin,
|
||||||
|
penaltyMode = mode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return CancellationPolicyResponse(
|
||||||
|
freeDaysBeforeCheckin = saved.freeDaysBeforeCheckin,
|
||||||
|
penaltyMode = saved.penaltyMode.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.android.trisolarisserver.models.booking
|
package com.android.trisolarisserver.models.booking
|
||||||
|
|
||||||
enum class ChargeType {
|
enum class ChargeType {
|
||||||
COMMISSION
|
COMMISSION,
|
||||||
|
CANCELLATION_PENALTY,
|
||||||
|
NO_SHOW_PENALTY
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.android.trisolarisserver.models.property
|
||||||
|
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.EnumType
|
||||||
|
import jakarta.persistence.Enumerated
|
||||||
|
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 jakarta.persistence.UniqueConstraint
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "property_cancellation_policy",
|
||||||
|
uniqueConstraints = [UniqueConstraint(columnNames = ["property_id"])]
|
||||||
|
)
|
||||||
|
class PropertyCancellationPolicy(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
@Column(columnDefinition = "uuid")
|
||||||
|
val id: UUID? = null,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "property_id", nullable = false)
|
||||||
|
var property: Property,
|
||||||
|
|
||||||
|
@Column(name = "free_days_before_checkin", nullable = false)
|
||||||
|
var freeDaysBeforeCheckin: Int = 0,
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "penalty_mode", nullable = false)
|
||||||
|
var penaltyMode: CancellationPenaltyMode = CancellationPenaltyMode.FULL_STAY,
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false, columnDefinition = "timestamptz")
|
||||||
|
var updatedAt: OffsetDateTime = OffsetDateTime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class CancellationPenaltyMode {
|
||||||
|
NO_CHARGE,
|
||||||
|
ONE_NIGHT,
|
||||||
|
FULL_STAY
|
||||||
|
}
|
||||||
@@ -2,8 +2,34 @@ package com.android.trisolarisserver.repo.booking
|
|||||||
|
|
||||||
import com.android.trisolarisserver.models.booking.Charge
|
import com.android.trisolarisserver.models.booking.Charge
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
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
|
import java.util.UUID
|
||||||
|
|
||||||
interface ChargeRepo : JpaRepository<Charge, UUID> {
|
interface ChargeRepo : JpaRepository<Charge, UUID> {
|
||||||
fun findByBookingIdOrderByOccurredAtDesc(bookingId: UUID): List<Charge>
|
fun findByBookingIdOrderByOccurredAtDesc(bookingId: UUID): List<Charge>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select coalesce(sum(c.amount), 0)
|
||||||
|
from Charge c
|
||||||
|
where c.booking.id = :bookingId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun sumAmountByBookingId(@Param("bookingId") bookingId: UUID): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select c.booking.id as bookingId, coalesce(sum(c.amount), 0) as total
|
||||||
|
from Charge c
|
||||||
|
where c.booking.id in :bookingIds
|
||||||
|
group by c.booking.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun sumAmountByBookingIds(@Param("bookingIds") bookingIds: List<UUID>): List<BookingChargeSumRow>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingChargeSumRow {
|
||||||
|
val bookingId: UUID
|
||||||
|
val total: Long
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.android.trisolarisserver.repo.property
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.models.property.PropertyCancellationPolicy
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface PropertyCancellationPolicyRepo : JpaRepository<PropertyCancellationPolicy, UUID> {
|
||||||
|
fun findByPropertyId(propertyId: UUID): PropertyCancellationPolicy?
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user