DEV Community

Cover image for Building Payment Flows in Android: Lessons from Real Fintech Apps

Building Payment Flows in Android: Lessons from Real Fintech Apps

Building Payment Flows in Android: Production Patterns from Fintech Applications

Subtitle: Architectural patterns and state management strategies for reliable payment flows at scale


Introduction

Over the past months, I've been building and refining payment systems at scale. Every architectural decision compounds over time, and payment flows demand particular attention to state management, error handling, and offline resilience.

This article documents the patterns that have proven effective in production fintech applications. These aren't theoretical approaches—they represent lessons learned from handling millions of transactions and the edge cases that accompany them.


Why Payment Flows Require Specialized Architecture

Most mobile applications tolerate a certain degree of architectural looseness. A user can restart the app if something fails. Recovery is straightforward.

Payment flows operate under different constraints:

State Ambiguity is Costly
When a user initiates a payment, your application must maintain absolute clarity about its status: submitted, pending, failed, queued offline. If this state is unclear, the user is equally uncertain, leading to duplicate submissions or lost transactions.

Race Conditions Have Financial Consequences
A user confirms payment and immediately navigates away. Your retry logic must account for this without resubmitting the payment or losing the transaction record.

Network Reliability Cannot Be Assumed
In many markets, network interruptions occur mid-transaction. Your system must handle incomplete requests gracefully—queuing the transaction locally and retrying when connectivity returns.

Offline-First Is Essential
Users may initiate transactions without active connectivity. The application must queue payments locally, survive process termination, and submit reliably when network becomes available.

This is why Clean Architecture and MVI pattern implementation is not optional for payment flows—it becomes a structural guarantee that state remains traceable and predictable.


Architectural Foundation

For context on the underlying architecture, see Clean Architecture in Android: A Real-World Guide.

The structure we use:

  • :domain — Pure Kotlin business logic without Android dependencies
  • :data — Repository layer managing offline queues, network calls, and persistence
  • :feature:payment — MVI implementation containing Intent, UiState, ViewModel, and UI layers

This separation ensures business logic can be tested independently of Android runtime, catching state-related bugs before deployment.


Pattern 1: Payment Confirmation Flow with Explicit State

The MVI Contract

sealed class PaymentConfirmationIntent {
    data class LoadPaymentDetails(val paymentId: String) : PaymentConfirmationIntent()
    object ConfirmPayment : PaymentConfirmationIntent()
    object RetryPayment : PaymentConfirmationIntent()
    object CancelPayment : PaymentConfirmationIntent()
    object DismissError : PaymentConfirmationIntent()
}

data class PaymentConfirmationUiState(
    val paymentDetails: PaymentDetails? = null,
    val isLoading: Boolean = false,
    val isConfirming: Boolean = false,
    val error: PaymentError? = null,
    val isOffline: Boolean = false,
    val confirmationStatus: ConfirmationStatus = ConfirmationStatus.Idle,
) {
    enum class ConfirmationStatus {
        Idle,
        AwaitingUserConfirmation,
        Processing,
        Success,
        Failed,
        OfflineQueued,
    }
}

sealed class PaymentConfirmationEffect {
    object NavigateToSuccess : PaymentConfirmationEffect()
    object NavigateToFailure : PaymentConfirmationEffect()
    object NavigateBack : PaymentConfirmationEffect()
}
Enter fullscreen mode Exit fullscreen mode

Structural Benefits:

The ConfirmationStatus.OfflineQueued state explicitly represents the condition where payment has been submitted locally and awaits network availability. This eliminates ambiguity about whether a transaction was successfully queued.

Each Intent represents a discrete user action with no overlap, preventing state collisions. The isOffline flag as part of UiState ensures the UI layer can respond to network conditions with appropriate messaging.

ViewModel Implementation

