Skip to content

fix(ios): emit already-owned error for dup subscription purchases#34

Merged
hyochan merged 1 commit into
mainfrom
fix/error-trigger-owning-subs
Nov 6, 2025
Merged

fix(ios): emit already-owned error for dup subscription purchases#34
hyochan merged 1 commit into
mainfrom
fix/error-trigger-owning-subs

Conversation

@hyochan
Copy link
Copy Markdown
Member

@hyochan hyochan commented Nov 6, 2025

Adds pre-purchase check for active subscriptions on iOS to prevent
"You're already subscribed" alert. Emits alreadyOwned error when user
attempts to purchase an active subscription, matching Android behavior.

Resolve hyochan/expo-iap#267

Summary by CodeRabbit

  • Bug Fixes
    • Users can no longer purchase subscriptions they already own; an error is returned instead
    • Enhanced error handling and logging during subscription entitlement verification

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 6, 2025

Walkthrough

A pre-purchase entitlement check is added for auto-renewable subscriptions in requestPurchase. When a subscription purchase is requested, the method now verifies if an active entitlement exists and emits an alreadyOwned error before proceeding. Non-subscription purchases and cases without active entitlements follow the existing flow.

Changes

Cohort / File(s) Summary
Pre-purchase subscription entitlement validation
packages/apple/Sources/OpenIapModule.swift
Added entitlement verification check for auto-renewable subscriptions within requestPurchase. Emits alreadyOwned error for active entitlements. Includes error handling for verification failures and preserves existing purchase flow for non-subscription items.

Sequence Diagram

sequenceDiagram
    actor User
    participant App
    participant Module as OpenIapModule
    participant EntitlementSvc as Entitlement<br/>Service
    participant StoreKit

    User->>App: Request subscription purchase
    App->>Module: requestPurchase(subscription)
    
    rect rgb(200, 220, 255)
    Note over Module: Pre-purchase check (NEW)
    Module->>EntitlementSvc: Verify entitlement
    end
    
    alt Entitlement is active
        EntitlementSvc-->>Module: Active entitlement found
        rect rgb(255, 200, 200)
        Module->>Module: Log warning
        Module->>App: Emit alreadyOwned error
        end
        Module->>User: Halt purchase
    else Entitlement not active or not found
        EntitlementSvc-->>Module: No active entitlement
        Module->>StoreKit: Proceed with purchase
        StoreKit-->>Module: Purchase result
        Module->>App: Emit success/error
    else Verification error
        EntitlementSvc-->>Module: Verification failed
        rect rgb(255, 230, 200)
        Module->>Module: Log debug message
        Module->>StoreKit: Continue purchase
        end
        StoreKit-->>Module: Purchase result
        Module->>App: Emit success/error
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • The entitlement verification logic flow and its interaction with the purchase process requires careful review to ensure no edge cases are missed.
  • Error handling branches (alreadyOwned, verification errors, non-verification errors) have different control paths that need validation.
  • Verify that the subscription product type detection is correct and that non-subscription items are unaffected.

Possibly related issues

  • #267 — The pre-purchase entitlement check that emits alreadyOwned errors for active subscriptions directly addresses the iOS behavior gap where purchase success was incorrectly triggered when a subscription was already owned.

Poem

🐰 Hop, hop, hooray! No double dips today,
iOS subscriptions now properly say "nay!"
When you own it twice, we catch it right,
With entitlements checked before the purchase bite. ✨

Pre-merge checks and finishing touches

✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: emitting an already-owned error for duplicate subscription purchases on iOS.
Linked Issues check ✅ Passed The pull request implements the primary objective from issue #267: emitting an already-owned error when users attempt to purchase an already-active subscription on iOS.
Out of Scope Changes check ✅ Passed All changes are directly related to the objective of adding pre-purchase entitlement checks for subscriptions; no unrelated modifications are present.
✨ 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 fix/error-trigger-owning-subs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@hyochan hyochan added 🍗 enhancement New feature or request 📱 iOS Related to iOS labels Nov 6, 2025
Copy link
Copy Markdown
Contributor

@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)
packages/apple/Sources/OpenIapModule.swift (1)

