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 e92569e..705290c 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingBalances.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingBalances.kt @@ -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) 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 af45fd8..e78ca4e 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt @@ -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, + checkOutOverrides: Map, + 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, 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 e72d2db..f5c6645 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingSnapshotBuilder.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingSnapshotBuilder.kt @@ -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 diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/common/ControllerLookups.kt b/src/main/kotlin/com/android/trisolarisserver/controller/common/ControllerLookups.kt index 8b65d39..53c6d7f 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/common/ControllerLookups.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/common/ControllerLookups.kt @@ -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, 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, - 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, + 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 diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/property/OrgPropertyDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/property/OrgPropertyDtos.kt index c9427a8..f53164e 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/property/OrgPropertyDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/property/OrgPropertyDtos.kt @@ -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? = null, val emailAddresses: Set? = 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? = null, val emailAddresses: Set? = 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, val emailAddresses: Set, @@ -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?, diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/property/Properties.kt b/src/main/kotlin/com/android/trisolarisserver/controller/property/Properties.kt index 67c2a30..5ac1551 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/property/Properties.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/property/Properties.kt @@ -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(), diff --git a/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt b/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt index 344bd68..72dd881 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt @@ -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,