diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index 9b0a583..00afef0 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -36,8 +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.razorpay.RazorpaySettingsScreen +import com.android.trisolarispms.ui.razorpay.RazorpayQrScreen import com.android.trisolarispms.ui.roomtype.AddAmenityScreen import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen import com.android.trisolarispms.ui.roomtype.AmenitiesScreen @@ -86,7 +86,7 @@ class MainActivity : ComponentActivity() { it == "ADMIN" || it == "MANAGER" || it == "STAFF" } == true } - val canManagePayuSettings: (String) -> Boolean = { propertyId -> + val canManageRazorpaySettings: (String) -> Boolean = { propertyId -> state.isSuperAdmin || state.propertyRoles[propertyId]?.any { it == "ADMIN" || it == "MANAGER" } == true @@ -125,11 +125,11 @@ class MainActivity : ComponentActivity() { currentRoute.propertyId, currentRoute.roomTypeId ) - is AppRoute.PayuSettings -> route.value = AppRoute.ActiveRoomStays( + is AppRoute.RazorpaySettings -> route.value = AppRoute.ActiveRoomStays( currentRoute.propertyId, selectedPropertyName.value ?: "Property" ) - is AppRoute.PayuQr -> route.value = AppRoute.BookingDetailsTabs( + is AppRoute.RazorpayQr -> route.value = AppRoute.BookingDetailsTabs( currentRoute.propertyId, currentRoute.bookingId, null @@ -278,8 +278,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) }, + showRazorpaySettings = canManageRazorpaySettings(currentRoute.propertyId), + onRazorpaySettings = { route.value = AppRoute.RazorpaySettings(currentRoute.propertyId) }, onManageRoomStay = { booking -> val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() } ?: booking.expectedCheckInAt.orEmpty() @@ -308,7 +308,7 @@ class MainActivity : ComponentActivity() { ) } ) - is AppRoute.PayuSettings -> PayuSettingsScreen( + is AppRoute.RazorpaySettings -> RazorpaySettingsScreen( propertyId = currentRoute.propertyId, onBack = { route.value = AppRoute.ActiveRoomStays( @@ -317,7 +317,7 @@ class MainActivity : ComponentActivity() { ) } ) - is AppRoute.PayuQr -> PayuQrScreen( + is AppRoute.RazorpayQr -> RazorpayQrScreen( propertyId = currentRoute.propertyId, bookingId = currentRoute.bookingId, pendingAmount = currentRoute.pendingAmount, @@ -470,8 +470,8 @@ class MainActivity : ComponentActivity() { guestId ) }, - onOpenPayuQr = { pendingAmount, guestPhone -> - route.value = AppRoute.PayuQr( + onOpenRazorpayQr = { pendingAmount, guestPhone -> + route.value = AppRoute.RazorpayQr( propertyId = currentRoute.propertyId, bookingId = currentRoute.bookingId, pendingAmount = pendingAmount, @@ -484,12 +484,12 @@ class MainActivity : ComponentActivity() { bookingId = currentRoute.bookingId ) }, - canManageDocuments = canManagePayuSettings(currentRoute.propertyId) + canManageDocuments = canManageRazorpaySettings(currentRoute.propertyId) ) is AppRoute.BookingPayments -> BookingPaymentsScreen( propertyId = currentRoute.propertyId, bookingId = currentRoute.bookingId, - canAddCash = canManagePayuSettings(currentRoute.propertyId), + canAddCash = canManageRazorpaySettings(currentRoute.propertyId), canDeleteCash = canDeleteCashPayment(currentRoute.propertyId), onBack = { route.value = AppRoute.BookingDetailsTabs( 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 100abf3..b1be5b4 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 @@ -16,5 +16,4 @@ interface ApiService : InboundEmailApi, AmenityApi, RatePlanApi, - PayuSettingsApi, - PayuPaymentLinkSettingsApi + RazorpaySettingsApi 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 1db3db2..f857f0c 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,10 +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.RazorpayQrEventDto +import com.android.trisolarispms.data.api.model.RazorpayQrListItemDto +import com.android.trisolarispms.data.api.model.RazorpayQrRequest +import com.android.trisolarispms.data.api.model.RazorpayQrResponse +import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkRequest +import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkResponse import com.android.trisolarispms.data.api.model.PaymentDto import com.android.trisolarispms.data.api.model.PaymentCreateRequest import retrofit2.Response @@ -103,19 +105,19 @@ interface BookingApi { @Body body: BookingRoomStayCreateRequest ): Response - @POST("properties/{propertyId}/bookings/{bookingId}/payments/payu/qr") - suspend fun generatePayuQr( + @POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr") + suspend fun generateRazorpayQr( @Path("propertyId") propertyId: String, @Path("bookingId") bookingId: String, - @Body body: PayuQrRequest - ): Response + @Body body: RazorpayQrRequest + ): Response - @POST("properties/{propertyId}/bookings/{bookingId}/payments/payu/link") - suspend fun generatePayuLink( + @POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/payment-link") + suspend fun generateRazorpayPaymentLink( @Path("propertyId") propertyId: String, @Path("bookingId") bookingId: String, - @Body body: PayuLinkRequest - ): Response + @Body body: RazorpayPaymentLinkRequest + ): Response @GET("properties/{propertyId}/bookings/{bookingId}/payments") suspend fun listPayments( @@ -136,4 +138,24 @@ interface BookingApi { @Path("bookingId") bookingId: String, @Path("paymentId") paymentId: String ): Response + + @GET("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr") + suspend fun listRazorpayQrs( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String + ): Response> + + @POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr/{qrId}/close") + suspend fun closeRazorpayQr( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Path("qrId") qrId: String + ): Response + + @GET("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr/{qrId}/events") + suspend fun listRazorpayQrEvents( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Path("qrId") qrId: String + ): 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 deleted file mode 100644 index 826ba46..0000000 --- a/app/src/main/java/com/android/trisolarispms/data/api/PayuPaymentLinkSettingsApi.kt +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 4d171b4..0000000 --- a/app/src/main/java/com/android/trisolarispms/data/api/PayuSettingsApi.kt +++ /dev/null @@ -1,22 +0,0 @@ -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/RazorpaySettingsApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/RazorpaySettingsApi.kt new file mode 100644 index 0000000..dd394d8 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/RazorpaySettingsApi.kt @@ -0,0 +1,22 @@ +package com.android.trisolarispms.data.api + +import com.android.trisolarispms.data.api.model.RazorpaySettingsRequest +import com.android.trisolarispms.data.api.model.RazorpaySettingsResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PUT +import retrofit2.http.Path + +interface RazorpaySettingsApi { + @GET("properties/{propertyId}/razorpay-settings") + suspend fun getRazorpaySettings( + @Path("propertyId") propertyId: String + ): Response + + @PUT("properties/{propertyId}/razorpay-settings") + suspend fun updateRazorpaySettings( + @Path("propertyId") propertyId: String, + @Body body: RazorpaySettingsRequest + ): Response +} 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 deleted file mode 100644 index 88269ea..0000000 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/PayuPaymentLinkSettingsModels.kt +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 2e5d553..0000000 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/PayuQrModels.kt +++ /dev/null @@ -1,26 +0,0 @@ -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/RazorpayQrModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RazorpayQrModels.kt new file mode 100644 index 0000000..25a1b89 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RazorpayQrModels.kt @@ -0,0 +1,42 @@ +package com.android.trisolarispms.data.api.model + +import com.google.gson.JsonElement + +data class RazorpayQrRequest( + val amount: Long, + val deviceInfo: String +) + +data class RazorpayQrResponse( + val qrId: String? = null, + val amount: Long? = null, + val currency: String? = null, + val imageUrl: String? = null +) + +data class RazorpayPaymentLinkRequest( + val amount: Long +) + +data class RazorpayPaymentLinkResponse( + val amount: Long? = null, + val currency: String? = null, + val paymentLink: String? = null +) + +data class RazorpayQrEventDto( + val event: String? = null, + val qrId: String? = null, + val status: String? = null, + val receivedAt: String? = null +) + +data class RazorpayQrListItemDto( + val qrId: String? = null, + val amount: Long? = null, + val currency: String? = null, + val status: String? = null, + val imageUrl: String? = null, + val expiryAt: String? = null, + val createdAt: String? = 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/RazorpaySettingsModels.kt similarity index 57% rename from app/src/main/java/com/android/trisolarispms/data/api/model/PayuSettingsModels.kt rename to app/src/main/java/com/android/trisolarispms/data/api/model/RazorpaySettingsModels.kt index accf7e1..ef59145 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/PayuSettingsModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RazorpaySettingsModels.kt @@ -2,20 +2,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, +data class RazorpaySettingsRequest( + val keyId: String, + val keySecret: String? = null, + val webhookSecret: String? = null, @SerializedName("isTest") val isTest: Boolean, val useSalt256: Boolean ) -data class PayuSettingsResponse( +data class RazorpaySettingsResponse( 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 hasKeyId: Boolean? = null, + val hasKeySecret: Boolean? = null, + val hasWebhookSecret: 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 d0aa94c..3dcd03f 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -67,8 +67,8 @@ sealed interface AppRoute { val ratePlanId: String, val ratePlanCode: String ) : AppRoute - data class PayuSettings(val propertyId: String) : AppRoute - data class PayuQr( + data class RazorpaySettings(val propertyId: String) : AppRoute + data class RazorpayQr( val propertyId: String, val bookingId: String, val pendingAmount: Long?, 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 deleted file mode 100644 index 0f0686d..0000000 --- a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuPaymentLinkSettingsState.kt +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index e33035d..0000000 --- a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuPaymentLinkSettingsViewModel.kt +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index 4f6ac73..0000000 --- a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrScreen.kt +++ /dev/null @@ -1,194 +0,0 @@ -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.runtime.DisposableEffect -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 - - DisposableEffect(Unit) { - onDispose { viewModel.reset() } - } - - 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 deleted file mode 100644 index 97d9777..0000000 --- a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrState.kt +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index c4dde9a..0000000 --- a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuQrViewModel.kt +++ /dev/null @@ -1,163 +0,0 @@ -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 reset() { - _state.value = PayuQrState(deviceInfo = buildDeviceInfo()) - } - - 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 deleted file mode 100644 index 48f2857..0000000 --- a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsScreen.kt +++ /dev/null @@ -1,437 +0,0 @@ -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/razorpay/RazorpayQrScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrScreen.kt new file mode 100644 index 0000000..e5c22c2 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrScreen.kt @@ -0,0 +1,244 @@ +package com.android.trisolarispms.ui.razorpay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +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.DisposableEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil.compose.SubcomposeAsyncImage +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.activity.compose.BackHandler +import com.android.trisolarispms.BuildConfig + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun RazorpayQrScreen( + propertyId: String, + bookingId: String, + pendingAmount: Long?, + guestPhone: String?, + onBack: () -> Unit, + viewModel: RazorpayQrViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + + DisposableEffect(Unit) { + onDispose { viewModel.reset() } + } + + LaunchedEffect(pendingAmount) { + viewModel.setInitialAmount(pendingAmount) + } + LaunchedEffect(Unit) { + if (BuildConfig.DEBUG && state.amountInput.isBlank()) { + viewModel.onAmountChange("10") + } + } + LaunchedEffect(propertyId, bookingId) { + viewModel.loadQrList(propertyId, bookingId) + } + + val isViewingQr = state.isClosed || !state.imageUrl.isNullOrBlank() + BackHandler(enabled = isViewingQr) { + viewModel.exitQrView() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Razorpay QR") }, + navigationIcon = { + IconButton(onClick = { + if (isViewingQr) { + viewModel.exitQrView() + } else { + onBack() + } + }) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + if (state.isClosed) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text( + text = "QR closed", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + } + } else if (!state.imageUrl.isNullOrBlank()) { + LaunchedEffect(state.qrId) { + viewModel.startStreamForCurrentQr(propertyId, bookingId) + } + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + SubcomposeAsyncImage( + model = state.imageUrl, + contentDescription = "Razorpay QR", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + loading = { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 3.dp + ) + } + }, + error = { + Text( + text = "Failed to load QR image", + color = MaterialTheme.colorScheme.error + ) + } + ) + } + } else { + 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)) + Button( + onClick = { viewModel.generate(propertyId, bookingId) }, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isLoading) { + Box( + modifier = Modifier + .size(18.dp) + .padding(end = 8.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(strokeWidth = 2.dp) + } + Spacer(modifier = Modifier.width(8.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.qrList.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "QR List", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + state.qrList.forEachIndexed { index, item -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { viewModel.openQrFromList(item) }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "QR ${index + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${item.amount ?: "-"} ${item.currency.orEmpty()}", + style = MaterialTheme.typography.titleMedium + ) + val qrId = item.qrId + if (!qrId.isNullOrBlank()) { + IconButton( + onClick = { + viewModel.closeQr(propertyId, bookingId, qrId) + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Close QR", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + Spacer(modifier = Modifier.height(10.dp)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrState.kt b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrState.kt new file mode 100644 index 0000000..09abc7f --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrState.kt @@ -0,0 +1,15 @@ +package com.android.trisolarispms.ui.razorpay + +data class RazorpayQrState( + val deviceInfo: String = "", + val amountInput: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val qrId: String? = null, + val amount: Long? = null, + val currency: String? = null, + val imageUrl: String? = null, + val isClosed: Boolean = false, + val paymentLink: String? = null, + val qrList: List = emptyList() +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrViewModel.kt new file mode 100644 index 0000000..3e46219 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrViewModel.kt @@ -0,0 +1,327 @@ +package com.android.trisolarispms.ui.razorpay + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import com.android.trisolarispms.data.api.ApiConstants +import com.android.trisolarispms.data.api.model.RazorpayQrEventDto +import com.android.trisolarispms.data.api.model.RazorpayQrListItemDto +import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkRequest +import com.android.trisolarispms.data.api.model.RazorpayQrRequest +import com.google.gson.Gson +import okhttp3.Request +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import okhttp3.sse.EventSources +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay + +class RazorpayQrViewModel : ViewModel() { + private val gson = Gson() + private val _state = MutableStateFlow( + RazorpayQrState(deviceInfo = buildDeviceInfo()) + ) + val state: StateFlow = _state + private var qrEventSource: EventSource? = null + private var lastQrId: String? = null + private var qrPollJob: Job? = null + + fun reset() { + stopQrEventStream() + stopQrEventPolling() + _state.value = RazorpayQrState(deviceInfo = buildDeviceInfo()) + } + + fun exitQrView() { + stopQrEventPolling() + _state.update { + it.copy( + qrId = null, + imageUrl = null, + isClosed = false, + error = null + ) + } + } + + 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.generateRazorpayQr( + propertyId = propertyId, + bookingId = bookingId, + body = RazorpayQrRequest( + amount = amount, + deviceInfo = current.deviceInfo + ) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + isLoading = false, + qrId = body.qrId, + amount = body.amount, + currency = body.currency, + imageUrl = body.imageUrl, + isClosed = false, + error = null + ) + } + loadQrList(propertyId, bookingId) + startQrEventStream(propertyId, bookingId, body.qrId) + } 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" + ) + } + } + } + } + + private fun startQrEventStream(propertyId: String, bookingId: String, qrId: String?) { + if (qrId.isNullOrBlank()) return + if (lastQrId == qrId && qrEventSource != null) return + stopQrEventStream() + lastQrId = qrId + val client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0) + val url = "${ApiConstants.BASE_URL}properties/$propertyId/bookings/$bookingId/payments/razorpay/qr/$qrId/events/stream" + val request = Request.Builder().url(url).get().build() + qrEventSource = EventSources.createFactory(client).newEventSource( + request, + object : EventSourceListener() { + override fun onEvent( + eventSource: EventSource, + id: String?, + type: String?, + data: String + ) { + if (data.isBlank() || type == "ping") return + val event = runCatching { + gson.fromJson(data, RazorpayQrEventDto::class.java) + }.getOrNull() ?: return + if (isClosedStatus(event.status)) { + _state.update { it.copy(isClosed = true, imageUrl = null) } + stopQrEventStream() + stopQrEventPolling() + } + } + + override fun onFailure( + eventSource: EventSource, + t: Throwable?, + response: okhttp3.Response? + ) { + stopQrEventStream() + } + + override fun onClosed(eventSource: EventSource) { + stopQrEventStream() + } + } + ) + startQrEventPolling(propertyId, bookingId, qrId) + } + + private fun stopQrEventStream() { + qrEventSource?.cancel() + qrEventSource = null + lastQrId = null + } + + private fun startQrEventPolling(propertyId: String, bookingId: String, qrId: String) { + if (qrPollJob?.isActive == true) return + qrPollJob = viewModelScope.launch { + while (true) { + val currentQrId = state.value.qrId + if (currentQrId.isNullOrBlank() || currentQrId != qrId || state.value.isClosed) { + break + } + try { + val response = ApiClient.create().listRazorpayQrEvents( + propertyId = propertyId, + bookingId = bookingId, + qrId = qrId + ) + val body = response.body() + if (response.isSuccessful && body != null) { + if (body.any { isClosedStatus(it.status) }) { + _state.update { it.copy(isClosed = true, imageUrl = null) } + stopQrEventStream() + break + } + } + } catch (_: Exception) { + // ignore polling errors + } + delay(5000) + } + } + } + + private fun stopQrEventPolling() { + qrPollJob?.cancel() + qrPollJob = null + } + + private fun isClosedStatus(status: String?): Boolean { + return when (status?.lowercase()) { + "credited", "closed", "expired" -> true + else -> false + } + } + + fun loadQrList(propertyId: String, bookingId: String) { + viewModelScope.launch { + try { + val response = ApiClient.create().listRazorpayQrs(propertyId, bookingId) + val body = response.body() + if (response.isSuccessful && body != null) { + val filtered = body.filterNot { item -> + when (item.status?.lowercase()) { + "closed", "expired", "credited" -> true + else -> false + } + } + _state.update { it.copy(qrList = filtered) } + } + } catch (_: Exception) { + // ignore list load errors + } + } + } + + fun closeQr(propertyId: String, bookingId: String, qrId: String) { + viewModelScope.launch { + try { + val response = ApiClient.create().closeRazorpayQr( + propertyId = propertyId, + bookingId = bookingId, + qrId = qrId + ) + if (response.isSuccessful) { + _state.update { current -> + current.copy(qrList = current.qrList.filterNot { it.qrId == qrId }) + } + loadQrList(propertyId, bookingId) + } + } catch (_: Exception) { + // ignore close errors + } + } + } + + fun openQrFromList(item: RazorpayQrListItemDto) { + val status = item.status?.lowercase() + _state.update { + it.copy( + qrId = item.qrId, + amount = item.amount, + currency = item.currency, + imageUrl = item.imageUrl, + isClosed = status == "closed" || status == "expired" || status == "credited", + error = null + ) + } + } + + fun startStreamForCurrentQr(propertyId: String, bookingId: String) { + startQrEventStream(propertyId, bookingId, state.value.qrId) + } + + 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.generateRazorpayPaymentLink( + propertyId = propertyId, + bookingId = bookingId, + body = RazorpayPaymentLinkRequest(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 buildDeviceInfo(): String { + val release = android.os.Build.VERSION.RELEASE + val model = android.os.Build.MODEL + return "Android $release; $model; PMS" + } + + override fun onCleared() { + super.onCleared() + stopQrEventStream() + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpaySettingsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpaySettingsScreen.kt new file mode 100644 index 0000000..b736ed4 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpaySettingsScreen.kt @@ -0,0 +1,274 @@ +package com.android.trisolarispms.ui.razorpay + +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.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.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.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun RazorpaySettingsScreen( + propertyId: String, + onBack: () -> Unit, + viewModel: RazorpaySettingsViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val clipboard = LocalClipboardManager.current + + LaunchedEffect(propertyId) { + viewModel.load(propertyId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Razorpay Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + RazorpaySettingsTab( + propertyId = propertyId, + state = state, + onKeyIdChange = viewModel::onMerchantKeyChange, + onKeySecretChange = viewModel::onSalt32Change, + onWebhookSecretChange = viewModel::onWebhookSecretChange, + onIsTestChange = viewModel::onIsTestChange, + onUseSalt256Change = viewModel::onUseSalt256Change, + onSalt256Change = viewModel::onSalt256Change, + onSave = { viewModel.save(propertyId) }, + clipboard = clipboard + ) + } + } +} + +@Composable +private fun RazorpaySettingsTab( + propertyId: String, + state: RazorpaySettingsState, + onKeyIdChange: (String) -> Unit, + onKeySecretChange: (String) -> Unit, + onWebhookSecretChange: (String) -> Unit, + onIsTestChange: (Boolean) -> Unit, + onUseSalt256Change: (Boolean) -> Unit, + onSalt256Change: (String) -> Unit, + onSave: () -> Unit, + clipboard: ClipboardManager +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Top + ) { + Text( + text = "Razorpay Settings", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "More payment settings will be added later. For now, configure Razorpay.", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Webhook URL is 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)) + if (state.hasKeyId) { + Text(text = "Key ID saved", style = MaterialTheme.typography.bodySmall) + } + if (state.hasKeySecret) { + Text(text = "Key Secret saved", style = MaterialTheme.typography.bodySmall) + } + if (state.hasWebhookSecret) { + Text(text = "Webhook Secret saved", style = MaterialTheme.typography.bodySmall) + } + 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.keyId, + onValueChange = onKeyIdChange, + label = { Text("Key ID") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = state.webhookSecret, + onValueChange = onWebhookSecretChange, + label = { Text("Webhook 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 test environment") + Switch( + checked = state.isTest, + onCheckedChange = onIsTestChange + ) + } + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = "Use secondary secret") + Switch( + checked = state.useSalt256, + onCheckedChange = onUseSalt256Change + ) + } + Spacer(modifier = Modifier.height(8.dp)) + + if (state.useSalt256) { + OutlinedTextField( + value = state.salt256, + onValueChange = onSalt256Change, + label = { Text("Secret (secondary)") }, + 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.keySecret, + onValueChange = onKeySecretChange, + label = { Text("Key Secret") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth() + ) + if (state.hasKeySecret) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Key Secret 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/razorpay/webhook", + onValueChange = {}, + label = { Text("Webhook URL") }, + readOnly = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = { + clipboard.setText( + AnnotatedString( + "https://api.hoteltrisolaris.in/properties/$propertyId/razorpay/webhook" + ) + ) + } + ) { + Text("Copy webhook URL") + } + Spacer(modifier = Modifier.height(12.dp)) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsState.kt b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpaySettingsState.kt similarity index 51% rename from app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsState.kt rename to app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpaySettingsState.kt index 05e7090..52b5f74 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpaySettingsState.kt @@ -1,12 +1,15 @@ -package com.android.trisolarispms.ui.payu +package com.android.trisolarispms.ui.razorpay -data class PayuSettingsState( - val merchantKey: String = "", - val salt32: String = "", +data class RazorpaySettingsState( + val keyId: String = "", + val keySecret: String = "", + val webhookSecret: String = "", val salt256: String = "", val isTest: Boolean = false, val useSalt256: Boolean = false, - val hasSalt32: Boolean = false, + val hasKeyId: Boolean = false, + val hasKeySecret: Boolean = false, + val hasWebhookSecret: Boolean = false, val hasSalt256: Boolean = false, val configured: Boolean = false, val isLoading: Boolean = false, diff --git a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpaySettingsViewModel.kt similarity index 62% rename from app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsViewModel.kt rename to app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpaySettingsViewModel.kt index 3595634..c1fff4f 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/payu/PayuSettingsViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpaySettingsViewModel.kt @@ -1,17 +1,17 @@ -package com.android.trisolarispms.ui.payu +package com.android.trisolarispms.ui.razorpay import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.ApiClient -import com.android.trisolarispms.data.api.model.PayuSettingsRequest +import com.android.trisolarispms.data.api.model.RazorpaySettingsRequest 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 +class RazorpaySettingsViewModel : ViewModel() { + private val _state = MutableStateFlow(RazorpaySettingsState()) + val state: StateFlow = _state fun load(propertyId: String) { viewModelScope.launch { @@ -20,11 +20,15 @@ class PayuSettingsViewModel : ViewModel() { } fun onMerchantKeyChange(value: String) { - _state.update { it.copy(merchantKey = value, error = null) } + _state.update { it.copy(keyId = value, error = null) } } fun onSalt32Change(value: String) { - _state.update { it.copy(salt32 = value, error = null) } + _state.update { it.copy(keySecret = value, error = null) } + } + + fun onWebhookSecretChange(value: String) { + _state.update { it.copy(webhookSecret = value, error = null) } } fun onSalt256Change(value: String) { @@ -41,33 +45,43 @@ class PayuSettingsViewModel : ViewModel() { fun save(propertyId: String) { val current = state.value - val merchantKey = current.merchantKey.trim() - val salt32 = current.salt32.trim() + val keyId = current.keyId.trim() + val keySecret = current.keySecret.trim() + val webhookSecret = current.webhookSecret.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 + if (!current.configured) { + if (keyId.isBlank()) { + _state.update { it.copy(error = "Key ID is required") } + return + } + if (current.useSalt256 && salt256.isBlank() && !current.hasSalt256) { + _state.update { it.copy(error = "Secondary secret is required") } + return + } + if (!current.useSalt256 && keySecret.isBlank() && !current.hasKeySecret) { + _state.update { it.copy(error = "Key secret is required") } + return + } + } else { + val hasKeyIdInput = keyId.isNotBlank() + val hasKeySecretInput = keySecret.isNotBlank() + if (hasKeyIdInput xor hasKeySecretInput) { + _state.update { it.copy(error = "Key ID and Key Secret must be provided together") } + return + } } viewModelScope.launch { _state.update { it.copy(isSaving = true, error = null, message = null) } try { val api = ApiClient.create() - val response = api.updatePayuSettings( + val response = api.updateRazorpaySettings( propertyId = propertyId, - body = PayuSettingsRequest( - merchantKey = merchantKey, - salt32 = salt32.ifBlank { null }, - salt256 = salt256.ifBlank { null }, + body = RazorpaySettingsRequest( + keyId = keyId, + keySecret = keySecret.ifBlank { null }, + webhookSecret = webhookSecret.ifBlank { null }, isTest = current.isTest, useSalt256 = current.useSalt256 ) @@ -103,18 +117,21 @@ class PayuSettingsViewModel : ViewModel() { } try { val api = ApiClient.create() - val response = api.getPayuSettings(propertyId) + val response = api.getRazorpaySettings(propertyId) val body = response.body() if (response.isSuccessful && body != null) { _state.update { it.copy( - merchantKey = body.merchantKey.orEmpty(), + keyId = "", isTest = body.isTest == true, useSalt256 = body.useSalt256 == true, - hasSalt32 = body.hasSalt32 == true, + hasKeyId = body.hasKeyId == true, + hasKeySecret = body.hasKeySecret == true, + hasWebhookSecret = body.hasWebhookSecret == true, hasSalt256 = body.hasSalt256 == true, configured = body.configured == true, - salt32 = "", + keySecret = "", + webhookSecret = "", salt256 = "", isLoading = false, isSaving = false, 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 a590cb8..cf399bd 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 @@ -52,8 +52,8 @@ fun ActiveRoomStaysScreen( onBack: () -> Unit, onViewRooms: () -> Unit, onCreateBooking: () -> Unit, - showPayuSettings: Boolean, - onPayuSettings: () -> Unit, + showRazorpaySettings: Boolean, + onRazorpaySettings: () -> Unit, onManageRoomStay: (BookingListItem) -> Unit, onViewBookingStays: (BookingListItem) -> Unit, onOpenBookingDetails: (BookingListItem) -> Unit, @@ -79,9 +79,9 @@ fun ActiveRoomStaysScreen( IconButton(onClick = onViewRooms) { Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms") } - if (showPayuSettings) { - IconButton(onClick = onPayuSettings) { - Icon(Icons.Default.Payment, contentDescription = "Payu Settings") + if (showRazorpaySettings) { + IconButton(onClick = onRazorpaySettings) { + Icon(Icons.Default.Payment, contentDescription = "Razorpay Settings") } } }, 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 88aa909..63d4ec3 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 @@ -76,7 +76,7 @@ fun BookingDetailsTabsScreen( onBack: () -> Unit, onEditCheckout: (String?, String?) -> Unit, onEditSignature: (String) -> Unit, - onOpenPayuQr: (Long?, String?) -> Unit, + onOpenRazorpayQr: (Long?, String?) -> Unit, onOpenPayments: () -> Unit, canManageDocuments: Boolean, staysViewModel: BookingRoomStaysViewModel = viewModel(), @@ -153,7 +153,7 @@ fun BookingDetailsTabsScreen( error = detailsState.error, onEditCheckout = onEditCheckout, onEditSignature = onEditSignature, - onOpenPayuQr = onOpenPayuQr, + onOpenRazorpayQr = onOpenRazorpayQr, onOpenPayments = onOpenPayments ) 1 -> BookingRoomStaysTabContent(staysState, staysViewModel) @@ -190,7 +190,7 @@ private fun GuestInfoTabContent( error: String?, onEditCheckout: (String?, String?) -> Unit, onEditSignature: (String) -> Unit, - onOpenPayuQr: (Long?, String?) -> Unit, + onOpenRazorpayQr: (Long?, String?) -> Unit, onOpenPayments: () -> Unit ) { val displayZone = remember { ZoneId.of("Asia/Kolkata") } @@ -280,10 +280,10 @@ private fun GuestInfoTabContent( title = "Calculations", headerContent = { if (hasGuestName && hasGuestPhone && hasPending) { - IconButton(onClick = { onOpenPayuQr(details.pending, details.guestPhone) }) { + IconButton(onClick = { onOpenRazorpayQr(details.pending, details.guestPhone) }) { Icon( imageVector = Icons.Default.QrCode, - contentDescription = "PayU QR", + contentDescription = "Razorpay QR", tint = MaterialTheme.colorScheme.primary ) }