Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client displays correct subscription #1088

Merged
merged 32 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0d93270
Rework how subscriptions are defined
miasma13 Nov 8, 2024
3f9ddb5
Remove unnecessary extension
miasma13 Nov 8, 2024
9be60c1
For country codes move to enum
miasma13 Nov 8, 2024
ce15454
Rework how configuration is injected into purchase manager
miasma13 Nov 8, 2024
bdd671f
Add global entitlement for IDTR
miasma13 Nov 13, 2024
7e04631
Remove feature names no longerr used by the frontend
miasma13 Nov 13, 2024
a4040aa
Remove unused notifications for unused feature names
miasma13 Nov 13, 2024
46960a0
Add new getSubscriptionFeatures call to service
miasma13 Nov 14, 2024
24af7fe
Move StoreSubscriptionConfiguration
miasma13 Nov 20, 2024
a9c1a84
Add temporary helper to init the values
miasma13 Nov 20, 2024
da71297
Implement SubscriptionFeatureMappingCache
miasma13 Nov 20, 2024
6ad4fde
Add API for fetching currentSubscriptionFeatures
miasma13 Nov 21, 2024
488878a
Store cached SubscriptionFeatureMapping in user defaults
miasma13 Nov 21, 2024
55f96c6
Expose current storefront region
miasma13 Nov 21, 2024
4ef5bcc
Prep for feature BE value migration
miasma13 Nov 22, 2024
db804c0
Add new PP feature flags
miasma13 Nov 22, 2024
c1337e7
Move to new values for SubscriptionOptions features
miasma13 Nov 22, 2024
cfca55c
Remove old deprecated SubscriptionFeatureName
miasma13 Nov 22, 2024
14ddd05
Move enum to proper place
miasma13 Nov 22, 2024
5507b23
Move to use subscription platform as an enum
miasma13 Nov 22, 2024
3c494eb
Implement SubscriptionFeatureFlags and support them via mapping
miasma13 Nov 25, 2024
0a08916
Make canPurchase dynamic based on store purchase manager state
miasma13 Nov 25, 2024
79103fd
Add the full restOfWorld country list definition
miasma13 Nov 25, 2024
09ddd7d
Logic to hide ROW behind feature flag
miasma13 Nov 25, 2024
69aa581
Clean up
miasma13 Nov 25, 2024
a85e68f
Add option to override storefront region
miasma13 Nov 28, 2024
a8f2789
Fix tests
miasma13 Nov 28, 2024
fa280dc
Update setting up available products
miasma13 Nov 28, 2024
87d3310
Update production subscription ids
miasma13 Nov 28, 2024
ed22c10
Swift lint fixes
miasma13 Nov 28, 2024
01afb3a
Prior to launch use hardcoded features for options
miasma13 Nov 28, 2024
85fd7e8
Add flag gating in the currentSubscriptionFeatures
miasma13 Nov 28, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ public enum PrivacyProSubfeature: String, Equatable, PrivacySubfeature {
case isLaunchedOverrideStripe
case useUnifiedFeedback
case setAccessTokenCookieForSubscriptionDomains
case isLaunchedROW
case isLaunchedROWOverride
}

