diff --git a/CHANGELOG.md b/CHANGELOG.md index 5babacc8b0c..8f2fe8c8708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ NEXT_VERSION_BUMP: PATCH ### Payments * [CHANGED][13144](https://github.com/stripe/stripe-android/pull/13144) On payment or setup confirmation failure, we now include error code, decline code, and error type in the exception thrown. +### CryptoOnramp +* [ADDED][13156](https://github.com/stripe/stripe-android/pull/13156) Added `SDKVersion`, `StripeCryptoOnrampError`, `CryptoOnrampApiException`, `AppAttestationException`, and `UncategorizedApiErrorException` to expose richer developer-facing diagnostics and preserved backend API error context for Crypto Onramp failures. +* [CHANGED][13156](https://github.com/stripe/stripe-android/pull/13156) Improved Crypto Onramp API error handling with localized fallback user messaging and more consistent error mapping across attestation, payment collection, and checkout flows. + + ## 23.9.1 - 2026-05-26 ### Payments diff --git a/crypto-onramp/api/crypto-onramp.api b/crypto-onramp/api/crypto-onramp.api index 3fe145a0a42..886899b9607 100644 --- a/crypto-onramp/api/crypto-onramp.api +++ b/crypto-onramp/api/crypto-onramp.api @@ -34,6 +34,53 @@ public final class com/stripe/android/crypto/onramp/OnrampCoordinator$Presenter public static synthetic fun verifyKycInfo$default (Lcom/stripe/android/crypto/onramp/OnrampCoordinator$Presenter;Lcom/stripe/android/paymentsheet/PaymentSheet$Address;ILjava/lang/Object;)V } +public final class com/stripe/android/crypto/onramp/exception/APIErrorContext { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/stripe/android/core/exception/StripeException;)V + public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Lcom/stripe/android/core/exception/StripeException; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ljava/lang/String; + public final fun component9 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/stripe/android/core/exception/StripeException;)Lcom/stripe/android/crypto/onramp/exception/APIErrorContext; + public static synthetic fun copy$default (Lcom/stripe/android/crypto/onramp/exception/APIErrorContext;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/stripe/android/core/exception/StripeException;ILjava/lang/Object;)Lcom/stripe/android/crypto/onramp/exception/APIErrorContext; + public fun equals (Ljava/lang/Object;)Z + public final fun getApiErrorCode ()Ljava/lang/String; + public final fun getApiErrorMessage ()Ljava/lang/String; + public final fun getApiErrorType ()Ljava/lang/String; + public final fun getApiUserMessage ()Ljava/lang/String; + public final fun getAppPackageName ()Ljava/lang/String; + public final fun getDocUrl ()Ljava/lang/String; + public final fun getMode ()Ljava/lang/String; + public final fun getOperation ()Ljava/lang/String; + public final fun getReason ()Ljava/lang/String; + public final fun getRequestId ()Ljava/lang/String; + public final fun getUnderlyingError ()Lcom/stripe/android/core/exception/StripeException; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/stripe/android/crypto/onramp/exception/AppAttestationException : com/stripe/android/crypto/onramp/exception/CryptoOnrampApiException { + public static final field $stable I + public fun getCode ()Ljava/lang/String; +} + +public abstract class com/stripe/android/crypto/onramp/exception/CryptoOnrampApiException : com/stripe/android/core/exception/StripeException, com/stripe/android/crypto/onramp/exception/StripeCryptoOnrampError { + public static final field $stable I + public final fun getContext ()Lcom/stripe/android/crypto/onramp/exception/APIErrorContext; + public final fun getDeveloperMessage ()Ljava/lang/String; + public final fun getDocUrl ()Ljava/lang/String; + public final fun getSdkVersions ()Ljava/util/List; + public final fun getUnderlyingError ()Lcom/stripe/android/core/exception/StripeException; + public synthetic fun getUnderlyingError ()Ljava/lang/Throwable; + public final fun getUserMessage ()Ljava/lang/String; +} + public final class com/stripe/android/crypto/onramp/exception/MissingConsumerSecretException : java/lang/IllegalStateException { public static final field $stable I } @@ -55,6 +102,35 @@ public final class com/stripe/android/crypto/onramp/exception/PaymentFailedExcep public static final field $stable I } +public final class com/stripe/android/crypto/onramp/exception/SDKVersion { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lcom/stripe/android/crypto/onramp/exception/SDKVersion; + public static synthetic fun copy$default (Lcom/stripe/android/crypto/onramp/exception/SDKVersion;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/stripe/android/crypto/onramp/exception/SDKVersion; + public fun equals (Ljava/lang/Object;)Z + public final fun getDebugDescription ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun getVersion ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/stripe/android/crypto/onramp/exception/StripeCryptoOnrampError { + public abstract fun getCode ()Ljava/lang/String; + public abstract fun getDeveloperMessage ()Ljava/lang/String; + public abstract fun getDocUrl ()Ljava/lang/String; + public abstract fun getSdkVersions ()Ljava/util/List; + public abstract fun getUnderlyingError ()Ljava/lang/Throwable; + public abstract fun getUserMessage ()Ljava/lang/String; +} + +public final class com/stripe/android/crypto/onramp/exception/UncategorizedApiErrorException : com/stripe/android/crypto/onramp/exception/CryptoOnrampApiException { + public static final field $stable I + public fun getCode ()Ljava/lang/String; +} + public final class com/stripe/android/crypto/onramp/model/CryptoNetwork : java/lang/Enum { public static final field Aptos Lcom/stripe/android/crypto/onramp/model/CryptoNetwork; public static final field Arbitrum Lcom/stripe/android/crypto/onramp/model/CryptoNetwork; diff --git a/crypto-onramp/res/values/strings.xml b/crypto-onramp/res/values/strings.xml new file mode 100644 index 00000000000..2716daf97ce --- /dev/null +++ b/crypto-onramp/res/values/strings.xml @@ -0,0 +1,7 @@ + + + + This app couldn\'t be verified due to an attestation error. Please try again later or contact the developer if the issue persists. + + Something went wrong. Please try again later. + diff --git a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/OnrampInteractor.kt b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/OnrampInteractor.kt index c04de7429a0..b405336c13b 100644 --- a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/OnrampInteractor.kt +++ b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/OnrampInteractor.kt @@ -9,11 +9,13 @@ import com.stripe.android.R import com.stripe.android.core.utils.flatMapCatching import com.stripe.android.crypto.onramp.CheckoutState.Status import com.stripe.android.crypto.onramp.analytics.OnrampAnalyticsEvent +import com.stripe.android.crypto.onramp.analytics.OnrampAnalyticsEvent.ErrorOccurred.Operation import com.stripe.android.crypto.onramp.analytics.OnrampAnalyticsService import com.stripe.android.crypto.onramp.exception.MissingConsumerSecretException import com.stripe.android.crypto.onramp.exception.MissingCryptoCustomerException import com.stripe.android.crypto.onramp.exception.MissingPaymentMethodException import com.stripe.android.crypto.onramp.exception.PaymentFailedException +import com.stripe.android.crypto.onramp.exception.toCryptoOnrampError import com.stripe.android.crypto.onramp.model.CrsCarfDeclaration import com.stripe.android.crypto.onramp.model.CryptoNetwork import com.stripe.android.crypto.onramp.model.KycInfo @@ -81,6 +83,30 @@ internal class OnrampInteractor @Inject constructor( private var analyticsService: OnrampAnalyticsService? = null + private fun mapError( + operation: Operation, + error: Throwable, + ): Throwable { + return error.toCryptoOnrampError( + context = application, + operation = operation, + publishableKey = state.value.configurationState?.publishableKey, + additionalSdkVersions = state.value.configurationState?.additionalSdkVersions.orEmpty(), + ) + } + + private fun trackError( + operation: Operation, + error: Throwable, + ) { + analyticsService?.track( + OnrampAnalyticsEvent.ErrorOccurred( + operation = operation, + error = error, + ) + ) + } + suspend fun configure(configurationState: OnrampConfiguration.State): OnrampConfigurationResult { _state.update { currentState -> OnrampState( @@ -109,13 +135,9 @@ internal class OnrampInteractor @Inject constructor( return when (linkResult) { is ConfigureResult.Success -> OnrampConfigurationResult.Completed(success = true) is ConfigureResult.Failed -> { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.Configure, - error = linkResult.error, - ) - ) - OnrampConfigurationResult.Failed(linkResult.error) + val error = mapError(Operation.Configure, linkResult.error) + trackError(Operation.Configure, error) + OnrampConfigurationResult.Failed(error) } } } @@ -139,36 +161,24 @@ internal class OnrampInteractor @Inject constructor( OnrampTokenAuthenticationResult.Completed() }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - OnrampAnalyticsEvent.ErrorOccurred.Operation.AuthenticateUserWithAuthToken, - error, - ) - ) - - OnrampTokenAuthenticationResult.Failed(error) + val mappedError = mapError(Operation.AuthenticateUserWithAuthToken, error) + trackError(Operation.AuthenticateUserWithAuthToken, mappedError) + OnrampTokenAuthenticationResult.Failed(mappedError) } ) } else { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.AuthenticateUserWithAuthToken, - error = error, - ) + val error = mapError( + operation = Operation.AuthenticateUserWithAuthToken, + error = MissingConsumerSecretException(), ) + trackError(Operation.AuthenticateUserWithAuthToken, error) OnrampTokenAuthenticationResult.Failed(error) } } is LinkController.AuthenticateWithTokenResult.Failed -> { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.AuthenticateUserWithAuthToken, - error = result.error, - ) - ) - - OnrampTokenAuthenticationResult.Failed(result.error) + val error = mapError(Operation.AuthenticateUserWithAuthToken, result.error) + trackError(Operation.AuthenticateUserWithAuthToken, error) + OnrampTokenAuthenticationResult.Failed(error) } } } @@ -186,14 +196,10 @@ internal class OnrampInteractor @Inject constructor( ) } is LinkController.LookupConsumerResult.Failed -> { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.HasLinkAccount, - error = result.error, - ) - ) + val error = mapError(Operation.HasLinkAccount, result.error) + trackError(Operation.HasLinkAccount, error) OnrampHasLinkAccountResult.Failed( - error = result.error + error = error ) } } @@ -218,35 +224,25 @@ internal class OnrampInteractor @Inject constructor( OnrampRegisterLinkUserResult.Completed(customerResponse.id) }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.RegisterLinkUser, - error = error, - ) - ) - OnrampRegisterLinkUserResult.Failed(error) + val mappedError = mapError(Operation.RegisterLinkUser, error) + trackError(Operation.RegisterLinkUser, mappedError) + OnrampRegisterLinkUserResult.Failed(mappedError) } ) } else { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.RegisterLinkUser, - error = error, - ) + val error = mapError( + operation = Operation.RegisterLinkUser, + error = MissingConsumerSecretException(), ) + trackError(Operation.RegisterLinkUser, error) OnrampRegisterLinkUserResult.Failed(error) } } is LinkController.RegisterConsumerResult.Failed -> { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.RegisterLinkUser, - error = result.error, - ) - ) + val error = mapError(Operation.RegisterLinkUser, result.error) + trackError(Operation.RegisterLinkUser, error) OnrampRegisterLinkUserResult.Failed( - error = result.error + error = error ) } } @@ -259,14 +255,10 @@ internal class OnrampInteractor @Inject constructor( OnrampUpdatePhoneNumberResult.Completed() } is LinkController.UpdatePhoneNumberResult.Failed -> { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.UpdatePhoneNumber, - error = result.error, - ) - ) + val error = mapError(Operation.UpdatePhoneNumber, result.error) + trackError(Operation.UpdatePhoneNumber, error) OnrampUpdatePhoneNumberResult.Failed( - error = result.error + error = error ) } } @@ -285,23 +277,17 @@ internal class OnrampInteractor @Inject constructor( OnrampRegisterWalletAddressResult.Completed() }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.RegisterWalletAddress, - error = error, - ) - ) - OnrampRegisterWalletAddressResult.Failed(error) + val mappedError = mapError(Operation.RegisterWalletAddress, error) + trackError(Operation.RegisterWalletAddress, mappedError) + OnrampRegisterWalletAddressResult.Failed(mappedError) } ) } else { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.RegisterWalletAddress, - error = error, - ) + val error = mapError( + operation = Operation.RegisterWalletAddress, + error = MissingConsumerSecretException(), ) + trackError(Operation.RegisterWalletAddress, error) OnrampRegisterWalletAddressResult.Failed(error) } } @@ -309,13 +295,11 @@ internal class OnrampInteractor @Inject constructor( suspend fun attachKycInfo(kycInfo: KycInfo): OnrampAttachKycInfoResult { val secret = consumerSessionClientSecret() if (secret == null) { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.AttachKycInfo, - error = error, - ) + val error = mapError( + operation = Operation.AttachKycInfo, + error = MissingConsumerSecretException(), ) + trackError(Operation.AttachKycInfo, error) return OnrampAttachKycInfoResult.Failed(error) } @@ -326,13 +310,9 @@ internal class OnrampInteractor @Inject constructor( OnrampAttachKycInfoResult.Completed() }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.AttachKycInfo, - error = error, - ) - ) - OnrampAttachKycInfoResult.Failed(error) + val mappedError = mapError(Operation.AttachKycInfo, error) + trackError(Operation.AttachKycInfo, mappedError) + OnrampAttachKycInfoResult.Failed(mappedError) } ) } @@ -340,13 +320,11 @@ internal class OnrampInteractor @Inject constructor( suspend fun retrieveMissingIdentifiers(): OnrampRetrieveMissingIdentifiersResult { val secret = consumerSessionClientSecret() if (secret == null) { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.RetrieveMissingIdentifiers, - error = error, - ) + val error = mapError( + operation = Operation.RetrieveMissingIdentifiers, + error = MissingConsumerSecretException(), ) + trackError(Operation.RetrieveMissingIdentifiers, error) return OnrampRetrieveMissingIdentifiersResult.Failed(error) } @@ -357,13 +335,9 @@ internal class OnrampInteractor @Inject constructor( OnrampRetrieveMissingIdentifiersResult.Completed(requirements) }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.RetrieveMissingIdentifiers, - error = error, - ) - ) - OnrampRetrieveMissingIdentifiersResult.Failed(error) + val mappedError = mapError(Operation.RetrieveMissingIdentifiers, error) + trackError(Operation.RetrieveMissingIdentifiers, mappedError) + OnrampRetrieveMissingIdentifiersResult.Failed(mappedError) } ) } @@ -371,13 +345,11 @@ internal class OnrampInteractor @Inject constructor( suspend fun submitIdentifiers(identifiers: List): OnrampSubmitIdentifiersResult { val secret = consumerSessionClientSecret() if (secret == null) { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.SubmitIdentifiers, - error = error, - ) + val error = mapError( + operation = Operation.SubmitIdentifiers, + error = MissingConsumerSecretException(), ) + trackError(Operation.SubmitIdentifiers, error) return OnrampSubmitIdentifiersResult.Failed(error) } @@ -388,13 +360,9 @@ internal class OnrampInteractor @Inject constructor( OnrampSubmitIdentifiersResult.Completed(result) }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.SubmitIdentifiers, - error = error, - ) - ) - OnrampSubmitIdentifiersResult.Failed(error) + val mappedError = mapError(Operation.SubmitIdentifiers, error) + trackError(Operation.SubmitIdentifiers, mappedError) + OnrampSubmitIdentifiersResult.Failed(mappedError) } ) } @@ -402,13 +370,11 @@ internal class OnrampInteractor @Inject constructor( suspend fun startCrsCarfDeclaration(): OnrampStartCrsCarfDeclarationResult { val secret = consumerSessionClientSecret() if (secret == null) { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.PresentCRSCARFDeclaration, - error = error, - ) + val error = mapError( + operation = Operation.PresentCRSCARFDeclaration, + error = MissingConsumerSecretException(), ) + trackError(Operation.PresentCRSCARFDeclaration, error) return OnrampStartCrsCarfDeclarationResult.Failed(error) } @@ -422,13 +388,9 @@ internal class OnrampInteractor @Inject constructor( ) }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.PresentCRSCARFDeclaration, - error = error, - ) - ) - OnrampStartCrsCarfDeclarationResult.Failed(error) + val mappedError = mapError(Operation.PresentCRSCARFDeclaration, error) + trackError(Operation.PresentCRSCARFDeclaration, mappedError) + OnrampStartCrsCarfDeclarationResult.Failed(mappedError) } ) } @@ -436,13 +398,11 @@ internal class OnrampInteractor @Inject constructor( suspend fun startIdentityVerification(): OnrampStartVerificationResult { val secret = consumerSessionClientSecret() if (secret == null) { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.VerifyIdentity, - error = error, - ) + val error = mapError( + operation = Operation.VerifyIdentity, + error = MissingConsumerSecretException(), ) + trackError(Operation.VerifyIdentity, error) return OnrampStartVerificationResult.Failed(error) } @@ -453,13 +413,9 @@ internal class OnrampInteractor @Inject constructor( OnrampStartVerificationResult.Completed(result) }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.VerifyIdentity, - error = error, - ) - ) - OnrampStartVerificationResult.Failed(error) + val mappedError = mapError(Operation.VerifyIdentity, error) + trackError(Operation.VerifyIdentity, mappedError) + OnrampStartVerificationResult.Failed(mappedError) } ) } @@ -468,13 +424,11 @@ internal class OnrampInteractor @Inject constructor( val secret = consumerSessionClientSecret() if (secret == null) { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.VerifyKyc, - error = error, - ) + val error = mapError( + operation = Operation.VerifyKyc, + error = MissingConsumerSecretException(), ) + trackError(Operation.VerifyKyc, error) return OnrampStartKycVerificationResult.Failed(error) } @@ -493,12 +447,9 @@ internal class OnrampInteractor @Inject constructor( ) }, onFailure = { error -> - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.VerifyKyc, - error = error, - ) - - OnrampStartKycVerificationResult.Failed(error) + val mappedError = mapError(Operation.VerifyKyc, error) + trackError(Operation.VerifyKyc, mappedError) + OnrampStartKycVerificationResult.Failed(mappedError) } ) } @@ -507,13 +458,11 @@ internal class OnrampInteractor @Inject constructor( suspend fun createCryptoPaymentToken(): OnrampCreateCryptoPaymentTokenResult { val cryptoCustomerId = _state.value.cryptoCustomerId if (cryptoCustomerId == null) { - val error = MissingCryptoCustomerException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.CreateCryptoPaymentToken, - error = error, - ) + val error = mapError( + operation = Operation.CreateCryptoPaymentToken, + error = MissingCryptoCustomerException(), ) + trackError(Operation.CreateCryptoPaymentToken, error) return OnrampCreateCryptoPaymentTokenResult.Failed(error) } @@ -555,13 +504,9 @@ internal class OnrampInteractor @Inject constructor( OnrampCreateCryptoPaymentTokenResult.Completed(cryptoPaymentToken.id) }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.CreateCryptoPaymentToken, - error = error, - ) - ) - OnrampCreateCryptoPaymentTokenResult.Failed(error) + val mappedError = mapError(Operation.CreateCryptoPaymentToken, error) + trackError(Operation.CreateCryptoPaymentToken, mappedError) + OnrampCreateCryptoPaymentTokenResult.Failed(mappedError) } ) } @@ -573,13 +518,9 @@ internal class OnrampInteractor @Inject constructor( OnrampLogOutResult.Completed() } is LinkController.LogOutResult.Failed -> { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.LogOut, - error = result.error, - ) - ) - OnrampLogOutResult.Failed(result.error) + val error = mapError(Operation.LogOut, result.error) + trackError(Operation.LogOut, error) + OnrampLogOutResult.Failed(error) } } } @@ -597,23 +538,17 @@ internal class OnrampInteractor @Inject constructor( OnrampAuthorizeResult.Consented(customerResponse.id) }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.Authorize, - error = error, - ) - ) - OnrampAuthorizeResult.Failed(error) + val mappedError = mapError(Operation.Authorize, error) + trackError(Operation.Authorize, mappedError) + OnrampAuthorizeResult.Failed(mappedError) } ) } else { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.Authorize, - error = error, - ) + val error = mapError( + operation = Operation.Authorize, + error = MissingConsumerSecretException(), ) + trackError(Operation.Authorize, error) OnrampAuthorizeResult.Failed(error) } } @@ -624,13 +559,9 @@ internal class OnrampInteractor @Inject constructor( is LinkController.AuthorizeResult.Canceled -> OnrampAuthorizeResult.Canceled() is LinkController.AuthorizeResult.Failed -> { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.Authorize, - error = result.error, - ) - ) - OnrampAuthorizeResult.Failed(result.error) + val error = mapError(Operation.Authorize, result.error) + trackError(Operation.Authorize, error) + OnrampAuthorizeResult.Failed(error) } } @@ -642,13 +573,9 @@ internal class OnrampInteractor @Inject constructor( OnrampVerifyIdentityResult.Completed() } is IdentityVerificationSheet.VerificationFlowResult.Failed -> { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.VerifyIdentity, - error = result.throwable, - ) - ) - OnrampVerifyIdentityResult.Failed(result.throwable) + val error = mapError(Operation.VerifyIdentity, result.throwable) + trackError(Operation.VerifyIdentity, error) + OnrampVerifyIdentityResult.Failed(error) } is IdentityVerificationSheet.VerificationFlowResult.Canceled -> OnrampVerifyIdentityResult.Cancelled() @@ -679,17 +606,18 @@ internal class OnrampInteractor @Inject constructor( kycInfo = null ) } ?: run { - OnrampCollectPaymentMethodResult.Failed(MissingPaymentMethodException()) + val error = mapError( + operation = Operation.CollectPaymentMethod, + error = MissingPaymentMethodException(), + ) + trackError(Operation.CollectPaymentMethod, error) + OnrampCollectPaymentMethodResult.Failed(error) } } is LinkController.PresentPaymentMethodsResult.Failed -> { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.CollectPaymentMethod, - error = result.error, - ) - ) - OnrampCollectPaymentMethodResult.Failed(result.error) + val error = mapError(Operation.CollectPaymentMethod, result.error) + trackError(Operation.CollectPaymentMethod, error) + OnrampCollectPaymentMethodResult.Failed(error) } is LinkController.PresentPaymentMethodsResult.Canceled -> OnrampCollectPaymentMethodResult.Cancelled() @@ -706,13 +634,9 @@ internal class OnrampInteractor @Inject constructor( } } is GooglePayPaymentMethodLauncher.Result.Failed -> { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.CollectPaymentMethod, - error = result.error, - ) - ) - OnrampCollectPaymentMethodResult.Failed(result.error) + val error = mapError(Operation.CollectPaymentMethod, result.error) + trackError(Operation.CollectPaymentMethod, error) + OnrampCollectPaymentMethodResult.Failed(error) } is GooglePayPaymentMethodLauncher.Result.Canceled -> OnrampCollectPaymentMethodResult.Cancelled() @@ -778,20 +702,20 @@ internal class OnrampInteractor @Inject constructor( OnrampVerifyKycInfoResult.Confirmed() }, onFailure = { - OnrampVerifyKycInfoResult.Failed( - refreshResult.exceptionOrNull() ?: Exception("Unknown error") + val error = mapError( + operation = Operation.VerifyKyc, + error = refreshResult.exceptionOrNull() ?: Exception("Unknown error"), ) + trackError(Operation.VerifyKyc, error) + OnrampVerifyKycInfoResult.Failed(error) } ) } else { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.VerifyIdentity, - error = error, - ) + val error = mapError( + operation = Operation.VerifyKyc, + error = MissingConsumerSecretException(), ) - + trackError(Operation.VerifyKyc, error) OnrampVerifyKycInfoResult.Failed(error) } } @@ -816,25 +740,17 @@ internal class OnrampInteractor @Inject constructor( OnrampCrsCarfDeclarationResult.Confirmed() }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.PresentCRSCARFDeclaration, - error = error, - ) - ) - - OnrampCrsCarfDeclarationResult.Failed(error) + val mappedError = mapError(Operation.PresentCRSCARFDeclaration, error) + trackError(Operation.PresentCRSCARFDeclaration, mappedError) + OnrampCrsCarfDeclarationResult.Failed(mappedError) } ) } else { - val error = MissingConsumerSecretException() - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.PresentCRSCARFDeclaration, - error = error, - ) + val error = mapError( + operation = Operation.PresentCRSCARFDeclaration, + error = MissingConsumerSecretException(), ) - + trackError(Operation.PresentCRSCARFDeclaration, error) OnrampCrsCarfDeclarationResult.Failed(error) } } @@ -865,12 +781,8 @@ internal class OnrampInteractor @Inject constructor( } fun onHandleNextActionError(error: Throwable) { - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.PerformCheckout, - error = error, - ) - ) + val mappedError = mapError(Operation.PerformCheckout, error) + trackError(Operation.PerformCheckout, mappedError) clearLaunchedNextAction() clearPendingCheckout() @@ -878,7 +790,7 @@ internal class OnrampInteractor @Inject constructor( _state.update { it.copy( checkoutState = CheckoutState( - Status.Completed(OnrampCheckoutResult.Failed(error)) + Status.Completed(OnrampCheckoutResult.Failed(mappedError)) ) ) } @@ -933,7 +845,7 @@ internal class OnrampInteractor @Inject constructor( _state.update { it.copy( checkoutState = CheckoutState( - Status.Completed(OnrampCheckoutResult.Failed(PaymentFailedException())) + Status.Completed(createAndTrackPaymentFailedCheckoutResult()) ) ) } @@ -1003,13 +915,9 @@ internal class OnrampInteractor @Inject constructor( ) }, onFailure = { error -> - analyticsService?.track( - OnrampAnalyticsEvent.ErrorOccurred( - operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.PerformCheckout, - error = error, - ) - ) - Status.Completed(OnrampCheckoutResult.Failed(error)) + val mappedError = mapError(Operation.PerformCheckout, error) + trackError(Operation.PerformCheckout, mappedError) + Status.Completed(OnrampCheckoutResult.Failed(mappedError)) } ) @@ -1086,15 +994,24 @@ internal class OnrampInteractor @Inject constructor( if (paymentMethod?.type == PaymentMethod.Type.USBankAccount) { OnrampCheckoutResult.Completed() } else { - OnrampCheckoutResult.Failed(PaymentFailedException()) + createAndTrackPaymentFailedCheckoutResult() } } - StripeIntent.Status.RequiresPaymentMethod -> OnrampCheckoutResult.Failed(PaymentFailedException()) + StripeIntent.Status.RequiresPaymentMethod -> createAndTrackPaymentFailedCheckoutResult() StripeIntent.Status.RequiresAction -> null // More handling needed - else -> OnrampCheckoutResult.Failed(PaymentFailedException()) + else -> createAndTrackPaymentFailedCheckoutResult() } } + private fun createAndTrackPaymentFailedCheckoutResult(): OnrampCheckoutResult.Failed { + val error = mapError( + operation = Operation.PerformCheckout, + error = PaymentFailedException(), + ) + trackError(Operation.PerformCheckout, error) + return OnrampCheckoutResult.Failed(error) + } + private fun Status.RequiresNextAction.nextActionLaunchDeduplicationKey(): String { return deduplicationKey( onrampSessionId, diff --git a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/AppAttestationException.kt b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/AppAttestationException.kt new file mode 100644 index 00000000000..93d81a0fca4 --- /dev/null +++ b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/AppAttestationException.kt @@ -0,0 +1,115 @@ +package com.stripe.android.crypto.onramp.exception + +import com.stripe.android.crypto.onramp.ExperimentalCryptoOnramp + +/** + * Indicates that Crypto Onramp could not verify the Android app attestation data. + */ +@ExperimentalCryptoOnramp +class AppAttestationException internal constructor( + context: APIErrorContext, + sdkVersions: List, + userMessage: String, +) : CryptoOnrampApiException( + context = context, + sdkVersions = sdkVersions, + userMessage = userMessage, + developerMessage = buildAppAttestationDeveloperMessage( + context = context, + code = context.code(fallback = APP_ATTESTATION_ERROR_CODE), + sdkVersions = sdkVersions, + ), +) { + override val code: String + get() = context.code(fallback = APP_ATTESTATION_ERROR_CODE) +} + +private const val ATTESTATION_NOT_ENABLED_REASON = "attestation_not_enabled" +internal const val APP_ATTESTATION_ERROR_CODE = "link_failed_to_attest_request" +private const val APP_NOT_REGISTERED_REASON = "app_not_registered" +private const val ATTESTATION_DATA_MISSING_REASON = "attestation_data_missing" +private const val APP_NOT_PLAY_RECOGNIZED_REASON = "app_not_play_recognized" +private const val ANDROID_PACKAGE_NAME_MISMATCH_REASON = "android_package_name_mismatch" +private const val ANDROID_ENVIRONMENT_MISMATCH_REASON = "android_environment_mismatch" +private const val ANDROID_VERDICT_VALIDATION_FAILED_REASON = "android_verdict_validation_failed" + +private const val ATTESTATION_NOT_ENABLED_DESCRIPTION = + "app attestation is not enabled for this Stripe account" +private const val APP_NOT_REGISTERED_DESCRIPTION = + "this app is not registered as a trusted application" +private const val ATTESTATION_DATA_MISSING_DESCRIPTION = + "attestation data is missing or incomplete" +private const val APP_NOT_PLAY_RECOGNIZED_DESCRIPTION = + "this app is not recognized by Google Play" +private const val ANDROID_PACKAGE_NAME_MISMATCH_DESCRIPTION = + "the app package name does not match the package name registered for this Stripe account" +private const val ANDROID_ENVIRONMENT_MISMATCH_DESCRIPTION = + "the Play Integrity distribution channel does not match this Stripe mode" +private const val ANDROID_VERDICT_VALIDATION_FAILED_DESCRIPTION = + "the Play Integrity verdict could not be validated" + +private const val ATTESTATION_NOT_ENABLED_NEXT_STEP = + "Contact Stripe to enable app attestation for this account and mode, then retry the Onramp flow." +private const val APP_NOT_REGISTERED_NEXT_STEP = + "Register this app's package name as a trusted application with Stripe, then retry the Onramp flow." +private const val ATTESTATION_DATA_MISSING_NEXT_STEP = + "Make sure all required Play Integrity fields are sent with the request, then retry the Onramp flow." +private const val APP_NOT_PLAY_RECOGNIZED_NEXT_STEP = + "Install the app from a Google Play testing or production track and retry the Onramp flow. Internal, closed, open testing, and production tracks are supported. Debug builds and sideloaded APKs will not pass this check." +private const val ANDROID_PACKAGE_NAME_MISMATCH_NEXT_STEP = + "Use the package name registered for this Stripe account, then retry the Onramp flow." +private const val ANDROID_ENVIRONMENT_MISMATCH_NEXT_STEP = + "Install the app from the Google Play track that matches this Stripe mode, then retry the Onramp flow." +private const val ANDROID_VERDICT_VALIDATION_FAILED_NEXT_STEP = + "Generate a new Play Integrity verdict and retry the Onramp flow. If the issue persists, check your app attestation configuration." +private const val DEFAULT_ATTESTATION_NEXT_STEP = + "Inspect the preserved Stripe API error for details and retry after correcting the app attestation configuration." + +private fun appAttestationSummary(reason: String?): String? { + return appAttestationDescription(reason)?.let(::attestationSummary) +} + +private fun appAttestationDescription(reason: String?): String? { + return when (reason) { + ATTESTATION_NOT_ENABLED_REASON -> ATTESTATION_NOT_ENABLED_DESCRIPTION + APP_NOT_REGISTERED_REASON -> APP_NOT_REGISTERED_DESCRIPTION + ATTESTATION_DATA_MISSING_REASON -> ATTESTATION_DATA_MISSING_DESCRIPTION + APP_NOT_PLAY_RECOGNIZED_REASON -> APP_NOT_PLAY_RECOGNIZED_DESCRIPTION + ANDROID_PACKAGE_NAME_MISMATCH_REASON -> ANDROID_PACKAGE_NAME_MISMATCH_DESCRIPTION + ANDROID_ENVIRONMENT_MISMATCH_REASON -> ANDROID_ENVIRONMENT_MISMATCH_DESCRIPTION + ANDROID_VERDICT_VALIDATION_FAILED_REASON -> ANDROID_VERDICT_VALIDATION_FAILED_DESCRIPTION + else -> null + } +} + +private fun appAttestationNextStep(reason: String?): String { + return when (reason) { + ATTESTATION_NOT_ENABLED_REASON -> ATTESTATION_NOT_ENABLED_NEXT_STEP + APP_NOT_REGISTERED_REASON -> APP_NOT_REGISTERED_NEXT_STEP + ATTESTATION_DATA_MISSING_REASON -> ATTESTATION_DATA_MISSING_NEXT_STEP + APP_NOT_PLAY_RECOGNIZED_REASON -> APP_NOT_PLAY_RECOGNIZED_NEXT_STEP + ANDROID_PACKAGE_NAME_MISMATCH_REASON -> ANDROID_PACKAGE_NAME_MISMATCH_NEXT_STEP + ANDROID_ENVIRONMENT_MISMATCH_REASON -> ANDROID_ENVIRONMENT_MISMATCH_NEXT_STEP + ANDROID_VERDICT_VALIDATION_FAILED_REASON -> ANDROID_VERDICT_VALIDATION_FAILED_NEXT_STEP + else -> DEFAULT_ATTESTATION_NEXT_STEP + } +} + +private fun attestationSummary(description: String): String { + return "App attestation failed: $description." +} + +private fun buildAppAttestationDeveloperMessage( + context: APIErrorContext, + code: String, + sdkVersions: List, +): String { + return CryptoOnrampErrorRenderer.renderApiDeveloperMessage( + context = context, + summary = appAttestationSummary(context.reason) + ?: (context.apiErrorMessage ?: "App attestation failed."), + code = code, + nextStep = appAttestationNextStep(context.reason), + sdkVersions = sdkVersions, + ) +} diff --git a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/CryptoOnrampApiException.kt b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/CryptoOnrampApiException.kt new file mode 100644 index 00000000000..ff59e95fe09 --- /dev/null +++ b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/CryptoOnrampApiException.kt @@ -0,0 +1,92 @@ +package com.stripe.android.crypto.onramp.exception + +import androidx.annotation.RestrictTo +import com.stripe.android.core.exception.StripeException +import com.stripe.android.crypto.onramp.ExperimentalCryptoOnramp + +/** + * Base exception type for Crypto Onramp failures that originate from a Stripe API error payload. + * + * [context] preserves the original API-facing fields and request debugging metadata. + * + * `message` is safe to display directly to app users. Use [developerMessage] for richer + * diagnostics. + */ +@ExperimentalCryptoOnramp +abstract class CryptoOnrampApiException internal constructor( + val context: APIErrorContext, + final override val sdkVersions: List, + final override val userMessage: String, + final override val developerMessage: String, +) : StripeException( + stripeError = context.underlyingError.stripeError, + requestId = context.underlyingError.requestId, + statusCode = context.underlyingError.statusCode, + cause = context.underlyingError, + message = userMessage, +), +StripeCryptoOnrampError { + final override val docUrl: String? + get() = context.docUrl + + final override val underlyingError: StripeException + get() = context.underlyingError + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + override fun analyticsValue(): String { + return context.underlyingError.analyticsValue() + } +} + +data class APIErrorContext( + /** + * The raw backend reason, when present. + */ + val reason: String?, + /** + * The Crypto Onramp operation that failed. + */ + val operation: String, + /** + * The Android application package name used for the request. + */ + val appPackageName: String, + /** + * The Stripe mode inferred from the publishable key, when available. + */ + val mode: String?, + /** + * The raw backend error code, when present. + */ + val apiErrorCode: String?, + /** + * The raw backend error type, when present. + */ + val apiErrorType: String?, + /** + * The raw backend developer-facing message, when present. + */ + val apiErrorMessage: String?, + /** + * The raw backend end-user-facing message, when present. + */ + val apiUserMessage: String?, + /** + * A documentation URL for recovery guidance, when available. + */ + val docUrl: String?, + /** + * The original Stripe exception that was mapped into this context. + */ + val underlyingError: StripeException, +) { + /** + * The Stripe API request ID associated with this error, when available. + */ + val requestId: String? + get() = underlyingError.requestId +} + +internal fun APIErrorContext.code(fallback: String): String { + return apiErrorCode ?: fallback +} diff --git a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/CryptoOnrampErrorRenderer.kt b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/CryptoOnrampErrorRenderer.kt new file mode 100644 index 00000000000..a3dff5addf0 --- /dev/null +++ b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/CryptoOnrampErrorRenderer.kt @@ -0,0 +1,81 @@ +package com.stripe.android.crypto.onramp.exception + +internal object CryptoOnrampErrorRenderer { + fun renderGenericApiDeveloperMessage( + context: APIErrorContext, + code: String, + sdkVersions: List, + ): String { + return renderApiDeveloperMessage( + context = context, + summary = context.apiErrorMessage ?: "Stripe API request failed.", + code = code, + nextStep = "Inspect the preserved Stripe API error for details and retry after correcting the request.", + sdkVersions = sdkVersions, + ) + } + + fun renderApiDeveloperMessage( + context: APIErrorContext, + summary: String, + code: String, + nextStep: String, + sdkVersions: List, + ): String { + return renderDeveloperMessage( + summary = summary, + requestContext = requestContextLines(context), + code = code, + nextStep = nextStep, + docUrl = context.docUrl, + sdkVersions = sdkVersions, + ) + } + + private fun renderDeveloperMessage( + summary: String, + requestContext: List, + code: String, + nextStep: String, + docUrl: String?, + sdkVersions: List, + ): String { + val footer = buildList { + add("Code: $code") + add("Next step: $nextStep") + docUrl?.let { + add("Docs: $it") + } + add("SDK: ${sdkVersionDescription(sdkVersions)}") + } + + return buildList { + add(summary) + if (requestContext.isNotEmpty()) { + add("") + add("Request Context:") + addAll(requestContext.map { " $it" }) + } + add("") + addAll(footer) + }.joinToString(separator = "\n") + } + + private fun sdkVersionDescription(sdkVersions: List): String { + val normalizedSdkVersions = sdkVersions.ifEmpty { listOf(SDKVersion.stripeAndroid) } + return normalizedSdkVersions.joinToString(separator = ", ") { it.debugDescription } + } + + private fun requestContextLines( + context: APIErrorContext, + ): List { + return listOfNotNull( + "operation: ${context.operation}", + "app_id: ${context.appPackageName}", + context.mode?.let { "mode: $it" }, + context.reason?.let { "reason: $it" }, + context.requestId?.let { "request_id: $it" }, + context.apiErrorType?.let { "type: $it" }, + ) + } +} diff --git a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/OnrampErrorMapper.kt b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/OnrampErrorMapper.kt new file mode 100644 index 00000000000..1eb11bdef2b --- /dev/null +++ b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/OnrampErrorMapper.kt @@ -0,0 +1,72 @@ +package com.stripe.android.crypto.onramp.exception + +import android.content.Context +import com.stripe.android.core.StripeError +import com.stripe.android.core.exception.StripeException +import com.stripe.android.crypto.onramp.R +import com.stripe.android.crypto.onramp.analytics.OnrampAnalyticsEvent + +internal fun Throwable.toCryptoOnrampError( + context: Context, + operation: OnrampAnalyticsEvent.ErrorOccurred.Operation, + publishableKey: String?, + additionalSdkVersions: List = emptyList(), +): Throwable { + if (this is StripeCryptoOnrampError) return this + + val stripeException = this as? StripeException ?: return this + val stripeError = stripeException.stripeError ?: return this + val apiUserMessage = stripeError.extraFields?.get(FIELD_USER_MESSAGE)?.takeIf { it.isNotBlank() } + val sdkVersions = listOf(SDKVersion.stripeAndroid) + additionalSdkVersions + + val apiErrorContext = APIErrorContext( + reason = stripeError.extraFields?.get(FIELD_REASON), + operation = operation.value, + appPackageName = context.packageName, + mode = publishableKey.toMode(), + apiErrorCode = stripeError.code, + apiErrorType = stripeException.apiErrorType(), + apiErrorMessage = stripeError.message, + apiUserMessage = apiUserMessage, + docUrl = stripeError.docUrl, + underlyingError = stripeException, + ) + + return if (stripeError.isAppAttestationError()) { + AppAttestationException( + context = apiErrorContext, + sdkVersions = sdkVersions, + userMessage = context.getString( + R.string.stripe_onramp_app_attestation_default_user_message, + ), + ) + } else { + UncategorizedApiErrorException( + context = apiErrorContext, + sdkVersions = sdkVersions, + userMessage = context.getString( + R.string.stripe_onramp_default_api_error_user_message, + ), + ) + } +} + +private fun StripeError.isAppAttestationError(): Boolean { + return code == APP_ATTESTATION_ERROR_CODE +} + +private fun StripeException.apiErrorType(): String? { + return stripeError?.type?.takeIf { it.isNotBlank() } +} + +private fun String?.toMode(): String? { + return when { + this == null -> null + startsWith("pk_live_") -> "live" + startsWith("pk_test_") -> "test" + else -> null + } +} + +private const val FIELD_REASON = "reason" +private const val FIELD_USER_MESSAGE = "user_message" diff --git a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/SDKVersion.kt b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/SDKVersion.kt new file mode 100644 index 00000000000..ce2ee5da974 --- /dev/null +++ b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/SDKVersion.kt @@ -0,0 +1,35 @@ +package com.stripe.android.crypto.onramp.exception + +import com.stripe.android.core.version.StripeSdkVersion +import com.stripe.android.crypto.onramp.ExperimentalCryptoOnramp + +/** + * A wrapper SDK name and version pair included in developer diagnostics. + * + * Do not use this type for the Stripe Android SDK version. Stripe Android is always included + * automatically. + */ +@ExperimentalCryptoOnramp +data class SDKVersion( + /** + * The SDK name. + */ + val name: String, + /** + * The SDK version. + */ + val version: String, +) { + /** + * A developer-facing SDK version description. + */ + val debugDescription: String + get() = "$name@$version" + + internal companion object { + val stripeAndroid = SDKVersion( + name = "stripe-android", + version = StripeSdkVersion.VERSION, + ) + } +} diff --git a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/StripeCryptoOnrampError.kt b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/StripeCryptoOnrampError.kt new file mode 100644 index 00000000000..e5f1dc24582 --- /dev/null +++ b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/StripeCryptoOnrampError.kt @@ -0,0 +1,43 @@ +package com.stripe.android.crypto.onramp.exception + +import com.stripe.android.crypto.onramp.ExperimentalCryptoOnramp + +/** + * Common rich-error contract for Crypto Onramp failures that expose SDK-owned recovery guidance. + * + * [userMessage] is safe to display directly to app users. Use [developerMessage] for richer + * diagnostics. + */ +@ExperimentalCryptoOnramp +interface StripeCryptoOnrampError { + /** + * An end-user-facing message, when available. + */ + val userMessage: String + + /** + * A richer developer-facing diagnostic message. + */ + val developerMessage: String + + /** + * A stable SDK-owned error code. + */ + val code: String + + /** + * A documentation URL for recovery guidance, when available. + */ + val docUrl: String? + + /** + * SDK versions included in developer diagnostics, including Stripe Android and any + * additional wrapper SDK versions. + */ + val sdkVersions: List + + /** + * The original cause that was mapped into this richer Crypto Onramp error. + */ + val underlyingError: Throwable? +} diff --git a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/UncategorizedApiErrorException.kt b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/UncategorizedApiErrorException.kt new file mode 100644 index 00000000000..df3eaa46a59 --- /dev/null +++ b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/exception/UncategorizedApiErrorException.kt @@ -0,0 +1,27 @@ +package com.stripe.android.crypto.onramp.exception + +import com.stripe.android.crypto.onramp.ExperimentalCryptoOnramp + +/** + * Indicates that a Stripe API request failed without a more specific Crypto Onramp category. + */ +@ExperimentalCryptoOnramp +class UncategorizedApiErrorException internal constructor( + context: APIErrorContext, + sdkVersions: List, + userMessage: String, +) : CryptoOnrampApiException( + context = context, + sdkVersions = sdkVersions, + userMessage = userMessage, + developerMessage = CryptoOnrampErrorRenderer.renderGenericApiDeveloperMessage( + context = context, + code = context.code(fallback = UNCATEGORIZED_API_ERROR_CODE), + sdkVersions = sdkVersions, + ), +) { + override val code: String + get() = context.code(fallback = UNCATEGORIZED_API_ERROR_CODE) +} + +private const val UNCATEGORIZED_API_ERROR_CODE = "uncategorized_api_error" diff --git a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/model/OnrampConfiguration.kt b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/model/OnrampConfiguration.kt index 5b8a55d28a5..8a284ca5182 100644 --- a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/model/OnrampConfiguration.kt +++ b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/model/OnrampConfiguration.kt @@ -1,6 +1,8 @@ package com.stripe.android.crypto.onramp.model +import androidx.annotation.RestrictTo import com.stripe.android.crypto.onramp.ExperimentalCryptoOnramp +import com.stripe.android.crypto.onramp.exception.SDKVersion import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher import com.stripe.android.link.LinkAppearance @@ -12,6 +14,7 @@ import com.stripe.android.link.LinkAppearance * @property appearance Appearance settings for the PaymentSheet UI. * @property cryptoCustomerId The unique customer ID for crypto onramp. * @property googlePayConfig The configuration for Google Pay, if Google Pay is supported by the merchant. + * @property additionalSdkVersions Additional wrapper SDK versions to include in developer diagnostics. */ @ExperimentalCryptoOnramp class OnrampConfiguration { @@ -20,6 +23,7 @@ class OnrampConfiguration { private var appearance: LinkAppearance? = null private var cryptoCustomerId: String? = null private var googlePayConfig: GooglePayPaymentMethodLauncher.Config? = null + private var additionalSdkVersions: List = emptyList() /** * Sets the display name of the merchant. @@ -57,12 +61,23 @@ class OnrampConfiguration { this.googlePayConfig = googlePayConfig } + /** + * Additional wrapper SDK versions to include in developer diagnostics, such as the + * Stripe React Native SDK version. Do not include Stripe Android; it is always included + * automatically. + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + fun additionalSdkVersions(additionalSdkVersions: List) = apply { + this.additionalSdkVersions = additionalSdkVersions + } + internal class State( val merchantDisplayName: String, val publishableKey: String, val appearance: LinkAppearance, val cryptoCustomerId: String? = null, - val googlePayConfig: GooglePayPaymentMethodLauncher.Config? = null + val googlePayConfig: GooglePayPaymentMethodLauncher.Config? = null, + val additionalSdkVersions: List = emptyList() ) internal fun build(): State { @@ -75,7 +90,8 @@ class OnrampConfiguration { }, appearance = appearance ?: LinkAppearance(), cryptoCustomerId = cryptoCustomerId, - googlePayConfig = googlePayConfig + googlePayConfig = googlePayConfig, + additionalSdkVersions = additionalSdkVersions ) } } diff --git a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/repositories/CryptoApiRepository.kt b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/repositories/CryptoApiRepository.kt index bd7a055ee2d..b93bbe03d11 100644 --- a/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/repositories/CryptoApiRepository.kt +++ b/crypto-onramp/src/main/java/com/stripe/android/crypto/onramp/repositories/CryptoApiRepository.kt @@ -2,13 +2,17 @@ package com.stripe.android.crypto.onramp.repositories import androidx.annotation.RestrictTo import com.stripe.android.core.AppInfo +import com.stripe.android.core.StripeError +import com.stripe.android.core.exception.APIConnectionException +import com.stripe.android.core.exception.APIException import com.stripe.android.core.injection.PUBLISHABLE_KEY import com.stripe.android.core.injection.STRIPE_ACCOUNT_ID import com.stripe.android.core.model.parsers.StripeErrorJsonParser import com.stripe.android.core.networking.ApiRequest import com.stripe.android.core.networking.StripeNetworkClient import com.stripe.android.core.networking.StripeRequest -import com.stripe.android.core.networking.executeRequestWithKSerializerParser +import com.stripe.android.core.networking.StripeResponse +import com.stripe.android.core.networking.responseJson import com.stripe.android.core.networking.toMap import com.stripe.android.core.version.StripeSdkVersion import com.stripe.android.crypto.onramp.model.CreatePaymentTokenRequest @@ -45,6 +49,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject +import org.json.JSONObject import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @@ -359,12 +364,31 @@ internal class CryptoApiRepository @Inject constructor( request: StripeRequest, responseSerializer: KSerializer, ): Result { - return executeRequestWithKSerializerParser( - stripeNetworkClient = stripeNetworkClient, - stripeErrorJsonParser = StripeErrorJsonParser(), - request = request, - responseSerializer = responseSerializer, - json = json + return runCatching { + stripeNetworkClient.executeRequest(request) + }.fold( + onSuccess = { response -> + if (response.isError) { + Result.failure(apiException(response)) + } else { + val parsedResponse = runCatching { + response.body?.let { body -> + json.decodeFromString(responseSerializer, body) + } + }.getOrNull() + + if (parsedResponse != null) { + Result.success(parsedResponse) + } else { + Result.failure( + APIException(message = "Failed to parse response JSON for ${response.body}") + ) + } + } + }, + onFailure = { + Result.failure(connectionException(request, it)) + } ).also { // If we get an authorization error, clear the Link account to force a re-authentication if (it.exceptionOrNull()?.isLinkAuthorizationError() == true) { @@ -373,7 +397,59 @@ internal class CryptoApiRepository @Inject constructor( } } + private fun apiException(response: StripeResponse): APIException { + val stripeError = try { + parseOnrampStripeError(response.responseJson()) + } catch (_: APIException) { + null + } + + return APIException( + stripeError = stripeError, + requestId = response.requestId?.value, + statusCode = response.code, + message = stripeError?.message + ?: "Request failed with status code ${response.code} and non-JSON error " + + "body." + ) + } + + private fun parseOnrampStripeError(responseJson: JSONObject): StripeError { + val stripeError = StripeErrorJsonParser().parse(responseJson) + val extraFields = stripeError.extraFields.orEmpty() + + responseJson + .optJSONObject(FIELD_ERROR) + ?.extractOnrampExtraFields() + .orEmpty() + + return stripeError.copy( + extraFields = extraFields.takeIf { it.isNotEmpty() } + ) + } + + private fun JSONObject.extractOnrampExtraFields(): Map { + return buildMap { + optStringOrNull(FIELD_REASON)?.let { put(FIELD_REASON, it) } + optStringOrNull(FIELD_USER_MESSAGE)?.let { put(FIELD_USER_MESSAGE, it) } + } + } + + private fun JSONObject.optStringOrNull(fieldName: String): String? { + return takeIf { has(fieldName) && !isNull(fieldName) } + ?.get(fieldName) + ?.toString() + } + + private fun connectionException(request: StripeRequest, cause: Throwable) = APIConnectionException( + "Failed to execute $request", + cause = cause + ) + internal companion object { + private const val FIELD_ERROR = "error" + private const val FIELD_REASON = "reason" + private const val FIELD_USER_MESSAGE = "user_message" + /** * @return `https://api.stripe.com/v1/crypto/internal/customers` */ diff --git a/crypto-onramp/src/test/java/com/stripe/android/crypto/onramp/CryptoApiRepositoryTest.kt b/crypto-onramp/src/test/java/com/stripe/android/crypto/onramp/CryptoApiRepositoryTest.kt index 10b032a98de..e9da2665a62 100644 --- a/crypto-onramp/src/test/java/com/stripe/android/crypto/onramp/CryptoApiRepositoryTest.kt +++ b/crypto-onramp/src/test/java/com/stripe/android/crypto/onramp/CryptoApiRepositoryTest.kt @@ -2,6 +2,7 @@ package com.stripe.android.crypto.onramp import com.google.common.truth.Truth.assertThat import com.stripe.android.core.ApiVersion +import com.stripe.android.core.exception.APIException import com.stripe.android.core.model.CountryCode import com.stripe.android.core.networking.ApiRequest import com.stripe.android.core.networking.StripeNetworkClient @@ -780,6 +781,51 @@ class CryptoApiRepositoryTest { } } + @Test + fun testCollectWalletAddressPreservesTopLevelOnrampErrorFields() { + runTest { + val stripeResponse = StripeResponse( + 400, + """ + { + "error": { + "code": "link_failed_to_attest_request", + "message": "App attestation failed", + "reason": "app_not_play_recognized", + "user_message": "This app couldn't be verified. Install it from Google Play and try again.", + "ignored_top_level_field": "ignored_value", + "extra_fields": { + "legacy_reason": "legacy_value" + } + } + } + """.trimIndent(), + emptyMap() + ) + + whenever(stripeNetworkClient.executeRequest(any())) + .thenReturn(stripeResponse) + + val result = cryptoApiRepository.setWalletAddress( + walletAddress = "0x1234567890abcdef", + network = CryptoNetwork.Ethereum, + consumerSessionClientSecret = "test-secret" + ) + + val error = result.exceptionOrNull() + + assertThat(error).isInstanceOf(APIException::class.java) + + val stripeError = (error as APIException).stripeError + assertThat(stripeError?.code).isEqualTo("link_failed_to_attest_request") + assertThat(stripeError?.extraFields?.get("legacy_reason")).isEqualTo("legacy_value") + assertThat(stripeError?.extraFields?.get("reason")).isEqualTo("app_not_play_recognized") + assertThat(stripeError?.extraFields?.get("user_message")) + .isEqualTo("This app couldn't be verified. Install it from Google Play and try again.") + assertThat(stripeError?.extraFields?.get("ignored_top_level_field")).isNull() + } + } + private fun assertKycCollectionRequest(apiRequest: ApiRequest) { assertThat(apiRequest.baseUrl) .isEqualTo("https://api.stripe.com/v1/crypto/internal/kyc_data_collection") diff --git a/crypto-onramp/src/test/java/com/stripe/android/crypto/onramp/OnrampInteractorTest.kt b/crypto-onramp/src/test/java/com/stripe/android/crypto/onramp/OnrampInteractorTest.kt index a6e62951a13..4eb56612159 100644 --- a/crypto-onramp/src/test/java/com/stripe/android/crypto/onramp/OnrampInteractorTest.kt +++ b/crypto-onramp/src/test/java/com/stripe/android/crypto/onramp/OnrampInteractorTest.kt @@ -1,13 +1,22 @@ package com.stripe.android.crypto.onramp +import android.app.Application import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat +import com.stripe.android.core.StripeError +import com.stripe.android.core.exception.APIException +import com.stripe.android.core.exception.InvalidRequestException import com.stripe.android.crypto.onramp.analytics.OnrampAnalyticsEvent import com.stripe.android.crypto.onramp.analytics.OnrampAnalyticsService +import com.stripe.android.crypto.onramp.exception.AppAttestationException import com.stripe.android.crypto.onramp.exception.MissingConsumerSecretException import com.stripe.android.crypto.onramp.exception.MissingCryptoCustomerException +import com.stripe.android.crypto.onramp.exception.MissingPaymentMethodException +import com.stripe.android.crypto.onramp.exception.PaymentFailedException +import com.stripe.android.crypto.onramp.exception.SDKVersion +import com.stripe.android.crypto.onramp.exception.UncategorizedApiErrorException import com.stripe.android.crypto.onramp.model.CreatePaymentTokenResponse import com.stripe.android.crypto.onramp.model.CrsCarfDeclaration import com.stripe.android.crypto.onramp.model.CryptoCustomerResponse @@ -182,6 +191,310 @@ class OnrampInteractorTest { ) } + @Test + fun testRegisterWalletAddressMapsBackendAttestationError() = runTest { + whenever(linkController.state(any())).thenReturn(MutableStateFlow(mockLinkStateWithAccount())) + whenever(linkController.configure(any())).thenReturn(ConfigureResult.Success) + val backendError = APIException( + stripeError = StripeError( + type = "api_error", + code = "link_failed_to_attest_request", + message = "App attestation failed", + extraFields = mapOf( + "reason" to "app_not_play_recognized", + "user_message" to + "This app couldn't be verified. Install it from Google Play " + + "and try again." + ) + ), + requestId = "req_123", + statusCode = 400, + ) + whenever(cryptoApiRepository.setWalletAddress(any(), any(), any())).thenReturn( + Result.failure(backendError) + ) + + interactor.onLinkControllerState(mockLinkStateWithAccount()) + interactor.configure(createConfigurationState()) + + val result = interactor.registerWalletAddress( + walletAddress = "0x1234567890abcdef", + network = CryptoNetwork.Ethereum + ) + + assertThat(result).isInstanceOf(OnrampRegisterWalletAddressResult.Failed::class.java) + + val error = (result as OnrampRegisterWalletAddressResult.Failed).error + assertThat(error).isInstanceOf(AppAttestationException::class.java) + + val attestationError = error as AppAttestationException + assertAppNotPlayRecognizedAttestationError( + attestationError = attestationError, + backendError = backendError, + ) + } + + @Test + fun testHasLinkAccountMapsAttestationInvalidRequestError() = runTest { + whenever(linkController.configure(any())).thenReturn(ConfigureResult.Success) + val failedResult = mock { + on { email } doReturn "test@example.com" + on { error } doReturn InvalidRequestException( + stripeError = StripeError( + code = "link_failed_to_attest_request", + message = "App attestation failed", + extraFields = mapOf( + "reason" to "app_not_play_recognized", + "user_message" to "This app couldn't be verified. Install it from Google Play and try again." + ) + ), + requestId = "req_456", + statusCode = 400, + ) + } + whenever(linkController.lookupConsumer(any())).thenReturn(failedResult) + + interactor.configure(createConfigurationState()) + + val result = interactor.hasLinkAccount("test@example.com") + + assertThat(result).isInstanceOf(OnrampHasLinkAccountResult.Failed::class.java) + + val error = (result as OnrampHasLinkAccountResult.Failed).error + assertThat(error).isInstanceOf(AppAttestationException::class.java) + + val attestationError = error as AppAttestationException + assertThat(attestationError.context.reason).isEqualTo("app_not_play_recognized") + assertThat(attestationError.context.mode).isEqualTo("test") + assertThat(attestationError.context.apiErrorType).isNull() + assertThat(attestationError.context.apiUserMessage) + .isEqualTo("This app couldn't be verified. Install it from Google Play and try again.") + assertThat(attestationError.message) + .isEqualTo( + "This app couldn't be verified due to an attestation error. Please try again later " + + "or contact the developer if the issue persists." + ) + assertThat(attestationError.developerMessage).contains("Request Context:") + assertThat(attestationError.developerMessage).contains("operation: has_link_account") + assertThat(attestationError.developerMessage).contains("request_id: req_456") + assertThat(attestationError.developerMessage).doesNotContain("type:") + } + + @Test + fun testHasLinkAccountMapsUncategorizedInvalidRequestError() = runTest { + whenever(linkController.configure(any())).thenReturn(ConfigureResult.Success) + val failedResult = mock { + on { email } doReturn "test@example.com" + on { error } doReturn InvalidRequestException( + stripeError = StripeError( + code = "email_blocked", + message = "This email address can't be used.", + docUrl = "https://stripe.com/docs/error-codes/email_blocked", + extraFields = mapOf( + "reason" to "email_blocked", + "user_message" to "This email can't be used. Try another one." + ) + ), + requestId = "req_789", + statusCode = 400, + ) + } + whenever(linkController.lookupConsumer(any())).thenReturn(failedResult) + + interactor.configure(createConfigurationState()) + + val result = interactor.hasLinkAccount("test@example.com") + + assertThat(result).isInstanceOf(OnrampHasLinkAccountResult.Failed::class.java) + + val error = (result as OnrampHasLinkAccountResult.Failed).error + assertThat(error).isInstanceOf(UncategorizedApiErrorException::class.java) + + val apiError = error as UncategorizedApiErrorException + assertThat(apiError.context.reason).isEqualTo("email_blocked") + assertThat(apiError.context.apiUserMessage).isEqualTo("This email can't be used. Try another one.") + assertThat(apiError.userMessage).isEqualTo("Something went wrong. Please try again later.") + assertThat(apiError.message).isEqualTo("Something went wrong. Please try again later.") + assertThat(apiError.context.apiErrorType).isNull() + assertThat(apiError.code).isEqualTo("email_blocked") + assertThat(apiError.docUrl).isEqualTo("https://stripe.com/docs/error-codes/email_blocked") + assertThat(apiError.sdkVersions).hasSize(1) + assertThat(apiError.sdkVersions.single().name).isEqualTo("stripe-android") + assertThat(apiError.sdkVersions.single().version).isNotEmpty() + assertThat(apiError.underlyingError).isSameInstanceAs(apiError.context.underlyingError) + assertThat(apiError.developerMessage) + .isEqualTo( + """ + This email address can't be used. + + Request Context: + operation: has_link_account + app_id: ${RuntimeEnvironment.getApplication().packageName} + mode: test + reason: email_blocked + request_id: req_789 + + Code: email_blocked + Next step: Inspect the preserved Stripe API error for details and retry after correcting the request. + Docs: https://stripe.com/docs/error-codes/email_blocked + SDK: ${apiError.sdkVersions.single().debugDescription} + """.trimIndent() + ) + } + + @Test + fun testHasLinkAccountIncludesAdditionalSdkVersionsInDeveloperMessage() = runTest { + whenever(linkController.configure(any())).thenReturn(ConfigureResult.Success) + val failedResult = mock { + on { email } doReturn "test@example.com" + on { error } doReturn InvalidRequestException( + stripeError = StripeError( + code = "link_failed_to_attest_request", + message = "App attestation failed", + extraFields = mapOf( + "reason" to "app_not_play_recognized", + ) + ), + requestId = "req_456", + statusCode = 400, + ) + } + whenever(linkController.lookupConsumer(any())).thenReturn(failedResult) + + interactor.configure( + createConfigurationState( + additionalSdkVersions = listOf( + SDKVersion(name = "stripe-react-native", version = "1.2.3"), + ), + ), + ) + + val result = interactor.hasLinkAccount("test@example.com") + + assertThat(result).isInstanceOf(OnrampHasLinkAccountResult.Failed::class.java) + + val error = (result as OnrampHasLinkAccountResult.Failed).error + assertThat(error).isInstanceOf(AppAttestationException::class.java) + + val attestationError = error as AppAttestationException + assertThat(attestationError.sdkVersions).containsExactly( + SDKVersion(name = "stripe-android", version = attestationError.sdkVersions.first().version), + SDKVersion(name = "stripe-react-native", version = "1.2.3"), + ).inOrder() + assertThat(attestationError.developerMessage).contains( + "SDK: stripe-android@${attestationError.sdkVersions.first().version}, stripe-react-native@1.2.3" + ) + } + + @Test + fun uncategorizedApiErrorExceptionFallsBackToSafeUserMessage() = runTest { + val application = mock { + on { packageName } doReturn "com.example.app" + on { getString(any()) } doReturn "Something went wrong. Please try again later." + } + val interactor = OnrampInteractor( + application = application, + linkController = linkController, + cryptoApiRepository = cryptoApiRepository, + analyticsServiceFactory = analyticsServiceFactory, + checkoutHandler = OnrampSessionClientSecretProvider { "test_secret" }, + savedStateHandle = SavedStateHandle() + ) + + whenever(linkController.configure(any())).thenReturn(ConfigureResult.Success) + val failedResult = mock { + on { email } doReturn "test@example.com" + on { error } doReturn InvalidRequestException( + stripeError = StripeError( + message = "Developer-facing message" + ), + requestId = "req_999", + statusCode = 400, + ) + } + whenever(linkController.lookupConsumer(any())).thenReturn(failedResult) + + interactor.configure(createConfigurationState()) + + val result = interactor.hasLinkAccount("test@example.com") + + assertThat(result).isInstanceOf(OnrampHasLinkAccountResult.Failed::class.java) + + val error = (result as OnrampHasLinkAccountResult.Failed).error + assertThat(error).isInstanceOf(UncategorizedApiErrorException::class.java) + + val apiError = error as UncategorizedApiErrorException + assertThat(apiError.userMessage).isEqualTo("Something went wrong. Please try again later.") + assertThat(apiError.message).isEqualTo("Something went wrong. Please try again later.") + assertThat(apiError.code).isEqualTo("uncategorized_api_error") + assertThat(apiError.context.apiErrorType).isNull() + assertThat(apiError.developerMessage).contains("Developer-facing message") + assertThat(apiError.developerMessage).contains("Code: uncategorized_api_error") + assertThat(apiError.developerMessage).contains("Next step:") + assertThat(apiError.developerMessage) + .contains("Inspect the preserved Stripe API error for details and retry after correcting the request.") + } + + @Test + fun appAttestationExceptionUsesSingleLocalizedFallbackUserMessage() = runTest { + val application = mock { + on { packageName } doReturn "com.example.app" + on { getString(any()) } doReturn + "This app couldn't be verified due to an attestation error. Please try again later or contact the developer if the issue persists." + } + val interactor = OnrampInteractor( + application = application, + linkController = linkController, + cryptoApiRepository = cryptoApiRepository, + analyticsServiceFactory = analyticsServiceFactory, + checkoutHandler = OnrampSessionClientSecretProvider { "test_secret" }, + savedStateHandle = SavedStateHandle() + ) + + whenever(linkController.configure(any())).thenReturn(ConfigureResult.Success) + val failedResult = mock { + on { email } doReturn "test@example.com" + on { error } doReturn InvalidRequestException( + stripeError = StripeError( + code = "link_failed_to_attest_request", + message = "App attestation failed", + extraFields = mapOf( + "reason" to "android_environment_mismatch" + ) + ), + requestId = "req_attestation_fallback", + statusCode = 400, + ) + } + whenever(linkController.lookupConsumer(any())).thenReturn(failedResult) + + interactor.configure(createConfigurationState()) + + val result = interactor.hasLinkAccount("test@example.com") + + assertThat(result).isInstanceOf(OnrampHasLinkAccountResult.Failed::class.java) + + val error = (result as OnrampHasLinkAccountResult.Failed).error + assertThat(error).isInstanceOf(AppAttestationException::class.java) + + val attestationError = error as AppAttestationException + assertThat(attestationError.context.reason).isEqualTo("android_environment_mismatch") + assertThat(attestationError.context.apiErrorType).isNull() + assertThat(attestationError.userMessage) + .isEqualTo( + "This app couldn't be verified due to an attestation error. Please try " + + "again later or contact the developer if the issue persists." + ) + assertThat(attestationError.message) + .isEqualTo( + "This app couldn't be verified due to an attestation error. Please try " + + "again later or contact the developer if the issue persists." + ) + assertThat(attestationError.developerMessage) + .contains("the Play Integrity distribution channel does not match this Stripe mode") + assertThat(attestationError.developerMessage).doesNotContain("type:") + } + @Test fun testAttachKycInfoIsSuccessful() = runTest { whenever(linkController.state(any())).thenReturn(MutableStateFlow(mockLinkStateWithAccount())) @@ -477,6 +790,35 @@ class OnrampInteractorTest { testAnalyticsService.assertContainsEvent(OnrampAnalyticsEvent.CollectPaymentMethodCompleted(null)) } + @Test + fun testHandlePresentPaymentMethodsResultMissingSelectedPaymentMethod() { + val context = RuntimeEnvironment.getApplication() + val mockState = LinkController.State( + internalLinkAccount = null, + merchantLogoUrl = null, + selectedPaymentMethodPreview = null, + createdPaymentMethod = null + ) + whenever(linkController.state(any())).thenReturn(MutableStateFlow(mockState)) + + interactor.onLinkControllerState(mockLinkStateWithAccount()) + + val result = interactor.handlePresentPaymentMethodsResult( + LinkController.PresentPaymentMethodsResult.Success, + context + ) + + assertThat(result).isInstanceOf(OnrampCollectPaymentMethodResult.Failed::class.java) + val failed = result as OnrampCollectPaymentMethodResult.Failed + assertThat(failed.error).isInstanceOf(MissingPaymentMethodException::class.java) + testAnalyticsService.assertContainsEvent( + OnrampAnalyticsEvent.ErrorOccurred( + operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.CollectPaymentMethod, + error = failed.error + ) + ) + } + @Test fun testOnAuthorize() { interactor.onLinkControllerState(mockLinkStateWithAccount()) @@ -658,6 +1000,81 @@ class OnrampInteractorTest { ) } + @Test + fun continueCheckout_withoutPendingSession_returnsFailedAndTracksError() = runTest { + interactor.onLinkControllerState(mockLinkStateWithAccount()) + + interactor.continueCheckout() + + val checkoutStatus = interactor.state.value.checkoutState?.status + assertThat(checkoutStatus).isInstanceOf(CheckoutState.Status.Completed::class.java) + val result = (checkoutStatus as CheckoutState.Status.Completed).result + assertThat(result).isInstanceOf(OnrampCheckoutResult.Failed::class.java) + val error = (result as OnrampCheckoutResult.Failed).error + assertThat(error).isInstanceOf(PaymentFailedException::class.java) + testAnalyticsService.assertContainsEvent( + OnrampAnalyticsEvent.ErrorOccurred( + operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.PerformCheckout, + error = error + ) + ) + } + + @Test + fun startCheckout_requiresPaymentMethod_returnsFailedAndTracksError() = runTest { + interactor.onLinkControllerState(mockLinkStateWithAccount()) + whenever(linkController.configure(any())).thenReturn(ConfigureResult.Success) + interactor.configure(createConfigurationState(cryptoCustomerId = "cpt_123")) + + val mockPlatformSettings = mock() + doReturn("pk_platform_123").whenever(mockPlatformSettings).publishableKey + whenever( + cryptoApiRepository.getPlatformSettings( + cryptoCustomerId = eq("cpt_123"), + countryHint = anyOrNull() + ) + ).thenReturn(Result.success(mockPlatformSettings)) + whenever( + cryptoApiRepository.getOnrampSession( + sessionId = "cos_test_session_id", + sessionClientSecret = "test_secret" + ) + ).thenReturn( + Result.success( + GetOnrampSessionResponse( + id = "cos_test_session_id", + clientSecret = "test_secret", + paymentIntentClientSecret = "pi_test_secret" + ) + ) + ) + whenever( + cryptoApiRepository.retrievePaymentIntent( + clientSecret = "pi_test_secret", + publishableKey = "pk_platform_123" + ) + ).thenReturn( + Result.success( + paymentIntent(status = StripeIntent.Status.RequiresPaymentMethod) + ) + ) + + interactor.startCheckout("cos_test_session_id") + + val checkoutStatus = interactor.state.value.checkoutState?.status + assertThat(checkoutStatus).isInstanceOf(CheckoutState.Status.Completed::class.java) + val result = (checkoutStatus as CheckoutState.Status.Completed).result + assertThat(result).isInstanceOf(OnrampCheckoutResult.Failed::class.java) + val error = (result as OnrampCheckoutResult.Failed).error + assertThat(error).isInstanceOf(PaymentFailedException::class.java) + testAnalyticsService.assertContainsEvent( + OnrampAnalyticsEvent.ErrorOccurred( + operation = OnrampAnalyticsEvent.ErrorOccurred.Operation.PerformCheckout, + error = error + ) + ) + } + @Test fun testAttachKycInfoFailsMissingSecret() = runTest { whenever( @@ -900,20 +1317,25 @@ class OnrampInteractorTest { consumerSessionClientSecret = null ) - private fun createConfigurationState(cryptoCustomerId: String? = null): OnrampConfiguration.State = + private fun createConfigurationState( + cryptoCustomerId: String? = null, + additionalSdkVersions: List = emptyList(), + ): OnrampConfiguration.State = OnrampConfiguration() .merchantDisplayName("merchant-display-name") .publishableKey("pk_test_12345") .appearance(mock()) .cryptoCustomerId(cryptoCustomerId) + .additionalSdkVersions(additionalSdkVersions) .build() private fun createInteractor( + application: Application = createApplication(), cryptoApiRepository: CryptoApiRepository, savedStateHandle: SavedStateHandle, ): OnrampInteractor { return OnrampInteractor( - application = RuntimeEnvironment.getApplication(), + application = application, linkController = linkController, cryptoApiRepository = cryptoApiRepository, analyticsServiceFactory = analyticsServiceFactory, @@ -922,6 +1344,68 @@ class OnrampInteractorTest { ) } + private fun createApplication( + defaultApiErrorUserMessage: String = "Something went wrong. Please try again later.", + defaultAppAttestationUserMessage: String = + "This app couldn't be verified due to an attestation error. Please try again later " + + "or contact the developer if the issue persists.", + ): Application { + val runtimeApplication = RuntimeEnvironment.getApplication() + + return mock { + on { packageName } doReturn runtimeApplication.packageName + on { getString(R.string.stripe_onramp_default_api_error_user_message) } doReturn + defaultApiErrorUserMessage + on { getString(R.string.stripe_onramp_app_attestation_default_user_message) } doReturn + defaultAppAttestationUserMessage + } + } + + private fun assertAppNotPlayRecognizedAttestationError( + attestationError: AppAttestationException, + backendError: APIException, + ) { + assertThat(attestationError.context.apiUserMessage) + .isEqualTo("This app couldn't be verified. Install it from Google Play and try again.") + assertThat(attestationError.userMessage) + .isEqualTo( + "This app couldn't be verified due to an attestation error. Please try again later " + + "or contact the developer if the issue persists." + ) + assertThat(attestationError.message) + .isEqualTo( + "This app couldn't be verified due to an attestation error. Please try again later " + + "or contact the developer if the issue persists." + ) + assertThat(attestationError.context.reason).isEqualTo("app_not_play_recognized") + assertThat(attestationError.context.mode).isEqualTo("test") + assertThat(attestationError.context.apiErrorType).isEqualTo("api_error") + assertThat(attestationError.code).isEqualTo("link_failed_to_attest_request") + assertThat(attestationError.docUrl).isNull() + assertThat(attestationError.sdkVersions).hasSize(1) + assertThat(attestationError.sdkVersions.single().name).isEqualTo("stripe-android") + assertThat(attestationError.sdkVersions.single().version).isNotEmpty() + assertThat(attestationError.underlyingError).isSameInstanceAs(backendError) + assertThat(attestationError.developerMessage) + .isEqualTo( + """ + App attestation failed: this app is not recognized by Google Play. + + Request Context: + operation: register_wallet_address + app_id: ${RuntimeEnvironment.getApplication().packageName} + mode: test + reason: app_not_play_recognized + request_id: req_123 + type: api_error + + Code: link_failed_to_attest_request + Next step: Install the app from a Google Play testing or production track and retry the Onramp flow. Internal, closed, open testing, and production tracks are supported. Debug builds and sideloaded APKs will not pass this check. + SDK: ${attestationError.sdkVersions.single().debugDescription} + """.trimIndent() + ) + } + private suspend fun stubCheckoutRequiresNextAction() { whenever(linkController.configure(any())).thenReturn(ConfigureResult.Success) interactor.configure(createConfigurationState(cryptoCustomerId = "cpt_123"))