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()
}