Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/Donations/DonationViewsUtil+IDEAL.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import SignalServiceKit
import SignalUI
import UIKit

extension DonationViewsUtil {

    /// If the donation can't be continued, build back up the donation UI and attempt to complete the donation.
    @MainActor
    static func restartAndCompleteInterruptedIDEALDonation(
        type donationType: Stripe.IDEALCallbackType,
        rootViewController: UIViewController,
        databaseStorage: SDSDatabaseStorage,
        appReadiness: AppReadinessSetter,
    ) async throws {
        let donationStore = DependenciesBridge.shared.externalPendingIDEALDonationStore
        let (success, intent, localIntent) = databaseStorage.read { tx in
            switch donationType {
            case let .oneTime(didSucceed: success, paymentIntentId: intentId):
                let localIntentId = donationStore.getPendingOneTimeDonation(tx: tx)
                return (success, intentId, localIntentId?.paymentIntentId)
            case let .monthly(didSucceed: success, _, setupIntentId: intentId):
                let localIntentId = donationStore.getPendingSubscription(tx: tx)
                return (success, intentId, localIntentId?.setupIntentId)
            }
        }

        if rootViewController.presentedViewController != nil {
            await rootViewController.awaitableDismiss(animated: false)
        }

        guard let frontVc = CurrentAppContext().frontmostViewController() else {
            return
        }

        // Build up the Donation UI
        let appSettings = AppSettingsViewController.inModalNavigationController(appReadiness: appReadiness)
        let donationsVC = DonationSettingsViewController()
        donationsVC.showExpirationSheet = false
        appSettings.viewControllers += [donationsVC]

        await frontVc.awaitablePresentFormSheet(appSettings, animated: false)

        if success, let localIntent, intent == localIntent {
            try await Self.completeDonation(
                type: donationType,
                from: donationsVC,
                databaseStorage: databaseStorage,
            )
        } else {
            Self.handleIDEALDonationIssue(
                success: success,
                donationType: donationType,
                from: donationsVC,
                databaseStorage: databaseStorage,
            )
        }
    }

    /// Attempts to seamlessly continue the donation, if the app state is still at the appropriate step in the iDEAL donation flow.
    ///
    /// - Returns:
    /// `true` if the donation was continued by previously-constructed UI.
    /// `false` otherwise,  in which case the caller is responsible for "reconstructing" the appropriate step in the
    /// donation flow and continuing the donation.
    @MainActor
    static func attemptToContinueActiveIDEALDonation(
        type donationType: Stripe.IDEALCallbackType,
        databaseStorage: SDSDatabaseStorage,
    ) async -> Bool {
        // Inspect this view controller to find out if the layout is as expected.
        guard
            let frontVC = CurrentAppContext().frontmostViewController(),
            let navController = frontVC.presentingViewController as? UINavigationController,
            let vc = navController.viewControllers.last,
            let donationPaymentVC = vc as? DonationPaymentDetailsViewController,
            donationPaymentVC.threeDSecureAuthenticationSession != nil
        else {
            // Not in the expected donation flow, so revert to building
            // the donation view stack from scratch
            return false
        }

        await frontVC.awaitableDismiss(animated: true)

        let (success, intentId) = {
            switch donationType {
            case
                let .oneTime(success, intent),
                let .monthly(success, _, intent):
                return (success, intent)
            }
        }()

        // Attempt to slide back into the current donation flow by completing
        // the active 3DS session with the intent.  If the payment was externally
        // failed, pass that into the existing donation flow to be handled inline
        return donationPaymentVC.completeExternal3DS(
            success: success,
            intentID: intentId,
        )
    }

