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