Contratos y Pagos
Gestiona solicitudes de servicios y pagos basados en escrow en la aplicación iMirly Android
Visión General
El sistema de contratos y pagos de iMirly maneja el ciclo de vida completo de las solicitudes de servicio:
- El cliente crea la solicitud (Solicitud)
- El profesional envía propuesta de precio
- El cliente acepta y paga (fondos retenidos en escrow)
- El profesional completa el trabajo
- El cliente aprueba y libera el pago
- El cliente valora al profesional
Servicio API de Solicitudes
Interfaz Retrofit
API Interfacepackage com.imirly.app.network
import retrofit2.Response
import retrofit2.http.*
data class CrearSolicitudRequest(
val anuncioId: String,
val detalles: String
)
data class SolicitudResponse(
val id: String,
val clienteId: String,
val anuncioId: String,
val estado: String,
val detallesSolicitud: String,
val fechaCreacion: String,
val nombreCliente: String? = "Cliente",
val proveedorId: String? = null,
val nombreProveedor: String? = "Profesional",
var soyElProveedor: Boolean = false
)
data class CambiarEstadoRequest(
val nuevoEstado: String
)
interface SolicitudApiService {
@POST("/api/v1/solicitudes")
suspend fun crearSolicitud(
@Body request: CrearSolicitudRequest
): Response
@GET("/api/v1/solicitudes/proveedor")
suspend fun getSolicitudesProveedor(): Response>
@GET("/api/v1/solicitudes/cliente")
suspend fun getSolicitudesCliente(): Response>
@PATCH("/api/v1/solicitudes/{id}/estado")
suspend fun cambiarEstadoSolicitud(
@Path("id") id: String,
@Body request: CambiarEstadoRequest
): Response
@DELETE("/api/v1/solicitudes/{id}")
suspend fun eliminarSolicitud(@Path("id") id: String): Response
@POST("/api/v1/solicitudes/{id}/pagar")
suspend fun pagarSolicitud(@Path("id") id: String): Response
}
Crear Solicitudes de Servicio
Desde Pantalla de Detalle
Cuando un usuario ve un anuncio de servicio, puede contactar al profesional:
package com.imirly.app.ui.catalog
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.imirly.app.network.CrearSolicitudRequest
import com.imirly.app.network.RetrofitClient
import kotlinx.coroutines.launch
class AnuncioDetailViewModel : ViewModel() {
var showContactDialog = mutableStateOf(false)
var mensajeContacto = mutableStateOf("")
var isSubmitting = mutableStateOf(false)
var requestSuccess = mutableStateOf(false)
fun enviarSolicitud(anuncioId: String) {
if (mensajeContacto.value.isBlank()) return
isSubmitting.value = true
viewModelScope.launch {
try {
val request = CrearSolicitudRequest(
anuncioId = anuncioId,
detalles = mensajeContacto.value
)
val response = RetrofitClient.solicitudService.crearSolicitud(request)
if (response.isSuccessful) {
requestSuccess.value = true
} else {
println("Error del servidor: ${response.code()}")
}
} catch (e: Exception) {
println("Fallo crítico en Retrofit: ${e.message}")
e.printStackTrace()
} finally {
isSubmitting.value = false
}
}
}
}
Diálogo de Contacto
Jetpack Compose@Composable
fun ContactDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
mensaje: MutableState,
isSubmitting: Boolean
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Contactar profesional") },
text = {
Column {
Text("Describe tu solicitud:")
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = mensaje.value,
onValueChange = { mensaje.value = it },
placeholder = { Text("Ej: Necesito reparar una tubería...") },
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
maxLines = 5
)
}
},
confirmButton = {
Button(
onClick = onConfirm,
enabled = !isSubmitting && mensaje.value.isNotBlank()
) {
Text(if (isSubmitting) "Enviando..." else "Enviar")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancelar")
}
}
)
}
Estados del Ciclo de Vida
La solicitud pasa por varios estados:
PENDIENTE
Estado inicial cuando el cliente crea la solicitud
ACEPTADO
El profesional ha revisado y enviado propuesta de precio
PAGADO
El cliente aceptó la propuesta y pagó (fondos en escrow)
COMPLETADO
El profesional marcó el trabajo como completado
FINALIZADO
El cliente aprobó el trabajo y liberó el pago
Pantalla de Mensajes (Bandeja de Entrada)
Messages ViewModel
ViewModelpackage com.imirly.app.ui.messages
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.imirly.app.network.RetrofitClient
import com.imirly.app.network.SolicitudResponse
import com.imirly.app.network.TokenManager
import kotlinx.coroutines.launch
class MessagesViewModel : ViewModel() {
var solicitudes = mutableStateOf>(emptyList())
var isLoading = mutableStateOf(true)
fun cargarSolicitudes() {
isLoading.value = true
viewModelScope.launch {
try {
// Cargar solicitudes como cliente y como proveedor
val respCliente = RetrofitClient.solicitudService.getSolicitudesCliente()
val respProveedor = RetrofitClient.solicitudService.getSolicitudesProveedor()
val listCliente = if (respCliente.isSuccessful) {
respCliente.body() ?: emptyList()
} else emptyList()
val listProveedor = if (respProveedor.isSuccessful) {
respProveedor.body()?.map { it.copy(soyElProveedor = true) } ?: emptyList()
} else emptyList()
solicitudes.value = listCliente + listProveedor
} catch (e: Exception) {
// Manejar error
} finally {
isLoading.value = false
}
}
}
}
Messages Screen UI
Jetpack Composepackage com.imirly.app.ui.messages
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.imirly.app.network.SolicitudResponse
@Composable
fun MessagesScreen(navController: NavController, viewModel: MessagesViewModel = viewModel()) {
LaunchedEffect(Unit) {
viewModel.cargarSolicitudes()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
"Mensajes",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF6C5CE7)
)
Spacer(modifier = Modifier.height(16.dp))
if (viewModel.isLoading.value) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (viewModel.solicitudes.value.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tienes conversaciones activas", color = Color.Gray)
}
} else {
LazyColumn {
items(viewModel.solicitudes.value) { solicitud ->
ConversationCard(
solicitud = solicitud,
onClick = {
val otroUsuarioId = if (solicitud.soyElProveedor) {
solicitud.clienteId
} else {
solicitud.proveedorId ?: ""
}
val nombreOtroUsuario = if (solicitud.soyElProveedor) {
solicitud.nombreCliente ?: "Cliente"
} else {
solicitud.nombreProveedor ?: "Profesional"
}
navController.navigate(
"chat/$otroUsuarioId/$nombreOtroUsuario/${solicitud.id}"
)
}
)
Spacer(modifier = Modifier.height(12.dp))
}
}
}
}
}
@Composable
fun ConversationCard(solicitud: SolicitudResponse, onClick: () -> Unit) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
color = Color.White,
shadowElevation = 2.dp
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar placeholder
Surface(
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(24.dp),
color = Color(0xFF6C5CE7)
) {
Box(contentAlignment = Alignment.Center) {
Text(
if (solicitud.soyElProveedor)
solicitud.nombreCliente?.first()?.toString() ?: "C"
else
solicitud.nombreProveedor?.first()?.toString() ?: "P",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
if (solicitud.soyElProveedor)
solicitud.nombreCliente ?: "Cliente"
else
solicitud.nombreProveedor ?: "Profesional",
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Text(
solicitud.detallesSolicitud,
color = Color.Gray,
fontSize = 14.sp,
maxLines = 1
)
}
// Status badge
Surface(
shape = RoundedCornerShape(8.dp),
color = when (solicitud.estado) {
"PENDIENTE" -> Color(0xFFFFB74D)
"ACEPTADO" -> Color(0xFF64B5F6)
"PAGADO" -> Color(0xFF81C784)
"COMPLETADO" -> Color(0xFF9575CD)
"FINALIZADO" -> Color(0xFF4CAF50)
else -> Color.Gray
}
) {
Text(
solicitud.estado,
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
}
Procesamiento de Pagos
Pago en Escrow
Cuando un cliente acepta una propuesta, los fondos se retienen en escrow:
fun aceptarYPagar(solicitudId: String, otroUsuarioId: String) {
viewModelScope.launch {
try {
val responsePago = RetrofitClient.solicitudService.pagarSolicitud(solicitudId)
if (responsePago.isSuccessful) {
// Enviar mensaje de notificación
val requestMsg = EnviarMensajeRequest(
receptorId = otroUsuarioId,
contenido = "He aceptado el presupuesto y he realizado el pago. El dinero está retenido de forma segura en iMirly. Ya puedes empezar el trabajo.",
solicitudId = solicitudId,
tipo = "PRESUPUESTO_ACEPTADO"
)
RetrofitClient.mensajeService.enviarMensaje(requestMsg)
// Recargar conversación
cargarConversacion(otroUsuarioId)
} else {
// Manejar error de pago
when (responsePago.code()) {
400 -> println("Saldo insuficiente")
404 -> println("Solicitud no encontrada")
else -> println("Error de pago: ${responsePago.code()}")
}
}
} catch (e: Exception) {
android.util.Log.e("ChatViewModel", "Excepción crítica al pagar", e)
}
}
}
El endpoint de pago verifica el saldo del usuario antes de procesar. Asegúrate de que los usuarios tengan fondos suficientes cargados.
Flujo de Completar Trabajo
Profesional Marca como Completado
fun marcarTrabajoFinalizado(receptorId: String, solicitudId: String) {
viewModelScope.launch {
try {
val requestMsg = EnviarMensajeRequest(
receptorId = receptorId,
contenido = "He marcado el trabajo como finalizado. Por favor, revísalo y confirma si todo está correcto para liberar el pago.",
solicitudId = solicitudId,
tipo = "TRABAJO_FINALIZADO"
)
val msgResponse = RetrofitClient.mensajeService.enviarMensaje(requestMsg)
if (msgResponse.isSuccessful) cargarConversacion(receptorId)
} catch (e: Exception) {
android.util.Log.e("ChatViewModel", "Error finalizando", e)
}
}
}
Cliente Aprueba y Libera Pago
fun aprobarTrabajo(receptorId: String, solicitudId: String) {
viewModelScope.launch {
try {
val requestMsg = EnviarMensajeRequest(
receptorId = receptorId,
contenido = "He revisado el trabajo y está perfecto. Pago liberado, ¡muchas gracias!",
solicitudId = solicitudId,
tipo = "SERVICIO_COMPLETADO"
)
val msgResponse = RetrofitClient.mensajeService.enviarMensaje(requestMsg)
if (msgResponse.isSuccessful) {
cargarConversacion(receptorId)
// El pago se libera al profesional
}
} catch (e: Exception) {
android.util.Log.e("ChatViewModel", "Error aprobando", e)
}
}
}
Cliente Rechaza Trabajo
fun rechazarTrabajo(receptorId: String, solicitudId: String) {
viewModelScope.launch {
try {
val requestMsg = EnviarMensajeRequest(
receptorId = receptorId,
contenido = "El trabajo no cumple con lo acordado. Necesita arreglos.",
solicitudId = solicitudId,
tipo = "SERVICIO_RECHAZADO"
)
val msgResponse = RetrofitClient.mensajeService.enviarMensaje(requestMsg)
if (msgResponse.isSuccessful) cargarConversacion(receptorId)
} catch (e: Exception) {
android.util.Log.e("ChatViewModel", "Error rechazando", e)
}
}
}
Sistema de Valoraciones
Enviar Valoración
fun enviarValoracion(receptorId: String, solicitudId: String, nota: Double) {
viewModelScope.launch {
try {
val requestMsg = EnviarMensajeRequest(
receptorId = receptorId,
contenido = "$nota|He dejado una valoración de $nota estrellas.",
solicitudId = solicitudId,
tipo = "VALORACION"
)
val msgResponse = RetrofitClient.mensajeService.enviarMensaje(requestMsg)
if (msgResponse.isSuccessful) cargarConversacion(receptorId)
} catch (e: Exception) {
android.util.Log.e("ChatViewModel", "Error valorando", e)
}
}
}
Diálogo de Valoración
Jetpack Compose@Composable
fun RatingDialog(
onDismiss: () -> Unit,
onSubmit: (Double) -> Unit
) {
var rating by remember { mutableStateOf(5.0) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Valorar profesional") },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("¿Cómo fue tu experiencia?")
Spacer(modifier = Modifier.height(16.dp))
Row {
for (i in 1..5) {
IconButton(
onClick = { rating = i.toDouble() }
) {
Icon(
imageVector = if (i <= rating) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = null,
tint = Color(0xFFFFB74D),
modifier = Modifier.size(32.dp)
)
}
}
}
Text("$rating estrellas", fontSize = 18.sp, fontWeight = FontWeight.Bold)
}
},
confirmButton = {
Button(onClick = { onSubmit(rating) }) {
Text("Enviar valoración")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancelar")
}
}
)
}
Borrar Conversaciones
fun borrarChatYSolicitud(
otroUsuarioId: String,
solicitudId: String,
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
viewModelScope.launch {
try {
// El backend protege el borrado si hay fondos retenidos
val response = RetrofitClient.solicitudService.eliminarSolicitud(solicitudId)
if (response.isSuccessful) {
// Borrar mensajes
RetrofitClient.mensajeService.borrarConversacion(otroUsuarioId)
onSuccess()
} else {
onError("No se puede borrar este chat porque hay un presupuesto en curso o pagado.")
}
} catch (e: Exception) {
onError("Error de conexión al intentar borrar el chat.")
}
}
}
El backend previene el borrado de conversaciones con pagos activos para proteger a ambas partes.
Probar Contratos y Pagos
-
Crear Solicitud
Como cliente, ve un servicio y envía una solicitud de contacto.
-
Enviar Propuesta
Como profesional, envía una propuesta de precio en el chat.
-
Aceptar y Pagar
Como cliente, acepta la propuesta y verifica que los fondos se retienen.
-
Completar Trabajo
Como profesional, marca el trabajo como completado.
-
Aprobar y Liberar
Como cliente, aprueba el trabajo y verifica que el pago se libera.
-
Valorar Profesional
Como cliente, envía una valoración (1-5 estrellas).
Consideraciones de Seguridad
- Todas las operaciones de pago requieren autenticación (JWT)
- El sistema escrow previene transferencias directas
- El backend valida saldo suficiente antes del pago
- Las solicitudes con fondos retenidos no pueden borrarse
- Solo el cliente puede liberar el pago tras aprobación