From 67ec6a43ea2e591e99fd141720382912baef3ecb Mon Sep 17 00:00:00 2001 From: Kyoichi Taniguchi Date: Mon, 2 Nov 2026 06:36:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=82=B5=E3=82=A4=E3=83=B3=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=97=E3=81=9F=E5=BE=8C=E3=81=AB=E8=B3=BC=E5=85=A5?= =?UTF-8?q?=E3=82=92=E3=81=84=E3=81=BE=E3=81=AE=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E3=81=B8=E7=A7=BB=E3=81=99=20syncPurchases=20?= =?UTF-8?q?=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncUser は身元を入れ替えるだけなので、前の身元で始めたトライアルは 「購入を復元」を押すまで新しいアカウントに付かなかった。 syncPurchases は Purchases.syncPurchases を包み、何も出さずに購入を移して SubscriptionStatus を返す。起動時の syncUser には組み込まない (既定の transfer behavior で使い捨てのアカウントが購入を奪うため)。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++ .../Domain/SubscriptionRepository.swift | 4 + .../Repository/RevenueCatRepository.swift | 13 +++ .../Public/UseCase/SubscriptionUseCase.swift | 36 +++++- .../UseCase/SubscriptionUseCaseImpl.swift | 5 + Sources/SubscriptionUI/PreviewSupport.swift | 2 + .../SubscriptionRepositoryMock.swift | 7 ++ .../SyncPurchasesTests.swift | 104 ++++++++++++++++++ 8 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 Tests/SubscriptionTests/SyncPurchasesTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7878222..6665a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- `SubscriptionUseCase.syncPurchases()`, which moves the purchases on this device's Apple Account + to the identity signed in now (`Purchases.syncPurchases`) without any prompt, refreshes the + cache and returns the resulting `SubscriptionStatus`. `syncUser(userId:)` only swaps the + identity, so a trial started under the previous identity stayed there after signing in on + another device until the customer pressed "Restore Purchases". Call it after the `syncUser` + of an explicit sign-in that changed the user identifier, never on launch: RevenueCat's default + transfer behaviour would let a throwaway account made after a reinstall take the purchases. + A rejected sync throws `SubscriptionError.userSyncFailed`. The protocol's default + implementation answers with `checkSubscriptionStatus()`, so an existing conformance keeps + compiling; `PreviewSubscriptionUseCase` answers with its status. + ## [3.5.0] - 2026-10-28 ### Added diff --git a/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift b/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift index 2ae1fc7..eed499d 100644 --- a/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift +++ b/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift @@ -19,6 +19,10 @@ 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. + func syncPurchases() async throws -> SubscriptionStatus + /// Attaches the billing identity to an app-level user identifier. func syncUser(userId: String) async throws diff --git a/Sources/Subscription/Internal/Repository/RevenueCatRepository.swift b/Sources/Subscription/Internal/Repository/RevenueCatRepository.swift index 477d086..44326ed 100644 --- a/Sources/Subscription/Internal/Repository/RevenueCatRepository.swift +++ b/Sources/Subscription/Internal/Repository/RevenueCatRepository.swift @@ -143,6 +143,19 @@ final class RevenueCatRepository: SubscriptionRepository { } } + func syncPurchases() async throws -> SubscriptionStatus { + guard isConfigured else { + throw SubscriptionError.notConfigured + } + + do { + let customerInfo = try await Purchases.shared.syncPurchases() + return extractSubscriptionStatus(from: customerInfo) + } catch { + throw SubscriptionError.userSyncFailed(error) + } + } + func syncUser(userId: String) async throws { guard isConfigured else { throw SubscriptionError.notConfigured diff --git a/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift b/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift index 2c075f9..e67bb72 100644 --- a/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift +++ b/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift @@ -175,11 +175,40 @@ 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 + /// 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`. + /// + /// 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. + 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. + /// 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()``. /// /// 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 @@ -264,6 +293,11 @@ public extension SubscriptionUseCase { } func trackPaywallImpression(source: String, offeringId: String?) async {} + + func syncPurchases() async throws -> SubscriptionStatus { + try await checkSubscriptionStatus() + } + func loadOffering(id: String) async throws -> SubscriptionOffering? { guard let current = try await loadOfferings(), current.id == id else { return nil } return current diff --git a/Sources/Subscription/Public/UseCase/SubscriptionUseCaseImpl.swift b/Sources/Subscription/Public/UseCase/SubscriptionUseCaseImpl.swift index 37708f2..20b7436 100644 --- a/Sources/Subscription/Public/UseCase/SubscriptionUseCaseImpl.swift +++ b/Sources/Subscription/Public/UseCase/SubscriptionUseCaseImpl.swift @@ -134,6 +134,11 @@ public final class SubscriptionUseCaseImpl: SubscriptionUseCase { return await state.confirm(status, at: now()) } + public func syncPurchases() async throws -> SubscriptionStatus { + let status = try await repository.syncPurchases() + return await state.confirm(status, at: now()) + } + public func syncUser(userId: String) async throws { try await repository.syncUser(userId: userId) diff --git a/Sources/SubscriptionUI/PreviewSupport.swift b/Sources/SubscriptionUI/PreviewSupport.swift index 3503e97..051d1f1 100644 --- a/Sources/SubscriptionUI/PreviewSupport.swift +++ b/Sources/SubscriptionUI/PreviewSupport.swift @@ -90,6 +90,8 @@ public struct PreviewSubscriptionUseCase: SubscriptionUseCase { public func restorePurchases() async throws -> SubscriptionStatus { status } + public func syncPurchases() async throws -> SubscriptionStatus { status } + public func syncUser(userId: String) async throws {} public func clearUser() async throws {} diff --git a/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift b/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift index 9cc2273..0815082 100644 --- a/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift +++ b/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift @@ -12,6 +12,7 @@ actor SubscriptionRepositoryMock: SubscriptionRepository { private var checkStatusError: Error? private(set) var syncedUserIds: [String] = [] private(set) var clearUserCallCount = 0 + private(set) var syncPurchasesCallCount = 0 private(set) var loadRequests: [OfferingRequest] = [] private(set) var purchasedFrom: [OfferingRequest] = [] private(set) var impressions: [PaywallImpression] = [] @@ -75,6 +76,12 @@ actor SubscriptionRepositoryMock: SubscriptionRepository { return statusToReturn } + func syncPurchases() async throws -> SubscriptionStatus { + try throwIfNeeded() + syncPurchasesCallCount += 1 + return statusToReturn + } + func syncUser(userId: String) async throws { try throwIfNeeded() syncedUserIds.append(userId) diff --git a/Tests/SubscriptionTests/SyncPurchasesTests.swift b/Tests/SubscriptionTests/SyncPurchasesTests.swift new file mode 100644 index 0000000..bb05c98 --- /dev/null +++ b/Tests/SubscriptionTests/SyncPurchasesTests.swift @@ -0,0 +1,104 @@ +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. +final class SyncPurchasesTests: XCTestCase { + private static let activeStatus = SubscriptionStatus( + isActive: true, + activeEntitlementId: "premium", + activePackageId: "com.example.annual", + expirationDate: Date(timeIntervalSince1970: 1_900_000_000) + ) + + private static let fixedNow = Date(timeIntervalSince1970: 1_800_000_000) + + private static func makeUseCase(repository: SubscriptionRepository) -> SubscriptionUseCaseImpl { + SubscriptionUseCaseImpl(repository: repository, now: { fixedNow }) + } + + func test_syncPurchasesの結果が状態キャッシュに反映される() async throws { + let mock = SubscriptionRepositoryMock() + await mock.setStatus(Self.activeStatus) + let useCase = Self.makeUseCase(repository: mock) + + let returned = try await useCase.syncPurchases() + + XCTAssertEqual(returned, Self.activeStatus.confirmed(at: Self.fixedNow)) + let cached = await useCase.getSubscriptionStatus() + XCTAssertEqual(cached, Self.activeStatus.confirmed(at: Self.fixedNow)) + let calls = await mock.syncPurchasesCallCount + 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 { + let mock = SubscriptionRepositoryMock() + let useCase = Self.makeUseCase(repository: mock) + + try await useCase.syncUser(userId: "new-account") + let afterSignIn = await useCase.getSubscriptionStatus() + XCTAssertFalse(afterSignIn.isActive) + + await mock.setStatus(Self.activeStatus) + let afterSync = try await useCase.syncPurchases() + + XCTAssertTrue(afterSync.isActive) + let cached = await useCase.getSubscriptionStatus() + 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 { + let mock = SubscriptionRepositoryMock() + let useCase = Self.makeUseCase(repository: mock) + + try await useCase.syncUser(userId: "user-1") + try await useCase.syncUser(userId: "user-2") + + let calls = await mock.syncPurchasesCallCount + XCTAssertEqual(calls, 0) + } + + func test_syncPurchasesが失敗しても状態キャッシュは変わらない() async throws { + let mock = SubscriptionRepositoryMock() + await mock.setStatus(Self.activeStatus) + let useCase = Self.makeUseCase(repository: mock) + _ = try await useCase.checkSubscriptionStatus() + + await mock.setError(SubscriptionError.userSyncFailed(URLError(.notConnectedToInternet))) + do { + _ = try await useCase.syncPurchases() + XCTFail("エラーが投げられるべき") + } catch SubscriptionError.userSyncFailed { + // Expected. + } + + let cached = await useCase.getSubscriptionStatus() + XCTAssertEqual(cached, Self.activeStatus.confirmed(at: Self.fixedNow)) + } + + // MARK: - Default implementation + + private struct StandIn: SubscriptionUseCase { + let status: SubscriptionStatus + func observeSubscriptionStatus() -> AsyncStream { AsyncStream { $0.finish() } } + func getSubscriptionStatus() async -> SubscriptionStatus { .inactive } + func checkSubscriptionStatus() async throws -> SubscriptionStatus { status } + func loadOfferings() async throws -> SubscriptionOffering? { nil } + func purchase(packageId: String) async throws -> SubscriptionStatus { .inactive } + func restorePurchases() async throws -> SubscriptionStatus { .inactive } + func syncUser(userId: String) async throws {} + func clearUser() async throws {} + } + + func test_既定の実装はcheckSubscriptionStatusで答える() async throws { + let useCase = StandIn(status: Self.activeStatus) + + let returned = try await useCase.syncPurchases() + + XCTAssertEqual(returned, Self.activeStatus) + } +}