RefundManager.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.refund

import androidx.annotation.UiThread
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData

import net.taler.common.Amount
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.RefundRequest
import net.taler.merchantpos.checkout.CheckoutManager
import net.taler.merchantpos.checkout.ExternalRefundResult
import net.taler.merchantpos.config.ConfigManager

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

sealed class RefundResult
{
    class Error(
        val msg: String,
    ) : RefundResult()

    class ExternalCompleted(
        val warning: String? = null,
    ) : RefundResult()

    object PastDeadline : RefundResult()

    object AlreadyRefunded : RefundResult()

    class Success(
        val refundUri: String,
        val item: OrderHistoryEntry,
        val amount: Amount,
        val reason: String,
    ) : RefundResult()
}

class RefundManager(
    private val configManager: ConfigManager,
    private val scope: CoroutineScope,
    private val api: MerchantApi,
)
{
    private var refundStatusJob: Job? = null
    private var refundMerchantUrl: String? = null
    private val mSubmitting = MutableLiveData(false)
    private val mRefundResult = MutableLiveData<RefundResult?>()
    private val mPendingRefundOrderId = MutableLiveData<String?>(null)
    private val mRefundReceived = MutableLiveData(false)

    internal val submitting: LiveData<Boolean> = mSubmitting
    internal val refundResult: LiveData<RefundResult?> = mRefundResult
    internal val pendingRefundOrderId: LiveData<String?> = mPendingRefundOrderId
    internal val refundReceived: LiveData<Boolean> = mRefundReceived

    internal var preferTaler: Boolean = false
        private set

    var toBeRefunded: OrderHistoryEntry? = null
        private set

    @UiThread
    internal fun startRefund(
        item: OrderHistoryEntry,
        preferTaler: Boolean = false,
    )
    {
        if (mSubmitting.value == true) return
        this.preferTaler = preferTaler
        refundStatusJob?.cancel()
        toBeRefunded = item
        mRefundResult.value = null
        mPendingRefundOrderId.value = null
        mRefundReceived.value = false
    }

    @UiThread
    internal fun abortRefund()
    {
        refundStatusJob?.cancel()
        toBeRefunded = null
        mRefundResult.value = null
        mPendingRefundOrderId.value = null
        mRefundReceived.value = false
    }

    @UiThread
    internal fun completeRefund()
    {
        refundStatusJob?.cancel()
        toBeRefunded = null
        mRefundResult.value = null
        mPendingRefundOrderId.value = null
        mRefundReceived.value = false
    }

    @UiThread
    internal fun debugSetRefundResult(result: RefundResult)
    {
        mRefundResult.value = result
    }

    @UiThread
    internal fun resumeRefund(item: OrderHistoryEntry): Boolean
    {
        val merchant = configManager.merchantConfig ?: return false
        val current = mRefundResult.value as? RefundResult.Success
        if (current?.item?.orderId != item.orderId || refundMerchantUrl != merchant.baseUrl)
        {
            if (!item.paid || !item.hasPendingRefund) return false
            val amount = item.pendingRefundAmount ?: item.refundAmount ?: return false
            if (amount <= Amount.zero(item.amount.currency)) return false
            val uri = refundUri(merchant.baseUrl, item.orderId) ?: return false
            // Recover an already approved refund; never POST another refund here.
            mRefundResult.value = RefundResult.Success(uri, item, amount.withSpec(configManager.currencySpec), "")
            refundMerchantUrl = merchant.baseUrl
            mRefundReceived.value = false
        }
        toBeRefunded = item
        mPendingRefundOrderId.value = item.orderId
        if (mRefundReceived.value != true)
        {
            observeRefundStatus(item.orderId)
        }
        return true
    }

    @UiThread
    internal fun refund(
        item: OrderHistoryEntry,
        amount: Amount,
        reason: String,
    ) = scope.launch {
        if (mSubmitting.value == true) return@launch
        val merchantConfig = configManager.merchantConfig ?: return@launch
        if (amount.currency != item.amount.currency ||
            amount <= Amount.zero(item.amount.currency) ||
            amount > item.talerRefundRemaining())
        {
            mRefundResult.value = RefundResult.AlreadyRefunded
            return@launch
        }
        mSubmitting.value = true
        val request = RefundRequest((item.refundAmount ?: Amount.zero(amount.currency)) + amount, reason)
        try
        {
            api.giveRefund(merchantConfig, item.orderId, request).handle(::onRefundError) {
                assertUiThread()
                refundMerchantUrl = merchantConfig.baseUrl
                mRefundResult.value =
                    RefundResult.Success(
                        refundUri = it.talerRefundUri,
                        item = item,
                        amount = amount,
                        reason = reason,
                    )
                mPendingRefundOrderId.value = item.orderId
                mRefundReceived.value = false
                observeRefundStatus(item.orderId)
            }
        }
        finally
        {
            mSubmitting.value = false
        }
    }

    @UiThread
    internal fun recordExternalRefund(
        checkoutManager: CheckoutManager,
        item: OrderHistoryEntry,
        checkoutId: String,
        paymentId: String,
        amount: Amount,
        reason: String,
    ) = scope.launch {
        if (mSubmitting.value == true) return@launch
        val checkout = checkoutManager.find(checkoutId) ?: return@launch
        val payment = item.amountExternal.orEmpty().find { it.id == paymentId } ?: return@launch
        if (!item.paid ||
            checkout.backendOrderId != item.orderId ||
            amount.currency != payment.amount.currency ||
            amount <= Amount.zero(payment.amount.currency) ||
            amount > item.externalRefundRemaining(payment, checkout))
        {
            return@launch
        }
        mSubmitting.value = true
        try
        {
            mRefundResult.value =
                when (val result = checkoutManager.recordManualRefund(checkoutId, paymentId, amount, reason))
                {
                    ExternalRefundResult.Done -> RefundResult.ExternalCompleted()
                    is ExternalRefundResult.NotRecorded -> RefundResult.ExternalCompleted(result.message)
                    is ExternalRefundResult.Failed -> RefundResult.Error(result.message)
                }
        }
        finally
        {
            mSubmitting.value = false
        }
    }

    @UiThread
    internal fun clearResult()
    {
        mRefundResult.value = null
    }

    @UiThread
    private fun onRefundError(msg: String)
    {
        assertUiThread()
        refundStatusJob?.cancel()
        mPendingRefundOrderId.postValue(null)
        mRefundReceived.postValue(false)
        if (msg.contains("2602"))
        {
            mRefundResult.postValue(RefundResult.AlreadyRefunded)
        }
        else
        {
            mRefundResult.postValue(RefundResult.Error(msg))
        }
    }

    @UiThread
    private fun observeRefundStatus(orderId: String)
    {
        refundStatusJob?.cancel()
        refundStatusJob =
            scope.launch {
                val merchantConfig = configManager.merchantConfig ?: return@launch
                while (true)
                {
                    var wasRefunded = false
                    api.checkOrder(merchantConfig, orderId).handle(null) { response ->
                        assertUiThread()
                        val paidResponse = response as? CheckPaymentResponse.Paid
                        if (paidResponse != null &&
                            paidResponse.refunded &&
                            !paidResponse.refundPending &&
                            paidResponse.refundAmount?.isZero() == false)
                        {
                            mRefundReceived.value = true
                            wasRefunded = true
                        }
                    }
                    if (wasRefunded ||
                        mRefundReceived.value == true)
                    {
                        break
                    }
                    delay(2_000)
                }
            }
    }
}

/** Standard refund URI, scoped to the currently configured merchant instance. */
internal fun refundUri(merchantUrl: String, orderId: String): String?
{
    val base = merchantUrl.toUri()
    val scheme =
        when (base.scheme)
        {
            "https" -> "taler"
            "http" -> "taler+http"
            else -> return null
        }
    if (base.host.isNullOrBlank() || base.userInfo != null || orderId.isBlank()) return null
    val path = base.encodedPath.orEmpty().trimEnd('/')
    return "$scheme://refund/${base.encodedAuthority}$path/"
        .toUri()
        .buildUpon()
        .appendPath(orderId)
        .appendPath("")
        .build()
        .toString()
}