summaryrefslogtreecommitdiff
path: root/wallet/src/main/java/net/taler/wallet/payment
diff options
context:
space:
mode:
Diffstat (limited to 'wallet/src/main/java/net/taler/wallet/payment')
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/AlreadyPaidFragment.kt49
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt184
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt115
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt177
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt96
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt29
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/ProductAdapter.kt26
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt57
-rw-r--r--wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt174
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 = {}) {}
+ }
+}