taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

PaymentManager.kt (9578B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2020 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.merchantpos.payment
     18 
     19 import android.content.Context
     20 import android.os.CountDownTimer
     21 import android.util.Log
     22 import androidx.annotation.UiThread
     23 import androidx.lifecycle.LiveData
     24 import androidx.lifecycle.MutableLiveData
     25 import kotlinx.coroutines.CoroutineScope
     26 import kotlinx.coroutines.Job
     27 import kotlinx.coroutines.isActive
     28 import kotlinx.coroutines.launch
     29 import kotlinx.serialization.json.Json
     30 import net.taler.common.RelativeTime
     31 import net.taler.lib.android.assertUiThread
     32 import net.taler.merchantlib.CheckPaymentResponse
     33 import net.taler.merchantlib.MerchantApi
     34 import net.taler.merchantlib.MinimalInventoryProduct
     35 import net.taler.merchantlib.OrderHistoryEntry
     36 import net.taler.merchantlib.PostOrderRequest
     37 import net.taler.merchantpos.MainActivity.Companion.TAG
     38 import net.taler.merchantpos.R
     39 import net.taler.merchantpos.config.ConfigManager
     40 import net.taler.merchantpos.config.ConfigProduct
     41 import net.taler.merchantpos.order.Order
     42 import java.util.concurrent.TimeUnit.HOURS
     43 import java.util.concurrent.TimeUnit.SECONDS
     44 
     45 private const val TIMEOUT = Long.MAX_VALUE
     46 private val CHECK_INTERVAL = SECONDS.toMillis(1)
     47 
     48 class PaymentManager(
     49     private val context: Context,
     50     private val configManager: ConfigManager,
     51     private val scope: CoroutineScope,
     52     private val api: MerchantApi,
     53 ) {
     54 
     55     private val mPayment = MutableLiveData<Payment>()
     56     val payment: LiveData<Payment> = mPayment
     57     private var checkJob: Job? = null
     58 
     59     private val checkTimer: CountDownTimer = object : CountDownTimer(TIMEOUT, CHECK_INTERVAL) {
     60         override fun onTick(millisUntilFinished: Long) {
     61             val orderId = payment.value?.orderId
     62             if (orderId == null) cancel()
     63             // only start new job if old one doesn't exist or is complete
     64             else if (checkJob == null || checkJob?.isCompleted == true) {
     65                 checkJob = checkPayment(orderId)
     66             }
     67         }
     68 
     69         override fun onFinish() {
     70             cancelPayment(context.getString(R.string.error_timeout))
     71         }
     72     }
     73 
     74     @UiThread
     75     fun createPayment(order: Order, includeProducts: Boolean = true) = scope.launch {
     76         val merchantConfig = configManager.merchantConfig!!
     77         mPayment.value = Payment(order, order.summary, configManager.currency!!)
     78         val inventoryProducts = if (includeProducts) {
     79             order.products.mapNotNull { product ->
     80                 val productId = product.productId ?: return@mapNotNull null
     81                 MinimalInventoryProduct(productId = productId, quantity = product.quantity)
     82             }
     83         } else {
     84             emptyList()
     85         }
     86         val useInventoryProducts = includeProducts && inventoryProducts.isNotEmpty()
     87         val request = PostOrderRequest(
     88             contractTerms = order.toContractTerms(
     89                 includeProducts = includeProducts && !useInventoryProducts
     90             ),
     91             refundDelay = RelativeTime.fromMillis(HOURS.toMillis(1))
     92             ,
     93             inventoryProducts = if (useInventoryProducts) inventoryProducts else null,
     94         )
     95         val requestJson = Json {
     96             encodeDefaults = false
     97             ignoreUnknownKeys = true
     98         }.encodeToString(request)
     99         Log.d(TAG, "PostOrderRequest: $requestJson")
    100         api.postOrder(merchantConfig, request).handle({ error ->
    101             if (looksLikeInventoryError(error)) {
    102                 configManager.refreshInventory()
    103             }
    104             onNetworkError(error)
    105         }) { orderResponse ->
    106             assertUiThread()
    107             mPayment.value = mPayment.value!!.copy(orderId = orderResponse.orderId)
    108             checkTimer.start()
    109         }
    110     }
    111 
    112     @UiThread
    113     fun resumePayment(item: OrderHistoryEntry) {
    114         val current = mPayment.value
    115         if (current?.orderId == item.orderId && !current.paid && current.error == null) {
    116             if (checkJob == null || checkJob?.isCompleted == true) {
    117                 checkJob = checkPayment(item.orderId)
    118             }
    119             checkTimer.start()
    120             return
    121         }
    122 
    123         val order = Order(
    124             id = -2,
    125             currency = item.amount.currency,
    126             currencySpec = item.amount.spec,
    127             availableCategories = emptyMap(),
    128             products = listOf(
    129                 ConfigProduct(
    130                     description = item.summary,
    131                     price = item.amount,
    132                     categories = listOf(Int.MIN_VALUE),
    133                     quantity = 1,
    134                 )
    135             ),
    136         )
    137         mPayment.value = Payment(
    138             order = order,
    139             summary = item.summary,
    140             currency = item.amount.currency,
    141             orderId = item.orderId,
    142             paid = item.paid,
    143         )
    144         if (!item.paid) {
    145             checkJob = checkPayment(item.orderId)
    146             checkTimer.start()
    147         }
    148     }
    149 
    150     @UiThread
    151     internal fun debugSetPayment(payment: Payment) {
    152         checkTimer.cancel()
    153         checkJob = null
    154         mPayment.value = payment
    155     }
    156 
    157     private fun checkPayment(orderId: String) = scope.launch {
    158         val merchantConfig = configManager.merchantConfig!!
    159         api.checkOrder(merchantConfig, orderId).handle({ error ->
    160             // don't call onNetworkError() to not cancel payment, just keep trying
    161             Log.d(TAG, "Network error: $error")
    162         }) { response ->
    163             assertUiThread()
    164             if (!isActive) return@handle // don't continue if job was cancelled
    165             val currentValue = requireNotNull(mPayment.value)
    166             when (response) {
    167                 is CheckPaymentResponse.Unpaid -> {
    168                     mPayment.value = currentValue.copy(talerPayUri = response.talerPayUri)
    169                 }
    170                 is CheckPaymentResponse.Claimed -> {
    171                     mPayment.value = currentValue.copy(claimed = true)
    172                 }
    173                 is CheckPaymentResponse.Paid -> {
    174                     mPayment.value = currentValue.copy(paid = true)
    175                     checkTimer.cancel()
    176                 }
    177             }
    178         }
    179     }
    180 
    181     private fun onNetworkError(error: String) {
    182         assertUiThread()
    183         Log.d(TAG, "Network error: $error")
    184         cancelPayment(error)
    185     }
    186 
    187     private fun looksLikeInventoryError(error: String): Boolean {
    188         val normalized = error.lowercase()
    189         return "inventory" in normalized ||
    190             "stock" in normalized ||
    191             "insufficient" in normalized ||
    192             "sold out" in normalized ||
    193             "out of stock" in normalized
    194     }
    195 
    196     @UiThread
    197     fun cancelPayment(error: String? = null) {
    198         stopPaymentChecks()
    199         val merchantConfig = configManager.merchantConfig!!
    200         mPayment.value?.let { payment ->
    201             if (!payment.paid) payment.orderId?.let { orderId ->
    202                 Log.d(TAG, "Deleting cancelled and unpaid order $orderId")
    203                 scope.launch {
    204                     api.deleteOrder(merchantConfig, orderId)
    205                 }
    206             }
    207         }
    208         mPayment.value?.copy(error = error)?.let {
    209             mPayment.value = it
    210         }
    211     }
    212 
    213     private val mDeleteNeedsForce = MutableLiveData<Boolean?>(null)
    214     val deleteNeedsForce: LiveData<Boolean?> = mDeleteNeedsForce
    215 
    216     @UiThread
    217     fun tryDeleteOrder() {
    218         stopPaymentChecks()
    219         val merchantConfig = configManager.merchantConfig!!
    220         val orderId = mPayment.value?.orderId ?: return
    221         scope.launch {
    222             val result = api.deleteOrder(merchantConfig, orderId)
    223             result.handle(
    224                 onFailure = { errorStr ->
    225                     Log.w(TAG, "Delete order $orderId failed: $errorStr")
    226                     mDeleteNeedsForce.postValue(true)
    227                 },
    228                 onSuccess = {
    229                     Log.d(TAG, "Delete order $orderId succeeded")
    230                     mPayment.postValue(mPayment.value?.copy(error = null))
    231                     mDeleteNeedsForce.postValue(false)
    232                 },
    233             )
    234         }
    235     }
    236 
    237     @UiThread
    238     fun forceDeleteOrder() {
    239         val merchantConfig = configManager.merchantConfig!!
    240         val orderId = mPayment.value?.orderId ?: return
    241         Log.d(TAG, "Force deleting claimed order $orderId")
    242         scope.launch {
    243             api.deleteOrder(merchantConfig, orderId, force = true).handle(
    244                 onFailure = { err -> Log.w(TAG, "Force delete of order $orderId failed: $err") },
    245                 onSuccess = { Log.d(TAG, "Force delete of order $orderId succeeded") },
    246             )
    247         }
    248         mPayment.value = mPayment.value?.copy(error = null)
    249         mDeleteNeedsForce.value = null
    250     }
    251 
    252     fun clearDeleteNeedsForce() {
    253         mDeleteNeedsForce.value = null
    254     }
    255 
    256     private fun stopPaymentChecks() {
    257         checkTimer.cancel()
    258         checkJob?.isCancelled
    259         checkJob = null
    260     }
    261 }