Skip to content

Commit

Permalink
Promotional Offers support
Browse files Browse the repository at this point in the history
  • Loading branch information
vegaro committed Jul 8, 2024
1 parent a358c5e commit c860846
Show file tree
Hide file tree
Showing 9 changed files with 532 additions and 24 deletions.
12 changes: 12 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@
3543914526F926D900E669DF /* SKProductSubscriptionDurationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDF41E924F6F844005BC22D /* SKProductSubscriptionDurationExtensions.swift */; };
3544DA6D2C2C848E00704E9D /* CustomerCenterViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3544DA692C2C848E00704E9D /* CustomerCenterViewModelTests.swift */; };
3544DA6F2C2C848E00704E9D /* ManageSubscriptionsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3544DA6A2C2C848E00704E9D /* ManageSubscriptionsViewModelTests.swift */; };
3546355C2C391F38001D7E85 /* FeedbackSurveyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3546355A2C391F38001D7E85 /* FeedbackSurveyViewModel.swift */; };
3546355D2C391F38001D7E85 /* PromotionalOfferViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3546355B2C391F38001D7E85 /* PromotionalOfferViewModel.swift */; };
3546355F2C391F4D001D7E85 /* PromotionalOfferView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3546355E2C391F4D001D7E85 /* PromotionalOfferView.swift */; };
354895D4267AE4B4001DC5B1 /* AttributionKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354895D3267AE4B4001DC5B1 /* AttributionKey.swift */; };
354895D6267BEDE3001DC5B1 /* ReservedSubscriberAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354895D5267BEDE3001DC5B1 /* ReservedSubscriberAttributes.swift */; };
35549323269E298B005F9AE9 /* OfferingsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35549322269E298B005F9AE9 /* OfferingsFactory.swift */; };
Expand Down Expand Up @@ -1171,6 +1174,9 @@
353756632C382C2800A1B8D6 /* URLUtilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLUtilities.swift; sourceTree = "<group>"; };
3544DA692C2C848E00704E9D /* CustomerCenterViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomerCenterViewModelTests.swift; sourceTree = "<group>"; };
3544DA6A2C2C848E00704E9D /* ManageSubscriptionsViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManageSubscriptionsViewModelTests.swift; sourceTree = "<group>"; };
3546355A2C391F38001D7E85 /* FeedbackSurveyViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedbackSurveyViewModel.swift; sourceTree = "<group>"; };
3546355B2C391F38001D7E85 /* PromotionalOfferViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PromotionalOfferViewModel.swift; sourceTree = "<group>"; };
3546355E2C391F4D001D7E85 /* PromotionalOfferView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PromotionalOfferView.swift; sourceTree = "<group>"; };
354895D3267AE4B4001DC5B1 /* AttributionKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionKey.swift; sourceTree = "<group>"; };
354895D5267BEDE3001DC5B1 /* ReservedSubscriberAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReservedSubscriberAttributes.swift; sourceTree = "<group>"; };
35549322269E298B005F9AE9 /* OfferingsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfferingsFactory.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2553,6 +2559,8 @@
353756572C382C2800A1B8D6 /* CustomerCenterViewModel.swift */,
353756582C382C2800A1B8D6 /* CustomerCenterViewState.swift */,
353756592C382C2800A1B8D6 /* ManageSubscriptionsViewModel.swift */,
3546355A2C391F38001D7E85 /* FeedbackSurveyViewModel.swift */,
3546355B2C391F38001D7E85 /* PromotionalOfferViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
Expand All @@ -2564,6 +2572,7 @@
35C200B02C39254100B9778B /* FeedbackSurveyView.swift */,
3537565C2C382C2800A1B8D6 /* ManageSubscriptionsView.swift */,
3537565D2C382C2800A1B8D6 /* NoSubscriptionsView.swift */,
3546355E2C391F4D001D7E85 /* PromotionalOfferView.swift */,
3537565E2C382C2800A1B8D6 /* RestorePurchasesAlert.swift */,
3537565F2C382C2800A1B8D6 /* WrongPlatformView.swift */,
);
Expand Down Expand Up @@ -5159,6 +5168,7 @@
887A60C92C1D037000E1A461 /* PurchaseButton.swift in Sources */,
887A60812C1D037000E1A461 /* PaywallData+Default.swift in Sources */,
887A606A2C1D037000E1A461 /* TrialOrIntroEligibilityChecker.swift in Sources */,
3546355F2C391F4D001D7E85 /* PromotionalOfferView.swift in Sources */,
353756722C382C2800A1B8D6 /* URLUtilities.swift in Sources */,
887A60862C1D037000E1A461 /* FooterHidingModifier.swift in Sources */,
887A60C02C1D037000E1A461 /* AsyncButton.swift in Sources */,
Expand All @@ -5180,6 +5190,7 @@
887A607E2C1D037000E1A461 /* Logger.swift in Sources */,
887A60852C1D037000E1A461 /* FitToAspectRatio.swift in Sources */,
353756652C382C2800A1B8D6 /* CustomerCenterConfigTestData.swift in Sources */,
3546355C2C391F38001D7E85 /* FeedbackSurveyViewModel.swift in Sources */,
353756692C382C2800A1B8D6 /* CustomerCenterViewState.swift in Sources */,
887A608B2C1D037000E1A461 /* PurchaseHandler+TestData.swift in Sources */,
353756672C382C2800A1B8D6 /* SubscriptionInformation.swift in Sources */,
Expand Down Expand Up @@ -5218,6 +5229,7 @@
887A60C42C1D037000E1A461 /* IconView.swift in Sources */,
887A60732C1D037000E1A461 /* ProcessedLocalizedConfiguration.swift in Sources */,
887A606F2C1D037000E1A461 /* PaywallData+Validation.swift in Sources */,
3546355D2C391F38001D7E85 /* PromotionalOfferViewModel.swift in Sources */,
887A60762C1D037000E1A461 /* TemplateViewConfiguration+Extensions.swift in Sources */,
887A607A2C1D037000E1A461 /* Variables.swift in Sources */,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,18 @@ enum CustomerCenterConfigTestData {
options: [
.init(
id: "1",
title: "Too expensive"
title: "Too expensive",
promotionalOffer: nil
),
.init(
id: "2",
title: "Don't use the app"
title: "Don't use the app",
promotionalOffer: nil
),
.init(
id: "3",
title: "Bought by mistake"
title: "Bought by mistake",
promotionalOffer: nil
)
]
))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// FeedbackSurveyViewModel.swift
//
//
// Created by Cesar de la Vega on 17/6/24.
//

