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

feat: add renewalInfo in ActiveSubscription#25

Merged
hyochan merged 3 commits intomainfrom
feat/activeSubscription-renewalInfo
Oct 15, 2025
Merged

feat: add renewalInfo in ActiveSubscription#25
hyochan merged 3 commits intomainfrom
feat/activeSubscription-renewalInfo

Conversation

@hyochan
Copy link
Member

@hyochan hyochan commented Oct 15, 2025

Summary by CodeRabbit

  • New Features

    • Shows pending upgrades with a dedicated pending row, icon, and "activates on next renewal" caption.
    • Subscribe action consolidated with loading state, integrated price/proration display, and unified action area.
  • Improvements

    • UI and listings now rely on active-subscription data and refreshed subscription state after restore.
    • iOS renewal details surfaced for clearer upgrade/cancel status.
  • Bug Fixes

    • More reliable detection of current plan, cancellations, auto-renew, and pending upgrades.

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

coderabbitai bot commented Oct 15, 2025

Walkthrough

Switches subscription logic and UI to use ActiveSubscription with optional iOS renewal info; adds StoreKit renewal-info plumbing and logging; relaxes access for renewal-info bridge; detects and surfaces pending upgrades in upgrade flows and diagnostics.

Changes

Cohort / File(s) Summary
Example app — subscription flow logic
Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift
Replace getAvailablePurchases usage with getActiveSubscriptions; derive current subscription and upgrade decisions from ActiveSubscription and its renewalInfo/pendingUpgradeProductId; update restore to refresh active subscriptions; adjust helpers/guards and UI data sources to use ActiveSubscription.
Example app — subscription card UI
Example/OpenIapExample/Screens/uis/SubscriptionCard.swift
Consolidate upgrade/action area to surface pending-upgrade state (upgradeInfo.isPending) with clock icon and message; unify upgrade/subscribe UI, loading states, price block and pro‑rata label; keep subscribed/manage/cancel branches while integrating pending messaging.
StoreKit renewal-info bridge
Sources/Helpers/StoreKitTypesBridge.swift
Change access to subscriptionRenewalInfoIOS (private → internal); require transaction.subscriptionGroupID; iterate subscription group statuses to derive RenewalInfoIOS.
Model: ActiveSubscription extension
Sources/Models/Types.swift
Add renewalInfoIOS: RenewalInfoIOS? to ActiveSubscription (new optional field; initializer updated accordingly).
Module: populate renewal info
Sources/OpenIapModule.swift
In getActiveSubscriptions, fetch per-transaction renewal info via StoreKitTypesBridge.subscriptionRenewalInfoIOS and populate ActiveSubscription.renewalInfoIOS.
Diagnostics / logging
Sources/OpenIapStore.swift
Add verbose logging of active subscriptions count and their renewalInfoIOS (productId, header, willAutoRenew, pendingUpgradeProductId) after refresh.
Dependency versions
openiap-versions.json
Bump gql dependency from 1.2.0 to 1.2.1.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant App as Example App UI
  participant OpenIAP as OpenIapModule
  participant Bridge as StoreKitTypesBridge
  participant SK as StoreKit

  User->>App: Open Subscription Screen
  App->>OpenIAP: getActiveSubscriptions()
  OpenIAP->>SK: fetch transactions
  loop per transaction
    OpenIAP->>Bridge: subscriptionRenewalInfoIOS(transaction)
    Bridge->>SK: request statuses (requires subscriptionGroupID)
    SK-->>Bridge: group statuses (auto‑renew, pending upgrade, ...)
    Bridge-->>OpenIAP: RenewalInfoIOS?
    OpenIAP->>OpenIAP: Build ActiveSubscription(with renewalInfoIOS)
  end
  OpenIAP-->>App: [ActiveSubscription...]
  App->>App: Determine current tier / pending upgrade
  App-->>User: Render UI (pending/upgrade/switch/manage)
Loading
sequenceDiagram
  autonumber
  actor User
  participant Card as SubscriptionCard UI
  participant OpenIAP as OpenIapModule

  User->>Card: Tap Upgrade
  Card->>OpenIAP: initiate upgrade flow
  OpenIAP-->>Card: async result (success / pending / failed)
  alt Pending upgrade detected
    Card-->>User: Show "activates on next renewal" + pending row
  else Success now
    Card-->>User: Show updated plan
  else Failure
    Card-->>User: Show error
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

I hop through statuses, sniffing renewal clues,
Pending upgrades found beneath StoreKit dews.
I nudge the UI with a clock and a cheer,
Logs hum the changes the next cycle will clear.
The rabbit applauds — plans queued, carrots near. 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% 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 and accurately describes the primary change introduced in the pull request, namely adding a renewalInfo property to the ActiveSubscription type, and follows conventional commit formatting.
✨ 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/activeSubscription-renewalInfo

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bdda284 and df875ea.

📒 Files selected for processing (2)
  • Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift (7 hunks)
  • Example/OpenIapExample/Screens/uis/SubscriptionCard.swift (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift (2)
Sources/OpenIapStore.swift (1)
  • getActiveSubscriptions (290-304)
Sources/OpenIapModule.swift (1)
  • getActiveSubscriptions (447-491)
Example/OpenIapExample/Screens/uis/SubscriptionCard.swift (1)
Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift (1)
  • product (435-437)
🔇 Additional comments (9)
Example/OpenIapExample/Screens/uis/SubscriptionCard.swift (2)

195-219: LGTM! Pending upgrade UI properly implemented.

The pending upgrade detection now uses the explicit upgradeInfo.isPending boolean flag, which addresses the previous review concern about fragile string matching. The UI clearly indicates the pending state with appropriate iconography and messaging.


220-268: LGTM! Regular upgrade flow well-structured.

The regular upgrade/switch option is well-implemented with:

  • Clear visual distinction between upgrade and plan switch scenarios
  • Proper loading state handling with disabled buttons
  • Pro-rated pricing indication for upgrades
  • Consistent styling and layout
Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift (7)

261-262: LGTM! Focused data loading approach.

The code now loads only activeSubscriptions, which contains all necessary information including renewal info. This aligns with the PR objective to use ActiveSubscription as the primary data source.


287-287: LGTM! Function signature correctly updated.

The upgradeSubscription function now accepts ActiveSubscription? instead of OpenIapPurchase?, aligning with the PR's shift to using ActiveSubscription as the primary data model.


317-323: LGTM! Current subscription detection improved.

The function now returns ActiveSubscription? and properly filters by isActive status. The tier preference logic (yearly over monthly) is appropriate for upgrade scenarios.


331-343: Excellent! Robust pending upgrade detection.

This implementation properly addresses the previous review concern about fragile string matching. The code now:

  • Directly checks renewalInfoIOS.pendingUpgradeProductId from the subscription's renewal info
  • Returns an explicit isPending: true flag instead of relying on message content
  • Provides a clear user-facing message

This is a significant improvement in reliability and maintainability. Based on past review comments.


443-449: LGTM! Subscription status check correctly implemented.

The function now checks activeSubscriptions and returns the isActive status directly, which is more accurate than checking the deprecated iosAvailablePurchases.


451-457: LGTM! Cancelled subscription detection is accurate.

The logic correctly identifies a cancelled subscription as one that is currently active but has willAutoRenew == false. This leverages the renewalInfoIOS data to provide accurate cancellation status.


461-475: Perfect! UpgradeInfo model properly extended.

The isPending property is correctly added with:

  • Explicit property declaration (line 466)
  • Default value of false in the initializer (line 468)
  • Proper initialization in the init body (line 473)

This ensures backward compatibility for existing call sites while supporting the new pending upgrade detection. Based on past review comments.


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: 2

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

188-270: Reduce code duplication between verified and unverified cases.

The logic for building RenewalInfoIOS from renewal info is duplicated between the .verified case (lines 189-229) and .unverified case (lines 230-270). This makes maintenance harder and increases the risk of inconsistencies.

Extract the renewal info mapping into a helper function:

private static func mapRenewalInfo(
    _ info: Product.SubscriptionInfo.RenewalInfo,
    transactionProductID: String
) -> RenewalInfoIOS {
    let pendingProductId = (info.autoRenewPreference != transactionProductID) ? 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,
        jsonRepresentation: nil,
        pendingUpgradeProductId: pendingProductId,
        priceIncreaseStatus: priceIncrease,
        renewalDate: info.renewalDate?.milliseconds,
        renewalOfferId: offerInfo?.id,
        renewalOfferType: offerInfo?.type,
        willAutoRenew: info.willAutoRenew
    )
}

Then simplify both cases:

switch status.renewalInfo {
case .verified(let info):
    return mapRenewalInfo(info, transactionProductID: transaction.productID)
case .unverified(let info, _):
    return mapRenewalInfo(info, transactionProductID: transaction.productID)
}
Example/OpenIapExample/Screens/uis/SubscriptionCard.swift (1)

195-264: Consider extracting upgrade UI into separate views.

The nested conditionals and button logic in the upgrade flow (lines 195-264) make this code difficult to follow. The complexity could be reduced by extracting sub-views.

Consider creating helper views:

@ViewBuilder
private func upgradeActionView(upgradeInfo: UpgradeInfo) -> some View {
    if upgradeInfo.isPending {
        PendingUpgradeView(currentTier: upgradeInfo.currentTier ?? "")
    } else {
        ActiveUpgradeView(
            upgradeInfo: upgradeInfo,
            product: product,
            isLoading: isLoading,
            onSubscribe: onSubscribe
        )
    }
}

This would make the main body more readable and each sub-view could be tested independently.

Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift (1)

317-323: Simplistic tier preference logic may not scale.

The tier selection logic (lines 321-322) assumes yearly is always preferred over monthly. This won't work correctly if:

  • User has multiple active subscriptions in different groups
  • There are more than two tiers
  • Tier priority isn't simply yearly > monthly

Consider using transaction date or a more explicit tier ranking:

