Path: blob/main/Signal/src/ViewControllers/Donations/DonationPaymentDetailsViewController.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import AuthenticationServices
import SignalServiceKit
import SignalUI
class DonationPaymentDetailsViewController: OWSTableViewController2 {
enum IDEALPaymentType {
case oneTime
case recurring(mandate: Stripe.PaymentMethod.Mandate)
}
enum PaymentMethod {
case card
case sepa(mandate: Stripe.PaymentMethod.Mandate)
case ideal(paymentType: IDEALPaymentType)
fileprivate var stripePaymentMethod: OWSRequestFactory.StripePaymentMethod {
switch self {
case .card:
return .card
case .sepa, .ideal:
return .bankTransfer(.sepa)
}
}
}
let donationAmount: FiatMoney
let donationMode: DonationMode
let paymentMethod: PaymentMethod
let onFinished: (Error?) -> Void
var threeDSecureAuthenticationSession: ASWebAuthenticationSession?
var threeDSecureAuthenticationFuture: Future<String>?
override var preferredNavigationBarStyle: OWSNavigationBarStyle { .solid }
override var navbarBackgroundColorOverride: UIColor? { tableBackgroundColor }
init(
donationAmount: FiatMoney,
donationMode: DonationMode,
paymentMethod: PaymentMethod,
onFinished: @escaping (Error?) -> Void,
) {
self.donationAmount = donationAmount
self.donationMode = donationMode
self.paymentMethod = paymentMethod
self.onFinished = onFinished
super.init()
switch paymentMethod {
case .card:
title = OWSLocalizedString(
"PAYMENT_DETAILS_CARD_TITLE",
comment: "Header title for card payment details screen",
)
case .sepa, .ideal:
title = OWSLocalizedString(
"PAYMENT_DETAILS_BANK_TITLE",
comment: "Header title for bank payment details screen",
)
}
}
deinit {
threeDSecureAuthenticationSession?.cancel()
}
// MARK: - View callbacks
override func viewDidLoad() {
super.viewDidLoad()
shouldAvoidKeyboard = true
render()
let sections = [donationAmountSection] + formSections()
contents = OWSTableContents(sections: sections)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
switch paymentMethod {
case .card:
cardNumberView.becomeFirstResponder()
case .sepa:
ibanView.becomeFirstResponder()
case .ideal:
break
}
}
// MARK: - Events
private func didSubmit() {
dismissKeyboard()
switch formState {
case .invalid, .potentiallyValid:
owsFailDebug("[Donations] It should be impossible to submit the form without a fully-valid card. Is the submit button properly disabled?")
case let .fullyValid(validForm):
switch donationMode {
case .oneTime:
oneTimeDonation(with: validForm)
case let .monthly(
subscriptionLevel,
subscriberID,
_,
currentSubscriptionLevel,
):
monthlyDonation(
with: validForm,
newSubscriptionLevel: subscriptionLevel,
priorSubscriptionLevel: currentSubscriptionLevel,
subscriberID: subscriberID,
)
case let .gift(thread, messageText):
switch validForm {
case let .card(creditOrDebitCard):
giftDonation(with: creditOrDebitCard, in: thread, messageText: messageText)
case .sepa, .ideal:
owsFailDebug("Gift badges do not support bank transfers")
}
}
}
}
private func dismissKeyboard() {
[
cardNumberView,
expirationView,
cvvView,
ibanView,
nameView,
emailView,
].forEach { formFieldView in
formFieldView.resignFirstResponder()
}
}
// MARK: - Rendering
private func render() {
// We'd like a link that doesn't go anywhere, because we'd like to
// handle the tapping ourselves. We use a "fake" URL because BonMot
// needs one.
let linkPart = StringStyle.Part.link(URL.Support.Donations.subscriptionFAQ)
let subheaderText: String
switch self.paymentMethod {
case .card:
subheaderText = OWSLocalizedString(
"CARD_DONATION_SUBHEADER_TEXT",
comment: "On the credit/debit card donation screen, a small amount of information text is shown. This is that text. It should (1) instruct users to enter their credit/debit card information (2) tell them that Signal does not collect or store their personal information.",
)
case .sepa, .ideal:
subheaderText = OWSLocalizedString(
"BANK_DONATION_SUBHEADER_TEXT",
comment: "On the bank transfer donation screen, a small amount of information text is shown. This is that text. It should (1) instruct users to enter their bank information (2) tell them that Signal does not collect or store their personal information.",
)
}
subheaderTextView.attributedText = .composed(of: [
subheaderText,
" ",
OWSLocalizedString(
"CARD_DONATION_SUBHEADER_LEARN_MORE",
comment: "On the credit/debit card donation screen, a small amount of information text is shown. Users can click this link to learn more information.",
).styled(with: linkPart),
]).styled(with: .color(.Signal.secondaryLabel), .font(.dynamicTypeFootnoteClamped))
subheaderTextView.linkTextAttributes = [.foregroundColor: UIColor.Signal.label]
// Only change the placeholder when enough digits are entered.
// Helps avoid a jittery UI as you type/delete.
let rawNumber = cardNumberView.text
let cardType = CreditAndDebitCards.cardType(ofNumber: rawNumber)
if rawNumber.count >= 2 {
cvvView.placeholder = String("1234".prefix(cardType.cvvCount))
}
let invalidFields: Set<InvalidFormField>
switch formState {
case let .invalid(fields):
invalidFields = fields
submitButton.isEnabled = false
case .potentiallyValid:
invalidFields = []
submitButton.isEnabled = false
case .fullyValid:
invalidFields = []
submitButton.isEnabled = true
}
tableView.beginUpdates()
cardNumberView.render(errorMessage: {
guard invalidFields.contains(.cardNumber) else { return nil }
return OWSLocalizedString(
"CARD_DONATION_CARD_NUMBER_GENERIC_ERROR",
comment: "Users can donate to Signal with a credit or debit card. If their card number is invalid, this generic error message will be shown. Try to use a short string to make space in the UI.",
)
}())
expirationView.render(errorMessage: {
guard invalidFields.contains(.expirationDate) else { return nil }
return OWSLocalizedString(
"CARD_DONATION_EXPIRATION_DATE_GENERIC_ERROR",
comment: "Users can donate to Signal with a credit or debit card. If their expiration date is invalid, this generic error message will be shown. Try to use a short string to make space in the UI.",
)
}())
cvvView.render(errorMessage: {
guard invalidFields.contains(.cvv) else { return nil }
if cvvView.text.count > cardType.cvvCount {
return OWSLocalizedString(
"CARD_DONATION_CVV_TOO_LONG_ERROR",
comment: "Users can donate to Signal with a credit or debit card. If their card verification code (CVV) is too long, this error will be shown. Try to use a short string to make space in the UI.",
)
} else {
return OWSLocalizedString(
"CARD_DONATION_CVV_GENERIC_ERROR",
comment: "Users can donate to Signal with a credit or debit card. If their card verification code (CVV) is invalid for reasons we cannot determine, this generic error message will be shown. Try to use a short string to make space in the UI.",
)
}
}())
ibanView.render(errorMessage: ibanErrorMessage(invalidFields: invalidFields))
// Currently, name and email can only be valid or potentially
// valid. There is no invalid state for either.
tableView.endUpdates()
}
private func ibanErrorMessage(invalidFields: Set<InvalidFormField>) -> String? {
invalidFields.lazy
.compactMap { field -> SEPABankAccounts.IBANInvalidity? in
guard case let .iban(invalidity) = field else { return nil }
return invalidity
}
.first
.map { invalidity in
switch invalidity {
case .invalidCharacters:
return OWSLocalizedString(
"SEPA_DONATION_IBAN_INVALID_CHARACTERS_ERROR",
comment: "Users can donate to Signal with a bank account. If their internation bank account number (IBAN) contains characters other than letters and numbers, this error will be shown. Try to use a short string to make space in the UI.",
)
case .invalidCheck:
return OWSLocalizedString(
"SEPA_DONATION_IBAN_INVALID_CHECK_ERROR",
comment: "Users can donate to Signal with a bank account. If their internation bank account number (IBAN) does not pass validation, this error will be shown. Try to use a short string to make space in the UI.",
)
case .invalidCountry:
return OWSLocalizedString(
"SEPA_DONATION_IBAN_INVALID_COUNTRY_ERROR",
comment: "Users can donate to Signal with a bank account. If their internation bank account number (IBAN) has an unsupported country code, this error will be shown. Try to use a short string to make space in the UI.",
)
case .tooLong:
return OWSLocalizedString(
"SEPA_DONATION_IBAN_TOO_LONG_ERROR",
comment: "Users can donate to Signal with a bank account. If their internation bank account number (IBAN) is too long, this error will be shown. Try to use a short string to make space in the UI.",
)
case .tooShort:
return OWSLocalizedString(
"SEPA_DONATION_IBAN_TOO_SHORT_ERROR",
comment: "Users can donate to Signal with a bank account. If their internation bank account number (IBAN) is too long, this error will be shown. Try to use a short string to make space in the UI.",
)
}
}
}
// MARK: - Donation amount section
private lazy var subheaderTextView: LinkingTextView = {
let result = LinkingTextView()
result.delegate = self
return result
}()
private lazy var donationAmountSection: OWSTableSection = {
let result = OWSTableSection(
items: [.init(
customCellBlock: { [weak self] in
let cell = OWSTableItem.newCell()
cell.selectionStyle = .none
guard let self else { return cell }
cell.contentView.addSubview(self.subheaderTextView)
self.subheaderTextView.autoPinEdgesToSuperviewMargins()
return cell
},
)],
)
result.hasBackground = false
return result
}()
// MARK: - Form
private var formState: FormState {
switch self.paymentMethod {
case .card:
return Self.formState(
cardNumber: cardNumberView.text,
isCardNumberFieldFocused: cardNumberView.isFirstResponder,
expirationDate: expirationView.text,
cvv: cvvView.text,
)
case let .sepa(mandate: mandate):
return Self.formState(
mandate: mandate,
iban: ibanView.text,
isIBANFieldFocused: ibanView.isFirstResponder,
name: nameView.text,
email: emailView.text,
isEmailFieldFocused: emailView.isFirstResponder,
)
case let .ideal(paymentType):
return Self.formState(
IDEALPaymentType: paymentType,
name: nameView.text,
email: emailView.text,
isEmailFieldFocused: emailView.isFirstResponder,
)
}
}
private func formSections() -> [OWSTableSection] {
switch self.paymentMethod {
case .card:
return [creditCardFormSection]
case .sepa:
return [sepaFormSection]
case .ideal:
return idealFormSections()
}
}
private static func cell(for formFieldView: FormFieldView) -> OWSTableItem {
.init(customCellBlock: { [weak formFieldView] in
let cell = OWSTableItem.newCell()
cell.selectionStyle = .none
guard let formFieldView else { return cell }
cell.contentView.addSubview(formFieldView)
formFieldView.autoPinEdgesToSuperviewMargins()
return cell
})
}
// MARK: Form field title strings
private static let cardNumberTitle = OWSLocalizedString(
"CARD_DONATION_CARD_NUMBER_LABEL",
comment: "Users can donate to Signal with a credit or debit card. This is the label for the card number field on that screen.",
)
private static let cardNumberPlaceholder = "0000000000000000"
private static let expirationTitle = OWSLocalizedString(
"CARD_DONATION_EXPIRATION_DATE_LABEL",
comment: "Users can donate to Signal with a credit or debit card. This is the label for the expiration date field on that screen. Try to use a short string to make space in the UI. (For example, the English text uses \"Exp. Date\" instead of \"Expiration Date\").",
)
private static let cvvTitle = OWSLocalizedString(
"CARD_DONATION_CVV_LABEL",
comment: "Users can donate to Signal with a credit or debit card. This is the label for the card verification code (CVV) field on that screen.",
)
private static let ibanTitle = OWSLocalizedString(
"SEPA_DONATION_IBAN_LABEL",
comment: "Users can donate to Signal with a bank account. This is the label for IBAN (internation bank account number) field on that screen.",
)
private static let ibanPlaceholder = "DE00000000000000000000"
private static let nameTitle = OWSLocalizedString(
"SEPA_DONATION_NAME_LABEL",
comment: "Users can donate to Signal with a bank account. This is the label for name field on that screen.",
)
private static let emailTitle = OWSLocalizedString(
"SEPA_DONATION_EMAIL_LABEL",
comment: "Users can donate to Signal with a bank account. This is the label for email field on that screen.",
)
// MARK: Form field title styles
private lazy var cardFormTitleLayout: FormFieldView.TitleLayout = titleLayout(
for: [
Self.cardNumberTitle,
Self.expirationTitle,
Self.cvvTitle,
],
titleWidth: 120,
placeholder: Self.formatCardNumber(unformatted: Self.cardNumberPlaceholder),
)
private lazy var sepaFormTitleLayout: FormFieldView.TitleLayout = titleLayout(
for: [
Self.ibanTitle,
Self.nameTitle,
Self.emailTitle,
],
titleWidth: 60,
placeholder: Self.formatIBAN(unformatted: Self.ibanPlaceholder),
)
private func titleLayout(for titles: [String], titleWidth: CGFloat, placeholder: String) -> FormFieldView.TitleLayout {
guard
Self.canTitlesFitInWidth(titles: titles, width: titleWidth),
self.canPlaceholderFitInAvailableWidth(
placeholder: placeholder,
headerWidth: titleWidth,
)
else { return .compact }
return .inline(width: titleWidth)
}
private static func canTitlesFitInWidth(titles: [String], width: CGFloat) -> Bool {
titles.allSatisfy { title in
FormFieldView.titleAttributedString(title).size().width <= width
}
}
private func canPlaceholderFitInAvailableWidth(placeholder: String, headerWidth: CGFloat) -> Bool {
let placeholderTextWidth = NSAttributedString(string: placeholder, attributes: [.font: FormFieldView.textFieldFont]).size().width
let insets = self.cellOuterInsets.totalWidth + Self.cellHInnerMargin * 2
let totalWidth = placeholderTextWidth + insets + headerWidth + FormFieldView.titleSpacing
return totalWidth <= self.view.width
}
// MARK: - Card form
private lazy var creditCardFormSection = OWSTableSection(items: [
Self.cell(for: self.cardNumberView),
Self.cell(for: self.expirationView),
Self.cell(for: self.cvvView),
])
// MARK: Card number
nonisolated static func formatCardNumber(unformatted: String) -> String {
var gaps: Set<Int>
switch CreditAndDebitCards.cardType(ofNumber: unformatted) {
case .americanExpress: gaps = [4, 10]
case .unionPay, .other: gaps = [4, 8, 12]
}
var result = [Character]()
for (i, character) in unformatted.enumerated() {
if gaps.contains(i) {
result.append(" ")
}
result.append(character)
}
if gaps.contains(unformatted.count) {
result.append(" ")
}
return String(result)
}
private lazy var cardNumberView = FormFieldView(
title: Self.cardNumberTitle,
titleLayout: self.cardFormTitleLayout,
placeholder: Self.formatCardNumber(unformatted: Self.cardNumberPlaceholder),
style: .formatted(
format: Self.formatCardNumber(unformatted:),
allowedCharacters: .numbers,
maxDigits: 19,
),
textContentType: .creditCardNumber,
delegate: self,
)
// MARK: Expiration date
nonisolated static func formatExpirationDate(unformatted: String) -> String {
switch unformatted.count {
case 0:
return unformatted
case 1:
let firstDigit = unformatted.first!
switch firstDigit {
case "0", "1": return unformatted
default: return unformatted + "/"
}
case 2:
if (UInt8(unformatted) ?? 0).isValidAsMonth {
return unformatted + "/"
} else {
return "\(unformatted.prefix(1))/\(unformatted.suffix(1))"
}
default:
let firstTwo = unformatted.prefix(2)
let firstTwoAsMonth = UInt8(String(firstTwo)) ?? 0
let monthCount = firstTwoAsMonth.isValidAsMonth ? 2 : 1
let month = unformatted.prefix(monthCount)
let year = unformatted.suffix(unformatted.count - monthCount)
return "\(month)/\(year)"
}
}
private lazy var expirationView = {
let textContentType: UITextContentType?
if #available(iOS 17.0, *) {
textContentType = .creditCardExpiration
} else {
textContentType = nil
}
return FormFieldView(
title: Self.expirationTitle,
titleLayout: self.cardFormTitleLayout,
placeholder: OWSLocalizedString(
"CARD_DONATION_EXPIRATION_DATE_PLACEHOLDER",
comment: "Users can donate to Signal with a credit or debit card. This is the label for the card expiration date field on that screen.",
),
style: .formatted(
format: Self.formatExpirationDate(unformatted:),
allowedCharacters: .numbers,
maxDigits: 4,
),
textContentType: textContentType,
delegate: self,
)
}()
// MARK: CVV
private lazy var cvvView = {
let textContentType: UITextContentType?
if #available(iOS 17.0, *) {
textContentType = .creditCardSecurityCode
} else {
textContentType = nil
}
return FormFieldView(
title: Self.cvvTitle,
titleLayout: self.cardFormTitleLayout,
placeholder: "123",
style: .formatted(
format: { $0 },
allowedCharacters: .numbers,
maxDigits: 4,
),
textContentType: textContentType,
delegate: self,
)
}()
// MARK: - SEPA form
private lazy var sepaFormSection = {
let section = OWSTableSection(items: [
Self.cell(for: self.ibanView),
Self.cell(for: self.nameView),
Self.cell(for: self.emailView),
])
let findAccountInfoButton = UIButton(
configuration: .mediumBorderless(title: OWSLocalizedString(
"BANK_DONATION_FOOTER_FIND_ACCOUNT_INFO",
comment: "On the bank donation screen, show a link below the input form to show help about finding account info.",
)),
primaryAction: UIAction { [weak self] _ in
self?.present(DonationPaymentDetailsFindAccountInfoSheetViewController(), animated: true)
},
)
let stackView = UIStackView.verticalButtonStack(buttons: [findAccountInfoButton])
stackView.directionalLayoutMargins.top = 16
section.customFooterView = stackView
return section
}()
// MARK: IBAN
private nonisolated static func formatIBAN(unformatted: String) -> String {
let gaps: Set<Int> = [4, 8, 12, 16, 20, 24, 28, 32]
var result = unformatted.enumerated().reduce(into: [Character]()) { partialResult, item in
let (i, character) = item
if gaps.contains(i) {
partialResult.append(" ")
}
partialResult.append(character)
}
if gaps.contains(unformatted.count) {
result.append(" ")
}
return String(result)
}
private lazy var ibanView: FormFieldView = FormFieldView(
title: Self.ibanTitle,
titleLayout: self.sepaFormTitleLayout,
placeholder: Self.formatIBAN(unformatted: Self.ibanPlaceholder),
style: .formatted(
format: Self.formatIBAN(unformatted:),
allowedCharacters: .alphanumeric,
maxDigits: 34,
),
textContentType: nil,
delegate: self,
)
// MARK: iDEAL
private func idealFormSections() -> [OWSTableSection] {
let textViewItems: [OWSTableItem]
switch self.donationMode {
case .oneTime, .gift:
textViewItems = [Self.cell(for: self.nameView)]
case .monthly:
textViewItems = [Self.cell(for: self.nameView), Self.cell(for: self.emailView)]
}
return [OWSTableSection(items: textViewItems)]
}
// MARK: Name & Email
private lazy var nameView = FormFieldView(
title: Self.nameTitle,
titleLayout: self.sepaFormTitleLayout,
placeholder: OWSLocalizedString(
"SEPA_DONATION_NAME_PLACEHOLDER",
comment: "Users can donate to Signal with a bank account. This is placeholder text for the name field before the user starts typing.",
),
style: .plain(keyboardType: .default),
textContentType: .name,
delegate: self,
)
private lazy var emailView = FormFieldView(
title: Self.emailTitle,
titleLayout: self.sepaFormTitleLayout,
placeholder: OWSLocalizedString(
"SEPA_DONATION_EMAIL_PLACEHOLDER",
comment: "Users can donate to Signal with a bank account. This is placeholder text for the email field before the user starts typing.",
),
style: .plain(keyboardType: .emailAddress),
textContentType: .emailAddress,
delegate: self,
)
// MARK: - Submit button, footer
private lazy var submitButton: UIButton = {
let amountString = CurrencyFormatter.format(money: self.donationAmount)
let title = {
let format: String
switch self.donationMode {
case .oneTime, .gift:
format = OWSLocalizedString(
"DONATE_BUTTON",
comment: "Users can donate to Signal with a credit or debit card. This is the heading on that screen, telling them how much they'll donate. Embeds {{formatted amount of money}}, such as \"$20\".",
)
case .monthly:
format = OWSLocalizedString(
"DONATE_BUTTON_MONTHLY",
comment: "Users can donate to Signal with a credit or debit card. This is the heading on that screen, telling them how much they'll donate every month. Embeds {{formatted amount of money}}, such as \"$20\".",
)
}
return String.nonPluralLocalizedStringWithFormat(format, amountString)
}()
return UIButton(
configuration: .largePrimary(title: title),
primaryAction: UIAction { [weak self] _ in
guard let self else { return }
let submitAction = {
self.didSubmit()
}
switch self.paymentMethod {
case .card, .sepa:
submitAction()
case .ideal:
switch self.donationMode {
case .oneTime, .gift:
submitAction()
case .monthly:
let title = OWSLocalizedString(
"IDEAL_DONATION_CONFIRM_DONATION_TITLE",
comment: "Fallback title confirming recurring donation with bank.",
)
let messageFormat = OWSLocalizedString(
"IDEAL_DONATION_CONFIRM_DONATION_WITH_BANK_MESSAGE",
comment: "Message confirming recurring donation with bank. This message confirms with the user that they will see a small confirmation charge with their bank before the donation. Embeds 1:{{ 0.01 euro, as a localized string }}, 2:{{ the amount of their donation, as a localized string }}.",
)
let oneEuroCentString = CurrencyFormatter.format(
money: FiatMoney(currencyCode: "EUR", value: 0.01),
)
let message = String.nonPluralLocalizedStringWithFormat(messageFormat, oneEuroCentString, amountString)
let actionSheet = ActionSheetController(title: title, message: message)
actionSheet.addAction(.init(
title: CommonStrings.continueButton,
style: .default,
handler: { _ in
submitAction()
},
))
actionSheet.addAction(.init(
title: CommonStrings.cancelButton,
style: .cancel,
handler: nil,
))
self.presentActionSheet(actionSheet)
}
}
},
)
}()
private lazy var bottomFooterContainer: UIView = {
let stackView = UIStackView.verticalButtonStack(buttons: [submitButton])
let view = UIView()
view.preservesSuperviewLayoutMargins = true
view.addSubview(stackView)
view.backgroundColor = .Signal.groupedBackground
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: view.topAnchor),
stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
return view
}()
override open var bottomFooter: UIView? {
get { bottomFooterContainer }
set {}
}
}
// MARK: - UITextViewDelegate
extension DonationPaymentDetailsViewController: UITextViewDelegate {
func textView(
_ textView: UITextView,
shouldInteractWith URL: URL,
in characterRange: NSRange,
interaction: UITextItemInteraction,
) -> Bool {
present(DonationPaymentDetailsReadMoreSheetViewController(), animated: true)
return false
}
}
// MARK: - CreditOrDebitCardDonationFormViewDelegate
extension DonationPaymentDetailsViewController: CreditOrDebitCardDonationFormViewDelegate {
func didSomethingChange() { render() }
}
// MARK: - Utilities
private extension UInt8 {
var isValidAsMonth: Bool { self >= 1 && self <= 12 }
}
// MARK: - Previews
#if DEBUG
@available(iOS 17, *)
#Preview {
DonationPaymentDetailsViewController(
donationAmount: .init(currencyCode: "USD", value: 10),
donationMode: .oneTime,
paymentMethod: .card,
onFinished: { _ in },
)
}
#endif