Add property billing policy times and policy-based night calculation
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:
@@ -43,7 +43,9 @@ class BookingBalances(
|
||||
}
|
||||
val expected = computeExpectedPay(
|
||||
roomStayRepo.findByBookingId(bookingId),
|
||||
booking.property.timezone
|
||||
booking.property.timezone,
|
||||
booking.property.billingCheckinTime,
|
||||
booking.property.billingCheckoutTime
|
||||
)
|
||||
val charges = chargeRepo.sumAmountByBookingId(bookingId)
|
||||
val collected = paymentRepo.sumAmountByBookingId(bookingId)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
package com.android.trisolarisserver.controller.booking
|
||||
import com.android.trisolarisserver.controller.common.billableNights
|
||||
import com.android.trisolarisserver.controller.common.computeExpectedPay
|
||||
import com.android.trisolarisserver.controller.common.nowForProperty
|
||||
import com.android.trisolarisserver.controller.common.parseOffset
|
||||
@@ -58,6 +59,7 @@ import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.math.abs
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@@ -218,7 +220,12 @@ class BookingFlow(
|
||||
val expectedPay = if (stays.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
computeExpectedPay(stays, property.timezone)
|
||||
computeExpectedPay(
|
||||
stays,
|
||||
property.timezone,
|
||||
property.billingCheckinTime,
|
||||
property.billingCheckoutTime
|
||||
)
|
||||
}
|
||||
val collected = paymentsByBooking[booking.id] ?: 0L
|
||||
val extraCharges = chargesByBooking[booking.id] ?: 0L
|
||||
@@ -461,6 +468,15 @@ class BookingFlow(
|
||||
if (stays.any { !isCheckoutAmountValid(it) || !isMinimumStayDurationValid(it, checkOutAt) }) {
|
||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay amount is outside allowed range")
|
||||
}
|
||||
ensureLedgerToleranceForCheckout(
|
||||
bookingId = bookingId,
|
||||
timezone = booking.property.timezone,
|
||||
billingCheckinTime = booking.property.billingCheckinTime,
|
||||
billingCheckoutTime = booking.property.billingCheckoutTime,
|
||||
stays = stays,
|
||||
checkOutOverrides = stays.associate { it.id!! to checkOutAt },
|
||||
restrictToClosedStaysOnly = false
|
||||
)
|
||||
val oldStates = stays.associate { it.id!! to it.toAt }
|
||||
stays.forEach { it.toAt = checkOutAt }
|
||||
roomStayRepo.saveAll(stays)
|
||||
@@ -524,6 +540,15 @@ class BookingFlow(
|
||||
if (!isMinimumStayDurationValid(stay, checkOutAt)) {
|
||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Minimum stay duration is 1 hour")
|
||||
}
|
||||
ensureLedgerToleranceForCheckout(
|
||||
bookingId = bookingId,
|
||||
timezone = booking.property.timezone,
|
||||
billingCheckinTime = booking.property.billingCheckinTime,
|
||||
billingCheckoutTime = booking.property.billingCheckoutTime,
|
||||
stays = staysForBooking,
|
||||
checkOutOverrides = mapOf(stay.id!! to checkOutAt),
|
||||
restrictToClosedStaysOnly = true
|
||||
)
|
||||
|
||||
val oldToAt = stay.toAt
|
||||
val oldIsVoided = stay.isVoided
|
||||
@@ -770,6 +795,51 @@ class BookingFlow(
|
||||
return java.time.Duration.between(stay.fromAt, checkOutAt).toMinutes() >= 60
|
||||
}
|
||||
|
||||
private fun ensureLedgerToleranceForCheckout(
|
||||
bookingId: UUID,
|
||||
timezone: String?,
|
||||
billingCheckinTime: String?,
|
||||
billingCheckoutTime: String?,
|
||||
stays: List<RoomStay>,
|
||||
checkOutOverrides: Map<UUID, OffsetDateTime>,
|
||||
restrictToClosedStaysOnly: Boolean
|
||||
) {
|
||||
val now = nowForProperty(timezone)
|
||||
val expectedStayAmount = stays
|
||||
.asSequence()
|
||||
.filter { !it.isVoided }
|
||||
.mapNotNull { stay ->
|
||||
val effectiveToAt = checkOutOverrides[stay.id] ?: stay.toAt
|
||||
if (restrictToClosedStaysOnly && effectiveToAt == null) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val endAt = effectiveToAt ?: now
|
||||
val rate = stay.nightlyRate ?: 0L
|
||||
if (rate <= 0L) {
|
||||
return@mapNotNull 0L
|
||||
}
|
||||
rate * billableNights(
|
||||
stay.fromAt,
|
||||
endAt,
|
||||
timezone,
|
||||
billingCheckinTime,
|
||||
billingCheckoutTime
|
||||
)
|
||||
}
|
||||
.sum()
|
||||
val extraCharges = chargeRepo.sumAmountByBookingId(bookingId)
|
||||
val collected = paymentRepo.sumAmountByBookingId(bookingId)
|
||||
val expectedTotal = expectedStayAmount + extraCharges
|
||||
val tolerance = (expectedTotal * 20L) / 100L
|
||||
val delta = collected - expectedTotal
|
||||
if (abs(delta) > tolerance) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Ledger mismatch: collected amount must be within 20% of expected amount before checkout"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun logStayAudit(
|
||||
stay: RoomStay,
|
||||
action: String,
|
||||
|
||||
@@ -50,8 +50,19 @@ 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 expectedPay = computeExpectedPayTotal(
|
||||
stays,
|
||||
booking.expectedCheckoutAt,
|
||||
booking.property.timezone,
|
||||
booking.property.billingCheckinTime,
|
||||
booking.property.billingCheckoutTime
|
||||
)
|
||||
val accruedPay = computeExpectedPay(
|
||||
stays,
|
||||
booking.property.timezone,
|
||||
booking.property.billingCheckinTime,
|
||||
booking.property.billingCheckoutTime
|
||||
)
|
||||
val extraCharges = chargeRepo.sumAmountByBookingId(bookingId)
|
||||
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
|
||||
val pending = accruedPay + extraCharges - amountCollected
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
@@ -103,27 +104,11 @@ internal fun nowForProperty(timezone: String?): OffsetDateTime {
|
||||
return OffsetDateTime.now(zone)
|
||||
}
|
||||
|
||||
internal fun computeExpectedPay(stays: List<RoomStay>, timezone: String?): Long {
|
||||
if (stays.isEmpty()) return 0
|
||||
val now = nowForProperty(timezone)
|
||||
var total = 0L
|
||||
stays.forEach { stay ->
|
||||
if (stay.isVoided) return@forEach
|
||||
val rate = stay.nightlyRate ?: 0L
|
||||
if (rate == 0L) return@forEach
|
||||
val start = stay.fromAt.toLocalDate()
|
||||
val endAt = stay.toAt ?: now
|
||||
val end = endAt.toLocalDate()
|
||||
val nights = daysBetweenInclusive(start, end)
|
||||
total += rate * nights
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
internal fun computeExpectedPayTotal(
|
||||
internal fun computeExpectedPay(
|
||||
stays: List<RoomStay>,
|
||||
expectedCheckoutAt: OffsetDateTime?,
|
||||
timezone: String?
|
||||
timezone: String?,
|
||||
billingCheckinTime: String?,
|
||||
billingCheckoutTime: String?
|
||||
): Long {
|
||||
if (stays.isEmpty()) return 0
|
||||
val now = nowForProperty(timezone)
|
||||
@@ -132,15 +117,87 @@ internal fun computeExpectedPayTotal(
|
||||
if (stay.isVoided) return@forEach
|
||||
val rate = stay.nightlyRate ?: 0L
|
||||
if (rate == 0L) return@forEach
|
||||
val start = stay.fromAt.toLocalDate()
|
||||
val endAt = stay.toAt ?: expectedCheckoutAt ?: now
|
||||
val end = endAt.toLocalDate()
|
||||
val nights = daysBetweenInclusive(start, end)
|
||||
val endAt = stay.toAt ?: now
|
||||
val nights = billableNights(
|
||||
stay.fromAt,
|
||||
endAt,
|
||||
timezone,
|
||||
billingCheckinTime,
|
||||
billingCheckoutTime
|
||||
)
|
||||
total += rate * nights
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
internal fun computeExpectedPayTotal(
|
||||
stays: List<RoomStay>,
|
||||
expectedCheckoutAt: OffsetDateTime?,
|
||||
timezone: String?,
|
||||
billingCheckinTime: String?,
|
||||
billingCheckoutTime: String?
|
||||
): Long {
|
||||
if (stays.isEmpty()) return 0
|
||||
val now = nowForProperty(timezone)
|
||||
var total = 0L
|
||||
stays.forEach { stay ->
|
||||
if (stay.isVoided) return@forEach
|
||||
val rate = stay.nightlyRate ?: 0L
|
||||
if (rate == 0L) return@forEach
|
||||
val endAt = stay.toAt ?: expectedCheckoutAt ?: now
|
||||
val nights = billableNights(
|
||||
stay.fromAt,
|
||||
endAt,
|
||||
timezone,
|
||||
billingCheckinTime,
|
||||
billingCheckoutTime
|
||||
)
|
||||
total += rate * nights
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
private const val DEFAULT_BILLING_CHECKIN_TIME = "12:00"
|
||||
private const val DEFAULT_BILLING_CHECKOUT_TIME = "11:00"
|
||||
|
||||
internal fun billableNights(
|
||||
startAt: OffsetDateTime,
|
||||
endAt: OffsetDateTime,
|
||||
timezone: String?,
|
||||
billingCheckinTime: String?,
|
||||
billingCheckoutTime: String?
|
||||
): Long {
|
||||
if (!endAt.isAfter(startAt)) return 1L
|
||||
val zone = try {
|
||||
if (timezone.isNullOrBlank()) ZoneId.of("Asia/Kolkata") else ZoneId.of(timezone)
|
||||
} catch (_: Exception) {
|
||||
ZoneId.of("Asia/Kolkata")
|
||||
}
|
||||
val checkinPolicy = parseBillingTimeOrDefault(billingCheckinTime, DEFAULT_BILLING_CHECKIN_TIME)
|
||||
val checkoutPolicy = parseBillingTimeOrDefault(billingCheckoutTime, DEFAULT_BILLING_CHECKOUT_TIME)
|
||||
val localStart = startAt.atZoneSameInstant(zone)
|
||||
val localEnd = endAt.atZoneSameInstant(zone)
|
||||
var billStartDate = localStart.toLocalDate()
|
||||
if (localStart.toLocalTime().isBefore(checkinPolicy)) {
|
||||
billStartDate = billStartDate.minusDays(1)
|
||||
}
|
||||
var billEndDate = localEnd.toLocalDate()
|
||||
if (localEnd.toLocalTime().isAfter(checkoutPolicy)) {
|
||||
billEndDate = billEndDate.plusDays(1)
|
||||
}
|
||||
val diff = billEndDate.toEpochDay() - billStartDate.toEpochDay()
|
||||
return if (diff <= 0L) 1L else diff
|
||||
}
|
||||
|
||||
private fun parseBillingTimeOrDefault(raw: String?, fallback: String): LocalTime {
|
||||
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: fallback
|
||||
return try {
|
||||
LocalTime.parse(value)
|
||||
} catch (_: Exception) {
|
||||
LocalTime.parse(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun daysBetweenInclusive(start: LocalDate, end: LocalDate): Long {
|
||||
val diff = end.toEpochDay() - start.toEpochDay()
|
||||
return if (diff <= 0) 1L else diff
|
||||
|
||||
@@ -7,6 +7,8 @@ data class PropertyCreateRequest(
|
||||
val addressText: String? = null,
|
||||
val timezone: String? = null,
|
||||
val currency: String? = null,
|
||||
val billingCheckinTime: String? = null,
|
||||
val billingCheckoutTime: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val otaAliases: Set<String>? = null,
|
||||
val emailAddresses: Set<String>? = null,
|
||||
@@ -19,6 +21,8 @@ data class PropertyUpdateRequest(
|
||||
val addressText: String? = null,
|
||||
val timezone: String? = null,
|
||||
val currency: String? = null,
|
||||
val billingCheckinTime: String? = null,
|
||||
val billingCheckoutTime: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val otaAliases: Set<String>? = null,
|
||||
val emailAddresses: Set<String>? = null,
|
||||
@@ -32,6 +36,8 @@ data class PropertyResponse(
|
||||
val addressText: String?,
|
||||
val timezone: String,
|
||||
val currency: String,
|
||||
val billingCheckinTime: String,
|
||||
val billingCheckoutTime: String,
|
||||
val active: Boolean,
|
||||
val otaAliases: Set<String>,
|
||||
val emailAddresses: Set<String>,
|
||||
@@ -42,6 +48,17 @@ data class PropertyCodeResponse(
|
||||
val code: String
|
||||
)
|
||||
|
||||
data class PropertyBillingPolicyRequest(
|
||||
val billingCheckinTime: String,
|
||||
val billingCheckoutTime: String
|
||||
)
|
||||
|
||||
data class PropertyBillingPolicyResponse(
|
||||
val propertyId: UUID,
|
||||
val billingCheckinTime: String,
|
||||
val billingCheckoutTime: String
|
||||
)
|
||||
|
||||
data class GuestResponse(
|
||||
val id: UUID,
|
||||
val name: String?,
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.android.trisolarisserver.controller.common.requireUser
|
||||
|
||||
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||
import com.android.trisolarisserver.controller.dto.property.PropertyCodeResponse
|
||||
import com.android.trisolarisserver.controller.dto.property.PropertyBillingPolicyRequest
|
||||
import com.android.trisolarisserver.controller.dto.property.PropertyBillingPolicyResponse
|
||||
import com.android.trisolarisserver.controller.dto.property.PropertyCreateRequest
|
||||
import com.android.trisolarisserver.controller.dto.property.PropertyResponse
|
||||
import com.android.trisolarisserver.controller.dto.property.PropertyUserDisableRequest
|
||||
@@ -31,6 +33,9 @@ import org.springframework.web.bind.annotation.RequestBody
|
||||
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.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@@ -58,6 +63,8 @@ class Properties(
|
||||
addressText = request.addressText,
|
||||
timezone = request.timezone ?: "Asia/Kolkata",
|
||||
currency = request.currency ?: "INR",
|
||||
billingCheckinTime = validateBillingTime(request.billingCheckinTime, "billingCheckinTime", "12:00"),
|
||||
billingCheckoutTime = validateBillingTime(request.billingCheckoutTime, "billingCheckoutTime", "11:00"),
|
||||
active = request.active ?: true,
|
||||
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
|
||||
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(),
|
||||
@@ -107,6 +114,53 @@ class Properties(
|
||||
return PropertyCodeResponse(code = property.code)
|
||||
}
|
||||
|
||||
@GetMapping("/properties/{propertyId}/billing-policy")
|
||||
fun getBillingPolicy(
|
||||
@PathVariable propertyId: UUID,
|
||||
@AuthenticationPrincipal principal: MyPrincipal?
|
||||
): PropertyBillingPolicyResponse {
|
||||
requirePrincipal(principal)
|
||||
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||
}
|
||||
return PropertyBillingPolicyResponse(
|
||||
propertyId = property.id!!,
|
||||
billingCheckinTime = property.billingCheckinTime,
|
||||
billingCheckoutTime = property.billingCheckoutTime
|
||||
)
|
||||
}
|
||||
|
||||
@PutMapping("/properties/{propertyId}/billing-policy")
|
||||
fun updateBillingPolicy(
|
||||
@PathVariable propertyId: UUID,
|
||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||
@RequestBody request: PropertyBillingPolicyRequest
|
||||
): PropertyBillingPolicyResponse {
|
||||
requirePrincipal(principal)
|
||||
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
|
||||
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||
}
|
||||
property.billingCheckinTime = validateBillingTime(
|
||||
request.billingCheckinTime,
|
||||
"billingCheckinTime",
|
||||
property.billingCheckinTime
|
||||
)
|
||||
property.billingCheckoutTime = validateBillingTime(
|
||||
request.billingCheckoutTime,
|
||||
"billingCheckoutTime",
|
||||
property.billingCheckoutTime
|
||||
)
|
||||
val saved = propertyRepo.save(property)
|
||||
return PropertyBillingPolicyResponse(
|
||||
propertyId = saved.id!!,
|
||||
billingCheckinTime = saved.billingCheckinTime,
|
||||
billingCheckoutTime = saved.billingCheckoutTime
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/properties/{propertyId}/users")
|
||||
fun listPropertyUsers(
|
||||
@PathVariable propertyId: UUID,
|
||||
@@ -270,6 +324,16 @@ class Properties(
|
||||
property.addressText = request.addressText ?: property.addressText
|
||||
property.timezone = request.timezone ?: property.timezone
|
||||
property.currency = request.currency ?: property.currency
|
||||
property.billingCheckinTime = validateBillingTime(
|
||||
request.billingCheckinTime,
|
||||
"billingCheckinTime",
|
||||
property.billingCheckinTime
|
||||
)
|
||||
property.billingCheckoutTime = validateBillingTime(
|
||||
request.billingCheckoutTime,
|
||||
"billingCheckoutTime",
|
||||
property.billingCheckoutTime
|
||||
)
|
||||
property.active = request.active ?: property.active
|
||||
if (request.otaAliases != null) {
|
||||
property.otaAliases = request.otaAliases.toMutableSet()
|
||||
@@ -292,6 +356,15 @@ class Properties(
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateBillingTime(value: String?, fieldName: String, fallback: String): String {
|
||||
val candidate = value?.trim()?.takeIf { it.isNotEmpty() } ?: fallback
|
||||
return try {
|
||||
LocalTime.parse(candidate, BILLING_TIME_FORMATTER).format(BILLING_TIME_FORMATTER)
|
||||
} catch (_: DateTimeParseException) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be HH:mm")
|
||||
}
|
||||
}
|
||||
|
||||
private fun generatePropertyCode(): String {
|
||||
repeat(10) {
|
||||
val code = buildString(7) {
|
||||
@@ -319,6 +392,10 @@ class Properties(
|
||||
Role.AGENT -> 100
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val BILLING_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Property.toResponse(): PropertyResponse {
|
||||
@@ -330,6 +407,8 @@ private fun Property.toResponse(): PropertyResponse {
|
||||
addressText = addressText,
|
||||
timezone = timezone,
|
||||
currency = currency,
|
||||
billingCheckinTime = billingCheckinTime,
|
||||
billingCheckoutTime = billingCheckoutTime,
|
||||
active = active,
|
||||
otaAliases = otaAliases.toSet(),
|
||||
emailAddresses = emailAddresses.toSet(),
|
||||
|
||||
@@ -38,6 +38,12 @@ class Property(
|
||||
@Column(nullable = false)
|
||||
var currency: String = "INR",
|
||||
|
||||
@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 = "is_active", nullable = false)
|
||||
var active: Boolean = true,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user