billing: add billable nights view and improve payment ledger cash handle logic

This commit is contained in:
androidlover5842
2026-02-04 14:12:19 +05:30
parent eab5517f9b
commit 3a90aa848d
7 changed files with 184 additions and 15 deletions

View File

@@ -101,6 +101,17 @@ data class BookingExpectedDatesRequest(
val expectedCheckOutAt: String? = null
)
data class BookingBillableNightsRequest(
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null
)
data class BookingBillableNightsResponse(
val bookingId: String? = null,
val status: String? = null,
val billableNights: Long? = null
)
data class BookingDetailsResponse(
val id: String? = null,
val status: String? = null,
@@ -139,6 +150,7 @@ data class BookingDetailsResponse(
val expectedPay: Long? = null,
val amountCollected: Long? = null,
val pending: Long? = null,
val billableNights: Long? = null,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null

View File

@@ -11,6 +11,8 @@ import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.BookingNoShowRequest
import com.android.trisolarispms.data.api.model.BookingListItem
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingBillableNightsRequest
import com.android.trisolarispms.data.api.model.BookingBillableNightsResponse
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.BookingBalanceResponse
@@ -60,6 +62,13 @@ interface BookingApi {
@Body body: BookingExpectedDatesRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/billable-nights")
suspend fun previewBillableNights(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingBillableNightsRequest
): Response<BookingBillableNightsResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/billing-policy")
suspend fun updateBookingBillingPolicy(
@Path("propertyId") propertyId: String,

View File

@@ -3,9 +3,11 @@ package com.android.trisolarispms.ui.booking
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -13,8 +15,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.BookingBillableNightsRequest
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.ui.common.PaddedScreenColumn
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
@@ -43,7 +47,10 @@ fun BookingExpectedDatesScreen(
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val today = LocalDate.now(displayZone)
val editableCheckIn = status?.uppercase() == "OPEN"
val bookingStatus = status?.uppercase()
val editableCheckIn = bookingStatus == "OPEN"
val billableNights = remember { mutableStateOf<Long?>(null) }
val isBillableNightsLoading = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(bookingId) {
@@ -70,19 +77,72 @@ fun BookingExpectedDatesScreen(
}
}
SaveTopBarScaffold(
title = "Update Expected Dates",
onBack = onBack,
saveEnabled = !isLoading.value,
onSave = {
isLoading.value = true
error.value = null
LaunchedEffect(
propertyId,
bookingId,
bookingStatus,
checkInDate.value,
checkInTime.value,
checkOutDate.value,
checkOutTime.value
) {
val inAt = if (editableCheckIn) {
checkInDate.value?.let { formatBookingIso(it, checkInTime.value) }
} else {
null
}
val outAt = checkOutDate.value?.let { formatBookingIso(it, checkOutTime.value) }
val previewBody = when (bookingStatus) {
"OPEN" -> {
if (inAt.isNullOrBlank() || outAt.isNullOrBlank()) null
else BookingBillableNightsRequest(expectedCheckInAt = inAt, expectedCheckOutAt = outAt)
}
"CHECKED_IN" -> {
if (outAt.isNullOrBlank()) null
else BookingBillableNightsRequest(expectedCheckOutAt = outAt)
}
else -> null
}
if (previewBody == null) {
billableNights.value = null
isBillableNightsLoading.value = false
return@LaunchedEffect
}
isBillableNightsLoading.value = true
try {
val api = ApiClient.create()
val response = api.previewBillableNights(
propertyId = propertyId,
bookingId = bookingId,
body = previewBody
)
billableNights.value = if (response.isSuccessful) response.body()?.billableNights else null
} catch (_: Exception) {
billableNights.value = null
} finally {
isBillableNightsLoading.value = false
}
}
SaveTopBarScaffold(
title = "Update Expected Dates",
onBack = onBack,
saveEnabled = !isLoading.value,
onSave = {
val inAt = if (editableCheckIn) {
checkInDate.value?.let { formatBookingIso(it, checkInTime.value) }
} else {
null
}
val outAt = checkOutDate.value?.let { formatBookingIso(it, checkOutTime.value) }
val hasCheckInChanged = editableCheckIn && !isSameBookingDateTime(inAt, expectedCheckInAt)
val hasCheckOutChanged = !isSameBookingDateTime(outAt, expectedCheckOutAt)
if (!hasCheckInChanged && !hasCheckOutChanged) {
onDone()
return@SaveTopBarScaffold
}
isLoading.value = true
error.value = null
scope.launch {
try {
val api = ApiClient.create()
@@ -151,6 +211,24 @@ fun BookingExpectedDatesScreen(
label = { Text("Expected Check-out") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(6.dp))
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f),
shape = MaterialTheme.shapes.small
) {
Text(
text = if (isBillableNightsLoading.value) {
"Billable Nights: Calculating..."
} else {
"Billable Nights: ${billableNights.value?.toString() ?: "-"}"
},
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)
)
}
val checkOutMinDate = maxOf(checkInDate.value ?: today, today)
Spacer(modifier = Modifier.height(8.dp))
BookingDateTimePickerInline(
@@ -174,3 +252,15 @@ fun BookingExpectedDatesScreen(
}
}
}
private fun isSameBookingDateTime(current: String?, original: String?): Boolean {
if (current.isNullOrBlank() && original.isNullOrBlank()) return true
if (current.isNullOrBlank() || original.isNullOrBlank()) return false
val currentInstant = runCatching { OffsetDateTime.parse(current).toInstant() }.getOrNull()
val originalInstant = runCatching { OffsetDateTime.parse(original).toInstant() }.getOrNull()
return if (currentInstant != null && originalInstant != null) {
currentInstant == originalInstant
} else {
current.trim() == original.trim()
}
}

View File

@@ -56,10 +56,23 @@ fun BookingPaymentsScreen(
val refundTarget = remember { mutableStateOf<PaymentDto?>(null) }
val refundAmount = rememberSaveable { mutableStateOf("") }
val refundNotes = rememberSaveable { mutableStateOf("") }
val amountValue = amountInput.value.toLongOrNull()
val pendingBalance = state.pendingBalance
val isAmountExceedingPending = pendingBalance != null && amountValue != null && amountValue > pendingBalance
val canAddCashPayment = !state.isLoading &&
amountValue != null &&
amountValue > 0L &&
(pendingBalance == null || amountValue <= pendingBalance) &&
(pendingBalance == null || pendingBalance > 0L)
LaunchedEffect(propertyId, bookingId) {
viewModel.load(propertyId, bookingId)
}
LaunchedEffect(state.message) {
if (state.message == "Cash payment added") {
amountInput.value = ""
}
}
BackTopBarScaffold(
title = "Payments",
@@ -72,6 +85,12 @@ fun BookingPaymentsScreen(
.padding(16.dp)
) {
if (canAddCash) {
Text(
text = "Pending balance: ${pendingBalance?.toString() ?: "-"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = amountInput.value,
onValueChange = { amountInput.value = it.filter { ch -> ch.isDigit() } },
@@ -79,14 +98,20 @@ fun BookingPaymentsScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
if (isAmountExceedingPending) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Amount cannot be greater than pending balance",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val amount = amountInput.value.toLongOrNull() ?: 0L
viewModel.addCashPayment(propertyId, bookingId, amount)
amountInput.value = ""
viewModel.addCashPayment(propertyId, bookingId, amountValue ?: 0L)
},
enabled = !state.isLoading,
enabled = canAddCashPayment,
modifier = Modifier.fillMaxWidth()
) {
Text("Add cash payment")
@@ -204,7 +229,7 @@ private fun PaymentCard(
runCatching { OffsetDateTime.parse(it) }.getOrNull()
}
val dateText = date?.format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a"))
val isCash = payment.method == "CASH"
val isCash = payment.method.equals("CASH", ignoreCase = true)
Card(
colors = CardDefaults.cardColors(
containerColor = if (isCash) {
@@ -229,6 +254,7 @@ private fun PaymentCard(
val hasRefundableAmount = (payment.amount ?: 0L) > 0L
if (
canRefund &&
!isCash &&
hasRefundableAmount &&
(!payment.id.isNullOrBlank() || !payment.gatewayPaymentId.isNullOrBlank())
) {

View File

@@ -6,5 +6,6 @@ data class BookingPaymentsState(
val isLoading: Boolean = false,
val error: String? = null,
val message: String? = null,
val payments: List<PaymentDto> = emptyList()
val payments: List<PaymentDto> = emptyList(),
val pendingBalance: Long? = null
)

View File

@@ -17,19 +17,23 @@ class BookingPaymentsViewModel : ViewModel() {
fun load(propertyId: String, bookingId: String) {
runPaymentAction(defaultError = "Load failed") { api ->
val response = api.listPayments(propertyId, bookingId)
val body = response.body()
if (response.isSuccessful && body != null) {
val paymentsResponse = api.listPayments(propertyId, bookingId)
val payments = paymentsResponse.body()
if (paymentsResponse.isSuccessful && payments != null) {
val pending = api.getBookingBalance(propertyId, bookingId)
.body()
?.pending
_state.update {
it.copy(
isLoading = false,
payments = body,
payments = payments,
pendingBalance = pending,
error = null,
message = null
)
}
} else {
setActionFailure("Load", response)
setActionFailure("Load", paymentsResponse)
}
}
}
@@ -40,6 +44,13 @@ class BookingPaymentsViewModel : ViewModel() {
return
}
runPaymentAction(defaultError = "Create failed") { api ->
val latestPending = api.getBookingBalance(propertyId, bookingId).body()?.pending
?: _state.value.pendingBalance
val validationError = validateCashAmount(amount = amount, pendingBalance = latestPending)
if (validationError != null) {
_state.update { it.copy(isLoading = false, error = validationError, message = null) }
return@runPaymentAction
}
val response = api.createPayment(
propertyId = propertyId,
bookingId = bookingId,
@@ -48,9 +59,11 @@ class BookingPaymentsViewModel : ViewModel() {
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { current ->
val nextPending = latestPending?.let { (it - amount).coerceAtLeast(0L) }
current.copy(
isLoading = false,
payments = listOf(body) + current.payments,
pendingBalance = nextPending ?: current.pendingBalance,
error = null,
message = "Cash payment added"
)
@@ -63,6 +76,7 @@ class BookingPaymentsViewModel : ViewModel() {
fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) {
runPaymentAction(defaultError = "Delete failed") { api ->
val payment = _state.value.payments.firstOrNull { it.id == paymentId }
val response = api.deletePayment(
propertyId = propertyId,
bookingId = bookingId,
@@ -70,9 +84,16 @@ class BookingPaymentsViewModel : ViewModel() {
)
if (response.isSuccessful) {
_state.update { current ->
val restoredPending = if (payment?.method == "CASH") {
val amount = payment.amount ?: 0L
current.pendingBalance?.plus(amount)
} else {
current.pendingBalance
}
current.copy(
isLoading = false,
payments = current.payments.filterNot { it.id == paymentId },
pendingBalance = restoredPending,
error = null,
message = "Cash payment deleted"
)
@@ -112,6 +133,7 @@ class BookingPaymentsViewModel : ViewModel() {
)
val body = response.body()
if (response.isSuccessful && body != null) {
load(propertyId, bookingId)
_state.update {
it.copy(
isLoading = false,
@@ -119,7 +141,6 @@ class BookingPaymentsViewModel : ViewModel() {
message = "Refund processed"
)
}
load(propertyId, bookingId)
} else {
setActionFailure("Refund", response)
}
@@ -155,4 +176,11 @@ class BookingPaymentsViewModel : ViewModel() {
)
}
}
private fun validateCashAmount(amount: Long, pendingBalance: Long?): String? {
if (pendingBalance == null) return null
if (pendingBalance <= 0L) return "No pending balance to collect"
if (amount > pendingBalance) return "Amount cannot be greater than pending balance ($pendingBalance)"
return null
}
}

View File

@@ -257,6 +257,8 @@ private fun GuestInfoTabContent(
GuestDetailRow(label = "Billing Mode", value = details?.billingMode)
if (billingMode == BookingBillingMode.FULL_24H) {
GuestDetailRow(label = "Billing Window", value = "24-hour block")
} else if (billingMode == BookingBillingMode.CUSTOM_WINDOW) {
GuestDetailRow(label = "Billing Checkout Time", value = details?.billingCheckoutTime)
} else {
GuestDetailRow(label = "Billing Check-in Time", value = details?.billingCheckinTime)
GuestDetailRow(label = "Billing Checkout Time", value = details?.billingCheckoutTime)
@@ -304,6 +306,7 @@ private fun GuestInfoTabContent(
) {
GuestDetailRow(label = "Amount Collected", value = details?.amountCollected?.toString())
GuestDetailRow(label = "Rent per day", value = details?.totalNightlyRate?.toString())
GuestDetailRow(label = "Billable Nights", value = details?.billableNights?.toString())
GuestDetailRow(label = "Expected pay", value = details?.expectedPay?.toString())
GuestDetailRow(label = "Pending", value = details?.pending?.toString())
}