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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand All @@ -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/)**
Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand All @@ -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/)**
Expand Down
4 changes: 4 additions & 0 deletions Sources/Subscription/Documentation.docc/Subscription.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions Sources/Subscription/Internal/Domain/OfferingRequest.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<SubscriptionStatus>
Expand Down
44 changes: 44 additions & 0 deletions Sources/Subscription/Internal/Repository/OfferingLookup.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
}
43 changes: 27 additions & 16 deletions Sources/Subscription/Internal/Repository/RevenueCatRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading