From be9a59ac843a1d319a5f9f0354695258ce6518b6 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 20 Nov 2023 11:01:04 -0800 Subject: [PATCH 1/4] Add support for Apple Pay --- Apps/OneBusAway/project.yml | 11 ++- OBAKit/Donations/DonationModel.swift | 79 +++++++++++++++++-- .../Extensions/FoundationExtensions.swift | 40 ++++++++-- 3 files changed, 113 insertions(+), 17 deletions(-) diff --git a/Apps/OneBusAway/project.yml b/Apps/OneBusAway/project.yml index 6492effb..91caff0c 100644 --- a/Apps/OneBusAway/project.yml +++ b/Apps/OneBusAway/project.yml @@ -64,16 +64,19 @@ targets: AppGroup: group.org.onebusaway.iphone BundledRegionsFileName: regions.json DeepLinkServerBaseAddress: https://onebusaway.co - DonationsEnabled: true + Donations: + Enabled: true + ApplePayMerchantID: merchant.org.onebusaway.iphone + DonationManagementPortal: "https://onebusaway.co/manage-donations" + StripePublishableKeys: + test: pk_test_51O20R8BsTfxG3EeliQswtDI6etwUu6cdYX7AIT2HkyI7iMbI2Ltqz0R06EkM7SmRTYJfOPwum1iTJgrJpoLoB8ii00eUXFwHjT + production: pk_live_51O20R8BsTfxG3EelVtkEOa5zmx9MPoAXi2Jrcx8PvBUwYvKlXEJdL3nYHF7Vmd1UZ6tKyrZrKUx89bwMmSaN5HIr0096SVUJts ExtensionURLScheme: onebusaway PrivacyPolicyURL: https://onebusaway.org/privacy/ PushNotificationAPIKey: d5d0d28a-6091-46cd-9627-0ce01ffa9f9e RESTServerAPIKey: org.onebusaway.iphone RegionsServerBaseAddress: https://regions.onebusaway.org RegionsServerAPIPath: /regions-v3.json - StripePublishableKeys: - test: pk_test_51O20R8BsTfxG3EeliQswtDI6etwUu6cdYX7AIT2HkyI7iMbI2Ltqz0R06EkM7SmRTYJfOPwum1iTJgrJpoLoB8ii00eUXFwHjT - production: pk_live_51O20R8BsTfxG3EelVtkEOa5zmx9MPoAXi2Jrcx8PvBUwYvKlXEJdL3nYHF7Vmd1UZ6tKyrZrKUx89bwMmSaN5HIr0096SVUJts settings: base: DEVELOPMENT_TEAM: 4ZQCMA634J diff --git a/OBAKit/Donations/DonationModel.swift b/OBAKit/Donations/DonationModel.swift index 8edeb786..12863fca 100644 --- a/OBAKit/Donations/DonationModel.swift +++ b/OBAKit/Donations/DonationModel.swift @@ -8,6 +8,7 @@ import StripePaymentSheet import SwiftUI import OBAKitCore +import PassKit extension PaymentSheetResult: Equatable { public static func == (lhs: PaymentSheetResult, rhs: PaymentSheetResult) -> Bool { @@ -37,6 +38,23 @@ class DonationModel: ObservableObject { @MainActor func donate(_ amountInCents: Int, recurring: Bool) async { analytics?.reportEvent?(.userAction, label: AnalyticsLabels.donateButtonTapped, value: String(amountInCents)) + + let paymentSheet = PaymentSheet( + intentConfiguration: buildIntentConfiguration(amountInCents, recurring: recurring), + configuration: buildPaymentSheetConfiguration(amountInCents, recurring: recurring) + ) + + guard let presenter = UIApplication.shared.keyWindowFromScene?.topViewController else { + fatalError() + } + + paymentSheet.present(from: presenter) { [weak self] result in + self?.result = result + self?.donationComplete = true + } + } + + private func buildIntentConfiguration(_ amountInCents: Int, recurring: Bool) -> PaymentSheet.IntentConfiguration { let mode = PaymentSheet.IntentConfiguration.Mode.payment( amount: amountInCents, currency: "USD", @@ -48,29 +66,76 @@ class DonationModel: ObservableObject { await strongSelf?.handleConfirm(amountInCents, recurring, intentCreationCallback) } } + return intentConfig + } + private func buildPaymentSheetConfiguration(_ amountInCents: Int, recurring: Bool) -> PaymentSheet.Configuration { var configuration = PaymentSheet.Configuration() configuration.billingDetailsCollectionConfiguration.name = .always configuration.billingDetailsCollectionConfiguration.email = .always + configuration.applePay = buildApplePayConfiguration(amountInCents, recurring: recurring) if let extensionURLScheme = Bundle.main.extensionURLScheme { configuration.returnURL = "\(extensionURLScheme)://stripe-redirect" } - let paymentSheet = PaymentSheet(intentConfiguration: intentConfig, configuration: configuration) + return configuration + } - guard let presenter = UIApplication.shared.keyWindowFromScene?.topViewController else { - fatalError() + private func buildApplePayConfiguration(_ amountInCents: Int, recurring: Bool) -> PaymentSheet.ApplePayConfiguration? { + guard + let merchantID = Bundle.main.applePayMerchantID, + let managementURL = Bundle.main.donationManagementPortal + else { + return nil } - paymentSheet.present(from: presenter) { [weak self] result in - self?.result = result - self?.donationComplete = true + let recurringHandlers: PaymentSheet.ApplePayConfiguration.Handlers? + + if recurring { + recurringHandlers = PaymentSheet.ApplePayConfiguration.Handlers( + paymentRequestHandler: { request in + let applePayLabel = OBALoc( + "donations.apple_pay.recurring_donation_label", + value: "OneBusAway Recurring Donation", + comment: "Required label for Apple Pay for a recurring donation" + ) + let billing = PKRecurringPaymentSummaryItem( + label: applePayLabel, + amount: NSDecimalNumber(decimal: Decimal(amountInCents) / 100) + ) + + // Payment starts today + billing.startDate = Date() + + // Pay once a month. + billing.intervalUnit = .month + billing.intervalCount = 1 + + request.recurringPaymentRequest = PKRecurringPaymentRequest( + paymentDescription: applePayLabel, + regularBilling: billing, + managementURL: managementURL + ) + request.paymentSummaryItems = [billing] + + return request + } + ) } + else { + recurringHandlers = nil + } + + return PaymentSheet.ApplePayConfiguration( + merchantId: merchantID, + merchantCountryCode: "US", + customHandlers: recurringHandlers + ) } - func handleConfirm(_ amountInCents: Int, _ recurring: Bool, _ intentCreationCallback: @escaping (Result) -> Void) async { + private func handleConfirm(_ amountInCents: Int, _ recurring: Bool, _ intentCreationCallback: @escaping (Result) -> Void) async { do { let intent = try await obacoService.postCreatePaymentIntent(donationAmountInCents: amountInCents, recurring: recurring) intentCreationCallback(.success(intent.clientSecret)) diff --git a/OBAKitCore/Extensions/FoundationExtensions.swift b/OBAKitCore/Extensions/FoundationExtensions.swift index 5242ad90..9ca811c0 100644 --- a/OBAKitCore/Extensions/FoundationExtensions.swift +++ b/OBAKitCore/Extensions/FoundationExtensions.swift @@ -45,19 +45,28 @@ public extension Bundle { return URL(string: str) } - /// A helper method for accessing the bundle's `DonationsEnabled` + /// A helper method for accessing the bundle's `Donations.Enabled` setting var donationsEnabled: Bool { - guard let dict = OBAKitConfig, let val = dict["DonationsEnabled"] as? Bool else { + guard let dict = donationsConfig, let val = dict["Enabled"] as? Bool else { return false } return val } - /// A helper method for accessing the bundle's `StripePublishableKey.production` value, if defined. + /// A helper method for accessing the bundle's `Donations.ApplePayMerchantID` setting. + var applePayMerchantID: String? { + guard let dict = donationsConfig else { + return nil + } + + return dict["ApplePayMerchantID"] as? String + } + + /// A helper method for accessing the bundle's `Donations.StripePublishableKey.production` value, if defined. var stripePublishableProductionKey: String? { guard - let dict = OBAKitConfig, + let dict = donationsConfig, let keys = dict["StripePublishableKeys"] as? [String: String] else { return nil @@ -66,10 +75,10 @@ public extension Bundle { return keys["production"] } - /// A helper method for accessing the bundle's `StripePublishableKeys.test` value, if defined. + /// A helper method for accessing the bundle's `Donations.StripePublishableKeys.test` value, if defined. var stripePublishableTestKey: String? { guard - let dict = OBAKitConfig, + let dict = donationsConfig, let keys = dict["StripePublishableKeys"] as? [String: String] else { return nil @@ -78,6 +87,17 @@ public extension Bundle { return keys["test"] } + var donationManagementPortal: URL? { + guard + let dict = donationsConfig, + let urlString = dict["DonationManagementPortal"] as? String + else { + return nil + } + + return URL(string: urlString) + } + /// A helper method for accessing the bundle's `ExtensionURLScheme`. /// /// `extensionURLScheme` is used as an `init()` parameter on `URLSchemeRouter`. @@ -142,6 +162,14 @@ public extension Bundle { optionalValue(for: "OBAKitConfig", type: [AnyHashable: Any].self) } + private var donationsConfig: [AnyHashable: Any]? { + guard let OBAKitConfig else { + return nil + } + + return OBAKitConfig["Donations"] as? [AnyHashable: Any] + } + private func url(for key: String) -> URL? { guard let address = optionalValue(for: key, type: String.self) else { return nil } return URL(string: address) From fc75708d4663281a8a1c9d9d39a9a414a08c3a0a Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 20 Nov 2023 11:09:13 -0800 Subject: [PATCH 2/4] Show a "Donations" section in the More tab --- OBAKit/Settings/MoreViewController.swift | 25 +++++++++++++++++++++ OBAKitCore/Donations/DonationsManager.swift | 4 ++++ 2 files changed, 29 insertions(+) diff --git a/OBAKit/Settings/MoreViewController.swift b/OBAKit/Settings/MoreViewController.swift index d63af284..93f8b07d 100644 --- a/OBAKit/Settings/MoreViewController.swift +++ b/OBAKit/Settings/MoreViewController.swift @@ -68,6 +68,7 @@ public class MoreViewController: UIViewController, public func items(for listView: OBAListView) -> [OBAListViewSection] { return [ headerSection, + donateSection, updatesAndAlertsSection, myLocationSection, helpOutSection, @@ -80,6 +81,30 @@ public class MoreViewController: UIViewController, return OBAListViewSection(id: "header", contents: [MoreHeaderItem()]) } + // MARK: Donate section + var donateSection: OBAListViewSection? { + guard application.donationsManager.donationsEnabled else { return nil } + + let header = OBALoc( + "more_controller.donate", + value: "Donate to OneBusAway", + comment: "Header for the donate section." + ) + + return OBAListViewSection(id: "donate", title: header, contents: [ + OBAListRowView.DefaultViewModel( + title: OBALoc( + "more_controller.donate_description", + value: "Your support helps us improve OneBusAway", + comment: "The call to action for the More controller's donate buton"), + onSelectAction: { _ in + let url = URL(string: "https://www.transifex.com/open-transit-software-foundation/onebusaway-ios/")! + self.application.open(url, options: [:], completionHandler: nil) + } + ) + ]) + } + // MARK: Updates and alerts section var updatesAndAlertsSection: OBAListViewSection { let header = OBALoc( diff --git a/OBAKitCore/Donations/DonationsManager.swift b/OBAKitCore/Donations/DonationsManager.swift index aafd448b..b9b95c9c 100644 --- a/OBAKitCore/Donations/DonationsManager.swift +++ b/OBAKitCore/Donations/DonationsManager.swift @@ -65,6 +65,10 @@ public class DonationsManager { // MARK: - State + public var donationsEnabled: Bool { + bundle.donationsEnabled + } + /// When true, it means the app should show an inline donation request UI. public var shouldRequestDonations: Bool { if !bundle.donationsEnabled { return false } From 9a06c110cb16a5c4db629af016f76cb76a7d32b1 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 20 Nov 2023 11:11:42 -0800 Subject: [PATCH 3/4] Move DonationsManager into OBAKit --- {OBAKitCore => OBAKit}/Donations/DonationsManager.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {OBAKitCore => OBAKit}/Donations/DonationsManager.swift (100%) diff --git a/OBAKitCore/Donations/DonationsManager.swift b/OBAKit/Donations/DonationsManager.swift similarity index 100% rename from OBAKitCore/Donations/DonationsManager.swift rename to OBAKit/Donations/DonationsManager.swift From e6fecb674ade663c85aedd98a794d7795bb0724b Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 20 Nov 2023 11:23:44 -0800 Subject: [PATCH 4/4] Add a donation UI to the more controller --- OBAKit/Donations/DonationsManager.swift | 13 +++++++++++++ OBAKit/Settings/MoreViewController.swift | 21 ++++++++++++++++++--- OBAKit/Stops/StopViewController.swift | 8 +------- OBAKitCore/Strings/Strings.swift | 4 ++++ 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/OBAKit/Donations/DonationsManager.swift b/OBAKit/Donations/DonationsManager.swift index b9b95c9c..028a3488 100644 --- a/OBAKit/Donations/DonationsManager.swift +++ b/OBAKit/Donations/DonationsManager.swift @@ -6,6 +6,7 @@ // import Foundation +import OBAKitCore /// Manages the visibility of donation requests. public class DonationsManager { @@ -79,4 +80,16 @@ public class DonationsManager { return donationRequestDismissedDate == nil } + + // MARK: - UI + + public static func buildDonationThankYouAlert() -> UIAlertController { + let alert = UIAlertController( + title: Strings.donationThankYouTitle, + message: Strings.donationThankYouBody, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.dismiss, style: .default)) + + return alert + } } diff --git a/OBAKit/Settings/MoreViewController.swift b/OBAKit/Settings/MoreViewController.swift index 93f8b07d..beab3369 100644 --- a/OBAKit/Settings/MoreViewController.swift +++ b/OBAKit/Settings/MoreViewController.swift @@ -97,14 +97,29 @@ public class MoreViewController: UIViewController, "more_controller.donate_description", value: "Your support helps us improve OneBusAway", comment: "The call to action for the More controller's donate buton"), - onSelectAction: { _ in - let url = URL(string: "https://www.transifex.com/open-transit-software-foundation/onebusaway-ios/")! - self.application.open(url, options: [:], completionHandler: nil) + onSelectAction: { [weak self] _ in + self?.showDonationUI() } ) ]) } + private func showDonationUI() { + guard let obacoService = application.obacoService else { return } + let donationModel = DonationModel(obacoService: obacoService, analytics: application.analytics) + let learnMoreView = DonationLearnMoreView { [weak self] donated in + guard donated else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self?.present(DonationsManager.buildDonationThankYouAlert(), animated: true) + } + } + .environmentObject(donationModel) + .environmentObject(AnalyticsModel(application.analytics)) + + present(UIHostingController(rootView: learnMoreView), animated: true) + } + // MARK: Updates and alerts section var updatesAndAlertsSection: OBAListViewSection { let header = OBALoc( diff --git a/OBAKit/Stops/StopViewController.swift b/OBAKit/Stops/StopViewController.swift index 974d8748..6e343ae3 100644 --- a/OBAKit/Stops/StopViewController.swift +++ b/OBAKit/Stops/StopViewController.swift @@ -650,14 +650,8 @@ public class StopViewController: UIViewController, let learnMoreView = DonationLearnMoreView { [weak self] donated in guard donated else { return } - let alert = UIAlertController( - title: OBALoc("donations.thank_you_alert.title", value: "Thank you for your support!", comment: "The title of the alert shown when the user donates."), - message: nil, - preferredStyle: .alert) - alert.addAction(UIAlertAction(title: Strings.dismiss, style: .default)) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self?.present(alert, animated: true) + self?.present(DonationsManager.buildDonationThankYouAlert(), animated: true) self?.application.donationsManager.dismissDonationsRequests() self?.refresh() } diff --git a/OBAKitCore/Strings/Strings.swift b/OBAKitCore/Strings/Strings.swift index b8de5b6e..1ecc0791 100644 --- a/OBAKitCore/Strings/Strings.swift +++ b/OBAKitCore/Strings/Strings.swift @@ -31,6 +31,10 @@ public class Strings: NSObject { public static let donate = OBALoc("common.donate", value: "Donate", comment: "The verb 'to donate', as in to give money to a charitable cause.") + public static let donationThankYouTitle = OBALoc("common.donation_thank_you.title", value: "Thank you for your support!", comment: "Title of an alert to display to the user after they donate to OTSF") + + public static let donationThankYouBody = OBALoc("common.donation_thank_you.body", value: "Your support will help us continue to improve OneBusAway.", comment: "The body of the message to display to the user after they donate to OTSF") + public static let confirmDelete = OBALoc("common.confirmdelete", value: "Confirm Delete", comment: "Asks the user to confirm a delete action. Typically this is showned when the user taps a 'delete' button on an important item.") public static let dismiss = OBALoc("common.dismiss", value: "Dismiss", comment: "The verb 'to dismiss', as in to hide or get rid of something. Used as a button title on alerts.")