diff options
Diffstat (limited to 'wallet/src/main/java/net/taler/wallet/payment')
9 files changed, 785 insertions, 122 deletions
diff --git a/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt deleted file mode 100644 index df2b2b8..0000000 --- a/wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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.wallet.payment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import net.taler.wallet.databinding.FragmentAlreadyPaidBinding - -/** - * Display the message that the user already paid for the order - * that the merchant is proposing. - */ -class AlreadyPaidFragment : Fragment() { - - private lateinit var ui: FragmentAlreadyPaidBinding - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - ui = FragmentAlreadyPaidBinding.inflate(inflater, container, false) - return ui.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - ui.backButton.setOnClickListener { - findNavController().navigateUp() - } - } - -} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt new file mode 100644 index 0000000..ffa4875 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt @@ -0,0 +1,184 @@ +/* + * This file is part of GNU Taler + * (C) 2023 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.wallet.payment + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.common.Amount +import net.taler.common.ContractTerms +import net.taler.wallet.AmountResult +import net.taler.wallet.R +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface + +sealed class AmountFieldStatus { + object FixedAmount : AmountFieldStatus() + class Default( + val amountStr: String? = null, + val currency: String? = null, + ) : AmountFieldStatus() + + object Invalid : AmountFieldStatus() +} + +@Composable +fun PayTemplateComposable( + defaultSummary: String?, + amountStatus: AmountFieldStatus, + currencies: List<String>, + payStatus: PayStatus, + onCreateAmount: (String, String) -> AmountResult, + onSubmit: (summary: String?, amount: Amount?) -> Unit, + onError: (resId: Int) -> Unit, +) { + // If wallet is empty, there's no way the user can pay something + if (amountStatus is AmountFieldStatus.Invalid) { + PayTemplateError(stringResource(R.string.receive_amount_invalid)) + } else if (currencies.isEmpty()) { + PayTemplateError(stringResource(R.string.payment_balance_insufficient)) + } else when (val p = payStatus) { + is PayStatus.None -> PayTemplateOrderComposable( + currencies = currencies, + defaultSummary = defaultSummary, + amountStatus = amountStatus, + onCreateAmount = onCreateAmount, + onError = onError, + onSubmit = onSubmit, + ) + + is PayStatus.Loading -> PayTemplateLoading() + is PayStatus.AlreadyPaid -> PayTemplateError(stringResource(R.string.payment_already_paid)) + is PayStatus.InsufficientBalance -> PayTemplateError(stringResource(R.string.payment_balance_insufficient)) + is PayStatus.Pending -> { + val error = p.error + PayTemplateError(if (error != null) { + stringResource(R.string.payment_error, error.userFacingMsg) + } else { + stringResource(R.string.payment_template_error) + }) + } + is PayStatus.Prepared -> {} // handled in fragment, will redirect + is PayStatus.Success -> {} // handled by other UI flow, no need for content here + } +} + +@Composable +fun PayTemplateError(message: String) { + Box( + modifier = Modifier.padding(16.dp).fillMaxSize(), + contentAlignment = Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.error, + ) + } +} + +@Composable +fun PayTemplateLoading() { + LoadingScreen() +} + +@Preview +@Composable +fun PayTemplateLoadingPreview() { + TalerSurface { + PayTemplateComposable( + defaultSummary = "Donation", + amountStatus = AmountFieldStatus.Default("20", "ARS"), + payStatus = PayStatus.Loading, + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { _ -> }, + ) + } +} + +@Preview +@Composable +fun PayTemplateInsufficientBalancePreview() { + TalerSurface { + PayTemplateComposable( + defaultSummary = "Donation", + amountStatus = AmountFieldStatus.Default("20", "ARS"), + payStatus = PayStatus.InsufficientBalance( + ContractTerms( + "test", + amount = Amount.zero("TESTKUDOS"), + products = emptyList() + ), Amount.zero("TESTKUDOS") + ), + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { _ -> }, + ) + } +} + +@Preview(widthDp = 300) +@Composable +fun PayTemplateAlreadyPaidPreview() { + TalerSurface { + PayTemplateComposable( + defaultSummary = "Donation", + amountStatus = AmountFieldStatus.Default("20", "ARS"), + payStatus = PayStatus.AlreadyPaid(transactionId = "transactionId"), + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { _ -> }, + ) + } +} + + +@Preview +@Composable +fun PayTemplateNoCurrenciesPreview() { + TalerSurface { + PayTemplateComposable( + defaultSummary = "Donation", + amountStatus = AmountFieldStatus.Default("20", "ARS"), + payStatus = PayStatus.None, + currencies = emptyList(), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { _ -> }, + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt new file mode 100644 index 0000000..4eb2c11 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt @@ -0,0 +1,115 @@ +/* + * This file is part of GNU Taler + * (C) 2023 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.wallet.payment + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.asFlow +import androidx.navigation.fragment.findNavController +import net.taler.common.Amount +import net.taler.common.showError +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.showError + +class PayTemplateFragment : Fragment() { + + private val model: MainViewModel by activityViewModels() + private lateinit var uriString: String + private lateinit var uri: Uri + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + uriString = arguments?.getString("uri") ?: error("no amount passed") + uri = Uri.parse(uriString) + + val defaultSummary = uri.getQueryParameter("summary") + val defaultAmount = uri.getQueryParameter("amount") + val amountFieldStatus = getAmountFieldStatus(defaultAmount) + + val payStatusFlow = model.paymentManager.payStatus.asFlow() + + return ComposeView(requireContext()).apply { + setContent { + val payStatus = payStatusFlow.collectAsStateLifecycleAware(initial = PayStatus.None) + TalerSurface { + PayTemplateComposable( + currencies = model.getCurrencies(), + defaultSummary = defaultSummary, + amountStatus = amountFieldStatus, + payStatus = payStatus.value, + onCreateAmount = model::createAmount, + onSubmit = this@PayTemplateFragment::createOrder, + onError = { this@PayTemplateFragment.showError(it) }, + ) + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (uri.queryParameterNames?.isEmpty() == true) { + createOrder(null, null) + } + + model.paymentManager.payStatus.observe(viewLifecycleOwner) { payStatus -> + when (payStatus) { + is PayStatus.Prepared -> { + findNavController().navigate(R.id.action_promptPayTemplate_to_promptPayment) + } + + is PayStatus.Pending -> if (payStatus.error != null && model.devMode.value == true) { + showError(payStatus.error) + } + + else -> {} + } + } + } + + private fun getAmountFieldStatus(defaultAmount: String?): AmountFieldStatus { + return if (defaultAmount == null) { + AmountFieldStatus.FixedAmount + } else if (defaultAmount.isBlank()) { + AmountFieldStatus.Default() + } else { + val parts = defaultAmount.split(":") + when (parts.size) { + 0 -> AmountFieldStatus.Default() + 1 -> AmountFieldStatus.Default(currency = parts[0]) + 2 -> AmountFieldStatus.Default(parts[1], parts[0]) + else -> AmountFieldStatus.Invalid + } + } + } + + private fun createOrder(summary: String?, amount: Amount?) { + model.paymentManager.preparePayForTemplate(uriString, summary, amount) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt new file mode 100644 index 0000000..d6131c7 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt @@ -0,0 +1,177 @@ +/* + * This file is part of GNU Taler + * (C) 2023 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.wallet.payment + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.common.Amount +import net.taler.wallet.AmountResult +import net.taler.wallet.R +import net.taler.wallet.compose.AmountInputField +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.deposit.CurrencyDropdown + +@Composable +fun PayTemplateOrderComposable( + currencies: List<String>, // assumed to have size > 0 + defaultSummary: String? = null, + amountStatus: AmountFieldStatus, + onCreateAmount: (String, String) -> AmountResult, + onError: (msgRes: Int) -> Unit, + onSubmit: (summary: String?, amount: Amount?) -> Unit, +) { + val amountDefault = amountStatus as? AmountFieldStatus.Default + + var summary by remember { mutableStateOf(defaultSummary) } + var currency by remember { mutableStateOf(amountDefault?.currency ?: currencies[0]) } + var amount by remember { mutableStateOf(amountDefault?.amountStr ?: "0") } + + Column(horizontalAlignment = End) { + if (defaultSummary != null) OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + value = summary ?: "", + isError = summary.isNullOrBlank(), + onValueChange = { summary = it }, + singleLine = true, + label = { Text(stringResource(R.string.withdraw_manual_ready_subject)) }, + ) + if (amountDefault != null) AmountField( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + amount = amount, + currency = currency, + currencies = currencies, + fixedCurrency = (amountStatus as? AmountFieldStatus.Default)?.currency != null, + onAmountChosen = { a, c -> + amount = a + currency = c + }, + ) + Button( + modifier = Modifier.padding(16.dp), + enabled = defaultSummary == null || !summary.isNullOrBlank(), + onClick = { + when (val res = onCreateAmount(amount, currency)) { + is AmountResult.InsufficientBalance -> onError(R.string.payment_balance_insufficient) + is AmountResult.InvalidAmount -> onError(R.string.receive_amount_invalid) + is AmountResult.Success -> onSubmit(summary, res.amount) + } + }, + ) { + Text(stringResource(R.string.payment_create_order)) + } + } +} + +@Composable +private fun AmountField( + modifier: Modifier = Modifier, + currencies: List<String>, + fixedCurrency: Boolean, + amount: String, + currency: String, + onAmountChosen: (amount: String, currency: String) -> Unit, +) { + Row( + modifier = modifier, + ) { + AmountInputField( + modifier = Modifier + .padding(end = 16.dp) + .weight(1f), + value = amount, + onValueChange = { onAmountChosen(it, currency) }, + label = { Text(stringResource(R.string.send_amount)) } + ) + CurrencyDropdown( + modifier = Modifier.weight(1f), + initialCurrency = currency, + currencies = currencies, + onCurrencyChanged = { onAmountChosen(amount, it) }, + readOnly = fixedCurrency, + ) + } +} + +@Preview +@Composable +fun PayTemplateDefaultPreview() { + TalerSurface { + PayTemplateOrderComposable( + defaultSummary = "Donation", + amountStatus = AmountFieldStatus.Default("20", "ARS"), + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { }, + ) + } +} + +@Preview +@Composable +fun PayTemplateFixedAmountPreview() { + TalerSurface { + PayTemplateOrderComposable( + defaultSummary = "default summary", + amountStatus = AmountFieldStatus.FixedAmount, + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { }, + ) + } +} + +@Preview +@Composable +fun PayTemplateBlankSubjectPreview() { + TalerSurface { + PayTemplateOrderComposable( + defaultSummary = "", + amountStatus = AmountFieldStatus.FixedAmount, + currencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _, _ -> }, + onError = { }, + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt index 34a8023..35cd9e6 100644 --- a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt +++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -22,16 +22,17 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import net.taler.common.Amount import net.taler.common.ContractTerms -import net.taler.lib.common.Amount import net.taler.wallet.TAG -import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.payment.PayStatus.AlreadyPaid import net.taler.wallet.payment.PayStatus.InsufficientBalance import net.taler.wallet.payment.PreparePayResponse.AlreadyConfirmedResponse import net.taler.wallet.payment.PreparePayResponse.InsufficientBalanceResponse import net.taler.wallet.payment.PreparePayResponse.PaymentPossibleResponse +import org.json.JSONObject val REGEX_PRODUCT_IMAGE = Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$") @@ -40,20 +41,28 @@ sealed class PayStatus { object Loading : PayStatus() data class Prepared( val contractTerms: ContractTerms, - val proposalId: String, + val transactionId: String, val amountRaw: Amount, - val amountEffective: Amount + val amountEffective: Amount, ) : PayStatus() data class InsufficientBalance( val contractTerms: ContractTerms, - val amountRaw: Amount + val amountRaw: Amount, ) : PayStatus() - // TODO bring user to fulfilment URI - object AlreadyPaid : PayStatus() - data class Error(val error: String) : PayStatus() - data class Success(val currency: String) : PayStatus() + data class AlreadyPaid( + val transactionId: String, + ) : PayStatus() + + data class Pending( + val transactionId: String? = null, + val error: TalerErrorInfo? = null, + ) : PayStatus() + data class Success( + val transactionId: String, + val currency: String, + ) : PayStatus() } class PaymentManager( @@ -67,50 +76,65 @@ class PaymentManager( @UiThread fun preparePay(url: String) = scope.launch { mPayStatus.value = PayStatus.Loading - api.request("preparePay", PreparePayResponse.serializer()) { + api.request("preparePayForUri", PreparePayResponse.serializer()) { put("talerPayUri", url) }.onError { - handleError("preparePay", it) + handleError("preparePayForUri", it) }.onSuccess { response -> mPayStatus.value = when (response) { is PaymentPossibleResponse -> response.toPayStatusPrepared() is InsufficientBalanceResponse -> InsufficientBalance( - response.contractTerms, - response.amountRaw + contractTerms = response.contractTerms, + amountRaw = response.amountRaw + ) + is AlreadyConfirmedResponse -> AlreadyPaid( + transactionId = response.transactionId, ) - is AlreadyConfirmedResponse -> AlreadyPaid } } } - fun confirmPay(proposalId: String, currency: String) = scope.launch { + fun confirmPay(transactionId: String, currency: String) = scope.launch { api.request("confirmPay", ConfirmPayResult.serializer()) { - put("proposalId", proposalId) + put("transactionId", transactionId) }.onError { handleError("confirmPay", it) - }.onSuccess { - mPayStatus.postValue(PayStatus.Success(currency)) - } - } - - @UiThread - fun abortPay() { - val ps = payStatus.value - if (ps is PayStatus.Prepared) { - abortProposal(ps.proposalId) + }.onSuccess { response -> + mPayStatus.postValue(when (response) { + is ConfirmPayResult.Done -> PayStatus.Success( + transactionId = response.transactionId, + currency = currency, + ) + is ConfirmPayResult.Pending -> PayStatus.Pending( + transactionId = response.transactionId, + error = response.lastError, + ) + }) } - resetPayStatus() } - internal fun abortProposal(proposalId: String) = scope.launch { - Log.i(TAG, "aborting proposal") - api.request<Unit>("abortProposal") { - put("proposalId", proposalId) + fun preparePayForTemplate(url: String, summary: String?, amount: Amount?) = scope.launch { + mPayStatus.value = PayStatus.Loading + api.request("preparePayForTemplate", PreparePayResponse.serializer()) { + put("talerPayTemplateUri", url) + put("templateParams", JSONObject().apply { + summary?.let { put("summary", it) } + amount?.let { put("amount", it.toJSONString()) } + }) }.onError { - Log.e(TAG, "received error response to abortProposal") - handleError("abortProposal", it) - }.onSuccess { - mPayStatus.postValue(PayStatus.None) + handleError("preparePayForTemplate", it) + }.onSuccess { response -> + mPayStatus.value = when (response) { + is PaymentPossibleResponse -> response.toPayStatusPrepared() + is InsufficientBalanceResponse -> InsufficientBalance( + contractTerms = response.contractTerms, + amountRaw = response.amountRaw, + ) + + is AlreadyConfirmedResponse -> AlreadyPaid( + transactionId = response.transactionId, + ) + } } } @@ -121,7 +145,7 @@ class PaymentManager( private fun handleError(operation: String, error: TalerErrorInfo) { Log.e(TAG, "got $operation error result $error") - mPayStatus.value = PayStatus.Error(error.userFacingMsg) + mPayStatus.value = PayStatus.Pending(error = error) } } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt index 4b908b5..407f55f 100644 --- a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt +++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt @@ -16,30 +16,30 @@ package net.taler.wallet.payment +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator +import net.taler.common.Amount import net.taler.common.ContractTerms -import net.taler.lib.android.CustomClassDiscriminator -import net.taler.lib.common.Amount import net.taler.wallet.backend.TalerErrorInfo +@OptIn(ExperimentalSerializationApi::class) @Serializable +@JsonClassDiscriminator("status") sealed class PreparePayResponse { - companion object : CustomClassDiscriminator { - override val discriminator: String = "status" - } @Serializable @SerialName("payment-possible") data class PaymentPossibleResponse( - val proposalId: String, + val transactionId: String, val amountRaw: Amount, val amountEffective: Amount, val contractTerms: ContractTerms, ) : PreparePayResponse() { fun toPayStatusPrepared() = PayStatus.Prepared( contractTerms = contractTerms, - proposalId = proposalId, + transactionId = transactionId, amountRaw = amountRaw, amountEffective = amountEffective, ) @@ -48,7 +48,6 @@ sealed class PreparePayResponse { @Serializable @SerialName("insufficient-balance") data class InsufficientBalanceResponse( - val proposalId: String, val amountRaw: Amount, val contractTerms: ContractTerms, ) : PreparePayResponse() @@ -56,13 +55,13 @@ sealed class PreparePayResponse { @Serializable @SerialName("already-confirmed") data class AlreadyConfirmedResponse( - val proposalId: String, + val transactionId: String, /** * Did the payment succeed? */ val paid: Boolean, val amountRaw: Amount, - val amountEffective: Amount, + val amountEffective: Amount? = null, val contractTerms: ContractTerms, ) : PreparePayResponse() } @@ -71,9 +70,15 @@ sealed class PreparePayResponse { sealed class ConfirmPayResult { @Serializable @SerialName("done") - data class Done(val contractTerms: ContractTerms) : ConfirmPayResult() + data class Done( + val transactionId: String, + val contractTerms: ContractTerms, + ) : ConfirmPayResult() @Serializable @SerialName("pending") - data class Pending(val lastError: TalerErrorInfo) : ConfirmPayResult() + data class Pending( + val transactionId: String, + val lastError: TalerErrorInfo? = null, + ) : ConfirmPayResult() } diff --git a/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt b/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt index 87b6387..289f0d7 100644 --- a/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt +++ b/wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt @@ -67,21 +67,25 @@ internal class ProductAdapter(private val listener: ProductImageClickListener) : fun bind(product: ContractProduct) { quantity.text = product.quantity.toString() - if (product.image == null) { + val productImage = product.image + if (productImage == null) { image.visibility = GONE - } else { - image.visibility = VISIBLE - // product.image was validated before, so non-null below - val match = REGEX_PRODUCT_IMAGE.matchEntire(product.image!!)!! - val decodedString = Base64.decode(match.groups[2]!!.value, Base64.DEFAULT) - val bitmap = decodeByteArray(decodedString, 0, decodedString.size) - image.setImageBitmap(bitmap) - image.setOnClickListener { - listener.onImageClick(bitmap) + } else REGEX_PRODUCT_IMAGE.matchEntire(productImage)?.let { match -> + match.groups[2]?.value?.let { group -> + image.visibility = VISIBLE + val decodedString = Base64.decode(group, Base64.DEFAULT) + val bitmap = decodeByteArray(decodedString, 0, decodedString.size) + image.setImageBitmap(bitmap) + image.setOnClickListener { + listener.onImageClick(bitmap) + } } } name.text = product.description - price.text = product.totalPrice.toString() + price.visibility = product.totalPrice?.let { + price.text = it.toString() + VISIBLE + } ?: GONE } } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt index 664dcc9..31c26a0 100644 --- a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt @@ -18,24 +18,29 @@ package net.taler.wallet.payment import android.graphics.Bitmap import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.View.GONE import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.observe +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar.LENGTH_LONG +import kotlinx.coroutines.launch +import net.taler.common.Amount import net.taler.common.ContractTerms import net.taler.common.fadeIn import net.taler.common.fadeOut -import net.taler.lib.common.Amount +import net.taler.common.showError import net.taler.wallet.MainViewModel import net.taler.wallet.R +import net.taler.wallet.TAG import net.taler.wallet.databinding.FragmentPromptPaymentBinding +import net.taler.wallet.showError /** * Show a payment and ask the user to accept/decline. @@ -44,14 +49,15 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { private val model: MainViewModel by activityViewModels() private val paymentManager by lazy { model.paymentManager } + private val transactionManager by lazy { model.transactionManager } private lateinit var ui: FragmentPromptPaymentBinding private val adapter = ProductAdapter(this) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { + savedInstanceState: Bundle?, + ): View { ui = FragmentPromptPaymentBinding.inflate(inflater, container, false) return ui.root } @@ -68,7 +74,15 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { override fun onDestroy() { super.onDestroy() if (!requireActivity().isChangingConfigurations) { - paymentManager.abortPay() + val payStatus = paymentManager.payStatus.value as? PayStatus.Prepared ?: return + transactionManager.abortTransaction(payStatus.transactionId) { error -> + Log.e(TAG, "Error abortTransaction $error") + if (model.devMode.value == false) { + showError(error.userFacingMsg) + } else { + showError(error) + } + } } } @@ -83,6 +97,7 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { private fun onPaymentStatusChanged(payStatus: PayStatus?) { when (payStatus) { + null -> {} is PayStatus.Prepared -> { showLoading(false) val fees = payStatus.amountEffective - payStatus.amountRaw @@ -91,8 +106,8 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { ui.bottom.confirmButton.setOnClickListener { model.showProgressBar.value = true paymentManager.confirmPay( - payStatus.proposalId, - payStatus.contractTerms.amount.currency + transactionId = payStatus.transactionId, + currency = payStatus.contractTerms.amount.currency, ) ui.bottom.confirmButton.fadeOut() ui.bottom.confirmProgressBar.fadeIn() @@ -107,19 +122,24 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { is PayStatus.Success -> { showLoading(false) paymentManager.resetPayStatus() - findNavController().navigate(R.id.action_promptPayment_to_nav_main) - model.showTransactions(payStatus.currency) + navigateToTransaction(payStatus.transactionId) Snackbar.make(requireView(), R.string.payment_initiated, LENGTH_LONG).show() } is PayStatus.AlreadyPaid -> { showLoading(false) paymentManager.resetPayStatus() - findNavController().navigate(R.id.action_promptPayment_to_alreadyPaid) + navigateToTransaction(payStatus.transactionId) + Snackbar.make(requireView(), R.string.payment_already_paid, LENGTH_LONG).show() } - is PayStatus.Error -> { + is PayStatus.Pending -> { showLoading(false) - ui.details.errorView.text = getString(R.string.payment_error, payStatus.error) - ui.details.errorView.fadeIn() + paymentManager.resetPayStatus() + navigateToTransaction(payStatus.transactionId) + if (payStatus.error != null && model.devMode.value == true) { + showError(payStatus.error) + } else { + showError(getString(R.string.payment_pending)) + } } is PayStatus.None -> { // No payment active. @@ -132,7 +152,7 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { } } - private fun showOrder(contractTerms: ContractTerms, amount:Amount, totalFees: Amount? = null) { + private fun showOrder(contractTerms: ContractTerms, amount: Amount, totalFees: Amount? = null) { ui.details.orderView.text = contractTerms.summary adapter.setItems(contractTerms.products) ui.details.productsList.fadeIn() @@ -154,4 +174,13 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { f.show(parentFragmentManager, "image") } + private fun navigateToTransaction(id: String?) { + lifecycleScope.launch { + if (id != null && transactionManager.selectTransaction(id)) { + findNavController().navigate(R.id.action_promptPayment_to_nav_transactions_detail_payment) + } else { + findNavController().navigate(R.id.action_promptPayment_to_nav_main) + } + } + } } diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt new file mode 100644 index 0000000..0f6d661 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt @@ -0,0 +1,174 @@ +/* + * This file is part of GNU Taler + * (C) 2023 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.wallet.payment + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.common.Amount +import net.taler.common.ContractMerchant +import net.taler.common.CurrencySpecification +import net.taler.common.Timestamp +import net.taler.common.toAbsoluteTime +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.transactions.AmountType +import net.taler.wallet.transactions.ErrorTransactionButton +import net.taler.wallet.transactions.TransactionAction +import net.taler.wallet.transactions.TransactionAction.Abort +import net.taler.wallet.transactions.TransactionAction.Retry +import net.taler.wallet.transactions.TransactionAction.Suspend +import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.transactions.TransactionInfo +import net.taler.wallet.transactions.TransactionInfoComposable +import net.taler.wallet.transactions.TransactionLinkComposable +import net.taler.wallet.transactions.TransactionMajorState.Pending +import net.taler.wallet.transactions.TransactionPayment +import net.taler.wallet.transactions.TransactionState +import net.taler.wallet.transactions.TransitionsComposable + +@Composable +fun TransactionPaymentComposable( + t: TransactionPayment, + devMode: Boolean, + spec: CurrencySpecification?, + onFulfill: (url: String) -> Unit, + onTransition: (t: TransactionAction) -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + horizontalAlignment = CenterHorizontally, + ) { + val context = LocalContext.current + Text( + modifier = Modifier.padding(16.dp), + text = t.timestamp.ms.toAbsoluteTime(context).toString(), + style = MaterialTheme.typography.bodyLarge, + ) + + TransactionAmountComposable( + label = stringResource(id = R.string.transaction_order_total), + amount = t.amountRaw.withSpec(spec), + amountType = AmountType.Neutral, + ) + + val fee = t.amountEffective - t.amountRaw + if (!fee.isZero()) { + TransactionAmountComposable( + label = stringResource(id = R.string.withdraw_fees), + amount = fee.withSpec(spec), + amountType = AmountType.Negative, + ) + } + + TransactionAmountComposable( + label = stringResource(id = R.string.transaction_paid), + amount = t.amountEffective.withSpec(spec), + amountType = AmountType.Negative, + ) + + if (t.posConfirmation != null) { + TransactionInfoComposable( + label = stringResource(id = R.string.payment_confirmation_code), + info = t.posConfirmation, + ) + } + + PurchaseDetails(info = t.info) { + onFulfill(t.info.fulfillmentUrl ?: "") + } + + TransitionsComposable(t, devMode, onTransition) + if (devMode && t.error != null) { + ErrorTransactionButton(error = t.error) + } + } +} + +@Composable +fun PurchaseDetails( + info: TransactionInfo, + onClick: () -> Unit, +) { + Column( + horizontalAlignment = CenterHorizontally, + ) { + // Summary and fulfillment message + val text = if (info.fulfillmentMessage == null) { + info.summary + } else { + "${info.summary}\n\n${info.fulfillmentMessage}" + } + if (info.fulfillmentUrl != null) { + TransactionLinkComposable( + label = stringResource(id = R.string.transaction_order), + info = text, + ) { onClick() } + } else { + TransactionInfoComposable( + label = stringResource(id = R.string.transaction_order), + info = text, + ) + } + // Order ID + Text( + stringResource(id = R.string.transaction_order_id, info.orderId), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Preview +@Composable +fun TransactionPaymentComposablePreview() { + val t = TransactionPayment( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Pending), + txActions = listOf(Retry, Suspend, Abort), + info = TransactionInfo( + orderId = "123", + merchant = ContractMerchant(name = "Taler"), + summary = "Some Product that was bought and can have quite a long label", + fulfillmentMessage = "This is some fulfillment message", + fulfillmentUrl = "https://bank.demo.taler.net/", + products = listOf(), + ), + amountRaw = Amount.fromString("TESTKUDOS", "42.1337"), + amountEffective = Amount.fromString("TESTKUDOS", "42.23"), + error = TalerErrorInfo(code = TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED), + ) + TalerSurface { + TransactionPaymentComposable(t = t, devMode = true, spec = null, onFulfill = {}) {} + } +} |