From c5e0648dd1e753bc3ee4632c7df02452c5895daf Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Fri, 30 Jan 2026 10:17:17 +0530 Subject: [PATCH] add payment ledger impl and payu impl --- app/build.gradle.kts | 2 + .../com/android/trisolarispms/MainActivity.kt | 72 +++ .../trisolarispms/data/api/ApiService.kt | 4 +- .../trisolarispms/data/api/BookingApi.kt | 33 ++ .../data/api/PayuPaymentLinkSettingsApi.kt | 22 + .../trisolarispms/data/api/PayuSettingsApi.kt | 22 + .../data/api/model/PaymentModels.kt | 25 + .../model/PayuPaymentLinkSettingsModels.kt | 18 + .../data/api/model/PayuQrModels.kt | 26 ++ .../data/api/model/PayuSettingsModels.kt | 21 + .../com/android/trisolarispms/ui/AppRoute.kt | 11 + .../ui/payment/BookingPaymentsScreen.kt | 164 +++++++ .../ui/payment/BookingPaymentsState.kt | 9 + .../ui/payment/BookingPaymentsViewModel.kt | 90 ++++ .../ui/payu/PayuPaymentLinkSettingsState.kt | 16 + .../payu/PayuPaymentLinkSettingsViewModel.kt | 120 +++++ .../trisolarispms/ui/payu/PayuQrScreen.kt | 189 ++++++++ .../trisolarispms/ui/payu/PayuQrState.kt | 14 + .../trisolarispms/ui/payu/PayuQrViewModel.kt | 159 +++++++ .../ui/payu/PayuSettingsScreen.kt | 437 ++++++++++++++++++ .../ui/payu/PayuSettingsState.kt | 16 + .../ui/payu/PayuSettingsViewModel.kt | 145 ++++++ .../ui/roomstay/ActiveRoomStaysScreen.kt | 8 + .../ui/roomstay/BookingDetailsTabsScreen.kt | 58 ++- gradle/libs.versions.toml | 2 + 25 files changed, 1677 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/PayuPaymentLinkSettingsApi.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/PayuSettingsApi.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/model/PaymentModels.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/model/PayuPaymentLinkSettingsModels.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/model/PayuQrModels.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/model/PayuSettingsModels.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payu/PayuPaymentLinkSettingsState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payu/PayuPaymentLinkSettingsViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e1ddd6..3a0a3db 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } @@ -60,6 +61,7 @@ dependencies { implementation(libs.lottie.compose) implementation(libs.calendar.compose) implementation(libs.libphonenumber) + implementation(libs.zxing.core) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.auth.ktx) implementation(libs.kotlinx.coroutines.play.services) diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index 1d4612b..f461c2a 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -25,6 +25,7 @@ import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen import com.android.trisolarispms.ui.home.HomeScreen +import com.android.trisolarispms.ui.payment.BookingPaymentsScreen import com.android.trisolarispms.ui.property.AddPropertyScreen import com.android.trisolarispms.ui.room.RoomFormScreen import com.android.trisolarispms.ui.room.RoomsScreen @@ -35,6 +36,8 @@ import com.android.trisolarispms.ui.roomimage.ImageTagsScreen import com.android.trisolarispms.ui.roomimage.AddImageTagScreen import com.android.trisolarispms.ui.roomimage.EditImageTagScreen import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen +import com.android.trisolarispms.ui.payu.PayuSettingsScreen +import com.android.trisolarispms.ui.payu.PayuQrScreen import com.android.trisolarispms.ui.roomtype.AddAmenityScreen import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen import com.android.trisolarispms.ui.roomtype.AmenitiesScreen @@ -83,6 +86,11 @@ class MainActivity : ComponentActivity() { it == "ADMIN" || it == "MANAGER" || it == "STAFF" } == true } + val canManagePayuSettings: (String) -> Boolean = { propertyId -> + state.isSuperAdmin || state.propertyRoles[propertyId]?.any { + it == "ADMIN" || it == "MANAGER" + } == true + } BackHandler(enabled = currentRoute != AppRoute.Home) { when (currentRoute) { @@ -114,6 +122,15 @@ class MainActivity : ComponentActivity() { currentRoute.propertyId, currentRoute.roomTypeId ) + is AppRoute.PayuSettings -> route.value = AppRoute.ActiveRoomStays( + currentRoute.propertyId, + selectedPropertyName.value ?: "Property" + ) + is AppRoute.PayuQr -> route.value = AppRoute.BookingDetailsTabs( + currentRoute.propertyId, + currentRoute.bookingId, + null + ) is AppRoute.CreateBooking -> route.value = AppRoute.ActiveRoomStays( currentRoute.propertyId, selectedPropertyName.value ?: "Property" @@ -157,6 +174,11 @@ class MainActivity : ComponentActivity() { currentRoute.propertyId, selectedPropertyName.value ?: "Property" ) + is AppRoute.BookingPayments -> route.value = AppRoute.BookingDetailsTabs( + currentRoute.propertyId, + currentRoute.bookingId, + null + ) } } @@ -253,6 +275,8 @@ class MainActivity : ComponentActivity() { onBack = { route.value = AppRoute.Home }, onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) }, onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) }, + showPayuSettings = canManagePayuSettings(currentRoute.propertyId), + onPayuSettings = { route.value = AppRoute.PayuSettings(currentRoute.propertyId) }, onManageRoomStay = { booking -> val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() } ?: booking.expectedCheckInAt.orEmpty() @@ -281,6 +305,28 @@ class MainActivity : ComponentActivity() { ) } ) + is AppRoute.PayuSettings -> PayuSettingsScreen( + propertyId = currentRoute.propertyId, + onBack = { + route.value = AppRoute.ActiveRoomStays( + currentRoute.propertyId, + selectedPropertyName.value ?: "Property" + ) + } + ) + is AppRoute.PayuQr -> PayuQrScreen( + propertyId = currentRoute.propertyId, + bookingId = currentRoute.bookingId, + pendingAmount = currentRoute.pendingAmount, + guestPhone = currentRoute.guestPhone, + onBack = { + route.value = AppRoute.BookingDetailsTabs( + currentRoute.propertyId, + currentRoute.bookingId, + null + ) + } + ) is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen( propertyId = currentRoute.propertyId, bookingFromAt = currentRoute.fromAt, @@ -420,6 +466,32 @@ class MainActivity : ComponentActivity() { currentRoute.bookingId, guestId ) + }, + onOpenPayuQr = { pendingAmount, guestPhone -> + route.value = AppRoute.PayuQr( + propertyId = currentRoute.propertyId, + bookingId = currentRoute.bookingId, + pendingAmount = pendingAmount, + guestPhone = guestPhone + ) + }, + onOpenPayments = { + route.value = AppRoute.BookingPayments( + propertyId = currentRoute.propertyId, + bookingId = currentRoute.bookingId + ) + } + ) + is AppRoute.BookingPayments -> BookingPaymentsScreen( + propertyId = currentRoute.propertyId, + bookingId = currentRoute.bookingId, + canAddCash = canManagePayuSettings(currentRoute.propertyId), + onBack = { + route.value = AppRoute.BookingDetailsTabs( + currentRoute.propertyId, + currentRoute.bookingId, + null + ) } ) is AppRoute.Rooms -> RoomsScreen( diff --git a/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt b/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt index ebb574d..100abf3 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt @@ -15,4 +15,6 @@ interface ApiService : TransportApi, InboundEmailApi, AmenityApi, - RatePlanApi + RatePlanApi, + PayuSettingsApi, + PayuPaymentLinkSettingsApi diff --git a/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt index db1e71c..046a766 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt @@ -14,6 +14,12 @@ import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest import com.android.trisolarispms.data.api.model.BookingDetailsResponse import com.android.trisolarispms.data.api.model.RoomStayDto +import com.android.trisolarispms.data.api.model.PayuQrRequest +import com.android.trisolarispms.data.api.model.PayuQrResponse +import com.android.trisolarispms.data.api.model.PayuLinkRequest +import com.android.trisolarispms.data.api.model.PayuLinkResponse +import com.android.trisolarispms.data.api.model.PaymentDto +import com.android.trisolarispms.data.api.model.PaymentCreateRequest import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET @@ -95,4 +101,31 @@ interface BookingApi { @Path("bookingId") bookingId: String, @Body body: BookingRoomStayCreateRequest ): Response + + @POST("properties/{propertyId}/bookings/{bookingId}/payments/payu/qr") + suspend fun generatePayuQr( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Body body: PayuQrRequest + ): Response + + @POST("properties/{propertyId}/bookings/{bookingId}/payments/payu/link") + suspend fun generatePayuLink( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Body body: PayuLinkRequest + ): Response + + @GET("properties/{propertyId}/bookings/{bookingId}/payments") + suspend fun listPayments( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String + ): Response> + + @POST("properties/{propertyId}/bookings/{bookingId}/payments") + suspend fun createPayment( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Body body: PaymentCreateRequest + ): Response } diff --git a/app/src/main/java/com/android/trisolarispms/data/api/PayuPaymentLinkSettingsApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/PayuPaymentLinkSettingsApi.kt new file mode 100644 index 0000000..826ba46 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/PayuPaymentLinkSettingsApi.kt @@ -0,0 +1,22 @@ +package com.android.trisolarispms.data.api + +import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsRequest +import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PUT +import retrofit2.http.Path + +interface PayuPaymentLinkSettingsApi { + @GET("properties/{propertyId}/payu-payment-link-settings") + suspend fun getPayuPaymentLinkSettings( + @Path("propertyId") propertyId: String + ): Response + + @PUT("properties/{propertyId}/payu-payment-link-settings") + suspend fun updatePayuPaymentLinkSettings( + @Path("propertyId") propertyId: String, + @Body body: PayuPaymentLinkSettingsRequest + ): Response +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/PayuSettingsApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/PayuSettingsApi.kt new file mode 100644 index 0000000..4d171b4 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/PayuSettingsApi.kt @@ -0,0 +1,22 @@ +package com.android.trisolarispms.data.api + +import com.android.trisolarispms.data.api.model.PayuSettingsRequest +import com.android.trisolarispms.data.api.model.PayuSettingsResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PUT +import retrofit2.http.Path + +interface PayuSettingsApi { + @GET("properties/{propertyId}/payu-settings") + suspend fun getPayuSettings( + @Path("propertyId") propertyId: String + ): Response + + @PUT("properties/{propertyId}/payu-settings") + suspend fun updatePayuSettings( + @Path("propertyId") propertyId: String, + @Body body: PayuSettingsRequest + ): Response +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/PaymentModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/PaymentModels.kt new file mode 100644 index 0000000..8a3eef2 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/PaymentModels.kt @@ -0,0 +1,25 @@ +package com.android.trisolarispms.data.api.model + +data class PaymentDto( + val id: String? = null, + val bookingId: String? = null, + val amount: Long? = null, + val currency: String? = null, + val method: String? = null, + val gatewayPaymentId: String? = null, + val gatewayTxnId: String? = null, + val bankRefNum: String? = null, + val mode: String? = null, + val pgType: String? = null, + val payerVpa: String? = null, + val payerName: String? = null, + val paymentSource: String? = null, + val reference: String? = null, + val notes: String? = null, + val receivedAt: String? = null, + val receivedByUserId: String? = null +) + +data class PaymentCreateRequest( + val amount: Long +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/PayuPaymentLinkSettingsModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/PayuPaymentLinkSettingsModels.kt new file mode 100644 index 0000000..88269ea --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/PayuPaymentLinkSettingsModels.kt @@ -0,0 +1,18 @@ +package com.android.trisolarispms.data.api.model + +data class PayuPaymentLinkSettingsRequest( + val merchantId: String, + val clientId: String, + val clientSecret: String, + val isTest: Boolean +) + +data class PayuPaymentLinkSettingsResponse( + val propertyId: String? = null, + val configured: Boolean? = null, + val merchantId: String? = null, + val isTest: Boolean? = null, + val hasClientId: Boolean? = null, + val hasClientSecret: Boolean? = null, + val hasAccessToken: Boolean? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/PayuQrModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/PayuQrModels.kt new file mode 100644 index 0000000..2e5d553 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/PayuQrModels.kt @@ -0,0 +1,26 @@ +package com.android.trisolarispms.data.api.model + +import com.google.gson.JsonElement + +data class PayuQrRequest( + val amount: Long, + val deviceInfo: String +) + +data class PayuQrResponse( + val txnid: String? = null, + val amount: Long? = null, + val currency: String? = null, + val payuResponse: JsonElement? = null +) + +data class PayuLinkRequest( + val amount: Long +) + +data class PayuLinkResponse( + val amount: Long? = null, + val currency: String? = null, + val paymentLink: String? = null, + val payuResponse: JsonElement? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/PayuSettingsModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/PayuSettingsModels.kt new file mode 100644 index 0000000..accf7e1 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/PayuSettingsModels.kt @@ -0,0 +1,21 @@ +package com.android.trisolarispms.data.api.model + +import com.google.gson.annotations.SerializedName + +data class PayuSettingsRequest( + val merchantKey: String, + val salt32: String? = null, + val salt256: String? = null, + @SerializedName("isTest") val isTest: Boolean, + val useSalt256: Boolean +) + +data class PayuSettingsResponse( + val propertyId: String? = null, + val configured: Boolean? = null, + val merchantKey: String? = null, + @SerializedName("test") val isTest: Boolean? = null, + val useSalt256: Boolean? = null, + val hasSalt32: Boolean? = null, + val hasSalt256: Boolean? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt index 3d18151..d0aa94c 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -47,6 +47,10 @@ sealed interface AppRoute { val bookingId: String, val guestId: String? ) : AppRoute + data class BookingPayments( + val propertyId: String, + val bookingId: String + ) : AppRoute data object AddProperty : AppRoute data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute data class Rooms(val propertyId: String) : AppRoute @@ -63,6 +67,13 @@ sealed interface AppRoute { val ratePlanId: String, val ratePlanCode: String ) : AppRoute + data class PayuSettings(val propertyId: String) : AppRoute + data class PayuQr( + val propertyId: String, + val bookingId: String, + val pendingAmount: Long?, + val guestPhone: String? + ) : AppRoute data object Amenities : AppRoute data object AddAmenity : AppRoute data class EditAmenity(val amenityId: String) : AppRoute 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 new file mode 100644 index 0000000..33a7c9d --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsScreen.kt @@ -0,0 +1,164 @@ +package com.android.trisolarispms.ui.payment + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.trisolarispms.data.api.model.PaymentDto +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun BookingPaymentsScreen( + propertyId: String, + bookingId: String, + canAddCash: Boolean, + onBack: () -> Unit, + viewModel: BookingPaymentsViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val amountInput = remember { mutableStateOf("") } + + LaunchedEffect(propertyId, bookingId) { + viewModel.load(propertyId, bookingId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Payments") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + ) { + if (canAddCash) { + OutlinedTextField( + value = amountInput.value, + onValueChange = { amountInput.value = it.filter { ch -> ch.isDigit() } }, + label = { Text("Cash amount") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + val amount = amountInput.value.toLongOrNull() ?: 0L + viewModel.addCashPayment(propertyId, bookingId, amount) + }, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text("Add cash payment") + } + Spacer(modifier = Modifier.height(12.dp)) + } + if (state.isLoading) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + } + state.error?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + } + if (!state.isLoading && state.error == null && state.payments.isEmpty()) { + Text(text = "No payments yet") + } + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + items(state.payments) { payment -> + PaymentCard(payment = payment) + } + } + } + } +} + +@Composable +private fun PaymentCard(payment: PaymentDto) { + val date = payment.receivedAt?.let { + runCatching { OffsetDateTime.parse(it) }.getOrNull() + } + val dateText = date?.format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a")) + val isCash = payment.method == "CASH" + Card( + colors = CardDefaults.cardColors( + containerColor = if (isCash) { + MaterialTheme.colorScheme.tertiaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + val amountText = buildString { + append(payment.amount?.toString() ?: "-") + payment.currency?.let { append(" $it") } + } + Text(text = amountText, style = MaterialTheme.typography.titleMedium) + payment.method?.let { + Text(text = "Method: $it", style = MaterialTheme.typography.bodySmall) + } + payment.payerVpa?.takeIf { it.isNotBlank() }?.let { + Text(text = "UPI: $it", style = MaterialTheme.typography.bodySmall) + } + payment.payerName?.takeIf { it.isNotBlank() }?.let { + Text(text = "Payer: $it", style = MaterialTheme.typography.bodySmall) + } + dateText?.let { + Text(text = "Received: $it", style = MaterialTheme.typography.bodySmall) + } + payment.reference?.takeIf { it.isNotBlank() }?.let { + Text(text = "Ref: $it", style = MaterialTheme.typography.bodySmall) + } + payment.notes?.takeIf { it.isNotBlank() }?.let { + Text(text = it, style = MaterialTheme.typography.bodySmall) + } + } + } +} 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 new file mode 100644 index 0000000..b384c8b --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsState.kt @@ -0,0 +1,9 @@ +package com.android.trisolarispms.ui.payment + +import com.android.trisolarispms.data.api.model.PaymentDto + +data class BookingPaymentsState( + val isLoading: Boolean = false, + val error: String? = null, + val payments: List = emptyList() +) 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 new file mode 100644 index 0000000..51ac3f2 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt @@ -0,0 +1,90 @@ +package com.android.trisolarispms.ui.payment + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class BookingPaymentsViewModel : ViewModel() { + private val _state = MutableStateFlow(BookingPaymentsState()) + val state: StateFlow = _state + + fun load(propertyId: String, bookingId: String) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val response = api.listPayments(propertyId, bookingId) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + isLoading = false, + payments = body, + error = null + ) + } + } else { + _state.update { + it.copy( + isLoading = false, + error = "Load failed: ${response.code()}" + ) + } + } + } catch (e: Exception) { + _state.update { + it.copy( + isLoading = false, + error = e.localizedMessage ?: "Load failed" + ) + } + } + } + } + + fun addCashPayment(propertyId: String, bookingId: String, amount: Long) { + if (amount <= 0) { + _state.update { it.copy(error = "Amount must be greater than 0") } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val response = api.createPayment( + propertyId = propertyId, + bookingId = bookingId, + body = com.android.trisolarispms.data.api.model.PaymentCreateRequest(amount = amount) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { current -> + current.copy( + isLoading = false, + payments = listOf(body) + current.payments, + error = null + ) + } + } else { + _state.update { + it.copy( + isLoading = false, + error = "Create failed: ${response.code()}" + ) + } + } + } catch (e: Exception) { + _state.update { + it.copy( + isLoading = false, + error = e.localizedMessage ?: "Create failed" + ) + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuPaymentLinkSettingsState.kt b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuPaymentLinkSettingsState.kt new file mode 100644 index 0000000..0f0686d --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuPaymentLinkSettingsState.kt @@ -0,0 +1,16 @@ +package com.android.trisolarispms.ui.payu + +data class PayuPaymentLinkSettingsState( + val merchantId: String = "", + val clientId: String = "", + val clientSecret: String = "", + val isTest: Boolean = false, + val configured: Boolean = false, + val hasClientId: Boolean = false, + val hasClientSecret: Boolean = false, + val hasAccessToken: Boolean = false, + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val error: String? = null, + val message: String? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuPaymentLinkSettingsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuPaymentLinkSettingsViewModel.kt new file mode 100644 index 0000000..e33035d --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuPaymentLinkSettingsViewModel.kt @@ -0,0 +1,120 @@ +package com.android.trisolarispms.ui.payu + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class PayuPaymentLinkSettingsViewModel : ViewModel() { + private val _state = MutableStateFlow(PayuPaymentLinkSettingsState()) + val state: StateFlow = _state + + fun load(propertyId: String) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, message = null) } + try { + val api = ApiClient.create() + val response = api.getPayuPaymentLinkSettings(propertyId) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + merchantId = body.merchantId.orEmpty(), + isTest = body.isTest == true, + configured = body.configured == true, + hasClientId = body.hasClientId == true, + hasClientSecret = body.hasClientSecret == true, + hasAccessToken = body.hasAccessToken == true, + clientId = "", + clientSecret = "", + isLoading = false, + error = null + ) + } + } else { + _state.update { + it.copy( + isLoading = false, + error = "Load failed: ${response.code()}", + message = null + ) + } + } + } catch (e: Exception) { + _state.update { + it.copy( + isLoading = false, + error = e.localizedMessage ?: "Load failed", + message = null + ) + } + } + } + } + + fun onMerchantIdChange(value: String) { + _state.update { it.copy(merchantId = value, error = null) } + } + + fun onClientIdChange(value: String) { + _state.update { it.copy(clientId = value, error = null) } + } + + fun onClientSecretChange(value: String) { + _state.update { it.copy(clientSecret = value, error = null) } + } + + fun onIsTestChange(value: Boolean) { + _state.update { it.copy(isTest = value, error = null) } + } + + fun save(propertyId: String) { + val current = state.value + val merchantId = current.merchantId.trim() + val clientId = current.clientId.trim() + val clientSecret = current.clientSecret.trim() + if (merchantId.isBlank() || clientId.isBlank() || clientSecret.isBlank()) { + _state.update { it.copy(error = "All fields are required") } + return + } + viewModelScope.launch { + _state.update { it.copy(isSaving = true, error = null, message = null) } + try { + val api = ApiClient.create() + val response = api.updatePayuPaymentLinkSettings( + propertyId = propertyId, + body = PayuPaymentLinkSettingsRequest( + merchantId = merchantId, + clientId = clientId, + clientSecret = clientSecret, + isTest = current.isTest + ) + ) + if (response.isSuccessful) { + _state.update { it.copy(isSaving = false, message = "Saved", error = null) } + load(propertyId) + } else { + _state.update { + it.copy( + isSaving = false, + error = "Save failed: ${response.code()}", + message = null + ) + } + } + } catch (e: Exception) { + _state.update { + it.copy( + isSaving = false, + error = e.localizedMessage ?: "Save failed", + message = null + ) + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrScreen.kt new file mode 100644 index 0000000..624b398 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrScreen.kt @@ -0,0 +1,189 @@ +package com.android.trisolarispms.ui.payu + +import android.graphics.Bitmap +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.common.BitMatrix + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun PayuQrScreen( + propertyId: String, + bookingId: String, + pendingAmount: Long?, + guestPhone: String?, + onBack: () -> Unit, + viewModel: PayuQrViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + + LaunchedEffect(pendingAmount) { + viewModel.setInitialAmount(pendingAmount) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("PayU QR") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Top + ) { + OutlinedTextField( + value = state.amountInput, + onValueChange = viewModel::onAmountChange, + label = { Text("Amount") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { + if (!guestPhone.isNullOrBlank()) { + viewModel.generateLink(propertyId, bookingId) { link -> + val uri = Uri.parse("smsto:${guestPhone}") + val intent = Intent(Intent.ACTION_SENDTO, uri) + .putExtra("sms_body", "Pay using this link: $link") + context.startActivity(intent) + } + } + }, + enabled = !state.isLoading && !guestPhone.isNullOrBlank(), + modifier = Modifier.weight(1f) + ) { + Text("Share Link") + } + Button( + onClick = { viewModel.generate(propertyId, bookingId) }, + enabled = !state.isLoading, + modifier = Modifier.weight(1f) + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier + .height(16.dp) + .padding(end = 8.dp), + strokeWidth = 2.dp + ) + Text("Generating...") + } else { + Text("Generate QR") + } + } + } + + state.error?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + + if (!state.qrString.isNullOrBlank()) { + Spacer(modifier = Modifier.height(16.dp)) + val qrBitmap = remember(state.qrString) { state.qrString?.let { createQrBitmap(it) } } + qrBitmap?.let { + androidx.compose.foundation.Image( + bitmap = it.asImageBitmap(), + contentDescription = "PayU QR", + modifier = Modifier + .fillMaxWidth() + .height(240.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Amount: ${state.amount ?: "-"} ${state.currency ?: ""}", + style = MaterialTheme.typography.bodyMedium + ) + Button( + onClick = { + clipboard.setText(AnnotatedString(state.qrString.orEmpty())) + } + ) { + Text("Copy QR string") + } + } + } + } + } +} + +private fun createQrBitmap(content: String, size: Int = 600): Bitmap { + val matrix: BitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size) + val width = matrix.width + val height = matrix.height + val pixels = IntArray(width * height) + for (y in 0 until height) { + val offset = y * width + for (x in 0 until width) { + pixels[offset + x] = if (matrix.get(x, y)) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() + } + } + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { + setPixels(pixels, 0, width, 0, 0, width, height) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrState.kt b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrState.kt new file mode 100644 index 0000000..97d9777 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrState.kt @@ -0,0 +1,14 @@ +package com.android.trisolarispms.ui.payu + +data class PayuQrState( + val deviceInfo: String = "", + val amountInput: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val txnId: String? = null, + val amount: Long? = null, + val currency: String? = null, + val qrString: String? = null, + val payuResponseRaw: String? = null, + val paymentLink: String? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrViewModel.kt new file mode 100644 index 0000000..f38fd22 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrViewModel.kt @@ -0,0 +1,159 @@ +package com.android.trisolarispms.ui.payu + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import com.android.trisolarispms.data.api.model.PayuLinkRequest +import com.android.trisolarispms.data.api.model.PayuQrRequest +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class PayuQrViewModel : ViewModel() { + private val gson = Gson() + private val _state = MutableStateFlow( + PayuQrState(deviceInfo = buildDeviceInfo()) + ) + val state: StateFlow = _state + + fun onAmountChange(value: String) { + val digits = value.filter { it.isDigit() } + _state.update { it.copy(amountInput = digits, error = null) } + } + + fun setInitialAmount(amount: Long?) { + if (amount == null || amount <= 0) return + _state.update { current -> + if (current.amountInput.isBlank()) { + current.copy(amountInput = amount.toString()) + } else { + current + } + } + } + + fun generate(propertyId: String, bookingId: String) { + val current = state.value + val amount = current.amountInput.toLongOrNull()?.takeIf { it > 0 } + if (amount == null) { + _state.update { it.copy(error = "Amount is required") } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val response = api.generatePayuQr( + propertyId = propertyId, + bookingId = bookingId, + body = PayuQrRequest( + amount = amount, + deviceInfo = current.deviceInfo + ) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + val (qrString, rawPayload) = extractQrString(body.payuResponse) + _state.update { + it.copy( + isLoading = false, + txnId = body.txnid, + amount = body.amount, + currency = body.currency, + qrString = qrString, + payuResponseRaw = rawPayload, + error = null + ) + } + } else { + _state.update { + it.copy( + isLoading = false, + error = "QR request failed: ${response.code()}" + ) + } + } + } catch (e: Exception) { + _state.update { + it.copy( + isLoading = false, + error = e.localizedMessage ?: "QR request failed" + ) + } + } + } + } + + fun generateLink(propertyId: String, bookingId: String, onReady: (String) -> Unit) { + val current = state.value + val amount = current.amountInput.toLongOrNull()?.takeIf { it > 0 } + if (amount == null) { + _state.update { it.copy(error = "Amount is required") } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val response = api.generatePayuLink( + propertyId = propertyId, + bookingId = bookingId, + body = PayuLinkRequest(amount = amount) + ) + val body = response.body() + if (response.isSuccessful && body?.paymentLink != null) { + _state.update { + it.copy( + isLoading = false, + paymentLink = body.paymentLink, + amount = body.amount, + currency = body.currency, + error = null + ) + } + onReady(body.paymentLink) + } else { + _state.update { + it.copy( + isLoading = false, + error = "Link request failed: ${response.code()}" + ) + } + } + } catch (e: Exception) { + _state.update { + it.copy( + isLoading = false, + error = e.localizedMessage ?: "Link request failed" + ) + } + } + } + } + + private fun extractQrString(payuResponse: JsonElement?): Pair { + if (payuResponse == null) return null to null + val raw = gson.toJson(payuResponse) + val root = when { + payuResponse.isJsonObject -> payuResponse.asJsonObject + payuResponse.isJsonPrimitive && payuResponse.asJsonPrimitive.isString -> { + runCatching { + gson.fromJson(payuResponse.asString, JsonObject::class.java) + }.getOrNull() + } + else -> null + } + val qrString = root?.getAsJsonObject("result")?.get("qrString")?.asString + return qrString to raw + } + + private fun buildDeviceInfo(): String { + val release = android.os.Build.VERSION.RELEASE + val model = android.os.Build.MODEL + return "Android $release; $model; PMS" + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsScreen.kt new file mode 100644 index 0000000..48f2857 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsScreen.kt @@ -0,0 +1,437 @@ +package com.android.trisolarispms.ui.payu + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import kotlinx.coroutines.launch + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun PayuSettingsScreen( + propertyId: String, + onBack: () -> Unit, + viewModel: PayuSettingsViewModel = viewModel(), + linkViewModel: PayuPaymentLinkSettingsViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val linkState by linkViewModel.state.collectAsState() + val clipboard = LocalClipboardManager.current + val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 }) + val scope = rememberCoroutineScope() + + LaunchedEffect(propertyId) { + viewModel.load(propertyId) + linkViewModel.load(propertyId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Payu Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + TabRow(selectedTabIndex = pagerState.currentPage) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text("QR") } + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text("Payment Links") } + ) + } + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + when (page) { + 0 -> PayuQrSettingsTab( + propertyId = propertyId, + state = state, + onMerchantKeyChange = viewModel::onMerchantKeyChange, + onIsTestChange = viewModel::onIsTestChange, + onUseSalt256Change = viewModel::onUseSalt256Change, + onSalt32Change = viewModel::onSalt32Change, + onSalt256Change = viewModel::onSalt256Change, + onSave = { viewModel.save(propertyId) }, + clipboard = clipboard + ) + 1 -> PayuPaymentLinksTab( + propertyId = propertyId, + state = linkState, + onMerchantIdChange = linkViewModel::onMerchantIdChange, + onClientIdChange = linkViewModel::onClientIdChange, + onClientSecretChange = linkViewModel::onClientSecretChange, + onIsTestChange = linkViewModel::onIsTestChange, + onSave = { linkViewModel.save(propertyId) } + ) + } + } + } + } +} + +@Composable +private fun PayuQrSettingsTab( + propertyId: String, + state: PayuSettingsState, + onMerchantKeyChange: (String) -> Unit, + onIsTestChange: (Boolean) -> Unit, + onUseSalt256Change: (Boolean) -> Unit, + onSalt32Change: (String) -> Unit, + onSalt256Change: (String) -> Unit, + onSave: () -> Unit, + clipboard: ClipboardManager +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Top + ) { + Text( + text = "Payu Settings", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "More payment settings will be added later. For now, configure PayU.", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Success/failure URLs are derived by the server using this property ID.", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(12.dp)) + + if (state.isLoading) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(12.dp)) + } + + Text( + text = if (state.configured) "Status: Configured" else "Status: Not configured", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(8.dp)) + + state.message?.let { + Text(text = it, color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(8.dp)) + } + + state.error?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + } + + OutlinedTextField( + value = state.merchantKey, + onValueChange = onMerchantKeyChange, + label = { Text("Merchant key") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = "Use PayU test URL") + Switch( + checked = state.isTest, + onCheckedChange = onIsTestChange + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = if (state.isTest) { + "Base URL: https://test.payu.in/_payment" + } else { + "Base URL: https://secure.payu.in/_payment" + }, + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = "Use salt256") + Switch( + checked = state.useSalt256, + onCheckedChange = onUseSalt256Change + ) + } + Spacer(modifier = Modifier.height(8.dp)) + + if (state.useSalt256) { + OutlinedTextField( + value = state.salt256, + onValueChange = onSalt256Change, + label = { Text("Salt256") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth() + ) + if (state.hasSalt256) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Salt256 saved", + style = MaterialTheme.typography.bodySmall + ) + } + } else { + OutlinedTextField( + value = state.salt32, + onValueChange = onSalt32Change, + label = { Text("Salt32") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth() + ) + if (state.hasSalt32) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Salt32 saved", + style = MaterialTheme.typography.bodySmall + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onSave, + enabled = !state.isSaving, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isSaving) { + CircularProgressIndicator( + modifier = Modifier + .height(16.dp) + .padding(end = 8.dp), + strokeWidth = 2.dp + ) + Text("Saving...") + } else { + Text("Save") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = "https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/success", + onValueChange = {}, + label = { Text("Success URL") }, + readOnly = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = "https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/failure", + onValueChange = {}, + label = { Text("Failure URL") }, + readOnly = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { + clipboard.setText( + AnnotatedString( + "https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/success" + ) + ) + } + ) { + Text("Copy success URL") + } + Button( + onClick = { + clipboard.setText( + AnnotatedString( + "https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/failure" + ) + ) + } + ) { + Text("Copy failure URL") + } + } + Spacer(modifier = Modifier.height(12.dp)) + } +} + +@Composable +private fun PayuPaymentLinksTab( + propertyId: String, + state: PayuPaymentLinkSettingsState, + onMerchantIdChange: (String) -> Unit, + onClientIdChange: (String) -> Unit, + onClientSecretChange: (String) -> Unit, + onIsTestChange: (Boolean) -> Unit, + onSave: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Top + ) { + if (state.isLoading) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(12.dp)) + } + Text( + text = if (state.configured) "Status: Configured" else "Status: Not configured", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(8.dp)) + if (state.hasClientId) { + Text(text = "Client ID saved", style = MaterialTheme.typography.bodySmall) + } + if (state.hasClientSecret) { + Text(text = "Client Secret saved", style = MaterialTheme.typography.bodySmall) + } + if (state.hasAccessToken) { + Text(text = "Access token available", style = MaterialTheme.typography.bodySmall) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Payment Link Settings", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(12.dp)) + + state.message?.let { + Text(text = it, color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(8.dp)) + } + state.error?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + } + + OutlinedTextField( + value = state.merchantId, + onValueChange = onMerchantIdChange, + label = { Text("Merchant ID") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = state.clientId, + onValueChange = onClientIdChange, + label = { Text("Client ID") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = state.clientSecret, + onValueChange = onClientSecretChange, + label = { Text("Client Secret") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = "Use PayU test environment") + Switch( + checked = state.isTest, + onCheckedChange = onIsTestChange + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onSave, + enabled = !state.isSaving, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isSaving) { + CircularProgressIndicator( + modifier = Modifier + .height(16.dp) + .padding(end = 8.dp), + strokeWidth = 2.dp + ) + Text("Saving...") + } else { + Text("Save") + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsState.kt b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsState.kt new file mode 100644 index 0000000..05e7090 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsState.kt @@ -0,0 +1,16 @@ +package com.android.trisolarispms.ui.payu + +data class PayuSettingsState( + val merchantKey: String = "", + val salt32: String = "", + val salt256: String = "", + val isTest: Boolean = false, + val useSalt256: Boolean = false, + val hasSalt32: Boolean = false, + val hasSalt256: Boolean = false, + val configured: Boolean = false, + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val error: String? = null, + val message: String? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsViewModel.kt new file mode 100644 index 0000000..3595634 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsViewModel.kt @@ -0,0 +1,145 @@ +package com.android.trisolarispms.ui.payu + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import com.android.trisolarispms.data.api.model.PayuSettingsRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class PayuSettingsViewModel : ViewModel() { + private val _state = MutableStateFlow(PayuSettingsState()) + val state: StateFlow = _state + + fun load(propertyId: String) { + viewModelScope.launch { + loadInternal(propertyId = propertyId, showLoading = true, message = null) + } + } + + fun onMerchantKeyChange(value: String) { + _state.update { it.copy(merchantKey = value, error = null) } + } + + fun onSalt32Change(value: String) { + _state.update { it.copy(salt32 = value, error = null) } + } + + fun onSalt256Change(value: String) { + _state.update { it.copy(salt256 = value, error = null) } + } + + fun onIsTestChange(value: Boolean) { + _state.update { it.copy(isTest = value, error = null) } + } + + fun onUseSalt256Change(value: Boolean) { + _state.update { it.copy(useSalt256 = value, error = null) } + } + + fun save(propertyId: String) { + val current = state.value + val merchantKey = current.merchantKey.trim() + val salt32 = current.salt32.trim() + val salt256 = current.salt256.trim() + + if (merchantKey.isBlank()) { + _state.update { it.copy(error = "Merchant key is required") } + return + } + if (current.useSalt256 && salt256.isBlank() && !current.hasSalt256) { + _state.update { it.copy(error = "Salt256 is required") } + return + } + if (!current.useSalt256 && salt32.isBlank() && !current.hasSalt32) { + _state.update { it.copy(error = "Salt32 is required") } + return + } + + viewModelScope.launch { + _state.update { it.copy(isSaving = true, error = null, message = null) } + try { + val api = ApiClient.create() + val response = api.updatePayuSettings( + propertyId = propertyId, + body = PayuSettingsRequest( + merchantKey = merchantKey, + salt32 = salt32.ifBlank { null }, + salt256 = salt256.ifBlank { null }, + isTest = current.isTest, + useSalt256 = current.useSalt256 + ) + ) + if (response.isSuccessful) { + loadInternal(propertyId = propertyId, showLoading = false, message = "Saved") + } else { + _state.update { + it.copy( + isSaving = false, + error = "Save failed: ${response.code()}", + message = null + ) + } + } + } catch (e: Exception) { + _state.update { + it.copy( + isSaving = false, + error = e.localizedMessage ?: "Save failed", + message = null + ) + } + } + } + } + + private suspend fun loadInternal(propertyId: String, showLoading: Boolean, message: String?) { + if (showLoading) { + _state.update { it.copy(isLoading = true, error = null, message = message) } + } else { + _state.update { it.copy(error = null, message = message) } + } + try { + val api = ApiClient.create() + val response = api.getPayuSettings(propertyId) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + merchantKey = body.merchantKey.orEmpty(), + isTest = body.isTest == true, + useSalt256 = body.useSalt256 == true, + hasSalt32 = body.hasSalt32 == true, + hasSalt256 = body.hasSalt256 == true, + configured = body.configured == true, + salt32 = "", + salt256 = "", + isLoading = false, + isSaving = false, + error = null + ) + } + } else { + _state.update { + it.copy( + isLoading = false, + isSaving = false, + error = "Load failed: ${response.code()}", + message = null + ) + } + } + } catch (e: Exception) { + _state.update { + it.copy( + isLoading = false, + isSaving = false, + error = e.localizedMessage ?: "Load failed", + message = null + ) + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt index 34e18e4..de6f8ee 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.MeetingRoom +import androidx.compose.material.icons.filled.Payment import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -51,6 +52,8 @@ fun ActiveRoomStaysScreen( onBack: () -> Unit, onViewRooms: () -> Unit, onCreateBooking: () -> Unit, + showPayuSettings: Boolean, + onPayuSettings: () -> Unit, onManageRoomStay: (BookingListItem) -> Unit, onViewBookingStays: (BookingListItem) -> Unit, onOpenBookingDetails: (BookingListItem) -> Unit, @@ -76,6 +79,11 @@ fun ActiveRoomStaysScreen( IconButton(onClick = onViewRooms) { Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms") } + if (showPayuSettings) { + IconButton(onClick = onPayuSettings) { + Icon(Icons.Default.Payment, contentDescription = "Payu Settings") + } + } }, colors = TopAppBarDefaults.topAppBarColors() ) 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 9a40543..3be1aed 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 @@ -5,11 +5,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -17,6 +20,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.ReceiptLong import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -67,6 +72,8 @@ fun BookingDetailsTabsScreen( onBack: () -> Unit, onEditCheckout: (String?, String?) -> Unit, onEditSignature: (String) -> Unit, + onOpenPayuQr: (Long?, String?) -> Unit, + onOpenPayments: () -> Unit, staysViewModel: BookingRoomStaysViewModel = viewModel(), detailsViewModel: BookingDetailsViewModel = viewModel() ) { @@ -126,7 +133,9 @@ fun BookingDetailsTabsScreen( isLoading = detailsState.isLoading, error = detailsState.error, onEditCheckout = onEditCheckout, - onEditSignature = onEditSignature + onEditSignature = onEditSignature, + onOpenPayuQr = onOpenPayuQr, + onOpenPayments = onOpenPayments ) 1 -> BookingRoomStaysTabContent(staysState, staysViewModel) } @@ -143,7 +152,9 @@ private fun GuestInfoTabContent( isLoading: Boolean, error: String?, onEditCheckout: (String?, String?) -> Unit, - onEditSignature: (String) -> Unit + onEditSignature: (String) -> Unit, + onOpenPayuQr: (Long?, String?) -> Unit, + onOpenPayments: () -> Unit ) { val displayZone = remember { ZoneId.of("Asia/Kolkata") } val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") } @@ -219,7 +230,33 @@ private fun GuestInfoTabContent( } } - SectionCard(title = "Calculations") { + val hasGuestName = details?.guestName?.isNotBlank() == true + val hasGuestPhone = details?.guestPhone?.isNotBlank() == true + val hasPending = (details?.pending ?: 0L) > 1L + SectionCard( + title = "Calculations", + headerContent = { + if (hasGuestName && hasGuestPhone && hasPending) { + IconButton(onClick = { onOpenPayuQr(details.pending, details.guestPhone) }) { + Icon( + imageVector = Icons.Default.QrCode, + contentDescription = "PayU QR", + tint = MaterialTheme.colorScheme.primary + ) + } + } + IconButton( + onClick = onOpenPayments, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Default.ReceiptLong, + contentDescription = "Payments", + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) { GuestDetailRow(label = "Amount Collected", value = details?.amountCollected?.toString()) GuestDetailRow(label = "Rent per day", value = details?.totalNightlyRate?.toString()) GuestDetailRow(label = "Expected pay", value = details?.expectedPay?.toString()) @@ -263,13 +300,24 @@ private fun GuestDetailRow(label: String, value: String?) { } @Composable -private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) { +private fun SectionCard( + title: String, + headerContent: (@Composable RowScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit +) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.padding(16.dp)) { - Text(text = title, style = MaterialTheme.typography.titleMedium) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.weight(1f)) + headerContent?.invoke(this) + } Spacer(modifier = Modifier.height(8.dp)) content() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 38a5cdf..e502873 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ coilSvg = "2.7.0" lottieCompose = "6.7.1" calendarCompose = "2.6.0" libphonenumber = "8.13.34" +zxingCore = "3.5.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -54,6 +55,7 @@ coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coilSvg" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" } calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" } libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" } +zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }