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

feat: add renewalInfoIOS for subscription status tracking#24

Merged
hyochan merged 5 commits intomainfrom
feat/renewal-info
Oct 15, 2025
Merged

feat: add renewalInfoIOS for subscription status tracking#24
hyochan merged 5 commits intomainfrom
feat/renewal-info

Conversation

@hyochan
Copy link
Member

@hyochan hyochan commented Oct 15, 2025

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

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

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.

@coderabbitai
Copy link

coderabbitai bot commented Oct 15, 2025

Walkthrough

Adds a RenewalInfoIOS model and attaches it to PurchaseIOS; extracts subscription renewal info from StoreKit via a new helper and uses it to set willAutoRenew/isAutoRenewing and include renewalInfoIOS in PurchaseIOS. Also adjusts deduplication/logging, external purchase notice error mapping, tests, and bumps a gql dependency.

Changes

Cohort / File(s) Summary
StoreKit renewal info bridge
Sources/Helpers/StoreKitTypesBridge.swift
Adds private static func subscriptionRenewalInfoIOS(for:) async -> RenewalInfoIOS? to fetch and map StoreKit subscription renewal statuses into RenewalInfoIOS; computes renewalInfoIOS and uses its willAutoRenew to derive auto-renew state when available.
Models: RenewalInfoIOS + PurchaseIOS update
Sources/Models/Types.swift
Adds public struct RenewalInfoIOS: Codable with renewal metadata fields and adds public var renewalInfoIOS: RenewalInfoIOS? to PurchaseIOS; updates PurchaseIOS initializer/signature to accept and store renewalInfoIOS.
External purchase notice flow and logging
Sources/OpenIapModule.swift
Converts unavailable/unknown notice outcomes to thrown PurchaseError values and maps errors into ExternalPurchaseNoticeResultIOS with consistent error messages; tightens transaction logging and replaces @unknown fallback with explicit error mapping.
Purchase deduplication and logging
Sources/OpenIapStore.swift
Deduplicates available purchases into a set, tracks/logs skipped inactive subscriptions, logs renewalInfo for active iOS subscriptions, and reduces per-item debug noise with summary logging.
Tests for RenewalInfoIOS integration
Tests/OpenIapTests.swift
Adds tests verifying renewalInfoIOS presence and fields, serialization/round-trip of PurchaseIOS with renewal info, and helpers to create sample purchases with renewal info.
Dependency version bump
openiap-versions.json
Updates gql version from 1.0.12 to 1.2.0.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor App
  participant Bridge as StoreKitTypesBridge
  participant StoreKit as StoreKit
  participant Models as PurchaseIOS/RenewalInfoIOS
  participant Store as OpenIapStore

  App->>Bridge: Build PurchaseIOS from transaction
  Bridge->>StoreKit: Query subscription status / renewal info
  alt Detailed renewal info available (iOS18+/macOS15+)
    StoreKit-->>Bridge: Renewal data
  else Limited or unavailable
    StoreKit-->>Bridge: No/limited data
  end
  Bridge->>Models: Construct RenewalInfoIOS? (including willAutoRenew)
  Bridge-->>Store: PurchaseIOS(renewalInfoIOS: ...)
  Store->>Store: Deduplicate purchases, skip inactive subs, log renewalInfo
  Store-->>App: Available purchases set (with renewal info)
Loading
sequenceDiagram
  autonumber
  actor Client
  participant Module as OpenIapModule
  participant System as ExternalPurchaseNoticeAPI

  Client->>Module: presentExternalPurchaseNotice(...)
  alt Sheet available
    Module->>System: Present notice
    System-->>Module: Result (acknowledged / dismissed / other)
    alt acknowledged/dismissed
      Module-->>Client: ExternalPurchaseNoticeResultIOS(status, errorMessage: nil)
    else unexpected
      Module->>Module: Throw PurchaseError(unknown result)
      Module-->>Client: ExternalPurchaseNoticeResultIOS(dismissed, errorMessage: mapped)
    end
  else Not available
    Module->>Module: Throw PurchaseError(featureNotSupported)
    Module-->>Client: ExternalPurchaseNoticeResultIOS(dismissed, errorMessage: mapped)
  end
  note right of Module: All errors are mapped to PurchaseError and surfaced via result.errorMessage
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

I dig through receipts at morning light,
Tuck renewal fields in tidy rows so tight.
willAutoRenew I gently sow,
Skip stale subs and let active grow.
A carrot-coded test — hop, all right! 🥕🐇

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly describes the core change of adding renewalInfoIOS to support iOS subscription status tracking, making it clear, specific, and directly related to the main feature introduced.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/renewal-info

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/OpenIapModule.swift (1)

653-681: Inconsistent error channel for notice sheet

On unavailability, you throw; other errors return .dismissed with an error string. This inconsistency complicates callers.

Return a result for unavailability too:

-            guard await ExternalPurchase.canPresent else {
-                throw makePurchaseError(
-                    code: .featureNotSupported,
-                    message: "External purchase notice sheet is not available"
-                )
-            }
+            guard await ExternalPurchase.canPresent else {
+                return ExternalPurchaseNoticeResultIOS(
+                    error: "External purchase notice sheet is not available",
+                    result: .dismissed
+                )
+            }

And for older OS:

-        } else {
-            throw makePurchaseError(
-                code: .featureNotSupported,
-                message: "External purchase notice sheet requires iOS 18.2 or later"
-            )
-        }
+        } else {
+            return ExternalPurchaseNoticeResultIOS(
+                error: "External purchase notice sheet requires iOS 18.2 or later",
+                result: .dismissed
+            )
+        }
🧹 Nitpick comments (4)
Sources/OpenIapStore.swift (2)

173-201: Gate verbose renewalInfo logs

These per-field logs are helpful but chatty. Consider wrapping with a debug flag or lowered log level to avoid production noise.


421-434: Use grace period and renewalInfo when deciding “active”

Inactive filtering ignores gracePeriodExpirationDate and willAutoRenew. This can drop subscriptions in billing‑retry grace periods.

Apply this refinement:

-            let isActive: Bool
-            if let expiry = iosPurchase.expirationDateIOS {
-                let expiryDate = Date(timeIntervalSince1970: expiry / 1000)
-                isActive = expiryDate > Date()
-            } else {
-                isActive = iosPurchase.isAutoRenewing
-                    || iosPurchase.purchaseState == .purchased
-                    || iosPurchase.purchaseState == .restored
-            }
+            var isActive: Bool
+            if let expiryMs = iosPurchase.expirationDateIOS {
+                let expiryDate = Date(timeIntervalSince1970: expiryMs / 1000)
+                isActive = expiryDate > Date()
+                // Treat billing grace period as active
+                if isActive == false, let graceMs = iosPurchase.renewalInfoIOS?.gracePeriodExpirationDate {
+                    let graceDate = Date(timeIntervalSince1970: graceMs / 1000)
+                    isActive = graceDate > Date()
+                }
+            } else {
+                // No expiry known; prefer RenewalInfo when available
+                isActive = (iosPurchase.renewalInfoIOS?.willAutoRenew == true)
+                    || iosPurchase.isAutoRenewing
+                    || iosPurchase.purchaseState == .purchased
+                    || iosPurchase.purchaseState == .restored
+            }
Sources/Helpers/StoreKitTypesBridge.swift (2)

139-170: Dead code: not used anywhere

determineAutoRenewStatus and its helper are no longer called. Remove or wire them in to avoid confusion.


172-249: Consider caching SubscriptionInfo.status calls per group

status(for:) is invoked per transaction during mapping, which can be costly for many entitlements. Cache results by subscriptionGroupID within a call scope or batch compute to reduce StoreKit calls.

Would you like a small in‑memory cache keyed by groupId for this mapper?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 551d86a and 80054ba.

📒 Files selected for processing (6)
  • Sources/Helpers/StoreKitTypesBridge.swift (3 hunks)
  • Sources/Models/Types.swift (2 hunks)
  • Sources/OpenIapModule.swift (3 hunks)
  • Sources/OpenIapStore.swift (3 hunks)
  • Tests/OpenIapTests.swift (3 hunks)
  • openiap-versions.json (1 hunks)
