Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion Sources/Helpers/StoreKitTypesBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ enum StoreKitTypesBridge {
let purchaseState: PurchaseState = .purchased
let expirationDate = transaction.expirationDate?.milliseconds
let revocationDate = transaction.revocationDate?.milliseconds
let autoRenewing = await determineAutoRenewStatus(for: transaction)
let renewalInfoIOS = await subscriptionRenewalInfoIOS(for: transaction)
// Default to false if renewalInfo unavailable - safer to underreport than falsely claim auto-renewal
let autoRenewing = renewalInfoIOS?.willAutoRenew ?? false
let environment: String?
if #available(iOS 16.0, *) {
environment = transaction.environment.rawValue
Expand Down Expand Up @@ -117,6 +119,7 @@ enum StoreKitTypesBridge {
quantityIOS: transaction.purchasedQuantity,
reasonIOS: reasonDetails.lowercased,
reasonStringRepresentationIOS: reasonDetails.string,
renewalInfoIOS: renewalInfoIOS,
revocationDateIOS: revocationDate,
revocationReasonIOS: transaction.revocationReason?.rawValue.description,
storefrontCountryCodeIOS: {
Expand Down Expand Up @@ -167,6 +170,114 @@ enum StoreKitTypesBridge {
return nil
}

private static func subscriptionRenewalInfoIOS(for transaction: StoreKit.Transaction) async -> RenewalInfoIOS? {
guard transaction.productType == .autoRenewable else {
return nil
}
guard let groupId = transaction.subscriptionGroupID else {
return nil
}

do {
let statuses = try await StoreKit.Product.SubscriptionInfo.status(for: groupId)

for status in statuses {
guard case .verified(let statusTransaction) = status.transaction else { continue }
guard statusTransaction.productID == transaction.productID else { continue }

switch status.renewalInfo {
case .verified(let info):
let pendingProductId = (info.autoRenewPreference != transaction.productID) ? info.autoRenewPreference : nil
let offerInfo: (id: String?, type: String?)?
#if swift(>=6.1)
if #available(iOS 18.0, macOS 15.0, *) {
// Map type only when present to avoid "nil" literal strings
let offerTypeString = info.offer.map { String(describing: $0.type) }
offerInfo = (id: info.offer?.id, type: offerTypeString)
} else {
#endif
// Fallback to deprecated properties
#if compiler(>=5.9)
let offerTypeString = info.offerType.map { String(describing: $0) }
offerInfo = (id: info.offerID, type: offerTypeString)
#else
offerInfo = nil
#endif
#if swift(>=6.1)
}
#endif
// priceIncreaseStatus only available on iOS 15.0+
let priceIncrease: String? = {
if #available(iOS 15.0, macOS 12.0, *) {
return String(describing: info.priceIncreaseStatus)
}
return nil
}()
let renewalInfo = RenewalInfoIOS(
autoRenewPreference: info.autoRenewPreference,
expirationReason: info.expirationReason?.rawValue.description,
gracePeriodExpirationDate: info.gracePeriodExpirationDate?.milliseconds,
isInBillingRetry: nil, // Not available in RenewalInfo, available in Status
jsonRepresentation: nil,
pendingUpgradeProductId: pendingProductId,
priceIncreaseStatus: priceIncrease,
renewalDate: info.renewalDate?.milliseconds,
renewalOfferId: offerInfo?.id,
renewalOfferType: offerInfo?.type,
willAutoRenew: info.willAutoRenew
)
return renewalInfo
case .unverified(let info, _):
let pendingProductId = (info.autoRenewPreference != transaction.productID) ? info.autoRenewPreference : nil
let offerInfo: (id: String?, type: String?)?
#if swift(>=6.1)
if #available(iOS 18.0, macOS 15.0, *) {
// Map type only when present to avoid "nil" literal strings
let offerTypeString = info.offer.map { String(describing: $0.type) }
offerInfo = (id: info.offer?.id, type: offerTypeString)
} else {
#endif
// Fallback to deprecated properties
#if compiler(>=5.9)
let offerTypeString = info.offerType.map { String(describing: $0) }
offerInfo = (id: info.offerID, type: offerTypeString)
#else
offerInfo = nil
#endif
#if swift(>=6.1)
}
#endif
// priceIncreaseStatus only available on iOS 15.0+
let priceIncrease: String? = {
if #available(iOS 15.0, macOS 12.0, *) {
return String(describing: info.priceIncreaseStatus)
}
return nil
}()
let renewalInfo = RenewalInfoIOS(
autoRenewPreference: info.autoRenewPreference,
expirationReason: info.expirationReason?.rawValue.description,
gracePeriodExpirationDate: info.gracePeriodExpirationDate?.milliseconds,
isInBillingRetry: nil, // Not available in RenewalInfo, available in Status
jsonRepresentation: nil,
pendingUpgradeProductId: pendingProductId,
priceIncreaseStatus: priceIncrease,
renewalDate: info.renewalDate?.milliseconds,
renewalOfferId: offerInfo?.id,
renewalOfferType: offerInfo?.type,
willAutoRenew: info.willAutoRenew
)
return renewalInfo
}
}
} catch {
OpenIapLog.debug("⚠️ Failed to fetch renewalInfo: \(error.localizedDescription)")
return nil
}

