CheckoutManager.kt

/*
 * This file is part of GNU Taler
 * (C) 2026 Taler Systems S.A.
 *
 * GNU Taler is free software; you can redistribute it and/or modify it under the
 * terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 3, or (at your option) any later version.
 *
 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

package net.taler.merchantpos.checkout

import androidx.annotation.UiThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData

import net.taler.common.Amount
import net.taler.common.Version
import net.taler.merchantlib.ExternalRefundRequest
import net.taler.merchantlib.MerchantApi
import net.taler.merchantpos.config.ConfigManager
import net.taler.merchantpos.order.Order
import net.taler.merchantpos.order.OrderManager
import net.taler.merchantpos.payment.POS_REFUND_DELAY
import net.taler.merchantpos.payment.toOrderParts
import net.taler.merchantpos.paymentoptions.ChargeResult
import net.taler.merchantpos.paymentoptions.ExternalCharge
import net.taler.merchantpos.paymentoptions.PaymentOption
import net.taler.merchantpos.paymentoptions.PaymentOptionsManager
import net.taler.merchantpos.paymentoptions.RefundOutcome
import net.taler.merchantpos.paymentoptions.TALER_OPTION_ID

import java.util.UUID
import java.util.concurrent.TimeUnit

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

// DD 96: external payments and merchant collection.
const val MIXED_PAYMENTS_PROTOCOL = 42

private val KEEP_COMPLETED_MS = TimeUnit.DAYS.toMillis(30)

sealed interface CheckoutResult
{
    data object Completed : CheckoutResult

    data class AwaitingWallet(
        val checkout: Checkout,
    ) : CheckoutResult

    data class Failed(
        val message: String,
    ) : CheckoutResult
}

data class ChargeEvent(
    val method: String,
    val amount: Amount,
    val result: ChargeResult,
)

/** NotRecorded means the refund succeeded but its backend record still needs updating. */
sealed interface ExternalRefundResult
{
    data object Done : ExternalRefundResult

    data class NotRecorded(
        val message: String,
    ) : ExternalRefundResult

    data class Failed(
        val message: String,
    ) : ExternalRefundResult
}

sealed interface RevertResult
{
    data object Reverted : RevertResult

    data object AlreadyPaid : RevertResult

    data class Failed(
        val message: String,
    ) : RevertResult
}

sealed interface AbandonResult
{
    data object Discarded : AbandonResult

    data class KeptForSettlement(
        val checkout: Checkout,
    ) : AbandonResult
}

/**
 * Persists checkout state and coordinates external payments with backend orders.
 * Pending charges must be verified before resolving them; manual refunds must
 * already have been returned to the customer before they are recorded here.
 */
