Skip to content

Commit

Permalink
Add MPE integration to Connections demo app
Browse files Browse the repository at this point in the history
  • Loading branch information
mats-stripe committed Dec 20, 2024
1 parent d3895cf commit 1bc3a6c
Show file tree
Hide file tree
Showing 3 changed files with 253 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -31,33 +31,79 @@ final class PlaygroundConfiguration {
}
}

// MARK: - Integration Type

enum IntegrationType: String, CaseIterable, Identifiable, Hashable {
case standalone = "standalone"
case paymentElement = "payment_element"

var displayName: String {
switch self {
case .standalone: "Standalone"
case .paymentElement: "Payment Element"
}
}

var id: String {
return rawValue
}
}

private static let integrationTypeKey = "integration_type"

var integrationType: IntegrationType {
get {
if
let integrationTypeString = configurationStore[Self.integrationTypeKey] as? String,
let integrationType = IntegrationType(rawValue: integrationTypeString)
{
return integrationType
} else {
return .standalone
}
}
set {
configurationStore[Self.integrationTypeKey] = newValue.rawValue
}
}

// MARK: - Experience

enum Experience: String, CaseIterable, Identifiable, Hashable {
case financialConnections = "financial_connections"
case instantDebits = "instant_debits"
case instantBankPayment = "instant_debits"
case linkCardBrand = "link_card_brand"

var displayName: String {
switch self {
case .financialConnections: "Financial Connections"
case .instantDebits: "Instant Debits"
case .instantBankPayment: "Instant Bank Payment"
case .linkCardBrand: "Link Card Brand"
}
}

var id: String {
return rawValue
}

var paymentMethods: String {
switch self {
case .financialConnections: return "['card', 'link', 'us_bank_account']"
case .instantBankPayment: return "['card', 'link']"
case .linkCardBrand: return "['card']"
}
}
}

private static let experienceKey = "experience"

