Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.

Commit 810a5b8

Browse files
authored
feat: add renewalInfoIOS for subscription status tracking (#24)
## Summary Adds `renewalInfoIOS` field to `PurchaseIOS` to support iOS subscription upgrade/downgrade detection and auto-renewal status tracking using Apple's StoreKit 2 RenewalInfo API. ## Key Features - ✅ Detect pending subscription upgrades via `pendingUpgradeProductId` - ✅ Check auto-renewal status via `willAutoRenew` - ✅ Get next renewal date via `renewalDate` - ✅ Identify subscription cancellations - ✅ Track subscription preferences with `autoRenewPreference` ## Changes - Added `subscriptionRenewalInfo()` in `StoreKitTypesBridge.swift` - Updated to `openiap-gql@1.2.0` with RenewalInfoIOS types - Added unit tests in `OpenIapTests.swift` - Tested with real Apple Sandbox subscriptions ## Usage Example ```typescript const purchase = result as PurchaseIOS; // Detect upgrade if (purchase.renewalInfoIOS?.pendingUpgradeProductId) { console.log('Upgrading to:', purchase.renewalInfoIOS.pendingUpgradeProductId); } // Detect cancellation if (purchase.renewalInfoIOS?.willAutoRenew === false) { console.log('Subscription cancelled'); } ``` ## Breaking Changes None - backwards compatible optional field. ## Related Provides upgrade/downgrade detection capability <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * New Features * Purchases now include richer subscription renewal metadata (renewal status, auto‑renew flag, renewal date, grace period, price‑increase and pending‑upgrade info). * Bug Fixes * More consistent error handling and messaging for external purchase notices; unexpected or unsupported cases now produce clear error results. * Refactor * Streamlined logging and purchase deduplication with summaries for inactive subscriptions and clearer transaction traces. * Tests * Added coverage for renewal metadata and serialization. * Chores * Dependency version updated. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 551d86a commit 810a5b8

File tree

6 files changed

+331
-39
lines changed

6 files changed

+331
-39
lines changed

Sources/Helpers/StoreKitTypesBridge.swift

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ enum StoreKitTypesBridge {
7070
let purchaseState: PurchaseState = .purchased
7171
let expirationDate = transaction.expirationDate?.milliseconds
7272
let revocationDate = transaction.revocationDate?.milliseconds
73-
let autoRenewing = await determineAutoRenewStatus(for: transaction)
73+
let renewalInfoIOS = await subscriptionRenewalInfoIOS(for: transaction)
74+
// Default to false if renewalInfo unavailable - safer to underreport than falsely claim auto-renewal
75+
let autoRenewing = renewalInfoIOS?.willAutoRenew ?? false
7476
let environment: String?
7577
if #available(iOS 16.0, *) {
7678
environment = transaction.environment.rawValue
@@ -117,6 +119,7 @@ enum StoreKitTypesBridge {
117119
quantityIOS: transaction.purchasedQuantity,
118120
reasonIOS: reasonDetails.lowercased,
119121
reasonStringRepresentationIOS: reasonDetails.string,
122+
renewalInfoIOS: renewalInfoIOS,
120123
revocationDateIOS: revocationDate,
121124
revocationReasonIOS: transaction.revocationReason?.rawValue.description,
122125
storefrontCountryCodeIOS: {
@@ -167,6 +170,114 @@ enum StoreKitTypesBridge {
167170
return nil
168171
}
169172

173+
private static func subscriptionRenewalInfoIOS(for transaction: StoreKit.Transaction) async -> RenewalInfoIOS? {
174+
guard transaction.productType == .autoRenewable else {
175+
return nil
176+
}
177+
guard let groupId = transaction.subscriptionGroupID else {
178+
return nil
179+
}
180+
181+
do {
182+
let statuses = try await StoreKit.Product.SubscriptionInfo.status(for: groupId)
183+
184+
for status in statuses {
185+
guard case .verified(let statusTransaction) = status.transaction else { continue }
186+
guard statusTransaction.productID == transaction.productID else { continue }
187+
188+
switch status.renewalInfo {
189+
case .verified(let info):
190+
let pendingProductId = (info.autoRenewPreference != transaction.productID) ? info.autoRenewPreference : nil
191+
let offerInfo: (id: String?, type: String?)?
192+
#if swift(>=6.1)
193+
if #available(iOS 18.0, macOS 15.0, *) {
194+
// Map type only when present to avoid "nil" literal strings
195+
let offerTypeString = info.offer.map { String(describing: $0.type) }
196+
offerInfo = (id: info.offer?.id, type: offerTypeString)
197+
} else {
198+
#endif
199+
// Fallback to deprecated properties
200+
#if compiler(>=5.9)
201+
let offerTypeString = info.offerType.map { String(describing: $0) }
202+
offerInfo = (id: info.offerID, type: offerTypeString)
203+
#else
204+
offerInfo = nil
205+
#endif
206+
#if swift(>=6.1)
207+
}
208+
#endif
209+
// priceIncreaseStatus only available on iOS 15.0+
210+
let priceIncrease: String? = {
211+
if #available(iOS 15.0, macOS 12.0, *) {
212+
return String(describing: info.priceIncreaseStatus)
213+
}
214+
return nil
215+
}()
216+
let renewalInfo = RenewalInfoIOS(
217+
autoRenewPreference: info.autoRenewPreference,
218+
expirationReason: info.expirationReason?.rawValue.description,
219+
gracePeriodExpirationDate: info.gracePeriodExpirationDate?.milliseconds,
220+
isInBillingRetry: nil, // Not available in RenewalInfo, available in Status
221+
jsonRepresentation: nil,
222+
pendingUpgradeProductId: pendingProductId,
223+
priceIncreaseStatus: priceIncrease,
224+
renewalDate: info.renewalDate?.milliseconds,
225+
renewalOfferId: offerInfo?.id,
226+
renewalOfferType: offerInfo?.type,
227+
willAutoRenew: info.willAutoRenew
228+
)
229+
return renewalInfo
230+
case .unverified(let info, _):
231+
let pendingProductId = (info.autoRenewPreference != transaction.productID) ? info.autoRenewPreference : nil
232+
let offerInfo: (id: String?, type: String?)?
233+
#if swift(>=6.1)
234+
if #available(iOS 18.0, macOS 15.0, *) {
235+
// Map type only when present to avoid "nil" literal strings
236+
let offerTypeString = info.offer.map { String(describing: $0.type) }
237+
offerInfo = (id: info.offer?.id, type: offerTypeString)
238+
} else {
239+
#endif
240+
// Fallback to deprecated properties
241+
#if compiler(>=5.9)
242+
let offerTypeString = info.offerType.map { String(describing: $0) }
243+
offerInfo = (id: info.offerID, type: offerTypeString)
244+
#else
245+
offerInfo = nil
246+
#endif
247+
#if swift(>=6.1)
248+
}
249+
#endif
250+
// priceIncreaseStatus only available on iOS 15.0+
251+
let priceIncrease: String? = {
252+
if #available(iOS 15.0, macOS 12.0, *) {
253+
return String(describing: info.priceIncreaseStatus)
254+
}
255+
return nil
256+
}()
257+
let renewalInfo = RenewalInfoIOS(
258+
autoRenewPreference: info.autoRenewPreference,
259+
expirationReason: info.expirationReason?.rawValue.description,
260+
gracePeriodExpirationDate: info.gracePeriodExpirationDate?.milliseconds,
261+
isInBillingRetry: nil, // Not available in RenewalInfo, available in Status
262+
jsonRepresentation: nil,
263+
pendingUpgradeProductId: pendingProductId,
264+
priceIncreaseStatus: priceIncrease,
265+
renewalDate: info.renewalDate?.milliseconds,
266+
renewalOfferId: offerInfo?.id,
267+
renewalOfferType: offerInfo?.type,
268+
willAutoRenew: info.willAutoRenew
269+
)
270+
return renewalInfo
271+
}
272+
}
273+
} catch {
274+
OpenIapLog.debug("⚠️ Failed to fetch renewalInfo: \(error.localizedDescription)")
275+
return nil
276+
}
277+
278+
return nil
279+
}
280+
170281
static func purchaseOptions(from props: RequestPurchaseIosProps) throws -> Set<StoreKit.Product.PurchaseOption> {
171282
var options: Set<StoreKit.Product.PurchaseOption> = []
172283
if let quantity = props.quantity, quantity > 1 {

Sources/Models/Types.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ public struct PurchaseIOS: Codable, PurchaseCommon {
403403
public var quantityIOS: Int?
404404
public var reasonIOS: String?
405405
public var reasonStringRepresentationIOS: String?
406+
public var renewalInfoIOS: RenewalInfoIOS?
406407
public var revocationDateIOS: Double?
407408
public var revocationReasonIOS: String?
408409
public var storefrontCountryCodeIOS: String?
@@ -456,9 +457,34 @@ public struct RefundResultIOS: Codable {
456457
public var status: String
457458
}
458459

460+
/// Subscription renewal information from Product.SubscriptionInfo.RenewalInfo
461+
/// https://developer.apple.com/documentation/storekit/product/subscriptioninfo/renewalinfo
459462
public struct RenewalInfoIOS: Codable {
460463
public var autoRenewPreference: String?
464+
/// When subscription expires due to cancellation/billing issue
465+
/// Possible values: "VOLUNTARY", "BILLING_ERROR", "DID_NOT_AGREE_TO_PRICE_INCREASE", "PRODUCT_NOT_AVAILABLE", "UNKNOWN"
466+
public var expirationReason: String?
467+
/// Grace period expiration date (milliseconds since epoch)
468+
/// When set, subscription is in grace period (billing issue but still has access)
469+
public var gracePeriodExpirationDate: Double?
470+
/// True if subscription failed to renew due to billing issue and is retrying
471+
/// Note: Not directly available in RenewalInfo, available in Status
472+
public var isInBillingRetry: Bool?
461473
public var jsonRepresentation: String?
474+
/// Product ID that will be used on next renewal (when user upgrades/downgrades)
475+
/// If set and different from current productId, subscription will change on expiration
476+
public var pendingUpgradeProductId: String?
477+
/// User's response to subscription price increase
478+
/// Possible values: "AGREED", "PENDING", null (no price increase)
479+
public var priceIncreaseStatus: String?
480+
/// Expected renewal date (milliseconds since epoch)
481+
/// For active subscriptions, when the next renewal/charge will occur
482+
public var renewalDate: Double?
483+
/// Offer ID applied to next renewal (promotional offer, subscription offer code, etc.)
484+
public var renewalOfferId: String?
485+
/// Type of offer applied to next renewal
486+
/// Possible values: "PROMOTIONAL", "SUBSCRIPTION_OFFER_CODE", "WIN_BACK", etc.
487+
public var renewalOfferType: String?
462488
public var willAutoRenew: Bool
463489
}
464490

Sources/OpenIapModule.swift

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
266266
let onlyActive = options?.onlyIncludeActiveItemsIOS ?? false
267267
var purchasedItems: [Purchase] = []
268268

269-
OpenIapLog.debug("🔍 getAvailablePurchases called. onlyActive=\(onlyActive)")
270-
271269
for await verification in (onlyActive ? Transaction.currentEntitlements : Transaction.all) {
272270
do {
273271
let transaction = try checkVerified(verification)
@@ -287,7 +285,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
287285
}
288286
}
289287

290-
OpenIapLog.debug("🔍 getAvailablePurchases returning \(purchasedItems.count) purchases")
288+
OpenIapLog.debug("🔍 getAvailablePurchases: \(purchasedItems.count) purchases (onlyActive=\(onlyActive))")
291289
return purchasedItems
292290
}
293291

@@ -652,9 +650,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
652650
#if os(iOS)
653651
if #available(iOS 18.2, *) {
654652
guard await ExternalPurchase.canPresent else {
655-
return ExternalPurchaseNoticeResultIOS(
656-
error: "External purchase notice sheet is not available",
657-
result: .dismissed
653+
throw makePurchaseError(
654+
code: .featureNotSupported,
655+
message: "External purchase notice sheet is not available"
658656
)
659657
}
660658

@@ -663,15 +661,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
663661
switch result {
664662
case .continuedWithExternalPurchaseToken(_):
665663
return ExternalPurchaseNoticeResultIOS(error: nil, result: .continue)
666-
@unknown default:
667-
return ExternalPurchaseNoticeResultIOS(
668-
error: "User dismissed notice sheet",
669-
result: .dismissed
664+
default:
665+
// Unexpected result type - should not normally happen
666+
throw makePurchaseError(
667+
code: .unknown,
668+
message: "Unexpected result from external purchase notice sheet"
670669
)
671670
}
671+
} catch let error as PurchaseError {
672+
return ExternalPurchaseNoticeResultIOS(
673+
error: error.message,
674+
result: .dismissed
675+
)
672676
} catch {
677+
let purchaseError = makePurchaseError(
678+
code: .serviceError,
679+
message: "Failed to present external purchase notice: \(error.localizedDescription)"
680+
)
673681
return ExternalPurchaseNoticeResultIOS(
674-
error: error.localizedDescription,
682+
error: purchaseError.message,
675683
result: .dismissed
676684
)
677685
}

Sources/OpenIapStore.swift

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,39 @@ public final class OpenIapStore: ObservableObject {
166166
defer { status.loadings.restorePurchases = false }
167167

168168
let purchases = try await module.getAvailablePurchases(options)
169-
OpenIapLog.debug("🧾 getAvailablePurchases returned \(purchases.count) purchases")
170-
purchases.forEach { purchase in
171-
if let ios = purchase.asIOS() {
172-
OpenIapLog.debug(" • purchase id=\(ios.transactionId) product=\(ios.productId) state=\(ios.purchaseState.rawValue) autoRenew=\(ios.isAutoRenewing) expires=\(ios.expirationDateIOS.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date.distantPast)")
173-
} else {
174-
OpenIapLog.debug(" • non-iOS purchase encountered")
169+
availablePurchases = deduplicatePurchases(purchases)
170+
171+
OpenIapLog.debug("🧾 availablePurchases: \(purchases.count) total → \(availablePurchases.count) active")
172+
173+
// Show renewal info details for active subscriptions
174+
let withRenewalInfo = availablePurchases.compactMap { $0.asIOS() }.filter { $0.renewalInfoIOS != nil }
175+
for purchase in withRenewalInfo {
176+
if let info = purchase.renewalInfoIOS {
177+
OpenIapLog.debug(" 📋 \(purchase.productId) renewalInfo:")
178+
OpenIapLog.debug(" • willAutoRenew: \(info.willAutoRenew)")
179+
OpenIapLog.debug(" • autoRenewPreference: \(info.autoRenewPreference ?? "nil")")
180+
if let pendingUpgrade = info.pendingUpgradeProductId {
181+
OpenIapLog.debug(" • pendingUpgradeProductId: \(pendingUpgrade) ⚠️ UPGRADE PENDING")
182+
}
183+
if let expirationReason = info.expirationReason {
184+
OpenIapLog.debug(" • expirationReason: \(expirationReason)")
185+
}
186+
if let renewalDate = info.renewalDate {
187+
let date = Date(timeIntervalSince1970: renewalDate / 1000)
188+
OpenIapLog.debug(" • renewalDate: \(date)")
189+
}
190+
if let gracePeriod = info.gracePeriodExpirationDate {
191+
let date = Date(timeIntervalSince1970: gracePeriod / 1000)
192+
OpenIapLog.debug(" • gracePeriodExpirationDate: \(date)")
193+
}
194+
if let offerId = info.renewalOfferId {
195+
OpenIapLog.debug(" • renewalOfferId: \(offerId)")
196+
}
197+
if let offerType = info.renewalOfferType {
198+
OpenIapLog.debug(" • renewalOfferType: \(offerType)")
199+
}
175200
}
176201
}
177-
availablePurchases = deduplicatePurchases(purchases)
178-
OpenIapLog.debug("🧾 availablePurchases updated to \(availablePurchases.count) items")
179202
}
180203

181204
public func requestPurchase(
@@ -378,27 +401,19 @@ public final class OpenIapStore: ObservableObject {
378401
private func deduplicatePurchases(_ purchases: [OpenIAP.Purchase]) -> [OpenIAP.Purchase] {
379402
var nonSubscriptionPurchases: [OpenIAP.Purchase] = []
380403
var latestSubscriptionByProduct: [String: OpenIAP.Purchase] = [:]
404+
var skippedInactive = 0
381405

382406
for purchase in purchases {
383407
guard let iosPurchase = purchase.asIOS() else {
384-
OpenIapLog.debug(" ↳ keeping non-iOS purchase entry")
385408
nonSubscriptionPurchases.append(purchase)
386409
continue
387410
}
388411

389412
let isSubscription = iosPurchase.expirationDateIOS != nil
390413
|| iosPurchase.isAutoRenewing
391-
|| (iosPurchase.subscriptionGroupIdIOS?.isEmpty == false) // group id arrives immediately for subs
392-
let expiryDescription: String
393-
if let expiry = iosPurchase.expirationDateIOS {
394-
let date = Date(timeIntervalSince1970: expiry / 1000)
395-
expiryDescription = "\(date)"
396-
} else {
397-
expiryDescription = "none"
398-
}
399-
OpenIapLog.debug(" ↳ evaluating purchase id=\(iosPurchase.transactionId) product=\(iosPurchase.productId) state=\(iosPurchase.purchaseState.rawValue) autoRenew=\(iosPurchase.isAutoRenewing) expires=\(expiryDescription) isSubscription=\(isSubscription)")
414+
|| (iosPurchase.subscriptionGroupIdIOS?.isEmpty == false)
415+
400416
if isSubscription == false {
401-
OpenIapLog.debug(" • classified as non-subscription, retaining")
402417
nonSubscriptionPurchases.append(purchase)
403418
continue
404419
}
@@ -407,32 +422,31 @@ public final class OpenIapStore: ObservableObject {
407422
if let expiry = iosPurchase.expirationDateIOS {
408423
let expiryDate = Date(timeIntervalSince1970: expiry / 1000)
409424
isActive = expiryDate > Date()
410-
OpenIapLog.debug(" • expiryDate=\(expiryDate) isActive=\(isActive)")
411425
} else {
412426
isActive = iosPurchase.isAutoRenewing
413427
|| iosPurchase.purchaseState == .purchased
414428
|| iosPurchase.purchaseState == .restored
415-
OpenIapLog.debug(" • no expiry; autoRenew=\(iosPurchase.isAutoRenewing) state=\(iosPurchase.purchaseState.rawValue) -> isActive=\(isActive)")
416429
}
430+
417431
guard isActive else {
418-
OpenIapLog.debug(" • skipping inactive subscription entry")
432+
skippedInactive += 1
419433
continue
420434
}
421435

422436
if let existing = latestSubscriptionByProduct[iosPurchase.productId], let existingIos = existing.asIOS() {
423437
let shouldReplace = shouldReplaceSubscription(existing: existingIos, candidate: iosPurchase)
424-
OpenIapLog.debug(" • existing subscription found (transactionDate=\(existingIos.transactionDate)); shouldReplace=\(shouldReplace)")
425438
if shouldReplace {
426439
latestSubscriptionByProduct[iosPurchase.productId] = purchase
427-
} else {
428-
OpenIapLog.debug(" • keeping existing subscription")
429440
}
430441
} else {
431-
OpenIapLog.debug(" • first subscription for product, storing")
432442
latestSubscriptionByProduct[iosPurchase.productId] = purchase
433443
}
434444
}
435445

446+
if skippedInactive > 0 {
447+
OpenIapLog.debug(" ↳ filtered out \(skippedInactive) inactive subscriptions")
448+
}
449+
436450
let allPurchases = nonSubscriptionPurchases + Array(latestSubscriptionByProduct.values)
437451
return allPurchases.sorted { lhs, rhs in
438452
(lhs.asIOS()?.transactionDate ?? 0) > (rhs.asIOS()?.transactionDate ?? 0)

0 commit comments

Comments
 (0)