diff options
Diffstat (limited to 'wallet/src/main/java/net/taler/wallet/payment')
10 files changed, 940 insertions, 105 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..d744183 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt @@ -0,0 +1,171 @@ +/* + * 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 + +@Composable +fun PayTemplateComposable( + currencies: List<String>, + payStatus: PayStatus, + onCreateAmount: (String, String) -> AmountResult, + onSubmit: (params: TemplateParams) -> Unit, + onError: (resId: Int) -> Unit, +) { + // If wallet is empty, there's no way the user can pay something + if (currencies.isEmpty()) { + PayTemplateError(stringResource(R.string.payment_balance_insufficient)) + } else when (val p = payStatus) { + is PayStatus.Checked -> { + val usableCurrencies = currencies + .intersect(p.supportedCurrencies.toSet()) + .toList() + if (usableCurrencies.isEmpty()) { + // If user doesn't have any supported currency, they can't pay either + PayTemplateError(stringResource(R.string.payment_balance_insufficient)) + } else { + PayTemplateOrderComposable( + usableCurrencies = usableCurrencies, + templateDetails = p.details, + onCreateAmount = onCreateAmount, + onError = onError, + onSubmit = onSubmit, + ) + } + } + + is PayStatus.None, 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( + 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( + 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( + 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( + 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/PayTemplateDetails.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt new file mode 100644 index 0000000..4c3f0a5 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt @@ -0,0 +1,126 @@ +/* + * This file is part of GNU Taler + * (C) 2024 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 kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.taler.common.Amount +import net.taler.common.RelativeTime + +@Serializable +data class TemplateContractDetails( + /** + * Human-readable summary for the template. + */ + val summary: String? = null, + + /** + * Required currency for payments to the template. The user may specify + * any amount, but it must be in this currency. This parameter is + * optional and should not be present if "amount" is given. + */ + val currency: String? = null, + + /** + * The price is imposed by the merchant and cannot be changed by the + * customer. This parameter is optional. + */ + val amount: Amount? = null, + + /** + * Minimum age buyer must have (in years). Default is 0. + */ + @SerialName("minimum_age") + val minimumAge: Int, + + /** + * The time the customer need to pay before his order will be deleted. It + * is deleted if the customer did not pay and if the duration is over. + */ + @SerialName("pay_duration") + val payDuration: RelativeTime, +) + +@Serializable +data class TemplateContractDetailsDefaults( + val summary: String? = null, + val currency: String? = null, + val amount: Amount? = null, + @SerialName("minimum_age") + val minimumAge: Int? = null, +) + +@Serializable +class WalletTemplateDetails( + /** + * Hard-coded information about the contract terms for this template. + */ + @SerialName("template_contract") + val templateContract: TemplateContractDetails, + + /** + * Key-value pairs matching a subset of the fields from template_contract + * that are user-editable defaults for this template. + */ + @SerialName("editable_defaults") + val editableDefaults: TemplateContractDetailsDefaults? = null, + + /** + * Required currency for payments. Useful if no amount is specified in + * the template_contract but the user should be required to pay in a + * particular currency anyway. Merchant backends may reject requests if + * the template_contract or editable_defaults do specify an amount in a + * different currency. This parameter is optional. + */ + @SerialName("required_currency") + val requiredCurrency: String? = null, +) { + val defaultSummary get() = editableDefaults?.summary + ?: templateContract.summary + + val defaultAmount get() = editableDefaults?.amount + ?: templateContract.amount + + val defaultCurrency get() = requiredCurrency + ?: editableDefaults?.currency + ?: templateContract.currency + + fun isSummaryEditable() = templateContract.summary == null + + fun isAmountEditable() = templateContract.amount == null + + fun isCurrencyEditable(usableCurrencies: List<String>) = isAmountEditable() + && requiredCurrency == null + && templateContract.currency == null + && usableCurrencies.size > 1 + + fun isTemplateEditable(usableCurrencies: List<String>) = isSummaryEditable() + || isAmountEditable() + || isCurrencyEditable(usableCurrencies) + + // NOTE: it is important to nullify non-editable values! + fun toTemplateParams() = TemplateParams( + amount = if(isAmountEditable()) templateContract.amount else null, + summary = if(isSummaryEditable()) templateContract.summary else null, + ) +} + +@Serializable +data class TemplateParams( + val amount: Amount? = null, + val summary: String? = null, +)
\ No newline at end of file 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..51c0bc0 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt @@ -0,0 +1,104 @@ +/* + * 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.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 + private val currencies by lazy { model.getCurrencies() } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + uriString = arguments?.getString("uri") ?: error("no amount passed") + uri = Uri.parse(uriString) + + val payStatusFlow = model.paymentManager.payStatus.asFlow() + + return ComposeView(requireContext()).apply { + setContent { + val payStatus = payStatusFlow.collectAsStateLifecycleAware(initial = PayStatus.None) + TalerSurface { + PayTemplateComposable( + currencies = currencies, + 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) + checkTemplate() + + 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) + } + + is PayStatus.Checked -> { + val usableCurrencies = currencies + .intersect(payStatus.supportedCurrencies.toSet()) + .toList() + if (!payStatus.details.isTemplateEditable(usableCurrencies)) { + createOrder(payStatus.details.toTemplateParams()) + } + } + + else -> {} + } + } + } + + private fun checkTemplate() { + model.paymentManager.checkPayForTemplate(uriString) + } + + private fun createOrder(params: TemplateParams) { + model.paymentManager.preparePayForTemplate(uriString, params) + } +} 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..2febfbb --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt @@ -0,0 +1,221 @@ +/* + * 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.LaunchedEffect +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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.RelativeTime +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 + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun PayTemplateOrderComposable( + usableCurrencies: List<String>, // non-empty intersection between the stored currencies and the ones supported by the merchant + templateDetails: WalletTemplateDetails, + onCreateAmount: (String, String) -> AmountResult, + onError: (msgRes: Int) -> Unit, + onSubmit: (params: TemplateParams) -> Unit, +) { + val defaultSummary = templateDetails.defaultSummary + val defaultAmount = templateDetails.defaultAmount + val defaultCurrency = templateDetails.defaultCurrency + + val summaryFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + var summary by remember { mutableStateOf(defaultSummary ?: "") } + var currency by remember { mutableStateOf(defaultCurrency ?: usableCurrencies[0]) } + var amount by remember { mutableStateOf(defaultAmount?.amountStr ?: "0") } + + Column(horizontalAlignment = End) { + OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .focusRequester(summaryFocusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + value = summary, + isError = templateDetails.isSummaryEditable() && summary.isBlank(), + onValueChange = { summary = it }, + singleLine = true, + readOnly = !templateDetails.isSummaryEditable(), + label = { Text(stringResource(R.string.withdraw_manual_ready_subject)) }, + ) + + AmountField( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + amount = amount, + currency = currency, + currencies = usableCurrencies, + readOnlyCurrency = !templateDetails.isCurrencyEditable(usableCurrencies), + readOnlyAmount = !templateDetails.isAmountEditable(), + onAmountChosen = { a, c -> + amount = a + currency = c + }, + ) + + Button( + modifier = Modifier.padding(16.dp), + enabled = !templateDetails.isSummaryEditable() || summary.isNotBlank(), + onClick = { + when (val res = onCreateAmount(amount, currency)) { + is AmountResult.InsufficientBalance -> onError(R.string.payment_balance_insufficient) + is AmountResult.InvalidAmount -> onError(R.string.amount_invalid) + // NOTE: it is important to nullify non-editable values! + is AmountResult.Success -> onSubmit(TemplateParams( + summary = if (templateDetails.isSummaryEditable()) summary else null, + amount = if(templateDetails.isAmountEditable()) res.amount else null, + )) + } + }, + ) { + Text(stringResource(R.string.payment_create_order)) + } + } + + LaunchedEffect(Unit) { + if (templateDetails.isSummaryEditable() + && templateDetails.defaultSummary == null) { + summaryFocusRequester.requestFocus() + } + } +} + +@Composable +private fun AmountField( + modifier: Modifier = Modifier, + currencies: List<String>, + amount: String, + currency: String, + readOnlyAmount: Boolean = true, + readOnlyCurrency: Boolean = true, + 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.amount_send)) }, + readOnly = readOnlyAmount, + ) + + CurrencyDropdown( + modifier = Modifier.weight(1f), + initialCurrency = currency, + currencies = currencies, + onCurrencyChanged = { onAmountChosen(amount, it) }, + readOnly = readOnlyCurrency, + ) + } +} + +val defaultTemplateDetails = WalletTemplateDetails( + templateContract = TemplateContractDetails( + minimumAge = 18, + payDuration = RelativeTime.forever(), + ), + editableDefaults = TemplateContractDetailsDefaults( + summary = "Donation", + amount = Amount.fromJSONString("KUDOS:10.0"), + ), +) + +@Preview +@Composable +fun PayTemplateDefaultPreview() { + TalerSurface { + PayTemplateOrderComposable( + templateDetails = defaultTemplateDetails, + usableCurrencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _ -> }, + onError = { }, + ) + } +} + +@Preview +@Composable +fun PayTemplateFixedAmountPreview() { + TalerSurface { + PayTemplateOrderComposable( + templateDetails = defaultTemplateDetails, + usableCurrencies = listOf("KUDOS", "ARS"), + onCreateAmount = { text, currency -> + AmountResult.Success(amount = Amount.fromString(currency, text)) + }, + onSubmit = { _ -> }, + onError = { }, + ) + } +} + +@Preview +@Composable +fun PayTemplateBlankSubjectPreview() { + TalerSurface { + PayTemplateOrderComposable( + templateDetails = defaultTemplateDetails, + usableCurrencies = 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 53cb259..647c98c 100644 --- a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt +++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -22,9 +22,12 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import net.taler.common.Amount import net.taler.common.ContractTerms import net.taler.wallet.TAG +import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.payment.PayStatus.AlreadyPaid @@ -32,30 +35,50 @@ 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+/=]+)$") sealed class PayStatus { - object None : PayStatus() - object Loading : PayStatus() + data object None : PayStatus() + data object Loading : PayStatus() data class Prepared( val contractTerms: ContractTerms, - val proposalId: String, + val transactionId: String, val amountRaw: Amount, val amountEffective: Amount, ) : PayStatus() + data class Checked( + val details: WalletTemplateDetails, + val supportedCurrencies: List<String>, + ) : PayStatus() + data class InsufficientBalance( val contractTerms: ContractTerms, 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() } +@Serializable +data class CheckPayTemplateResponse( + val templateDetails: WalletTemplateDetails, + val supportedCurrencies: List<String>, +) + class PaymentManager( private val api: WalletBackendApi, private val scope: CoroutineScope, @@ -75,42 +98,68 @@ class PaymentManager( 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)) + }.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, + ) + }) } } - @UiThread - fun abortPay() { - val ps = payStatus.value - if (ps is PayStatus.Prepared) { - abortProposal(ps.proposalId) + fun checkPayForTemplate(url: String) = scope.launch { + mPayStatus.value = PayStatus.Loading + api.request("checkPayForTemplate", CheckPayTemplateResponse.serializer()) { + put("talerPayTemplateUri", url) + }.onError { + handleError("checkPayForTemplate", it) + }.onSuccess { response -> + mPayStatus.value = PayStatus.Checked( + details = response.templateDetails, + supportedCurrencies = response.supportedCurrencies, + ) } - resetPayStatus() } - internal fun abortProposal(proposalId: String) = scope.launch { - Log.i(TAG, "aborting proposal") - api.request<Unit>("abortProposal") { - put("proposalId", proposalId) + fun preparePayForTemplate(url: String, params: TemplateParams) = scope.launch { + mPayStatus.value = PayStatus.Loading + api.request("preparePayForTemplate", PreparePayResponse.serializer()) { + put("talerPayTemplateUri", url) + put("templateParams", JSONObject(BackendManager.json.encodeToString(params))) }.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 +170,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 7e03472..407f55f 100644 --- a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt +++ b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt @@ -32,14 +32,14 @@ sealed class PreparePayResponse { @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 7ed1bab..1995f9d 100644 --- a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt @@ -18,23 +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.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.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. @@ -43,6 +49,7 @@ 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) @@ -67,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 +98,7 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { private fun onPaymentStatusChanged(payStatus: PayStatus?) { when (payStatus) { null -> {} + is PayStatus.Checked -> {} // does not apply, only used for templates is PayStatus.Prepared -> { showLoading(false) val fees = payStatus.amountEffective - payStatus.amountRaw @@ -91,8 +107,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 +123,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. @@ -154,4 +175,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..beb37d9 --- /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, + ) + + if (t.amountEffective > t.amountRaw) { + val fee = t.amountEffective - t.amountRaw + TransactionAmountComposable( + label = stringResource(id = R.string.amount_fee), + 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 = {}) {} + } +} |