From 8a00caabd7597628e43134c011fa61b10558b879 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 19 Nov 2021 15:30:15 -0400 Subject: [PATCH] Update how we deal with failed or in progress subscriptions. --- .../settings/app/AppSettingsViewModel.kt | 2 +- .../manage/ActiveSubscriptionPreference.kt | 5 +- .../manage/ManageDonationsFragment.kt | 18 ++-- .../manage/ManageDonationsState.kt | 19 +++- .../manage/ManageDonationsViewModel.kt | 2 +- .../subscribe/SubscribeFragment.kt | 12 +++ .../subscribe/SubscribeViewModel.kt | 21 ++++- .../securesms/jobs/RefreshOwnProfileJob.java | 5 + ...SubscriptionReceiptRequestResponseJob.java | 12 ++- .../securesms/keyvalue/DonationsValues.kt | 17 +++- .../securesms/subscription/Subscription.kt | 3 +- .../api/subscriptions/ActiveSubscription.java | 91 ++++++++++++++++++- 12 files changed, 190 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt index c4d5e37219..5fe538198f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt @@ -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).") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt index d414aecd6c..b9fb769acd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt @@ -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() ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index cc68ac968e..775ee77568 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -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()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt index accf6c70df..59bb9e7f3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt @@ -8,8 +8,25 @@ data class ManageDonationsState( val featuredBadge: Badge? = null, val transactionState: TransactionState = TransactionState.Init, val availableSubscriptions: List = 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt index ec6f2e7a4e..90ca53d31b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt @@ -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) } }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt index e73e1cff34..671dc00bb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt @@ -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, 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 105f8a0eff..9728b5ccf0 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 @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index 6aa23ec53e..adfd98d14b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -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() 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 23612b1226..fb60957e78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -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 response) throws Exception { + private void handleApplicationError(ServiceResponse 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()); 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 6b94ca5148..7c2d732e9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -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 = 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 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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt index 1f1d919491..4723f067c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt @@ -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() ) 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 c92451119f..88b7904b57 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 @@ -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 it’s 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. It’s 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 don’t 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 wasn’t 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 hasn’t been paid but the subscription remains in place. + * The latest invoice remains open and invoices continue to be generated but payments aren’t 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()); + } } }