Update how we deal with failed or in progress subscriptions.

fork-5.53.8
Alex Hart 2021-11-19 15:30:15 -04:00 zatwierdzone przez Cody Henthorne
rodzic b4fe5bdcc6
commit 8a00caabd7
12 zmienionych plików z 190 dodań i 17 usunięć

Wyświetl plik

@ -39,7 +39,7 @@ class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRep
}
subscriptionsRepository.getActiveSubscription().subscribeBy(
onSuccess = { subscription -> store.update { it.copy(hasActiveSubscription = subscription.isActive) } },
onSuccess = { subscription -> store.update { it.copy(hasActiveSubscription = subscription.activeSubscription != null) } },
onError = { throwable ->
if (throwable.isNotFoundException()) {
Log.w(TAG, "Could not load active subscription due to unset SubscriberId (404).")

Wyświetl plik

@ -7,10 +7,10 @@ import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.DateUtils
@ -27,6 +27,7 @@ import java.util.Locale
object ActiveSubscriptionPreference {
class Model(
val price: FiatMoney,
val subscription: Subscription,
val onAddBoostClick: () -> Unit,
val renewalTimestamp: Long = -1L,
@ -62,7 +63,7 @@ object ActiveSubscriptionPreference {
R.string.MySupportPreference__s_per_month,
FiatMoneyUtil.format(
context.resources,
model.subscription.prices.first { it.currency == SignalStore.donationsValues().getSubscriptionCurrency() },
model.price,
FiatMoneyUtil.formatOptions()
)
)

Wyświetl plik

@ -4,6 +4,7 @@ import android.widget.Toast
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.util.DimensionUnit
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.LifecycleDisposable
import java.util.Currency
import java.util.concurrent.TimeUnit
/**
@ -85,20 +87,24 @@ class ManageDonationsFragment : DSLSettingsFragment() {
)
if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) {
val activeSubscription = state.transactionState.activeSubscription
if (activeSubscription.isActive) {
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.activeSubscription.level == it.level }
val activeSubscription = state.transactionState.activeSubscription.activeSubscription
if (activeSubscription != null) {
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.level == it.level }
if (subscription != null) {
space(DimensionUnit.DP.toPixels(12f).toInt())
val activeCurrency = Currency.getInstance(activeSubscription.currency)
val activeAmount = activeSubscription.amount.movePointLeft(activeCurrency.defaultFractionDigits)
customPref(
ActiveSubscriptionPreference.Model(
price = FiatMoney(activeAmount, activeCurrency),
subscription = subscription,
onAddBoostClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
},
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.activeSubscription.endOfCurrentPeriod),
redemptionState = state.subscriptionRedemptionState,
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.endOfCurrentPeriod),
redemptionState = state.getRedemptionState(),
onContactSupport = {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
@ -120,7 +126,7 @@ class ManageDonationsFragment : DSLSettingsFragment() {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
isEnabled = state.subscriptionRedemptionState != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
isEnabled = state.getRedemptionState() != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
onClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
}

Wyświetl plik

@ -8,8 +8,25 @@ data class ManageDonationsState(
val featuredBadge: Badge? = null,
val transactionState: TransactionState = TransactionState.Init,
val availableSubscriptions: List<Subscription> = emptyList(),
val subscriptionRedemptionState: SubscriptionRedemptionState = SubscriptionRedemptionState.NONE
private val subscriptionRedemptionState: SubscriptionRedemptionState = SubscriptionRedemptionState.NONE
) {
fun getRedemptionState(): SubscriptionRedemptionState {
return when (transactionState) {
TransactionState.Init -> subscriptionRedemptionState
TransactionState.InTransaction -> SubscriptionRedemptionState.IN_PROGRESS
is TransactionState.NotInTransaction -> getStateFromActiveSubscription(transactionState.activeSubscription) ?: subscriptionRedemptionState
}
}
fun getStateFromActiveSubscription(activeSubscription: ActiveSubscription): SubscriptionRedemptionState? {
return when {
activeSubscription.isFailedPayment -> SubscriptionRedemptionState.FAILED
activeSubscription.isInProgress -> SubscriptionRedemptionState.IN_PROGRESS
else -> null
}
}
sealed class TransactionState {
object Init : TransactionState()
object InTransaction : TransactionState()

Wyświetl plik

@ -73,7 +73,7 @@ class ManageDonationsViewModel(
it.copy(transactionState = transactionState)
}
if (transactionState is ManageDonationsState.TransactionState.NotInTransaction && !transactionState.activeSubscription.isActive) {
if (transactionState is ManageDonationsState.TransactionState.NotInTransaction && transactionState.activeSubscription.activeSubscription == null) {
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
}
},

Wyświetl plik

@ -10,6 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgePreview
@ -35,6 +36,7 @@ import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
import java.util.Calendar
import java.util.Currency
import java.util.concurrent.TimeUnit
/**
@ -167,9 +169,19 @@ class SubscribeFragment : DSLSettingsFragment(
space(DimensionUnit.DP.toPixels(75f).toInt())
} else {
state.subscriptions.forEach {
val isActive = state.activeSubscription?.activeSubscription?.level == it.level && state.activeSubscription.isActive
val activePrice = state.activeSubscription?.activeSubscription?.let { sub ->
val activeCurrency = Currency.getInstance(sub.currency)
val activeAmount = sub.amount.movePointLeft(activeCurrency.defaultFractionDigits)
FiatMoney(activeAmount, activeCurrency)
}
customPref(
Subscription.Model(
activePrice = if (isActive) { activePrice } else null,
subscription = it,
isSelected = state.selectedSubscription == it,
isEnabled = areFieldsEnabled,

Wyświetl plik

@ -162,6 +162,20 @@ class SubscribeViewModel(
}
}
private fun cancelActiveSubscriptionIfNecessary(): Completable {
return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable {
if (it) {
donationPaymentRepository.cancelActiveSubscription().doOnComplete {
SignalStore.donationsValues().setLastEndOfPeriod(0L)
SignalStore.donationsValues().clearLevelOperations()
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = false
}
} else {
Completable.complete()
}
}
}
fun cancel() {
store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) }
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
@ -201,7 +215,10 @@ class SubscribeViewModel(
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
val setup = ensureSubscriberId.andThen(continueSetup).onErrorResumeNext { Completable.error(DonationExceptions.SetupFailed(it)) }
val setup = ensureSubscriberId
.andThen(cancelActiveSubscriptionIfNecessary())
.andThen(continueSetup)
.onErrorResumeNext { Completable.error(DonationExceptions.SetupFailed(it)) }
setup.andThen(setLevel).subscribeBy(
onError = { throwable ->
@ -233,7 +250,7 @@ class SubscribeViewModel(
fun updateSubscription() {
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
donationPaymentRepository.setSubscriptionLevel(store.state.selectedSubscription!!.level.toString())
cancelActiveSubscriptionIfNecessary().andThen(donationPaymentRepository.setSubscriptionLevel(store.state.selectedSubscription!!.level.toString()))
.subscribeBy(
onComplete = {
store.update { it.copy(stage = SubscribeState.Stage.READY) }

Wyświetl plik

@ -200,6 +200,11 @@ public class RefreshOwnProfileJob extends BaseJob {
Log.d(TAG, "Marking subscription badge as expired, should notifiy next time the conversation list is open.");
SignalStore.donationsValues().setExpiredBadge(mostRecentExpiration);
if (!SignalStore.donationsValues().isUserManuallyCancelled()) {
Log.d(TAG, "Detected an unexpected subscription expiry.");
SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true);
}
} else if (!remoteHasBoostBadges && localHasBoostBadges) {
Badge mostRecentExpiration = Recipient.self()
.getBadges()

Wyświetl plik

@ -118,6 +118,11 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
if (subscription == null || !subscription.isActive()) {
Log.w(TAG, "User does not have an active subscription yet.", true);
throw new RetryableException();
} else if (subscription.isFailedPayment()) {
Log.w(TAG, "Subscription payment failure. Passing through to redemption job.", true);
SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true);
setOutputData(new Data.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_PAYMENT_FAILURE, true).build());
return;
} else {
Log.i(TAG, "Recording end of period from active subscription.", true);
SignalStore.donationsValues().setLastEndOfPeriod(subscription.getEndOfCurrentPeriod());
@ -206,7 +211,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
}
private static void handleApplicationError(ServiceResponse<ReceiptCredentialResponse> response) throws Exception {
private void handleApplicationError(ServiceResponse<ReceiptCredentialResponse> response) throws Exception {
switch (response.getStatus()) {
case 204:
Log.w(TAG, "User does not have receipts available to exchange. Exiting.", response.getApplicationError().get(), true);
@ -214,6 +219,11 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
case 400:
Log.w(TAG, "Receipt credential request failed to validate.", response.getApplicationError().get(), true);
throw new Exception(response.getApplicationError().get());
case 402:
Log.w(TAG, "Subscription payment failure. Passing through to redemption job.", true);
SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true);
setOutputData(new Data.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_PAYMENT_FAILURE, true).build());
break;
case 403:
Log.w(TAG, "SubscriberId password mismatch or account auth was present.", response.getApplicationError().get(), true);
throw new Exception(response.getApplicationError().get());

Wyświetl plik

@ -29,6 +29,7 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
private const val KEY_LEVEL_HISTORY = "donation.level.history"
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"
}
override fun onFirstEverAppLaunch() = Unit
@ -36,7 +37,8 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(
KEY_CURRENCY_CODE_BOOST,
KEY_LAST_KEEP_ALIVE_LAUNCH,
KEY_LAST_END_OF_PERIOD
KEY_LAST_END_OF_PERIOD,
SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT
)
private val subscriptionCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency()) }
@ -208,4 +210,17 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
fun clearSubscriptionRedemptionFailed() {
putBoolean(SUBSCRIPTION_REDEMPTION_FAILED, false)
}
/**
* Denotes that the previous attempt to subscribe failed in some way. Either an
* automatic renewal failed resulting in an unexpected expiration, or payment failed
* on Stripe's end.
*
* Before trying to resubscribe, we should first ensure there are no subscriptions set
* on the server. Otherwise, we could get into a situation where the user is unable to
* resubscribe.
*/
var shouldCancelSubscriptionBeforeNextSubscribeAttempt: Boolean
get() = getBoolean(SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT, false)
set(value) = putBoolean(SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT, value)
}

Wyświetl plik

@ -79,6 +79,7 @@ data class Subscription(
}
class Model(
val activePrice: FiatMoney?,
val subscription: Subscription,
val isSelected: Boolean,
val isActive: Boolean,
@ -134,7 +135,7 @@ data class Subscription(
val formattedPrice = FiatMoneyUtil.format(
context.resources,
model.subscription.prices.first { it.currency == model.selectedCurrency },
model.activePrice ?: model.subscription.prices.first { it.currency == model.selectedCurrency },
FiatMoneyUtil.formatOptions()
)

Wyświetl plik

@ -4,9 +4,71 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
import java.util.Objects;
public final class ActiveSubscription {
private enum Status {
/**
* The subscription is currently in a trial period and its safe to provision your product for your customer.
* The subscription transitions automatically to active when the first payment is made.
*/
TRIALING("trialing"),
/**
* The subscription is in good standing and the most recent payment was successful. Its safe to provision your product for your customer.
*/
ACTIVE("active"),
/**
* Payment failed when you created the subscription. A successful payment needs to be made within 23 hours to activate the subscription.
*/
INCOMPLETE("incomplete"),
/**
* The initial payment on the subscription failed and no successful payment was made within 23 hours of creating the subscription.
* These subscriptions dont bill customers. This status exists so you can track customers that failed to activate their subscriptions.
*/
INCOMPLETE_EXPIRED("incomplete_expired"),
/**
* Payment on the latest invoice either failed or wasnt attempted.
*/
PAST_DUE("past_due"),
/**
* The subscription has been canceled. During cancellation, automatic collection for all unpaid invoices is disabled (auto_advance=false).
*/
CANCELED("canceled"),
/**
* The latest invoice hasnt been paid but the subscription remains in place.
* The latest invoice remains open and invoices continue to be generated but payments arent attempted.
*/
UNPAID("unpaid");
private final String status;
Status(String status) {
this.status = status;
}
private static Status getStatus(String status) {
for (Status s : Status.values()) {
if (Objects.equals(status, s.status)) {
return s;
}
}
throw new IllegalArgumentException("Unknown status " + status);
}
static boolean isPaymentFailed(String status) {
Status s = getStatus(status);
return s == INCOMPLETE || s == INCOMPLETE_EXPIRED;
}
}
private final Subscription activeSubscription;
@JsonCreator
@ -22,6 +84,14 @@ public final class ActiveSubscription {
return activeSubscription != null && activeSubscription.isActive();
}
public boolean isInProgress() {
return activeSubscription != null && !isActive() && !isFailedPayment();
}
public boolean isFailedPayment() {
return activeSubscription != null && !isActive() && isFailedPayment();
}
public static final class Subscription {
private final int level;
private final String currency;
@ -30,6 +100,7 @@ public final class ActiveSubscription {
private final boolean isActive;
private final long billingCycleAnchor;
private final boolean willCancelAtPeriodEnd;
private final String status;
@JsonCreator
public Subscription(@JsonProperty("level") int level,
@ -38,7 +109,8 @@ public final class ActiveSubscription {
@JsonProperty("endOfCurrentPeriod") long endOfCurrentPeriod,
@JsonProperty("active") boolean isActive,
@JsonProperty("billingCycleAnchor") long billingCycleAnchor,
@JsonProperty("cancelAtPeriodEnd") boolean willCancelAtPeriodEnd)
@JsonProperty("cancelAtPeriodEnd") boolean willCancelAtPeriodEnd,
@JsonProperty("status") String status)
{
this.level = level;
this.currency = currency;
@ -47,6 +119,7 @@ public final class ActiveSubscription {
this.isActive = isActive;
this.billingCycleAnchor = billingCycleAnchor;
this.willCancelAtPeriodEnd = willCancelAtPeriodEnd;
this.status = status;
}
public int getLevel() {
@ -89,5 +162,21 @@ public final class ActiveSubscription {
public boolean willCancelAtPeriodEnd() {
return willCancelAtPeriodEnd;
}
/**
* The Stripe status of this subscription (see https://stripe.com/docs/billing/subscriptions/overview#subscription-statuses)
*/
public String getStatus() {
return status;
}
public boolean isInProgress() {
return !isActive() &&
!Status.isPaymentFailed(getStatus());
}
public boolean isFailedPayment() {
return Status.isPaymentFailed(getStatus());
}
}
}