@HiltViewModel
class PaymentConfirmationViewModel @Inject constructor(
    private val paymentRepository: PaymentRepository,
    private val offlinePaymentQueue: OfflinePaymentQueue,
    private val networkManager: NetworkManager,
) : ViewModel() {

    private val _state = MutableStateFlow<PaymentConfirmationUiState>(
        PaymentConfirmationUiState()
    )
    val state: StateFlow<PaymentConfirmationUiState> = _state.asStateFlow()

    fun processIntent(intent: PaymentConfirmationIntent) {
        when (intent) {
            is PaymentConfirmationIntent.LoadPaymentDetails -> 
                loadPaymentDetails(intent.paymentId)
            is PaymentConfirmationIntent.ConfirmPayment -> 
                confirmPayment()
            is PaymentConfirmationIntent.RetryPayment -> 
                retryPayment()
            is PaymentConfirmationIntent.CancelPayment -> 
                cancelPayment()
            is PaymentConfirmationIntent.DismissError -> 
                dismissError()
        }
    }

    private fun confirmPayment() {
        viewModelScope.launch {
            _state.update { it.copy(isConfirming = true) }

            try {
                if (!networkManager.isConnected()) {
                    val queuedPayment = offlinePaymentQueue.enqueue(
                        _state.value.paymentDetails ?: return@launch
                    )
                    _state.update {
                        it.copy(
                            isConfirming = false,
                            confirmationStatus = 
                                PaymentConfirmationUiState.ConfirmationStatus.OfflineQueued,
                        )
                    }
                    return@launch
                }

                val result = paymentRepository.submitPayment(
                    _state.value.paymentDetails!!
                )

                when (result) {
                    is PaymentResult.Success -> {
                        _state.update {
                            it.copy(
                                isConfirming = false,
                                confirmationStatus = 
                                    PaymentConfirmationUiState.ConfirmationStatus.Success,
                            )
                        }
                    }
                    is PaymentResult.Failure -> {
                        _state.update {
                            it.copy(
                                isConfirming = false,
                                error = result.error,
                                confirmationStatus = 
                                    PaymentConfirmationUiState.ConfirmationStatus.Failed,
                            )
                        }
                    }
                }
            } catch (e: Exception) {
                _state.update {
                    it.copy(
                        isConfirming = false,
                        error = PaymentError.Unknown(e.message ?: "Unknown error"),
                        confirmationStatus = 
                            PaymentConfirmationUiState.ConfirmationStatus.Failed,
                    )
                }
            }
        }
    }

    private fun retryPayment() {
        confirmPayment()
    }

    private fun dismissError() {
        _state.update { it.copy(error = null) }
    }

    private fun loadPaymentDetails(paymentId: String) {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true) }
            try {
                val details = paymentRepository.getPaymentDetails(paymentId)
                _state.update {
                    it.copy(
                        isLoading = false,
                        paymentDetails = details,
                        isOffline = !networkManager.isConnected(),
                    )
                }
            } catch (e: Exception) {
                _state.update {
                    it.copy(
                        isLoading = false,
                        error = PaymentError.LoadFailed(
                            e.message ?: "Failed to load"
                        ),
                    )
                }
            }
        }
    }

    private fun cancelPayment() {
        // Navigate back without submission
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Implementation Details:

  1. Network connectivity is checked before submission
  2. Offline state triggers local queueing with explicit status update
  3. Exception handling updates UI state rather than propagating errors
  4. All state transitions are explicit and traceable

UI Layer

@Composable
fun PaymentConfirmationScreen(
    viewModel: PaymentConfirmationViewModel,
    paymentId: String,
    onSuccess: () -> Unit,
    onFailure: () -> Unit,
    onBack: () -> Unit,
) {
    val state by viewModel.state.collectAsState()

    LaunchedEffect(Unit) {
        viewModel.processIntent(
            PaymentConfirmationIntent.LoadPaymentDetails(paymentId)
        )
    }

    LaunchedEffect(state.confirmationStatus) {
        when (state.confirmationStatus) {
            PaymentConfirmationUiState.ConfirmationStatus.Success -> 
                onSuccess()
            PaymentConfirmationUiState.ConfirmationStatus.Failed -> 
                onFailure()
            else -> {}
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        when {
            state.isLoading -> {
                CircularProgressIndicator(
                    modifier = Modifier.align(Alignment.Center)
                )
            }
            state.paymentDetails != null -> {
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(16.dp),
                ) {
                    PaymentDetailsCard(state.paymentDetails)

                    if (state.isOffline) {
                        OfflineWarningBanner()
                    }

                    state.error?.let { error ->
                        ErrorBanner(
                            message = error.message,
                            onDismiss = {
                                viewModel.processIntent(
                                    PaymentConfirmationIntent.DismissError
                                )
                            }
                        )
                    }

                    Spacer(modifier = Modifier.weight(1f))

                    when (state.confirmationStatus) {
                        PaymentConfirmationUiState.ConfirmationStatus
                            .AwaitingUserConfirmation -> {
                            ConfirmationButtons(viewModel, onBack)
                        }
                        PaymentConfirmationUiState.ConfirmationStatus
                            .OfflineQueued -> {
                            Button(
                                onClick = { onBack() },
                                modifier = Modifier.fillMaxWidth(),
                            ) {
                                Text("Done")
                            }
                        }
                        PaymentConfirmationUiState.ConfirmationStatus.Failed -> {
                            Button(
                                onClick = {
                                    viewModel.processIntent(
                                        PaymentConfirmationIntent.RetryPayment
                                    )
                                },
                                modifier = Modifier.fillMaxWidth(),
                            ) {
                                Text("Retry")
                            }
                        }
                        else -> {}
                    }
                }
            }
            state.error != null -> {
                ErrorScreen(
                    error = state.error,
                    onRetry = {
                        viewModel.processIntent(
                            PaymentConfirmationIntent
                                .LoadPaymentDetails(paymentId)
                        )
                    },
                    onBack = onBack,
                )
            }
        }
    }
}

@Composable
private fun OfflineWarningBanner() {
    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = Color(0xFFFFF3CD)
        ),
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(
                imageVector = Icons.Default.Warning,
                contentDescription = null,
                tint = Color(0xFFFF9800),
            )
            Text(
                text = "You are offline. Your payment will be submitted when connectivity is restored.",
                fontSize = 14.sp,
                modifier = Modifier.weight(1f),
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Offline Payment Queue with Persistence

Local queueing with durable storage is essential for offline-first applications.

// domain/payment/OfflinePaymentQueue.kt

data class QueuedPayment(
    val id: String,
    val payment: PaymentDetails,
    val timestamp: Long,
    val retryCount: Int = 0,
    val status: QueueStatus = QueueStatus.Pending,
) {
    enum class QueueStatus {
        Pending,
        Retrying,
        Submitted,
        Failed,
    }
}

interface OfflinePaymentQueue {
    suspend fun enqueue(payment: PaymentDetails): QueuedPayment
    suspend fun getAll(): List<QueuedPayment>
    suspend fun getById(id: String): QueuedPayment?
    suspend fun remove(id: String)
    suspend fun updateStatus(id: String, status: QueueStatus)
    suspend fun incrementRetry(id: String)
}

// data/payment/OfflinePaymentQueueImpl.kt

@Singleton
class OfflinePaymentQueueImpl @Inject constructor(
    private val paymentDao: PaymentQueueDao,
) : OfflinePaymentQueue {

    override suspend fun enqueue(payment: PaymentDetails): QueuedPayment {
        val queued = QueuedPayment(
            id = UUID.randomUUID().toString(),
            payment = payment,
            timestamp = System.currentTimeMillis(),
        )
        paymentDao.insert(queued.toEntity())
        return queued
    }

    override suspend fun getAll(): List<QueuedPayment> {
        return paymentDao.getAll().map { it.toDomain() }
    }

    override suspend fun updateStatus(
        id: String,
        status: QueueStatus
    ) {
        paymentDao.updateStatus(id, status.name)
    }

    override suspend fun remove(id: String) {
        paymentDao.delete(id)
    }

    override suspend fun incrementRetry(id: String) {
        paymentDao.incrementRetry(id)
    }

    override suspend fun getById(id: String): QueuedPayment? {
        return paymentDao.getById(id)?.toDomain()
    }
}
Enter fullscreen mode Exit fullscreen mode

Background synchronization handles submission when connectivity is restored:

// data/payment/PaymentSyncWorker.kt

class PaymentSyncWorker @Inject constructor(
    context: Context,
    params: WorkerParameters,
    private val offlineQueue: OfflinePaymentQueue,
    private val paymentRepository: PaymentRepository,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            val queuedPayments = offlineQueue.getAll()
                .filter { it.status == QueueStatus.Pending }

            queuedPayments.forEach { queued ->
                try {
                    offlineQueue.updateStatus(
                        queued.id,
                        QueueStatus.Retrying
                    )

                    val result = paymentRepository.submitPayment(
                        queued.payment
                    )

                    when (result) {
                        is PaymentResult.Success -> {
                            offlineQueue.updateStatus(
                                queued.id,
                                QueueStatus.Submitted
                            )
                            offlineQueue.remove(queued.id)
                        }
                        is PaymentResult.Failure -> {
                            if (queued.retryCount < MAX_RETRIES) {
                                offlineQueue.incrementRetry(queued.id)
                            } else {
                                offlineQueue.updateStatus(
                                    queued.id,
                                    QueueStatus.Failed
                                )
                            }
                        }
                    }
                } catch (e: Exception) {
                    if (queued.retryCount < MAX_RETRIES) {
                        offlineQueue.incrementRetry(queued.id)
                    } else {
                        offlineQueue.updateStatus(
                            queued.id,
                            QueueStatus.Failed
                        )
                    }
                }
            }

            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }

    companion object {
        private const val MAX_RETRIES = 5
        const val WORK_NAME = "payment_sync"
    }
}

// Schedule in application startup:
// WorkManager.getInstance(context).enqueueUniquePeriodicWork(
//     PaymentSyncWorker.WORK_NAME,
//     ExistingPeriodicWorkPolicy.KEEP,
//     PeriodicWorkRequestBuilder<PaymentSyncWorker>(
//         15, TimeUnit.MINUTES
//     ).build()
// )
Enter fullscreen mode Exit fullscreen mode

Benefits of This Approach:

  • Payment data persists across process termination
  • Retry logic respects maximum attempt limits
  • Each payment has a unique identifier preventing duplicate submissions
  • Status transitions are durable and traceable

Pattern 3: Biometric Authentication for Payment Confirmation

Biometric authentication integrates as a discrete state within the payment flow:

sealed class BiometricAuthIntent {
    object AuthenticateWithBiometric : BiometricAuthIntent()
    object CancelBiometric : BiometricAuthIntent()
    object RetryBiometric : BiometricAuthIntent()
}

data class BiometricAuthUiState(
    val isAuthenticating: Boolean = false,
    val authResult: BiometricAuthResult? = null,
    val error: BiometricError? = null,
) {
    sealed class BiometricAuthResult {
        object Success : BiometricAuthResult()
        object Failure : BiometricAuthResult()
        object Cancelled : BiometricAuthResult()
    }
}

@HiltViewModel
class BiometricAuthViewModel @Inject constructor(
    private val biometricManager: BiometricManager,
) : ViewModel() {

    private val _state = MutableStateFlow<BiometricAuthUiState>(
        BiometricAuthUiState()
    )
    val state: StateFlow<BiometricAuthUiState> = _state.asStateFlow()

    fun processIntent(intent: BiometricAuthIntent) {
        when (intent) {
            BiometricAuthIntent.AuthenticateWithBiometric -> authenticate()
            BiometricAuthIntent.CancelBiometric -> cancel()
            BiometricAuthIntent.RetryBiometric -> authenticate()
        }
    }

    private fun authenticate() {
        viewModelScope.launch {
            _state.update { it.copy(isAuthenticating = true) }

            try {
                val result = biometricManager.authenticate()
                _state.update {
                    it.copy(
                        isAuthenticating = false,
                        authResult = result,
                    )
                }
            } catch (e: BiometricException) {
                _state.update {
                    it.copy(
                        isAuthenticating = false,
                        error = BiometricError.AuthenticationFailed(
                            e.message ?: "Authentication failed"
                        ),
                    )
                }
            }
        }
    }

    private fun cancel() {
        biometricManager.cancel()
        _state.update {
            it.copy(
                isAuthenticating = false,
                authResult = BiometricAuthUiState.BiometricAuthResult
                    .Cancelled,
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Biometric authentication state remains consistent with the MVI pattern rather than introducing separate concerns.


Transaction Flow Example

Consider a complete transaction lifecycle:

  1. User initiates payment confirmation
  2. App checks network connectivity
  3. If offline: payment queued locally with OfflineQueued status
  4. If online: payment submitted to server
  5. On network restoration: background worker processes offline queue
  6. Each queued payment retried up to 5 times before marking failed
  7. User notified of final status through UI state updates

This approach ensures:

  • No state ambiguity at any point
  • Network interruptions don't result in lost transactions
  • Duplicate submissions are prevented
  • Process termination doesn't destroy payment records

Open-Source Implementation

These patterns are available in production-ready form:

  1. clean-architecture-android — Foundation template with Clean Architecture, MVI, and Jetpack Compose

  2. fintech-mobile-patterns-android — Payment flow implementations with offline queuing, error handling, and authentication patterns

Both repositories include complete implementations with testing examples.


Conclusion

Payment flow architecture demands explicit state management and thoughtful error handling. The patterns discussed—explicit state enums, offline queuing with persistence, and MVI-based architecture—provide structural guarantees that reduce the likelihood of state-related bugs.

These approaches have proven effective at production scale. They prioritize clarity of state over brevity of code, understanding that payment systems benefit more from predictability than elegance.


Additional Resources

Top comments (0)