diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt index 01d65c94b8..afd40ebc66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt @@ -62,6 +62,16 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) : * Payment failed by the credit card processor, with a specific reason told to us by Stripe. */ class StripeDeclinedError(source: DonationErrorSource, cause: Throwable, val declineCode: StripeDeclineCode, val method: PaymentSourceType.Stripe) : PaymentSetupError(source, cause) + + /** + * Payment setup failed in some way, which we are told about by PayPal. + */ + class PayPalCodedError(source: DonationErrorSource, cause: Throwable, val errorCode: Int) : PaymentSetupError(source, cause) + + /** + * Payment failed by the credit card processor, with a specific reason told to us by PayPal. + */ + class PayPalDeclinedError(source: DonationErrorSource, cause: Throwable, val code: PayPalDeclineCode.KnownCode) : PaymentSetupError(source, cause) } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/PayPalDeclineCode.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/PayPalDeclineCode.kt new file mode 100644 index 0000000000..1cefcb49e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/PayPalDeclineCode.kt @@ -0,0 +1,128 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.errors + +/** + * From: https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses#decline-codes + */ +data class PayPalDeclineCode( + val code: Int +) { + + val knownCode: KnownCode? = KnownCode.fromCode(code) + + enum class KnownCode(val code: Int) { + DO_NOT_HONOR(2000), + INSUFFICIENT_FUNDS(2001), + LIMIT_EXCEEDED(2002), + CARDHOLDER_ACTIVITY_LIMIT_EXCEEDED(2003), + EXPIRED_CARD(2004), + INVALID_CREDIT_CARD(2005), + INVALID_EXPIRATION_DATE(2006), + NO_ACCOUNT(2007), + CARD_ACCOUNT_LENGTH_ERROR(2008), + NO_SUCH_ISSUER(2009), + CARD_ISSUER_DECLINED_CVV(2010), + VOICE_AUTHORIZATION_REQUIRED(2011), + PROCESSOR_DECLINED_POSSIBLE_LOST_CARD(2012), + PROCESSOR_DECLINED_POSSIBLE_STOLEN_CARD(2013), + PROCESSOR_DECLINED_FRAUD_SUSPECTED(2014), + TRANSACTION_NOT_ALLOWED(2015), + DUPLICATE_TRANSACTION(2016), + CARDHOLDER_STOPPED_BILLING(2017), + CARDHOLDER_STOPPED_ALL_BILLING(2018), + INVALID_TRANSACTION(2019), + VIOLATION(2020), + SECURITY_VIOLATION(2021), + DECLINED_UPDATED_CARDHOLDER_AVAILABLE(2022), + PROCESSOR_DOES_NOT_SUPPORT_THIS_FEATURE(2023), + CARD_TYPE_NOT_ENABLED(2024), + SET_UP_ERROR_MERCHANT(2025), + INVALID_MERCHANT_ID(2026), + SET_UP_ERROR_AMOUNT(2027), + SET_UP_ERROR_HIERARCHY(2028), + SET_UP_ERROR_CARD(2029), + SET_UP_ERROR_TERMINAL(2030), + ENCRYPTION_ERROR(2031), + SURCHARGE_NOT_PERMITTED(2032), + INCONSISTENT_DATA(2033), + NO_ACTION_TAKEN(2034), + PARTIAL_APPROVAL_FOR_AMOUNT_IN_GROUP_3_VERSION(2035), + AUTHORIZATION_COULD_NOT_BE_FOUND(2036), + ALREADY_REVERSED(2037), + PROCESSOR_DECLINED(2038), + INVALID_AUTHORIZATION_CODE(2039), + INVALID_STORE(2040), + DECLINED_CALL_FOR_APPROVAL(2041), + INVALID_CLIENT_ID(2042), + ERROR_DO_NOT_RETRY_CALL_ISSUER(2043), + DECLINED_CALL_ISSUER(2044), + INVALID_MERCHANT_NUMBER(2045), + DECLINED(2046), + CALL_ISSUER_PICK_UP_CARD(2047), + INVALID_AMOUNT(2048), + INVALID_SKU_NUMBER(2049), + INVALID_CREDIT_PLAN(2050), + CREDIT_CARD_NUMBER_DOES_NOT_MATCH_METHOD_OF_PAYMENT(2051), + INVALID_LEVEL_3_PURCHASE(2052), + CARD_REPORTED_AS_LOST_OR_STOLEN(2053), + REVERSAL_AMOUNT_DOES_NOT_MATCH_AUTHORIZATION_AMOUNT(2054), + INVALID_TRANSACTION_DIVISION_NUMBER(2055), + TRANSACTION_AMOUNT_EXCEEDS_THE_TRANSACTION_DIVISION_LIMIT(2056), + ISSUER_OR_CARDHOLDER_HAS_PUT_A_RESTRICTION_ON_THE_CARD(2057), + MERCHANT_NOT_MASTERCARD_SECURECODE_ENABLED(2058), + ADDRESS_VERIFICATION_FAILED(2059), + ADDRESS_VERIFICATION_AND_CARD_SECURITY_CODE_FAILED(2060), + INVALID_TRANSACTION_DATA(2061), + INVALID_TAX_AMOUNT(2062), + PAYPAL_BUSINESS_ACCOUNT_PREFERENCE_RESULTED_IN_THE_TRANSACTION_FAILING(2063), + INVALID_CURRENCY_CODE(2064), + REFUND_TIME_LIMIT_EXCEEDED(2065), + PAYPAL_BUSINESS_ACCOUNT_RESTRICTED(2066), + AUTHORIZATION_EXPIRED(2067), + PAYPAL_BUSINESS_ACCOUNT_LOCKED_OR_CLOSED(2068), + PAYPAL_BLOCKING_DUPLICATE_ORDER_IDS(2069), + PAYPAL_BUYER_REVOKED_PRE_APPROVED_PAYMENT_AUTHORIZATION(2070), + PAYPAL_PAYEE_ACCOUNT_INVALID_OR_DOES_NOT_HAVE_A_VERIFIED_EMAIL(2071), + PAYPAL_PAYEE_EMAIL_INCORRECTLY_FORMATTED(2072), + PAYPAL_VALIDATION_ERROR(2073), + FUNDING_INSTRUMENT_IN_THE_PAYPAL_ACCOUNT_WAS_DECLINED_BY_THE_PROCESSR_OR_BANK_OR_IT_CANT_BE_USED_FOR_THIS_PAYMENT(2074), + PAYER_ACCOUNT_IS_LOCKED_OR_CLOSED(2075), + PAYER_CANNOT_PAY_FOR_THIS_TRANSACTION_WITH_PAYPAL(2076), + TRANSACTION_REFUSED_DUE_TO_PAYPAL_RISK_MODEL(2077), + INVALID_SECURE_PAYMENT_DATA(2078), + PAYPAL_MERCHANT_ACCOUNT_CONFIGURATION_ERROR(2079), + INVALID_USER_CREDENTIALS(2080), + PAYPAL_PENDING_PAYMENTS_ARE_NOT_SUPPORTED(2081), + PAYPAL_DOMESTIC_TRANSACTION_REQUIRED(2082), + PAYPAL_PHONE_NUMBER_REQUIRED(2083), + PAYPAL_TAX_INFO_REQUIRED(2084), + PAYPAL_PAYEE_BLOCKED_TRANSACTION(2085), + PAYPAL_TRANSACTION_LIMIT_EXCEEDED(2086), + PAYPAL_REFERENCE_TRANSACTIONS_ARE_NOT_ENABLED_FOR_YOUR_ACCOUNT(2087), + CURRENCY_NOT_ENABLED_FOR_YOUR_PAYPAL_SELLER_ACCOUNT(2088), + PAYPAL_PAYEE_EMAIL_PERMISSION_DENIED_FOR_THIS_REQUEST(2089), + PAYPAL_OR_VENMO_ACCOUNT_NOT_CONFIGURED_TO_REFUND_MORE_THAN_SETTLED_AMOUNT(2090), + CURRENCY_OF_THIS_TRANSACTION_MUST_MATCH_CURRENCY_OF_YOUR_PAYPAL_ACCOUNT(2091), + NO_DATA_FOUND_TRY_ANOTHER_VERIFICATION_METHOD(2092), + PAYPAL_PAYMENT_METHOD_IS_INVALID(2093), + PAYPAL_PAYMENT_HAS_ALREADY_BEEN_COMPLETED(2094), + PAYPAL_REFUND_IS_NOT_ALLOWED_AFTER_PARTIAL_REFUND(2095), + PAYPAL_BUYER_ACCOUNT_CANT_BE_THE_SAME_AS_THE_SELLER_ACCOUNT(2096), + PAYPAL_AUTHORIZATION_AMOUNT_LIMIT_EXCEEDED(2097), + PAYPAL_AUTHORIZATION_COUNT_LIMIT_EXCEEDED(2098), + CARDHOLDER_AUTHORIZATION_REQUIRED(2099), + PAYPAL_CHANNEL_INITIATED_BILLING_NOT_ENABLED_FOR_YOUR_ACCOUNT(2100), + ADDITIONAL_AUTHORIZATION_REQUIRED(2101), + INCORRECT_PIN(2102), + PIN_TRY_EXCEEDED(2103), + OFFLINE_ISSUER_DECLINED(2104), + CANNOT_AUTHORIZE_AT_THIS_TIME_LIFE_CYCLE(2105), + CANNOT_AUTHORIZE_AT_THIS_TIME_POLICY(2106), + CARD_NOT_ACTIVATED(2107), + CLOSED_CARD(2108), + PROCESSOR_NETWORK_UNAVAILABLE_TRY_AGAIN(3000); + + companion object { + fun fromCode(code: Int): KnownCode? = values().firstOrNull { it.code == code } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java index 2b3b7282c0..7bc18a6ebd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -16,6 +16,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext; import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; 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.PayPalDeclineCode; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.DonationReceiptRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -139,11 +140,11 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { if (isForKeepAlive) { Log.w(TAG, "Subscription payment failure in active subscription response (status = " + subscription.getStatus() + ").", true); - onPaymentFailure(subscription.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod(), true); + onPaymentFailure(subscription.getStatus(), subscription.getProcessor(), chargeFailure, subscription.getEndOfCurrentPeriod(), true); throw new Exception("Active subscription hit a payment failure: " + subscription.getStatus()); } else { Log.w(TAG, "New subscription has hit a payment failure. (status = " + subscription.getStatus() + ").", true); - onPaymentFailure(subscription.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod(), false); + onPaymentFailure(subscription.getStatus(), subscription.getProcessor(), chargeFailure, subscription.getEndOfCurrentPeriod(), false); throw new Exception("New subscription has hit a payment failure: " + subscription.getStatus()); } } else if (!subscription.isActive()) { @@ -153,7 +154,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { if (!isForKeepAlive) { Log.w(TAG, "Initial subscription payment failed, treating as a permanent failure."); - onPaymentFailure(subscription.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod(), false); + onPaymentFailure(subscription.getStatus(), subscription.getProcessor(), chargeFailure, subscription.getEndOfCurrentPeriod(), false); throw new Exception("New subscription has hit a payment failure."); } } @@ -283,7 +284,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { * 1. In the case of a keep-alive event, we want to book-keep the error to show the user on a subsequent launch, and we want to sync our failure state to * linked devices. */ - private void onPaymentFailure(@NonNull String status, @Nullable ActiveSubscription.ChargeFailure chargeFailure, long timestamp, boolean isForKeepAlive) { + private void onPaymentFailure(@NonNull String status, @NonNull ActiveSubscription.Processor processor, @Nullable ActiveSubscription.ChargeFailure chargeFailure, long timestamp, boolean isForKeepAlive) { SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true); if (isForKeepAlive) { Log.d(TAG, "Is for a keep-alive and we have a status. Setting UnexpectedSubscriptionCancelation state...", true); @@ -291,8 +292,8 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(status); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationTimestamp(timestamp); MultiDeviceSubscriptionSyncRequestJob.enqueue(); - } else if (chargeFailure != null) { - Log.d(TAG, "Charge failure detected: " + chargeFailure, true); + } else if (chargeFailure != null && processor == ActiveSubscription.Processor.STRIPE) { + Log.d(TAG, "Stripe charge failure detected: " + chargeFailure, true); StripeDeclineCode declineCode = StripeDeclineCode.Companion.getFromCode(chargeFailure.getOutcomeNetworkReason()); DonationError.PaymentSetupError paymentSetupError; @@ -319,6 +320,44 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { ); } + Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true); + DonationError.routeDonationError(context, paymentSetupError); + } else if (chargeFailure != null && processor == ActiveSubscription.Processor.BRAINTREE) { + Log.d(TAG, "PayPal charge failure detected: " + chargeFailure, true); + + + int code; + try { + code = Integer.parseInt(chargeFailure.getCode()); + } catch (NumberFormatException e) { + Log.w(TAG, "PayPal charge failure code had unexpected type."); + code = -1; + } + + PayPalDeclineCode declineCode = new PayPalDeclineCode(code); + DonationError.PaymentSetupError paymentSetupError; + PaymentSourceType paymentSourceType = SignalStore.donationsValues().getSubscriptionPaymentSourceType(); + boolean isPayPalSource = paymentSourceType instanceof PaymentSourceType.PayPal; + + if (declineCode.getKnownCode() != null && isPayPalSource) { + paymentSetupError = new DonationError.PaymentSetupError.PayPalDeclinedError( + getErrorSource(), + new Exception(chargeFailure.getMessage()), + declineCode.getKnownCode() + ); + } else if (isPayPalSource) { + paymentSetupError = new DonationError.PaymentSetupError.PayPalCodedError( + getErrorSource(), + new Exception("Card was declined. " + chargeFailure.getCode()), + code + ); + } else { + paymentSetupError = new DonationError.PaymentSetupError.GenericError( + getErrorSource(), + new Exception("Payment Failed for " + paymentSourceType.getCode()) + ); + } + Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true); DonationError.routeDonationError(context, paymentSetupError); } else { 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 73a790451a..71810604ba 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 @@ -15,6 +15,27 @@ public final class ActiveSubscription { public static final ActiveSubscription EMPTY = new ActiveSubscription(null, null); + public enum Processor { + STRIPE("STRIPE"), + BRAINTREE("BRAINTREE"); + + private final String code; + + Processor(String code) { + this.code = code; + } + + static Processor fromCode(String code) { + for (Processor value : Processor.values()) { + if (value.code.equals(code)) { + return value; + } + } + + return STRIPE; + } + } + private enum Status { /** * The subscription is currently in a trial period and it's safe to provision your product for your customer. @@ -121,6 +142,7 @@ public final class ActiveSubscription { private final long billingCycleAnchor; private final boolean willCancelAtPeriodEnd; private final String status; + private final Processor processor; @JsonCreator public Subscription(@JsonProperty("level") int level, @@ -130,7 +152,8 @@ public final class ActiveSubscription { @JsonProperty("active") boolean isActive, @JsonProperty("billingCycleAnchor") long billingCycleAnchor, @JsonProperty("cancelAtPeriodEnd") boolean willCancelAtPeriodEnd, - @JsonProperty("status") String status) + @JsonProperty("status") String status, + @JsonProperty("processor") String processor) { this.level = level; this.currency = currency; @@ -140,6 +163,7 @@ public final class ActiveSubscription { this.billingCycleAnchor = billingCycleAnchor; this.willCancelAtPeriodEnd = willCancelAtPeriodEnd; this.status = status; + this.processor = Processor.fromCode(processor); } public int getLevel() { @@ -190,6 +214,10 @@ public final class ActiveSubscription { return status; } + public Processor getProcessor() { + return processor; + } + public boolean isInProgress() { return !isActive() && !Status.isPaymentFailed(getStatus()); }