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,