From 4c20cbd7ca63ce2e652da5e0d3486dbb08202c40 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Mon, 2 Feb 2026 09:20:27 +0530 Subject: [PATCH] Add advance-booking cancellation policy engine --- AGENTS.md | 2 + .../controller/booking/BookingBalances.kt | 7 +- .../controller/booking/BookingFlow.kt | 91 ++++++++++++++++++- .../booking/BookingSnapshotBuilder.kt | 7 +- .../dto/property/CancellationPolicyDtos.kt | 11 +++ .../property/CancellationPolicies.kt | 91 +++++++++++++++++++ .../models/booking/ChargeType.kt | 4 +- .../property/PropertyCancellationPolicy.kt | 47 ++++++++++ .../repo/booking/ChargeRepo.kt | 26 ++++++ .../PropertyCancellationPolicyRepo.kt | 9 ++ 10 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/dto/property/CancellationPolicyDtos.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/property/CancellationPolicies.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/models/property/PropertyCancellationPolicy.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyCancellationPolicyRepo.kt diff --git a/AGENTS.md b/AGENTS.md index e49f376..c0107fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,7 @@ Booking flow - /properties/{propertyId}/bookings/{bookingId}/room-requests (room-type quantity reservation) - /properties/{propertyId}/bookings/{bookingId}/room-requests/{requestId} (cancel reservation) - /properties/{propertyId}/room-stays/{roomStayId}/void (soft-void active stay) +- /properties/{propertyId}/cancellation-policy (get/update policy) Card issuing - /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. - 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. +- 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 - Payment provider migrated: PayU removed; Razorpay now used for settings, QR, payment links, and webhooks. diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingBalances.kt b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingBalances.kt index 785df8b..e92569e 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingBalances.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingBalances.kt @@ -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 ) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt index 8220dbb..af45fd8 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt @@ -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) { diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingSnapshotBuilder.kt b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingSnapshotBuilder.kt index 1c43c5f..e72d2db 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingSnapshotBuilder.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingSnapshotBuilder.kt @@ -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 ) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/property/CancellationPolicyDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/property/CancellationPolicyDtos.kt new file mode 100644 index 0000000..cb82501 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/property/CancellationPolicyDtos.kt @@ -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 +) diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/property/CancellationPolicies.kt b/src/main/kotlin/com/android/trisolarisserver/controller/property/CancellationPolicies.kt new file mode 100644 index 0000000..1199d1a --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/property/CancellationPolicies.kt @@ -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 + ) + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/ChargeType.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/ChargeType.kt index 01b0c8d..b5965de 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/booking/ChargeType.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/ChargeType.kt @@ -1,5 +1,7 @@ package com.android.trisolarisserver.models.booking enum class ChargeType { - COMMISSION + COMMISSION, + CANCELLATION_PENALTY, + NO_SHOW_PENALTY } diff --git a/src/main/kotlin/com/android/trisolarisserver/models/property/PropertyCancellationPolicy.kt b/src/main/kotlin/com/android/trisolarisserver/models/property/PropertyCancellationPolicy.kt new file mode 100644 index 0000000..0edcc3a --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/property/PropertyCancellationPolicy.kt @@ -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 +} diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/booking/ChargeRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/booking/ChargeRepo.kt index a1b550b..5ca9b3d 100644 --- a/src/main/kotlin/com/android/trisolarisserver/repo/booking/ChargeRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/repo/booking/ChargeRepo.kt @@ -2,8 +2,34 @@ package com.android.trisolarisserver.repo.booking import com.android.trisolarisserver.models.booking.Charge 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 ChargeRepo : JpaRepository { fun findByBookingIdOrderByOccurredAtDesc(bookingId: UUID): List + + @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): List +} + +interface BookingChargeSumRow { + val bookingId: UUID + val total: Long } diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyCancellationPolicyRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyCancellationPolicyRepo.kt new file mode 100644 index 0000000..f27ee50 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/property/PropertyCancellationPolicyRepo.kt @@ -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 { + fun findByPropertyId(propertyId: UUID): PropertyCancellationPolicy? +}