taler-android

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

commit fe8a23b42341a4732f2f5967415068c56abd6adf
parent d87619f15f51898ca1328d1cf72543435c329c2d
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 27 Mar 2025 14:25:25 +0100

[wallet] render causeHint in merchant payments

Diffstat:
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt | 45+++++++++++++++++++++++++++++++++++++++++++--
Mwallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt | 5++++-
Mwallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt | 20+++++++++++++++++++-
Mwallet/src/main/res/layout/payment_details.xml | 21+++++++++++++++++++--
Mwallet/src/main/res/values/strings.xml | 6++++++
6 files changed, 232 insertions(+), 6 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt @@ -25,6 +25,7 @@ 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.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.taler.common.Amount @@ -34,6 +35,14 @@ import net.taler.wallet.AmountResult import net.taler.wallet.R import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.payment.InsufficientBalanceHint.AgeRestricted +import net.taler.wallet.payment.InsufficientBalanceHint.ExchangeMissingGlobalFees +import net.taler.wallet.payment.InsufficientBalanceHint.FeesNotCovered +import net.taler.wallet.payment.InsufficientBalanceHint.MerchantAcceptInsufficient +import net.taler.wallet.payment.InsufficientBalanceHint.MerchantDepositInsufficient +import net.taler.wallet.payment.InsufficientBalanceHint.Unknown +import net.taler.wallet.payment.InsufficientBalanceHint.WalletBalanceAvailableInsufficient +import net.taler.wallet.payment.InsufficientBalanceHint.WalletBalanceMaterialInsufficient import net.taler.wallet.systemBarsPaddingBottom @Composable @@ -70,7 +79,25 @@ fun PayTemplateComposable( 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.InsufficientBalance -> { + var errorMsg = stringResource(R.string.payment_balance_insufficient) + when(p.balanceDetails.causeHint) { + null -> null + Unknown -> null + MerchantAcceptInsufficient -> R.string.payment_balance_insufficient_hint_merchant_accept_insufficient + MerchantDepositInsufficient -> R.string.payment_balance_insufficient_hint_merchant_deposit_insufficient + AgeRestricted -> R.string.payment_balance_insufficient_hint_age_restricted + WalletBalanceMaterialInsufficient -> R.string.payment_balance_insufficient_hint_wallet_balance_material_insufficient + WalletBalanceAvailableInsufficient -> null // "normal case" + ExchangeMissingGlobalFees -> R.string.payment_balance_insufficient_hint_exchange_missing_global_fees + FeesNotCovered -> R.string.payment_balance_insufficient_hint_fees_not_covered + }?.let { hintRes -> + errorMsg += "\n\n" + errorMsg += stringResource(hintRes) + } + + PayTemplateError(errorMsg) + } is PayStatus.Pending -> { val error = p.error PayTemplateError(if (error != null) { @@ -97,6 +124,7 @@ fun PayTemplateError(message: String) { text = message, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, ) } } @@ -133,7 +161,20 @@ fun PayTemplateInsufficientBalancePreview() { "test", amount = Amount.zero("TESTKUDOS"), products = emptyList() - ), Amount.zero("TESTKUDOS") + ), + Amount.zero("TESTKUDOS"), + PaymentInsufficientBalanceDetails( + amountRequested = Amount.fromJSONString("TESTKUDOS:1"), + causeHint = InsufficientBalanceHint.MerchantDepositInsufficient, + balanceAvailable = Amount.fromJSONString("TESTKUDOS:1"), + balanceMaterial = Amount.fromJSONString("TESTKUDOS:1"), + balanceAgeAcceptable = Amount.fromJSONString("TESTKUDOS:1"), + balanceReceiverAcceptable = Amount.fromJSONString("TESTKUDOS:0"), + balanceReceiverDepositable = Amount.fromJSONString("TESTKUDOS:0"), + balanceExchangeDepositable = Amount.fromJSONString("TESTKUDOS:1"), + maxEffectiveSpendAmount = Amount.fromJSONString("TESTKUDOS:1"), + perExchange = emptyMap(), + ) ), currencies = listOf("KUDOS", "ARS"), onCreateAmount = { text, currency -> diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -55,6 +55,7 @@ sealed class PayStatus { data class InsufficientBalance( val contractTerms: ContractTerms, val amountRaw: Amount, + val balanceDetails: PaymentInsufficientBalanceDetails, ) : PayStatus() data class AlreadyPaid( @@ -97,7 +98,8 @@ class PaymentManager( is PaymentPossibleResponse -> response.toPayStatusPrepared() is InsufficientBalanceResponse -> InsufficientBalance( contractTerms = response.contractTerms, - amountRaw = response.amountRaw + amountRaw = response.amountRaw, + balanceDetails = response.balanceDetails, ) is AlreadyConfirmedResponse -> AlreadyPaid( transactionId = response.transactionId, @@ -152,6 +154,7 @@ class PaymentManager( is InsufficientBalanceResponse -> InsufficientBalance( contractTerms = response.contractTerms, amountRaw = response.amountRaw, + balanceDetails = response.balanceDetails, ) is AlreadyConfirmedResponse -> AlreadyPaid( diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt @@ -50,6 +50,7 @@ sealed class PreparePayResponse { data class InsufficientBalanceResponse( val amountRaw: Amount, val contractTerms: ContractTerms, + val balanceDetails: PaymentInsufficientBalanceDetails, ) : PreparePayResponse() @Serializable @@ -67,6 +68,146 @@ sealed class PreparePayResponse { } @Serializable +enum class InsufficientBalanceHint { + Unknown, + + /** + * Merchant doesn't accept money from exchange(s) that the wallet supports. + */ + @SerialName("merchant-accept-insufficient") + MerchantAcceptInsufficient, + + /** + * Merchant accepts funds from a matching exchange, but the funds can't be + * deposited with the wire method. + */ + @SerialName("merchant-deposit-insufficient") + MerchantDepositInsufficient, + + /** + * While in principle the balance is sufficient, + * the age restriction on coins causes the spendable + * balance to be insufficient. + */ + @SerialName("age-restricted") + AgeRestricted, + + /** + * Wallet has enough available funds, + * but the material funds are insufficient. Usually because there is a + * pending refresh operation. + */ + @SerialName("wallet-balance-material-insufficient") + WalletBalanceMaterialInsufficient, + + /** + * The wallet simply doesn't have enough available funds. + * This is the "obvious" case of insufficient balance. + */ + @SerialName("wallet-balance-available-insufficient") + WalletBalanceAvailableInsufficient, + + /** + * Exchange is missing the global fee configuration, thus fees are unknown + * and funds from this exchange can't be used for p2p payments. + */ + @SerialName("exchange-missing-global-fees") + ExchangeMissingGlobalFees, + + /** + * Even though the balance looks sufficient for the instructed amount, + * the fees can be covered by neither the merchant nor the remaining wallet + * balance. + */ + @SerialName("fees-not-covered") + FeesNotCovered, +} + +@Serializable +data class PaymentInsufficientBalanceDetails( + /** + * Amount requested by the merchant. + */ + val amountRequested: Amount, + + /** + * Wire method for the requested payment, only applicable + * for merchant payments. + */ + val wireMethod: String? = null, + + /** + * Hint as to why the balance is insufficient. + * + * If this hint is not provided, the balance hints of + * the individual exchanges should be shown, as the overall + * reason might be a combination of the reasons for different exchanges. + */ + val causeHint: InsufficientBalanceHint? = null, + + /** + * Balance of type "available" (see balance.ts for definition). + */ + val balanceAvailable: Amount, + + /** + * Balance of type "material" (see balance.ts for definition). + */ + val balanceMaterial: Amount, + + /** + * Balance of type "age-acceptable" (see balance.ts for definition). + */ + val balanceAgeAcceptable: Amount, + + /** + * Balance of type "merchant-acceptable" (see balance.ts for definition). + */ + val balanceReceiverAcceptable: Amount, + + /** + * Balance of type "merchant-depositable" (see balance.ts for definition). + */ + val balanceReceiverDepositable: Amount, + + val balanceExchangeDepositable: Amount, + + /** + * Maximum effective amount that the wallet can spend, + * when all fees are paid by the wallet. + */ + val maxEffectiveSpendAmount: Amount, + + val perExchange: Map<String, PerExchange>, +) { + + @Serializable + data class PerExchange( + val balanceAvailable: Amount, + val balanceMaterial: Amount, + val balanceExchangeDepositable: Amount, + val balanceAgeAcceptable: Amount, + val balanceReceiverAcceptable: Amount, + val balanceReceiverDepositable: Amount, + val maxEffectiveSpendAmount: Amount, + + /** + * Exchange doesn't have global fees configured for the relevant year, + * p2p payments aren't possible. + * + * @deprecated (2025-02-18) use causeHint instead + */ + val missingGlobalFees: Boolean, + + /** + * Hint that UIs should show to explain the insufficient + * balance. + */ + val causeHint: InsufficientBalanceHint? = null, + ) +} + +@Serializable sealed class ConfirmPayResult { @Serializable @SerialName("done") diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt @@ -45,6 +45,7 @@ import net.taler.wallet.R import net.taler.wallet.TAG import net.taler.wallet.databinding.FragmentPromptPaymentBinding import net.taler.wallet.showError +import net.taler.wallet.payment.InsufficientBalanceHint.* /** * Show a payment and ask the user to accept/decline. @@ -132,8 +133,25 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { is PayStatus.InsufficientBalance -> { showLoading(false) showOrder(payStatus.contractTerms, payStatus.amountRaw) - ui.details.errorView.setText(R.string.payment_balance_insufficient) + ui.details.errorView.text = getString( + R.string.payment_balance_insufficient_max, + payStatus.balanceDetails.balanceAvailable.toString(), + ) ui.details.errorView.fadeIn() + when(payStatus.balanceDetails.causeHint) { + null -> null + Unknown -> null + MerchantAcceptInsufficient -> R.string.payment_balance_insufficient_hint_merchant_accept_insufficient + MerchantDepositInsufficient -> R.string.payment_balance_insufficient_hint_merchant_deposit_insufficient + AgeRestricted -> R.string.payment_balance_insufficient_hint_age_restricted + WalletBalanceMaterialInsufficient -> R.string.payment_balance_insufficient_hint_wallet_balance_material_insufficient + WalletBalanceAvailableInsufficient -> null // "normal case" + ExchangeMissingGlobalFees -> R.string.payment_balance_insufficient_hint_exchange_missing_global_fees + FeesNotCovered -> R.string.payment_balance_insufficient_hint_fees_not_covered + }?.let { hintRes -> + ui.details.errorHintView.setText(hintRes) + ui.details.errorHintView.fadeIn() + } } is PayStatus.Success -> { showLoading(false) diff --git a/wallet/src/main/res/layout/payment_details.xml b/wallet/src/main/res/layout/payment_details.xml @@ -35,7 +35,7 @@ android:textColor="@android:color/holo_red_dark" android:textSize="22sp" android:visibility="gone" - app:layout_constraintBottom_toTopOf="@+id/orderLabelView" + app:layout_constraintBottom_toTopOf="@+id/errorHintView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -44,6 +44,23 @@ tools:visibility="visible" /> <TextView + android:id="@+id/errorHintView" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_margin="16dp" + android:textAlignment="center" + android:textColor="@android:color/holo_red_dark" + android:textSize="22sp" + android:visibility="gone" + app:layout_constraintBottom_toTopOf="@+id/orderLabelView" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/errorView" + app:layout_constraintVertical_chainStyle="packed" + tools:text="@string/payment_balance_insufficient" + tools:visibility="visible" /> + + <TextView android:id="@+id/orderLabelView" android:layout_width="0dp" android:layout_height="wrap_content" @@ -56,7 +73,7 @@ app:layout_constraintBottom_toTopOf="@+id/orderView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/errorView" + app:layout_constraintTop_toBottomOf="@+id/errorHintView" tools:visibility="visible" /> <TextView diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -184,6 +184,12 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="payment_aborting">Aborting</string> <string name="payment_already_paid">You\'ve already paid for this purchase.</string> <string name="payment_balance_insufficient">Balance insufficient!</string> + <string name="payment_balance_insufficient_hint_age_restricted">Purchase not possible due to age restriction</string> + <string name="payment_balance_insufficient_hint_exchange_missing_global_fees">Provider is missing the global fee configuration, this likely means it is misconfigured</string> + <string name="payment_balance_insufficient_hint_fees_not_covered">Not enough funds to pay the provider fees not covered by the merchant</string> + <string name="payment_balance_insufficient_hint_merchant_accept_insufficient">Merchant doesn\'t accept money from one or more providers in this wallet</string> + <string name="payment_balance_insufficient_hint_merchant_deposit_insufficient">Merchant doesn\'t accept the wire method of the provider, this likely means it is misconfigured</string> + <string name="payment_balance_insufficient_hint_wallet_balance_material_insufficient">Some of the coins needed for this purchase are currently unavailable</string> <string name="payment_balance_insufficient_max">Balance insufficient! Maximum is %1$s</string> <string name="payment_button_confirm">Confirm payment</string> <string name="payment_confirmation_code">Confirmation code</string>