Skip to content

Commit

Permalink
Subscriptions - 21. Manage Billing options to third parties (#2574)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/0/1206397634707138/f

Description:

Allows the user to display third party billing information (Google/Stripe)
  • Loading branch information
afterxleep authored Mar 14, 2024
1 parent 8993b63 commit 415b75c
Show file tree
Hide file tree
Showing 12 changed files with 199 additions and 32 deletions.
4 changes: 4 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,7 @@
CBDD5DE129A6741300832877 /* MockBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDD5DE029A6741300832877 /* MockBundle.swift */; };
CBEFB9142AE0844700DEDE7B /* CriticalAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEFB9102ADFFE7900DEDE7B /* CriticalAlerts.swift */; };
CBFCB30E2B2CD47800253E9E /* ConfigurationURLDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFCB30D2B2CD47800253E9E /* ConfigurationURLDebugViewController.swift */; };
D60B1F272B9DDE5A00AE4760 /* SubscriptionGoogleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */; };
D61CDA162B7CF77300A0FBB9 /* Subscription in Frameworks */ = {isa = PBXBuildFile; productRef = D61CDA152B7CF77300A0FBB9 /* Subscription */; };
D61CDA182B7CF78300A0FBB9 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = D61CDA172B7CF78300A0FBB9 /* ZIPFoundation */; };
D63657192A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63657182A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift */; };
Expand Down Expand Up @@ -2455,6 +2456,7 @@
CBF14FC427970AB0001D94D0 /* HomeMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeMessageViewModel.swift; sourceTree = "<group>"; };
CBF14FC627970C8A001D94D0 /* HomeMessageCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeMessageCollectionViewCell.swift; sourceTree = "<group>"; };
CBFCB30D2B2CD47800253E9E /* ConfigurationURLDebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationURLDebugViewController.swift; sourceTree = "<group>"; };
D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionGoogleView.swift; sourceTree = "<group>"; };
D63657182A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmailManagerRequestDelegate.swift; sourceTree = "<group>"; };
D64648AC2B59936B0033090B /* SubscriptionEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionEmailView.swift; sourceTree = "<group>"; };
D64648AE2B5993890033090B /* SubscriptionEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionEmailViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4641,6 +4643,7 @@
D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */,
D6F93E3D2B50A8A0004C268D /* SubscriptionSettingsView.swift */,
D6D95CE02B6D52DA00960317 /* RootPresentationMode.swift */,
D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -6891,6 +6894,7 @@
1E908BF129827C480008C8F3 /* AutoconsentUserScript.swift in Sources */,
4B0295192537BC6700E00CEF /* ConfigurationDebugViewController.swift in Sources */,
1E7A71192934EC6100B7EA19 /* OmniBarNotificationContainerView.swift in Sources */,
D60B1F272B9DDE5A00AE4760 /* SubscriptionGoogleView.swift in Sources */,
984D035C24AE15CD0066CFB8 /* TabSwitcherSettings.swift in Sources */,
D6E83C562B21ECC1006C8AFB /* SettingsLegacyViewProvider.swift in Sources */,
98B31292218CCB8C00E54DE1 /* AppDependencyProvider.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ final class AsyncHeadlessWebViewViewModel: ObservableObject {
initialScrollPositionSubject.send(newPosition)
isFirstUpdate = false
} else {
subsequentScrollPositionSubject.send(newPosition)
DispatchQueue.main.async {
self.subsequentScrollPositionSubject.send(newPosition)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "google-play.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,7 @@ final class SubscriptionFlowViewModel: ObservableObject {
.receive(on: DispatchQueue.main)
.sink { [weak self] value in
guard let strongSelf = self else { return }

let shouldNavigateBack = value && (strongSelf.webViewModel.url?.lastPathComponent != URL.subscriptionBaseURL.lastPathComponent)
strongSelf.canNavigateBack = shouldNavigateBack
strongSelf.canNavigateBack = value
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,22 @@ import Core
@available(iOS 15.0, *)
final class SubscriptionSettingsViewModel: ObservableObject {

enum Constants {
static let monthlyProductID = "ios.subscription.1month"
static let yearlyProductID = "ios.subscription.1year"
static let updateFrequency: Float = 10
}

let accountManager: AccountManager
private var subscriptionUpdateTimer: Timer?
private var signOutObserver: Any?
private var subscriptionInfo: SubscriptionService.GetSubscriptionResponse?

@Published var subscriptionDetails: String = ""
@Published var subscriptionType: String = ""
@Published var shouldDisplayRemovalNotice: Bool = false
@Published var shouldDismissView: Bool = false
@Published var shouldDisplayGoogleView: Bool = false

// Used to display stripe WebUI
@Published var stripeViewModel: SubscriptionExternalLinkViewModel?
@Published var shouldDisplayStripeView: Bool = false
private var externalAllowedDomains = ["stripe.com"]


init(accountManager: AccountManager = AccountManager()) {
self.accountManager = accountManager
Expand All @@ -62,14 +64,33 @@ final class SubscriptionSettingsViewModel: ObservableObject {
let subscriptionResult = await SubscriptionService.getSubscription(accessToken: token, cachePolicy: cachePolicy)
switch subscriptionResult {
case .success(let subscription):
updateSubscriptionDetails(status: subscription.status, date: subscription.expiresOrRenewsAt, product: subscription.productId)
case .failure(let error):
subscriptionInfo = subscription
updateSubscriptionsStatusMessage(status: subscription.status,
date: subscription.expiresOrRenewsAt,
product: subscription.productId,
billingPeriod: subscription.billingPeriod)
case .failure:
AccountManager().signOut()
shouldDismissView = true
}
}
}

func manageSubscription() {
switch subscriptionInfo?.platform {
case .apple:
Task { await manageAppleSubscription() }
case .google:
manageGoogleSubscription()
case .stripe:
Task { await manageStripeSubscription() }
default:
return
}
}

// MARK: -

private func setupNotificationObservers() {
signOutObserver = NotificationCenter.default.addObserver(forName: .accountDidSignOut, object: nil, queue: .main) { [weak self] _ in
DispatchQueue.main.async {
Expand All @@ -88,12 +109,11 @@ final class SubscriptionSettingsViewModel: ObservableObject {
}
}
}


private func updateSubscriptionDetails(status: Subscription.Status, date: Date, product: String) {
private func updateSubscriptionsStatusMessage(status: Subscription.Status, date: Date, product: String, billingPeriod: Subscription.BillingPeriod) {
let statusString = (status == .autoRenewable) ? UserText.subscriptionRenews : UserText.subscriptionExpires
self.subscriptionDetails = UserText.subscriptionInfo(status: statusString, expiration: dateFormatter.string(from: date))
self.subscriptionType = product == Constants.monthlyProductID ? UserText.subscriptionMonthly : UserText.subscriptionAnnual
self.subscriptionType = billingPeriod == .monthly ? UserText.subscriptionMonthly : UserText.subscriptionAnnual
}

func removeSubscription() {
Expand All @@ -103,22 +123,46 @@ final class SubscriptionSettingsViewModel: ObservableObject {
presentationLocation: .withoutBottomBar)
}

func manageSubscription() {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
Task {
do {
try await AppStore.showManageSubscriptions(in: windowScene)
} catch {
openSubscriptionManagementURL()
}
}
@MainActor private func manageAppleSubscription() async {
let url = URL.manageSubscriptionsInAppStoreAppURL
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
do {
try await AppStore.showManageSubscriptions(in: windowScene)
} catch {
self.openURL(url)
}
} else {
self.openURL(url)
}
}

private func manageGoogleSubscription() {
shouldDisplayGoogleView = true
}

private func manageStripeSubscription() async {
guard let token = accountManager.accessToken, let externalID = accountManager.externalID else { return }
let serviceResponse = await SubscriptionService.getCustomerPortalURL(accessToken: token, externalID: externalID)

// Get Stripe Customer Portal URL and update the model
if case .success(let response) = serviceResponse {
guard let url = URL(string: response.customerPortalUrl) else { return }
if let existingModel = stripeViewModel {
existingModel.url = url
} else {
openSubscriptionManagementURL()
let model = SubscriptionExternalLinkViewModel(url: url, allowedDomains: externalAllowedDomains)
DispatchQueue.main.async {
self.stripeViewModel = model
}
}
}
DispatchQueue.main.async {
self.shouldDisplayStripeView = true
}
}

private func openSubscriptionManagementURL() {
let url = URL.manageSubscriptionsInAppStoreAppURL
@MainActor
private func openURL(_ url: URL) {
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct SubscriptionExternalLinkView: View {

@Environment(\.dismiss) var dismiss
@ObservedObject var viewModel: SubscriptionExternalLinkViewModel
@State var title: String?

enum Constants {
static let navButtonPadding: CGFloat = 20.0
Expand All @@ -47,6 +48,7 @@ struct SubscriptionExternalLinkView: View {
}
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
.navigationTitle(title ?? "")

.onAppear(perform: {
setUpAppearances()
Expand Down
1 change: 1 addition & 0 deletions DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ struct SubscriptionFlowView: View {
message: Text(UserText.subscriptionFoundText),
primaryButton: .cancel(Text(UserText.subscriptionFoundCancel)) {
viewModel.transactionError = nil
viewModel.finalizeSubscriptionFlow()
},
secondaryButton: .default(Text(UserText.subscriptionFoundRestore)) {
viewModel.restoreAppstoreTransaction()
Expand Down
66 changes: 66 additions & 0 deletions DuckDuckGo/Subscription/Views/SubscriptionGoogleView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// SubscriptionGoogleView.swift
// DuckDuckGo
//
// 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
import SwiftUI
#if SUBSCRIPTION
@available(iOS 15.0, *)

struct SubscriptionGoogleView: View {

enum Constants {
static let padding: CGFloat = 20.0
}

@Environment(\.dismiss) var dismiss

var body: some View {
ZStack {
Color(designSystemColor: .background)
.edgesIgnoringSafeArea(.all)
VStack(alignment: .center) {
Image("google-play").padding(.top, Constants.padding)

Text(UserText.subscriptionManageBillingGoogleText)
.daxSubheadRegular()
.foregroundColor(Color(designSystemColor: .textSecondary))
.multilineTextAlignment(.center)
.padding(Constants.padding)
Spacer()
}
}
.navigationBarTitle(UserText.subscriptionManageBillingGoogleTitle, displayMode: .inline)
.applyInsetGroupedListStyle()
}

}
#endif


#if SUBSCRIPTION && DEBUG
@available(iOS 15.0, *)

struct SubscriptionGoogleView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
SubscriptionGoogleView().navigationBarTitleDisplayMode(.inline)
}
}
}
#endif
23 changes: 18 additions & 5 deletions DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct SubscriptionSettingsView: View {
@StateObject var viewModel = SubscriptionSettingsViewModel()
@StateObject var sceneEnvironment = SceneEnvironment()
@State var isFirstOnAppear = true

@ViewBuilder
private var optionsView: some View {
List {
Expand Down Expand Up @@ -65,6 +65,12 @@ struct SubscriptionSettingsView: View {
isButton: true)
}

.sheet(isPresented: $viewModel.shouldDisplayStripeView) {
if let stripeViewModel = viewModel.stripeViewModel {
SubscriptionExternalLinkView(viewModel: stripeViewModel, title: UserText.subscriptionManagePlan)
}
}

Section(header: Text(UserText.subscriptionManageDevices)) {

NavigationLink(destination: SubscriptionRestoreView()) {
Expand Down Expand Up @@ -92,6 +98,10 @@ struct SubscriptionSettingsView: View {
})
}
}

NavigationLink(destination: SubscriptionGoogleView(), isActive: $viewModel.shouldDisplayGoogleView) {
EmptyView()
}
}
.navigationTitle(UserText.settingsPProManageSubscription)
.applyInsetGroupedListStyle()
Expand Down Expand Up @@ -122,6 +132,13 @@ struct SubscriptionSettingsView: View {
}
}

@ViewBuilder
private var stripeView: some View {
if let stripeViewModel = viewModel.stripeViewModel {
SubscriptionExternalLinkView(viewModel: stripeViewModel)
}
}

var body: some View {
Group {
if #available(iOS 16.0, *) {
Expand Down Expand Up @@ -151,10 +168,6 @@ struct SubscriptionSettingsView_Previews: PreviewProvider {
NavigationView {
SubscriptionSettingsView().navigationBarTitleDisplayMode(.inline)
}
// You can customize the preview environment here if needed.
// For example, you can set a specific device, size, or dark mode/light mode.
// .previewDevice(PreviewDevice(rawValue: "iPhone 12"))
// .preferredColorScheme(.dark)
}
}

Expand Down
4 changes: 4 additions & 0 deletions DuckDuckGo/UserText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,10 @@ But if you *do* want a peek under the hood, you can find more information about
public static let subscriptionBackendErrorMessage = NSLocalizedString("subscription.restore.backend.error.message", value: "We’re having trouble connecting. Please try again later.", comment: "Alert for general error message")
public static let subscriptionBackendErrorButton = NSLocalizedString("subscription.restore.backend.error.button", value: "Back to Settings", comment: "Button text for general error message")

public static let subscriptionManageBillingGoogleTitle = NSLocalizedString("subscription.billing.google.title", value: "Subscription Plans", comment: "Title for the manage billing page")
public static let subscriptionManageBillingGoogleText = NSLocalizedString("subscription.billing.google.text", value: "Your subscription was purchased through the Google Play Store. To renew your subscription, please open Google Play Store subscription settings on a device signed in to the same Google Account used to originally purchase your subscription.", comment: "Text for the manage billing page")


// PIR:
public static let subscriptionPIRHeroText = NSLocalizedString("subscription.pir.hero", value: "Activate Privacy Pro on desktop to set up Personal Information Removal", comment: "Hero Text for Personal information removal")
public static let subscriptionPIRHeroDetail = NSLocalizedString("subscription.pir.heroText", value: "In the DuckDuckGo browser for desktop, go to %@ and click %@ to get started.", comment: "Description on how to use Personal information removal in desktop. The first placeholder references a location in the Desktop application. <i.e: Settings > Privacy Pro>, and the second, the menu entry. i.e. <I have a Subscription>")
Expand Down
6 changes: 6 additions & 0 deletions DuckDuckGo/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -2037,6 +2037,12 @@ But if you *do* want a peek under the hood, you can find more information about
/* Subscription availability message on Apple devices */
"subscription.available.apple" = "Privacy Pro is available on any device signed in to the same Apple ID.";

/* Text for the manage billing page */
"subscription.billing.google.text" = "Your subscription was purchased through the Google Play Store. To renew your subscription, please open Google Play Store subscription settings on a device signed in to the same Google Account used to originally purchase your subscription.";

/* Title for the manage billing page */
"subscription.billing.google.title" = "Subscription Plans";

/* Subscription Removal confirmation message */
"subscription.cancel.message" = "Your subscription has been removed from this device.";

Expand Down

0 comments on commit 415b75c

Please sign in to comment.