    @MainActor
    private static func completeDonation(
        type donationType: Stripe.IDEALCallbackType,
        from donationsVC: DonationSettingsViewController,
        databaseStorage: SDSDatabaseStorage,
    ) async throws {
        let badge = try await Self.loadBadgeForDonation(type: donationType, databaseStorage: databaseStorage)

        defer {
            // refresh the local state upon completing the donation
            // to refresh any pending donation messages
            Task { await donationsVC.loadAndUpdateState() }
        }

        do {
            try await DonationViewsUtil.wrapInProgressView(
                from: donationsVC,
                operation: {
                    try await DonationViewsUtil.completeIDEALDonation(
                        donationType: donationType,
                        databaseStorage: databaseStorage,
                    )
                },
            )
            // Do this after the `wrapPromiseInProgressView` completes
            // to dismiss the progress spinner.  Then display the
            // result of the donation.
            let badgeThanksSheetPresenter = BadgeThanksSheetPresenter.fromGlobalsWithSneakyTransaction(
                successMode: donationType.asSuccessMode,
            )

            Task {
                await badgeThanksSheetPresenter?.presentAndRecordBadgeThanks(
                    fromViewController: donationsVC,
                )
            }
        } catch {
            if let badge {
                DonationViewsUtil.presentErrorSheet(
                    from: donationsVC,
                    error: error,
                    mode: donationType.asDonationMode,
                    badge: badge,
                    paymentMethod: .ideal,
                )
            } else {
                owsFailDebug("[Donations] Failed to load donation badge")
            }
            throw error
        }
    }

    @MainActor
    private static func handleIDEALDonationIssue(
        success: Bool,
        donationType: Stripe.IDEALCallbackType,
        from donationsVC: DonationSettingsViewController,
        databaseStorage: SDSDatabaseStorage,
    ) {
        let clearPendingDonation = { @MainActor in
            let idealStore = DependenciesBridge.shared.externalPendingIDEALDonationStore
            databaseStorage.write { tx in
                switch donationType {
                case .monthly:
                    idealStore.clearPendingSubscription(tx: tx)
                case .oneTime:
                    idealStore.clearPendingOneTimeDonation(tx: tx)
                }
            }
        }

        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_DONATION_FAILED_ALERT_TITLE",
                comment: "Title for a sheet explaining that a payment failed.",
            ),
            message: OWSLocalizedString(
                "DONATION_REDIRECT_ERROR_PAYMENT_DENIED_MESSAGE",
                comment: "Error message displayed if something goes wrong with 3DSecure/iDEAL payment authorization.  This will be encountered if the user denies the payment.",
            ),
        )
        actionSheet.addAction(.init(title: CommonStrings.okButton, style: .default, handler: { _ in
            if !success {
                // Failing a donation will cause it to fail on the Stripe
                // side no matter what, so clear it out before presenting
                clearPendingDonation()
            }
        }))
        actionSheet.addAction(
            .init(
                title: OWSLocalizedString(
                    "DONATION_BADGE_ISSUE_SHEET_TRY_AGAIN_BUTTON_TITLE",
                    comment: "Title for a button asking the user to try their donation again, because something went wrong.",
                ),
                style: .default,
                handler: { _ in
                    clearPendingDonation()
                    donationsVC.showDonateViewController(preferredDonateMode: donationType.asDonationMode)
                },
            ),
        )

        if let frontVc = CurrentAppContext().frontmostViewController() {
            frontVc.presentActionSheet(actionSheet, animated: true)
        }
    }

    private static func loadBadgeForDonation(
        type donationType: Stripe.IDEALCallbackType,
        databaseStorage: SDSDatabaseStorage,
    ) async throws -> ProfileBadge? {
        switch donationType {
        case .oneTime:
            switch try await DonationSubscriptionManager.getCachedBadge(level: .boostBadge).fetchIfNeeded().awaitable() {
            case .notFound:
                return nil
            case let .profileBadge(profileBadge):
                return profileBadge
            }
        case .monthly:
            let donationStore = DependenciesBridge.shared.externalPendingIDEALDonationStore
            let monthlyDonation = databaseStorage.read { tx in
                return donationStore.getPendingSubscription(tx: tx)
            }
            guard let monthlyDonation else {
                return nil
            }
            try await SSKEnvironment.shared.profileManagerRef.badgeStore.populateAssetsOnBadge(monthlyDonation.newSubscriptionLevel.badge)
            return monthlyDonation.newSubscriptionLevel.badge
        }
    }
}

private extension Stripe.IDEALCallbackType {

    var asSuccessMode: DonationReceiptCredentialResultStore.Mode {
        switch self {
        case .oneTime: return .oneTimeBoost
        case .monthly: return .recurringSubscriptionInitiation
        }
    }

    var asDonationMode: DonateViewController.DonateMode {
        switch self {
        case .oneTime: return .oneTime
        case .monthly: return .monthly
        }
    }
}