Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed
- Corrected the documentation of `syncPurchases()`, which 3.6.0 described as moving purchases
from the previous identity to the one signed in. It does not: RevenueCat sends a sync as a
restore only while the current identity is anonymous, so after `syncUser(userId:)` a receipt
that belongs to another app user identifier stays with it. To bring a purchase over after an
explicit sign-in that changed the identifier, call `restorePurchases()` when the entitlement is
not active — never on launch. The behaviour is unchanged.

## [3.6.0] - 2026-11-01

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ protocol SubscriptionRepository: Sendable {
/// Re-applies purchases on the Apple Account; resolves to `.inactive` when there are none.
func restorePurchases() async throws -> SubscriptionStatus

/// Moves the purchases on the Apple Account to the current billing identity without any
/// system prompt, and reports the entitlement afterwards.
/// Sends the purchases on the Apple Account for the current billing identity without any
/// system prompt, and reports the entitlement afterwards. Takes nothing over from another
/// named identity (``restorePurchases()`` does).
func syncPurchases() async throws -> SubscriptionStatus

/// Attaches the billing identity to an app-level user identifier.
Expand Down
41 changes: 20 additions & 21 deletions Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,40 +175,39 @@ public protocol SubscriptionUseCase: Sendable {
/// - Throws: ``SubscriptionError/restoreFailed(_:)`` if the store rejects the request.
func restorePurchases() async throws -> SubscriptionStatus

/// Moves the purchases already on this device's Apple Account to the identity signed in
/// Sends the purchases on this device's Apple Account to the store for the identity signed in
/// now, without showing anything, and refreshes the cache.
///
/// ``syncUser(userId:)`` only swaps the billing identity. A purchase made under the identity
/// this device was using before — an anonymous one, or another account — stays there, so a
/// customer who starts a trial and then signs in on a second device does not see it until
/// they press "Restore Purchases". Call this right after a ``syncUser(userId:)`` that the
/// customer caused by signing in, so what they bought follows them to the account.
///
/// - Important: Call it only when the customer explicitly signed in and the user identifier
/// changed. **Never on launch, and never from the ``syncUser(userId:)`` a launch makes to
/// restore a session.** RevenueCat's default transfer behaviour moves the purchases to the
/// identity that syncs them, so syncing on every launch lets a throwaway account — one an
/// app creates after a reinstall — take the purchases away from the account that owns them.
///
/// Unlike ``restorePurchases()`` it never asks the customer to sign in to the App Store,
/// which is what makes it safe to run without a button press. Like it, returning normally
/// is not evidence of an entitlement: with nothing to move, it returns `.inactive`.
/// - Important: **This does not take purchases over from another identity.** RevenueCat sends a
/// sync as a restore only while the current identity is anonymous; once
/// ``syncUser(userId:)`` has signed someone in, a receipt that already belongs to another
/// app user identifier stays with it. To bring a purchase made under the previous identity —
/// a trial started before signing in on a second device — over to the account just signed in
/// to, call ``restorePurchases()`` after the ``syncUser(userId:)`` of an explicit sign-in that
/// changed the user identifier, and only when the entitlement is not already active.
/// Never do either on launch: when several identities share one Apple Account, each device
/// would take the purchases back from the others.
///
/// What it is for is attributing purchases this identity can already claim — ones made while
/// it was anonymous, or under it on another device — without the App Store prompt a restore
/// can raise. Returning normally is not evidence of an entitlement: with nothing to send, it
/// returns `.inactive`.
///
/// The default implementation answers with ``checkSubscriptionStatus()``, which is right for
/// a stand-in that reaches no store.
///
/// - Returns: The entitlement state of the signed-in identity after the purchases moved.
/// - Throws: ``SubscriptionError/userSyncFailed(_:)`` if the store rejects the sync, leaving
/// the purchases with the previous identity, or ``SubscriptionError/notConfigured`` if the
/// API key was empty at init.
/// - Returns: The entitlement state of the signed-in identity after the sync.
/// - Throws: ``SubscriptionError/userSyncFailed(_:)`` if the store rejects the sync, or
/// ``SubscriptionError/notConfigured`` if the API key was empty at init.
func syncPurchases() async throws -> SubscriptionStatus

/// Binds subsequent purchases to an app-level user identifier and refreshes the cache.
///
/// Call this after the customer signs in. Until it is called, purchases are attributed
/// to an anonymous identity, so an entitlement bought before sign-in does not follow the
/// account to another device. It does not move purchases already made under the previous
/// identity; when the customer signed in by hand, follow it with ``syncPurchases()``.
/// identity: after an explicit sign-in that changed the identifier, call
/// ``restorePurchases()`` when the entitlement is not active, and never on launch.
///
/// The entitlement is discarded — from memory and from the ``EntitlementCache`` — as soon as
/// the identity swaps, and refilled by the refresh that follows. A throwing call therefore
Expand Down
2 changes: 2 additions & 0 deletions Tests/SubscriptionTests/SubscriptionRepositoryMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ actor SubscriptionRepositoryMock: SubscriptionRepository {
private(set) var syncedUserIds: [String] = []
private(set) var clearUserCallCount = 0
private(set) var syncPurchasesCallCount = 0
private(set) var restoreCallCount = 0
private(set) var loadRequests: [OfferingRequest] = []
private(set) var purchasedFrom: [OfferingRequest] = []
private(set) var impressions: [PaywallImpression] = []
Expand Down Expand Up @@ -73,6 +74,7 @@ actor SubscriptionRepositoryMock: SubscriptionRepository {

func restorePurchases() async throws -> SubscriptionStatus {
try throwIfNeeded()
restoreCallCount += 1
return statusToReturn
}

Expand Down
18 changes: 10 additions & 8 deletions Tests/SubscriptionTests/SyncPurchasesTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import XCTest
@testable import Subscription

/// Covers `syncPurchases()`, which moves the purchases on the Apple Account to the identity
/// signed in now, and the rule that only an explicit sign-in may trigger it.
/// Covers `syncPurchases()`, which sends the purchases on the Apple Account for the identity
/// signed in now, and that `syncUser` never syncs or restores on its own.
final class SyncPurchasesTests: XCTestCase {
private static let activeStatus = SubscriptionStatus(
isActive: true,
Expand Down Expand Up @@ -31,9 +31,9 @@ final class SyncPurchasesTests: XCTestCase {
XCTAssertEqual(calls, 1)
}

/// The sign-in that leaves a second device without the trial it bought on the first: the
/// identity swaps, the new identity owns nothing yet, and the sync moves the purchase over.
func test_サインイン後に同期すると前の身元の購入が新しい身元に付く() async throws {
/// The new identity's entitlement, as the store reports it after the sync, replaces what was
/// read right after the swap.
func test_サインイン後の同期で読んだ権利がキャッシュに入る() async throws {
let mock = SubscriptionRepositoryMock()
let useCase = Self.makeUseCase(repository: mock)

Expand All @@ -49,9 +49,9 @@ final class SyncPurchasesTests: XCTestCase {
XCTAssertTrue(cached.isActive)
}

/// `syncUser` runs on every launch to restore a session. If it synced purchases too, a
/// throwaway account made after a reinstall would take them from the account that owns them.
func test_syncUserは購入を同期しない() async throws {
/// `syncUser` runs on every launch to restore a session. Syncing or restoring from there would
/// let devices sharing an Apple Account take the purchases from one another.
func test_syncUserは購入を同期も復元もしない() async throws {
let mock = SubscriptionRepositoryMock()
let useCase = Self.makeUseCase(repository: mock)

Expand All @@ -60,6 +60,8 @@ final class SyncPurchasesTests: XCTestCase {

let calls = await mock.syncPurchasesCallCount
XCTAssertEqual(calls, 0)
let restores = await mock.restoreCallCount
XCTAssertEqual(restores, 0)
}

func test_syncPurchasesが失敗しても状態キャッシュは変わらない() async throws {
Expand Down
Loading