🧰 Additional context used
📓 Path-based instructions (7)
Sources/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Sources/**/*.swift: iOS-specific functions MUST have IOS suffix
Android-specific functions MUST have Android suffix
Cross-platform functions must have NO platform suffix
Acronyms in Swift should be ALL CAPS only when used as a suffix; otherwise use Pascal case (first letter caps, rest lowercase)
Specific casing: iOS -> Ios in beginning/middle, IOS as suffix
Specific casing: IAP -> Iap in beginning/middle, IAP as suffix
Specific casing: API -> Api in beginning/middle, API as suffix
Specific casing: URL -> Url in beginning/middle, URL as suffix
OpenIapError static code constants use PascalCase names; raw string values remain E_ codes; avoid introducing new E_-prefixed identifiers in Swift

Files:

  • Sources/OpenIapStore.swift
  • Sources/Models/Types.swift
  • Sources/OpenIapModule.swift
  • Sources/Helpers/StoreKitTypesBridge.swift
Sources/Models/Types.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Types.swift in Sources/Models is auto-generated; DO NOT edit directly

Files:

  • Sources/Models/Types.swift
Sources/Models/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Models must match OpenIAP specification exactly (official types only)

Files:

  • Sources/Models/Types.swift
Sources/{OpenIapProtocol.swift,OpenIapModule.swift}

📄 CodeRabbit inference engine (CLAUDE.md)

Sources/{OpenIapProtocol.swift,OpenIapModule.swift}: Public API names MUST match openiap.dev and React Native OpenIAP (Apple module)
Use standard Apple module API names exactly: initConnection(), endConnection(), fetchProducts(), getAvailablePurchases(), requestPurchase(), finishTransaction()

Files:

  • Sources/OpenIapModule.swift
openiap-versions.json

📄 CodeRabbit inference engine (CLAUDE.md)

openiap-versions.json: Version management is in openiap-versions.json; update versions there (not elsewhere)
Commit changes to openiap-versions.json when updating versions

Files:

  • openiap-versions.json
Sources/Helpers/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Helpers should use descriptive names ending with purpose (e.g., Manager, Cache, Status)

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*Store*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Avoid using 'Store' in names for caching/helper classes; use Cache or Manager instead

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
🧠 Learnings (3)
📚 Learning: 2025-10-09T19:13:15.972Z
Learnt from: CR
PR: hyodotdev/openiap-apple#0
File: CLAUDE.md:0-0
Timestamp: 2025-10-09T19:13:15.972Z
Learning: Applies to Sources/{OpenIapProtocol.swift,OpenIapModule.swift} : Use standard Apple module API names exactly: initConnection(), endConnection(), fetchProducts(), getAvailablePurchases(), requestPurchase(), finishTransaction()

Applied to files:

  • Sources/OpenIapStore.swift
  • Sources/OpenIapModule.swift
📚 Learning: 2025-10-09T19:13:15.972Z
Learnt from: CR
PR: hyodotdev/openiap-apple#0
File: CLAUDE.md:0-0
Timestamp: 2025-10-09T19:13:15.972Z
Learning: Applies to openiap-versions.json : Version management is in openiap-versions.json; update versions there (not elsewhere)

Applied to files:

  • openiap-versions.json
📚 Learning: 2025-10-09T19:13:15.972Z
Learnt from: CR
PR: hyodotdev/openiap-apple#0
File: CLAUDE.md:0-0
Timestamp: 2025-10-09T19:13:15.972Z
Learning: Applies to openiap-versions.json : Commit changes to openiap-versions.json when updating versions

Applied to files:

  • openiap-versions.json
🧬 Code graph analysis (3)
Tests/OpenIapTests.swift (1)
Sources/Models/OpenIapSerialization.swift (2)
  • purchase (200-207)
  • encode (22-31)
Sources/OpenIapStore.swift (2)
Example/OpenIapExample/Models/IapCompat.swift (2)
  • asIOS (44-49)
  • asIOS (54-59)
Sources/Helpers/StoreKitTypesBridge.swift (1)
  • purchase (64-66)
Sources/OpenIapModule.swift (1)
Sources/Models/OpenIapError.swift (1)
  • purchaseError (79-81)
🪛 SwiftLint (0.57.0)
Sources/Helpers/StoreKitTypesBridge.swift

[Warning] 208-208: TODOs should be resolved (Add when API confirmed)

(todo)


[Warning] 235-235: TODOs should be resolved (Add when API confirmed)

(todo)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test
🔇 Additional comments (9)
openiap-versions.json (1)

3-3: Version bump location is correct

gql upgraded to 1.2.0 in the right place. No other changes needed here.

