Add property billing policy times and policy-based night calculation
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s

This commit is contained in:
androidlover5842
2026-02-02 10:17:00 +05:30
parent 734591807f
commit 776ed6dc4e
7 changed files with 270 additions and 28 deletions

View File

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

View File

@@ -1,4 +1,5 @@
package com.android.trisolarisserver.controller.booking 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.computeExpectedPay
import com.android.trisolarisserver.controller.common.nowForProperty import com.android.trisolarisserver.controller.common.nowForProperty
import com.android.trisolarisserver.controller.common.parseOffset 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.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime import java.time.OffsetDateTime
import kotlin.math.abs
import java.util.UUID import java.util.UUID
@RestController @RestController
@@ -218,7 +220,12 @@ class BookingFlow(
val expectedPay = if (stays.isEmpty()) { val expectedPay = if (stays.isEmpty()) {
null null
} else { } else {
computeExpectedPay(stays, property.timezone) computeExpectedPay(
stays,
property.timezone,
property.billingCheckinTime,
property.billingCheckoutTime
)
} }
val collected = paymentsByBooking[booking.id] ?: 0L val collected = paymentsByBooking[booking.id] ?: 0L
val extraCharges = chargesByBooking[booking.id] ?: 0L val extraCharges = chargesByBooking[booking.id] ?: 0L
@@ -461,6 +468,15 @@ class BookingFlow(
if (stays.any { !isCheckoutAmountValid(it) || !isMinimumStayDurationValid(it, checkOutAt) }) { if (stays.any { !isCheckoutAmountValid(it) || !isMinimumStayDurationValid(it, checkOutAt) }) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay amount is outside allowed range") 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 } val oldStates = stays.associate { it.id!! to it.toAt }
stays.forEach { it.toAt = checkOutAt } stays.forEach { it.toAt = checkOutAt }
roomStayRepo.saveAll(stays) roomStayRepo.saveAll(stays)
@@ -524,6 +540,15 @@ class BookingFlow(
if (!isMinimumStayDurationValid(stay, checkOutAt)) { if (!isMinimumStayDurationValid(stay, checkOutAt)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Minimum stay duration is 1 hour") 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 oldToAt = stay.toAt
val oldIsVoided = stay.isVoided val oldIsVoided = stay.isVoided
@@ -770,6 +795,51 @@ class BookingFlow(
return java.time.Duration.between(stay.fromAt, checkOutAt).toMinutes() >= 60 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( private fun logStayAudit(
stay: RoomStay, stay: RoomStay,
action: String, action: String,

View File

@@ -50,8 +50,19 @@ class BookingSnapshotBuilder(
} }
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L } val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
val expectedPay = computeExpectedPayTotal(stays, booking.expectedCheckoutAt, booking.property.timezone) val expectedPay = computeExpectedPayTotal(
val accruedPay = computeExpectedPay(stays, booking.property.timezone) 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 extraCharges = chargeRepo.sumAmountByBookingId(bookingId)
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId) val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
val pending = accruedPay + extraCharges - amountCollected val pending = accruedPay + extraCharges - amountCollected

View File

@@ -19,6 +19,7 @@ import com.android.trisolarisserver.repo.room.RoomStayRepo
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
import java.util.UUID import java.util.UUID
@@ -103,27 +104,11 @@ internal fun nowForProperty(timezone: String?): OffsetDateTime {
return OffsetDateTime.now(zone) return OffsetDateTime.now(zone)
} }
internal fun computeExpectedPay(stays: List<RoomStay>, timezone: String?): Long { internal fun computeExpectedPay(
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(
stays: List<RoomStay>, stays: List<RoomStay>,
expectedCheckoutAt: OffsetDateTime?, timezone: String?,
timezone: String? billingCheckinTime: String?,
billingCheckoutTime: String?
): Long { ): Long {
if (stays.isEmpty()) return 0 if (stays.isEmpty()) return 0
val now = nowForProperty(timezone) val now = nowForProperty(timezone)
@@ -132,15 +117,87 @@ internal fun computeExpectedPayTotal(
if (stay.isVoided) return@forEach if (stay.isVoided) return@forEach
val rate = stay.nightlyRate ?: 0L val rate = stay.nightlyRate ?: 0L
if (rate == 0L) return@forEach if (rate == 0L) return@forEach
val start = stay.fromAt.toLocalDate() val endAt = stay.toAt ?: now
val endAt = stay.toAt ?: expectedCheckoutAt ?: now val nights = billableNights(
val end = endAt.toLocalDate() stay.fromAt,
val nights = daysBetweenInclusive(start, end) endAt,
timezone,
billingCheckinTime,
billingCheckoutTime
)
total += rate * nights total += rate * nights
} }
return total 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 { internal fun daysBetweenInclusive(start: LocalDate, end: LocalDate): Long {
val diff = end.toEpochDay() - start.toEpochDay() val diff = end.toEpochDay() - start.toEpochDay()
return if (diff <= 0) 1L else diff return if (diff <= 0) 1L else diff

View File

@@ -7,6 +7,8 @@ data class PropertyCreateRequest(
val addressText: String? = null, val addressText: String? = null,
val timezone: String? = null, val timezone: String? = null,
val currency: String? = null, val currency: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val active: Boolean? = null, val active: Boolean? = null,
val otaAliases: Set<String>? = null, val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null, val emailAddresses: Set<String>? = null,
@@ -19,6 +21,8 @@ data class PropertyUpdateRequest(
val addressText: String? = null, val addressText: String? = null,
val timezone: String? = null, val timezone: String? = null,
val currency: String? = null, val currency: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val active: Boolean? = null, val active: Boolean? = null,
val otaAliases: Set<String>? = null, val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null, val emailAddresses: Set<String>? = null,
@@ -32,6 +36,8 @@ data class PropertyResponse(
val addressText: String?, val addressText: String?,
val timezone: String, val timezone: String,
val currency: String, val currency: String,
val billingCheckinTime: String,
val billingCheckoutTime: String,
val active: Boolean, val active: Boolean,
val otaAliases: Set<String>, val otaAliases: Set<String>,
val emailAddresses: Set<String>, val emailAddresses: Set<String>,
@@ -42,6 +48,17 @@ data class PropertyCodeResponse(
val code: String 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( data class GuestResponse(
val id: UUID, val id: UUID,
val name: String?, val name: String?,

View File

@@ -5,6 +5,8 @@ import com.android.trisolarisserver.controller.common.requireUser
import com.android.trisolarisserver.component.auth.PropertyAccess import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.controller.dto.property.PropertyCodeResponse 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.PropertyCreateRequest
import com.android.trisolarisserver.controller.dto.property.PropertyResponse import com.android.trisolarisserver.controller.dto.property.PropertyResponse
import com.android.trisolarisserver.controller.dto.property.PropertyUserDisableRequest 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.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.UUID import java.util.UUID
@RestController @RestController
@@ -58,6 +63,8 @@ class Properties(
addressText = request.addressText, addressText = request.addressText,
timezone = request.timezone ?: "Asia/Kolkata", timezone = request.timezone ?: "Asia/Kolkata",
currency = request.currency ?: "INR", currency = request.currency ?: "INR",
billingCheckinTime = validateBillingTime(request.billingCheckinTime, "billingCheckinTime", "12:00"),
billingCheckoutTime = validateBillingTime(request.billingCheckoutTime, "billingCheckoutTime", "11:00"),
active = request.active ?: true, active = request.active ?: true,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(), otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(), emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf(),
@@ -107,6 +114,53 @@ class Properties(
return PropertyCodeResponse(code = property.code) 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") @GetMapping("/properties/{propertyId}/users")
fun listPropertyUsers( fun listPropertyUsers(
@PathVariable propertyId: UUID, @PathVariable propertyId: UUID,
@@ -270,6 +324,16 @@ class Properties(
property.addressText = request.addressText ?: property.addressText property.addressText = request.addressText ?: property.addressText
property.timezone = request.timezone ?: property.timezone property.timezone = request.timezone ?: property.timezone
property.currency = request.currency ?: property.currency 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 property.active = request.active ?: property.active
if (request.otaAliases != null) { if (request.otaAliases != null) {
property.otaAliases = request.otaAliases.toMutableSet() 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 { private fun generatePropertyCode(): String {
repeat(10) { repeat(10) {
val code = buildString(7) { val code = buildString(7) {
@@ -319,6 +392,10 @@ class Properties(
Role.AGENT -> 100 Role.AGENT -> 100
} }
} }
companion object {
private val BILLING_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
}
} }
private fun Property.toResponse(): PropertyResponse { private fun Property.toResponse(): PropertyResponse {
@@ -330,6 +407,8 @@ private fun Property.toResponse(): PropertyResponse {
addressText = addressText, addressText = addressText,
timezone = timezone, timezone = timezone,
currency = currency, currency = currency,
billingCheckinTime = billingCheckinTime,
billingCheckoutTime = billingCheckoutTime,
active = active, active = active,
otaAliases = otaAliases.toSet(), otaAliases = otaAliases.toSet(),
emailAddresses = emailAddresses.toSet(), emailAddresses = emailAddresses.toSet(),

View File

@@ -38,6 +38,12 @@ class Property(
@Column(nullable = false) @Column(nullable = false)
var currency: String = "INR", 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) @Column(name = "is_active", nullable = false)
var active: Boolean = true, var active: Boolean = true,