public enum SslCertificatesSubfeature: String, PrivacySubfeature {
Expand Down
1 change: 1 addition & 0 deletions Sources/Subscription/API/Model/Entitlement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public struct Entitlement: Codable, Equatable {
case networkProtection = "Network Protection"
case dataBrokerProtection = "Data Broker Protection"
case identityTheftRestoration = "Identity Theft Restoration"
case identityTheftRestorationGlobal = "Global Identity Theft Restoration"
case unknown

public init(from decoder: Decoder) throws {
Expand Down
11 changes: 11 additions & 0 deletions Sources/Subscription/API/SubscriptionEndpointService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public struct GetProductsItem: Decodable {
public let currency: String
}

public struct GetSubscriptionFeaturesResponse: Decodable {
public let features: [Entitlement.ProductName]
}

public struct GetCustomerPortalURLResponse: Decodable {
public let customerPortalUrl: String
}
Expand All @@ -47,6 +51,7 @@ public protocol SubscriptionEndpointService {
func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result<Subscription, SubscriptionServiceError>
func signOut()
func getProducts() async -> Result<[GetProductsItem], APIServiceError>
func getSubscriptionFeatures(for subscriptionID: String) async -> Result<GetSubscriptionFeaturesResponse, APIServiceError>
func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result<GetCustomerPortalURLResponse, APIServiceError>
func confirmPurchase(accessToken: String, signature: String) async -> Result<ConfirmPurchaseResponse, APIServiceError>
}
Expand Down Expand Up @@ -137,6 +142,12 @@ public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService {

// MARK: -

public func getSubscriptionFeatures(for subscriptionID: String) async -> Result<GetSubscriptionFeaturesResponse, APIServiceError> {
await apiService.executeAPICall(method: "GET", endpoint: "products/\(subscriptionID)/features", headers: nil, body: nil)
}

// MARK: -

public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result<GetCustomerPortalURLResponse, APIServiceError> {
var headers = apiService.makeAuthorizationHeader(for: accessToken)
headers["externalAccountId"] = externalID
Expand Down
33 changes: 33 additions & 0 deletions Sources/Subscription/FeatureFlags/FeatureFlaggerMapping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// FeatureFlaggerMapping.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

open class FeatureFlaggerMapping<Feature> {
samsymons marked this conversation as resolved.
Show resolved Hide resolved
public typealias Mapping = (_ feature: Feature) -> Bool

private let isFeatureEnabledMapping: Mapping

public init(mapping: @escaping Mapping) {
isFeatureEnabledMapping = mapping
}

public func isFeatureOn(_ feature: Feature) -> Bool {
return isFeatureEnabledMapping(feature)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// SubscriptionEnvironmentNames.swift
// SubscriptionFeatureFlags.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
Expand All @@ -18,18 +18,21 @@

import Foundation

public enum SubscriptionFeatureName: String, CaseIterable {
case privateBrowsing = "private-browsing"
case privateSearch = "private-search"
case emailProtection = "email-protection"
case appTrackingProtection = "app-tracking-protection"
case vpn = "vpn"
case personalInformationRemoval = "personal-information-removal"
case identityTheftRestoration = "identity-theft-restoration"
public enum SubscriptionFeatureFlags {
case isLaunchedROW
case isLaunchedROWOverride
case usePrivacyProUSARegionOverride
case usePrivacyProROWRegionOverride
}

public enum SubscriptionPlatformName: String {
case ios
case macos
case stripe
public extension SubscriptionFeatureFlags {

var defaultState: Bool {
switch self {
case .isLaunchedROW, .isLaunchedROWOverride:
return true
case .usePrivacyProUSARegionOverride, .usePrivacyProROWRegionOverride:
return false
}
}
}
21 changes: 17 additions & 4 deletions Sources/Subscription/Flows/Models/SubscriptionOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,34 @@
import Foundation

public struct SubscriptionOptions: Encodable, Equatable {
let platform: String
let platform: SubscriptionPlatformName
let options: [SubscriptionOption]
let features: [SubscriptionFeature]

public static var empty: SubscriptionOptions {
let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }
let features = [SubscriptionFeature(name: .networkProtection),
SubscriptionFeature(name: .dataBrokerProtection),
SubscriptionFeature(name: .identityTheftRestoration)]
let platform: SubscriptionPlatformName
#if os(iOS)
platform = .ios
#else
platform = .macos
#endif
return SubscriptionOptions(platform: platform.rawValue, options: [], features: features)
return SubscriptionOptions(platform: platform, options: [], features: features)
}

public func withoutPurchaseOptions() -> Self {
SubscriptionOptions(platform: platform, options: [], features: features)
}
}

public enum SubscriptionPlatformName: String, Encodable {
case ios
case macos
case stripe
}

public struct SubscriptionOption: Encodable, Equatable {
let id: String
let cost: SubscriptionOptionCost
Expand All @@ -45,5 +58,5 @@ struct SubscriptionOptionCost: Encodable, Equatable {
}

public struct SubscriptionFeature: Encodable, Equatable {
let name: String
let name: Entitlement.ProductName
}
6 changes: 4 additions & 2 deletions Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow {
cost: cost)
}

let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }
let features = [SubscriptionFeature(name: .networkProtection),
SubscriptionFeature(name: .dataBrokerProtection),
SubscriptionFeature(name: .identityTheftRestoration)]

return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe.rawValue,
return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe,
options: options,
features: features))
}
Expand Down
106 changes: 90 additions & 16 deletions Sources/Subscription/Managers/StorePurchaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public protocol StorePurchaseManager {
var purchasedProductIDs: [String] { get }
var purchaseQueue: [String] { get }
var areProductsAvailable: Bool { get }
var currentStorefrontRegion: SubscriptionRegion { get }

@MainActor func syncAppleIDAccount() async throws
@MainActor func updateAvailableProducts() async
Expand All @@ -56,21 +57,24 @@ public protocol StorePurchaseManager {
@available(macOS 12.0, iOS 15.0, *)
public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManager {

let productIdentifiers = ["ios.subscription.1month", "ios.subscription.1year",
"subscription.1month", "subscription.1year",
"review.subscription.1month", "review.subscription.1year",
"tf.sandbox.subscription.1month", "tf.sandbox.subscription.1year",
"ddg.privacy.pro.monthly.renews.us", "ddg.privacy.pro.yearly.renews.us"]
private let storeSubscriptionConfiguration: StoreSubscriptionConfiguration
private let subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache
private let subscriptionFeatureFlagger: FeatureFlaggerMapping<SubscriptionFeatureFlags>?

@Published public private(set) var availableProducts: [Product] = []
@Published public private(set) var purchasedProductIDs: [String] = []
@Published public private(set) var purchaseQueue: [String] = []

public var areProductsAvailable: Bool { !availableProducts.isEmpty }
public private(set) var currentStorefrontRegion: SubscriptionRegion = .usa
private var transactionUpdates: Task<Void, Never>?
private var storefrontChanges: Task<Void, Never>?

public init() {
public init(subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache,
subscriptionFeatureFlagger: FeatureFlaggerMapping<SubscriptionFeatureFlags>? = nil) {
self.storeSubscriptionConfiguration = DefaultStoreSubscriptionConfiguration()
self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache
self.subscriptionFeatureFlagger = subscriptionFeatureFlagger
transactionUpdates = observeTransactionUpdates()
storefrontChanges = observeStorefrontChanges()
}
Expand Down Expand Up @@ -109,17 +113,29 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
return nil
}

let options = [SubscriptionOption(id: monthly.id, cost: .init(displayPrice: monthly.displayPrice, recurrence: "monthly")),
SubscriptionOption(id: yearly.id, cost: .init(displayPrice: yearly.displayPrice, recurrence: "yearly"))]
let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }
let platform: SubscriptionPlatformName

let platform: SubscriptionPlatformName = {
#if os(iOS)
platform = .ios
.ios
#else
platform = .macos
.macos
#endif
return SubscriptionOptions(platform: platform.rawValue,
}()

let options = [SubscriptionOption(id: monthly.id,
cost: .init(displayPrice: monthly.displayPrice, recurrence: "monthly")),
SubscriptionOption(id: yearly.id,
cost: .init(displayPrice: yearly.displayPrice, recurrence: "yearly"))]

let features: [SubscriptionFeature]

if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) {
features = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(name: $0) }
} else {
let allFeatures: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration]
features = allFeatures.compactMap { SubscriptionFeature(name: $0) }
}

return SubscriptionOptions(platform: platform,
options: options,
features: features)
}
Expand All @@ -129,11 +145,36 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts")

do {
let availableProducts = try await Product.products(for: productIdentifiers)
Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products")
let storefrontCountryCode: String?
let storefrontRegion: SubscriptionRegion

if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) {
if let subscriptionFeatureFlagger, subscriptionFeatureFlagger.isFeatureOn(.usePrivacyProUSARegionOverride) {
storefrontCountryCode = "USA"
} else if let subscriptionFeatureFlagger, subscriptionFeatureFlagger.isFeatureOn(.usePrivacyProROWRegionOverride) {
storefrontCountryCode = "POL"
} else {
storefrontCountryCode = await Storefront.current?.countryCode
}

storefrontRegion = SubscriptionRegion.matchingRegion(for: storefrontCountryCode ?? "USA") ?? .usa // Fallback to USA
} else {
storefrontCountryCode = "USA"
storefrontRegion = .usa
}

self.currentStorefrontRegion = storefrontRegion
let applicableProductIdentifiers = storeSubscriptionConfiguration.subscriptionIdentifiers(for: storefrontRegion)
let availableProducts = try await Product.products(for: applicableProductIdentifiers)
Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products for \(storefrontCountryCode ?? "<nil>", privacy: .public)")

if self.availableProducts != availableProducts {
self.availableProducts = availableProducts

// Update cached subscription features mapping
for id in availableProducts.compactMap({ $0.id }) {
_ = await subscriptionFeatureMappingCache.subscriptionFeatures(for: id)
}
}
} catch {
Logger.subscription.error("[StorePurchaseManager] Error: \(String(reflecting: error), privacy: .public)")
Expand Down Expand Up @@ -295,3 +336,36 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
}
}
}

public extension UserDefaults {

enum Constants {
static let storefrontRegionOverrideKey = "Subscription.debug.storefrontRegionOverride"
static let usaValue = "usa"
static let rowValue = "row"
}

dynamic var storefrontRegionOverride: SubscriptionRegion? {
get {
switch string(forKey: Constants.storefrontRegionOverrideKey) {
case "usa":
return .usa
case "row":
return .restOfWorld
default:
return nil
}
}

set {
switch newValue {
case .usa:
set(Constants.usaValue, forKey: Constants.storefrontRegionOverrideKey)
case .restOfWorld:
set(Constants.rowValue, forKey: Constants.storefrontRegionOverrideKey)
default:
removeObject(forKey: Constants.storefrontRegionOverrideKey)
}
}
}
}
Loading
Loading