As per coding guidelines

Sources/OpenIapStore.swift (1)

169-172: Good: summarize after deduplication

Assigning deduplicated purchases before logging prevents noisy per-item logs. Looks solid.

Sources/Helpers/StoreKitTypesBridge.swift (1)

208-209: Resolve or suppress TODOs flagged by SwiftLint

Replace TODOs with tracked issues or // swiftlint:disable:this todo if intentional.

As per static analysis hints

Also applies to: 235-235

Tests/OpenIapTests.swift (3)

47-102: RenewalInfo tests look solid

Covers presence and key fields, good fixtures.


104-141: Serialization round‑trip verified

Dictionary and JSON round‑trip checks are appropriate. Nice.


218-253: Helper for sample with renewal info is clean

Keeps tests DRY and readable.

Sources/Models/Types.swift (2)

406-406: Model surface extension aligns with schema

Adding renewalInfoIOS to PurchaseIOS matches the new capability.

As per coding guidelines (auto‑generated file)


460-489: RenewalInfoIOS fields look correct and future‑proof

Doc comments help. Ensure this stays code‑generated to match the OpenIAP spec.

Sources/OpenIapModule.swift (1)

288-289: LGTM: clearer restore log

Including onlyActive flag improves traceability.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
Sources/Helpers/StoreKitTypesBridge.swift (2)

188-243: Extract duplicate logic into a helper function.

The verified (lines 188-215) and unverified (lines 216-242) cases contain nearly identical logic (~27 lines). The only difference is the info source.

Consider extracting the common logic:

private static func makeRenewalInfo(
    from info: StoreKit.Product.SubscriptionInfo.RenewalInfo,
    currentProductID: String
) -> RenewalInfoIOS {
    let pendingProductId = (info.autoRenewPreference != currentProductID) ? 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 {
        #if compiler(>=5.9)
        offerInfo = (id: info.offerID, type: info.offerType?.rawValue.description)
        #else
        offerInfo = nil
        #endif
    }
    return RenewalInfoIOS(
        autoRenewPreference: info.autoRenewPreference,
        expirationReason: info.expirationReason?.rawValue.description,
        gracePeriodExpirationDate: info.gracePeriodExpirationDate?.milliseconds,
        isInBillingRetry: nil,
        jsonRepresentation: nil,
        pendingUpgradeProductId: pendingProductId,
        priceIncreaseStatus: nil,
        renewalDate: info.renewalDate?.milliseconds,
        renewalOfferId: offerInfo?.id,
        renewalOfferType: offerInfo?.type,
        willAutoRenew: info.willAutoRenew
    )
}

Then simplify the switch cases:

 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 {
-        #if compiler(>=5.9)
-        offerInfo = (id: info.offerID, type: info.offerType?.rawValue.description)
-        #else
-        offerInfo = nil
-        #endif
-    }
-    let renewalInfo = RenewalInfoIOS(...)
-    return renewalInfo
+    return makeRenewalInfo(from: info, currentProductID: transaction.productID)
 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 {
-        #if compiler(>=5.9)
-        offerInfo = (id: info.offerID, type: info.offerType?.rawValue.description)
-        #else
-        offerInfo = nil
-        #endif
-    }
-    let renewalInfo = RenewalInfoIOS(...)
-    return renewalInfo
+    return makeRenewalInfo(from: info, currentProductID: transaction.productID)
 }

173-173: Consider adding IOS suffix for consistency.

The function returns RenewalInfoIOS? (an iOS-specific type). Per the coding guidelines, iOS-specific functions should have an IOS suffix.

As per coding guidelines:

-private static func subscriptionRenewalInfo(for transaction: StoreKit.Transaction) async -> RenewalInfoIOS? {
+private static func subscriptionRenewalInfoIOS(for transaction: StoreKit.Transaction) async -> RenewalInfoIOS? {

And update the call site:

-let renewalInfoIOS = await subscriptionRenewalInfo(for: transaction)
+let renewalInfoIOS = await subscriptionRenewalInfoIOS(for: transaction)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 80054ba and 81657ef.

📒 Files selected for processing (1)
  • Sources/Helpers/StoreKitTypesBridge.swift (3 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
Sources/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Sources/**/*.swift: iOS-specific functions MUST have IOS suffix
Android-specific functions MUST have Android suffix
Cross-platform functions must have NO platform suffix
Acronyms in Swift should be ALL CAPS only when used as a suffix; otherwise use Pascal case (first letter caps, rest lowercase)
Specific casing: iOS -> Ios in beginning/middle, IOS as suffix
Specific casing: IAP -> Iap in beginning/middle, IAP as suffix
Specific casing: API -> Api in beginning/middle, API as suffix
Specific casing: URL -> Url in beginning/middle, URL as suffix
OpenIapError static code constants use PascalCase names; raw string values remain E_ codes; avoid introducing new E_-prefixed identifiers in Swift

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Helpers should use descriptive names ending with purpose (e.g., Manager, Cache, Status)

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*Store*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Avoid using 'Store' in names for caching/helper classes; use Cache or Manager instead

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
🪛 SwiftLint (0.57.0)
Sources/Helpers/StoreKitTypesBridge.swift

[Warning] 209-209: TODOs should be resolved (Add when API confirmed)

(todo)


[Warning] 236-236: TODOs should be resolved (Add when API confirmed)

(todo)

🔇 Additional comments (2)
Sources/Helpers/StoreKitTypesBridge.swift (2)

73-75: LGTM! Conservative fallback addressed.

The fallback to false when renewal info is unavailable correctly prevents misreporting cancelled/paused subscriptions as auto-renewing. This addresses the concern raised in the previous review.


192-201: Guard deprecated offer properties by OS availability, not compiler version Confirm when offerID/offerType were first introduced, then replace the #if compiler(>=5.9) fallback with an if #available(<introduced version>, *) check, and apply the same update at lines 219–228.

@hyochan hyochan added the 🎯 feature New feature label Oct 15, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 81657ef and 904856a.

📒 Files selected for processing (1)
  • Sources/Helpers/StoreKitTypesBridge.swift (3 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
Sources/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Sources/**/*.swift: iOS-specific functions MUST have IOS suffix
Android-specific functions MUST have Android suffix
Cross-platform functions must have NO platform suffix
Acronyms in Swift should be ALL CAPS only when used as a suffix; otherwise use Pascal case (first letter caps, rest lowercase)
Specific casing: iOS -> Ios in beginning/middle, IOS as suffix
Specific casing: IAP -> Iap in beginning/middle, IAP as suffix
Specific casing: API -> Api in beginning/middle, API as suffix
Specific casing: URL -> Url in beginning/middle, URL as suffix
OpenIapError static code constants use PascalCase names; raw string values remain E_ codes; avoid introducing new E_-prefixed identifiers in Swift

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Helpers should use descriptive names ending with purpose (e.g., Manager, Cache, Status)

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*Store*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Avoid using 'Store' in names for caching/helper classes; use Cache or Manager instead

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
🔇 Additional comments (2)
Sources/Helpers/StoreKitTypesBridge.swift (2)

73-76: Conservative auto-renew fallback: LGTM

Using renewalInfoIOS?.willAutoRenew ?? false is correct and avoids false positives.


122-122: Attaching renewalInfoIOS: LGTM

Optional field preserves backward compatibility.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 904856a and e9e1a9d.

📒 Files selected for processing (1)
  • Sources/Helpers/StoreKitTypesBridge.swift (3 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
Sources/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Sources/**/*.swift: iOS-specific functions MUST have IOS suffix
Android-specific functions MUST have Android suffix
Cross-platform functions must have NO platform suffix
Acronyms in Swift should be ALL CAPS only when used as a suffix; otherwise use Pascal case (first letter caps, rest lowercase)
Specific casing: iOS -> Ios in beginning/middle, IOS as suffix
Specific casing: IAP -> Iap in beginning/middle, IAP as suffix
Specific casing: API -> Api in beginning/middle, API as suffix
Specific casing: URL -> Url in beginning/middle, URL as suffix
OpenIapError static code constants use PascalCase names; raw string values remain E_ codes; avoid introducing new E_-prefixed identifiers in Swift

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Helpers should use descriptive names ending with purpose (e.g., Manager, Cache, Status)

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*Store*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Avoid using 'Store' in names for caching/helper classes; use Cache or Manager instead

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
🔇 Additional comments (2)
Sources/Helpers/StoreKitTypesBridge.swift (2)

73-76: Conservative fallback for auto‑renewing looks good

Defaulting to false when renewal info is unavailable is the safe choice. Thanks for addressing prior feedback.


122-122: Passing renewalInfoIOS into PurchaseIOS is correct

Plumbs renewal info through without breaking existing payloads (optional field).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
Sources/Helpers/StoreKitTypesBridge.swift (1)

173-279: Consider extracting common mapping logic to reduce duplication.

The verified and unverified cases (lines 189-229 vs 230-270) share nearly identical mapping logic. While this duplication isn't critical, extracting a helper function would improve maintainability.

Optional refactor to reduce duplication:

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 }

            let info: Product.SubscriptionInfo.RenewalInfo
            switch status.renewalInfo {
            case .verified(let verifiedInfo):
                info = verifiedInfo
            case .unverified(let unverifiedInfo, _):
                info = unverifiedInfo
            }
            
            return mapRenewalInfo(info, transaction: transaction)
        }
    } catch {
        OpenIapLog.debug("⚠️ Failed to fetch renewalInfo: \(error.localizedDescription)")
        return nil
    }

    return nil
}

private static func mapRenewalInfo(_ info: Product.SubscriptionInfo.RenewalInfo, transaction: StoreKit.Transaction) -> RenewalInfoIOS {
    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, *) {
        let offerTypeString = info.offer.map { String(describing: $0.type) }
        offerInfo = (id: info.offer?.id, type: offerTypeString)
    } else {
    #endif
        #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
    
    let priceIncrease: String? = {
        if #available(iOS 15.0, macOS 12.0, *) {
            return String(describing: info.priceIncreaseStatus)
        }
        return nil
    }()
    
    return 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
    )
}