186-235: Pre-purchase entitlement check correctly prevents duplicate subscription purchases.

The implementation successfully addresses issue #267 by checking for active entitlements before calling product.purchase(), preventing the iOS "You're already subscribed" modal and ensuring an alreadyOwned error is emitted instead of a success callback.

The error handling strategy is sound:

  • Active subscription detected → emit alreadyOwned and halt purchase ✓
  • Verification fails → emit error but allow purchase attempt (reasonable fallback) ✓
  • No entitlement or expired → proceed normally ✓

Optional: Consider extracting entitlement check into a helper method.

The nested conditionals and error handling add cognitive complexity to requestPurchase. Consider extracting lines 188-235 into a private helper method:

private func validateSubscriptionNotOwnedIOS(
    product: StoreKit.Product,
    sku: String
) async throws {
    guard product.type == .autoRenewable else { return }
    guard let currentEntitlement = await product.currentEntitlement else { return }
    
    do {
        let transaction = try checkVerified(currentEntitlement)
        let isActive = transaction.expirationDate.map { $0 > Date() } ?? true
        
        if isActive {
            OpenIapLog.debug("""
                ⚠️ [requestPurchase] Subscription already owned:
                - SKU: \(sku)
                - Transaction ID: \(transaction.id)
                - Expiration: \(transaction.expirationDate?.description ?? "none")
                """)
            let error = makePurchaseError(code: .alreadyOwned, productId: sku)
            emitPurchaseError(error)
            throw error
        }
    } catch let purchaseError as PurchaseError {
        emitPurchaseError(purchaseError)
        if purchaseError.code == .alreadyOwned {
            throw purchaseError
        }
        OpenIapLog.debug("⚠️ Current entitlement verification failed: \(purchaseError.message)")
    } catch {
        let verificationError = makePurchaseError(
            code: .transactionValidationFailed,
            productId: sku,
            message: "Current entitlement check failed: \(error.localizedDescription)"
        )
        OpenIapLog.debug("⚠️ Current entitlement check failed: \(error.localizedDescription)")
        emitPurchaseError(verificationError)
    }
}

Then call it in requestPurchase:

let product = try await storeProduct(for: sku)
let options = try StoreKitTypesBridge.purchaseOptions(from: iosProps)

try await validateSubscriptionNotOwnedIOS(product: product, sku: sku)

let result: StoreKit.Product.PurchaseResult
// ... proceed with purchase

This improves readability while maintaining identical behavior. However, this is purely optional and not required—the current inline implementation is also acceptable.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ac0cf8c and 02d60dd.

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

📄 CodeRabbit inference engine (CLAUDE.md)

packages/apple/Sources/**/*.swift: iOS-specific functions must have IOS suffix; cross-platform functions have no suffix
Swift acronym naming: acronyms are ALL CAPS only as a suffix; when at beginning/middle, use Pascal case (e.g., IapManager, ProductIAP)

Files:

  • packages/apple/Sources/OpenIapModule.swift
🧠 Learnings (2)
📚 Learning: 2025-10-18T05:54:54.802Z
Learnt from: CR
Repo: hyodotdev/openiap PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-10-18T05:54:54.802Z
Learning: Applies to packages/apple/Sources/Models/**/*.swift : Keep official OpenIAP types in Sources/Models/ matching openiap.dev/docs/types naming (e.g., Product.swift, Purchase.swift, ActiveSubscription.swift)

Applied to files:

  • packages/apple/Sources/OpenIapModule.swift
📚 Learning: 2025-10-18T05:54:54.802Z
Learnt from: CR
Repo: hyodotdev/openiap PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-10-18T05:54:54.802Z
Learning: Applies to packages/apple/Sources/Helpers/**/*.swift : Place internal helper classes (not official OpenIAP types) under Sources/Helpers/ (e.g., ProductManager.swift, IapStatus.swift)

Applied to files:

  • packages/apple/Sources/OpenIapModule.swift
⏰ 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 Android

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🍗 enhancement New feature or request 📱 iOS Related to iOS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS: no error triggered when already owning a subscription

1 participant