return nil
}

static func purchaseOptions(from props: RequestPurchaseIosProps) throws -> Set<StoreKit.Product.PurchaseOption> {
var options: Set<StoreKit.Product.PurchaseOption> = []
if let quantity = props.quantity, quantity > 1 {
Expand Down
26 changes: 26 additions & 0 deletions Sources/Models/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ public struct PurchaseIOS: Codable, PurchaseCommon {
public var quantityIOS: Int?
public var reasonIOS: String?
public var reasonStringRepresentationIOS: String?
public var renewalInfoIOS: RenewalInfoIOS?
public var revocationDateIOS: Double?
public var revocationReasonIOS: String?
public var storefrontCountryCodeIOS: String?
Expand Down Expand Up @@ -456,9 +457,34 @@ public struct RefundResultIOS: Codable {
public var status: String
}

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

Expand Down
30 changes: 19 additions & 11 deletions Sources/OpenIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
let onlyActive = options?.onlyIncludeActiveItemsIOS ?? false
var purchasedItems: [Purchase] = []

OpenIapLog.debug("🔍 getAvailablePurchases called. onlyActive=\(onlyActive)")

for await verification in (onlyActive ? Transaction.currentEntitlements : Transaction.all) {
do {
let transaction = try checkVerified(verification)
Expand All @@ -287,7 +285,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
}
}

OpenIapLog.debug("🔍 getAvailablePurchases returning \(purchasedItems.count) purchases")
OpenIapLog.debug("🔍 getAvailablePurchases: \(purchasedItems.count) purchases (onlyActive=\(onlyActive))")
return purchasedItems
}

Expand Down Expand Up @@ -652,9 +650,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
#if os(iOS)
if #available(iOS 18.2, *) {
guard await ExternalPurchase.canPresent else {
return ExternalPurchaseNoticeResultIOS(
error: "External purchase notice sheet is not available",
result: .dismissed
throw makePurchaseError(
code: .featureNotSupported,
message: "External purchase notice sheet is not available"
)
}

Expand All @@ -663,15 +661,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
switch result {
case .continuedWithExternalPurchaseToken(_):
return ExternalPurchaseNoticeResultIOS(error: nil, result: .continue)
@unknown default:
return ExternalPurchaseNoticeResultIOS(
error: "User dismissed notice sheet",
result: .dismissed
default:
// Unexpected result type - should not normally happen
throw makePurchaseError(
code: .unknown,
message: "Unexpected result from external purchase notice sheet"
)
}
} catch let error as PurchaseError {
return ExternalPurchaseNoticeResultIOS(
error: error.message,
result: .dismissed
)
} catch {
let purchaseError = makePurchaseError(
code: .serviceError,
message: "Failed to present external purchase notice: \(error.localizedDescription)"
)
return ExternalPurchaseNoticeResultIOS(
error: error.localizedDescription,
error: purchaseError.message,
result: .dismissed
)
}
Expand Down
66 changes: 40 additions & 26 deletions Sources/OpenIapStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,16 +166,39 @@ public final class OpenIapStore: ObservableObject {
defer { status.loadings.restorePurchases = false }

let purchases = try await module.getAvailablePurchases(options)
OpenIapLog.debug("🧾 getAvailablePurchases returned \(purchases.count) purchases")
purchases.forEach { purchase in
if let ios = purchase.asIOS() {
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)")
} else {
OpenIapLog.debug(" • non-iOS purchase encountered")
availablePurchases = deduplicatePurchases(purchases)

OpenIapLog.debug("🧾 availablePurchases: \(purchases.count) total → \(availablePurchases.count) active")

// Show renewal info details for active subscriptions
let withRenewalInfo = availablePurchases.compactMap { $0.asIOS() }.filter { $0.renewalInfoIOS != nil }
for purchase in withRenewalInfo {
if let info = purchase.renewalInfoIOS {
OpenIapLog.debug(" 📋 \(purchase.productId) renewalInfo:")
OpenIapLog.debug(" • willAutoRenew: \(info.willAutoRenew)")
OpenIapLog.debug(" • autoRenewPreference: \(info.autoRenewPreference ?? "nil")")
if let pendingUpgrade = info.pendingUpgradeProductId {
OpenIapLog.debug(" • pendingUpgradeProductId: \(pendingUpgrade) ⚠️ UPGRADE PENDING")
}
if let expirationReason = info.expirationReason {
OpenIapLog.debug(" • expirationReason: \(expirationReason)")
}
if let renewalDate = info.renewalDate {
let date = Date(timeIntervalSince1970: renewalDate / 1000)
OpenIapLog.debug(" • renewalDate: \(date)")
}
if let gracePeriod = info.gracePeriodExpirationDate {
let date = Date(timeIntervalSince1970: gracePeriod / 1000)
OpenIapLog.debug(" • gracePeriodExpirationDate: \(date)")
}
if let offerId = info.renewalOfferId {
OpenIapLog.debug(" • renewalOfferId: \(offerId)")
}
if let offerType = info.renewalOfferType {
OpenIapLog.debug(" • renewalOfferType: \(offerType)")
}
}
}
availablePurchases = deduplicatePurchases(purchases)
OpenIapLog.debug("🧾 availablePurchases updated to \(availablePurchases.count) items")
}

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

for purchase in purchases {
guard let iosPurchase = purchase.asIOS() else {
OpenIapLog.debug(" ↳ keeping non-iOS purchase entry")
nonSubscriptionPurchases.append(purchase)
continue
}

let isSubscription = iosPurchase.expirationDateIOS != nil
|| iosPurchase.isAutoRenewing
|| (iosPurchase.subscriptionGroupIdIOS?.isEmpty == false) // group id arrives immediately for subs
let expiryDescription: String
if let expiry = iosPurchase.expirationDateIOS {
let date = Date(timeIntervalSince1970: expiry / 1000)
expiryDescription = "\(date)"
} else {
expiryDescription = "none"
}
OpenIapLog.debug(" ↳ evaluating purchase id=\(iosPurchase.transactionId) product=\(iosPurchase.productId) state=\(iosPurchase.purchaseState.rawValue) autoRenew=\(iosPurchase.isAutoRenewing) expires=\(expiryDescription) isSubscription=\(isSubscription)")
|| (iosPurchase.subscriptionGroupIdIOS?.isEmpty == false)

if isSubscription == false {
OpenIapLog.debug(" • classified as non-subscription, retaining")
nonSubscriptionPurchases.append(purchase)
continue
}
Expand All @@ -407,32 +422,31 @@ public final class OpenIapStore: ObservableObject {
if let expiry = iosPurchase.expirationDateIOS {
let expiryDate = Date(timeIntervalSince1970: expiry / 1000)
isActive = expiryDate > Date()
OpenIapLog.debug(" • expiryDate=\(expiryDate) isActive=\(isActive)")
} else {
isActive = iosPurchase.isAutoRenewing
|| iosPurchase.purchaseState == .purchased
|| iosPurchase.purchaseState == .restored
OpenIapLog.debug(" • no expiry; autoRenew=\(iosPurchase.isAutoRenewing) state=\(iosPurchase.purchaseState.rawValue) -> isActive=\(isActive)")
}

guard isActive else {
OpenIapLog.debug(" • skipping inactive subscription entry")
skippedInactive += 1
continue
}

if let existing = latestSubscriptionByProduct[iosPurchase.productId], let existingIos = existing.asIOS() {
let shouldReplace = shouldReplaceSubscription(existing: existingIos, candidate: iosPurchase)
OpenIapLog.debug(" • existing subscription found (transactionDate=\(existingIos.transactionDate)); shouldReplace=\(shouldReplace)")
if shouldReplace {
latestSubscriptionByProduct[iosPurchase.productId] = purchase
} else {
OpenIapLog.debug(" • keeping existing subscription")
}
} else {
OpenIapLog.debug(" • first subscription for product, storing")
latestSubscriptionByProduct[iosPurchase.productId] = purchase
}
}

if skippedInactive > 0 {
OpenIapLog.debug(" ↳ filtered out \(skippedInactive) inactive subscriptions")
}

let allPurchases = nonSubscriptionPurchases + Array(latestSubscriptionByProduct.values)
return allPurchases.sorted { lhs, rhs in
(lhs.asIOS()?.transactionDate ?? 0) > (rhs.asIOS()?.transactionDate ?? 0)
Expand Down
Loading