Additionally, consider populating isInBillingRetry from the status object if the API is available:

// Inside the loop where you have access to status
isInBillingRetry: {
    if #available(iOS 16.0, macOS 14.0, *) {
        return status.isInBillingRetry
    }
    return nil
}()
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e9e1a9d and 94fadbe.

📒 Files selected for processing (1)
  • Sources/Helpers/StoreKitTypesBridge.swift (3 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
Sources/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Sources/**/*.swift: iOS-specific functions MUST have IOS suffix
Android-specific functions MUST have Android suffix
Cross-platform functions must have NO platform suffix
Acronyms in Swift should be ALL CAPS only when used as a suffix; otherwise use Pascal case (first letter caps, rest lowercase)
Specific casing: iOS -> Ios in beginning/middle, IOS as suffix
Specific casing: IAP -> Iap in beginning/middle, IAP as suffix
Specific casing: API -> Api in beginning/middle, API as suffix
Specific casing: URL -> Url in beginning/middle, URL as suffix
OpenIapError static code constants use PascalCase names; raw string values remain E_ codes; avoid introducing new E_-prefixed identifiers in Swift

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Helpers should use descriptive names ending with purpose (e.g., Manager, Cache, Status)

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*Store*.swift

📄 CodeRabbit inference engine (CLAUDE.md)

Avoid using 'Store' in names for caching/helper classes; use Cache or Manager instead

Files:

  • Sources/Helpers/StoreKitTypesBridge.swift
🔇 Additional comments (1)
Sources/Helpers/StoreKitTypesBridge.swift (1)

73-75: LGTM! Conservative fallback approach for auto-renewal status.

The implementation correctly derives autoRenewing from renewalInfoIOS?.willAutoRenew with a safe false fallback when renewal information is unavailable. This prevents falsely reporting subscriptions as auto-renewing and aligns with the fixes from previous reviews.

@hyochan hyochan merged commit 810a5b8 into main Oct 15, 2025
2 checks passed
@hyochan hyochan deleted the feat/renewal-info branch October 15, 2025 18:00
hyochan added a commit to hyochan/expo-iap that referenced this pull request Oct 15, 2025
Applied hyodotdev/openiap-apple#24

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* iOS subscription status cards: Upgrade Detected and Cancellation
Detected with per-subscription details (current vs. upgrading product,
dates, auto-renew status) and per-item renewal-info viewing.
  * Dedicated renewal-info view button with alert-based details.

* **Improvements**
  * More reliable status refresh by waiting until subscriptions load.

* **Style**
* New visual styles for upgrade/cancellation cards and renewal-info
controls.

* **New Support**
  * Added an additional subscription product (e.g., yearly).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

🎯 feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant