diff --git a/CHANGELOG.md b/CHANGELOG.md index 77459c9..3ec915a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- `SubscriptionUseCase.loadOffering(placement:)`, which loads the offering the dashboard serves at a + placement, and the current offering when it serves none there. `SubscriptionOffering.servedBy` + (`.placement(_:)`, `.current`, `.identifier`) says which one came back. +- `SubscriptionOffering.metadata`, the offering's dashboard metadata with string values only, and + `droppedMetadataKeys`, the keys whose values were of another type and were left out. +- `purchase(packageId:from:)` and `trackPaywallImpression(source:offering:)`, which buy from and + record an impression of an offering the package loaded the way it was found, so a placement's + offering carries its placement to RevenueCat. If the dashboard has moved the placement to another + offering since, the package is still bought from the offering that was shown. +- The protocol's default implementations answer `loadOffering(placement:)` with `loadOfferings()` + and route the two new calls through the identifier-based ones, so an existing conformance keeps + compiling. + +### Changed +- `SubscriptionRevenueCatUI` reads a placement through the same lookup as the core. Its behaviour + is unchanged: a placement the dashboard serves nothing at still finds no offering there. + ## [3.4.0] - 2026-10-27 ### Added diff --git a/README.ja.md b/README.ja.md index 1c7932c..c82233f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -166,7 +166,8 @@ RevenueCat は自分のペイウォールの表示は自分で数えるが、ア まで Paywall Encounter のチャートに載らない。表示ごとに 1 回、画面に出たところで `trackPaywallImpression(source:)` を呼ぶ。`source` は出した場所で、RevenueCat のペイウォール ID として送られ、チャートをこれで分けられる。名前を指定した offering を売るペイウォールは -`offeringId:` も渡す。その offering の placement とターゲティングが表示に付く。 +`offeringId:` も渡す。パッケージで読んだ offering を売るペイウォールは offering そのものを渡す +(`trackPaywallImpression(source:offering:)`)。配られた placement が表示に付く。 ```swift Task { await subscriptionUseCase.trackPaywallImpression(source: "settings") } @@ -175,6 +176,24 @@ Task { await subscriptionUseCase.trackPaywallImpression(source: "settings") } 呼ぶたびに別の表示として数えられるので、同じ表示で `onAppear` が 2 回走っても 1 回にとどめる。 RevenueCat のペイウォール(`RevenueCatPaywallView`)は SDK が数えるので呼ばなくてよい。 +### 入口ごとに違う offering を売る + +ペイウォールを開く場所を RevenueCat のダッシュボードで placement として名付け、そこに配られた offering +を読む。何も配られていない placement は current の offering を返すので、ダッシュボードを整える前に +出しても壊れない。 + +```swift +guard let offering = try await subscriptionUseCase.loadOffering(placement: "onboarding") else { return } +offering.servedBy // .placement("onboarding")。何も配られていなければ .current +offering.metadata["appeal"] // ダッシュボードの metadata(文字列の値だけ) +try await subscriptionUseCase.purchase(packageId: offering.packages[0].id, from: offering) +await subscriptionUseCase.trackPaywallImpression(source: "onboarding", offering: offering) +``` + +ID でなく offering そのもので買い、表示を記録すると、placement が RevenueCat に届く。`metadata` は +文字列の値だけを持つ。数・真偽値・オブジェクトの鍵は `droppedMetadataKeys` に入るので、読めない設定を +黙って捨てずに知らせられる。 + ## ドキュメント **[API ドキュメントと Getting Started](https://no-problem-dev.github.io/swift-subscription/documentation/subscription/)** diff --git a/README.md b/README.md index 179b92f..7982c91 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,9 @@ RevenueCat counts impressions of its own paywalls; one the app draws is invisibl Encounter chart until the app says it was shown. Call `trackPaywallImpression(source:)` once per presentation, when the paywall is on screen, with where it was shown from — `source` becomes RevenueCat's paywall identifier, so the chart splits by -it. A paywall that sells a named offering passes `offeringId:` as well, so the impression carries -that offering's placement and targeting: +it. A paywall that sells a named offering passes `offeringId:` as well, and one built from an +offering the package loaded passes the offering itself (`trackPaywallImpression(source:offering:)`), +so the impression carries the placement it was served at: ```swift Task { await subscriptionUseCase.trackPaywallImpression(source: "settings") } @@ -184,6 +185,25 @@ Task { await subscriptionUseCase.trackPaywallImpression(source: "settings") } Each call is a separate impression, so guard it against a second `onAppear` for the same presentation. RevenueCat paywalls (`RevenueCatPaywallView`) are counted by the SDK and need no call. +### Selling a different offering at each entry point + +Name the place a paywall opens as a placement in the RevenueCat dashboard, and load what the +dashboard serves there. A placement the dashboard serves nothing at answers with the current +offering, so the app can ship before the dashboard is set up: + +```swift +guard let offering = try await subscriptionUseCase.loadOffering(placement: "onboarding") else { return } +offering.servedBy // .placement("onboarding"), or .current when nothing is served there +offering.metadata["appeal"] // the dashboard's metadata, string values only +try await subscriptionUseCase.purchase(packageId: offering.packages[0].id, from: offering) +await subscriptionUseCase.trackPaywallImpression(source: "onboarding", offering: offering) +``` + +Buying and recording an impression with the offering itself, rather than its identifier, carries +the placement to RevenueCat. `metadata` keeps only string values; the keys of numbers, booleans and +objects are in `droppedMetadataKeys`, so a dashboard entry the app cannot read can be reported +rather than silently ignored. + ## Documentation **[API documentation and Getting Started](https://no-problem-dev.github.io/swift-subscription/documentation/subscription/)** diff --git a/Sources/Subscription/Documentation.docc/Subscription.md b/Sources/Subscription/Documentation.docc/Subscription.md index 22e3f64..b5e234e 100644 --- a/Sources/Subscription/Documentation.docc/Subscription.md +++ b/Sources/Subscription/Documentation.docc/Subscription.md @@ -75,10 +75,14 @@ sandbox account. ### Presenting a paywall - ``SubscriptionOffering`` +- ``SubscriptionOffering/ServedBy`` +- ``SubscriptionUseCase/loadOffering(placement:)`` +- ``SubscriptionUseCase/purchase(packageId:from:)`` - ``SubscriptionPackage`` - ``PackageDuration`` - ``IntroductoryOffer`` - ``SubscriptionUseCase/trackPaywallImpression(source:offeringId:)`` +- ``SubscriptionUseCase/trackPaywallImpression(source:offering:)`` ### SwiftUI integration diff --git a/Sources/Subscription/Internal/Domain/OfferingRequest.swift b/Sources/Subscription/Internal/Domain/OfferingRequest.swift new file mode 100644 index 0000000..607a38b --- /dev/null +++ b/Sources/Subscription/Internal/Domain/OfferingRequest.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Which offering a call reads, buys from, or records an impression of. `package` so +/// `SubscriptionRevenueCatUI` reads a placement through the same ``OfferingLookup``. +package enum OfferingRequest: Sendable, Equatable { + /// The offering the dashboard marks current. + case current + /// The offering with this dashboard identifier, whether or not it is current. + case identifier(String) + /// The offering the dashboard serves at this placement, or the current one when it serves none. + case placement(String) + /// An offering already loaded, found again the way it was found. + case loaded(id: String, servedBy: SubscriptionOffering.ServedBy) + + /// How to find `offering` again. + package static func loaded(_ offering: SubscriptionOffering) -> OfferingRequest { + .loaded(id: offering.id, servedBy: offering.servedBy) + } +} diff --git a/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift b/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift index 3a0e838..2ae1fc7 100644 --- a/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift +++ b/Sources/Subscription/Internal/Domain/SubscriptionRepository.swift @@ -9,13 +9,12 @@ protocol SubscriptionRepository: Sendable { /// Reads the authoritative entitlement state from the store. func checkSubscriptionStatus() async throws -> SubscriptionStatus - /// Fetches the offering with this identifier, or the current one for `nil`; `nil` when the - /// dashboard has no such offering. - func loadOffering(id: String?) async throws -> SubscriptionOffering? + /// Fetches the offering the request names; `nil` when the dashboard has no such offering. + func loadOffering(_ request: OfferingRequest) async throws -> SubscriptionOffering? - /// Presents the purchase sheet for a package of the offering with this identifier (the - /// current one for `nil`) and reports the entitlement once the purchase settles. - func purchase(packageId: String, offeringId: String?) async throws -> SubscriptionStatus + /// Presents the purchase sheet for a package of the offering the request names and reports + /// the entitlement once the purchase settles. + func purchase(packageId: String, from request: OfferingRequest) async throws -> SubscriptionStatus /// Re-applies purchases on the Apple Account; resolves to `.inactive` when there are none. func restorePurchases() async throws -> SubscriptionStatus @@ -26,9 +25,9 @@ protocol SubscriptionRepository: Sendable { /// Returns the billing identity to an anonymous one. func clearUser() async throws - /// Records that the app's own paywall selling the offering with this identifier (the current - /// one for `nil`) was shown; `paywallId` is where it was shown from. - func trackPaywallImpression(paywallId: String, offeringId: String?) async + /// Records that the app's own paywall selling the offering the request names was shown; + /// `paywallId` is where it was shown from. + func trackPaywallImpression(paywallId: String, from request: OfferingRequest) async /// Streams entitlement changes pushed by the store, including ones this app did not cause. func observeSubscriptionChanges() -> AsyncStream diff --git a/Sources/Subscription/Internal/Repository/OfferingLookup.swift b/Sources/Subscription/Internal/Repository/OfferingLookup.swift new file mode 100644 index 0000000..d828c89 --- /dev/null +++ b/Sources/Subscription/Internal/Repository/OfferingLookup.swift @@ -0,0 +1,44 @@ +import Foundation +import RevenueCat + +/// Finds the RevenueCat offering a request names, and how it was found. +/// +/// Shared by the core and `SubscriptionRevenueCatUI` (hence `package`), so a placement is read the +/// same way whether the app draws the paywall or the dashboard does. `Offerings` cannot be built +/// outside the SDK, so its parts are passed in: `served` is `Offerings.currentOffering(forPlacement:)`, +/// which already answers with the placements' fallback offering for a placement the dashboard does +/// not list, and with `nil` when the project has no placements. +/// +/// An identifier the dashboard does not have finds nothing. It never falls back to the current +/// offering: a paywall asked for one set of products and would be selling another. A placement +/// does fall back to the current offering, and says so with ``SubscriptionOffering/ServedBy/current``. +package enum OfferingLookup { + package static func offering( + for request: OfferingRequest, + all: [String: Offering], + current: Offering?, + served: (String) -> Offering? + ) -> (offering: Offering, servedBy: SubscriptionOffering.ServedBy)? { + switch request { + case .current: + return current.map { ($0, .current) } + case .identifier(let id): + return all[id].map { ($0, .identifier) } + case .placement(let placement): + if let offering = served(placement) { return (offering, .placement(placement)) } + return current.map { ($0, .current) } + case .loaded(let id, let servedBy): + // Prefer the copy that carries the placement or targeting context, but only while it is + // still the offering the customer was shown; otherwise buy what was shown, by identifier. + switch servedBy { + case .placement(let placement): + if let offering = served(placement), offering.identifier == id { return (offering, servedBy) } + case .current: + if let offering = current, offering.identifier == id { return (offering, .current) } + case .identifier: + break + } + return all[id].map { ($0, .identifier) } + } + } +} diff --git a/Sources/Subscription/Internal/Repository/RevenueCatRepository.swift b/Sources/Subscription/Internal/Repository/RevenueCatRepository.swift index 69515e9..477d086 100644 --- a/Sources/Subscription/Internal/Repository/RevenueCatRepository.swift +++ b/Sources/Subscription/Internal/Repository/RevenueCatRepository.swift @@ -51,18 +51,18 @@ final class RevenueCatRepository: SubscriptionRepository { } } - func loadOffering(id: String?) async throws -> SubscriptionOffering? { + func loadOffering(_ request: OfferingRequest) async throws -> SubscriptionOffering? { guard isConfigured else { throw SubscriptionError.notConfigured } - let offering: Offering? + let found: (offering: Offering, servedBy: SubscriptionOffering.ServedBy)? do { - offering = Self.offering(id, in: try await Purchases.shared.offerings()) + found = Self.offering(request, in: try await Purchases.shared.offerings()) } catch { throw SubscriptionError.networkError(error) } - guard let offering else { return nil } + guard let (offering, servedBy) = found else { return nil } // Asked only for packages that carry an introductory offer: eligibility is a store // round trip, and the answer means nothing for a product without one. @@ -94,17 +94,24 @@ final class RevenueCatRepository: SubscriptionRepository { ) } - return SubscriptionOffering(id: offering.identifier, packages: packages) + let metadata = SubscriptionOffering.stringMetadata(offering.metadata) + return SubscriptionOffering( + id: offering.identifier, + packages: packages, + metadata: metadata.values, + droppedMetadataKeys: metadata.dropped, + servedBy: servedBy + ) } - func purchase(packageId: String, offeringId: String?) async throws -> SubscriptionStatus { + func purchase(packageId: String, from request: OfferingRequest) async throws -> SubscriptionStatus { guard isConfigured else { throw SubscriptionError.notConfigured } do { let offerings = try await Purchases.shared.offerings() - guard let offering = Self.offering(offeringId, in: offerings), + guard let offering = Self.offering(request, in: offerings)?.offering, let package = offering.availablePackages.first(where: { $0.identifier == packageId }) else { throw SubscriptionError.packageNotFound(packageId) } @@ -168,14 +175,14 @@ final class RevenueCatRepository: SubscriptionRepository { } } - func trackPaywallImpression(paywallId: String, offeringId: String?) async { + func trackPaywallImpression(paywallId: String, from request: OfferingRequest) async { guard isConfigured else { return } // The `Offering` is passed rather than its identifier: the SDK derives the placement and // targeting context from it, and its identifier-only initialiser is deprecated for losing // them. `offerings()` answers from the SDK's cache when the paywall has just loaded it. guard let offerings = try? await Purchases.shared.offerings(), - let offering = Self.offering(offeringId, in: offerings) else { return } + let offering = Self.offering(request, in: offerings)?.offering else { return } Purchases.shared.trackCustomPaywallImpression( CustomPaywallImpressionParams(paywallId: paywallId, offering: offering) @@ -228,13 +235,17 @@ final class RevenueCatRepository: SubscriptionRepository { // MARK: - Testable Core - /// The offering with this identifier, or the current one for `nil`. - /// - /// An identifier that is not in the dashboard finds nothing. It never falls back to the - /// current offering: a paywall asked for one set of products and would be selling another. - static func offering(_ identifier: String?, in offerings: Offerings) -> Offering? { - guard let identifier else { return offerings.current } - return offerings.all[identifier] + /// The offering the request names in `offerings`, and how it was found (``OfferingLookup``). + static func offering( + _ request: OfferingRequest, + in offerings: Offerings + ) -> (offering: Offering, servedBy: SubscriptionOffering.ServedBy)? { + OfferingLookup.offering( + for: request, + all: offerings.all, + current: offerings.current, + served: { offerings.currentOffering(forPlacement: $0) } + ) } /// What a purchase that threw means. A deferred purchase — Ask to Buy — is not a failure. diff --git a/Sources/Subscription/Public/Model/SubscriptionOffering.swift b/Sources/Subscription/Public/Model/SubscriptionOffering.swift index db134c3..7a47039 100644 --- a/Sources/Subscription/Public/Model/SubscriptionOffering.swift +++ b/Sources/Subscription/Public/Model/SubscriptionOffering.swift @@ -203,9 +203,25 @@ public struct SubscriptionPackage: Sendable, Identifiable { /// /// Which offering is current is a server-side decision, so the products can change without /// an app release. A paywall that needs a particular offering rather than the current one asks -/// for it by identifier with ``SubscriptionUseCase/loadOffering(id:)``. Build the paywall from whatever `packages` contains rather than assuming -/// a fixed monthly-and-annual pair. +/// for it by identifier with ``SubscriptionUseCase/loadOffering(id:)``, or for the one the +/// dashboard serves where the paywall opens with ``SubscriptionUseCase/loadOffering(placement:)``. +/// Build the paywall from whatever `packages` contains rather than assuming a fixed +/// monthly-and-annual pair. public struct SubscriptionOffering: Sendable, Identifiable { + /// How an offering was found: which question the dashboard answered with it. + /// + /// Buying from an offering and recording its impression go back to the store the same way, + /// so a purchase made from a placement's offering carries that placement in RevenueCat. + public enum ServedBy: Sendable, Equatable { + /// The offering the dashboard marks current. Also what ``SubscriptionUseCase/loadOffering(placement:)`` + /// answers with when the dashboard serves nothing at the placement. + case current + /// Asked for by its identifier with ``SubscriptionUseCase/loadOffering(id:)``. + case identifier + /// Served at this placement by the dashboard's placements and targeting. + case placement(String) + } + /// The offering's dashboard identifier, useful for attributing conversions to a paywall /// experiment. public let id: String @@ -216,16 +232,66 @@ public struct SubscriptionOffering: Sendable, Identifiable { /// nothing to sell. public let packages: [SubscriptionPackage] + /// The offering's metadata from the dashboard, keeping only the values that are strings. + /// + /// Metadata is a remote switch, so it is read as text the app compares against values it + /// already knows, never as copy to show. A value of any other JSON type — a number, a + /// boolean, an object — is left out rather than converted, and its key is listed in + /// ``droppedMetadataKeys`` so the app can report a dashboard entry it cannot read. + public let metadata: [String: String] + + /// The metadata keys whose values were not strings and are therefore missing from ``metadata``. + public let droppedMetadataKeys: Set + + /// How the offering was found. + public let servedBy: ServedBy + /// Creates an offering. /// /// Provided for tests and previews; real values come from - /// ``SubscriptionUseCase/loadOfferings()``. + /// ``SubscriptionUseCase/loadOfferings()``. The offering has no metadata and is reported as + /// the current one. /// /// - Parameters: /// - id: The offering's dashboard identifier. /// - packages: The products to display. public init(id: String, packages: [SubscriptionPackage]) { + self.init(id: id, packages: packages, metadata: [:], servedBy: .current) + } + + /// Creates an offering with metadata, as the dashboard would return it. + /// + /// - Parameters: + /// - id: The offering's dashboard identifier. + /// - packages: The products to display. + /// - metadata: The string values of the offering's metadata. + /// - droppedMetadataKeys: The keys whose values were not strings. + /// - servedBy: How the offering was found. + public init( + id: String, + packages: [SubscriptionPackage], + metadata: [String: String], + droppedMetadataKeys: Set = [], + servedBy: ServedBy + ) { self.id = id self.packages = packages + self.metadata = metadata + self.droppedMetadataKeys = droppedMetadataKeys + self.servedBy = servedBy + } + + /// Splits the dashboard's metadata into its string values and the keys of everything else. + static func stringMetadata(_ raw: [String: Any]) -> (values: [String: String], dropped: Set) { + var values: [String: String] = [:] + var dropped: Set = [] + for (key, value) in raw { + if let text = value as? String { + values[key] = text + } else { + dropped.insert(key) + } + } + return (values, dropped) } } diff --git a/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift b/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift index a4b2760..2c075f9 100644 --- a/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift +++ b/Sources/Subscription/Public/UseCase/SubscriptionUseCase.swift @@ -90,6 +90,31 @@ public protocol SubscriptionUseCase: Sendable { /// - Throws: ``SubscriptionError/networkError(_:)`` if the products cannot be fetched. func loadOffering(id: String) async throws -> SubscriptionOffering? + /// Fetches the offering the dashboard serves at a placement, or the current one when it + /// serves nothing there. + /// + /// A placement names where a paywall opens — `"onboarding"`, `"feature_gate"` — and the + /// dashboard's placements and targeting decide which offering it sells there, so the products + /// and metadata of each entry point can change, or be experimented on, without a release. + /// + /// ``SubscriptionOffering/servedBy`` says which one came back: `.placement` when the dashboard + /// served an offering at the placement, `.current` when it did not — the placement is unknown + /// to the dashboard and has no fallback, it is set to serve no offering, or the project has no + /// placements at all. A placement the dashboard does not list but answers with its placements' + /// fallback offering is reported as `.placement`: the store does not tell the two apart. + /// + /// Buy from it with ``purchase(packageId:from:)`` and record it with + /// ``trackPaywallImpression(source:offering:)``, which carry the placement to RevenueCat. + /// + /// The default implementation answers with ``loadOfferings()``, which is right for a + /// conformance that knows a single offering. + /// + /// - Parameter placement: The placement identifier set in the dashboard. + /// - Returns: The offering, or `nil` when the dashboard serves nothing at the placement and + /// no offering is marked current. + /// - Throws: ``SubscriptionError/networkError(_:)`` if the products cannot be fetched. + func loadOffering(placement: String) async throws -> SubscriptionOffering? + /// Presents the system purchase sheet for one package and refreshes the cache on success. /// /// - Parameter packageId: A ``SubscriptionPackage/id`` from ``loadOfferings()``. This is @@ -120,6 +145,24 @@ public protocol SubscriptionUseCase: Sendable { /// the offering or the package in it is not there. func purchase(packageId: String, inOffering offeringId: String) async throws -> SubscriptionStatus + /// Presents the system purchase sheet for a package of an offering this package loaded, and + /// refreshes the cache on success. + /// + /// The package is bought the way the offering was found (``SubscriptionOffering/servedBy``), + /// so a purchase from a placement's offering is attributed to that placement in RevenueCat. If + /// the dashboard has since started serving a different offering there, the package is still + /// bought from the offering the customer was shown, without the placement. + /// + /// The default implementation calls ``purchase(packageId:inOffering:)`` with the offering's + /// identifier. + /// + /// - Parameters: + /// - packageId: A ``SubscriptionPackage/id`` from `offering`. + /// - offering: The offering the paywall was built from. + /// - Returns: The entitlement state after the purchase settles. + /// - Throws: As ``purchase(packageId:inOffering:)``. + func purchase(packageId: String, from offering: SubscriptionOffering) async throws -> SubscriptionStatus + /// Re-applies purchases already tied to the signed-in Apple Account. /// /// Returning normally is not evidence of an entitlement: when there is nothing to @@ -193,6 +236,21 @@ public protocol SubscriptionUseCase: Sendable { /// current offering. Pass the identifier given to ``loadOffering(id:)`` when the paywall /// was built from it. func trackPaywallImpression(source: String, offeringId: String?) async + + /// Tells RevenueCat that a paywall the app draws itself, selling an offering this package + /// loaded, was put in front of the customer. + /// + /// The same as ``trackPaywallImpression(source:offeringId:)``, except that the impression is + /// recorded the way the offering was found (``SubscriptionOffering/servedBy``): an offering + /// from ``loadOffering(placement:)`` carries its placement, which an identifier alone loses. + /// + /// The default implementation calls ``trackPaywallImpression(source:offeringId:)`` with the + /// offering's identifier. + /// + /// - Parameters: + /// - source: Where the paywall was shown from. It is sent as RevenueCat's paywall identifier. + /// - offering: The offering the paywall sells. + func trackPaywallImpression(source: String, offering: SubscriptionOffering) async } public extension SubscriptionUseCase { @@ -218,4 +276,16 @@ public extension SubscriptionUseCase { } return try await purchase(packageId: packageId) } + + func loadOffering(placement: String) async throws -> SubscriptionOffering? { + try await loadOfferings() + } + + func purchase(packageId: String, from offering: SubscriptionOffering) async throws -> SubscriptionStatus { + try await purchase(packageId: packageId, inOffering: offering.id) + } + + func trackPaywallImpression(source: String, offering: SubscriptionOffering) async { + await trackPaywallImpression(source: source, offeringId: offering.id) + } } diff --git a/Sources/Subscription/Public/UseCase/SubscriptionUseCaseImpl.swift b/Sources/Subscription/Public/UseCase/SubscriptionUseCaseImpl.swift index 53ca71a..37708f2 100644 --- a/Sources/Subscription/Public/UseCase/SubscriptionUseCaseImpl.swift +++ b/Sources/Subscription/Public/UseCase/SubscriptionUseCaseImpl.swift @@ -101,20 +101,31 @@ public final class SubscriptionUseCaseImpl: SubscriptionUseCase { } public func loadOfferings() async throws -> SubscriptionOffering? { - try await repository.loadOffering(id: nil) + try await repository.loadOffering(.current) } public func loadOffering(id: String) async throws -> SubscriptionOffering? { - try await repository.loadOffering(id: id) + try await repository.loadOffering(.identifier(id)) + } + + public func loadOffering(placement: String) async throws -> SubscriptionOffering? { + try await repository.loadOffering(.placement(placement)) } public func purchase(packageId: String) async throws -> SubscriptionStatus { - let status = try await repository.purchase(packageId: packageId, offeringId: nil) - return await state.confirm(status, at: now()) + try await purchase(packageId: packageId, request: .current) } public func purchase(packageId: String, inOffering offeringId: String) async throws -> SubscriptionStatus { - let status = try await repository.purchase(packageId: packageId, offeringId: offeringId) + try await purchase(packageId: packageId, request: .identifier(offeringId)) + } + + public func purchase(packageId: String, from offering: SubscriptionOffering) async throws -> SubscriptionStatus { + try await purchase(packageId: packageId, request: .loaded(offering)) + } + + private func purchase(packageId: String, request: OfferingRequest) async throws -> SubscriptionStatus { + let status = try await repository.purchase(packageId: packageId, from: request) return await state.confirm(status, at: now()) } @@ -145,7 +156,11 @@ public final class SubscriptionUseCaseImpl: SubscriptionUseCase { } public func trackPaywallImpression(source: String, offeringId: String?) async { - await repository.trackPaywallImpression(paywallId: source, offeringId: offeringId) + await repository.trackPaywallImpression(paywallId: source, from: offeringId.map(OfferingRequest.identifier) ?? .current) + } + + public func trackPaywallImpression(source: String, offering: SubscriptionOffering) async { + await repository.trackPaywallImpression(paywallId: source, from: .loaded(offering)) } } diff --git a/Sources/SubscriptionRevenueCatUI/RevenueCatOfferingLookup.swift b/Sources/SubscriptionRevenueCatUI/RevenueCatOfferingLookup.swift index 0ed7c55..a555812 100644 --- a/Sources/SubscriptionRevenueCatUI/RevenueCatOfferingLookup.swift +++ b/Sources/SubscriptionRevenueCatUI/RevenueCatOfferingLookup.swift @@ -15,16 +15,24 @@ enum RevenueCatOfferingLookup { all: [String: Offering], current: Offering? ) -> Offering? { - guard let identifier else { return current } - return all[identifier] + OfferingLookup.offering( + for: identifier.map(OfferingRequest.identifier) ?? .current, + all: all, + current: current, + served: { _ in nil } + )?.offering } /// The offering served at a placement when one is named, and otherwise the offering with /// this identifier, or the current one for `nil`. /// + /// A placement is read as the core reads it (``OfferingLookup``), except that the core's + /// fallback to the current offering is declined: a placement the dashboard serves nothing at + /// finds nothing here, so the container shows the app's own paywall rather than whatever + /// RevenueCat paywall the current offering carries. + /// /// - Parameter served: What the dashboard serves at a placement: RevenueCat's - /// `Offerings.currentOffering(forPlacement:)`, which already falls back to the current - /// offering for a placement it does not know. + /// `Offerings.currentOffering(forPlacement:)`. static func offering( _ identifier: String?, placement: String?, @@ -33,7 +41,9 @@ enum RevenueCatOfferingLookup { served: (String) -> Offering? ) -> Offering? { guard let placement else { return offering(identifier, all: all, current: current) } - return served(placement) + guard let found = OfferingLookup.offering(for: .placement(placement), all: all, current: current, served: served), + found.servedBy == .placement(placement) else { return nil } + return found.offering } /// What finding `offering` means for choosing a paywall. diff --git a/Tests/SubscriptionTests/OfferingPlacementTests.swift b/Tests/SubscriptionTests/OfferingPlacementTests.swift new file mode 100644 index 0000000..962faa1 --- /dev/null +++ b/Tests/SubscriptionTests/OfferingPlacementTests.swift @@ -0,0 +1,154 @@ +import RevenueCat +import XCTest +@testable import Subscription + +/// Covers placements and metadata: which RevenueCat offering a request finds and how it says so, +/// the use case handing the request through, the protocol's defaults for a conformance that knows +/// one offering, and keeping only the string values of the dashboard's metadata. +final class OfferingPlacementTests: XCTestCase { + private static func offering(_ identifier: String) -> Offering { + Offering(identifier: identifier, serverDescription: identifier, availablePackages: [], webCheckoutUrl: nil) + } + + private static let current = offering("default") + private static let gate = offering("gate") + private static let all = ["default": current, "gate": gate, "trip": offering("trip")] + + private static func find( + _ request: OfferingRequest, + current: Offering? = OfferingPlacementTests.current, + served: (String) -> Offering? = { $0 == "feature_gate" ? OfferingPlacementTests.gate : nil } + ) -> (id: String, servedBy: SubscriptionOffering.ServedBy)? { + OfferingLookup.offering(for: request, all: all, current: current, served: served) + .map { ($0.offering.identifier, $0.servedBy) } + } + + // MARK: - OfferingLookup + + func test_placementに配られたofferingはplacementとして返す() { + let found = Self.find(.placement("feature_gate")) + XCTAssertEqual(found?.id, "gate") + XCTAssertEqual(found?.servedBy, .placement("feature_gate")) + } + + func test_placementに何も配られていなければcurrentとして返す() { + let found = Self.find(.placement("unknown")) + XCTAssertEqual(found?.id, "default") + XCTAssertEqual(found?.servedBy, .current) + } + + func test_placementに何も無くcurrentも無ければnil() { + XCTAssertNil(Self.find(.placement("unknown"), current: nil)) + } + + func test_IDの指定はcurrentで代用しない() { + XCTAssertEqual(Self.find(.identifier("trip"))?.servedBy, .identifier) + XCTAssertNil(Self.find(.identifier("winter"))) + XCTAssertEqual(Self.find(.current)?.id, "default") + } + + /// Buying from a placement's offering finds the copy the placement serves, which carries the + /// placement to RevenueCat. + func test_placementで読んだofferingはplacementから買い直す() { + let found = Self.find(.loaded(id: "gate", servedBy: .placement("feature_gate"))) + XCTAssertEqual(found?.id, "gate") + XCTAssertEqual(found?.servedBy, .placement("feature_gate")) + } + + /// The dashboard moved the placement to another offering after the paywall loaded: the + /// customer is still sold what they were shown. + func test_placementの割り当てが変わっていたら見せたofferingをIDで買う() { + let found = Self.find(.loaded(id: "trip", servedBy: .placement("feature_gate"))) + XCTAssertEqual(found?.id, "trip") + XCTAssertEqual(found?.servedBy, .identifier) + } + + func test_currentとして読んだofferingは今もcurrentならcurrentから買う() { + XCTAssertEqual(Self.find(.loaded(id: "default", servedBy: .current))?.servedBy, .current) + XCTAssertEqual(Self.find(.loaded(id: "trip", servedBy: .current))?.servedBy, .identifier) + } + + // MARK: - SubscriptionUseCaseImpl + + func test_placementの読み込みと購入と表示はその聞き方でストアに渡す() async throws { + let mock = SubscriptionRepositoryMock() + let served = SubscriptionOffering(id: "gate", packages: [], metadata: [:], servedBy: .placement("feature_gate")) + await mock.setOffering(served) + let useCase = SubscriptionUseCaseImpl(repository: mock) + + let loaded = try await useCase.loadOffering(placement: "feature_gate") + _ = try await useCase.purchase(packageId: "$rc_annual", from: try XCTUnwrap(loaded)) + await useCase.trackPaywallImpression(source: "gate", offering: try XCTUnwrap(loaded)) + + let loadRequests = await mock.loadRequests + let purchasedFrom = await mock.purchasedFrom + let impressions = await mock.impressions + XCTAssertEqual(loadRequests, [.placement("feature_gate")]) + XCTAssertEqual(purchasedFrom, [.loaded(id: "gate", servedBy: .placement("feature_gate"))]) + XCTAssertEqual(impressions, [.init(paywallId: "gate", request: .loaded(id: "gate", servedBy: .placement("feature_gate")))]) + } + + // MARK: - Default implementation + + private actor SingleOffering: SubscriptionUseCase { + let offering: SubscriptionOffering? + private(set) var purchasedFrom: [String] = [] + private(set) var impressions: [String?] = [] + + init(offering: SubscriptionOffering?) { self.offering = offering } + + nonisolated func observeSubscriptionStatus() -> AsyncStream { AsyncStream { $0.finish() } } + func getSubscriptionStatus() async -> SubscriptionStatus { .inactive } + func checkSubscriptionStatus() async throws -> SubscriptionStatus { .inactive } + func loadOfferings() async throws -> SubscriptionOffering? { offering } + func purchase(packageId: String) async throws -> SubscriptionStatus { .inactive } + func purchase(packageId: String, inOffering offeringId: String) async throws -> SubscriptionStatus { + purchasedFrom.append(offeringId) + return .inactive + } + func restorePurchases() async throws -> SubscriptionStatus { .inactive } + func syncUser(userId: String) async throws {} + func clearUser() async throws {} + func trackPaywallImpression(source: String, offeringId: String?) async { + impressions.append(offeringId) + } + } + + func test_既定の実装はplacementにcurrentを返し_IDで買って記録する() async throws { + let current = SubscriptionOffering(id: "default", packages: []) + let useCase = SingleOffering(offering: current) + + let loaded = try await useCase.loadOffering(placement: "feature_gate") + _ = try await useCase.purchase(packageId: "$rc_annual", from: try XCTUnwrap(loaded)) + await useCase.trackPaywallImpression(source: "gate", offering: try XCTUnwrap(loaded)) + + XCTAssertEqual(loaded?.id, "default") + XCTAssertEqual(loaded?.servedBy, .current) + let purchasedFrom = await useCase.purchasedFrom + let impressions = await useCase.impressions + XCTAssertEqual(purchasedFrom, ["default"]) + XCTAssertEqual(impressions, ["default"]) + } + + // MARK: - Metadata + + func test_metadataは文字列だけを持ち_ほかの型の鍵を落としたと伝える() { + let metadata = SubscriptionOffering.stringMetadata([ + "appeal": "see_progress", + "onboarding_paywall": "off", + "trial_days": 7, + "enabled": false, + "copy": ["ja": "見出し"], + ]) + + XCTAssertEqual(metadata.values, ["appeal": "see_progress", "onboarding_paywall": "off"]) + XCTAssertEqual(metadata.dropped, ["trial_days", "enabled", "copy"]) + } + + func test_手で作ったofferingはmetadataが空でcurrent() { + let offering = SubscriptionOffering(id: "default", packages: []) + XCTAssertEqual(offering.metadata, [:]) + XCTAssertEqual(offering.droppedMetadataKeys, []) + XCTAssertEqual(offering.servedBy, .current) + } +} diff --git a/Tests/SubscriptionTests/OfferingSelectionTests.swift b/Tests/SubscriptionTests/OfferingSelectionTests.swift index ce96023..6450574 100644 --- a/Tests/SubscriptionTests/OfferingSelectionTests.swift +++ b/Tests/SubscriptionTests/OfferingSelectionTests.swift @@ -19,8 +19,8 @@ final class OfferingSelectionTests: XCTestCase { _ = try await useCase.loadOffering(id: "trip") _ = try await useCase.loadOfferings() - let asked = await mock.loadedOfferingIds - XCTAssertEqual(asked, ["trip", nil]) + let asked = await mock.loadRequests + XCTAssertEqual(asked, [.identifier("trip"), .current]) } func test_IDを指定したofferingから買うとそのIDで買う() async throws { @@ -31,7 +31,7 @@ final class OfferingSelectionTests: XCTestCase { _ = try await useCase.purchase(packageId: "$rc_annual") let purchasedFrom = await mock.purchasedFrom - XCTAssertEqual(purchasedFrom, ["trip", nil]) + XCTAssertEqual(purchasedFrom, [.identifier("trip"), .current]) } // MARK: - Default implementation diff --git a/Tests/SubscriptionTests/PaywallImpressionTests.swift b/Tests/SubscriptionTests/PaywallImpressionTests.swift index cec5e81..d150b87 100644 --- a/Tests/SubscriptionTests/PaywallImpressionTests.swift +++ b/Tests/SubscriptionTests/PaywallImpressionTests.swift @@ -15,7 +15,7 @@ final class PaywallImpressionTests: XCTestCase { await useCase.trackPaywallImpression(source: "settings") let impressions = await mock.impressions - XCTAssertEqual(impressions, [.init(paywallId: "settings", offeringId: nil)]) + XCTAssertEqual(impressions, [.init(paywallId: "settings", request: .current)]) } func test_IDを指定したofferingのペイウォールはそのIDで記録する() async { @@ -25,7 +25,7 @@ final class PaywallImpressionTests: XCTestCase { await useCase.trackPaywallImpression(source: "trip_limit", offeringId: "trip") let impressions = await mock.impressions - XCTAssertEqual(impressions, [.init(paywallId: "trip_limit", offeringId: "trip")]) + XCTAssertEqual(impressions, [.init(paywallId: "trip_limit", request: .identifier("trip"))]) } /// Each call is a separate impression on RevenueCat's side, so the use case must neither diff --git a/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift b/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift index fe8b3c7..9cc2273 100644 --- a/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift +++ b/Tests/SubscriptionTests/SubscriptionRepositoryMock.swift @@ -12,14 +12,14 @@ actor SubscriptionRepositoryMock: SubscriptionRepository { private var checkStatusError: Error? private(set) var syncedUserIds: [String] = [] private(set) var clearUserCallCount = 0 - private(set) var loadedOfferingIds: [String?] = [] - private(set) var purchasedFrom: [String?] = [] + private(set) var loadRequests: [OfferingRequest] = [] + private(set) var purchasedFrom: [OfferingRequest] = [] private(set) var impressions: [PaywallImpression] = [] /// One `trackPaywallImpression` call, as the repository received it. struct PaywallImpression: Equatable { let paywallId: String - let offeringId: String? + let request: OfferingRequest } /// Pushes a status into the stream, standing in for a change the store would push. @@ -58,15 +58,15 @@ actor SubscriptionRepositoryMock: SubscriptionRepository { return statusToReturn } - func loadOffering(id: String?) async throws -> SubscriptionOffering? { + func loadOffering(_ request: OfferingRequest) async throws -> SubscriptionOffering? { try throwIfNeeded() - loadedOfferingIds.append(id) + loadRequests.append(request) return offeringToReturn } - func purchase(packageId: String, offeringId: String?) async throws -> SubscriptionStatus { + func purchase(packageId: String, from request: OfferingRequest) async throws -> SubscriptionStatus { try throwIfNeeded() - purchasedFrom.append(offeringId) + purchasedFrom.append(request) return statusToReturn } @@ -85,8 +85,8 @@ actor SubscriptionRepositoryMock: SubscriptionRepository { clearUserCallCount += 1 } - func trackPaywallImpression(paywallId: String, offeringId: String?) async { - impressions.append(PaywallImpression(paywallId: paywallId, offeringId: offeringId)) + func trackPaywallImpression(paywallId: String, from request: OfferingRequest) async { + impressions.append(PaywallImpression(paywallId: paywallId, request: request)) } nonisolated func observeSubscriptionChanges() -> AsyncStream {