From 47a792acc2e8b31bd65b0918df24adfcc7d24537 Mon Sep 17 00:00:00 2001 From: Kyoichi Taniguchi Date: Mon, 2 Nov 2026 06:52:11 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20syncPurchases=20=E3=81=AF=E5=88=A5?= =?UTF-8?q?=E3=81=AE=E8=BA=AB=E5=85=83=E3=81=8B=E3=82=89=E8=B3=BC=E5=85=A5?= =?UTF-8?q?=E3=82=92=E5=BC=95=E3=81=8D=E5=8F=96=E3=82=89=E3=81=AA=E3=81=84?= =?UTF-8?q?=E3=81=A8=E6=9B=B8=E3=81=8D=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3.6.0 では、サインインの後に前の身元の購入を移す API として書いていた。 RevenueCat は今の身元が匿名のときだけ同期を復元として送るので、syncUser の後は 別の app user ID に付いた購入は動かない(PurchasesOrchestrator の allowSharingAppStoreAccount)。 移すには、uid が変わった明示のサインインの後、権利が無いときだけ restorePurchases を呼ぶ。挙動は変えない。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++ .../Domain/SubscriptionRepository.swift | 5 ++- .../Public/UseCase/SubscriptionUseCase.swift | 41 +++++++++---------- .../SubscriptionRepositoryMock.swift | 2 + .../SyncPurchasesTests.swift | 18 ++++---- 5 files changed, 43 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b14db5..e00f447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift b/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift index eed499d..0965267 100644 --- a/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift +++ b/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift @@ -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. diff --git a/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift b/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift index e67bb72..0b2bd9e 100644 --- a/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift +++ b/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift @@ -175,32 +175,30 @@ 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. @@ -208,7 +206,8 @@ public protocol SubscriptionUseCase: Sendable { /// 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 diff --git a/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift b/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift index 0815082..150484b 100644 --- a/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift +++ b/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift @@ -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] = [] @@ -73,6 +74,7 @@ actor SubscriptionRepositoryMock: SubscriptionRepository { func restorePurchases() async throws -> SubscriptionStatus { try throwIfNeeded() + restoreCallCount += 1 return statusToReturn } diff --git a/Tests/SubscriptionTests/SyncPurchasesTests.swift b/Tests/SubscriptionTests/SyncPurchasesTests.swift index bb05c98..0ef7f25 100644 --- a/Tests/SubscriptionTests/SyncPurchasesTests.swift +++ b/Tests/SubscriptionTests/SyncPurchasesTests.swift @@ -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, @@ -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) @@ -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) @@ -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 {