Skip to content

Commit

Permalink
[BANKCON-15710] Support requested billing address fields in Instant D…
Browse files Browse the repository at this point in the history
…ebits bank tab (#4162)

## Summary

This adds the `name`, `phoneNumber`, and `address` fields / sections in
the MPE Bank tab, if they are included as part of the merchant
configuration. The email field will always be shown, even when the
configuration specifies to never collect an email, if no email is
provided in the default billing details.

## Motivation

https://jira.corp.stripe.com/browse/BANKCON-15710

## Testing

Plenty of unit tests, and some manual testing:

| Configuration | Screenshot |
|--------|--------|
| Automatic | <img width=50%
src="https://github.com/user-attachments/assets/991a31bc-8b73-47b7-a613-60471eb45d75">
|
| Always or Full | <img width=50%
src="https://github.com/user-attachments/assets/f3c5a258-d957-42f4-b73c-1e1bc422a188">
|
| Never, no defaults | <img width=50%
src="https://github.com/user-attachments/assets/991a31bc-8b73-47b7-a613-60471eb45d75">
|
| Never, default email | <img width=50%
src="https://github.com/user-attachments/assets/76483838-a1bb-45f6-bd77-fdcc7040fa59">
|

## Changelog

N/a
  • Loading branch information
mats-stripe authored Oct 24, 2024
1 parent 459b211 commit f1bc6fc
Show file tree
Hide file tree
Showing 5 changed files with 509 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -170,23 +170,27 @@ extension PaymentSheet {
// - US Bank Account is *not* an available payment method.
// - Not a deferred intent flow.
// - Link Funding Sources contains Bank Account.
// - We collect an email, or a default non-empty email has been provided.
var eligibleForInstantDebits: Bool {
elementsSession.orderedPaymentMethodTypes.contains(.link) &&
!elementsSession.orderedPaymentMethodTypes.contains(.USBankAccount) &&
!intent.isDeferredIntent &&
elementsSession.linkFundingSources?.contains(.bankAccount) == true
elementsSession.linkFundingSources?.contains(.bankAccount) == true &&
configuration.isEligibleForBankTab
}

// We should manually add Link Card Brand as a payment method when:
// - Link Funding Sources contains Bank Account.
// - US Bank Account is *not* an available payment method.
// - Not a deferred intent flow.
// - Link Card Brand is the Link Mode
// - We collect an email, or a default non-empty email has been provided.
var eligibleForLinkCardBrand: Bool {
elementsSession.linkFundingSources?.contains(.bankAccount) == true &&
!elementsSession.orderedPaymentMethodTypes.contains(.USBankAccount) &&
!intent.isDeferredIntent &&
elementsSession.linkSettings?.linkMode == .linkCardBrand
elementsSession.linkSettings?.linkMode == .linkCardBrand &&
configuration.isEligibleForBankTab
}

if eligibleForInstantDebits {
Expand Down Expand Up @@ -451,3 +455,10 @@ extension STPPaymentMethodParams {
}
}
}

extension PaymentElementConfiguration {
var isEligibleForBankTab: Bool {
billingDetailsCollectionConfiguration.email != .never ||
(defaultBillingDetails.email?.isEmpty == false && billingDetailsCollectionConfiguration.attachDefaultsToPaymentMethod)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -643,20 +643,33 @@ extension PaymentSheetFormFactory {
return StaticElement(view: label)
}

func makeInstantDebits() -> PaymentMethodElement {
func makeInstantDebits(countries: [String]? = nil) -> PaymentMethodElement {
let titleElement: StaticElement? = if case .paymentSheet = configuration {
makeSectionTitleLabelWith(text: Self.PayByBankDescriptionText)
} else {
nil
}

let billingConfiguration = configuration.billingDetailsCollectionConfiguration
let nameElement = billingConfiguration.name == .always ? makeName() : nil
let phoneElement = billingConfiguration.phone == .always ? makePhone() : nil
let addressElement = billingConfiguration.address == .full
? makeBillingAddressSection(collectionMode: .all(), countries: countries)
: nil

// An email is required, so only hide the email field iff:
// The configuration specifies never collecting email, and a default (non-empty) email is provided.
let shouldHideEmailField = billingConfiguration.email == .never &&
configuration.defaultBillingDetails.email?.isEmpty == false
let emailElement = shouldHideEmailField ? nil : makeEmail()

return InstantDebitsPaymentMethodElement(
configuration: configuration,
titleElement: {
switch configuration {
case .customerSheet:
return nil // customer sheet is not supported
case .paymentSheet:
return makeSectionTitleLabelWith(
text: Self.PayByBankDescriptionText
)
}
}(),
emailElement: makeEmail(),
titleElement: titleElement,
nameElement: nameElement,
emailElement: emailElement,
phoneElement: phoneElement,
addressElement: addressElement,
theme: theme
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@ final class InstantDebitsPaymentMethodElement: ContainerElement {
private let configuration: PaymentSheetFormFactoryConfig
private let formElement: FormElement

let nameElement: TextFieldElement?
let emailElement: TextFieldElement?
let phoneElement: PhoneNumberElement?
let addressElement: AddressSectionElement?

private var linkedBankElements: [Element] {
return [linkedBankInfoSectionElement]
}
private let linkedBankInfoSectionElement: SectionElement
private let emailElement: TextFieldElement
private let linkedBankInfoView: BankAccountInfoView
private var linkedBank: InstantDebitsLinkedBank?
private let theme: ElementsAppearance
Expand Down Expand Up @@ -64,17 +68,91 @@ final class InstantDebitsPaymentMethodElement: ContainerElement {
}
}

var enableCTA: Bool {
return STPEmailAddressValidator.stringIsValidEmailAddress(email)
var name: String? {
nameElement?.text ?? defaultName
}

var defaultName: String? {
guard configuration.billingDetailsCollectionConfiguration.attachDefaultsToPaymentMethod else { return nil }
return configuration.defaultBillingDetails.name
}
var email: String {
return emailElement.text

var email: String? {
emailElement?.text ?? defaultEmail
}

var defaultEmail: String? {
guard configuration.billingDetailsCollectionConfiguration.attachDefaultsToPaymentMethod else { return nil }
return configuration.defaultBillingDetails.email
}

var phone: String? {
phoneElement?.phoneNumber?.string(as: .e164) ?? defaultPhone
}

var defaultPhone: String? {
guard configuration.billingDetailsCollectionConfiguration.attachDefaultsToPaymentMethod else { return nil }
return configuration.defaultBillingDetails.phone
}

var address: PaymentSheet.Address {
PaymentSheet.Address(
city: addressElement?.city?.text,
country: addressElement?.selectedCountryCode,
line1: addressElement?.line1?.text,
line2: addressElement?.line2?.text,
postalCode: addressElement?.postalCode?.text,
state: addressElement?.state?.rawData
)
}

var defaultAddress: PaymentSheet.Address? {
guard configuration.billingDetailsCollectionConfiguration.attachDefaultsToPaymentMethod else { return nil }
return configuration.defaultBillingDetails.address
}

var enableCTA: Bool {
let nameValid: Bool = {
// If the name field isn't shown, we treat the name as valid.
guard nameElement != nil else { return true }
// Otherwise, check if the name field is not empty.
return name?.isEmpty == false
}()

let emailValid: Bool = {
if emailElement != nil {
// If the email field is shown, make sure we have a valid email.
return STPEmailAddressValidator.stringIsValidEmailAddress(email)
} else {
// Otherwise, make sure the default email provided is not empty.
return defaultEmail?.isEmpty == false
}
}()

let phoneValid: Bool = {
// If the phone field isn't shown, we treat the phone number as valid.
guard phoneElement != nil else { return true }
// Otherwise, check if the phone field is not empty.
return phone?.isEmpty == false
}()

let addressValid: Bool = {
// If the address section isn't shown, we treat the address as valid.
guard addressElement != nil else { return true }
// If the address section is shown, the address is valid if all fields (except line2) are not empty.
return address.isValid
}()

return nameValid && emailValid && phoneValid && addressValid
}

init(
configuration: PaymentSheetFormFactoryConfig,
titleElement: StaticElement?,
emailElement: PaymentMethodElementWrapper<TextFieldElement>,
nameElement: PaymentMethodElementWrapper<TextFieldElement>?,
emailElement: PaymentMethodElementWrapper<TextFieldElement>?,
phoneElement: PaymentMethodElementWrapper<PhoneNumberElement>?,
addressElement: PaymentMethodElementWrapper<AddressSectionElement>?,
theme: ElementsAppearance = .default
) {
self.configuration = configuration
Expand All @@ -84,14 +162,22 @@ final class InstantDebitsPaymentMethodElement: ContainerElement {
elements: [StaticElement(view: linkedBankInfoView)],
theme: theme
)
self.emailElement = emailElement.element

self.nameElement = nameElement?.element
self.emailElement = emailElement?.element
self.phoneElement = phoneElement?.element
self.addressElement = addressElement?.element

self.linkedBank = nil
self.linkedBankInfoSectionElement.view.isHidden = true
self.theme = theme

let allElements: [Element?] = [
titleElement,
nameElement,
emailElement,
phoneElement,
addressElement,
linkedBankInfoSectionElement,
]
let autoSectioningElements = allElements.compactMap { $0 }
Expand Down Expand Up @@ -204,3 +290,14 @@ extension InstantDebitsPaymentMethodElement: ElementDelegate {
self.delegate?.continueToNextField(element: element)
}
}

private extension PaymentSheet.Address {
/// An address is valid if all fields except `line2` are not empty.
var isValid: Bool {
city?.isEmpty == false &&
country?.isEmpty == false &&
line1?.isEmpty == false &&
postalCode?.isEmpty == false &&
state?.isEmpty == false
}
}
Loading

0 comments on commit f1bc6fc

Please sign in to comment.