mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-12 00:47:50 +00:00
core, ios, android, desktop: let a lapsed iOS subscriber subscribe again to the badge the profile retired
The iOS app refused any subscription to an Apple ID that ever subscribed, so a lapsed subscriber could never subscribe again in the app. Core now tells the apps each subscription's store reference (BadgeSubscription.transactionRef, optional in JSON), and the iOS gate opens a subscription card only when every earlier subscription of the Apple ID is the active profile's newest purchase, retired, of the card's badge: the one charge core resumes. Kotlin gets the model field only, as Play gives a resubscription a new token and needs no gate. Run: 011-subscription-resume, slice S2b
This commit is contained in:
1 parent
1d72c4c2e7
commit
038f50ac30
11 files changed
+80
-36
No files matched your search
@@ -2258,7 +2258,7 @@ func redeemErrorText(_ error: Error, purchase: Bool) -> String {
|
||||
}
|
||||
}
|
||||
if case .subscribedBefore? = error as? BadgeStoreError {
|
||||
return NSLocalizedString("This Apple ID subscribed before. Subscribing again is not available in the app yet.", comment: "alert message")
|
||||
return NSLocalizedString("This Apple ID subscribed before. In the app, it can subscribe again only to continue this profile's last purchase, when that was a subscription to this badge.", comment: "alert message")
|
||||
}
|
||||
return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
|
||||
}
|
||||
|
||||
@@ -104,9 +104,9 @@ final class BadgeStore: ObservableObject {
|
||||
// set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was
|
||||
// closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again
|
||||
@Published private var reconciledOnce = false
|
||||
// Apple folds a new subscription of an Apple ID that subscribed before into the earlier one, whose record
|
||||
// here is already credited or refused, so the buyer would pay for nothing; nil until load() has checked
|
||||
@Published private(set) var subscribedBefore: Bool? = nil
|
||||
// the original ids of the Apple ID's subscriptions; nil until load() has read them. Apple folds a new subscription
|
||||
// of an Apple ID that subscribed before into the earlier one, which core resumes only for the profile it retired
|
||||
@Published private(set) var priorSubscriptions: Set<String>? = nil
|
||||
// the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run
|
||||
private var sweepFailureShown = false
|
||||
private var transactionUpdates: Task<Void, Never>? = nil
|
||||
@@ -139,13 +139,27 @@ final class BadgeStore: ObservableObject {
|
||||
return storePurchases.purchases
|
||||
}
|
||||
|
||||
func forSale(_ period: BadgePeriod) -> Bool {
|
||||
func forSale(_ level: BadgeLevel, _ period: BadgePeriod) -> Bool {
|
||||
switch period {
|
||||
case .oneMonth: true
|
||||
case .monthly, .annual: subscribedBefore == false
|
||||
case .monthly, .annual: priorSubscriptions?.allSatisfy { $0 == resumableSubscription(level) } ?? false
|
||||
}
|
||||
}
|
||||
|
||||
// a subscription of an Apple ID that subscribed before resumes only the active profile's newest purchase,
|
||||
// once its badge is retired and only at its own level, as the service grants a new charge only of the same tier
|
||||
private func resumableSubscription(_ level: BadgeLevel) -> String? {
|
||||
let badgeModel = BadgeModel.shared
|
||||
guard badgeModel.userId == ChatModel.shared.currentUser?.userId,
|
||||
let badgeState = badgeModel.badgeState,
|
||||
!badgeState.shown,
|
||||
badgeState.badgeType == level.badgeType
|
||||
else { return nil }
|
||||
return badgeState.subscription?.transactionRef
|
||||
}
|
||||
|
||||
var subscribedBefore: Bool { priorSubscriptions?.isEmpty == false }
|
||||
|
||||
func price(_ level: BadgeLevel, _ period: BadgePeriod, compact: Bool = true) -> BadgePrice {
|
||||
switch state {
|
||||
case .notLoaded, .loading: return .loading
|
||||
@@ -179,10 +193,10 @@ final class BadgeStore: ObservableObject {
|
||||
let storefront = await Storefront.current
|
||||
logger.warning("BadgeStore.load: no product returned for \(missing.joined(separator: ", ")) - bundle \(Bundle.main.bundleIdentifier ?? "?"), storefront \(storefront?.countryCode ?? "none"), canMakePayments \(AppStore.canMakePayments)")
|
||||
}
|
||||
let subscribed = await hasSubscribedBefore()
|
||||
let prior = await subscriptionOriginalIds()
|
||||
await MainActor.run {
|
||||
products = byId
|
||||
subscribedBefore = subscribed
|
||||
priorSubscriptions = prior
|
||||
state = .loaded
|
||||
}
|
||||
} catch let error {
|
||||
@@ -198,10 +212,11 @@ final class BadgeStore: ObservableObject {
|
||||
throw BadgeStoreError.productUnavailable(productId: productId)
|
||||
}
|
||||
// another device on this Apple ID may have subscribed since load(), and Apple would fold this purchase into that one
|
||||
if product.type == .autoRenewable, await hasSubscribedBefore() {
|
||||
await MainActor.run { subscribedBefore = true }
|
||||
if product.type == .autoRenewable {
|
||||
let prior = await subscriptionOriginalIds()
|
||||
await MainActor.run { priorSubscriptions = priorSubscriptions?.union(prior) }
|
||||
}
|
||||
guard await MainActor.run(body: { forSale(period) }) else {
|
||||
guard await MainActor.run(body: { forSale(level, period) }) else {
|
||||
throw BadgeStoreError.subscribedBefore
|
||||
}
|
||||
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else {
|
||||
@@ -215,7 +230,9 @@ final class BadgeStore: ObservableObject {
|
||||
let outcome = try await storePurchase(product, invoiceId)
|
||||
if product.type == .autoRenewable {
|
||||
switch outcome {
|
||||
case .purchased, .pending: await MainActor.run { subscribedBefore = true }
|
||||
case let .purchased(receipt): await MainActor.run { _ = priorSubscriptions?.insert(String(receipt.transaction.originalID)) }
|
||||
// StoreKit lists no deferred purchase, so subscriptions stay shut for this run
|
||||
case .pending: await MainActor.run { priorSubscriptions = nil }
|
||||
case .cancelled: break
|
||||
}
|
||||
}
|
||||
@@ -353,7 +370,7 @@ final class BadgeStore: ObservableObject {
|
||||
private func reconcile(_ verification: VerificationResult<Transaction>, listed: Bool) async throws {
|
||||
let receipt = storeReceipt(verification)
|
||||
let t = receipt.transaction
|
||||
if t.productType == .autoRenewable { await MainActor.run { subscribedBefore = true } }
|
||||
if t.productType == .autoRenewable { await MainActor.run { _ = priorSubscriptions?.insert(String(t.originalID)) } }
|
||||
if t.productType == .autoRenewable && t.revocationDate != nil {
|
||||
// the service refuses a revoked charge as invalid, and that would refuse the whole subscription's record
|
||||
await t.finish()
|
||||
@@ -367,7 +384,7 @@ final class BadgeStore: ObservableObject {
|
||||
// The service asks the store only once the paid period ends, so a cancellation or a failed charge before
|
||||
// then is seen only here. Sent with the latest transaction, which names the subscription as its first one did.
|
||||
func reportSubscriptionStates() async {
|
||||
guard await hasSubscribedBefore() else { return }
|
||||
guard !(await subscriptionOriginalIds()).isEmpty else { return }
|
||||
await load()
|
||||
guard let subscription = await MainActor.run(body: { products.values.first { $0.type == .autoRenewable }?.subscription }) else { return }
|
||||
do {
|
||||
@@ -441,11 +458,13 @@ final class BadgeStore: ObservableObject {
|
||||
}
|
||||
|
||||
// an unverified transaction counts too: Apple, not this check, decides what a new subscription folds into
|
||||
private func hasSubscribedBefore() async -> Bool {
|
||||
private func subscriptionOriginalIds() async -> Set<String> {
|
||||
var ids: Set<String> = []
|
||||
for await verification in Transaction.all {
|
||||
if verification.unsafePayloadValue.productType == .autoRenewable { return true }
|
||||
let t = verification.unsafePayloadValue
|
||||
if t.productType == .autoRenewable { ids.insert(String(t.originalID)) }
|
||||
}
|
||||
return false
|
||||
return ids
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -14,6 +14,8 @@ struct BadgesCheckOrderView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject private var store = BadgeStore.shared
|
||||
// store.forSale reads the active profile's badge
|
||||
@ObservedObject private var badgeModel = BadgeModel.shared
|
||||
let level: BadgeLevel
|
||||
let period: BadgePeriod
|
||||
@State private var purchasing = false
|
||||
@@ -54,8 +56,8 @@ struct BadgesCheckOrderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !store.forSale(period) && store.subscribedBefore == true {
|
||||
footnote("This Apple ID subscribed before. Subscribing again is not available in the app yet.")
|
||||
if !store.forSale(level, period) && store.subscribedBefore {
|
||||
footnote("This Apple ID subscribed before. In the app, it can subscribe again only to continue this profile's last purchase, when that was a subscription to this badge.")
|
||||
}
|
||||
|
||||
Spacer(minLength: 20)
|
||||
@@ -118,7 +120,7 @@ struct BadgesCheckOrderView: View {
|
||||
|
||||
private func payButton() -> some View {
|
||||
let price = store.price(level, period, compact: false)
|
||||
let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId) || !store.forSale(period)
|
||||
let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId) || !store.forSale(level, period)
|
||||
return Button {
|
||||
purchase()
|
||||
} label: {
|
||||
|
||||
@@ -71,13 +71,15 @@ enum BadgePeriod: String, CaseIterable, Identifiable {
|
||||
struct BadgesHowLongView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject private var store = BadgeStore.shared
|
||||
// store.forSale reads the active profile's badge
|
||||
@ObservedObject private var badgeModel = BadgeModel.shared
|
||||
let level: BadgeLevel
|
||||
@State private var chosenPeriod: BadgePeriod? = nil
|
||||
@State private var continueActive = false
|
||||
|
||||
// monthly once subscriptions turn out to be for sale, unless a period was chosen before that
|
||||
private var selectedPeriod: BadgePeriod {
|
||||
chosenPeriod ?? (store.forSale(.monthly) ? .monthly : .oneMonth)
|
||||
chosenPeriod ?? (store.forSale(level, .monthly) ? .monthly : .oneMonth)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -109,8 +111,8 @@ struct BadgesHowLongView: View {
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if store.subscribedBefore == true {
|
||||
Text("This Apple ID subscribed before. Subscribing again is not available in the app yet.")
|
||||
if !store.forSale(level, .monthly) && store.subscribedBefore {
|
||||
Text("This Apple ID subscribed before. In the app, it can subscribe again only to continue this profile's last purchase, when that was a subscription to this badge.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -139,7 +141,7 @@ struct BadgesHowLongView: View {
|
||||
|
||||
private func periodCard(_ period: BadgePeriod) -> some View {
|
||||
let isSelected = period == selectedPeriod
|
||||
let forSale = store.forSale(period)
|
||||
let forSale = store.forSale(level, period)
|
||||
return Button {
|
||||
chosenPeriod = period
|
||||
} label: {
|
||||
|
||||
@@ -358,6 +358,8 @@ public struct BadgeSubscription: Decodable, Hashable {
|
||||
public var planMonths: Int
|
||||
// nil until the service or the device's store has been asked
|
||||
public var status: BadgeSubscriptionState?
|
||||
// the store's reference: Apple's original transaction id, or a hash of Play's token
|
||||
public var transactionRef: String?
|
||||
}
|
||||
|
||||
// the store's state as last observed; a newer core may know a state this version does not
|
||||
|
||||
@@ -66,9 +66,9 @@ The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages
|
||||
|
||||
### GAP: A badge subscription cannot be restored or bought again
|
||||
**Source:** [Shared/Views/Badges/BadgeStore.swift](../Shared/Views/Badges/BadgeStore.swift)
|
||||
Restoring a badge subscription on a new device or under another profile does not work, for two independent reasons. `restorePurchases()` reads `Transaction.unfinished`, so a purchase that was already finished is never found. And the service has credited the payment to the purchase key of the install that bought it, so a new key is answered `receipt_used`. Fixing only the client would turn "Nothing to restore" into a refusal, which is worse. On the same install a lapsed subscriber cannot subscribe again either: the app's gate refuses (`subscribedBefore`, scanning `Transaction.all`), and core would not credit the new charge anyway, since Apple keeps the original transaction id and core answers the hand-over from the old credited record.
|
||||
Restoring a badge subscription on a new device or under another profile does not work, for two independent reasons. `restorePurchases()` reads `Transaction.unfinished`, so a purchase that was already finished is never found. And the service has credited the payment to the purchase key of the install that bought it, so a new key is answered `receipt_used`. Fixing only the client would turn "Nothing to restore" into a refusal, which is worse. On the same install a lapsed subscriber can subscribe again only to the badge the active profile's newest purchase retired: core resumes that purchase on the new charge, as Apple keeps the original transaction id. The app's gate (`forSale`, comparing the original ids in `Transaction.all`, read once a run, with the purchase's `transactionRef`) shuts subscriptions for any other profile, device or badge, and for the rest of the run after a purchase that awaits approval, which StoreKit does not list.
|
||||
|
||||
**REC:** A transfer design, in the service and the client, for new devices and profiles. For the same install, a resume path in core that credits a later charge of a retired subscription to its own purchase, then a gate narrowed to it.
|
||||
**REC:** A transfer design, in the service and the client, for new devices and profiles.
|
||||
|
||||
---
|
||||
|
||||
+3
-1
@@ -2277,7 +2277,9 @@ data class BadgeState(
|
||||
data class BadgeSubscription(
|
||||
val planMonths: Int,
|
||||
// null until the service or the device's store has been asked
|
||||
val status: BadgeSubscriptionState? = null
|
||||
val status: BadgeSubscriptionState? = null,
|
||||
// the store's reference: Apple's original transaction id, or a hash of Play's token
|
||||
val transactionRef: String? = null
|
||||
)
|
||||
|
||||
// the store's state as last observed; a newer core may know a state this version does not
|
||||
|
||||
@@ -302,7 +302,8 @@ data BadgeState = BadgeState
|
||||
-- | A store subscription funding the purchase: the latest charge's plan, and the store's state as last observed.
|
||||
data BadgeSubscription = BadgeSubscription
|
||||
{ planMonths :: Int,
|
||||
status :: Maybe SubscriptionState
|
||||
status :: Maybe SubscriptionState,
|
||||
transactionRef :: Maybe Text -- the store receipt's: Apple's original transaction id, or a hash of Play's token
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
|
||||
@@ -508,14 +508,15 @@ getBadgePurchase db purchaseId =
|
||||
WHERE l.badge_purchase_id = p.badge_purchase_id AND l.entry_type = 'credit' AND l.entry_credit_type = 'charge'
|
||||
ORDER BY l.entry_id DESC
|
||||
LIMIT 1),
|
||||
p.subscription_status, p.unrenewed_since, p.last_unrenewed_at
|
||||
p.subscription_status, p.unrenewed_since, p.last_unrenewed_at, r.transaction_ref
|
||||
FROM badge_purchases p
|
||||
JOIN users u ON u.user_id = p.user_id
|
||||
LEFT JOIN badge_store_receipts r ON r.badge_store_receipt_id = p.badge_store_receipt_id
|
||||
WHERE p.badge_purchase_id = ? AND p.purchase_priv_key IS NOT NULL
|
||||
|]
|
||||
(Only purchaseId)
|
||||
where
|
||||
toPurchase ((badgePurchaseId, purchaseKey, purchasePrivKey, Binary mk, badgeType, shown_, ackedKind_, ackedEpisode_, alertSnoozeUntil) :. (failedSince_, lastAttemptAt_, reason_, nextWakeAt, planMonths_, status, since_, lastAt_)) =
|
||||
toPurchase ((badgePurchaseId, purchaseKey, purchasePrivKey, Binary mk, badgeType, shown_, ackedKind_, ackedEpisode_, alertSnoozeUntil) :. (failedSince_, lastAttemptAt_, reason_, nextWakeAt, planMonths_, status, since_, lastAt_, transactionRef)) =
|
||||
UserBadgePurchase
|
||||
{ badgePurchaseId,
|
||||
purchaseKey,
|
||||
@@ -528,7 +529,7 @@ getBadgePurchase db purchaseId =
|
||||
issueError = BadgeIssueError <$> failedSince_ <*> lastAttemptAt_ <*> reason_,
|
||||
nextWakeAt,
|
||||
unrenewedRun = UnrenewedRun <$> since_ <*> lastAt_,
|
||||
purchaseKind = maybe PKPrepaid (\planMonths -> PKSubscription BadgeSubscription {planMonths, status}) planMonths_
|
||||
purchaseKind = maybe PKPrepaid (\planMonths -> PKSubscription BadgeSubscription {planMonths, status, transactionRef}) planMonths_
|
||||
}
|
||||
|
||||
setBadgeSubscriptionStatus :: DB.Connection -> Int64 -> SubscriptionState -> IO ()
|
||||
|
||||
+10
-4
@@ -1166,12 +1166,18 @@ testBadgeSubscriptionJSON = do
|
||||
J.Object o -> KM.lookup "subscription" o
|
||||
_ -> Nothing
|
||||
subscriptionField (badgeState Nothing) `shouldBe` Nothing
|
||||
subscriptionField (badgeState $ Just BadgeSubscription {planMonths = 12, status = Just SSCancelled})
|
||||
subscriptionField (badgeState $ Just BadgeSubscription {planMonths = 12, status = Just SSCancelled, transactionRef = Nothing})
|
||||
`shouldBe` Just (J.object ["planMonths" J..= (12 :: Int), "status" J..= ("cancelled" :: T.Text)])
|
||||
-- not yet observed: the field is left out rather than sent as null
|
||||
subscriptionField (badgeState $ Just BadgeSubscription {planMonths = 1, status = Nothing})
|
||||
subscriptionField (badgeState $ Just BadgeSubscription {planMonths = 1, status = Nothing, transactionRef = Nothing})
|
||||
`shouldBe` Just (J.object ["planMonths" J..= (1 :: Int)])
|
||||
let renewal status = badgeRenewal futureTime $ Just BadgeSubscription {planMonths = 1, status}
|
||||
let withRef = BadgeSubscription {planMonths = 1, status = Just SSRenewing, transactionRef = Just "2000000812345680"}
|
||||
subscriptionField (badgeState $ Just withRef)
|
||||
`shouldBe` Just (J.object ["planMonths" J..= (1 :: Int), "status" J..= ("renewing" :: T.Text), "transactionRef" J..= ("2000000812345680" :: T.Text)])
|
||||
J.fromJSON (J.toJSON withRef) `shouldBe` J.Success withRef
|
||||
-- as an older core sends it
|
||||
J.fromJSON (J.object ["planMonths" J..= (1 :: Int)]) `shouldBe` J.Success BadgeSubscription {planMonths = 1, status = Nothing, transactionRef = Nothing}
|
||||
let renewal status = badgeRenewal futureTime $ Just BadgeSubscription {planMonths = 1, status, transactionRef = Nothing}
|
||||
renewal (Just SSRenewing) `shouldBe` (Just futureTime, True)
|
||||
mapM_ (\s -> renewal s `shouldBe` (Nothing, False)) [Nothing, Just SSCancelled, Just SSBillingRetry, Just SSEnded, Just (SSUnknown "renewing soon")]
|
||||
badgeRenewal futureTime Nothing `shouldBe` (Nothing, False)
|
||||
@@ -1194,7 +1200,7 @@ testSubscriptionAlertDerivation = do
|
||||
early = addUTCTime (-10 * nominalDay) ends
|
||||
soon = addUTCTime (-nominalDay) ends
|
||||
past = addUTCTime nominalDay ends
|
||||
subscription months st = SB.PKSubscription BadgeSubscription {planMonths = months, status = st}
|
||||
subscription months st = SB.PKSubscription BadgeSubscription {planMonths = months, status = st, transactionRef = Nothing}
|
||||
alertOf t kind' isShown err =
|
||||
let p = SB.UserBadgePurchase {badgePurchaseId = 1, purchaseKey = k, purchasePrivKey = pk, masterKey = mk, badgeType = BTSupporter, shown = isShown, alertAcked = Nothing, alertSnoozeUntil = Nothing, issueError = err, nextWakeAt = Nothing, unrenewedRun = Nothing, purchaseKind = kind'}
|
||||
in (\BT.BadgeAlert {kind, date} -> (kind, date)) <$> derivedBadgeAlert t p Nothing balance
|
||||
|
||||
@@ -62,8 +62,9 @@ import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeHash, badgeCodeText, format
|
||||
import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, emptyEntry, endOfMondayAfter)
|
||||
import Simplex.Chat.Badges.Service
|
||||
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..))
|
||||
import qualified Simplex.Chat.Badges.Types as BT
|
||||
import Simplex.Chat.Bot.Store (withDB')
|
||||
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatError (..), ChatErrorType (..), ChatResponse (CRCustomChatResponse))
|
||||
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatError (..), ChatErrorType (..), ChatResponse (CRBadgeState, CRCustomChatResponse))
|
||||
import Simplex.Chat.Core (sendChatCmdStr)
|
||||
import Simplex.Chat.Library.Commands (resumeHoldLimit, subscriptionRecoveryWindow)
|
||||
import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..))
|
||||
@@ -2891,6 +2892,13 @@ testFreshAnswerOverridesDeviceEnded ps =
|
||||
subscriptionStatuses (chatController alice) `shouldEventuallyReturn` [Just "renewing"]
|
||||
shownBadgeId (chatController alice) >>= (`shouldSatisfy` isJust)
|
||||
|
||||
-- | Read from the API response, as the apps read it: the terminal view does not show it.
|
||||
badgeSubscriptionRef :: HasCallStack => ChatController -> IO (Maybe Text)
|
||||
badgeSubscriptionRef cc =
|
||||
sendChatCmdStr cc "/_badge state 1" >>= \case
|
||||
Right (CRBadgeState _ (Just BT.BadgeState {BT.subscription = Just BT.BadgeSubscription {BT.transactionRef = ref_}}) _) -> pure ref_
|
||||
r -> error $ "expected a subscription's badge state, got " <> show (() <$ r)
|
||||
|
||||
subscriptionStatuses :: ChatController -> IO [Maybe Text]
|
||||
subscriptionStatuses ChatController {chatStore} =
|
||||
withTransaction chatStore $ \db -> map fromOnly <$> DB.query_ db "SELECT subscription_status FROM badge_purchases"
|
||||
@@ -2933,6 +2941,7 @@ testSubscriptionStateKept ps =
|
||||
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
|
||||
(_, rows) <- clientSubscribedMonthly env alice
|
||||
subscriptionStatuses (chatController alice) `shouldReturn` [Just "renewing"]
|
||||
badgeSubscriptionRef (chatController alice) `shouldReturn` Just (googlePurchaseRef googleSubscriptionToken)
|
||||
alice ##> "/_badge state 1"
|
||||
alice <### [Predicate (\l -> "1: supporter, 0 months left" `isPrefixOf` l && ", subscription renewing" `isInfixOf` l)]
|
||||
-- the request asserts coverage that has ended, so the service asks the store for its answer
|
||||
|
||||
Reference in new issue
Block a user