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:
@@ -24,17 +24,22 @@ import com.android.trisolarisserver.repo.guest.GuestRepo
|
||||
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
|
||||
import com.android.trisolarisserver.models.booking.BookingStatus
|
||||
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.TransportMode
|
||||
import com.android.trisolarisserver.models.room.RoomStayAuditLog
|
||||
import com.android.trisolarisserver.models.room.RoomStay
|
||||
import com.android.trisolarisserver.models.room.RateSource
|
||||
import com.android.trisolarisserver.models.property.Role
|
||||
import com.android.trisolarisserver.models.property.CancellationPenaltyMode
|
||||
import com.android.trisolarisserver.repo.property.AppUserRepo
|
||||
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
|
||||
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||
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.PropertyCancellationPolicyRepo
|
||||
import com.android.trisolarisserver.repo.room.RoomRepo
|
||||
import com.android.trisolarisserver.repo.room.RoomStayAuditLogRepo
|
||||
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||
@@ -71,9 +76,11 @@ class BookingFlow(
|
||||
private val guestRatingRepo: GuestRatingRepo,
|
||||
private val guestDocumentRepo: GuestDocumentRepo,
|
||||
private val paymentRepo: PaymentRepo,
|
||||
private val chargeRepo: ChargeRepo,
|
||||
private val bookingSnapshotBuilder: BookingSnapshotBuilder,
|
||||
private val roomStayAuditLogRepo: RoomStayAuditLogRepo,
|
||||
private val bookingRoomRequestRepo: BookingRoomRequestRepo
|
||||
private val bookingRoomRequestRepo: BookingRoomRequestRepo,
|
||||
private val propertyCancellationPolicyRepo: PropertyCancellationPolicyRepo
|
||||
) {
|
||||
|
||||
@PostMapping
|
||||
@@ -199,6 +206,12 @@ class BookingFlow(
|
||||
paymentRepo.sumAmountByBookingIds(bookingIds)
|
||||
.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 ->
|
||||
val guest = booking.primaryGuest
|
||||
val stays = staysByBooking[booking.id].orEmpty()
|
||||
@@ -208,7 +221,8 @@ class BookingFlow(
|
||||
computeExpectedPay(stays, property.timezone)
|
||||
}
|
||||
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(
|
||||
id = booking.id!!,
|
||||
status = booking.status.name,
|
||||
@@ -583,7 +597,7 @@ class BookingFlow(
|
||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||
@RequestBody request: BookingCancelRequest
|
||||
) {
|
||||
requireActor(propertyId, principal)
|
||||
val actor = requireActor(propertyId, principal)
|
||||
val booking = requireBooking(propertyId, bookingId)
|
||||
if (booking.status == BookingStatus.CHECKED_IN) {
|
||||
val active = roomStayRepo.findActiveByBookingId(bookingId)
|
||||
@@ -591,6 +605,7 @@ class BookingFlow(
|
||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel checked-in booking")
|
||||
}
|
||||
}
|
||||
createCancellationPenaltyIfApplicable(propertyId, booking, actor, ChargeType.CANCELLATION_PENALTY, request.reason)
|
||||
booking.status = BookingStatus.CANCELLED
|
||||
if (request.reason != null) booking.notes = request.reason
|
||||
booking.updatedAt = OffsetDateTime.now()
|
||||
@@ -607,11 +622,12 @@ class BookingFlow(
|
||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||
@RequestBody request: BookingNoShowRequest
|
||||
) {
|
||||
requireActor(propertyId, principal)
|
||||
val actor = requireActor(propertyId, principal)
|
||||
val booking = requireBooking(propertyId, bookingId)
|
||||
if (booking.status != BookingStatus.OPEN) {
|
||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open")
|
||||
}
|
||||
createCancellationPenaltyIfApplicable(propertyId, booking, actor, ChargeType.NO_SHOW_PENALTY, request.reason)
|
||||
booking.status = BookingStatus.NO_SHOW
|
||||
if (request.reason != null) booking.notes = request.reason
|
||||
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) {
|
||||
val requests = bookingRoomRequestRepo.findActiveForFulfillment(bookingId, roomTypeId, checkInAt)
|
||||
for (request in requests) {
|
||||
|
||||
Reference in New Issue
Block a user