diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 94ad4bfa77..f624d42ed4 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -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)) } diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 37ba01b4e3..8ac8d2ee73 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -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? = 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? = 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, 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 { + var ids: Set = [] 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 diff --git a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift index 1ab0e1cea0..392af670e9 100644 --- a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift @@ -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: { diff --git a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift index bba0fcd128..a308e05e92 100644 --- a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift @@ -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: { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f2e2e17986..3977f562bd 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -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 diff --git a/apps/ios/product/gaps.md b/apps/ios/product/gaps.md index 40c13d904d..2858f29e96 100644 --- a/apps/ios/product/gaps.md +++ b/apps/ios/product/gaps.md @@ -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. --- diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index d1ca796df4..e19961126d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -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 diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index 6754adc19f..18e6e9972f 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -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) diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index dbcf92978f..997b5e80ff 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -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 () diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index 28c03a0a26..ef83c5b37d 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -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 diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 9da368cd86..ea9e52b283 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -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