Move billing policy to booking level with override modes and audit logs
All checks were successful
build-and-deploy / build-deploy (push) Successful in 36s

This commit is contained in:
androidlover5842
2026-02-02 10:43:47 +05:30
parent 776ed6dc4e
commit 8ba0fedd8b
10 changed files with 270 additions and 12 deletions

View File

@@ -44,8 +44,9 @@ class BookingBalances(
val expected = computeExpectedPay(
roomStayRepo.findByBookingId(bookingId),
booking.property.timezone,
booking.property.billingCheckinTime,
booking.property.billingCheckoutTime
booking.billingMode,
booking.billingCheckinTime,
booking.billingCheckoutTime
)
val charges = chargeRepo.sumAmountByBookingId(bookingId)
val collected = paymentRepo.sumAmountByBookingId(bookingId)

View File

@@ -15,6 +15,7 @@ import com.android.trisolarisserver.controller.dto.booking.BookingCheckOutReques
import com.android.trisolarisserver.controller.dto.booking.BookingCreateRequest
import com.android.trisolarisserver.controller.dto.booking.BookingCreateResponse
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
import com.android.trisolarisserver.controller.dto.booking.BookingBillingPolicyUpdateRequest
import com.android.trisolarisserver.controller.dto.booking.BookingExpectedDatesUpdateRequest
import com.android.trisolarisserver.controller.dto.booking.BookingLinkGuestRequest
import com.android.trisolarisserver.controller.dto.booking.BookingNoShowRequest
@@ -24,7 +25,9 @@ import com.android.trisolarisserver.repo.guest.GuestDocumentRepo
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.BookingBillingMode
import com.android.trisolarisserver.models.booking.BookingRoomRequestStatus
import com.android.trisolarisserver.models.booking.BookingBillingPolicyAuditLog
import com.android.trisolarisserver.models.booking.Charge
import com.android.trisolarisserver.models.booking.ChargeType
import com.android.trisolarisserver.models.booking.MemberRelation
@@ -38,6 +41,7 @@ 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.BookingBillingPolicyAuditLogRepo
import com.android.trisolarisserver.repo.booking.ChargeRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.property.PropertyCancellationPolicyRepo
@@ -58,7 +62,10 @@ 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.LocalTime
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import kotlin.math.abs
import java.util.UUID
@@ -82,6 +89,7 @@ class BookingFlow(
private val bookingSnapshotBuilder: BookingSnapshotBuilder,
private val roomStayAuditLogRepo: RoomStayAuditLogRepo,
private val bookingRoomRequestRepo: BookingRoomRequestRepo,
private val bookingBillingPolicyAuditLogRepo: BookingBillingPolicyAuditLogRepo,
private val propertyCancellationPolicyRepo: PropertyCancellationPolicyRepo
) {
@@ -112,6 +120,12 @@ class BookingFlow(
val fromCity = request.fromCity?.trim()?.ifBlank { null }
val toCity = request.toCity?.trim()?.ifBlank { null }
val memberRelation = parseMemberRelation(request.memberRelation)
val (billingMode, billingCheckinTime, billingCheckoutTime) = resolveBookingBillingPolicy(
property = property,
modeRaw = request.billingMode,
billingCheckinTimeRaw = request.billingCheckinTime,
billingCheckoutTimeRaw = request.billingCheckoutTime
)
val hasGuestCounts = request.maleCount != null || request.femaleCount != null || request.childCount != null
val adultCount = if (hasGuestCounts) {
(request.maleCount ?: 0) + (request.femaleCount ?: 0)
@@ -131,6 +145,9 @@ class BookingFlow(
checkinAt = null,
expectedCheckinAt = expectedCheckInAt,
expectedCheckoutAt = expectedCheckOutAt,
billingMode = billingMode,
billingCheckinTime = billingCheckinTime,
billingCheckoutTime = billingCheckoutTime,
transportMode = request.transportMode?.let {
val mode = parseTransportMode(it)
if (!isTransportModeAllowed(property, mode)) {
@@ -157,6 +174,9 @@ class BookingFlow(
return BookingCreateResponse(
id = saved.id!!,
status = saved.status.name,
billingMode = saved.billingMode.name,
billingCheckinTime = saved.billingCheckinTime,
billingCheckoutTime = saved.billingCheckoutTime,
guestId = guest.id,
checkInAt = saved.checkinAt?.toString(),
expectedCheckInAt = saved.expectedCheckinAt?.toString(),
@@ -223,8 +243,9 @@ class BookingFlow(
computeExpectedPay(
stays,
property.timezone,
property.billingCheckinTime,
property.billingCheckoutTime
booking.billingMode,
booking.billingCheckinTime,
booking.billingCheckoutTime
)
}
val collected = paymentsByBooking[booking.id] ?: 0L
@@ -238,6 +259,9 @@ class BookingFlow(
guestPhone = guest?.phoneE164,
roomNumbers = roomNumbersByBooking[booking.id] ?: emptyList(),
source = booking.source,
billingMode = booking.billingMode.name,
billingCheckinTime = booking.billingCheckinTime,
billingCheckoutTime = booking.billingCheckoutTime,
expectedCheckInAt = booking.expectedCheckinAt?.toString(),
expectedCheckOutAt = booking.expectedCheckoutAt?.toString(),
checkInAt = booking.checkinAt?.toString(),
@@ -447,6 +471,58 @@ class BookingFlow(
bookingEvents.emit(propertyId, bookingId)
}
@PostMapping("/{bookingId}/billing-policy")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun updateBillingPolicy(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingBillingPolicyUpdateRequest
) {
val actor = requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
when (booking.status) {
BookingStatus.CHECKED_OUT,
BookingStatus.CANCELLED,
BookingStatus.NO_SHOW -> throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed")
else -> {}
}
val oldMode = booking.billingMode
val oldCheckin = booking.billingCheckinTime
val oldCheckout = booking.billingCheckoutTime
val (newMode, newCheckin, newCheckout) = resolveBookingBillingPolicy(
property = booking.property,
modeRaw = request.billingMode,
billingCheckinTimeRaw = request.billingCheckinTime,
billingCheckoutTimeRaw = request.billingCheckoutTime
)
booking.billingMode = newMode
booking.billingCheckinTime = newCheckin
booking.billingCheckoutTime = newCheckout
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
if (oldMode != newMode || oldCheckin != newCheckin || oldCheckout != newCheckout) {
bookingBillingPolicyAuditLogRepo.save(
BookingBillingPolicyAuditLog(
property = booking.property,
booking = booking,
actor = actor,
oldBillingMode = oldMode,
newBillingMode = newMode,
oldBillingCheckinTime = oldCheckin,
newBillingCheckinTime = newCheckin,
oldBillingCheckoutTime = oldCheckout,
newBillingCheckoutTime = newCheckout
)
)
}
bookingEvents.emit(propertyId, bookingId)
}
@PostMapping("/{bookingId}/check-out")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
@@ -471,8 +547,9 @@ class BookingFlow(
ensureLedgerToleranceForCheckout(
bookingId = bookingId,
timezone = booking.property.timezone,
billingCheckinTime = booking.property.billingCheckinTime,
billingCheckoutTime = booking.property.billingCheckoutTime,
billingMode = booking.billingMode,
billingCheckinTime = booking.billingCheckinTime,
billingCheckoutTime = booking.billingCheckoutTime,
stays = stays,
checkOutOverrides = stays.associate { it.id!! to checkOutAt },
restrictToClosedStaysOnly = false
@@ -543,8 +620,9 @@ class BookingFlow(
ensureLedgerToleranceForCheckout(
bookingId = bookingId,
timezone = booking.property.timezone,
billingCheckinTime = booking.property.billingCheckinTime,
billingCheckoutTime = booking.property.billingCheckoutTime,
billingMode = booking.billingMode,
billingCheckinTime = booking.billingCheckinTime,
billingCheckoutTime = booking.billingCheckoutTime,
stays = staysForBooking,
checkOutOverrides = mapOf(stay.id!! to checkOutAt),
restrictToClosedStaysOnly = true
@@ -798,6 +876,7 @@ class BookingFlow(
private fun ensureLedgerToleranceForCheckout(
bookingId: UUID,
timezone: String?,
billingMode: BookingBillingMode,
billingCheckinTime: String?,
billingCheckoutTime: String?,
stays: List<RoomStay>,
@@ -822,6 +901,7 @@ class BookingFlow(
stay.fromAt,
endAt,
timezone,
billingMode,
billingCheckinTime,
billingCheckoutTime
)
@@ -899,4 +979,53 @@ class BookingFlow(
response.setHeader("X-Accel-Buffering", "no")
}
private fun resolveBookingBillingPolicy(
property: com.android.trisolarisserver.models.property.Property,
modeRaw: String?,
billingCheckinTimeRaw: String?,
billingCheckoutTimeRaw: String?
): Triple<BookingBillingMode, String, String> {
val mode = parseBillingMode(modeRaw)
return when (mode) {
BookingBillingMode.PROPERTY_POLICY -> Triple(
BookingBillingMode.PROPERTY_POLICY,
property.billingCheckinTime,
property.billingCheckoutTime
)
BookingBillingMode.CUSTOM_WINDOW -> Triple(
BookingBillingMode.CUSTOM_WINDOW,
normalizeBillingTime(billingCheckinTimeRaw, "billingCheckinTime"),
normalizeBillingTime(billingCheckoutTimeRaw, "billingCheckoutTime")
)
BookingBillingMode.FULL_24H -> Triple(
BookingBillingMode.FULL_24H,
"00:00",
"23:59"
)
}
}
private fun parseBillingMode(raw: String?): BookingBillingMode {
if (raw.isNullOrBlank()) return BookingBillingMode.PROPERTY_POLICY
return try {
BookingBillingMode.valueOf(raw.trim().uppercase())
} catch (_: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown billing mode")
}
}
private fun normalizeBillingTime(raw: String?, fieldName: String): String {
val value = raw?.trim()?.takeIf { it.isNotEmpty() }
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName required")
return try {
LocalTime.parse(value, BILLING_TIME_FORMATTER).format(BILLING_TIME_FORMATTER)
} catch (_: DateTimeParseException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be HH:mm")
}
}
companion object {
private val BILLING_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
}
}

View File

@@ -54,14 +54,16 @@ class BookingSnapshotBuilder(
stays,
booking.expectedCheckoutAt,
booking.property.timezone,
booking.property.billingCheckinTime,
booking.property.billingCheckoutTime
booking.billingMode,
booking.billingCheckinTime,
booking.billingCheckoutTime
)
val accruedPay = computeExpectedPay(
stays,
booking.property.timezone,
booking.property.billingCheckinTime,
booking.property.billingCheckoutTime
booking.billingMode,
booking.billingCheckinTime,
booking.billingCheckoutTime
)
val extraCharges = chargeRepo.sumAmountByBookingId(bookingId)
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
@@ -80,6 +82,9 @@ class BookingSnapshotBuilder(
vehicleNumbers = vehicleNumbers,
roomNumbers = roomNumbers,
source = booking.source,
billingMode = booking.billingMode.name,
billingCheckinTime = booking.billingCheckinTime,
billingCheckoutTime = booking.billingCheckoutTime,
fromCity = booking.fromCity,
toCity = booking.toCity,
memberRelation = booking.memberRelation?.name,

View File

@@ -12,6 +12,7 @@ import com.android.trisolarisserver.controller.common.requireRoomStayForProperty
import com.android.trisolarisserver.repo.guest.GuestRepo
import com.android.trisolarisserver.models.booking.Guest
import com.android.trisolarisserver.models.booking.BookingBillingMode
import com.android.trisolarisserver.models.property.Property
import com.android.trisolarisserver.models.room.RoomStay
import com.android.trisolarisserver.repo.property.PropertyRepo
@@ -107,6 +108,7 @@ internal fun nowForProperty(timezone: String?): OffsetDateTime {
internal fun computeExpectedPay(
stays: List<RoomStay>,
timezone: String?,
billingMode: BookingBillingMode,
billingCheckinTime: String?,
billingCheckoutTime: String?
): Long {
@@ -122,6 +124,7 @@ internal fun computeExpectedPay(
stay.fromAt,
endAt,
timezone,
billingMode,
billingCheckinTime,
billingCheckoutTime
)
@@ -134,6 +137,7 @@ internal fun computeExpectedPayTotal(
stays: List<RoomStay>,
expectedCheckoutAt: OffsetDateTime?,
timezone: String?,
billingMode: BookingBillingMode,
billingCheckinTime: String?,
billingCheckoutTime: String?
): Long {
@@ -149,6 +153,7 @@ internal fun computeExpectedPayTotal(
stay.fromAt,
endAt,
timezone,
billingMode,
billingCheckinTime,
billingCheckoutTime
)
@@ -164,10 +169,19 @@ internal fun billableNights(
startAt: OffsetDateTime,
endAt: OffsetDateTime,
timezone: String?,
billingMode: BookingBillingMode,
billingCheckinTime: String?,
billingCheckoutTime: String?
): Long {
if (!endAt.isAfter(startAt)) return 1L
if (billingMode == BookingBillingMode.FULL_24H) {
val minutes = java.time.Duration.between(startAt, endAt).toMinutes().coerceAtLeast(0L)
if (minutes == 0L) return 1L
val fullDays = minutes / (24L * 60L)
val remainder = minutes % (24L * 60L)
val extraNight = if (remainder > 120L) 1L else 0L
return (fullDays + extraNight).coerceAtLeast(1L)
}
val zone = try {
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
} catch (_: Exception) {

View File

@@ -24,6 +24,9 @@ data class BookingCreateRequest(
val source: String? = null,
val expectedCheckInAt: String,
val expectedCheckOutAt: String,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val guestPhoneE164: String? = null,
val fromCity: String? = null,
val toCity: String? = null,
@@ -39,6 +42,9 @@ data class BookingCreateRequest(
data class BookingCreateResponse(
val id: UUID,
val status: String,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val guestId: UUID?,
val checkInAt: String?,
val expectedCheckInAt: String?,
@@ -53,6 +59,9 @@ data class BookingListItem(
val guestPhone: String?,
val roomNumbers: List<Int>,
val source: String?,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?,
val checkInAt: String?,
@@ -80,6 +89,9 @@ data class BookingDetailResponse(
val vehicleNumbers: List<String>,
val roomNumbers: List<Int>,
val source: String?,
val billingMode: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val fromCity: String?,
val toCity: String?,
val memberRelation: String?,
@@ -112,6 +124,12 @@ data class BookingExpectedDatesUpdateRequest(
val expectedCheckOutAt: String? = null
)
data class BookingBillingPolicyUpdateRequest(
val billingMode: String,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)
data class BookingCheckOutRequest(
val checkOutAt: String? = null,
val notes: String? = null

View File

@@ -49,6 +49,16 @@ class Booking(
@Column(name = "expected_checkout_at", columnDefinition = "timestamptz")
var expectedCheckoutAt: OffsetDateTime? = null,
@Enumerated(EnumType.STRING)
@Column(name = "billing_mode", nullable = false)
var billingMode: BookingBillingMode = BookingBillingMode.PROPERTY_POLICY,
@Column(name = "billing_checkin_time", nullable = false)
var billingCheckinTime: String = "12:00",
@Column(name = "billing_checkout_time", nullable = false)
var billingCheckoutTime: String = "11:00",
@Column(name = "email_audit_pdf_url")
var emailAuditPdfUrl: String? = null,

View File

@@ -0,0 +1,7 @@
package com.android.trisolarisserver.models.booking
enum class BookingBillingMode {
PROPERTY_POLICY,
CUSTOM_WINDOW,
FULL_24H
}

View File

@@ -0,0 +1,63 @@
package com.android.trisolarisserver.models.booking
import com.android.trisolarisserver.models.property.AppUser
import com.android.trisolarisserver.models.property.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 java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(name = "booking_billing_policy_audit_log")
class BookingBillingPolicyAuditLog(
@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,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "actor_user_id")
var actor: AppUser? = null,
@Enumerated(EnumType.STRING)
@Column(name = "old_billing_mode", nullable = false)
var oldBillingMode: BookingBillingMode,
@Enumerated(EnumType.STRING)
@Column(name = "new_billing_mode", nullable = false)
var newBillingMode: BookingBillingMode,
@Column(name = "old_billing_checkin_time", nullable = false)
var oldBillingCheckinTime: String,
@Column(name = "new_billing_checkin_time", nullable = false)
var newBillingCheckinTime: String,
@Column(name = "old_billing_checkout_time", nullable = false)
var oldBillingCheckoutTime: String,
@Column(name = "new_billing_checkout_time", nullable = false)
var newBillingCheckoutTime: String,
@Column(name = "event", nullable = false)
var event: String = "POLICY_MODIFIED",
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -0,0 +1,7 @@
package com.android.trisolarisserver.repo.booking
import com.android.trisolarisserver.models.booking.BookingBillingPolicyAuditLog
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface BookingBillingPolicyAuditLogRepo : JpaRepository<BookingBillingPolicyAuditLog, UUID>

View File

@@ -6,6 +6,7 @@ import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.guest.GuestRepo
import com.android.trisolarisserver.repo.email.InboundEmailRepo
import com.android.trisolarisserver.models.booking.Booking
import com.android.trisolarisserver.models.booking.BookingBillingMode
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.booking.Guest
import com.android.trisolarisserver.models.booking.InboundEmail
@@ -186,6 +187,9 @@ class EmailIngestionService(
sourceBookingId = sourceBookingId,
expectedCheckinAt = checkin,
expectedCheckoutAt = checkout,
billingMode = BookingBillingMode.PROPERTY_POLICY,
billingCheckinTime = property.billingCheckinTime,
billingCheckoutTime = property.billingCheckoutTime,
emailAuditPdfUrl = emailAuditPdfUrl
)
return bookingRepo.save(booking)