billing: add billable nights view and improve payment ledger cash handle logic
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user