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:
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>