From 80054ba7a3c146342628ce78307d0125fcd13b31 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Oct 2025 02:09:27 +0900 Subject: [PATCH 1/5] feat(ios): add renewalInfoIOS for subscription status tracking --- Sources/Helpers/StoreKitTypesBridge.swift | 83 +++++++++++++- Sources/Models/Types.swift | 26 +++++ Sources/OpenIapModule.swift | 30 +++-- Sources/OpenIapStore.swift | 66 ++++++----- Tests/OpenIapTests.swift | 133 ++++++++++++++++++++++ openiap-versions.json | 2 +- 6 files changed, 301 insertions(+), 39 deletions(-) diff --git a/Sources/Helpers/StoreKitTypesBridge.swift b/Sources/Helpers/StoreKitTypesBridge.swift index fbe0c57..57a38b9 100644 --- a/Sources/Helpers/StoreKitTypesBridge.swift +++ b/Sources/Helpers/StoreKitTypesBridge.swift @@ -70,7 +70,8 @@ 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 subscriptionRenewalInfo(for: transaction) + let autoRenewing = renewalInfoIOS?.willAutoRenew ?? (transaction.productType == .autoRenewable) let environment: String? if #available(iOS 16.0, *) { environment = transaction.environment.rawValue @@ -117,6 +118,7 @@ enum StoreKitTypesBridge { quantityIOS: transaction.purchasedQuantity, reasonIOS: reasonDetails.lowercased, reasonStringRepresentationIOS: reasonDetails.string, + renewalInfoIOS: renewalInfoIOS, revocationDateIOS: revocationDate, revocationReasonIOS: transaction.revocationReason?.rawValue.description, storefrontCountryCodeIOS: { @@ -167,6 +169,85 @@ enum StoreKitTypesBridge { return nil } + private static func subscriptionRenewalInfo(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 #available(iOS 18.0, macOS 15.0, *) { + offerInfo = (id: info.offer?.id, type: info.offer?.type.rawValue.description) + } else { + // Fallback to deprecated properties + #if compiler(>=5.9) + offerInfo = (id: info.offerID, type: info.offerType?.rawValue.description) + #else + offerInfo = nil + #endif + } + 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: nil, // TODO: Add when API confirmed + 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 #available(iOS 18.0, macOS 15.0, *) { + offerInfo = (id: info.offer?.id, type: info.offer?.type.rawValue.description) + } else { + // Fallback to deprecated properties + #if compiler(>=5.9) + offerInfo = (id: info.offerID, type: info.offerType?.rawValue.description) + #else + offerInfo = nil + #endif + } + 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: nil, // TODO: Add when API confirmed + renewalDate: info.renewalDate?.milliseconds, + renewalOfferId: offerInfo?.id, + renewalOfferType: offerInfo?.type, + willAutoRenew: info.willAutoRenew + ) + return renewalInfo + } + } + } catch { + return nil + } + + return nil + } + static func purchaseOptions(from props: RequestPurchaseIosProps) throws -> Set { var options: Set = [] if let quantity = props.quantity, quantity > 1 { diff --git a/Sources/Models/Types.swift b/Sources/Models/Types.swift index f79889d..5ad2f50 100644 --- a/Sources/Models/Types.swift +++ b/Sources/Models/Types.swift @@ -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? @@ -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 } diff --git a/Sources/OpenIapModule.swift b/Sources/OpenIapModule.swift index a9db530..2fa471a 100644 --- a/Sources/OpenIapModule.swift +++ b/Sources/OpenIapModule.swift @@ -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) @@ -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 } @@ -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" ) } @@ -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 ) } diff --git a/Sources/OpenIapStore.swift b/Sources/OpenIapStore.swift index 4401ab7..2b2f6b7 100644 --- a/Sources/OpenIapStore.swift +++ b/Sources/OpenIapStore.swift @@ -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( @@ -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 } @@ -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) diff --git a/Tests/OpenIapTests.swift b/Tests/OpenIapTests.swift index d000f7d..426e3e9 100644 --- a/Tests/OpenIapTests.swift +++ b/Tests/OpenIapTests.swift @@ -44,6 +44,101 @@ final class OpenIapTests: XCTestCase { XCTAssertEqual(error.message, "User cancelled the purchase flow") } + func testPurchaseIOSWithRenewalInfo() { + let renewalInfo = RenewalInfoIOS( + autoRenewPreference: "dev.hyo.premium_year", + expirationReason: nil, + gracePeriodExpirationDate: nil, + isInBillingRetry: nil, + jsonRepresentation: nil, + pendingUpgradeProductId: "dev.hyo.premium_year", + priceIncreaseStatus: nil, + renewalDate: 1729087555000, + renewalOfferId: nil, + renewalOfferType: nil, + willAutoRenew: false + ) + + let purchase = PurchaseIOS( + appAccountToken: nil, + appBundleIdIOS: "dev.hyo.app", + countryCodeIOS: "US", + currencyCodeIOS: "USD", + currencySymbolIOS: "$", + environmentIOS: "Sandbox", + expirationDateIOS: 1729087555000, + id: "2000001034753679", + ids: nil, + isAutoRenewing: false, + isUpgradedIOS: false, + offerIOS: nil, + originalTransactionDateIOS: 1729083955000, + originalTransactionIdentifierIOS: "2000001034753679", + ownershipTypeIOS: "purchased", + platform: .ios, + productId: "dev.hyo.martie.premium", + purchaseState: .purchased, + purchaseToken: "jws_token", + quantity: 1, + quantityIOS: 1, + reasonIOS: "purchase", + reasonStringRepresentationIOS: "purchase", + renewalInfoIOS: renewalInfo, + revocationDateIOS: nil, + revocationReasonIOS: nil, + storefrontCountryCodeIOS: "US", + subscriptionGroupIdIOS: "21686373", + transactionDate: 1729083955000, + transactionId: "2000001034753679", + transactionReasonIOS: "PURCHASE", + webOrderLineItemIdIOS: nil + ) + + XCTAssertNotNil(purchase.renewalInfoIOS) + XCTAssertEqual(purchase.renewalInfoIOS?.willAutoRenew, false) + XCTAssertEqual(purchase.renewalInfoIOS?.autoRenewPreference, "dev.hyo.premium_year") + XCTAssertEqual(purchase.renewalInfoIOS?.pendingUpgradeProductId, "dev.hyo.premium_year") + XCTAssertEqual(purchase.renewalInfoIOS?.renewalDate, 1729087555000) + } + + func testPurchaseIOSSerializationWithRenewalInfo() throws { + let renewalInfo = RenewalInfoIOS( + autoRenewPreference: "dev.hyo.premium_year", + expirationReason: nil, + gracePeriodExpirationDate: nil, + isInBillingRetry: nil, + jsonRepresentation: nil, + pendingUpgradeProductId: "dev.hyo.premium_year", + priceIncreaseStatus: nil, + renewalDate: 1729087555000, + renewalOfferId: nil, + renewalOfferType: nil, + willAutoRenew: false + ) + + let purchase = makeSamplePurchaseWithRenewalInfo(renewalInfo) + + // Test encoding to dictionary + let dictionary = OpenIapSerialization.encode(purchase) + XCTAssertNotNil(dictionary["renewalInfoIOS"]) + + if let renewalDict = dictionary["renewalInfoIOS"] as? [String: Any] { + XCTAssertEqual(renewalDict["willAutoRenew"] as? Bool, false) + XCTAssertEqual(renewalDict["autoRenewPreference"] as? String, "dev.hyo.premium_year") + XCTAssertEqual(renewalDict["pendingUpgradeProductId"] as? String, "dev.hyo.premium_year") + XCTAssertEqual(renewalDict["renewalDate"] as? Double, 1729087555000) + } else { + XCTFail("renewalInfoIOS should be a dictionary") + } + + // Test round-trip encoding/decoding + let data = try JSONEncoder().encode(purchase) + let decoded = try JSONDecoder().decode(PurchaseIOS.self, from: data) + XCTAssertNotNil(decoded.renewalInfoIOS) + XCTAssertEqual(decoded.renewalInfoIOS?.willAutoRenew, false) + XCTAssertEqual(decoded.renewalInfoIOS?.pendingUpgradeProductId, "dev.hyo.premium_year") + } + // MARK: - Helpers private func makeSampleProduct() -> ProductIOS { @@ -108,6 +203,7 @@ final class OpenIapTests: XCTestCase { quantityIOS: 1, reasonIOS: "purchase", reasonStringRepresentationIOS: "purchase", + renewalInfoIOS: nil, revocationDateIOS: nil, revocationReasonIOS: nil, storefrontCountryCodeIOS: "US", @@ -119,6 +215,43 @@ final class OpenIapTests: XCTestCase { ) } + private func makeSamplePurchaseWithRenewalInfo(_ renewalInfo: RenewalInfoIOS) -> PurchaseIOS { + PurchaseIOS( + appAccountToken: nil, + appBundleIdIOS: "dev.hyo.app", + countryCodeIOS: "US", + currencyCodeIOS: "USD", + currencySymbolIOS: "$", + environmentIOS: "Sandbox", + expirationDateIOS: 1729087555000, + id: "2000001034753679", + ids: nil, + isAutoRenewing: false, + isUpgradedIOS: false, + offerIOS: nil, + originalTransactionDateIOS: 1729083955000, + originalTransactionIdentifierIOS: "2000001034753679", + ownershipTypeIOS: "purchased", + platform: .ios, + productId: "dev.hyo.martie.premium", + purchaseState: .purchased, + purchaseToken: "jws_token", + quantity: 1, + quantityIOS: 1, + reasonIOS: "purchase", + reasonStringRepresentationIOS: "purchase", + renewalInfoIOS: renewalInfo, + revocationDateIOS: nil, + revocationReasonIOS: nil, + storefrontCountryCodeIOS: "US", + subscriptionGroupIdIOS: "21686373", + transactionDate: 1729083955000, + transactionId: "2000001034753679", + transactionReasonIOS: "PURCHASE", + webOrderLineItemIdIOS: nil + ) + } + private func makeSampleSubscription() -> ProductSubscriptionIOS { let subscriptionPeriod = SubscriptionPeriodValueIOS(unit: .month, value: 1) let offer = SubscriptionOfferIOS( diff --git a/openiap-versions.json b/openiap-versions.json index 0ee4b24..8e3c180 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,4 +1,4 @@ { "apple": "1.2.19", - "gql": "1.0.12" + "gql": "1.2.0" } From 81657efcfd7314727a5943ee29efa058474c93ce Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Oct 2025 02:20:01 +0900 Subject: [PATCH 2/5] fix: apply code review --- Sources/Helpers/StoreKitTypesBridge.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Helpers/StoreKitTypesBridge.swift b/Sources/Helpers/StoreKitTypesBridge.swift index 57a38b9..b2bdfd8 100644 --- a/Sources/Helpers/StoreKitTypesBridge.swift +++ b/Sources/Helpers/StoreKitTypesBridge.swift @@ -71,7 +71,8 @@ enum StoreKitTypesBridge { let expirationDate = transaction.expirationDate?.milliseconds let revocationDate = transaction.revocationDate?.milliseconds let renewalInfoIOS = await subscriptionRenewalInfo(for: transaction) - let autoRenewing = renewalInfoIOS?.willAutoRenew ?? (transaction.productType == .autoRenewable) + // 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 From 904856a590b832a29705b01c377f7cf4b829979e Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Oct 2025 02:28:16 +0900 Subject: [PATCH 3/5] fix: fill priceIncreaseStatus --- Sources/Helpers/StoreKitTypesBridge.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Helpers/StoreKitTypesBridge.swift b/Sources/Helpers/StoreKitTypesBridge.swift index b2bdfd8..c8f8d76 100644 --- a/Sources/Helpers/StoreKitTypesBridge.swift +++ b/Sources/Helpers/StoreKitTypesBridge.swift @@ -206,7 +206,7 @@ enum StoreKitTypesBridge { isInBillingRetry: nil, // Not available in RenewalInfo, available in Status jsonRepresentation: nil, pendingUpgradeProductId: pendingProductId, - priceIncreaseStatus: nil, // TODO: Add when API confirmed + priceIncreaseStatus: String(describing: info.priceIncreaseStatus), renewalDate: info.renewalDate?.milliseconds, renewalOfferId: offerInfo?.id, renewalOfferType: offerInfo?.type, @@ -233,7 +233,7 @@ enum StoreKitTypesBridge { isInBillingRetry: nil, // Not available in RenewalInfo, available in Status jsonRepresentation: nil, pendingUpgradeProductId: pendingProductId, - priceIncreaseStatus: nil, // TODO: Add when API confirmed + priceIncreaseStatus: String(describing: info.priceIncreaseStatus), renewalDate: info.renewalDate?.milliseconds, renewalOfferId: offerInfo?.id, renewalOfferType: offerInfo?.type, From e9e1a9da01a24159e556ba6d3e255ba5b0326adb Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Oct 2025 02:45:26 +0900 Subject: [PATCH 4/5] fix: code review --- Sources/Helpers/StoreKitTypesBridge.swift | 33 ++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/Sources/Helpers/StoreKitTypesBridge.swift b/Sources/Helpers/StoreKitTypesBridge.swift index c8f8d76..db5c580 100644 --- a/Sources/Helpers/StoreKitTypesBridge.swift +++ b/Sources/Helpers/StoreKitTypesBridge.swift @@ -190,15 +190,25 @@ enum StoreKitTypesBridge { let pendingProductId = (info.autoRenewPreference != transaction.productID) ? info.autoRenewPreference : nil let offerInfo: (id: String?, type: String?)? if #available(iOS 18.0, macOS 15.0, *) { - offerInfo = (id: info.offer?.id, type: info.offer?.type.rawValue.description) + // 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 { // Fallback to deprecated properties #if compiler(>=5.9) - offerInfo = (id: info.offerID, type: info.offerType?.rawValue.description) + let offerTypeString = info.offerType.map { String(describing: $0) } + offerInfo = (id: info.offerID, type: offerTypeString) #else offerInfo = nil #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, @@ -206,7 +216,7 @@ enum StoreKitTypesBridge { isInBillingRetry: nil, // Not available in RenewalInfo, available in Status jsonRepresentation: nil, pendingUpgradeProductId: pendingProductId, - priceIncreaseStatus: String(describing: info.priceIncreaseStatus), + priceIncreaseStatus: priceIncrease, renewalDate: info.renewalDate?.milliseconds, renewalOfferId: offerInfo?.id, renewalOfferType: offerInfo?.type, @@ -217,15 +227,25 @@ enum StoreKitTypesBridge { let pendingProductId = (info.autoRenewPreference != transaction.productID) ? info.autoRenewPreference : nil let offerInfo: (id: String?, type: String?)? if #available(iOS 18.0, macOS 15.0, *) { - offerInfo = (id: info.offer?.id, type: info.offer?.type.rawValue.description) + // 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 { // Fallback to deprecated properties #if compiler(>=5.9) - offerInfo = (id: info.offerID, type: info.offerType?.rawValue.description) + let offerTypeString = info.offerType.map { String(describing: $0) } + offerInfo = (id: info.offerID, type: offerTypeString) #else offerInfo = nil #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, @@ -233,7 +253,7 @@ enum StoreKitTypesBridge { isInBillingRetry: nil, // Not available in RenewalInfo, available in Status jsonRepresentation: nil, pendingUpgradeProductId: pendingProductId, - priceIncreaseStatus: String(describing: info.priceIncreaseStatus), + priceIncreaseStatus: priceIncrease, renewalDate: info.renewalDate?.milliseconds, renewalOfferId: offerInfo?.id, renewalOfferType: offerInfo?.type, @@ -243,6 +263,7 @@ enum StoreKitTypesBridge { } } } catch { + OpenIapLog.debug("⚠️ Failed to fetch renewalInfo: \(error.localizedDescription)") return nil } From 94fadbe3c871bde447cebc0628e4b05576563cdb Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Oct 2025 02:56:25 +0900 Subject: [PATCH 5/5] fix: code review --- Sources/Helpers/StoreKitTypesBridge.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/Helpers/StoreKitTypesBridge.swift b/Sources/Helpers/StoreKitTypesBridge.swift index db5c580..5be9235 100644 --- a/Sources/Helpers/StoreKitTypesBridge.swift +++ b/Sources/Helpers/StoreKitTypesBridge.swift @@ -70,7 +70,7 @@ enum StoreKitTypesBridge { let purchaseState: PurchaseState = .purchased let expirationDate = transaction.expirationDate?.milliseconds let revocationDate = transaction.revocationDate?.milliseconds - let renewalInfoIOS = await subscriptionRenewalInfo(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? @@ -170,7 +170,7 @@ enum StoreKitTypesBridge { return nil } - private static func subscriptionRenewalInfo(for transaction: StoreKit.Transaction) async -> RenewalInfoIOS? { + private static func subscriptionRenewalInfoIOS(for transaction: StoreKit.Transaction) async -> RenewalInfoIOS? { guard transaction.productType == .autoRenewable else { return nil } @@ -189,11 +189,13 @@ enum StoreKitTypesBridge { 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) } @@ -201,7 +203,9 @@ enum StoreKitTypesBridge { #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, *) { @@ -226,11 +230,13 @@ enum StoreKitTypesBridge { 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) } @@ -238,7 +244,9 @@ enum StoreKitTypesBridge { #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, *) {