class CheckoutManager internal constructor(
    private val store: CheckoutStore,
    private val backendForCurrentMerchant: () -> CheckoutBackend?,
    private val merchantIdentity: LiveData<String?>,
    private val backendVersion: () -> String?,
    private val externalOptionsEnabled: () -> Boolean,
    private val optionFor: (method: String) -> PaymentOption?,
    private val onOrderSettled: (posOrderId: Int) -> Unit,
    private val now: () -> Long,
    private val newId: () -> String,
)
{
    constructor(
        store: CheckoutStore,
        api: MerchantApi,
        configManager: ConfigManager,
        paymentOptions: PaymentOptionsManager,
        orderManager: OrderManager,
    ) : this(
        store = store,
        backendForCurrentMerchant = {
            configManager.merchantConfig?.let { MerchantCheckoutBackend(api, it) }
        },
        merchantIdentity = configManager.merchantIdentity,
        backendVersion = { configManager.backendVersion },
        externalOptionsEnabled = {
            paymentOptions.options.any { it.id != TALER_OPTION_ID && paymentOptions.isEnabled(it) }
        },
        optionFor = { method -> paymentOptions.options.firstOrNull { it.id == method } },
        onOrderSettled = orderManager::onOrderPaid,
        now = System::currentTimeMillis,
        newId = { UUID.randomUUID().toString() },
    )

    private val session = newId()
    private var allCheckouts = dropOldCompleted(store.load())
    private val creationMutex = Mutex()
    private val mCheckouts = MediatorLiveData<List<Checkout>>()
    private val mCurrentId = MutableLiveData<String?>(null)
    private val mChargeEvent = MutableLiveData<ChargeEvent?>(null)

    val checkouts: LiveData<List<Checkout>> = mCheckouts
    val currentId: LiveData<String?> = mCurrentId
    val chargeEvent: LiveData<ChargeEvent?> = mChargeEvent

    val current: Checkout? get() = mCurrentId.value?.let(::find)

    init
    {
        mCheckouts.value = visibleCheckouts()
        mCheckouts.addSource(merchantIdentity) {
            if (mCurrentId.value?.let(::find) == null) mCurrentId.value = null
            mChargeEvent.value = null
            mCheckouts.value = visibleCheckouts()
        }
    }

    private fun belongsToCurrentMerchant(checkout: Checkout): Boolean =
        checkout.merchantId != null && checkout.merchantId == merchantIdentity.value

    private fun visibleCheckouts() = allCheckouts.filter(::belongsToCurrentMerchant)

    private fun findStored(id: String) = allCheckouts.firstOrNull { it.id == id }

    fun find(id: String): Checkout? = findStored(id)?.takeIf(::belongsToCurrentMerchant)

    fun unfinished(): List<Checkout> = visibleCheckouts().filter { it.stage != CheckoutStage.Completed }

    fun backendSupportsMixedPayments(): Boolean
    {
        val current = backendVersion()?.let(Version::parse)?.current ?: return false
        return current >= MIXED_PAYMENTS_PROTOCOL
    }

    fun shouldUseCheckout(): Boolean = backendSupportsMixedPayments() && externalOptionsEnabled()

    @UiThread
    fun start(
        order: Order,
        includeProducts: Boolean,
    ): Checkout
    {
        val parts = order.toOrderParts(includeProducts)
        val checkout =
            Checkout(
                id = newId(),
                merchantId = checkNotNull(merchantIdentity.value) { "Merchant is not configured" },
                createdAt = now(),
                session = session,
                posOrderId = order.id,
                contractTerms = parts.contractTerms,
                inventoryProducts = parts.inventoryProducts,
            )
        save(allCheckouts + checkout)
        mCurrentId.value = checkout.id
        return checkout
    }

    @UiThread
    fun resume(checkoutId: String): Boolean
    {
        val checkout = find(checkoutId) ?: return false
        if (checkout.stage == CheckoutStage.Completed ||
            checkout.stage == CheckoutStage.Abandoned)
        {
            return false
        }
        mCurrentId.value = checkoutId
        return true
    }

    fun newPaymentId(): String = newId()

    @UiThread
    fun recordCash(tendered: Amount): CashOutcome
    {
        val outcome = requireCurrent().withCash(tendered, newId(), now())
        replace(outcome.checkout)
        return outcome
    }

    @UiThread
    fun recordExternalPayment(
        method: String,
        amount: Amount,
        paymentId: String,
        transactionCode: String?,
    ): Checkout = recordExternal(requireCurrent(), method, amount, paymentId, transactionCode)

    private fun recordExternal(
        checkout: Checkout,
        method: String,
        amount: Amount,
        paymentId: String,
        transactionCode: String?,
    ): Checkout
    {
        if (checkout.payments.any { it.id == paymentId }) return checkout
        val updated = checkout.withExternalPayment(method, amount, paymentId, transactionCode, now())
        replace(updated)
        return updated
    }

    @UiThread
    fun consumeChargeEvent()
    {
        mChargeEvent.value = null
    }

    @UiThread
    fun chargeWith(
        option: PaymentOption,
        amount: Amount,
        title: String,
    ): Boolean
    {
        val checkout = requireCurrent()
        if (!checkout.canTakeExternalPayments ||
            amount.currency != checkout.currency ||
            amount <= Amount.zero(checkout.currency) ||
            amount > checkout.open)
        {
            return false
        }
        val pending = PendingCharge(newId(), option.id, amount, now())
        // A failed disk write must prevent the terminal from being launched.
        replace(checkout.copy(pendingCharge = pending))
        val started =
            try
            {
                option.startCharge(ExternalCharge(pending.reference, amount, title)) { result ->
                    receiveChargeResult(checkout.id, pending.reference, result)
                }
            }
            catch (e: Exception)
            {
                // Launch may already have reached the provider. Keep the unresolved attempt.
                receiveChargeResult(checkout.id, pending.reference, ChargeResult.Unknown(e.message))
                true
            }
        if (!started) receiveChargeResult(checkout.id, pending.reference, ChargeResult.Cancelled)
        return started
    }

    private fun receiveChargeResult(
        checkoutId: String,
        reference: String,
        result: ChargeResult,
    )
    {
        val checkout = findStored(checkoutId) ?: return
        val pending = checkout.pendingCharge?.takeIf { it.reference == reference } ?: return
        val reported =
            try
            {
                when (result)
                {
                    is ChargeResult.Success ->
                    {
                        replace(
                            checkout
                                .copy(pendingCharge = null)
                                .withExternalPayment(
                                    pending.method,
                                    pending.amount,
                                    reference,
                                    result.transactionCode,
                                    now(),
                                ),
                        )
                    }

                    ChargeResult.Cancelled, is ChargeResult.Failed ->
                    {
                        replace(checkout.copy(pendingCharge = null))
                    }

                    is ChargeResult.Unknown ->
                    {
                        Unit
                    }
                }
                result
            }
            catch (e: Exception)
            {
                ChargeResult.Unknown(e.message)
            }
        if (belongsToCurrentMerchant(checkout))
        {
            mChargeEvent.value = ChargeEvent(pending.method, pending.amount, reported)
        }
    }

    @UiThread
    fun resolvePendingCharge(
        reference: String,
        paid: Boolean,
        transactionCode: String? = null,
    )
    {
        val checkout = requireCurrent()
        require(checkout.pendingCharge?.reference == reference) { "Payment is no longer pending" }
        require(!paid || !transactionCode.isNullOrBlank()) { "Enter the confirmed transaction reference" }
        receiveChargeResult(
            checkout.id,
            reference,
            if (paid) ChargeResult.Success(transactionCode!!.trim()) else ChargeResult.Cancelled,
        )
    }

    suspend fun finishWithTaler(): CheckoutResult
    {
        val checkout = current ?: return CheckoutResult.Failed("No checkout in progress")
        val backend = backendForCurrentMerchant() ?: return CheckoutResult.Failed("Merchant is not configured")
        return when (val order = ensureBackendOrder(checkout, backend))
        {
            is BackendResult.Success ->
            {
                if (belongsToCurrentMerchant(checkout))
                {
                    CheckoutResult.AwaitingWallet(findStored(checkout.id)!!)
                }
                else
                {
                    CheckoutResult.Failed("Merchant account changed")
                }
            }

            is BackendResult.Failure ->
            {
                CheckoutResult.Failed(order.message)
            }
        }
    }

    suspend fun collectByMerchant(): CheckoutResult
    {
        val checkout = current ?: return CheckoutResult.Failed("No checkout in progress")
        if (!checkout.isFullyPaidExternally)
        {
            return CheckoutResult.Failed("Part of this order still has to be paid with Taler")
        }
        val backend = backendForCurrentMerchant() ?: return CheckoutResult.Failed("Merchant is not configured")
        val orderId =
            when (val order = ensureBackendOrder(checkout, backend))
            {
                is BackendResult.Success -> order.value
                is BackendResult.Failure -> return CheckoutResult.Failed(order.message)
            }
        if (!belongsToCurrentMerchant(checkout)) return CheckoutResult.Failed("Merchant account changed")
        return when (val collected = backend.collect(orderId))
        {
            is BackendResult.Success ->
            {
                complete(orderId, checkout.merchantId)
                if (belongsToCurrentMerchant(checkout))
                {
                    CheckoutResult.Completed
                }
                else
                {
                    CheckoutResult.Failed("Merchant account changed")
                }
            }

            // The customer may have scanned the free order in the meantime.
            is BackendResult.Failure ->
            {
                if (backend.isPaid(orderId) == true)
                {
                    complete(orderId, checkout.merchantId)
                    if (belongsToCurrentMerchant(checkout))
                    {
                        CheckoutResult.Completed
                    }
                    else
                    {
                        CheckoutResult.Failed("Merchant account changed")
                    }
                }
                else
                {
                    CheckoutResult.Failed(collected.message)
                }
            }
        }
    }

    @UiThread
    fun onBackendOrderPaid(orderId: String) = complete(orderId)

    suspend fun refundExternalPayment(
        checkoutId: String,
        paymentId: String,
        amount: Amount,
        reason: String,
    ): ExternalRefundResult
    {
        val checkout = find(checkoutId) ?: return ExternalRefundResult.Failed("Unknown payment")
        val backend = backendForCurrentMerchant() ?: return ExternalRefundResult.Failed("Merchant is not configured")
        val payment =
            checkout.payments.firstOrNull { it.id == paymentId }
                ?: return ExternalRefundResult.Failed("Unknown payment")
        if (amount.currency != checkout.currency ||
            amount <= Amount.zero(checkout.currency) ||
            amount > checkout.refundableOf(payment))
        {
            return ExternalRefundResult.Failed("More than is left of this payment")
        }
        val option = optionFor(payment.method)
        if (option == null ||
            !option.canRefund)
        {
            return ExternalRefundResult.Failed("This payment has to be refunded in its own app")
        }
        when (val outcome = option.refund(payment.transactionCode, amount, reason))
        {
            RefundOutcome.Success ->
            {
                Unit
            }

            RefundOutcome.NotSupported ->
            {
                return ExternalRefundResult.Failed("This payment has to be refunded in its own app")
            }

            is RefundOutcome.Failed ->
            {
                return ExternalRefundResult.Failed(outcome.message)
            }
        }
        val refundId = newId()
        replace(checkout.withRefund(refundId, paymentId, amount, reason, now()))
        val orderId = checkout.backendOrderId ?: return ExternalRefundResult.Done
        val request =
            ExternalRefundRequest(
                method = payment.method,
                id = refundId,
                paymentId = paymentId,
                amount = amount,
                reason = reason,
            )
        return when (val recorded = backend.recordExternalRefund(orderId, request))
        {
            is BackendResult.Success -> ExternalRefundResult.Done
            is BackendResult.Failure -> ExternalRefundResult.NotRecorded(recorded.message)
        }
    }

    suspend fun recordManualRefund(
        checkoutId: String,
        paymentId: String,
        amount: Amount,
        reason: String,
    ): ExternalRefundResult
    {
        val checkout = find(checkoutId) ?: return ExternalRefundResult.Failed("Unknown payment")
        val backend = backendForCurrentMerchant() ?: return ExternalRefundResult.Failed("Merchant is not configured")
        val payment =
            checkout.payments.firstOrNull { it.id == paymentId }
                ?: return ExternalRefundResult.Failed("Unknown payment")
        if (amount.currency != checkout.currency ||
            amount <= Amount.zero(checkout.currency) ||
            amount > checkout.refundableOf(payment))
        {
            return ExternalRefundResult.Failed("More than is left of this payment")
        }
        val refundId = newId()
        // Persist before contacting the backend. A retry must never return this money twice.
        replace(checkout.withRefund(refundId, paymentId, amount, reason, now()))
        val orderId = checkout.backendOrderId ?: return ExternalRefundResult.Done
        return when (
            val recorded =
                backend.recordExternalRefund(
                    orderId,
                    ExternalRefundRequest(payment.method, refundId, paymentId, amount, reason),
                )
        )
        {
            is BackendResult.Success -> ExternalRefundResult.Done
            is BackendResult.Failure -> ExternalRefundResult.NotRecorded(recorded.message)
        }
    }

    suspend fun revertTalerStep(): RevertResult
    {
        val checkout = current ?: return RevertResult.Failed("No checkout in progress")
        val backend = backendForCurrentMerchant() ?: return RevertResult.Failed("Merchant is not configured")
        if (checkout.creationPending) return RevertResult.Failed("Retry order creation before changing payments")
        val orderId = checkout.backendOrderId ?: return RevertResult.Reverted
        return when (backend.isPaid(orderId))
        {
            true ->
            {
                complete(orderId, checkout.merchantId)
                RevertResult.AlreadyPaid
            }

            null ->
            {
                RevertResult.Failed("The merchant backend could not be reached")
            }

            false ->
            {
                when (val deleted = backend.deleteOrder(orderId, checkout.payments.isNotEmpty()))
                {
                    is BackendResult.Success ->
                    {
                        replace(
                            checkout.copy(
                                stage = CheckoutStage.Collecting,
                                backendOrderId = null,
                                attempt = checkout.attempt + 1,
                            ),
                        )
                        RevertResult.Reverted
                    }

                    is BackendResult.Failure ->
                    {
                        RevertResult.Failed(deleted.message)
                    }
                }
            }
        }
    }

    suspend fun abandon(): AbandonResult?
    {
        val checkout = current ?: return null
        mCurrentId.value = null
        // Do not discard or abandon a record that still needs reconciliation.
        if (checkout.pendingCharge != null ||
            checkout.creationPending)
        {
            return AbandonResult.KeptForSettlement(checkout)
        }
        val backend = backendForCurrentMerchant() ?: return AbandonResult.KeptForSettlement(checkout)
        if (checkout.payments.isEmpty())
        {
            checkout.backendOrderId?.let { orderId ->
                // A failed delete may hide a paid order or a lost response. Keep the
                // checkout recoverable until the backend confirms it is gone.
                if (backend.deleteOrder(orderId) is BackendResult.Failure)
                {
                    return AbandonResult.KeptForSettlement(checkout)
                }
            }
            save(allCheckouts.filterNot { it.id == checkout.id })
            return AbandonResult.Discarded
        }
        val kept = checkout.copy(stage = CheckoutStage.Abandoned)
        replace(kept)
        return AbandonResult.KeptForSettlement(kept)
    }

    @UiThread
    fun onBackendOrderDeleted(orderId: String)
    {
        val checkout = visibleCheckouts().find { it.backendOrderId == orderId } ?: return
        replace(checkout.copy(stage = CheckoutStage.Abandoned, backendOrderId = null))
        if (mCurrentId.value == checkout.id) mCurrentId.value = null
    }

    @UiThread
    fun dismiss(checkoutId: String)
    {
        val checkout = find(checkoutId) ?: return
        if (checkout.pendingCharge != null ||
            checkout.creationPending)
        {
            return
        }
        if (checkout.stage != CheckoutStage.Completed &&
            checkout.stage != CheckoutStage.Abandoned)
        {
            return
        }
        save(allCheckouts.filterNot { it.id == checkoutId })
    }

    @UiThread
    fun close()
    {
        mCurrentId.value = null
    }

    private suspend fun ensureBackendOrder(
        original: Checkout,
        backend: CheckoutBackend,
    ): BackendResult<String> =
        creationMutex.withLock {
            val checkout = find(original.id) ?: return@withLock BackendResult.Failure("Merchant account changed")
            if (checkout.pendingCharge !=
                null)
            {
                return@withLock BackendResult.Failure("Resolve the pending card payment first")
            }
            if (checkout.stage ==
                CheckoutStage.Abandoned)
            {
                return@withLock BackendResult.Failure("Checkout was abandoned")
            }
            checkout.backendOrderId?.let { return@withLock BackendResult.Success(it) }
            val frozen = checkout.copy(creationPending = true)
            replace(frozen)
            val orderId =
                when (val created = backend.createOrder(frozen.toOrderRequest(POS_REFUND_DELAY)))
                {
                    is BackendResult.Success ->
                    {
                        created.value
                    }

                    is BackendResult.Failure ->
                    {
                        // Creation may have succeeded before its response was lost.
                        val existing = frozen.backendOrderIdToCreate
                        if (backend.isPaid(existing) == null) return@withLock created
                        existing
                    }
                }
            replace(frozen.copy(stage = CheckoutStage.AwaitingTaler, backendOrderId = orderId, creationPending = false))
            BackendResult.Success(orderId)
        }

    private fun complete(
        orderId: String,
        merchantId: String? = merchantIdentity.value,
    )
    {
        val checkout =
            allCheckouts.firstOrNull {
                it.backendOrderId == orderId && it.merchantId == merchantId && merchantId != null
            }
                ?: return
        if (checkout.stage == CheckoutStage.Completed) return
        replace(checkout.copy(stage = CheckoutStage.Completed))
        if (mCurrentId.value == checkout.id) mCurrentId.value = null
        // After a restart the order list is new; its ids no longer mean that sale.
        if (checkout.session == session &&
            belongsToCurrentMerchant(checkout))
        {
            onOrderSettled(checkout.posOrderId)
        }
    }

    private fun requireCurrent(): Checkout = checkNotNull(current) { "No checkout in progress" }

    private fun replace(checkout: Checkout)
    {
        save(allCheckouts.map { if (it.id == checkout.id) checkout else it })
    }

    private fun save(checkouts: List<Checkout>)
    {
        store.save(checkouts)
        allCheckouts = checkouts
        mCheckouts.value = visibleCheckouts()
    }

    private fun dropOldCompleted(checkouts: List<Checkout>): List<Checkout>
    {
        val cutoff = now() - KEEP_COMPLETED_MS
        return checkouts.filterNot { it.stage == CheckoutStage.Completed && it.createdAt < cutoff }
    }
}