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 }