Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for on_behalf_of to STPPaymentCardTextField and STPCardFormView #3529

Merged
merged 4 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
### Apple Pay
* [Changed] Apple Pay additionalEnabledApplePayNetworks are now in front of the supported network list.

### PaymentsUI
* [Added] Added support for `onBehalfOf` to STPPaymentCardTextField and STPCardFormView. This parameter may be required when setting a connected account as the merchant of record for a payment. For more information, see the [Connect docs](https://docs.stripe.com/connect/charges#on_behalf_of).

## 23.27.0 2024-04-08
### Payments
* [Added] Support for Alma bindings.
Expand Down
7 changes: 7 additions & 0 deletions Stripe/StripeiOSTests/STPCardFormViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ class STPCardFormViewTests: XCTestCase {
waitForExpectations(timeout: 3.0)
}

func testCBCOBO() {
STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey
let cardFormView = STPCardFormView(billingAddressCollection: .automatic, cbcEnabledOverride: true)
cardFormView.onBehalfOf = "acct_abc123"
XCTAssertEqual((cardFormView.numberField.validator as! STPCardNumberInputTextFieldValidator).cbcController.onBehalfOf, "acct_abc123")
}

func testCBCFourDigitCVCIsInvalid() {
STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey
let cardFormView = STPCardFormView(billingAddressCollection: .automatic, cbcEnabledOverride: true)
Expand Down
7 changes: 7 additions & 0 deletions Stripe/StripeiOSTests/STPPaymentCardTextFieldTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,13 @@ class STPPaymentCardTextFieldTest: XCTestCase {
waitForExpectations(timeout: 3.0)
}

func testOBOCBC() {
STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey
let sut = STPPaymentCardTextField()
sut.onBehalfOf = "acct_abc123"
XCTAssertEqual(sut.viewModel.cbcController.onBehalfOf, "acct_abc123")
}

func testFourDigitCVCNotAllowedUnknownCBCCard() {
STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey
let sut = STPPaymentCardTextField()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@ class CardElementConfigService {
// change for an individual PK over the lifetime of a process.
private var _configsForPK: [String: CardElementConfigFetchState] = [:]

var isCBCEligible: Bool {
func isCBCEligible(onBehalfOf: String? = nil) -> Bool {
guard let publishableKey = apiClient.publishableKey else {
// User has not yet initialized a PK, bail
return false
}
if let fetchState = _configsForPK[publishableKey] {

let cacheKey = publishableKey + (onBehalfOf ?? "")

if let fetchState = _configsForPK[cacheKey] {
switch fetchState {
case .fetching:
// Still waiting for a config, so we don't yet know if the user is CBC-eligible.
Expand All @@ -51,23 +54,28 @@ class CardElementConfigService {
}

// Kick off a fetch request
_configsForPK[publishableKey] = .fetching
_configsForPK[cacheKey] = .fetching

let resultHandler: (Result<CardElementConfig, Error>) -> Void = { result in
DispatchQueue.main.async {
switch result {
case .success(let cardElementConfig):
// Cache the result for the next time the card element is presented
self._configsForPK[publishableKey] = .cached(cardElementConfig)
self._configsForPK[cacheKey] = .cached(cardElementConfig)
case .failure:
// Ignore failures, but send an analytic to the server
self._configsForPK[publishableKey] = .failed
self._configsForPK[cacheKey] = .failed
STPAnalyticsClient.sharedClient.logCardElementConfigLoadFailed()
}
}
}

apiClient.get(url: CardElementConfigEndpoint, parameters: [:], ephemeralKeySecret: nil, completion: resultHandler)
var parameters: [String: Any] = [:]
if let onBehalfOf {
parameters["on_behalf_of"] = onBehalfOf
}

apiClient.get(url: CardElementConfigEndpoint, parameters: parameters, ephemeralKeySecret: nil, completion: resultHandler)

// No answer yet, so we don't know if the user is CBC-eligible
return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,13 @@ class STPCBCController {

var cbcEnabledOverride: Bool?

var onBehalfOf: String?

var cbcEnabled: Bool {
if let cbcEnabledOverride = cbcEnabledOverride {
return cbcEnabledOverride
}
return CardElementConfigService.shared.isCBCEligible
return CardElementConfigService.shared.isCBCEligible(onBehalfOf: onBehalfOf)
}

enum BrandState: Equatable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,19 @@ public class STPCardFormView: STPFormView {
}
self.preferredNetworks = preferredNetworks.map { STPCardBrand(rawValue: $0.intValue) ?? .unknown }
}

/// The account (if any) for which the funds of the intent are intended.
/// The Stripe account ID (if any) which is the business of record.
/// See [use cases](https://docs.stripe.com/connect/charges#on_behalf_of) to determine if this option is relevant for your integration.
/// This should match the [on_behalf_of](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-on_behalf_of)
/// provided on the Intent used when confirming payment.
public var onBehalfOf: String? {
didSet {
if let cardValidator = (numberField.validator as? STPCardNumberInputTextFieldValidator) {
cardValidator.cbcController.onBehalfOf = onBehalfOf
}
}
}
}

/// :nodoc:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2402,6 +2402,17 @@ open class STPPaymentCardTextField: UIControl, UIKeyInput, STPFormTextFieldDeleg
}
self.preferredNetworks = preferredNetworks.map { STPCardBrand(rawValue: $0.intValue) ?? .unknown }
}

/// The account (if any) for which the funds of the intent are intended.
/// The Stripe account ID (if any) which is the business of record.
/// See [use cases](https://docs.stripe.com/connect/charges#on_behalf_of) to determine if this option is relevant for your integration.
/// This should match the [on_behalf_of](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-on_behalf_of)
/// provided on the Intent used when confirming payment.
public var onBehalfOf: String? {
didSet {
viewModel.cbcController.onBehalfOf = onBehalfOf
}
}
}

/// This protocol allows a delegate to be notified when a payment text field's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,42 @@ class CardElementConfigServiceTests: APIStubbedTestCase {
return HTTPStubsResponse(data: responseData, statusCode: 200, headers: nil)
}
// Returns false at first...
XCTAssertFalse(cecs.isCBCEligible)
XCTAssertFalse(cecs.isCBCEligible())

waitForExpectations(timeout: 3.0)
// But after waiting for the response (and another turn of the runloop), it returns true!
let exp2 = expectation(description: "processed and checked response")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
XCTAssertTrue(cecs.isCBCEligible)
XCTAssertTrue(cecs.isCBCEligible())
exp2.fulfill()
}
waitForExpectations(timeout: 1.0)
}

func testSuccessfullyFetchesConfigForOnBehalfOf() throws {
let exp = expectation(description: "fetched config")
let cecs = CardElementConfigService()
cecs.apiClient = stubbedAPIClient()
cecs.apiClient.publishableKey = "pk_test_123abc"
stub { urlRequest in
return urlRequest.url?.absoluteString.contains("/mobile-card-element-config?on_behalf_of=acct_abc123") ?? false
} response: { _ in
let responseData = """
{"card_brand_choice":{"eligible":true}}
""".data(using: .utf8)!
defer {
exp.fulfill()
}
return HTTPStubsResponse(data: responseData, statusCode: 200, headers: nil)
}
// Returns false at first...
XCTAssertFalse(cecs.isCBCEligible(onBehalfOf: "acct_abc123"))

waitForExpectations(timeout: 3.0)
// But after waiting for the response (and another turn of the runloop), it returns true!
let exp2 = expectation(description: "processed and checked response")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
XCTAssertTrue(cecs.isCBCEligible(onBehalfOf: "acct_abc123"))
exp2.fulfill()
}
waitForExpectations(timeout: 1.0)
Expand All @@ -59,13 +88,13 @@ class CardElementConfigServiceTests: APIStubbedTestCase {
return HTTPStubsResponse(data: responseData, statusCode: 200, headers: nil)
}
// Returns false at first...
XCTAssertFalse(cecs.isCBCEligible)
XCTAssertFalse(cecs.isCBCEligible())

waitForExpectations(timeout: 3.0)
// But after waiting for the response (and another turn of the runloop), it still returns false (as the response was invalid)
let exp2 = expectation(description: "processed and checked response")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
XCTAssertFalse(cecs.isCBCEligible)
XCTAssertFalse(cecs.isCBCEligible())
exp2.fulfill()
}
waitForExpectations(timeout: 1.0)
Expand Down