diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt index 1be0520133..ad18145848 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -28,6 +29,7 @@ import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentInt import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse import org.whispersystems.signalservice.api.util.Preconditions import org.whispersystems.signalservice.internal.push.DonationProcessor +import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError class PayPalPaymentInProgressViewModel( private val payPalRepository: PayPalRepository, @@ -89,10 +91,10 @@ class PayPalPaymentInProgressViewModel( }, onError = { throwable -> Log.w(TAG, "Failed to update subscription", throwable, true) - val donationError: DonationError = if (throwable is DonationError) { - throwable - } else { - DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) + val donationError: DonationError = when (throwable) { + is DonationError -> throwable + is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.PayPal) + else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) } DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) @@ -166,10 +168,10 @@ class PayPalPaymentInProgressViewModel( Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true) store.update { DonationProcessorStage.FAILED } - val donationError: DonationError = if (throwable is DonationError) { - throwable - } else { - DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST) + val donationError: DonationError = when (throwable) { + is DonationError -> throwable + is DonationProcessorError -> throwable.toDonationError(request.donateToSignalType.toErrorSource(), PaymentSourceType.PayPal) + else -> DonationError.genericBadgeRedemptionFailure(request.donateToSignalType.toErrorSource()) } DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) }, @@ -196,10 +198,10 @@ class PayPalPaymentInProgressViewModel( Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true) store.update { DonationProcessorStage.FAILED } - val donationError: DonationError = if (throwable is DonationError) { - throwable - } else { - DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) + val donationError: DonationError = when (throwable) { + is DonationError -> throwable + is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.PayPal) + else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) } DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index 4f08dc7538..95cca1a980 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -23,12 +23,14 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.rx.RxStore import org.whispersystems.signalservice.api.util.Preconditions import org.whispersystems.signalservice.internal.push.DonationProcessor +import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError class StripePaymentInProgressViewModel( private val stripeRepository: StripeRepository, @@ -148,10 +150,10 @@ class StripePaymentInProgressViewModel( } .flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it, paymentSourceProvider.paymentSourceType) } .onErrorResumeNext { - if (it is DonationError) { - Completable.error(it) - } else { - Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, paymentSourceProvider.paymentSourceType)) + when { + it is DonationError -> Completable.error(it) + it is DonationProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.SUBSCRIPTION, paymentSourceProvider.paymentSourceType)) + else -> Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, paymentSourceProvider.paymentSourceType)) } } @@ -211,10 +213,10 @@ class StripePaymentInProgressViewModel( Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true) store.update { DonationProcessorStage.FAILED } - val donationError: DonationError = if (throwable is DonationError) { - throwable - } else { - DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST) + val donationError: DonationError = when (throwable) { + is DonationError -> throwable + is DonationProcessorError -> throwable.toDonationError(request.donateToSignalType.toErrorSource(), paymentSourceProvider.paymentSourceType) + else -> DonationError.genericBadgeRedemptionFailure(request.donateToSignalType.toErrorSource()) } DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) }, @@ -256,10 +258,10 @@ class StripePaymentInProgressViewModel( }, onError = { throwable -> Log.w(TAG, "Failed to update subscription", throwable, true) - val donationError: DonationError = if (throwable is DonationError) { - throwable - } else { - DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) + val donationError: DonationError = when (throwable) { + is DonationError -> throwable + is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.Stripe.GooglePay) + else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) } DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationProcessorErrorExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationProcessorErrorExtensions.kt new file mode 100644 index 0000000000..55b7406741 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationProcessorErrorExtensions.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.errors + +import org.signal.donations.PaymentSourceType +import org.signal.donations.StripeDeclineCode +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription +import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError + +fun DonationProcessorError.toDonationError( + source: DonationErrorSource, + method: PaymentSourceType +): DonationError { + return when (processor) { + ActiveSubscription.Processor.STRIPE -> { + check(method is PaymentSourceType.Stripe) + val declineCode = StripeDeclineCode.getFromCode(chargeFailure.code) + if (declineCode.isKnown()) { + DonationError.PaymentSetupError.StripeDeclinedError(source, this, declineCode, method) + } else if (chargeFailure.code != null) { + DonationError.PaymentSetupError.StripeCodedError(source, this, chargeFailure.code) + } else { + DonationError.PaymentSetupError.GenericError(source, this) + } + } + ActiveSubscription.Processor.BRAINTREE -> { + check(method is PaymentSourceType.PayPal) + val code = chargeFailure.code + if (code == null) { + DonationError.PaymentSetupError.GenericError(source, this) + } else { + val declineCode = PayPalDeclineCode.KnownCode.fromCode(code.toInt()) + if (declineCode != null) { + DonationError.PaymentSetupError.PayPalDeclinedError(source, this, declineCode) + } else { + DonationError.PaymentSetupError.PayPalCodedError(source, this, code.toInt()) + } + } + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java index 71810604ba..926afdff25 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java @@ -25,6 +25,10 @@ public final class ActiveSubscription { this.code = code; } + public String getCode() { + return code; + } + static Processor fromCode(String code) { for (Processor value : Processor.values()) { if (value.code.equals(code)) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index a7da200d8e..b894218aa2 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -112,6 +112,7 @@ import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupRequ import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResponse; import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse; import org.whispersystems.signalservice.internal.crypto.AttachmentDigest; +import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError; import org.whispersystems.signalservice.internal.push.exceptions.ForbiddenException; import org.whispersystems.signalservice.internal.push.exceptions.GroupExistsException; import org.whispersystems.signalservice.internal.push.exceptions.GroupMismatchedDevicesException; @@ -1157,7 +1158,7 @@ public class PushServiceSocket { public PayPalConfirmPaymentIntentResponse confirmPayPalOneTimePaymentIntent(String currency, String amount, long level, String payerId, String paymentId, String paymentToken) throws IOException { String payload = JsonUtil.toJson(new PayPalConfirmOneTimePaymentIntentPayload(amount, currency, level, payerId, paymentId, paymentToken)); Log.d(TAG, payload); - String result = makeServiceRequestWithoutAuthentication(CONFIRM_PAYPAL_ONE_TIME_PAYMENT_INTENT, "POST", payload); + String result = makeServiceRequestWithoutAuthentication(CONFIRM_PAYPAL_ONE_TIME_PAYMENT_INTENT, "POST", payload, NO_HEADERS, new DonationResponseHandler()); return JsonUtil.fromJsonResponse(result, PayPalConfirmPaymentIntentResponse.class); } @@ -1209,7 +1210,7 @@ public class PushServiceSocket { } public void updateSubscriptionLevel(String subscriberId, String level, String currencyCode, String idempotencyKey) throws IOException { - makeServiceRequestWithoutAuthentication(String.format(UPDATE_SUBSCRIPTION_LEVEL, subscriberId, level, currencyCode, idempotencyKey), "PUT", ""); + makeServiceRequestWithoutAuthentication(String.format(UPDATE_SUBSCRIPTION_LEVEL, subscriberId, level, currencyCode, idempotencyKey), "PUT", "", NO_HEADERS, new DonationResponseHandler()); } public ActiveSubscription getSubscription(String subscriberId) throws IOException { @@ -2760,6 +2761,24 @@ public class PushServiceSocket { makeServiceRequest(String.format(REPORT_SPAM, serviceId.toString(), serverGuid), "POST", JsonUtil.toJson(new SpamTokenMessage(reportingToken))); } + /** + * Handler for a couple donation endpoints. + */ + private static class DonationResponseHandler implements ResponseCodeHandler { + @Override + public void handle(int responseCode, ResponseBody body) throws NonSuccessfulResponseCodeException, PushNetworkException { + if (responseCode == 440) { + try { + throw JsonUtil.fromJson(body.string(), DonationProcessorError.class); + } catch (IOException e) { + throw new NonSuccessfulResponseCodeException(440); + } + } else { + throw new NonSuccessfulResponseCodeException(responseCode); + } + } + } + private static class RegistrationSessionResponseHandler implements ResponseCodeHandler { @Override diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorError.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorError.kt new file mode 100644 index 0000000000..db07d9726e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorError.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.internal.push.exceptions + +import com.fasterxml.jackson.annotation.JsonCreator +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.Processor + +/** + * HTTP 440 Exception when something bad happens while updating a user's subscription level or + * confirming a PayPal intent. + */ +class DonationProcessorError @JsonCreator constructor( + val processor: Processor, + val chargeFailure: ChargeFailure +) : NonSuccessfulResponseCodeException(440) { + override fun toString(): String { + return """ + DonationProcessorError (440) + Processor: $processor + Charge Failure: $chargeFailure + """.trimIndent() + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorErrorTest.kt b/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorErrorTest.kt new file mode 100644 index 0000000000..598096d715 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorErrorTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.internal.push.exceptions + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription +import org.whispersystems.signalservice.internal.util.JsonUtil + +class DonationProcessorErrorTest { + + companion object { + private val TEST_PROCESSOR = ActiveSubscription.Processor.STRIPE + private const val TEST_CODE = "account_closed" + private const val TEST_MESSAGE = "test_message" + private const val TEST_OUTCOME_NETWORK_STATUS = "test_outcomeNetworkStatus" + private const val TEST_OUTCOME_REASON = "test_outcomeReason" + private const val TEST_OUTCOME_TYPE = "test_outcomeType" + private val TEST_JSON = """ + { + "processor": "${TEST_PROCESSOR.code}", + "chargeFailure": { + "code": "$TEST_CODE", + "message": "$TEST_MESSAGE", + "outcomeNetworkStatus": "$TEST_OUTCOME_NETWORK_STATUS", + "outcomeReason": "$TEST_OUTCOME_REASON", + "outcomeType": "$TEST_OUTCOME_TYPE" + } + + } + """.trimIndent() + } + + @Test + fun givenTestJson_whenIFromJson_thenIExpectProperlyParsedError() { + val result = JsonUtil.fromJson(TEST_JSON, DonationProcessorError::class.java) + + assertEquals(TEST_PROCESSOR, result.processor) + assertEquals(TEST_CODE, result.chargeFailure.code) + assertEquals(TEST_OUTCOME_TYPE, result.chargeFailure.outcomeType) + assertEquals(TEST_OUTCOME_NETWORK_STATUS, result.chargeFailure.outcomeNetworkStatus) + } +}