Add advance-booking cancellation policy engine
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s

This commit is contained in:
androidlover5842
2026-02-02 09:20:27 +05:30
parent 30c37affb4
commit 4c20cbd7ca
10 changed files with 286 additions and 9 deletions

View File

@@ -5,6 +5,7 @@ import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.payment.BookingBalanceResponse
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.room.RoomStayRepo
import com.android.trisolarisserver.security.MyPrincipal
@@ -23,6 +24,7 @@ class BookingBalances(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val roomStayRepo: RoomStayRepo,
private val chargeRepo: ChargeRepo,
private val paymentRepo: PaymentRepo
) {
@@ -43,10 +45,11 @@ class BookingBalances(
roomStayRepo.findByBookingId(bookingId),
booking.property.timezone
)
val charges = chargeRepo.sumAmountByBookingId(bookingId)
val collected = paymentRepo.sumAmountByBookingId(bookingId)
val pending = expected - collected
val pending = expected + charges - collected
return BookingBalanceResponse(
expectedPay = expected,
expectedPay = expected + charges,
amountCollected = collected,
pending = pending
)

View File

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

View File

@@ -4,6 +4,7 @@ import com.android.trisolarisserver.controller.common.computeExpectedPayTotal
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
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.booking.PaymentRepo
import com.android.trisolarisserver.repo.room.RoomStayRepo
@@ -16,6 +17,7 @@ import java.util.UUID
class BookingSnapshotBuilder(
private val bookingRepo: BookingRepo,
private val roomStayRepo: RoomStayRepo,
private val chargeRepo: ChargeRepo,
private val paymentRepo: PaymentRepo,
private val guestVehicleRepo: GuestVehicleRepo
) {
@@ -50,8 +52,9 @@ class BookingSnapshotBuilder(
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
val expectedPay = computeExpectedPayTotal(stays, booking.expectedCheckoutAt, booking.property.timezone)
val accruedPay = computeExpectedPay(stays, booking.property.timezone)
val extraCharges = chargeRepo.sumAmountByBookingId(bookingId)
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
val pending = accruedPay - amountCollected
val pending = accruedPay + extraCharges - amountCollected
return BookingDetailResponse(
id = booking.id!!,
@@ -84,7 +87,7 @@ class BookingSnapshotBuilder(
registeredByName = booking.createdBy?.name,
registeredByPhone = booking.createdBy?.phoneE164,
totalNightlyRate = totalNightlyRate,
expectedPay = expectedPay,
expectedPay = expectedPay + extraCharges,
amountCollected = amountCollected,
pending = pending
)