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

import SafariServices

import SignalServiceKit
import SignalUI

private enum MySupportErrorState {
    case paymentProcessing(paymentMethod: DonationPaymentMethod?)

    case awaitingIDEALAuthorization

    case previouslyActiveSubscriptionLapsed(
        chargeFailureCode: String?,
        paymentMethod: DonationPaymentMethod?,
    )

    case paymentFailed(
        chargeFailureCode: String?,
        paymentMethod: DonationPaymentMethod?,
    )

    var tableCellSubtitle: String {
        switch self {
        case .paymentProcessing(paymentMethod: .sepa):
            return OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_TABLE_CELL_SUBTITLE_BANK_PAYMENT_PROCESSING",
                comment: "A label describing a donation payment that was made via bank transfer, which is still processing and has not completed.",
            )
        case .paymentProcessing:
            return OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_TABLE_CELL_SUBTITLE_NON_BANK_PAYMENT_PROCESSING",
                comment: "A label describing a donation payment that was made by a method other than bank transfer (such as by credit card), which is still processing and has not completed.",
            )
        case .previouslyActiveSubscriptionLapsed:
            return OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_TABLE_CELL_SUBTITLE_SUBSCRIPTION_LAPSED",
                comment: "A label describing a recurring monthly donation that used to be active, but has now been canceled because it failed to renew.",
            )
        case .paymentFailed:
            return OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_TABLE_CELL_SUBTITLE_PAYMENT_FAILED",
                comment: "A label describing a donation payment that has failed to process.",
            )
        case .awaitingIDEALAuthorization:
            return OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_TABLE_CELL_SUBTITLE_WAITING_FOR_AUTHORIZATION",
                comment: "A label describing a donation payment that has requires authorization.",
            )
        }
    }

    var shouldShowErrorIcon: Bool {
        switch self {
        case .previouslyActiveSubscriptionLapsed, .paymentFailed: return true
        case .paymentProcessing, .awaitingIDEALAuthorization: return false
        }
    }
}

extension DonationSettingsViewController {
    private var logger: PrefixedLogger { PrefixedLogger(prefix: "[DSVC]") }

    func mySupportSection(
        subscriptionStatus: State.SubscriptionStatus,
        profileBadgeLookup: ProfileBadgeLookup,
        oneTimeBoostReceiptCredentialRequestError: DonationReceiptCredentialRequestError?,
        pendingOneTimeDonation: PendingOneTimeIDEALDonation?,
        hasAnyBadges: Bool,
    ) -> OWSTableSection? {
        let section = OWSTableSection(title: OWSLocalizedString(
            "DONATION_VIEW_MY_SUPPORT_TITLE",
            comment: "Title for the 'my support' section in the donation view",
        ))

        switch subscriptionStatus {
        case .loadFailed:
            section.add(.label(withText: OWSLocalizedString(
                "DONATION_VIEW_LOAD_FAILED",
                comment: "Text that's shown when the donation view fails to load data, probably due to network failure",
            )))
        case .noSubscription:
            break
        case let .pendingSubscription(pendingDonation):
            if
                let recurringSubscriptionTableItem = mySupportRecurringSubscriptionTableItem(
                    subscriptionType: .pendingAuthorization(pendingDonation),
                    subscriptionBadge: pendingDonation.newSubscriptionLevel.badge,
                    previouslyHadActiveSubscription: pendingDonation.oldSubscriptionLevel != nil,
                    receiptCredentialRequestError: nil,
                )
            {
                section.add(recurringSubscriptionTableItem)
            }
        case let .hasSubscription(
            subscription,
            subscriptionLevel,
            previouslyHadActiveSubscription,
            receiptCredentialRequestError,
        ):
            if
                let recurringSubscriptionTableItem = mySupportRecurringSubscriptionTableItem(
                    subscriptionType: .subscription(subscription),
                    subscriptionBadge: subscriptionLevel?.badge,
                    previouslyHadActiveSubscription: previouslyHadActiveSubscription,
                    receiptCredentialRequestError: receiptCredentialRequestError,
                )
            {
                section.add(recurringSubscriptionTableItem)
            }
        }

        if
            let oneTimeBoostItem = mySupportOneTimeBoostTableItem(
                boostBadge: profileBadgeLookup.boostBadge,
                pendingOneTimeIDEALDonation: pendingOneTimeDonation,
                receiptCredentialRequestError: oneTimeBoostReceiptCredentialRequestError,
            )
        {
            section.add(oneTimeBoostItem)
        }

        if hasAnyBadges {
            section.add(.disclosureItem(
                icon: .donateBadges,
                withText: OWSLocalizedString("DONATION_VIEW_MANAGE_BADGES", comment: "Title for the 'Badges' button on the donation screen"),
                actionBlock: { [weak self] in
                    guard let self else { return }
                    let vc = SSKEnvironment.shared.databaseStorageRef.read { tx in
                        return BadgeConfigurationViewController.load(delegate: self, tx: tx)
                    }
                    self.navigationController?.pushViewController(vc, animated: true)
                },
            ))
        }

        guard section.itemCount > 0 else {
            return nil
        }

        return section
    }

    private enum RecurringSubscriptionTableItemType {
        case subscription(Subscription)
        case pendingAuthorization(PendingMonthlyIDEALDonation)
    }

    private func mySupportRecurringSubscriptionTableItem(
        subscriptionType: RecurringSubscriptionTableItemType,
        subscriptionBadge: ProfileBadge?,
        previouslyHadActiveSubscription: Bool,
        receiptCredentialRequestError: DonationReceiptCredentialRequestError?,
    ) -> OWSTableItem? {
        let errorState: MySupportErrorState? = {
            switch subscriptionType {
            case .pendingAuthorization:
                return .awaitingIDEALAuthorization

            case .subscription(let subscription):
                if
                    let receiptCredentialRequestError,
                    receiptCredentialRequestError.errorCode == .paymentStillProcessing,
                    subscription.status == .canceled
                {
                    /// The receipt credential redemption job may have run out
                    /// of retries while the payment was still processing,
                    /// leaving us with that persisted error. If the
                    /// subscription is now canceled, though we know the payment
                    /// never went through, and we should show as much.
                    ///
                    /// - Note This should no longer be possible, as the job in
                    /// question no longer runs out of retries.
                    return .paymentFailed(
                        chargeFailureCode: nil,
                        paymentMethod: receiptCredentialRequestError.paymentMethod,
                    )
                } else if let receiptCredentialRequestError {
                    logger.warn("Recurring subscription with receipt credential request error! \(receiptCredentialRequestError)")

                    return receiptCredentialRequestError.mySupportErrorState(
                        previouslyHadActiveSubscription: previouslyHadActiveSubscription,
                    )
                } else {
                    if subscription.isPaymentProcessing {
                        switch subscription.status {
                        case .pastDue:
                            /// Payments in `.pastDue` will be processing, but
                            /// we won't have tried to redeem a receipt credential
                            /// for them so it's expected we won't have a
                            /// corresponding error.
                            break
                        case .active, .canceled, .unrecognized:
                            logger.warn("Subscription is processing, but we don't have a receipt credential request error about it!")
                        }
                    }

                    if subscription.chargeFailure != nil {
                        logger.warn("Subscription has charge failure, but we don't have a receipt credential request error about it!")
                    }

                    switch subscription.status {
                    case .active:
                        return nil
                    case .pastDue:
                        // Don't treat a subscription with a failed renewal as failed
                        // for the purposes of this view – it may yet succeed!
                        return nil
                    case .canceled:
                        /// This is weird, but could apply to subscriptions that
                        /// were canceled due to charge failures before we used
                        /// `ReceiptCredentialRequestError`s to track failures.
                        logger.warn("Subscription is canceled, but we don't have a receipt credential request error about it!")

                        if
                            let chargeFailure = subscription.chargeFailure,
                            previouslyHadActiveSubscription
                        {
                            return .previouslyActiveSubscriptionLapsed(
                                chargeFailureCode: chargeFailure.code,
                                paymentMethod: subscription.donationPaymentMethod,
                            )
                        } else if let chargeFailure = subscription.chargeFailure {
                            return .paymentFailed(
                                chargeFailureCode: chargeFailure.code,
                                paymentMethod: subscription.donationPaymentMethod,
                            )
                        }

                        return nil
                    case .unrecognized:
                        // Not sure what's going on here, but we don't want to show a
                        // subscription with an unexpected status.
                        logger.error("Unexpected subscription status: \(subscription.status)")
                        return nil
                    }
                }
            }
        }()

        let pricingTitle: String = {
            let pricingFormat = OWSLocalizedString(
                "SUSTAINER_VIEW_PRICING",
                comment: "Pricing text for sustainer view badges, embeds {{price}}",
            )

            let amount = {
                switch subscriptionType {
                case .pendingAuthorization(let donation):
                    return donation.amount
                case .subscription(let subscription):
                    return subscription.amount
                }
            }()

            let currencyString = CurrencyFormatter.format(money: amount)

            return String.nonPluralLocalizedStringWithFormat(pricingFormat, currencyString)
        }()

        let statusSubtitle: String = {
            if let errorState {
                return errorState.tableCellSubtitle
            }

            switch subscriptionType {
            case .subscription(let subscription):
                let renewalFormat = OWSLocalizedString(
                    "SUSTAINER_VIEW_RENEWAL",
                    comment: "Renewal date text for sustainer view level, embeds {{renewal date}}",
                )

                let dateFormatter = DateFormatter()
                dateFormatter.dateStyle = .medium
                let dateArg = dateFormatter.string(from: subscription.endOfCurrentPeriod)

                return String.nonPluralLocalizedStringWithFormat(renewalFormat, dateArg)
            case .pendingAuthorization:
                owsFailDebug("Should not be displaying a subscription message for a donation pending authorization.")
                return OWSLocalizedString(
                    "SUSTAINER_VIEW_PROCESSING_PAYMENT",
                    comment: "Loading indicator on the sustainer view",
                )
            }
        }()

        let errorIconView: UIView? = {
            if let errorState, errorState.shouldShowErrorIcon {
                return mySupportErrorIconView()
            }

            return nil
        }()

        return OWSTableItem(
            customCellBlock: { () -> UITableViewCell in
                return OWSTableItem.buildImageCell(
                    image: subscriptionBadge?.assets?.universal160,
                    itemName: pricingTitle,
                    subtitle: statusSubtitle,
                    accessoryType: .disclosureIndicator,
                    accessoryContentView: errorIconView,
                )
            },
            actionBlock: { [weak self] () -> Void in
                guard let self else { return }

                guard let errorState else {
                    self.showDonateViewController(preferredDonateMode: .monthly)
                    return
                }

                switch errorState {
                case let .previouslyActiveSubscriptionLapsed(chargeFailureCode, paymentMethod):
                    self.presentRecurringSubscriptionLapsedActionSheet(
                        chargeFailureCode: chargeFailureCode,
                        paymentMethod: paymentMethod,
                    )
                case let .paymentProcessing(paymentMethod):
                    self.presentPaymentProcessingActionSheet(
                        paymentMethod: paymentMethod,
                        donateMode: .monthly,
                    )
                case .awaitingIDEALAuthorization:
                    self.presentAwaitingIDEALAuthorizationSheet(
                        donateMode: .monthly,
                    )
                case let .paymentFailed(chargeFailureCode, paymentMethod):
                    self.presentDonationFailedActionSheet(
                        chargeFailureCode: chargeFailureCode,
                        paymentMethod: paymentMethod,
                        preferredDonateMode: .monthly,
                    )
                }
            },
        )
    }

    private func mySupportOneTimeBoostTableItem(
        boostBadge: ProfileBadge?,
        pendingOneTimeIDEALDonation: PendingOneTimeIDEALDonation?,
        receiptCredentialRequestError: DonationReceiptCredentialRequestError?,
    ) -> OWSTableItem? {

        var amount: FiatMoney?
        var errorState: MySupportErrorState?

        // We don't show anything for one-time boosts unless there's an
        // error.
        if let receiptCredentialRequestError {
            amount = receiptCredentialRequestError.amount

            logger.info("Showing boost error. \(receiptCredentialRequestError)")

            errorState = receiptCredentialRequestError.mySupportErrorState(
                previouslyHadActiveSubscription: false,
            )
        } else if let pendingOneTimeIDEALDonation {
            amount = pendingOneTimeIDEALDonation.amount
            errorState = .awaitingIDEALAuthorization
        }

        guard let errorState, let amount else { return nil }

        return OWSTableItem(
            customCellBlock: { [weak self] () -> UITableViewCell in
                guard let self else { return UITableViewCell() }

                let pricingTitle: String = {
                    let pricingFormat = OWSLocalizedString(
                        "DONATION_SETTINGS_ONE_TIME_AMOUNT_FORMAT",
                        comment: "A string describing the amount and currency of a one-time payment. Embeds {{ the amount, formatted as a currency }}.",
                    )

                    return String.nonPluralLocalizedStringWithFormat(
                        pricingFormat,
                        CurrencyFormatter.format(money: amount),
                    )
                }()

                return OWSTableItem.buildImageCell(
                    image: boostBadge?.assets?.universal160,
                    itemName: pricingTitle,
                    subtitle: errorState.tableCellSubtitle,
                    accessoryType: .disclosureIndicator,
                    accessoryContentView: errorState.shouldShowErrorIcon ? self.mySupportErrorIconView() : nil,
                )
            },
            actionBlock: { [weak self] () -> Void in
                guard let self else { return }

                switch errorState {
                case .previouslyActiveSubscriptionLapsed:
                    owsFail("Impossible for one-time boost!")
                case let .paymentProcessing(paymentMethod):
                    self.presentPaymentProcessingActionSheet(
                        paymentMethod: paymentMethod,
                        donateMode: .oneTime,
                    )
                case .awaitingIDEALAuthorization:
                    self.presentAwaitingIDEALAuthorizationSheet(
                        donateMode: .oneTime,
                    )
                case let .paymentFailed(chargeFailureCode, paymentMethod):
                    self.presentDonationFailedActionSheet(
                        chargeFailureCode: chargeFailureCode,
                        paymentMethod: paymentMethod,
                        preferredDonateMode: .oneTime,
                    )
                }
            },
        )
    }

    private func presentPaymentProcessingActionSheet(
        paymentMethod: DonationPaymentMethod?,
        donateMode: DonateViewController.DonateMode,
    ) {
        let actionSheet: ActionSheetController

        switch paymentMethod {
        case nil, .applePay, .creditOrDebitCard, .paypal:
            actionSheet = DonationViewsUtil.nonBankPaymentStillProcessingActionSheet()
        case .sepa, .ideal:
            actionSheet = ActionSheetController(
                title: OWSLocalizedString(
                    "DONATION_SETTINGS_MY_SUPPORT_BANK_PAYMENT_PROCESSING_TITLE",
                    comment: "Title for an alert explaining that a one-time payment made via bank transfer is being processed.",
                ),
                message: OWSLocalizedString(
                    "DONATION_SETTINGS_MY_SUPPORT_BANK_PAYMENT_PROCESSING_MESSAGE",
                    comment: "Message for an alert explaining that a one-time payment made via bank transfer is being processed.",
                ),
            )

            actionSheet.addAction(ActionSheetAction(
                title: CommonStrings.learnMore,
                handler: { [weak self] _ in
                    guard let self else { return }

                    self.present(
                        SFSafariViewController(url: URL.Support.Donations.donationPending),
                        animated: true,
                    )
                },
            ))
            actionSheet.addAction(OWSActionSheets.okayAction)
        }

        self.presentActionSheet(actionSheet, animated: true)
    }

    private func presentAwaitingIDEALAuthorizationSheet(
        donateMode: DonateViewController.DonateMode,
    ) {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_DONATION_UNCONFIMRED_ALERT_TITLE",
                comment: "Title for a sheet explaining that a payment needs confirmation.",
            ),
            message: OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_BANK_PAYMENT_AWAITING_AUTHORIZATION_MESSAGE",
                comment: "Prompt the user asking if they want to keep the current in-flight, but unauthorized donation, or try again.",
            ),
        )
        actionSheet.addAction(OWSActionSheets.okayAction)
        actionSheet.addAction(ActionSheetAction(
            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.",
            ),
            handler: { [weak self] _ in
                guard let self else { return }
                self.presentAwaitingIDEALAuthorizationActionSheet(donateMode: donateMode)
            },
        ))
        self.presentActionSheet(actionSheet, animated: true)
    }

    private func presentDonationFailedActionSheet(
        chargeFailureCode: String?,
        paymentMethod: DonationPaymentMethod?,
        preferredDonateMode: DonateViewController.DonateMode,
    ) {
        let actionSheetMessage: String = {
            let messageFormat: String = OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_DONATION_FAILED_ALERT_MESSAGE_FORMAT",
                comment: "Message shown in a sheet explaining that the user's donation has failed because payment failed. Embeds {{ a specific, already-localized string describing the payment failure reason }}.",
            )

            let (chargeFailureString, _) = DonationViewsUtil.localizedDonationFailure(
                chargeErrorCode: chargeFailureCode,
                paymentMethod: paymentMethod,
            )

            return String.nonPluralLocalizedStringWithFormat(messageFormat, chargeFailureString)
        }()

        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_DONATION_FAILED_ALERT_TITLE",
                comment: "Title for a sheet explaining that a payment failed.",
            ),
            message: actionSheetMessage,
        )

        switch preferredDonateMode {
        case .monthly:
            actionSheet.addAction(showDonateAndCancelSubscriptionAction(title: .tryAgain))
        case .oneTime:
            actionSheet.addAction(showOneTimeDonateAndClearErrorAction(title: .tryAgain))
        }
        actionSheet.addAction(OWSActionSheets.cancelAction)

        self.presentActionSheet(actionSheet, animated: true)
    }

    private func presentRecurringSubscriptionLapsedActionSheet(
        chargeFailureCode: String?,
        paymentMethod: DonationPaymentMethod?,
    ) {
        let actionSheetMessage: String = {
            let messageFormat = OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_RECURRING_SUBSCRIPTION_LAPSED_CHARGE_FAILURE_ALERT_MESSAGE_FORMAT",
                comment: "Message shown in a sheet explaining that the user's recurring subscription has ended because payment failed. Embeds {{ a specific, already-localized string describing the failure reason }}.",
            )

            let (chargeFailureString, _) = DonationViewsUtil.localizedDonationFailure(
                chargeErrorCode: chargeFailureCode,
                paymentMethod: paymentMethod,
            )

            return String.nonPluralLocalizedStringWithFormat(messageFormat, chargeFailureString)
        }()

        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "DONATION_SETTINGS_MY_SUPPORT_RECURRING_SUBSCRIPTION_LAPSED_TITLE",
                comment: "Title for a sheet explaining that the user's recurring subscription has ended because payment failed.",
            ),
            message: actionSheetMessage,
        )

        actionSheet.addAction(showDonateAndCancelSubscriptionAction(title: .renewSubscription))
        actionSheet.addAction(OWSActionSheets.cancelAction)

        self.presentActionSheet(actionSheet, animated: true)
    }

    private enum ShowDonateActionTitle {
        case renewSubscription
        case tryAgain

        var localizedTitle: String {
            switch self {
            case .renewSubscription:
                return OWSLocalizedString(
                    "DONATION_SETTINGS_MY_SUPPORT_ACTION_SHEET_ACTION_TITLE_RENEW_SUBSCRIPTION",
                    comment: "Title for an action in an action sheet asking the user to renew a subscription that has failed to renew.",
                )
            case .tryAgain:
                return OWSLocalizedString(
                    "DONATION_SETTINGS_MY_SUPPORT_ACTION_SHEET_ACTION_TITLE_TRY_AGAIN",
                    comment: "Title for an action in an action sheet asking the user to try again, in reference to a donation that failed.",
                )
            }
        }
    }

    private func showOneTimeDonateAndClearErrorAction(title: ShowDonateActionTitle) -> ActionSheetAction {
        clearErrorAndShowDonateAction(title: title.localizedTitle, donateMode: .oneTime) { tx in
            DependenciesBridge.shared.donationReceiptCredentialResultStore
                .clearRequestError(errorMode: .oneTimeBoost, tx: tx)
        }
    }

    private func showDonateAndCancelSubscriptionAction(title: ShowDonateActionTitle) -> ActionSheetAction {
        return ActionSheetAction(title: title.localizedTitle) { _ in
            Task.detached {
                let subscriberId = SSKEnvironment.shared.databaseStorageRef.read { tx in
                    return DonationSubscriptionManager.getSubscriberID(transaction: tx)
                }
                if let subscriberId {
                    try await DonationSubscriptionManager.cancelSubscription(for: subscriberId)
                }
                await self.loadAndUpdateState()
                await self.showDonateViewController(preferredDonateMode: .monthly)
            }
        }
    }

    private func mySupportErrorIconView() -> UIView {
        let imageView = UIImageView.withTemplateImageName(
            "error-circle",
            tintColor: .ows_accentRed,
        )
        imageView.autoPinToSquareAspectRatio()

        return imageView
    }
}

private extension DonationReceiptCredentialRequestError {
    func mySupportErrorState(
        previouslyHadActiveSubscription: Bool,
    ) -> MySupportErrorState {
        switch errorCode {
        case .paymentFailed:
            if previouslyHadActiveSubscription {
                return .previouslyActiveSubscriptionLapsed(
                    chargeFailureCode: chargeFailureCodeIfPaymentFailed,
                    paymentMethod: paymentMethod,
                )
            }

            return .paymentFailed(
                chargeFailureCode: chargeFailureCodeIfPaymentFailed,
                paymentMethod: paymentMethod,
            )
        case .paymentStillProcessing:
            return .paymentProcessing(paymentMethod: paymentMethod)
        case
            .localValidationFailed,
            .serverValidationFailed,
            .paymentNotFound,
            .paymentIntentRedeemed:
            // This isn't quite the right thing to do, since the payment isn't
            // the thing that failed. However, it should be super rare for us to
            // get into this state – we could alternatively add a "generic
            // error" case for us to fall back on.
            return .paymentFailed(
                chargeFailureCode: nil,
                paymentMethod: paymentMethod,
            )
        }
    }
}