diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt index 5c22d13..2d087ac 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt index 3ef7d33..e8ac98d 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt @@ -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 + @POST("properties/{propertyId}/bookings/{bookingId}/billable-nights") + suspend fun previewBillableNights( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Body body: BookingBillableNightsRequest + ): Response + @POST("properties/{propertyId}/bookings/{bookingId}/billing-policy") suspend fun updateBookingBillingPolicy( @Path("propertyId") propertyId: String, diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingExpectedDatesScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingExpectedDatesScreen.kt index 5218197..3e2c161 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingExpectedDatesScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingExpectedDatesScreen.kt @@ -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(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() + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsScreen.kt index afc3142..7d4ab66 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsScreen.kt @@ -56,10 +56,23 @@ fun BookingPaymentsScreen( val refundTarget = remember { mutableStateOf(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()) ) { diff --git a/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsState.kt b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsState.kt index d8e4a78..f61175d 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsState.kt @@ -6,5 +6,6 @@ data class BookingPaymentsState( val isLoading: Boolean = false, val error: String? = null, val message: String? = null, - val payments: List = emptyList() + val payments: List = emptyList(), + val pendingBalance: Long? = null ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt index 13eb2c9..7e26f05 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt @@ -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 + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt index ed7909e..177325b 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt @@ -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()) }