fix(ios): a running TREK Cloud subscription is found on a fresh install too

The check before a purchase read only the entitlements on the device. Those
can be empty while the subscription runs, so the app offered the plans and
the purchase changed the running subscription's plan in the App Store. The
status of the subscription group now decides when the device has none. A
renewal the device has not finished yet no longer counts as a purchase
waiting for its instance either, which also let the check through.
This commit is contained in:
Maurice
2026-11-12 21:21:51 +01:00
parent 2bcfc1bfcb
commit 10d1a61614
+44 -7
View File
@@ -685,7 +685,7 @@ extension TrekShellPlugin {
// A held one that was delivered stops the purchase here. One that was paid
// but never set up is handed back as bought, so its instance is set up now.
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
if await TrekStore.isUnfinished(held.id) {
if await TrekStore.awaitingSetup(held) {
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
} else {
call.resolve(["kind": "subscribed"])
@@ -759,8 +759,8 @@ extension TrekShellPlugin {
call.resolve(["held": false])
return
}
let unfinished = await TrekStore.isUnfinished(held.id)
call.resolve(["held": !unfinished])
let awaiting = await TrekStore.awaitingSetup(held)
call.resolve(["held": !awaiting])
}
}
}
@@ -793,17 +793,54 @@ enum TrekStore {
}
}
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64, productId: String)? {
var newest: (date: Date, jws: String, id: UInt64, productId: String)?
typealias Held = (jws: String, id: UInt64, productId: String, firstPurchase: Bool)
/// The subscription this Apple ID holds under `prefix`, newest first. The
/// entitlements on the device come first; on a fresh install they can still
/// be empty while the subscription runs, so the status of the subscription
/// group, which StoreKit asks the App Store for, decides then.
static func currentEntitlement(prefix: String) async -> Held? {
var newest: (date: Date, held: Held)?
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
transaction.productID.hasPrefix(prefix),
transaction.revocationDate == nil else { continue }
if newest == nil || transaction.purchaseDate > newest!.date {
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id, transaction.productID)
newest = (transaction.purchaseDate, held(result.jwsRepresentation, transaction))
}
}
return newest.map { (jws: $0.jws, id: $0.id, productId: $0.productId) }
if let newest { return newest.held }
return await subscriptionGroupHeld(prefix: prefix)
}
/// The plans the control plane sells (`cloud.<plan>.<monthly|yearly>`, as
/// productIdFor in iap.ts); one of them is enough to find their group.
private static let plans = ["start", "basis", "plus", "pro"]
private static func subscriptionGroupHeld(prefix: String) async -> Held? {
let ids = plans.flatMap { ["\(prefix)\($0).monthly", "\(prefix)\($0).yearly"] }
guard let products = try? await Product.products(for: ids),
let group = products.compactMap({ $0.subscription?.subscriptionGroupID }).first,
let statuses = try? await Product.SubscriptionInfo.status(for: group) else { return nil }
let running: [Product.SubscriptionInfo.RenewalState] = [.subscribed, .inGracePeriod, .inBillingRetryPeriod]
for status in statuses where running.contains(status.state) {
if case .verified(let transaction) = status.transaction, transaction.revocationDate == nil {
return held(status.transaction.jwsRepresentation, transaction)
}
}
return nil
}
private static func held(_ jws: String, _ transaction: Transaction) -> Held {
(jws: jws, id: transaction.id, productId: transaction.productID, firstPurchase: transaction.originalID == transaction.id)
}
/// A first purchase that was paid but whose instance was never set up: handed
/// back as bought so it is set up now. A renewal the device has not finished
/// yet is no such purchase; its subscription is running and counts as held.
static func awaitingSetup(_ held: Held) async -> Bool {
guard held.firstPurchase else { return false }
return await isUnfinished(held.id)
}
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists.