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:
spacedandy-agents-simplex authored and spaced4ndy committed 2026-10-20 16:33:12 +04:00
1 parent 1d72c4c2e7
commit 038f50ac30
11 files changed
+80 -36

No files matched your search

+1 -1
View File
@@ -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))
}
+35 -16
View File
@@ -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: {
+2
View File
@@ -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
+2 -2
View File
@@ -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.
---
@@ -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
+2 -1
View File
@@ -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)
+4 -3
View File
@@ -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
View File
@@ -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
+10 -1
View File
@@ -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