var experience: Experience {
get {
if
let sdkTypeString = configurationStore[Self.experienceKey] as? String,
let sdkType = Experience(rawValue: sdkTypeString)
let experienceString = configurationStore[Self.experienceKey] as? String,
let experience = Experience(rawValue: experienceString)
{
return sdkType
return experience
} else {
return .financialConnections
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,32 @@ struct PlaygroundView: View {
ZStack {
VStack {
Form {
Section(header: Text("Experience")) {
VStack(alignment: .leading, spacing: 4) {
Picker("Select Experience", selection: viewModel.experience) {
ForEach(PlaygroundConfiguration.Experience.allCases) {
Text($0.displayName)
.tag($0)
}
Section(header: Text("Integration Type")) {
Picker("Integration Type", selection: viewModel.integrationType) {
ForEach(PlaygroundConfiguration.IntegrationType.allCases) {
Text($0.displayName)
.tag($0)
}
.pickerStyle(.segmented)
}
.pickerStyle(.segmented)
}

Picker("Experience", selection: viewModel.experience) {
ForEach(PlaygroundConfiguration.Experience.allCases) {
Text($0.displayName)
.tag($0)
}

if viewModel.integrationType.wrappedValue == .standalone && viewModel.experience.wrappedValue == .linkCardBrand {
Text("'Link Card Brand' in the standalone integration will launch the Instant Bank Payment flow.")
.font(.caption)
.italic()
} else if viewModel.integrationType.wrappedValue == .paymentElement {
Text("Payment methods requested will be: `\(viewModel.experience.wrappedValue.paymentMethods)`")
.font(.caption)
}
}
.pickerStyle(.inline)

Section(header: Text("Select SDK Type")) {
VStack(alignment: .leading, spacing: 4) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Combine
import Foundation
import StripeFinancialConnections
import StripePaymentSheet
import SwiftUI
import UIKit

Expand All @@ -23,14 +24,34 @@ final class PlaygroundViewModel: ObservableObject {

let playgroundConfiguration = PlaygroundConfiguration.shared

var integrationType: Binding<PlaygroundConfiguration.IntegrationType> {
Binding(
get: {
self.playgroundConfiguration.integrationType
},
set: { newValue in
self.playgroundConfiguration.integrationType = newValue

if newValue == .paymentElement, self.playgroundConfiguration.merchant.customId == .default {
// Set to Netowrking merchant when switching to Payment Element.
if let networkingMerchant = self.playgroundConfiguration.merchants.first(where: { $0.customId == .networking }) {
self.playgroundConfiguration.merchant = networkingMerchant
}
}

self.objectWillChange.send()
}
)
}

var experience: Binding<PlaygroundConfiguration.Experience> {
Binding(
get: {
self.playgroundConfiguration.experience
},
set: { newValue in
self.playgroundConfiguration.experience = newValue
if newValue == .instantDebits {
if newValue == .instantBankPayment {
// Instant debits only supports the payment intent use case.
self.playgroundConfiguration.useCase = .paymentIntent
}
Expand Down Expand Up @@ -217,10 +238,66 @@ final class PlaygroundViewModel: ObservableObject {
}

func didSelectShow() {
setup()
switch playgroundConfiguration.integrationType {
case .standalone:
setupStandalone()
case .paymentElement:
setupPaymentElement()
}
}

private func setupPaymentElement() {
func presentAlert(for error: PaymentSheetError) {
var title: String = "Error"
let message: String?

switch error {
case .invalidResponse:
message = "Invalid server response"
case .decodingError(let error):
message = "Decoding error: \(error)"
case .paymentSheetCanceled:
title = "Canceled"
message = nil
case .paymentSheetError(let error):
message = "Error from payment sheet: \(error)"
}

DispatchQueue.main.async {
UIAlertController.showAlert(title: title, message: message)
}
}

isLoading = true
CreatePaymentIntent(
configuration: playgroundConfiguration.configurationDictionary
) { [weak self] createPaymentIntentResult in
guard let self else { return }
switch createPaymentIntentResult {
case .success(let paymentIntent):
PresentPaymentSheet(
paymentIntent: paymentIntent,
config: self.playgroundConfiguration,
completion: { paymentSheetResult in
switch paymentSheetResult {
case .success:
UIAlertController.showAlert(title: "Payment success")
case .failure(let error):
presentAlert(for: error)
}
}
)
case .failure(let error):
presentAlert(for: error)
}

DispatchQueue.main.async {
self.isLoading = false
}
}
}

private func setup() {
private func setupStandalone() {
isLoading = true
SetupPlayground(
configurationDictionary: playgroundConfiguration.configurationDictionary
Expand Down Expand Up @@ -455,6 +532,105 @@ private func PresentFinancialConnectionsSheet(
}
}

private func CreatePaymentIntent(
configuration: [String: Any],
completion: @escaping (Result<CreatePaymentIntentResponse, PaymentSheetError>) -> Void
) {
let baseURL = "https://financial-connections-playground-ios.glitch.me"
let endpoint = "/create_payment_intent"
let url = URL(string: baseURL + endpoint)!

var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = try! JSONSerialization.data(
withJSONObject: configuration,
options: .prettyPrinted
)
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")

URLSession.shared.dataTask(
with: urlRequest,
completionHandler: { data, _, error in
guard error == nil, let data else {
completion(.failure(.invalidResponse))
return
}
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let paymentIntent = try decoder.decode(CreatePaymentIntentResponse.self, from: data)
completion(.success(paymentIntent))
} catch {
completion(.failure(.decodingError(error)))
}
}
)
.resume()
}

struct CreatePaymentIntentResponse: Decodable {
let id: String
let clientSecret: String
let publishableKey: String
let customerId: String
let ephemeralKey: String
let amount: Int
let currency: String
}

enum PaymentSheetError: Error {
case invalidResponse
case decodingError(Error)
case paymentSheetCanceled
case paymentSheetError(Error)
}

private func PresentPaymentSheet(
paymentIntent: CreatePaymentIntentResponse,
config: PlaygroundConfiguration,
completion: @escaping (Result<String, PaymentSheetError>) -> Void
) {
/// https://docs.stripe.com/payments/accept-a-payment?platform=ios&ui=payment-sheet
STPAPIClient.shared.publishableKey = paymentIntent.publishableKey

var configuration = PaymentSheet.Configuration()
configuration.merchantDisplayName = "Financial Connections Example"
configuration.customer = .init(
id: paymentIntent.customerId,
ephemeralKeySecret: paymentIntent.ephemeralKey
)
configuration.allowsDelayedPaymentMethods = true
configuration.defaultBillingDetails.email = config.email
configuration.defaultBillingDetails.phone = config.phone

let isUITest = (ProcessInfo.processInfo.environment["UITesting"] != nil)
// disable app-to-app for UI tests
configuration.returnURL = isUITest ? nil : "financial-connections-example://redirect"

let paymentSheet = PaymentSheet(
paymentIntentClientSecret: paymentIntent.clientSecret,
configuration: configuration
)

DispatchQueue.main.async {
let topMostViewController = UIViewController.topMostViewController()!
paymentSheet.present(
from: topMostViewController,
completion: { paymentSheetResult in
switch paymentSheetResult {
case .completed:
completion(.success("Payment completed"))
case .canceled:
completion(.failure(.paymentSheetCanceled))
case .failed(let error):
completion(.failure(.paymentSheetError(error)))
}
}
)
}
}

private extension [String] {
/// Returns nil if the array is empty, otherwise joins the array values with a new line.
var joinedUnlessEmpty: String? {
Expand Down

0 comments on commit 1bc3a6c

Please sign in to comment.