Purchases and Paywalls
Sell subscriptions from a Setgreet paywall while your purchase tool keeps handling the money.
Purchases and Paywalls
A Setgreet paywall is a normal flow screen. You design it in the editor and change it without an app release. The purchase itself runs through the tool your app already uses, such as RevenueCat, Adapty or StoreKit 2. Setgreet never handles a payment or a receipt, and it never needs an API key of your purchase tool.
Your app connects the two with one small interface, a purchase provider. Setgreet calls it to load products, to buy and to restore. Your implementation calls your purchase tool.
Purchases need iOS SDK 1.6.0, Android SDK 1.6.0, React Native SDK 1.6.0 or Flutter SDK 1.6.0 or later.
How it works
- When a paywall screen appears, the SDK asks your provider for the products of the screen's source, for example your RevenueCat current offering.
- The plan picker on the screen fills with those products, and prices in the copy resolve from them. Prices always come from the store at runtime, never from the editor.
- When the user taps the purchase button, the SDK calls your provider, which runs the store purchase.
- The flow branches on the outcome: purchased, cancelled, failed or pending.
- The SDK reports the outcome to Setgreet analytics and, for identified users, records whether the user has access.
1. Add a purchase provider to your app
Copy the reference RevenueCat provider for your platform into your app. It compiles against the RevenueCat package your app already depends on: purchases-ios 5.76.0, purchases-android 10.8.0, react-native-purchases 10.4.0 or purchases_flutter 10.3.0, or later. Setgreet adds no RevenueCat dependency and does not configure RevenueCat for you.
//
// SetgreetRevenueCatProvider.swift
//
// A Setgreet purchase provider backed by RevenueCat.
// Requires a Setgreet iOS SDK version with purchase support and
// RevenueCat purchases-ios 5.76.0 or later.
//
// Copy this file into your app. Configure RevenueCat yourself, then
// register it: Setgreet.shared.setPurchaseProvider(SetgreetRevenueCatProvider())
//
// Product refs are RevenueCat package identifiers ($rc_annual, $rc_monthly
// or your own). A package identifier is unique only within its offering,
// so packages are kept per source. Access refs are the keys of your active
// entitlements.
//
import Foundation
import RevenueCat
import SetgreetSDK
final class SetgreetRevenueCatProvider: SetgreetPurchaseProvider {
let providerId = "revenuecat"
/// Packages by source, then by ref, kept between `loadProducts` and
/// `purchase`: a package identifier such as `$rc_annual` exists in every
/// offering, so a ref alone does not name a package. And the offering
/// each source resolved to, for impression tracking.
private let lock = NSLock()
private var packagesBySource: [String: [String: Package]] = [:]
private var offeringsBySource: [String: Offering] = [:]
/// Scoped locking: `NSLock.lock()` is not allowed directly in an async
/// function, so the async paths go through this synchronous helper.
private func withLock<T>(_ body: () -> T) -> T {
lock.lock()
defer { lock.unlock() }
return body()
}
// MARK: - Catalog
func loadProducts(_ source: SetgreetPurchaseSource) async throws -> [SetgreetPurchaseProduct] {
let packages = try await loadOffering(source).availablePackages
let eligibility = await Purchases.shared.checkTrialOrIntroDiscountEligibility(
productIdentifiers: packages.map { $0.storeProduct.productIdentifier }
)
return packages.map {
Self.product(from: $0, eligibility: eligibility[$0.storeProduct.productIdentifier]?.status)
}
}
/// Fetches the offering `source` resolves to and caches its packages
/// under that source, for `purchase(productRef:source:)`.
private func loadOffering(_ source: SetgreetPurchaseSource) async throws -> Offering {
guard Purchases.isConfigured else {
throw SetgreetPurchaseError(
code: .providerNotInitialized,
message: "Purchases.configure(withAPIKey:) has not been called"
)
}
let offerings: Offerings
do {
offerings = try await Purchases.shared.offerings()
} catch {
throw Self.normalizedError(error, fallback: .networkError)
}
guard let offering = Self.offering(for: source, in: offerings) else {
throw SetgreetPurchaseError(
code: .productNotFound,
message: "No offering for \(source.sourceType) \(source.sourceId ?? "")"
)
}
let packages = Dictionary(
offering.availablePackages.map { ($0.identifier, $0) },
uniquingKeysWith: { first, _ in first }
)
withLock {
packagesBySource[Self.key(for: source)] = packages
offeringsBySource[Self.key(for: source)] = offering
}
return offering
}
// MARK: - Purchase
func purchase(productRef: String, source: SetgreetPurchaseSource) async -> SetgreetPurchaseResult {
guard Purchases.isConfigured else {
return SetgreetPurchaseResult(
status: .failed, productRef: productRef,
error: SetgreetPurchaseError(code: .providerNotInitialized)
)
}
let package: Package
switch await resolvePackage(ref: productRef, in: source) {
case .success(let found):
package = found
case .failure(let error):
return SetgreetPurchaseResult(status: .failed, productRef: productRef, error: error)
}
do {
let result = try await Purchases.shared.purchase(package: package)
if result.userCancelled {
return SetgreetPurchaseResult(status: .cancelled, productRef: productRef)
}
return SetgreetPurchaseResult(
status: .purchased, productRef: productRef,
accessRefs: Self.accessRefs(result.customerInfo)
)
} catch {
let normalized = Self.normalizedError(error, fallback: .purchaseFailed)
switch normalized.code {
case .purchaseCancelled:
return SetgreetPurchaseResult(status: .cancelled, productRef: productRef, error: normalized)
case .purchasePending:
return SetgreetPurchaseResult(status: .pending, productRef: productRef, error: normalized)
default:
return SetgreetPurchaseResult(status: .failed, productRef: productRef, error: normalized)
}
}
}
/// The package `ref` names in `source`, never one of the same ref from
/// another offering. A cold cache (no `loadProducts(source)` yet in this
/// process) loads that source's offering first.
private func resolvePackage(ref: String, in source: SetgreetPurchaseSource) async -> Result<Package, SetgreetPurchaseError> {
let key = Self.key(for: source)
if let cached = withLock({ packagesBySource[key]?[ref] }) {
return .success(cached)
}
do {
_ = try await loadOffering(source)
} catch let error as SetgreetPurchaseError {
return .failure(error)
} catch {
return .failure(SetgreetPurchaseError(code: .unknown, message: error.localizedDescription))
}
guard let loaded = withLock({ packagesBySource[key]?[ref] }) else {
return .failure(SetgreetPurchaseError(
code: .productNotFound,
message: "No package \(ref) in \(source.sourceType) \(source.sourceId ?? "")"
))
}
return .success(loaded)
}
// MARK: - Restore
func restorePurchases() async -> SetgreetRestoreResult {
guard Purchases.isConfigured else {
return SetgreetRestoreResult(status: .failed, error: SetgreetPurchaseError(code: .providerNotInitialized))
}
do {
let info = try await Purchases.shared.restorePurchases()
let refs = Self.accessRefs(info)
return SetgreetRestoreResult(status: refs.isEmpty ? .nothingToRestore : .restored, accessRefs: refs)
} catch {
return SetgreetRestoreResult(status: .failed, error: Self.normalizedError(error, fallback: .restoreFailed))
}
}
// MARK: - Access
func accessState() async -> SetgreetPurchaseAccessState? {
guard Purchases.isConfigured, let info = try? await Purchases.shared.customerInfo() else { return nil }
let refs = Self.accessRefs(info)
return SetgreetPurchaseAccessState(active: !refs.isEmpty, accessRefs: refs)
}
// MARK: - Attribution
func trackPaywallImpression(_ context: SetgreetPaywallImpressionContext) {
guard Purchases.isConfigured else { return }
if #available(iOS 15.0, *) {
let offering = withLock { offeringsBySource[Self.key(for: context.source)] }
let params = offering.map { CustomPaywallImpressionParams(paywallId: context.screenId, offering: $0) }
?? CustomPaywallImpressionParams(paywallId: context.screenId)
Purchases.shared.trackCustomPaywallImpression(params)
}
}
func setAttributes(_ attributes: [String: String]) {
guard Purchases.isConfigured else { return }
Purchases.shared.attribution.setAttributes(attributes)
}
// `identify(userId:)` and `reset()` keep their default no-ops on purpose:
// the host app already drives `Purchases.shared.logIn` / `logOut`, and a
// second caller can merge or switch RevenueCat identities by accident.
// MARK: - Mapping
private static func key(for source: SetgreetPurchaseSource) -> String {
"\(source.sourceType)|\(source.sourceId ?? "")"
}
private static func offering(for source: SetgreetPurchaseSource, in offerings: Offerings) -> Offering? {
switch source.sourceType {
case "offering":
return source.sourceId.flatMap { offerings.offering(identifier: $0) }
case "placement":
return source.sourceId.flatMap { offerings.currentOffering(forPlacement: $0) }
default:
return offerings.current
}
}
private static func product(from package: Package, eligibility: IntroEligibilityStatus?) -> SetgreetPurchaseProduct {
let storeProduct = package.storeProduct
return SetgreetPurchaseProduct(
ref: package.identifier,
storeProductId: storeProduct.productIdentifier,
title: storeProduct.localizedTitle,
description: storeProduct.localizedDescription,
localizedPrice: storeProduct.localizedPriceString,
priceAmount: storeProduct.price,
currencyCode: storeProduct.currencyCode ?? "",
period: storeProduct.subscriptionPeriod.map {
SetgreetPurchasePeriod(unit: unit($0.unit), count: $0.value)
},
introOffer: storeProduct.introductoryDiscount.map { discount in
SetgreetPurchaseIntroOffer(
type: introType(discount.paymentMode),
localizedPrice: discount.localizedPriceString,
periodText: periodText(discount.subscriptionPeriod),
eligible: eligible(eligibility)
)
}
)
}
private static func unit(_ unit: SubscriptionPeriod.Unit) -> SetgreetPurchasePeriodUnit {
switch unit {
case .day: return .day
case .week: return .week
case .month: return .month
case .year: return .year
@unknown default: return .month
}
}
private static func introType(_ mode: StoreProductDiscount.PaymentMode) -> String {
switch mode {
case .freeTrial: return SetgreetPurchaseIntroOffer.trialType
case .payAsYouGo: return "payAsYouGo"
case .payUpFront: return "payUpFront"
@unknown default: return "unknown"
}
}
/// `7 days`, `1 week`: what the SDK turns into `7-day free trial`.
private static func periodText(_ period: SubscriptionPeriod) -> String {
let name: String
switch period.unit {
case .day: name = "day"
case .week: name = "week"
case .month: name = "month"
case .year: name = "year"
@unknown default: name = "period"
}
return "\(period.value) \(name)\(period.value == 1 ? "" : "s")"
}
/// nil when RevenueCat could not determine eligibility (missing
/// subscription group info); the SDK then shows no trial text, which it
/// derives only for a user reported as eligible.
private static func eligible(_ status: IntroEligibilityStatus?) -> Bool? {
switch status {
case .eligible?: return true
case .ineligible?, .noIntroOfferExists?: return false
case .unknown?, nil: return nil
@unknown default: return nil
}
}
private static func accessRefs(_ info: CustomerInfo) -> [String] {
Array(info.entitlements.active.keys).sorted()
}
/// RevenueCat's `ErrorCode` → the normalized code; the vendor's case name
/// travels as `providerCode` for logs and analytics.
private static func normalizedError(_ error: Error, fallback: SetgreetPurchaseErrorCode) -> SetgreetPurchaseError {
let nsError = error as NSError
guard nsError.domain == ErrorCode.errorDomain, let code = ErrorCode(rawValue: nsError.code) else {
return SetgreetPurchaseError(code: fallback, message: error.localizedDescription)
}
let normalized: SetgreetPurchaseErrorCode
switch code {
case .purchaseCancelledError: normalized = .purchaseCancelled
case .paymentPendingError: normalized = .purchasePending
case .productNotAvailableForPurchaseError: normalized = .productUnavailable
case .networkError, .offlineConnectionError: normalized = .networkError
case .invalidAppUserIdError: normalized = .identityError
case .configurationError: normalized = .notConfigured
default: normalized = fallback
}
return SetgreetPurchaseError(code: normalized, providerCode: "\(code)", message: nsError.localizedDescription)
}
}//
// SetgreetRevenueCatProvider.kt
//
// A Setgreet purchase provider backed by RevenueCat.
// Requires an Android SDK version with purchase support and RevenueCat
// purchases-android 10.8.0 or later.
//
// Copy this file into your app and add your own package line. Configure
// RevenueCat yourself, then register it:
// Setgreet.setPurchaseProvider(SetgreetRevenueCatProvider())
//
// Product refs are RevenueCat package identifiers ($rc_annual, $rc_monthly
// or your own). A package identifier is unique only within its offering,
// so packages are kept per source. Access refs are the keys of your active
// entitlements.
//
import android.app.Activity
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.PurchasesException
import com.revenuecat.purchases.PurchasesTransactionException
import com.revenuecat.purchases.awaitCustomerInfo
import com.revenuecat.purchases.awaitOfferings
import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.awaitRestore
import com.revenuecat.purchases.models.OfferPaymentMode
import com.revenuecat.purchases.models.Period
import com.revenuecat.purchases.models.StoreProduct
import com.revenuecat.purchases.paywalls.events.CustomPaywallImpressionParams
import com.setgreet.purchases.SetgreetPaywallImpressionContext
import com.setgreet.purchases.SetgreetPurchaseAccessState
import com.setgreet.purchases.SetgreetPurchaseError
import com.setgreet.purchases.SetgreetPurchaseErrorCode
import com.setgreet.purchases.SetgreetPurchaseIntroOffer
import com.setgreet.purchases.SetgreetPurchasePeriod
import com.setgreet.purchases.SetgreetPurchasePeriodUnit
import com.setgreet.purchases.SetgreetPurchaseProduct
import com.setgreet.purchases.SetgreetPurchaseProvider
import com.setgreet.purchases.SetgreetPurchaseResult
import com.setgreet.purchases.SetgreetPurchaseSource
import com.setgreet.purchases.SetgreetPurchaseStatus
import com.setgreet.purchases.SetgreetRestoreResult
import com.setgreet.purchases.SetgreetRestoreStatus
import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException
class SetgreetRevenueCatProvider : SetgreetPurchaseProvider {
override val providerId: String = "revenuecat"
/**
* Packages by source, then by ref, kept between `loadProducts` and
* `purchase`: a package identifier such as `$rc_annual` exists in every
* offering, so a ref alone does not name a package. And the offering each
* source resolved to, for impression tracking.
*/
private val lock = Any()
private val packagesBySource = mutableMapOf<String, Map<String, Package>>()
private val offeringsBySource = mutableMapOf<String, Offering>()
// Catalog
override suspend fun loadProducts(source: SetgreetPurchaseSource): List<SetgreetPurchaseProduct> =
loadOffering(source).availablePackages.map { product(it) }
/** Fetches the offering [source] resolves to and keeps its packages under that source, for [purchase]. */
private suspend fun loadOffering(source: SetgreetPurchaseSource): Offering {
if (!Purchases.isConfigured) {
throw SetgreetPurchaseError(
code = SetgreetPurchaseErrorCode.PROVIDER_NOT_INITIALIZED,
message = "Purchases.configure(...) has not been called"
)
}
val offerings = try {
Purchases.sharedInstance.awaitOfferings()
} catch (error: PurchasesException) {
throw normalizedError(error, SetgreetPurchaseErrorCode.NETWORK_ERROR)
}
val offering = offering(source, offerings) ?: throw SetgreetPurchaseError(
code = SetgreetPurchaseErrorCode.PRODUCT_NOT_FOUND,
message = "No offering for ${source.sourceType} ${source.sourceId.orEmpty()}"
)
// The first package of an identifier wins, as RevenueCat lists them.
val packages = LinkedHashMap<String, Package>()
offering.availablePackages.forEach { packages.putIfAbsent(it.identifier, it) }
synchronized(lock) {
packagesBySource[key(source)] = packages
offeringsBySource[key(source)] = offering
}
return offering
}
// Purchase
override suspend fun purchase(
activity: Activity,
productRef: String,
source: SetgreetPurchaseSource
): SetgreetPurchaseResult {
if (!Purchases.isConfigured) {
return SetgreetPurchaseResult(
status = SetgreetPurchaseStatus.FAILED,
productRef = productRef,
error = SetgreetPurchaseError(SetgreetPurchaseErrorCode.PROVIDER_NOT_INITIALIZED)
)
}
val pkg = try {
resolvePackage(productRef, source)
} catch (error: SetgreetPurchaseError) {
return SetgreetPurchaseResult(SetgreetPurchaseStatus.FAILED, productRef, error = error)
}
return try {
val result = Purchases.sharedInstance.awaitPurchase(PurchaseParams.Builder(activity, pkg).build())
SetgreetPurchaseResult(
status = SetgreetPurchaseStatus.PURCHASED,
productRef = productRef,
accessRefs = accessRefs(result.customerInfo)
)
} catch (error: PurchasesException) {
val ownedAccessRefs = if (error.code == PurchasesErrorCode.ProductAlreadyPurchasedError) activeAccessRefs() else null
purchaseFailure(error, productRef, ownedAccessRefs)
}
}
/**
* The result of a purchase RevenueCat refused with [error].
*
* `ProductAlreadyPurchasedError` is how RevenueCat reports Google Play's
* `ITEM_ALREADY_OWNED`: the Google account already owns the product, e.g.
* a subscriber who reached the paywall anyway. When RevenueCat confirms an
* active entitlement ([ownedAccessRefs] not empty) the user already has
* what they tapped for, so it is reported as purchased and the flow moves
* on, instead of a generic error under the button. Without one (the
* purchase belongs to another app user, or is not synced yet) it stays a
* failure.
*/
internal fun purchaseFailure(
error: PurchasesException,
productRef: String,
ownedAccessRefs: List<String>?
): SetgreetPurchaseResult {
if ((error as? PurchasesTransactionException)?.userCancelled == true) {
return SetgreetPurchaseResult(SetgreetPurchaseStatus.CANCELLED, productRef)
}
if (error.code == PurchasesErrorCode.ProductAlreadyPurchasedError && !ownedAccessRefs.isNullOrEmpty()) {
return SetgreetPurchaseResult(SetgreetPurchaseStatus.PURCHASED, productRef, accessRefs = ownedAccessRefs)
}
val normalized = normalizedError(error, SetgreetPurchaseErrorCode.PURCHASE_FAILED)
val status = when (normalized.code) {
SetgreetPurchaseErrorCode.PURCHASE_CANCELLED -> SetgreetPurchaseStatus.CANCELLED
SetgreetPurchaseErrorCode.PURCHASE_PENDING -> SetgreetPurchaseStatus.PENDING
else -> SetgreetPurchaseStatus.FAILED
}
return SetgreetPurchaseResult(status, productRef, error = normalized)
}
/** The user's active entitlements as RevenueCat reports them now; null when it cannot say. */
private suspend fun activeAccessRefs(): List<String>? = try {
accessRefs(Purchases.sharedInstance.awaitCustomerInfo())
} catch (error: PurchasesException) {
null
}
/**
* The package [ref] names in [source], never one of the same ref from
* another offering. A cold cache (no `loadProducts(source)` yet in this
* process) loads that source's offering first.
*/
private suspend fun resolvePackage(ref: String, source: SetgreetPurchaseSource): Package {
synchronized(lock) { packagesBySource[key(source)]?.get(ref) }?.let { return it }
try {
loadOffering(source)
} catch (error: SetgreetPurchaseError) {
throw error
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Exception) {
throw SetgreetPurchaseError(SetgreetPurchaseErrorCode.UNKNOWN, message = error.message)
}
return synchronized(lock) { packagesBySource[key(source)]?.get(ref) } ?: throw SetgreetPurchaseError(
code = SetgreetPurchaseErrorCode.PRODUCT_NOT_FOUND,
message = "No package $ref in ${source.sourceType} ${source.sourceId.orEmpty()}"
)
}
// Restore
override suspend fun restorePurchases(): SetgreetRestoreResult {
if (!Purchases.isConfigured) {
return SetgreetRestoreResult(
status = SetgreetRestoreStatus.FAILED,
error = SetgreetPurchaseError(SetgreetPurchaseErrorCode.PROVIDER_NOT_INITIALIZED)
)
}
return try {
val refs = accessRefs(Purchases.sharedInstance.awaitRestore())
SetgreetRestoreResult(
status = if (refs.isEmpty()) SetgreetRestoreStatus.NOTHING_TO_RESTORE else SetgreetRestoreStatus.RESTORED,
accessRefs = refs
)
} catch (error: PurchasesException) {
SetgreetRestoreResult(
status = SetgreetRestoreStatus.FAILED,
error = normalizedError(error, SetgreetPurchaseErrorCode.RESTORE_FAILED)
)
}
}
// Access
override suspend fun accessState(): SetgreetPurchaseAccessState? {
if (!Purchases.isConfigured) return null
val info = try {
Purchases.sharedInstance.awaitCustomerInfo()
} catch (error: PurchasesException) {
return null
}
val refs = accessRefs(info)
return SetgreetPurchaseAccessState(active = refs.isNotEmpty(), accessRefs = refs)
}
// Attribution
override fun trackPaywallImpression(context: SetgreetPaywallImpressionContext) {
if (!Purchases.isConfigured) return
val offering = synchronized(lock) { offeringsBySource[key(context.source)] }
val params = if (offering != null) {
CustomPaywallImpressionParams(paywallId = context.screenId, offering = offering)
} else {
CustomPaywallImpressionParams(paywallId = context.screenId)
}
Purchases.sharedInstance.trackCustomPaywallImpression(params)
}
override fun setAttributes(attributes: Map<String, String>) {
if (!Purchases.isConfigured) return
Purchases.sharedInstance.setAttributes(attributes)
}
// `identify(userId)` and `reset()` keep their default no-ops on purpose:
// the host app already drives `Purchases.sharedInstance.logIn` / `logOut`,
// and a second caller can merge or switch RevenueCat identities by accident.
// Mapping
private fun key(source: SetgreetPurchaseSource): String = "${source.sourceType}|${source.sourceId.orEmpty()}"
private fun offering(source: SetgreetPurchaseSource, offerings: Offerings): Offering? = when (source.sourceType) {
"offering" -> source.sourceId?.let { offerings[it] }
"placement" -> source.sourceId?.let { offerings.getCurrentOfferingForPlacement(it) }
else -> offerings.current
}
private fun product(pkg: Package): SetgreetPurchaseProduct {
val storeProduct = pkg.product
val price = storeProduct.price
return SetgreetPurchaseProduct(
ref = pkg.identifier,
storeProductId = storeProduct.id,
// `name`, not `title`: on Google Play the title carries the app name.
title = storeProduct.name,
description = storeProduct.description,
localizedPrice = price.formatted,
priceAmount = BigDecimal.valueOf(price.amountMicros, 6).stripTrailingZeros(),
currencyCode = price.currencyCode,
period = storeProduct.period?.let { SetgreetPurchasePeriod(unit = unit(it.unit), count = it.value) },
introOffer = introOffer(storeProduct)
)
}
/**
* The trial (else the intro price) of the option a package purchase uses:
* its default option, the one `PurchaseParams.Builder(activity, package)`
* buys.
*
* Eligibility: Google Play hands an app only the offers the current user
* is eligible for, so an offer present here is one the user can take.
* Google: "For subscriptions, the queryProductDetailsAsync() method returns
* subscription product details and a maximum of 50 user eligible offers
* per subscription"
* (https://developer.android.com/google/play/billing/integrate, "Process
* the result"). RevenueCat: "The subscriptionOptions contain only offers
* that the current customer is eligible for"
* (https://www.revenuecat.com/docs/subscription-guidance/subscription-offers).
* A user who already used the trial gets the base plan as the default
* option, which has no free or intro phase.
*/
private fun introOffer(storeProduct: StoreProduct): SetgreetPurchaseIntroOffer? {
val option = storeProduct.defaultOption ?: return null
val phase = option.freePhase ?: option.introPhase ?: return null
val type = introType(phase.offerPaymentMode) ?: return null
return SetgreetPurchaseIntroOffer(
type = type,
localizedPrice = phase.price.formatted,
periodText = periodText(phase.billingPeriod),
eligible = true
)
}
private fun unit(unit: Period.Unit): SetgreetPurchasePeriodUnit = when (unit) {
Period.Unit.DAY -> SetgreetPurchasePeriodUnit.DAY
Period.Unit.WEEK -> SetgreetPurchasePeriodUnit.WEEK
Period.Unit.MONTH -> SetgreetPurchasePeriodUnit.MONTH
Period.Unit.YEAR -> SetgreetPurchasePeriodUnit.YEAR
else -> SetgreetPurchasePeriodUnit.MONTH
}
/** null for a phase that is not an offer (the dashboard knows these three types only). */
private fun introType(mode: OfferPaymentMode?): String? = when (mode) {
OfferPaymentMode.FREE_TRIAL -> SetgreetPurchaseIntroOffer.TRIAL_TYPE
OfferPaymentMode.SINGLE_PAYMENT -> "payUpFront"
OfferPaymentMode.DISCOUNTED_RECURRING_PAYMENT -> "payAsYouGo"
null -> null
}
/** `7 days`, `1 week`: what the SDK turns into `7-day free trial`. */
private fun periodText(period: Period): String {
val name = when (period.unit) {
Period.Unit.DAY -> "day"
Period.Unit.WEEK -> "week"
Period.Unit.MONTH -> "month"
Period.Unit.YEAR -> "year"
else -> "period"
}
return "${period.value} $name${if (period.value == 1) "" else "s"}"
}
private fun accessRefs(info: CustomerInfo): List<String> = info.entitlements.active.keys.sorted()
/**
* RevenueCat's `PurchasesErrorCode` → the normalized code; the vendor's code
* name travels as `providerCode` for logs and analytics.
*/
private fun normalizedError(error: PurchasesException, fallback: SetgreetPurchaseErrorCode): SetgreetPurchaseError {
val code = when (error.code) {
PurchasesErrorCode.PurchaseCancelledError -> SetgreetPurchaseErrorCode.PURCHASE_CANCELLED
PurchasesErrorCode.PaymentPendingError -> SetgreetPurchaseErrorCode.PURCHASE_PENDING
PurchasesErrorCode.ProductNotAvailableForPurchaseError -> SetgreetPurchaseErrorCode.PRODUCT_UNAVAILABLE
PurchasesErrorCode.NetworkError -> SetgreetPurchaseErrorCode.NETWORK_ERROR
PurchasesErrorCode.InvalidAppUserIdError -> SetgreetPurchaseErrorCode.IDENTITY_ERROR
PurchasesErrorCode.ConfigurationError -> SetgreetPurchaseErrorCode.NOT_CONFIGURED
else -> fallback
}
return SetgreetPurchaseError(code = code, providerCode = error.code.name, message = error.message)
}
}//
// setgreetRevenueCatProvider.ts
//
// A Setgreet purchase provider backed by RevenueCat.
// Requires a Setgreet React Native SDK version with purchase support and
// RevenueCat react-native-purchases 10.4.0 or later.
//
// Copy this file into your app. Configure RevenueCat yourself, then
// register it: setPurchaseProvider(new SetgreetRevenueCatProvider())
//
// Product refs are RevenueCat package identifiers ($rc_annual, $rc_monthly
// or your own). A package identifier is unique only within its offering,
// so packages are kept per source. Access refs are the keys of your active
// entitlements.
//
import { Platform } from 'react-native';
import Purchases, {
INTRO_ELIGIBILITY_STATUS,
OFFER_PAYMENT_MODE,
PURCHASES_ERROR_CODE,
} from 'react-native-purchases';
import type {
CustomerInfo,
IntroEligibility,
PricingPhase,
PurchasesOffering,
PurchasesPackage,
PurchasesStoreProduct,
} from 'react-native-purchases';
import {
isSetgreetPurchaseErrorCode,
purchaseError,
} from '@setgreet/react-native-sdk';
import type {
SetgreetPaywallImpressionContext,
SetgreetPurchaseAccessState,
SetgreetPurchaseError,
SetgreetPurchaseErrorCode,
SetgreetPurchaseIntroOffer,
SetgreetPurchasePeriod,
SetgreetPurchasePeriodUnit,
SetgreetPurchaseProduct,
SetgreetPurchaseProvider,
SetgreetPurchaseResult,
SetgreetPurchaseSource,
SetgreetRestoreResult,
} from '@setgreet/react-native-sdk';
export class SetgreetRevenueCatProvider implements SetgreetPurchaseProvider {
readonly providerId = 'revenuecat';
/**
* Packages by source, then by ref, kept between `loadProducts` and
* `purchase`: a package identifier such as `$rc_annual` exists in every
* offering, so a ref alone does not name a package. And the offering each
* source resolved to, for impression tracking.
*/
private readonly packagesBySource = new Map<
string,
Map<string, PurchasesPackage>
>();
private readonly offeringsBySource = new Map<string, PurchasesOffering>();
// Catalog
async loadProducts(
source: SetgreetPurchaseSource
): Promise<SetgreetPurchaseProduct[]> {
const packages = (await this.loadOffering(source)).availablePackages;
const eligibility = await introEligibility(packages);
return packages.map((aPackage) =>
toProduct(aPackage, eligibility[aPackage.product.identifier])
);
}
/**
* Fetches the offering `source` resolves to and keeps its packages under
* that source, for `purchase`.
*/
private async loadOffering(
source: SetgreetPurchaseSource
): Promise<PurchasesOffering> {
if (!(await isConfigured())) {
throw purchaseError('provider_not_initialized', {
message: 'Purchases.configure() has not been called',
});
}
let offering: PurchasesOffering | null;
try {
offering = await resolveOffering(source);
} catch (error) {
throw normalizedError(error, 'network_error');
}
if (offering == null) {
throw purchaseError('product_not_found', {
message: `No offering for ${source.sourceType} ${source.sourceId ?? ''}`,
});
}
// The first package of an identifier wins, as RevenueCat lists them.
const packages = new Map<string, PurchasesPackage>();
offering.availablePackages.forEach((aPackage) => {
if (!packages.has(aPackage.identifier)) {
packages.set(aPackage.identifier, aPackage);
}
});
this.packagesBySource.set(sourceKey(source), packages);
this.offeringsBySource.set(sourceKey(source), offering);
return offering;
}
// Purchase
async purchase(
productRef: string,
source: SetgreetPurchaseSource
): Promise<SetgreetPurchaseResult> {
if (!(await isConfigured())) {
return {
status: 'failed',
productRef,
error: purchaseError('provider_not_initialized'),
};
}
let aPackage: PurchasesPackage;
try {
aPackage = await this.resolvePackage(productRef, source);
} catch (error) {
return {
status: 'failed',
productRef,
error: asSetgreetError(error),
};
}
try {
const { customerInfo } = await Purchases.purchasePackage(aPackage);
return {
status: 'purchased',
productRef,
accessRefs: accessRefs(customerInfo),
};
} catch (error) {
const ownedAccessRefs =
purchasesErrorCode(error) ===
PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR
? await activeAccessRefs()
: null;
return purchaseFailure(error, productRef, ownedAccessRefs);
}
}
/**
* The package `ref` names in `source`, never one of the same ref from
* another offering. A cold cache (no `loadProducts(source)` yet in this
* session) loads that source's offering first.
*/
private async resolvePackage(
ref: string,
source: SetgreetPurchaseSource
): Promise<PurchasesPackage> {
const cached = this.packagesBySource.get(sourceKey(source))?.get(ref);
if (cached) {
return cached;
}
await this.loadOffering(source);
const loaded = this.packagesBySource.get(sourceKey(source))?.get(ref);
if (!loaded) {
throw purchaseError('product_not_found', {
message: `No package ${ref} in ${source.sourceType} ${source.sourceId ?? ''}`,
});
}
return loaded;
}
// Restore
async restorePurchases(): Promise<SetgreetRestoreResult> {
if (!(await isConfigured())) {
return {
status: 'failed',
error: purchaseError('provider_not_initialized'),
};
}
try {
const refs = accessRefs(await Purchases.restorePurchases());
return {
status: refs.length === 0 ? 'nothingToRestore' : 'restored',
accessRefs: refs,
};
} catch (error) {
return {
status: 'failed',
error: normalizedError(error, 'restore_failed'),
};
}
}
// Access
async accessState(): Promise<SetgreetPurchaseAccessState | null> {
if (!(await isConfigured())) {
return null;
}
try {
const refs = accessRefs(await Purchases.getCustomerInfo());
return { active: refs.length > 0, accessRefs: refs };
} catch {
return null;
}
}
// Attribution
trackPaywallImpression(context: SetgreetPaywallImpressionContext): void {
const offering = this.offeringsBySource.get(sourceKey(context.source));
whenConfigured(() =>
Purchases.trackCustomPaywallImpression({
paywallId: context.screenId,
offering: offering ?? null,
})
);
}
setAttributes(attributes: Record<string, string>): void {
whenConfigured(() => Purchases.setAttributes(attributes));
}
// `identify` and `reset` are left out on purpose: your app already drives
// `Purchases.logIn` / `Purchases.logOut`, and a second caller can merge or
// switch RevenueCat identities by accident.
}
// Offerings
function sourceKey(source: SetgreetPurchaseSource): string {
return `${source.sourceType}|${source.sourceId ?? ''}`;
}
async function resolveOffering(
source: SetgreetPurchaseSource
): Promise<PurchasesOffering | null> {
switch (source.sourceType) {
case 'offering': {
if (!source.sourceId) {
return null;
}
const offerings = await Purchases.getOfferings();
return offerings.all[source.sourceId] ?? null;
}
case 'placement':
return source.sourceId
? Purchases.getCurrentOfferingForPlacement(source.sourceId)
: null;
default:
return (await Purchases.getOfferings()).current;
}
}
async function isConfigured(): Promise<boolean> {
try {
return await Purchases.isConfigured();
} catch {
return false;
}
}
/** Runs a fire-and-forget RevenueCat call once Purchases is configured; failures are dropped. */
function whenConfigured(call: () => Promise<unknown>): void {
isConfigured()
.then((configured) => (configured ? call() : undefined))
.catch(() => undefined);
}
/**
* iOS only: RevenueCat computes trial and intro eligibility against the App
* Store. Google Play hands an app only the offers the user can take, so on
* Android there is nothing to ask.
*/
async function introEligibility(
packages: PurchasesPackage[]
): Promise<Record<string, IntroEligibility>> {
if (Platform.OS !== 'ios' || packages.length === 0) {
return {};
}
try {
return await Purchases.checkTrialOrIntroductoryPriceEligibility(
packages.map((aPackage) => aPackage.product.identifier)
);
} catch {
return {};
}
}
// Purchase outcomes
/**
* The result of a purchase RevenueCat refused with `error`.
*
* `PRODUCT_ALREADY_PURCHASED_ERROR` is how RevenueCat reports Google Play's
* `ITEM_ALREADY_OWNED`: the store account already owns the product, e.g. a
* subscriber who reached the paywall anyway. When RevenueCat confirms an
* active entitlement (`ownedAccessRefs` not empty) the user already has what
* they tapped for, so it is reported as purchased and the flow moves on,
* instead of a generic error under the button. Without one (the purchase
* belongs to another app user, or is not synced yet) it stays a failure.
*/
function purchaseFailure(
error: unknown,
productRef: string,
ownedAccessRefs: string[] | null
): SetgreetPurchaseResult {
const code = purchasesErrorCode(error);
if (code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
return { status: 'cancelled', productRef };
}
if (
code === PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR &&
ownedAccessRefs != null &&
ownedAccessRefs.length > 0
) {
return { status: 'purchased', productRef, accessRefs: ownedAccessRefs };
}
const normalized = normalizedError(error, 'purchase_failed');
switch (normalized.code) {
case 'purchase_cancelled':
return { status: 'cancelled', productRef, error: normalized };
case 'purchase_pending':
return { status: 'pending', productRef, error: normalized };
default:
return { status: 'failed', productRef, error: normalized };
}
}
/** The user's active entitlements as RevenueCat reports them now; null when it cannot say. */
async function activeAccessRefs(): Promise<string[] | null> {
try {
return accessRefs(await Purchases.getCustomerInfo());
} catch {
return null;
}
}
function accessRefs(info: CustomerInfo): string[] {
return Object.keys(info.entitlements.active).sort();
}
// Errors
/**
* The `PURCHASES_ERROR_CODE` of a rejection from react-native-purchases,
* which rejects with a `PurchasesError`; undefined for anything else.
*/
function purchasesErrorCode(error: unknown): string | undefined {
if (typeof error === 'object' && error !== null && 'code' in error) {
return typeof error.code === 'string' ? error.code : undefined;
}
return undefined;
}
function errorMessage(error: unknown): string | null {
if (typeof error === 'object' && error !== null && 'message' in error) {
return typeof error.message === 'string' ? error.message : null;
}
return typeof error === 'string' ? error : null;
}
/** RevenueCat's readable error name, e.g. `PurchaseCancelledError`; else its numeric code. */
function readableErrorCode(error: unknown): string | null {
if (typeof error === 'object' && error !== null && 'userInfo' in error) {
const userInfo = error.userInfo;
if (
typeof userInfo === 'object' &&
userInfo !== null &&
'readableErrorCode' in userInfo &&
typeof userInfo.readableErrorCode === 'string' &&
userInfo.readableErrorCode.length > 0
) {
return userInfo.readableErrorCode;
}
}
return purchasesErrorCode(error) ?? null;
}
/**
* RevenueCat's `PURCHASES_ERROR_CODE` → the normalized code; the vendor's code
* travels as `providerCode` for logs and analytics.
*/
function normalizedError(
error: unknown,
fallback: SetgreetPurchaseErrorCode
): SetgreetPurchaseError {
let code: SetgreetPurchaseErrorCode;
switch (purchasesErrorCode(error)) {
case PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR:
code = 'purchase_cancelled';
break;
case PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR:
code = 'purchase_pending';
break;
case PURCHASES_ERROR_CODE.PRODUCT_NOT_AVAILABLE_FOR_PURCHASE_ERROR:
code = 'product_unavailable';
break;
case PURCHASES_ERROR_CODE.NETWORK_ERROR:
case PURCHASES_ERROR_CODE.OFFLINE_CONNECTION_ERROR:
code = 'network_error';
break;
case PURCHASES_ERROR_CODE.INVALID_APP_USER_ID_ERROR:
code = 'identity_error';
break;
case PURCHASES_ERROR_CODE.CONFIGURATION_ERROR:
code = 'not_configured';
break;
default:
code = fallback;
}
return purchaseError(code, {
providerCode: readableErrorCode(error),
message: errorMessage(error),
});
}
/** An error this file threw itself keeps its code; anything else is `unknown`. */
function asSetgreetError(error: unknown): SetgreetPurchaseError {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
isSetgreetPurchaseErrorCode(error.code)
) {
const providerCode =
'providerCode' in error && typeof error.providerCode === 'string'
? error.providerCode
: null;
return { code: error.code, providerCode, message: errorMessage(error) };
}
return normalizedError(error, 'unknown');
}
// Mapping
function toProduct(
aPackage: PurchasesPackage,
eligibility: IntroEligibility | undefined
): SetgreetPurchaseProduct {
const product = aPackage.product;
return {
ref: aPackage.identifier,
storeProductId: product.identifier,
title: Platform.OS === 'android' ? playTitle(product.title) : product.title,
description: product.description,
localizedPrice: product.priceString,
priceAmount: product.price,
currencyCode: product.currencyCode,
period: period(product.subscriptionPeriod),
introOffer:
Platform.OS === 'android'
? playIntroOffer(product)
: appStoreIntroOffer(product, eligibility),
};
}
/**
* Google Play's product title carries the app name in a trailing
* parenthesised group ("Premium (My App)"). The Kotlin reference uses Play's
* `name` instead, which react-native-purchases does not expose, so that group
* is dropped here: scanning back from the final ")" to its matching "(", so
* parentheses inside the product name or the app name survive
* ("Pro (Annual) (My App (Beta))" becomes "Pro (Annual)"). A title without a
* balanced trailing group, or with nothing before it, is kept as is.
*/
function playTitle(title: string): string {
const trimmed = title.trimEnd();
if (!trimmed.endsWith(')')) {
return title;
}
let depth = 0;
for (let index = trimmed.length - 1; index >= 0; index -= 1) {
const character = trimmed[index];
if (character === ')') {
depth += 1;
} else if (character === '(') {
depth -= 1;
if (depth === 0) {
const name = trimmed.slice(0, index).trimEnd();
return name.length > 0 ? name : title;
}
}
}
return title;
}
const PERIOD_UNITS: Record<string, SetgreetPurchasePeriodUnit> = {
D: 'day',
W: 'week',
M: 'month',
Y: 'year',
};
/** `P1Y`, `P3M`, `P1W`, `P7D` (ISO 8601); null for a one-time product. */
function period(iso8601: string | null): SetgreetPurchasePeriod | null {
const match = /^P(\d+)([DWMY])$/.exec(iso8601 ?? '');
const count = Number(match?.[1]);
const unit = PERIOD_UNITS[match?.[2] ?? ''];
return unit && count > 0 ? { unit, count } : null;
}
/**
* Android: the trial (else the intro price) of the option a package purchase
* uses, its default option. Google Play returns only offers the current user
* is eligible for, so an offer present here is one the user can take.
*/
function playIntroOffer(
product: PurchasesStoreProduct
): SetgreetPurchaseIntroOffer | null {
const option = product.defaultOption;
const phase: PricingPhase | null =
option?.freePhase ?? option?.introPhase ?? null;
if (phase == null) {
return null;
}
const type = playIntroType(phase.offerPaymentMode);
if (type == null) {
return null;
}
return {
type,
localizedPrice: phase.price.formatted,
periodText: periodText(phase.billingPeriod.unit, phase.billingPeriod.value),
eligible: true,
};
}
/** null for a phase that is not an offer (the dashboard knows these three types only). */
function playIntroType(mode: OFFER_PAYMENT_MODE | null): string | null {
switch (mode) {
case OFFER_PAYMENT_MODE.FREE_TRIAL:
return 'trial';
case OFFER_PAYMENT_MODE.SINGLE_PAYMENT:
return 'payUpFront';
case OFFER_PAYMENT_MODE.DISCOUNTED_RECURRING_PAYMENT:
return 'payAsYouGo';
default:
return null;
}
}
/**
* iOS: the App Store introductory offer. react-native-purchases reports its
* price and cycles but not its payment mode, so a free price is a trial, more
* than one cycle is pay-as-you-go, and one paid cycle is pay-up-front.
*/
function appStoreIntroOffer(
product: PurchasesStoreProduct,
eligibility: IntroEligibility | undefined
): SetgreetPurchaseIntroOffer | null {
const intro = product.introPrice;
if (intro == null) {
return null;
}
let type = 'payUpFront';
if (intro.price === 0) {
type = 'trial';
} else if (intro.cycles > 1) {
type = 'payAsYouGo';
}
return {
type,
localizedPrice: intro.priceString,
periodText: periodText(intro.periodUnit, intro.periodNumberOfUnits),
eligible: eligible(eligibility),
};
}
/**
* null when RevenueCat could not determine eligibility; the SDK then shows no
* trial text, which it derives only for a user reported as eligible.
*/
function eligible(eligibility: IntroEligibility | undefined): boolean | null {
switch (eligibility?.status) {
case INTRO_ELIGIBILITY_STATUS.INTRO_ELIGIBILITY_STATUS_ELIGIBLE:
return true;
case INTRO_ELIGIBILITY_STATUS.INTRO_ELIGIBILITY_STATUS_INELIGIBLE:
case INTRO_ELIGIBILITY_STATUS.INTRO_ELIGIBILITY_STATUS_NO_INTRO_OFFER_EXISTS:
return false;
default:
return null;
}
}
/** `7 days`, `1 week`: what the SDK turns into `7-day free trial`. */
function periodText(unit: string, value: number): string {
const names: Record<string, string> = {
DAY: 'day',
WEEK: 'week',
MONTH: 'month',
YEAR: 'year',
};
const name = names[unit] ?? 'period';
return `${value} ${name}${value === 1 ? '' : 's'}`;
}//
// setgreet_revenuecat_provider.dart
//
// A Setgreet purchase provider backed by RevenueCat.
// Requires a Setgreet Flutter SDK version with purchase support and
// RevenueCat purchases_flutter 10.3.0 or later.
//
// Copy this file into your app. Configure RevenueCat yourself, then
// register it: Setgreet.setPurchaseProvider(SetgreetRevenueCatProvider())
//
// Product refs are RevenueCat package identifiers ($rc_annual, $rc_monthly
// or your own). A package identifier is unique only within its offering,
// so packages are kept per source. Access refs are the keys of your active
// entitlements.
//
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:setgreet/setgreet.dart';
class SetgreetRevenueCatProvider extends SetgreetPurchaseProvider {
@override
String get providerId => 'revenuecat';
/// Packages by source, then by ref, kept between `loadProducts` and
/// `purchase`: a package identifier such as `$rc_annual` exists in every
/// offering, so a ref alone does not name a package. And the offering each
/// source resolved to, for impression tracking.
final Map<SetgreetPurchaseSource, Map<String, Package>> _packagesBySource =
{};
final Map<SetgreetPurchaseSource, Offering> _offeringsBySource = {};
// Catalog
@override
Future<List<SetgreetPurchaseProduct>> loadProducts(
SetgreetPurchaseSource source) async {
final packages = (await _loadOffering(source)).availablePackages;
final eligibility = await _introEligibility(packages);
return [
for (final package in packages)
_product(package, eligibility[package.storeProduct.identifier]?.status),
];
}
/// Fetches the offering [source] resolves to and keeps its packages under
/// that source, for [purchase].
Future<Offering> _loadOffering(SetgreetPurchaseSource source) async {
if (!await Purchases.isConfigured) {
throw const SetgreetPurchaseError(
code: SetgreetPurchaseErrorCode.providerNotInitialized,
message: 'Purchases.configure(...) has not been called',
);
}
final Offering? offering;
try {
offering = await _offering(source);
} on PlatformException catch (error) {
throw _normalizedError(error, SetgreetPurchaseErrorCode.networkError);
}
if (offering == null) {
throw SetgreetPurchaseError(
code: SetgreetPurchaseErrorCode.productNotFound,
message:
'No offering for ${source.sourceType} ${source.sourceId ?? ''}',
);
}
// The first package of an identifier wins, as RevenueCat lists them.
final packages = <String, Package>{};
for (final package in offering.availablePackages) {
packages.putIfAbsent(package.identifier, () => package);
}
_packagesBySource[source] = packages;
_offeringsBySource[source] = offering;
return offering;
}
Future<Offering?> _offering(SetgreetPurchaseSource source) async {
final id = source.sourceId;
switch (source.sourceType) {
case 'offering':
return id == null
? null
: (await Purchases.getOfferings()).getOffering(id);
case 'placement':
return id == null
? null
: await Purchases.getCurrentOfferingForPlacement(id);
default:
return (await Purchases.getOfferings()).current;
}
}
/// App Store only: whether this user can take each product's introductory
/// offer. Google Play needs no call (see [_playIntroOffer]), and RevenueCat
/// answers "unknown" there anyway.
Future<Map<String, IntroEligibility>> _introEligibility(
List<Package> packages) async {
if (_onGooglePlay) return const {};
try {
return await Purchases.checkTrialOrIntroductoryPriceEligibility(
[for (final package in packages) package.storeProduct.identifier],
);
} catch (_) {
// Best effort: unknown eligibility, so the SDK shows no trial text.
return const {};
}
}
// Purchase
@override
Future<SetgreetPurchaseResult> purchase(
String productRef, SetgreetPurchaseSource source) async {
if (!await Purchases.isConfigured) {
return SetgreetPurchaseResult(
status: SetgreetPurchaseStatus.failed,
productRef: productRef,
error: const SetgreetPurchaseError(
code: SetgreetPurchaseErrorCode.providerNotInitialized),
);
}
final Package package;
try {
package = await _resolvePackage(productRef, source);
} on SetgreetPurchaseError catch (error) {
return SetgreetPurchaseResult(
status: SetgreetPurchaseStatus.failed,
productRef: productRef,
error: error,
);
}
try {
final result = await Purchases.purchase(PurchaseParams.package(package));
return SetgreetPurchaseResult(
status: SetgreetPurchaseStatus.purchased,
productRef: productRef,
accessRefs: _accessRefs(result.customerInfo),
);
} on PlatformException catch (error) {
final ownedAccessRefs =
_errorCode(error) == PurchasesErrorCode.productAlreadyPurchasedError
? await _activeAccessRefs()
: null;
return _purchaseFailure(error, productRef, ownedAccessRefs);
}
}
/// The result of a purchase RevenueCat refused with [error].
///
/// `productAlreadyPurchasedError` is how RevenueCat reports Google Play's
/// `ITEM_ALREADY_OWNED`: the store account already owns the product, e.g. a
/// subscriber who reached the paywall anyway. When RevenueCat confirms an
/// active entitlement ([ownedAccessRefs] not empty) the user already has
/// what they tapped for, so it is reported as purchased and the flow moves
/// on, instead of a generic error under the button. Without one (the
/// purchase belongs to another app user, or is not synced yet) it stays a
/// failure.
SetgreetPurchaseResult _purchaseFailure(
PlatformException error,
String productRef,
List<String>? ownedAccessRefs,
) {
final details = error.details;
if (details is Map && details['userCancelled'] == true) {
return SetgreetPurchaseResult(
status: SetgreetPurchaseStatus.cancelled,
productRef: productRef,
);
}
if (_errorCode(error) == PurchasesErrorCode.productAlreadyPurchasedError &&
ownedAccessRefs != null &&
ownedAccessRefs.isNotEmpty) {
return SetgreetPurchaseResult(
status: SetgreetPurchaseStatus.purchased,
productRef: productRef,
accessRefs: ownedAccessRefs,
);
}
final normalized =
_normalizedError(error, SetgreetPurchaseErrorCode.purchaseFailed);
final status = switch (normalized.code) {
SetgreetPurchaseErrorCode.purchaseCancelled =>
SetgreetPurchaseStatus.cancelled,
SetgreetPurchaseErrorCode.purchasePending =>
SetgreetPurchaseStatus.pending,
_ => SetgreetPurchaseStatus.failed,
};
return SetgreetPurchaseResult(
status: status,
productRef: productRef,
error: normalized,
);
}
/// The user's active entitlements as RevenueCat reports them now; null when
/// it cannot say.
Future<List<String>?> _activeAccessRefs() async {
try {
return _accessRefs(await Purchases.getCustomerInfo());
} on PlatformException {
return null;
}
}
/// The package [ref] names in [source], never one of the same ref from
/// another offering. A cold cache (no `loadProducts(source)` yet in this
/// process) loads that source's offering first.
Future<Package> _resolvePackage(
String ref, SetgreetPurchaseSource source) async {
final cached = _packagesBySource[source]?[ref];
if (cached != null) return cached;
try {
await _loadOffering(source);
} on SetgreetPurchaseError {
rethrow;
} catch (error) {
throw SetgreetPurchaseError(
code: SetgreetPurchaseErrorCode.unknown,
message: error.toString(),
);
}
final loaded = _packagesBySource[source]?[ref];
if (loaded == null) {
throw SetgreetPurchaseError(
code: SetgreetPurchaseErrorCode.productNotFound,
message:
'No package $ref in ${source.sourceType} ${source.sourceId ?? ''}',
);
}
return loaded;
}
// Restore
@override
Future<SetgreetRestoreResult> restorePurchases() async {
if (!await Purchases.isConfigured) {
return const SetgreetRestoreResult(
status: SetgreetRestoreStatus.failed,
error: SetgreetPurchaseError(
code: SetgreetPurchaseErrorCode.providerNotInitialized),
);
}
try {
final refs = _accessRefs(await Purchases.restorePurchases());
return SetgreetRestoreResult(
status: refs.isEmpty
? SetgreetRestoreStatus.nothingToRestore
: SetgreetRestoreStatus.restored,
accessRefs: refs,
);
} on PlatformException catch (error) {
return SetgreetRestoreResult(
status: SetgreetRestoreStatus.failed,
error: _normalizedError(error, SetgreetPurchaseErrorCode.restoreFailed),
);
}
}
// Access
@override
Future<SetgreetPurchaseAccessState?> accessState() async {
if (!await Purchases.isConfigured) return null;
final CustomerInfo info;
try {
info = await Purchases.getCustomerInfo();
} on PlatformException {
return null;
}
final refs = _accessRefs(info);
return SetgreetPurchaseAccessState(
active: refs.isNotEmpty, accessRefs: refs);
}
// Attribution
@override
void trackPaywallImpression(SetgreetPaywallImpressionContext context) {
final offering = _offeringsBySource[context.source];
unawaited(_whenConfigured(() => Purchases.trackCustomPaywallImpression(
params: CustomPaywallImpressionParams(
paywallId: context.screenId,
offering: offering,
),
)));
}
@override
void setAttributes(Map<String, String> attributes) {
unawaited(_whenConfigured(() => Purchases.setAttributes(attributes)));
}
/// Runs [call] when RevenueCat is configured. Attribution is best effort:
/// a failure is dropped.
Future<void> _whenConfigured(Future<void> Function() call) async {
try {
if (await Purchases.isConfigured) await call();
} catch (_) {
// Nothing to report: the paywall works without it.
}
}
// `identify(userId)` and `reset()` keep their default no-ops on purpose:
// the host app already drives `Purchases.logIn` / `Purchases.logOut`, and a
// second caller can merge or switch RevenueCat identities by accident.
// Mapping
/// Android stores are Google Play here; the App Store otherwise.
bool get _onGooglePlay => defaultTargetPlatform == TargetPlatform.android;
SetgreetPurchaseProduct _product(
Package package, IntroEligibilityStatus? eligibility) {
final storeProduct = package.storeProduct;
return SetgreetPurchaseProduct(
ref: package.identifier,
storeProductId: storeProduct.identifier,
title: _onGooglePlay
? playProductName(storeProduct.title)
: storeProduct.title,
description: storeProduct.description,
localizedPrice: storeProduct.priceString,
priceAmount: storeProduct.price,
currencyCode: storeProduct.currencyCode,
period: _period(storeProduct.subscriptionPeriod),
introOffer: _onGooglePlay
? _playIntroOffer(storeProduct)
: _appStoreIntroOffer(storeProduct, eligibility),
);
}
/// Google Play's product title without the app name Play appends in
/// parentheses: "Premium Annual (Your App)" -> "Premium Annual".
///
/// purchases_flutter maps `title` from Play's title, which carries the app
/// name, and has no `name` (the Kotlin reference reads `name`). Scans back
/// from the final ")" to its matching "(", so the product's own
/// parentheses survive ("Pro (Annual) (Your App)" -> "Pro (Annual)") and a
/// nested app name goes as a whole ("Pro (Your App (Beta))" -> "Pro"). A
/// title that does not end in a balanced group, or that is nothing but the
/// group, is returned unchanged.
@visibleForTesting
static String playProductName(String title) {
final trimmed = title.trimRight();
if (!trimmed.endsWith(')')) return title;
var depth = 0;
for (var i = trimmed.length - 1; i >= 0; i--) {
final character = trimmed[i];
if (character == ')') {
depth++;
} else if (character == '(') {
depth--;
if (depth == 0) {
final name = trimmed.substring(0, i).trimRight();
return name.isEmpty ? title : name;
}
}
}
return title;
}
/// `P1W`, `P1M`, `P3M`, `P1Y`: the store's ISO 8601 subscription period.
/// null for a one-time purchase.
static SetgreetPurchasePeriod? _period(String? iso8601) {
final match = RegExp(r'^P(\d+)([DWMY])$').firstMatch(iso8601 ?? '');
if (match == null) return null;
final unit = switch (match.group(2)) {
'D' => SetgreetPurchasePeriodUnit.day,
'W' => SetgreetPurchasePeriodUnit.week,
'M' => SetgreetPurchasePeriodUnit.month,
_ => SetgreetPurchasePeriodUnit.year,
};
return SetgreetPurchasePeriod(
unit: unit, count: int.parse(match.group(1)!));
}
/// App Store: the product's introductory offer, eligible as RevenueCat
/// reports it.
///
/// purchases_flutter does not expose StoreKit's payment mode, so the type
/// is derived from the offer: free is a trial; a paid offer over several
/// periods is pay-as-you-go, over one period pay-up-front. A known
/// approximation: a one-period pay-as-you-go offer reads as `payUpFront`.
/// The type of a paid offer is catalog metadata only; the SDK derives
/// `trialText` from trials alone, so the trial text is unaffected.
SetgreetPurchaseIntroOffer? _appStoreIntroOffer(
StoreProduct storeProduct, IntroEligibilityStatus? eligibility) {
final intro = storeProduct.introductoryPrice;
if (intro == null) return null;
return SetgreetPurchaseIntroOffer(
type: intro.price == 0
? SetgreetPurchaseIntroOffer.trialType
: intro.cycles > 1
? 'payAsYouGo'
: 'payUpFront',
localizedPrice: intro.priceString,
periodText: _periodText(intro.periodUnit, intro.periodNumberOfUnits),
eligible: _eligible(eligibility),
);
}
/// Google Play: the trial (else the intro price) of the option a package
/// purchase uses, its default option.
///
/// Eligibility: Google Play hands an app only the offers the current user
/// is eligible for, so an offer present here is one the user can take.
/// RevenueCat: "The subscriptionOptions contain only offers that the
/// current customer is eligible for"
/// (https://www.revenuecat.com/docs/subscription-guidance/subscription-offers).
/// A user who already used the trial gets the base plan as the default
/// option, which has no free or intro phase.
SetgreetPurchaseIntroOffer? _playIntroOffer(StoreProduct storeProduct) {
final option = storeProduct.defaultOption;
final phase = option?.freePhase ?? option?.introPhase;
if (phase == null) return null;
// null for a phase that is not an offer (the dashboard knows these
// three types only).
final type = switch (phase.offerPaymentMode) {
OfferPaymentMode.freeTrial => SetgreetPurchaseIntroOffer.trialType,
OfferPaymentMode.singlePayment => 'payUpFront',
OfferPaymentMode.discountedRecurringPayment => 'payAsYouGo',
_ => null,
};
if (type == null) return null;
final period = phase.billingPeriod;
return SetgreetPurchaseIntroOffer(
type: type,
localizedPrice: phase.price.formatted,
periodText:
period == null ? null : _periodText(period.unit, period.value),
eligible: true,
);
}
/// `7 days`, `1 week`: what the SDK turns into `7-day free trial`.
static String _periodText(PeriodUnit unit, int value) {
final name = switch (unit) {
PeriodUnit.day => 'day',
PeriodUnit.week => 'week',
PeriodUnit.month => 'month',
PeriodUnit.year => 'year',
_ => 'period',
};
return '$value $name${value == 1 ? '' : 's'}';
}
/// null when RevenueCat could not determine eligibility (missing
/// subscription group info); the SDK then shows no trial text, which it
/// derives only for a user reported as eligible.
static bool? _eligible(IntroEligibilityStatus? status) => switch (status) {
IntroEligibilityStatus.introEligibilityStatusEligible => true,
IntroEligibilityStatus.introEligibilityStatusIneligible ||
IntroEligibilityStatus.introEligibilityStatusNoIntroOfferExists =>
false,
_ => null,
};
static List<String> _accessRefs(CustomerInfo info) =>
info.entitlements.active.keys.toList()..sort();
/// RevenueCat's error code; null for an error that did not come from
/// RevenueCat (its codes are numeric).
static PurchasesErrorCode? _errorCode(PlatformException error) {
final code = int.tryParse(error.code);
if (code == null || code < 0) return null;
return PurchasesErrorHelper.getErrorCode(error);
}
/// RevenueCat's `PurchasesErrorCode` -> the normalized code; the vendor's
/// code name travels as `providerCode` for logs and analytics.
static SetgreetPurchaseError _normalizedError(
PlatformException error, SetgreetPurchaseErrorCode fallback) {
final code = _errorCode(error);
if (code == null) {
return SetgreetPurchaseError(code: fallback, message: error.message);
}
final normalized = switch (code) {
PurchasesErrorCode.purchaseCancelledError =>
SetgreetPurchaseErrorCode.purchaseCancelled,
PurchasesErrorCode.paymentPendingError =>
SetgreetPurchaseErrorCode.purchasePending,
PurchasesErrorCode.productNotAvailableForPurchaseError =>
SetgreetPurchaseErrorCode.productUnavailable,
PurchasesErrorCode.networkError ||
PurchasesErrorCode.offlineConnectionError =>
SetgreetPurchaseErrorCode.networkError,
PurchasesErrorCode.invalidAppUserIdError =>
SetgreetPurchaseErrorCode.identityError,
PurchasesErrorCode.configurationError =>
SetgreetPurchaseErrorCode.notConfigured,
_ => fallback,
};
return SetgreetPurchaseError(
code: normalized,
providerCode: code.name,
message: error.message,
);
}
}The provider leaves identify and reset empty on purpose. If your app already calls RevenueCat's logIn and logOut, a second caller could merge or switch RevenueCat customers by accident.
2. Register it
Register the provider once, after configuring RevenueCat and initializing Setgreet:
import RevenueCat
import SetgreetSDK
Purchases.configure(withAPIKey: "your_revenuecat_public_key")
Setgreet.shared.initialize(appKey: "your_app_key", config: SetgreetConfig(debugMode: false))
Setgreet.shared.setPurchaseProvider(SetgreetRevenueCatProvider())import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration
import com.setgreet.Setgreet
import com.setgreet.model.SetgreetConfig
Purchases.configure(PurchasesConfiguration.Builder(applicationContext, "your_revenuecat_public_key").build())
Setgreet.initialize(applicationContext, "your_app_key", SetgreetConfig(debugMode = false))
Setgreet.setPurchaseProvider(SetgreetRevenueCatProvider())import Purchases from 'react-native-purchases';
import { initialize, setPurchaseProvider } from '@setgreet/react-native-sdk';
import { SetgreetRevenueCatProvider } from './setgreetRevenueCatProvider';
Purchases.configure({ apiKey: 'your_revenuecat_public_key' });
initialize('your_app_key', { debugMode: false });
setPurchaseProvider(new SetgreetRevenueCatProvider());import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:setgreet/setgreet.dart';
import 'setgreet_revenuecat_provider.dart';
await Purchases.configure(PurchasesConfiguration('your_revenuecat_public_key'));
await Setgreet.initialize('your_app_key', config: SetgreetConfig(debugMode: false));
await Setgreet.setPurchaseProvider(SetgreetRevenueCatProvider());Registration loads your default source in the background, so your products appear in the dashboard before any user sees a paywall. A paywall without a plan picker, and a screen that only quotes prices, also load the default source. It is your current offering unless you pass another one:
Setgreet.shared.setPurchaseProvider(SetgreetRevenueCatProvider(), defaultSource: .offering("onboarding"))Setgreet.setPurchaseProvider(SetgreetRevenueCatProvider(), defaultSource = SetgreetPurchaseSource.offering("onboarding"))setPurchaseProvider(new SetgreetRevenueCatProvider(), {
defaultSource: SetgreetPurchaseSource.offering('onboarding'),
});await Setgreet.setPurchaseProvider(
SetgreetRevenueCatProvider(),
defaultSource: const SetgreetPurchaseSource.offering('onboarding'),
);After the first launch with a registered provider, the Purchases tab of your app in the dashboard shows it as connected, with the products your device reported.
In Flutter, register from the root isolate: the native SDK calls your provider through the root isolate's platform channel. With several Flutter engines, the native SDK keeps only the last registration.
3. Build the paywall in the editor
- Plan picker. Add a Segmented Control and turn on Fill from your purchase provider. Choose the source (the current offering, a specific offering or a placement) and either show all of its products or pick the ones you want. The options you type yourself stay as the fallback.
- Skip for users who already have access. New paywalls skip anyone your purchase tool already reports as having access, so people who pay never see them. For an upsell paywall, choose the specific access it sells instead, or turn the option off.
- Purchase button. Set a button's tap action to Purchase, in the Monetization group. It buys the product selected in the plan picker, or a fixed product you choose. Like a Continue button, it first checks the screen's required fields, such as a consent checkbox.
- Restore button. Set a button's tap action to Restore Purchases. Add one to every paywall so returning subscribers can get their access back.
- Where they work. Purchase and restore buttons work on flow screens. Inside a modal they do nothing yet, so keep them on the paywall screen itself.
- Prices in copy. Insert product values with the variable picker, for example
{{purchase.selected.price}}or{{purchase.$rc_annual.pricePerMonth}}. The fields aretitle,price,pricePerMonth,pricePerWeek,period,trialTextandcurrency.selectedfollows the plan picker.
The editor preview uses example products, or the products your app last reported, and lets you simulate each outcome. No purchase runs in the preview.
4. Decide what happens when products can't load
Set When products can't load on the plan picker, or on the purchase button when the paywall has no plan picker:
- Skip this screen (the default): the flow continues past the paywall.
- Show the typed options: the paywall shows your fallback options, with the purchase buttons disabled.
- Close the flow.
This applies when no provider is registered, when the store can't be reached, when the source returns no products, and when your provider takes longer than 10 seconds to load them. It also decides what users of an SDK version without purchase support see: their paywall is skipped or the flow is closed. With Show the typed options, they see the paywall with your fallback options and without prices, and its purchase and restore buttons reach your app as custom actions, which buy nothing unless your app handles them. SDK versions before 1.2.0 can't skip a screen, so they show that paywall whatever you choose here.
5. Branch on the outcome
Add a Response Split after the paywall that splits on the purchase button. Its outcomes are purchased, cancelled, failed and pending (for example when a parent has to approve the purchase). A restore button reports restored, nothingToRestore or failed. A paywall skipped because the user already has access reports hasAccess, or follows the purchased path when your split has no hasAccess path.
After purchased, and after a restore that granted access, the flow moves on by itself and the split routes on that outcome. Any other outcome keeps the user on the paywall, and the split routes on it when the user moves on another way, for example with a Not now button. A user who moves on without tapping the purchase button at all is routed as cancelled, or as failed when your split has no cancelled path, so nobody reaches the purchased path without buying. cancelled only unlocks the button. failed, pending and nothingToRestore also show a short message under the button, which you can change with the button's Purchase error text, Purchase pending text and Nothing to restore text.
6. Listen in your app (optional)
Setgreet.shared.setFlowCallbacks { callbacks in
callbacks
.onPurchaseCompleted { event in
// event.productRef, event.accessRefs
}
.onPurchaseCancelled { _ in }
.onPurchaseFailed { event in
// event.error.code
}
.onPurchasePending { _ in }
.onRestoreCompleted { event in
// event.status, event.accessRefs
}
.onRestoreFailed { _ in }
}Setgreet.setFlowCallbacks {
onPurchaseCompleted { event ->
// event.productRef, event.accessRefs
}
onPurchaseCancelled { }
onPurchaseFailed { event ->
// event.error.code
}
onPurchasePending { }
onRestoreCompleted { event ->
// event.status, event.accessRefs
}
onRestoreFailed { }
}useFlowEvents({
onPurchaseCompleted: (event) => {
// event.productRef, event.accessRefs
},
onPurchaseCancelled: () => {},
onPurchaseFailed: (event) => {
// event.error.code
},
onPurchasePending: () => {},
onRestoreCompleted: (event) => {
// event.status, event.accessRefs
},
onRestoreFailed: () => {},
});Setgreet.setFlowCallbacks(
SetgreetFlowCallbacks()
..onPurchaseCompleted((event) {
// event.productRef, event.accessRefs
})
..onPurchaseCancelled((_) {})
..onPurchaseFailed((event) {
// event.error.code
})
..onPurchasePending((_) {})
..onRestoreCompleted((event) {
// event.status, event.accessRefs
})
..onRestoreFailed((_) {}),
);On iOS, Android and Flutter, setFlowCallbacks replaces the callbacks set before it. If you already set flow callbacks, add these to the same builder instead of calling it a second time. In Flutter, you can also listen to the Setgreet.flowEvents stream, which carries the same events. From Flutter SDK 1.7.0 you can use the stream and setFlowCallbacks together; with 1.6.0, use one or the other, and listen to the stream from one place only.
In React Native, useFlowEvents listens while its component is mounted. Outside React, use addPurchaseCompletedListener, addPurchaseCancelledListener, addPurchaseFailedListener, addPurchasePendingListener, addRestoreCompletedListener and addRestoreFailedListener. Each returns a subscription; call its remove() when you no longer need it.
Every event carries the flow, screen and component it came from, and the provider's id.
Target users by access
To keep people who already pay away from a paywall, use Skip for users who already have access on the paywall itself. The SDK asks your purchase tool on the device, so it works for anonymous users too and needs no attribute.
For targeting flows, the SDK records three user attributes for identified users whenever your provider reports access: when it loads products, including the load that follows setPurchaseProvider, after every purchase and restore, and after you call identifyUser.
purchase_active:trueorfalsepurchase_access: your active entitlement identifiers, comma separatedpurchase_provider: your provider's id, for examplerevenuecat
Anonymous users carry no attributes, and an identified user has none until your provider has reported access once. A trigger condition on an attribute the user doesn't have only matches with is not set. So target people who haven't bought with purchase_active is not set OR purchase_active equals false, joined at the top level of the trigger. Segments are available on paid plans.
Measure the paywall
The SDK sends these events with the flow's analytics: paywall_impression, purchase_started, purchase_completed, purchase_cancelled, purchase_failed, purchase_pending, restore_started, restore_completed and restore_failed. A completed purchase, or a restore that returned access, is also tracked as the event setgreet:purchase_completed or setgreet:restore_completed, which you can use as a trigger or, on paid plans, as a conversion goal. Setgreet counts outcomes and records the price the user saw. Revenue reporting stays in your purchase tool.
Your provider also receives each paywall impression (the reference provider reports it to RevenueCat with trackCustomPaywallImpression) and, right before each purchase, the Setgreet flow and screen ids as the attributes setgreet_flow and setgreet_screen, so a purchase can be traced back to the flow that sold it.
Test in the sandbox
- iOS. Add a StoreKit configuration file to your app with the products your offering uses, and select it in your scheme under Run, Options, StoreKit Configuration. Purchases then run against that local configuration.
- Android. Use a license tester account from the Google Play Console with a build from a testing track.
Your purchase tool's sandbox guide explains how it records test purchases. Pass SetgreetConfig(debugMode: true) on iOS and Flutter, SetgreetConfig(debugMode = true) on Android, or { debugMode: true } to initialize in React Native, to see catalog loads and provider errors in the console, and the provider's message under a failed purchase button. After a test purchase, paywalls that skip users who already have access are skipped on that device, and debug mode logs why.
Write your own provider
Any purchase tool works. Implement SetgreetPurchaseProvider:
public protocol SetgreetPurchaseProvider: AnyObject {
var providerId: String { get } // "adapty", "storekit", ...
func loadProducts(_ source: SetgreetPurchaseSource) async throws -> [SetgreetPurchaseProduct]
func purchase(productRef: String, source: SetgreetPurchaseSource) async -> SetgreetPurchaseResult
func restorePurchases() async -> SetgreetRestoreResult
func accessState() async -> SetgreetPurchaseAccessState?
func trackPaywallImpression(_ context: SetgreetPaywallImpressionContext)
func setAttributes(_ attributes: [String: String])
func identify(userId: String) async // optional
func reset() async // optional
}interface SetgreetPurchaseProvider {
val providerId: String // "adapty", "play_billing", ...
suspend fun loadProducts(source: SetgreetPurchaseSource): List<SetgreetPurchaseProduct>
suspend fun purchase(activity: Activity, productRef: String, source: SetgreetPurchaseSource): SetgreetPurchaseResult
suspend fun restorePurchases(): SetgreetRestoreResult
suspend fun accessState(): SetgreetPurchaseAccessState?
fun trackPaywallImpression(context: SetgreetPaywallImpressionContext)
fun setAttributes(attributes: Map<String, String>)
suspend fun identify(userId: String) {} // optional
suspend fun reset() {} // optional
}interface SetgreetPurchaseProvider {
readonly providerId: string; // 'revenuecat', 'adapty', ...
loadProducts(source: SetgreetPurchaseSource): Promise<SetgreetPurchaseProduct[]>;
purchase(productRef: string, source: SetgreetPurchaseSource): Promise<SetgreetPurchaseResult>;
restorePurchases(): Promise<SetgreetRestoreResult>;
accessState(): Promise<SetgreetPurchaseAccessState | null>;
trackPaywallImpression?(context: SetgreetPaywallImpressionContext): void; // optional
setAttributes?(attributes: Record<string, string>): void; // optional
identify?(userId: string): Promise<void>; // optional
reset?(): Promise<void>; // optional
}abstract class SetgreetPurchaseProvider {
String get providerId; // "revenuecat", "adapty", ...
Future<List<SetgreetPurchaseProduct>> loadProducts(SetgreetPurchaseSource source);
Future<SetgreetPurchaseResult> purchase(String productRef, SetgreetPurchaseSource source);
Future<SetgreetRestoreResult> restorePurchases();
Future<SetgreetPurchaseAccessState?> accessState();
void trackPaywallImpression(SetgreetPaywallImpressionContext context) {} // optional
void setAttributes(Map<String, String> attributes) {} // optional
Future<void> identify(String userId) async {} // optional
Future<void> reset() async {} // optional
}A product's ref is whatever your tool uses to find the product again in purchase: a RevenueCat package identifier, an Adapty product id or a StoreKit product id. A ref only has to be unique within its source, because purchase also receives the source the product came from. Buy the product you returned for that ref from that source: RevenueCat, for example, has a $rc_annual package in every offering. Report outcomes as statuses, because purchase and restorePurchases never throw. Throw a SetgreetPurchaseError from loadProducts when the products can't be loaded. loadProducts has 10 seconds to return before the paywall applies its When products can't load setting.
On Android, purchase also receives the Activity showing the flow, because Google Play starts its purchase screen from an Activity. The SDK calls your provider on the main thread. If no Activity is left when the user taps, the purchase is reported as failed and your provider is not called. A Java app can't implement the suspend functions directly, so write the provider in Kotlin.
In React Native, build the error loadProducts throws with purchaseError, for example throw purchaseError('network_error', { message }); any other rejection is reported as unknown. A purchase or restorePurchases that rejects anyway is reported as failed, except a purchase rejected with code purchase_cancelled or purchase_pending, which is reported as cancelled or pending. On Android, a flow opens in its own activity, which pauses your React Native activity, and React Native does not run JavaScript timers while it is paused, so provider methods must not depend on setTimeout or setInterval. The store sheet opens from React Native's current activity, your app's, not from the flow's. If that activity was destroyed meanwhile, the purchase reports failed.
In Flutter, extend SetgreetPurchaseProvider so the optional methods keep their no-op defaults. A purchase or restorePurchases that throws anyway is reported as failed, with the code of a thrown SetgreetPurchaseError or else unknown. trackPaywallImpression and setAttributes return void, so nothing awaits them: an async override must catch its own errors. On Android, the paywall runs in Setgreet's own activity, while your provider lives in the engine of your FlutterActivity. If that activity is destroyed underneath the paywall, loads and purchases fail.
providerId must start with a lowercase letter and contain only lowercase letters, digits and underscores, 40 characters at most. Setgreet rejects the products your app reports under any other id, so the Purchases tab never shows the provider as connected. In React Native and Flutter, setPurchaseProvider throws for any other id.