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,
)
}
}
}