PaymentManager.kt

/*
 * This file is part of GNU Taler
 * (C) 2020 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.payment

import android.content.Context
import android.os.CountDownTimer
import android.util.Log

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

import net.taler.lib.android.assertUiThread
import net.taler.merchantlib.CheckPaymentResponse
import net.taler.merchantlib.MerchantApi
import net.taler.merchantlib.OrderHistoryEntry
import net.taler.merchantlib.PostOrderRequest
import net.taler.merchantpos.MainActivity.Companion.TAG
import net.taler.merchantpos.R
import net.taler.merchantpos.checkout.Checkout
import net.taler.merchantpos.config.ConfigManager
import net.taler.merchantpos.config.ConfigProduct
import net.taler.merchantpos.order.Order

import java.util.concurrent.TimeUnit.SECONDS

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

private const val TIMEOUT = Long.MAX_VALUE
private val CHECK_INTERVAL = SECONDS.toMillis(1)

/** Order list id of the synthetic order shown for a checkout's Taler part. */
private const val CHECKOUT_DISPLAY_ORDER_ID = -3

class PaymentManager(
    private val context: Context,
    private val configManager: ConfigManager,
    private val scope: CoroutineScope,
    private val api: MerchantApi,
)
{
    private var checkJob: Job? = null
    private val mPayment = MutableLiveData<Payment?>(null)
    private val mDeleteNeedsForce = MutableLiveData<Boolean?>(null)

    val payment: LiveData<Payment?> = mPayment
    val deleteNeedsForce: LiveData<Boolean?> = mDeleteNeedsForce

    private val checkTimer: CountDownTimer =
        object : CountDownTimer(TIMEOUT, CHECK_INTERVAL)
        {
            override fun onTick(millisUntilFinished: Long)
            {
                val orderId = payment.value?.orderId
                if (orderId == null)
                {
                    cancel()
                }
                else if (checkJob == null ||
                    checkJob?.isCompleted == true)
                {
                    checkJob = checkPayment(orderId)
                }
            }

            override fun onFinish()
            {
                cancelPayment(context.getString(R.string.error_timeout))
            }
        }

    @UiThread
    fun createPayment(
        order: Order,
        includeProducts: Boolean = true,
    ) = scope.launch {
        val merchantConfig = configManager.merchantConfig!!
        val payment = Payment(order, order.summary, configManager.currency!!)
        mPayment.value = payment
        val parts = order.toOrderParts(includeProducts)
        val request =
            PostOrderRequest(
                contractTerms = parts.contractTerms,
                refundDelay = POS_REFUND_DELAY,
                inventoryProducts = parts.inventoryProducts,
            )
        api.postOrder(merchantConfig, request).handle(
            { error ->
                if (looksLikeInventoryError(error))
                {
                    configManager.refreshInventory()
                }
                onNetworkError(error)
            }
        ) { orderResponse ->
            assertUiThread()
            if (mPayment.value !== payment)
            {
                Log.d(TAG, "Ignoring order ${orderResponse.orderId} for abandoned payment")
                // cancelPayment() could not delete it yet, it had no order ID back then
                scope.launch { api.deleteOrder(merchantConfig, orderResponse.orderId) }
                return@handle
            }
            mPayment.value = payment.copy(orderId = orderResponse.orderId)
            checkTimer.start()
        }
    }

    @UiThread
    fun resumePayment(item: OrderHistoryEntry)
    {
        val current = mPayment.value
        if (current?.orderId == item.orderId &&
            !current.paid &&
            current.error == null)
        {
            if (checkJob == null ||
                checkJob?.isCompleted == true)
            {
                checkJob = checkPayment(item.orderId)
            }
            checkTimer.start()
            return
        }

        stopPaymentChecks()
        val order =
            Order(
                id = -2,
                currency = item.amount.currency,
                currencySpec = item.amount.spec,
                availableCategories = emptyMap(),
                products =
                    listOf(
                        ConfigProduct(
                            description = item.summary,
                            price = item.amount,
                            categories = listOf(Int.MIN_VALUE),
                            quantity = 1,
                        ),
                    ),
            )
        mPayment.value =
            Payment(
                order = order,
                summary = item.summary,
                currency = item.amount.currency,
                orderId = item.orderId,
                paid = item.paid,
                statusLoading = !item.paid,
            )
        if (!item.paid)
        {
            checkJob = checkPayment(item.orderId)
            checkTimer.start()
        }
    }

    /**
     * Shows the Taler part of a mixed checkout. Its backend order already exists and
     * carries the external payments, so this screen only waits for the wallet.
     */
    @UiThread
    fun showCheckoutOrder(checkout: Checkout)
    {
        val orderId = checkout.backendOrderId ?: return
        stopPaymentChecks()
        val current = mPayment.value
        if (current?.checkoutId == checkout.id &&
            current.orderId == orderId &&
            !current.paid &&
            current.error == null)
        {
            checkJob = checkPayment(orderId)
            checkTimer.start()
            return
        }
        val spec = configManager.currencySpec
        val open = checkout.open.withSpec(spec)
        val order =
            Order(
                id = CHECKOUT_DISPLAY_ORDER_ID,
                currency = open.currency,
                currencySpec = spec,
                availableCategories = emptyMap(),
                products =
                    listOf(
                        ConfigProduct(
                            description = checkout.contractTerms.summary,
                            price = open,
                            categories = listOf(Int.MIN_VALUE),
                            quantity = 1,
                        ),
                    ),
            )
        mPayment.value =
            Payment(
                order = order,
                summary = checkout.contractTerms.summary,
                currency = open.currency,
                orderId = orderId,
                checkoutId = checkout.id,
                // Nothing is left for the wallet, so the merchant can finish it themselves.
                selfCollect = checkout.open.isZero(),
                statusLoading = true,
            )
        checkJob = checkPayment(orderId)
        checkTimer.start()
    }

    @UiThread
    internal fun debugSetPayment(payment: Payment)
    {
        checkTimer.cancel()
        checkJob = null
        mPayment.value = payment
    }

    private fun checkPayment(orderId: String) =
        scope.launch {
            val merchantConfig = configManager.merchantConfig!!
            api.checkOrder(merchantConfig, orderId).handle(
                { error ->
                    // don't call onNetworkError() to not cancel payment, just keep trying
                    Log.d(TAG, "Network error: $error")
                }
            ) { response ->
                assertUiThread()
                if (!isActive) return@handle // don't continue if job was cancelled
                val currentValue = mPayment.value
                if (currentValue?.orderId != orderId)
                {
                    Log.d(TAG, "Ignoring stale check result for order $orderId")
                    return@handle
                }
                val updated =
                    currentValue.copy(
                        statusLoading = false,
                        merchantName = response.paymentDetails?.merchant?.name ?: currentValue.merchantName,
                        payDeadline = response.paymentDetails?.payDeadline ?: currentValue.payDeadline,
                    )
                when (response)
                {
                    is CheckPaymentResponse.Unpaid ->
                    {
                        val upgradedUri = upgradeTalerPayUriToHttps(response.talerPayUri)
                        mPayment.value =
                            when
                            {
                                upgradedUri == null ->
                                {
                                    updated.copy(
                                        talerPayUri = response.talerPayUri,
                                        insecureTalerPayUri = null,
                                    )
                                }

                                currentValue.httpsUpgradeAccepted ->
                                {
                                    updated.copy(
                                        talerPayUri = upgradedUri,
                                        insecureTalerPayUri = null,
                                    )
                                }

                                currentValue.insecureUriAccepted ->
                                {
                                    updated.copy(
                                        talerPayUri = response.talerPayUri,
                                        insecureTalerPayUri = null,
                                    )
                                }

                                else ->
                                {
                                    updated.copy(
                                        talerPayUri = null,
                                        insecureTalerPayUri = response.talerPayUri,
                                    )
                                }
                            }
                    }

                    is CheckPaymentResponse.Claimed ->
                    {
                        mPayment.value = updated.copy(claimed = true)
                    }

                    is CheckPaymentResponse.Paid ->
                    {
                        mPayment.value = updated.copy(paid = true)
                        checkTimer.cancel()
                    }
                }
            }
        }

    @UiThread
    fun tryHttpsPaymentUri()
    {
        val currentValue = mPayment.value ?: return
        val insecureUri = currentValue.insecureTalerPayUri ?: return
        val upgradedUri = upgradeTalerPayUriToHttps(insecureUri) ?: return
        mPayment.value =
            currentValue.copy(
                talerPayUri = upgradedUri,
                insecureTalerPayUri = null,
                httpsUpgradeAccepted = true,
                insecureUriAccepted = false,
            )
    }

    @UiThread
    fun continueWithInsecurePaymentUri()
    {
        val currentValue = mPayment.value ?: return
        val insecureUri = currentValue.insecureTalerPayUri ?: return
        mPayment.value =
            currentValue.copy(
                talerPayUri = insecureUri,
                insecureTalerPayUri = null,
                httpsUpgradeAccepted = false,
                insecureUriAccepted = true,
            )
    }

    private fun onNetworkError(error: String)
    {
        assertUiThread()
        Log.d(TAG, "Network error: $error")
        cancelPayment(error)
    }

    private fun looksLikeInventoryError(error: String): Boolean
    {
        val normalized = error.lowercase()
        return "inventory" in normalized ||
            "stock" in normalized ||
            "insufficient" in normalized ||
            "sold out" in normalized ||
            "out of stock" in normalized
    }

    @UiThread
    fun onOrderDeleted(orderId: String)
    {
        if (mPayment.value?.orderId != orderId) return
        stopPaymentChecks()
        mPayment.value = null
    }

    fun cancelPayment(error: String? = null)
    {
        stopPaymentChecks()
        val merchantConfig = configManager.merchantConfig!!
        mPayment.value?.let { payment ->
            // A checkout order records cash or card already taken, so it stays for resuming.
            if (!payment.paid &&
                payment.checkoutId == null)
            {
                payment.orderId?.let { orderId ->
                    Log.d(TAG, "Deleting cancelled and unpaid order $orderId")
                    scope.launch {
                        api.deleteOrder(merchantConfig, orderId)
                    }
                }
            }
        }
        mPayment.value?.copy(error = error)?.let {
            mPayment.value = it
        }
    }

    @UiThread
    fun tryDeleteOrder()
    {
        stopPaymentChecks()
        if (mPayment.value?.checkoutId != null)
        {
            // Leave the checkout order alone and simply go back to the checkout.
            mDeleteNeedsForce.value = false
            return
        }
        val merchantConfig = configManager.merchantConfig!!
        val orderId = mPayment.value?.orderId ?: return
        scope.launch {
            val result = api.deleteOrder(merchantConfig, orderId)
            result.handle(
                onFailure = { errorStr ->
                    Log.w(TAG, "Delete order $orderId failed: $errorStr")
                    mDeleteNeedsForce.postValue(true)
                },
                onSuccess = {
                    Log.d(TAG, "Delete order $orderId succeeded")
                    mPayment.postValue(mPayment.value?.copy(error = null))
                    mDeleteNeedsForce.postValue(false)
                },
            )
        }
    }

    @UiThread
    fun forceDeleteOrder()
    {
        val merchantConfig = configManager.merchantConfig!!
        val orderId = mPayment.value?.orderId ?: return
        Log.d(TAG, "Force deleting claimed order $orderId")
        scope.launch {
            api.deleteOrder(merchantConfig, orderId, force = true).handle(
                onFailure = { err -> Log.w(TAG, "Force delete of order $orderId failed: $err") },
                onSuccess = { Log.d(TAG, "Force delete of order $orderId succeeded") },
            )
        }
        mPayment.value = mPayment.value?.copy(error = null)
        mDeleteNeedsForce.value = null
    }

    fun clearDeleteNeedsForce()
    {
        mDeleteNeedsForce.value = null
    }

    private fun stopPaymentChecks()
    {
        checkTimer.cancel()
        checkJob?.cancel()
        checkJob = null
    }
}