private func getCurrentSubscription() -> ActiveSubscription? {
    let activeSubs = iapStore.activeSubscriptions.filter { $0.isActive }
    
    // Return the most recent subscription
    return activeSubs.max { $0.transactionDate < $1.transactionDate }
}

Or maintain an explicit tier map:

private let tierRanking: [String: Int] = [
    "dev.hyo.martie.premium": 1,
    "dev.hyo.martie.premium_year": 2
]

private func getCurrentSubscription() -> ActiveSubscription? {
    let activeSubs = iapStore.activeSubscriptions.filter { $0.isActive }
    return activeSubs.max { 
        (tierRanking[$0.productId] ?? 0) < (tierRanking[$1.productId] ?? 0)
    }
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9b3f6b6 and b309e96.

📒 Files selected for processing (6)
  • Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift (6 hunks)
  • Example/OpenIapExample/Screens/uis/SubscriptionCard.swift (1 hunks)
  • Sources/Helpers/StoreKitTypesBridge.swift (1 hunks)
  • Sources/Models/Types.swift (1 hunks)
  • Sources/OpenIapModule.swift (2 hunks)
  • Sources/OpenIapStore.swift (1 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
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/Models/Types.swift
  • Sources/OpenIapStore.swift
  • Sources/Helpers/StoreKitTypesBridge.swift
  • Sources/OpenIapModule.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/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
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
🧬 Code graph analysis (3)
Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift (3)
Sources/OpenIapModule.swift (1)
  • getActiveSubscriptions (447-491)
Sources/OpenIapStore.swift (1)
  • getActiveSubscriptions (290-304)
Sources/Helpers/StoreKitTypesBridge.swift (1)
  • product (6-8)
Example/OpenIapExample/Screens/uis/SubscriptionCard.swift (1)
Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift (1)
  • product (434-436)
Sources/OpenIapModule.swift (1)
Sources/Helpers/StoreKitTypesBridge.swift (1)
  • subscriptionRenewalInfoIOS (173-279)
🪛 GitHub Actions: Tests
Sources/Models/Types.swift

[error] 1-1: CI step './scripts/generate-types.sh' failed with exit code 1. The diff shows removal of renewalInfoIOS from ActiveSubscription; potential breaking change in Types.swift that caused the script to fail.

🔇 Additional comments (9)
Sources/OpenIapStore.swift (1)

292-303: LGTM! Diagnostic logging is helpful.

The logging for active subscriptions and renewal info provides useful visibility into subscription states, especially for pending upgrades. The implementation is read-only with no side effects.

Sources/Models/Types.swift (2)

178-180: Verify the field placement and documentation.

The renewalInfoIOS field is positioned after purchaseTokenAndroid. Ensure this ordering matches the GraphQL schema definition.

The documentation comment is clear and describes the field's purpose well. Verify that the GraphQL schema includes similar documentation.

Based on coding guidelines


1-4: Update GraphQL schema and regenerate types
Ensure your GraphQL schema defines renewalInfoIOS: RenewalInfoIOS under ActiveSubscription, run npm run generate, and commit both the schema and regenerated types file.

Sources/OpenIapModule.swift (2)

468-470: LGTM! Renewal info retrieval is correctly integrated.

The call to StoreKitTypesBridge.subscriptionRenewalInfoIOS(for: transaction) is properly placed within the loop that builds active subscriptions. The async call is awaited correctly.


480-480: LGTM! RenewalInfo is passed to ActiveSubscription.

The renewalInfo parameter is correctly passed to the ActiveSubscription initializer, completing the integration of renewal information into the subscription data model.

Sources/Helpers/StoreKitTypesBridge.swift (2)

173-173: Access level change is necessary.

Changing from private to internal allows OpenIapModule to call this method when building ActiveSubscription objects. This is the correct scope for the integration.

Based on coding guidelines (iOS-specific functions MUST have IOS suffix - the function name subscriptionRenewalInfoIOS follows this rule correctly)


177-179: Verify subscriptionGroupID semantics across supported iOS versions
The guard returns nil for transaction.subscriptionGroupID == nil. Per Apple (iOS 15+), all auto-renewable subscriptions belong to a group—confirm this holds for your min-iOS target or add availability checks to avoid unintentionally dropping valid renewals.

Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift (2)

450-456: LGTM! Cancellation detection uses renewal info correctly.

The logic correctly identifies a cancelled subscription as one that is active but has renewalInfoIOS.willAutoRenew == false. This is the proper way to detect cancellations in StoreKit 2.


261-262: Verify UI coverage after replacing getAvailablePurchases() with getActiveSubscriptions()
Removing the getAvailablePurchases() call may leave iapStore.iosAvailablePurchases unset. Confirm that activeSubscriptions populates all data used in:

  • SubscriptionFlowScreen.swift (line 439)
  • AvailablePurchasesScreen.swift

@hyochan hyochan merged commit 96fdb69 into main Oct 15, 2025
2 checks passed
@hyochan hyochan deleted the feat/activeSubscription-renewalInfo branch October 15, 2025 19:28
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