From 115f7063d53ef48ae9932bea167c935d2533e103 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 18 Apr 2022 16:37:12 -0300 Subject: [PATCH] Add support and tracking of ChargeFailure in ActiveSubscription. --- .../subscription/SubscriptionsRepository.kt | 2 +- .../subscribe/SubscribeViewModel.kt | 4 +- .../jobs/SubscriptionKeepAliveJob.java | 1 + ...SubscriptionReceiptRequestResponseJob.java | 20 +++-- .../securesms/keyvalue/DonationsValues.kt | 20 +++++ .../api/subscriptions/ActiveSubscription.java | 89 ++++++++++++++++++- .../api/services/DonationsServiceTest.java | 2 +- 7 files changed, 125 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt index 1c41cdb5f..78159ddb8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt @@ -25,7 +25,7 @@ class SubscriptionsRepository(private val donationsService: DonationsService) { donationsService.getSubscription(localSubscription.subscriberId) .flatMap(ServiceResponse::flattenResult) } else { - Single.just(ActiveSubscription(null)) + Single.just(ActiveSubscription.EMPTY) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt index 7f7805a0c..3e8fa9159 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt @@ -148,7 +148,7 @@ class SubscribeViewModel( .getActiveSubscription() .subscribeBy( onSuccess = { activeSubscriptionSubject.onNext(it) }, - onError = { activeSubscriptionSubject.onNext(ActiveSubscription(null)) } + onError = { activeSubscriptionSubject.onNext(ActiveSubscription.EMPTY) } ) } @@ -167,6 +167,7 @@ class SubscribeViewModel( SignalStore.donationsValues().setLastEndOfPeriod(0L) SignalStore.donationsValues().clearLevelOperations() SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = false + SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(null) SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = 0L MultiDeviceSubscriptionSyncRequestJob.enqueue() @@ -185,6 +186,7 @@ class SubscribeViewModel( SignalStore.donationsValues().setLastEndOfPeriod(0L) SignalStore.donationsValues().clearLevelOperations() SignalStore.donationsValues().markUserManuallyCancelled() + SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(null) SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = 0L refreshActiveSubscription() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java index c511bd754..809c68dee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java @@ -97,6 +97,7 @@ public class SubscriptionKeepAliveJob extends BaseJob { if (activeSubscription.isFailedPayment()) { Log.i(TAG, "User has a subscription with a failed payment. Marking the payment failure. Status message: " + activeSubscription.getActiveSubscription().getStatus(), true); + SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(activeSubscription.getChargeFailure()); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(activeSubscription.getActiveSubscription().getStatus()); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationTimestamp(activeSubscription.getActiveSubscription().getEndOfCurrentPeriod()); return; 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 7212f398c..9dc8a1c68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -138,13 +138,20 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { } private void doRun() throws Exception { - ActiveSubscription.Subscription subscription = getLatestSubscriptionInformation(); + ActiveSubscription activeSubscription = getLatestSubscriptionInformation(); + ActiveSubscription.Subscription subscription = activeSubscription.getActiveSubscription(); + if (subscription == null) { Log.w(TAG, "Subscription is null.", true); throw new RetryableException(); } else if (subscription.isFailedPayment()) { + ActiveSubscription.ChargeFailure chargeFailure = activeSubscription.getChargeFailure(); + if (chargeFailure != null) { + Log.w(TAG, "Subscription payment charge failure code: " + chargeFailure.getCode() + ", message: " + chargeFailure.getMessage(), true); + } + Log.w(TAG, "Subscription payment failure in active subscription response (status = " + subscription.getStatus() + ").", true); - onPaymentFailure(subscription.getStatus(), subscription.getEndOfCurrentPeriod()); + onPaymentFailure(subscription.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod()); throw new Exception("Subscription has a payment failure: " + subscription.getStatus()); } else if (!subscription.isActive()) { Log.w(TAG, "Subscription is not yet active. Status: " + subscription.getStatus(), true); @@ -184,13 +191,13 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { } } - private @Nullable ActiveSubscription.Subscription getLatestSubscriptionInformation() throws Exception { + private @NonNull ActiveSubscription getLatestSubscriptionInformation() throws Exception { ServiceResponse activeSubscription = ApplicationDependencies.getDonationsService() .getSubscription(subscriberId) .blockingGet(); if (activeSubscription.getResult().isPresent()) { - return activeSubscription.getResult().get().getActiveSubscription(); + return activeSubscription.getResult().get(); } else if (activeSubscription.getApplicationError().isPresent()) { Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.", activeSubscription.getApplicationError().get(), true); DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource())); @@ -234,7 +241,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { throw new Exception(response.getApplicationError().get()); case 402: Log.w(TAG, "Subscription payment failure in credential response.", response.getApplicationError().get(), true); - onPaymentFailure(null, 0L); + onPaymentFailure(null, null, 0L); throw new Exception(response.getApplicationError().get()); case 403: Log.w(TAG, "SubscriberId password mismatch or account auth was present.", response.getApplicationError().get(), true); @@ -253,11 +260,12 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { } } - private void onPaymentFailure(@Nullable String status, long timestamp) { + private void onPaymentFailure(@Nullable String status, @Nullable ActiveSubscription.ChargeFailure chargeFailure, long timestamp) { SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true); if (status == null) { DonationError.routeDonationError(context, DonationError.genericPaymentFailure(getErrorSource())); } else { + SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(chargeFailure); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(status); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationTimestamp(timestamp); MultiDeviceSubscriptionSyncRequestJob.enqueue(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt index 211c85ffd..d258b50e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -11,8 +11,10 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList import org.thoughtcrime.securesms.payments.currency.CurrencyUtil import org.thoughtcrime.securesms.subscription.LevelUpdateOperation import org.thoughtcrime.securesms.subscription.Subscriber +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey import org.whispersystems.signalservice.api.subscriptions.SubscriberId +import org.whispersystems.signalservice.internal.util.JsonUtil import java.util.Currency import java.util.Locale import java.util.concurrent.TimeUnit @@ -34,6 +36,7 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign private const val DISPLAY_BADGES_ON_PROFILE = "donation.display.badges.on.profile" private const val SUBSCRIPTION_REDEMPTION_FAILED = "donation.subscription.redemption.failed" private const val SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT = "donation.should.cancel.subscription.before.next.subscribe.attempt" + private const val SUBSCRIPTION_CANCELATION_CHARGE_FAILURE = "donation.subscription.cancelation.charge.failure" private const val SUBSCRIPTION_CANCELATION_REASON = "donation.subscription.cancelation.reason" private const val SUBSCRIPTION_CANCELATION_TIMESTAMP = "donation.subscription.cancelation.timestamp" private const val SUBSCRIPTION_CANCELATION_WATERMARK = "donation.subscription.cancelation.watermark" @@ -233,6 +236,23 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign putBoolean(SUBSCRIPTION_REDEMPTION_FAILED, false) } + fun setUnexpectedSubscriptionCancelationChargeFailure(chargeFailure: ActiveSubscription.ChargeFailure?) { + if (chargeFailure == null) { + remove(SUBSCRIPTION_CANCELATION_CHARGE_FAILURE) + } else { + putString(SUBSCRIPTION_CANCELATION_CHARGE_FAILURE, JsonUtil.toJson(chargeFailure)) + } + } + + fun getUnexpectedSubscriptionCancelationChargeFailure(): ActiveSubscription.ChargeFailure? { + val json = getString(SUBSCRIPTION_CANCELATION_CHARGE_FAILURE, null) + return if (json.isNullOrEmpty()) { + null + } else { + JsonUtil.fromJson(json, ActiveSubscription.ChargeFailure::class.java) + } + } + var unexpectedSubscriptionCancelationReason: String? by stringValue(SUBSCRIPTION_CANCELATION_REASON, null) var unexpectedSubscriptionCancelationTimestamp: Long by longValue(SUBSCRIPTION_CANCELATION_TIMESTAMP, 0L) var unexpectedSubscriptionCancelationWatermark: Long by longValue(SUBSCRIPTION_CANCELATION_WATERMARK, 0L) 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 f1b56dbea..dbc036a87 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 @@ -11,6 +11,8 @@ import java.util.Set; public final class ActiveSubscription { + public static final ActiveSubscription EMPTY = new ActiveSubscription(null, null); + private enum Status { /** * The subscription is currently in a trial period and it's safe to provision your product for your customer. @@ -35,7 +37,7 @@ public final class ActiveSubscription { INCOMPLETE_EXPIRED("incomplete_expired"), /** - * Payment on the latest invoice either failed or wasn't attempted. + * Payment on the latest invoice either failed or wasn't attempted. */ PAST_DUE("past_due"), @@ -78,17 +80,25 @@ public final class ActiveSubscription { } } - private final Subscription activeSubscription; + private final Subscription activeSubscription; + private final ChargeFailure chargeFailure; @JsonCreator - public ActiveSubscription(@JsonProperty("subscription") Subscription activeSubscription) { + public ActiveSubscription(@JsonProperty("subscription") Subscription activeSubscription, + @JsonProperty("chargeFailure") ChargeFailure chargeFailure) + { this.activeSubscription = activeSubscription; + this.chargeFailure = chargeFailure; } public Subscription getActiveSubscription() { return activeSubscription; } + public ChargeFailure getChargeFailure() { + return chargeFailure; + } + public boolean isActive() { return activeSubscription != null && activeSubscription.isActive(); } @@ -98,7 +108,7 @@ public final class ActiveSubscription { } public boolean isFailedPayment() { - return activeSubscription != null && !isActive() && activeSubscription.isFailedPayment(); + return chargeFailure != null || (activeSubscription != null && !isActive() && activeSubscription.isFailedPayment()); } public static final class Subscription { @@ -199,4 +209,75 @@ public final class ActiveSubscription { return Objects.hash(level, currency, amount, endOfCurrentPeriod, isActive, billingCycleAnchor, willCancelAtPeriodEnd, status); } } + + public static final class ChargeFailure { + private final String code; + private final String message; + private final String outcomeNetworkStatus; + private final String outcomeNetworkReason; + private final String outcomeType; + + @JsonCreator + public ChargeFailure(@JsonProperty("code") String code, + @JsonProperty("message") String message, + @JsonProperty("outcomeNetworkStatus") String outcomeNetworkStatus, + @JsonProperty("outcomeNetworkReason") String outcomeNetworkReason, + @JsonProperty("outcomeType") String outcomeType) + { + this.code = code; + this.message = message; + this.outcomeNetworkStatus = outcomeNetworkStatus; + this.outcomeNetworkReason = outcomeNetworkReason; + this.outcomeType = outcomeType; + } + + /** + * Error code explaining reason for charge failure if available (see the errors section for a list of codes). + *

+ * See: https://stripe.com/docs/api/charges/object#charge_object-failure_code + */ + public String getCode() { + return code; + } + + /** + * Message to user further explaining reason for charge failure if available. + *

+ * See: https://stripe.com/docs/api/charges/object#charge_object-failure_message + */ + public String getMessage() { + return message; + } + + /** + * Possible values are approved_by_network, declined_by_network, not_sent_to_network, and reversed_after_approval. + * The value reversed_after_approval indicates the payment was blocked by Stripe after bank authorization, + * and may temporarily appear as “pending” on a cardholder’s statement. + *

+ * See: https://stripe.com/docs/api/charges/object#charge_object-outcome-network_status + */ + public String getOutcomeNetworkStatus() { + return outcomeNetworkStatus; + } + + /** + * An enumerated value providing a more detailed explanation of the outcome’s type. Charges blocked by Radar’s default block rule have the value + * highest_risk_level. Charges placed in review by Radar’s default review rule have the value elevated_risk_level. Charges authorized, blocked, or placed + * in review by custom rules have the value rule. See understanding declines for more details. + *

+ * See: https://stripe.com/docs/api/charges/object#charge_object-outcome-reason + */ + public String getOutcomeNetworkReason() { + return outcomeNetworkReason; + } + + /** + * Possible values are authorized, manual_review, issuer_declined, blocked, and invalid. See understanding declines and Radar reviews for details. + *

+ * See: https://stripe.com/docs/api/charges/object#charge_object-outcome-type + */ + public String getOutcomeType() { + return outcomeType; + } + } } diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.java index fc2ad501f..dd032ca96 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.java @@ -64,6 +64,6 @@ public class DonationsServiceTest { } private ActiveSubscription getActiveSubscription() { - return new ActiveSubscription(null); + return ActiveSubscription.EMPTY; } }