import Foundation
import RevenueCat

#if !os(macOS) && !os(tvOS) && !os(watchOS) && !os(visionOS)

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@MainActor
class FeedbackSurveyViewModel: ObservableObject {

typealias CustomerInfoFetcher = @Sendable () async throws -> CustomerInfo

@Published
var feedbackSurveyData: FeedbackSurveyData
@Published
var isShowingPromotionalOffer: Bool = false
@Published
var loadingStates: [String: Bool] = [:]

var promotionalOffer: PromotionalOffer? {
return promotionalOfferViewModel.promotionalOffer
}

var product: StoreProduct? {
return promotionalOfferViewModel.product
}

private var customerInfoFetcher: CustomerInfoFetcher
private var promotionalOfferViewModel: PromotionalOfferViewModel

convenience init(feedbackSurveyData: FeedbackSurveyData) {
self.init(feedbackSurveyData: feedbackSurveyData,
promotionalOfferViewModel: PromotionalOfferViewModel(),
customerInfoFetcher: {
guard Purchases.isConfigured else {
throw PaywallError.purchasesNotConfigured
}

return try await Purchases.shared.customerInfo()
})
}

// @PublicForExternalTesting
init(feedbackSurveyData: FeedbackSurveyData,
promotionalOfferViewModel: PromotionalOfferViewModel,
customerInfoFetcher: @escaping CustomerInfoFetcher) {
self.feedbackSurveyData = feedbackSurveyData
self.promotionalOfferViewModel = promotionalOfferViewModel
self.customerInfoFetcher = customerInfoFetcher
}

func handleAction(for option: CustomerCenterConfigData.HelpPath.FeedbackSurvey.Option) async {
if let promotionalOffer = option.promotionalOffer {
self.loadingStates[option.id] = true
await promotionalOfferViewModel.loadPromo(promotionalOfferId: promotionalOffer.iosOfferId)
self.isShowingPromotionalOffer = true
} else {
self.feedbackSurveyData.action()
}
}

func handleSheetDismiss() {
self.feedbackSurveyData.action()
self.loadingStates.removeAll()
}

}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class ManageSubscriptionsViewModel: ObservableObject {
var showRestoreAlert: Bool = false
@Published
var feedbackSurveyData: FeedbackSurveyData?
@Published
var isShowingPromotionalOffer: Bool = false
@Published
var loadingPath: CustomerCenterConfigData.HelpPath?

@Published
var state: CustomerCenterViewState {
Expand All @@ -49,27 +53,41 @@ class ManageSubscriptionsViewModel: ObservableObject {
@Published
private(set) var refundRequestStatusMessage: String?

var product: StoreProduct? {
return promotionalOfferViewModel.product
}

var promotionalOffer: PromotionalOffer? {
return promotionalOfferViewModel.promotionalOffer
}

private var purchasesProvider: ManageSubscriptionsPurchaseType
private var promotionalOfferViewModel: PromotionalOfferViewModel

private var error: Error?

convenience init(screen: CustomerCenterConfigData.Screen) {
self.init(screen: screen,
purchasesProvider: ManageSubscriptionPurchases())
purchasesProvider: ManageSubscriptionPurchases(),
promotionalOfferViewModel: PromotionalOfferViewModel())
}

// @PublicForExternalTesting
init(screen: CustomerCenterConfigData.Screen,
purchasesProvider: ManageSubscriptionsPurchaseType) {
purchasesProvider: ManageSubscriptionsPurchaseType,
promotionalOfferViewModel: PromotionalOfferViewModel) {
self.state = .notLoaded
self.screen = screen
self.purchasesProvider = purchasesProvider
self.promotionalOfferViewModel = promotionalOfferViewModel
}

init(screen: CustomerCenterConfigData.Screen,
subscriptionInformation: SubscriptionInformation) {
self.screen = screen
self.subscriptionInformation = subscriptionInformation
self.purchasesProvider = ManageSubscriptionPurchases()
self.promotionalOfferViewModel = PromotionalOfferViewModel()
state = .success
}

Expand Down Expand Up @@ -120,7 +138,40 @@ class ManageSubscriptionsViewModel: ObservableObject {
}
}

func performAction(for path: CustomerCenterConfigData.HelpPath) async {
func handleSheetDismiss() {
if let loadingPath = loadingPath {
performAction(for: loadingPath)
self.loadingPath = nil
}
}

#if os(iOS) || targetEnvironment(macCatalyst)
func determineFlow(for path: CustomerCenterConfigData.HelpPath) async {
switch path.detail {
case let .feedbackSurvey(feedbackSurvey):
self.feedbackSurveyData = FeedbackSurveyData(configuration: feedbackSurvey) { [weak self] in
await self?.performAction(for: path)
}
case let .promotionalOffer(promotionalOffer):
self.loadingPath = path
await promotionalOfferViewModel.loadPromo(promotionalOfferId: promotionalOffer.iosOfferId)
self.isShowingPromotionalOffer = true
default:
performAction(for: path)
}
}
#endif

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
private extension ManageSubscriptionsViewModel {

#if os(iOS) || targetEnvironment(macCatalyst)
private func performAction(for path: CustomerCenterConfigData.HelpPath) async {
switch path.type {
case .missingPurchase:
self.showRestoreAlert = true
Expand Down
120 changes: 120 additions & 0 deletions RevenueCatUI/CustomerCenter/ViewModels/PromotionalOfferViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// PromotionalOfferViewModel.swift
//
//
// Created by Cesar de la Vega on 17/6/24.
//

import Foundation
import RevenueCat

#if !os(macOS) && !os(tvOS) && !os(watchOS) && !os(visionOS)

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@MainActor
class PromotionalOfferViewModel: ObservableObject {

typealias CustomerInfoFetcher = @Sendable () async throws -> CustomerInfo

@Published
var product: StoreProduct?
@Published
var promotionalOffer: PromotionalOffer?
@Published
var transaction: StoreTransaction?

private var customerInfoFetcher: CustomerInfoFetcher

convenience init() {
self.init(product: nil, promotionalOffer: nil)
}

convenience init(product: StoreProduct?,
promotionalOffer: PromotionalOffer?) {
self.init(product: product,
promotionalOffer: promotionalOffer,
customerInfoFetcher: {
guard Purchases.isConfigured else {
throw PaywallError.purchasesNotConfigured
}

return try await Purchases.shared.customerInfo()
})
}

// @PublicForExternalTesting
init(product: StoreProduct?,
promotionalOffer: PromotionalOffer?,
customerInfoFetcher: @escaping CustomerInfoFetcher) {
self.product = product
self.promotionalOffer = promotionalOffer
self.customerInfoFetcher = customerInfoFetcher
}

func purchasePromo() async {
guard let promotionalOffer = self.promotionalOffer,
let product = self.product else {
print("Promotional offer not loaded")
return
}
do {
let purchase = try await Purchases.shared.purchase(product: product, promotionalOffer: promotionalOffer)
self.transaction = purchase.transaction
} catch {
print("Error purchasing product with promotional offer: \(error)")
}
}

func loadPromo(promotionalOfferId: String) async {
do {
let customerInfo = try await self.customerInfoFetcher()
let activeSubscriptionProductIds = customerInfo.activeSubscriptions

guard let appStoreSubscription = customerInfo.entitlements.active.first(where: {
$0.value.store == .appStore
}) else {
print("No active App Store subscriptions found")
return
}

let productId = appStoreSubscription.value.productIdentifier
let products = await Purchases.shared.products([productId])
guard let product = products.first(where: { product in
product.discounts.contains { $0.offerIdentifier == promotionalOfferId }
}) else {
print("No active product found with the given promotional offer ID")
return
}

self.product = product

if let discount = product.discounts.first(where: { $0.offerIdentifier == promotionalOfferId }) {
do {
let promotionalOffer = try await Purchases.shared.promotionalOffer(forProductDiscount: discount,
product: product)
self.promotionalOffer = promotionalOffer
} catch {
print("Error fetching promotional offer")
return
}
}
} catch {
print("Error fetching promotional offer for active product: \(error)")
return
}
}

}

#endif
Loading

0 comments on commit c860846

Please sign in to comment.