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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions Sources/SubscriptionUI/PreviewSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
7 changes: 7 additions & 0 deletions Tests/SubscriptionTests/SubscriptionRepositoryMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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)
Expand Down
104 changes: 104 additions & 0 deletions Tests/SubscriptionTests/SyncPurchasesTests.swift
Original file line number Diff line number Diff line change
@@ -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<SubscriptionStatus> { 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)
}
}
Loading