Skip to content

Commit

Permalink
feat(ad-hoc): support capture confirmation during dynamic checkout (#357
Browse files Browse the repository at this point in the history
)

* Support capture confirmation during dynamic checkout
* Rely on embedded view model directly to render primary button
* Simplify Apple Pay processing
  • Loading branch information
andrii-vysotskyi-cko authored Sep 25, 2024
1 parent 774a135 commit a952535
Show file tree
Hide file tree
Showing 9 changed files with 61 additions and 127 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,32 @@ public struct PODynamicCheckoutAlternativePaymentConfiguration {

public struct PaymentConfirmation {

/// Confirmation button configuration.
public struct ConfirmButton { // swiftlint:disable:this nesting

/// Button title.
public let title: String?

/// Creates button instance.
public init(title: String? = nil) {
self.title = title
}
}

/// Amount of time (in seconds) that module is allowed to wait before receiving final payment confirmation.
/// Default timeout is 3 minutes while maximum value is 15 minutes.
public let timeout: TimeInterval

/// A delay before showing progress indicator during payment confirmation.
public let showProgressIndicatorAfter: TimeInterval?

/// Payment confirmation button configuration.
///
/// Displays a confirmation button when the user needs to perform an external customer action (e.g.,
/// completing a step with a third-party service) before proceeding with payment capture. The user
/// must press this button to continue.
public let confirmButton: ConfirmButton?

/// Action that could be optionally presented to user during payment confirmation stage. To remove action
/// use `nil`, this is default behaviour.
public let cancelButton: CancelButton?
Expand All @@ -28,10 +47,12 @@ public struct PODynamicCheckoutAlternativePaymentConfiguration {
public init(
timeout: TimeInterval = 180,
showProgressIndicatorAfter: TimeInterval? = nil,
confirmButton: ConfirmButton? = nil,
cancelButton: CancelButton? = nil
) {
self.timeout = timeout
self.showProgressIndicatorAfter = showProgressIndicatorAfter
self.confirmButton = confirmButton
self.cancelButton = cancelButton
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,6 @@ final class DynamicCheckoutDefaultInteractor:
}
}

func submit() {
guard case .paymentProcessing(let currentState) = state else {
return
}
switch currentPaymentMethod(state: currentState) {
case .card:
currentState.cardTokenizationInteractor?.tokenize()
case .nativeAlternativePayment:
currentState.nativeAlternativePaymentInteractor?.submit()
default:
assertionFailure("Active payment method doesn't support forced submission")
}
}

override func cancel() {
cancel(force: true)
}
Expand Down Expand Up @@ -368,27 +354,27 @@ final class DynamicCheckoutDefaultInteractor:
paymentMethodId: methodId,
cardTokenizationInteractor: nil,
nativeAlternativePaymentInteractor: nil,
submission: .submitting,
isCancellable: false,
shouldInvalidateInvoice: true
shouldInvalidateInvoice: false
)
state = .paymentProcessing(paymentProcessingState)
Task { @MainActor in
do {
guard let delegate else {
throw POFailure(message: "Delegate must be set to authorize invoice.", code: .generic(.mobile))
}
let tokenizationRequest = POApplePayTokenizationRequest(paymentRequest: request)
let coordinator = DynamicCheckoutApplePayTokenizationCoordinator { [invoicesService] card in
var authorizationRequest = POInvoiceAuthorizationRequest(
invoiceId: startedState.invoice.id, source: card.id
)
let threeDSService = await delegate.dynamicCheckout(willAuthorizeInvoiceWith: &authorizationRequest)
try await invoicesService.authorizeInvoice(
request: authorizationRequest, threeDSService: threeDSService
)
}
_ = try await cardsService.tokenize(request: tokenizationRequest, delegate: coordinator)
await delegate.dynamicCheckout(willAuthorizeInvoiceWith: request)
let card = try await cardsService.tokenize(
request: POApplePayTokenizationRequest(paymentRequest: request)
)
invalidateInvoiceIfPossible()
var authorizationRequest = POInvoiceAuthorizationRequest(
invoiceId: startedState.invoice.id, source: card.id
)
let threeDSService = await delegate.dynamicCheckout(willAuthorizeInvoiceWith: &authorizationRequest)
try await invoicesService.authorizeInvoice(
request: authorizationRequest, threeDSService: threeDSService
)
setSuccessState()
} catch {
recoverPaymentProcessing(error: error)
Expand All @@ -411,7 +397,6 @@ final class DynamicCheckoutDefaultInteractor:
paymentMethodId: method.id,
cardTokenizationInteractor: interactor,
nativeAlternativePaymentInteractor: nil,
submission: .possible,
isCancellable: true
)
state = .paymentProcessing(paymentProcessingState)
Expand All @@ -428,11 +413,9 @@ final class DynamicCheckoutDefaultInteractor:
case .idle:
break // Ignored
case .started(let startedState):
currentState.submission = startedState.areParametersValid ? .possible : .temporarilyUnavailable
currentState.isCancellable = currentState.snapshot.isCancellable
self.state = .paymentProcessing(currentState)
case .tokenizing:
currentState.submission = .submitting
currentState.isCancellable = false
self.state = .paymentProcessing(currentState)
case .tokenized:
Expand All @@ -452,7 +435,6 @@ final class DynamicCheckoutDefaultInteractor:
paymentMethodId: method.id,
cardTokenizationInteractor: nil,
nativeAlternativePaymentInteractor: nil,
submission: .submitting,
isCancellable: false,
shouldInvalidateInvoice: true
)
Expand Down Expand Up @@ -492,7 +474,6 @@ final class DynamicCheckoutDefaultInteractor:
paymentMethodId: method.id,
cardTokenizationInteractor: nil,
nativeAlternativePaymentInteractor: interactor,
submission: .submitting,
isCancellable: false,
isReady: false
)
Expand All @@ -510,25 +491,21 @@ final class DynamicCheckoutDefaultInteractor:
case .idle:
break // Ignored
case .starting:
currentState.submission = .submitting
currentState.isCancellable = false
currentState.isReady = false
currentState.isAwaitingNativeAlternativePaymentCapture = false
self.state = .paymentProcessing(currentState)
case .started(let startedState):
currentState.submission = startedState.areParametersValid ? .possible : .temporarilyUnavailable
currentState.isCancellable = startedState.isCancellable
currentState.isReady = true
currentState.isAwaitingNativeAlternativePaymentCapture = false
self.state = .paymentProcessing(currentState)
case .submitting(let submittingState):
currentState.submission = .submitting
currentState.isCancellable = submittingState.isCancellable
currentState.isReady = true
currentState.isAwaitingNativeAlternativePaymentCapture = false
self.state = .paymentProcessing(currentState)
case .awaitingCapture(let awaitingCaptureState):
currentState.submission = .submitting
currentState.isCancellable = awaitingCaptureState.isCancellable
currentState.isReady = true
currentState.isAwaitingNativeAlternativePaymentCapture = true
Expand All @@ -550,7 +527,6 @@ final class DynamicCheckoutDefaultInteractor:
paymentMethodId: method.id,
cardTokenizationInteractor: nil,
nativeAlternativePaymentInteractor: nil,
submission: .submitting,
isCancellable: false,
shouldInvalidateInvoice: true
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ protocol DynamicCheckoutInteractor: Interactor<DynamicCheckoutInteractorState> {
/// Please note that only selected payment method can be started.
func startPayment(methodId: String)

/// Submits current payment method's data.
func submit()

/// Notifies interactor that user requested cancel confirmation.
func didRequestCancelConfirmation()
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,6 @@ enum DynamicCheckoutInteractorState {
/// Native APM interactor.
let nativeAlternativePaymentInteractor: (any NativeAlternativePaymentInteractor)?

/// Submission state.
var submission: PaymentSubmission

/// Defines whether payment is cancellable.
var isCancellable: Bool

Expand Down Expand Up @@ -91,18 +88,6 @@ enum DynamicCheckoutInteractorState {
var shouldInvalidateInvoice = false
}

enum PaymentSubmission {

/// Submission is currently unavailable.
case temporarilyUnavailable

/// Submission is currently possible.
case possible

/// Payment is already being processed.
case submitting
}

struct Recovering {

/// Failure that caused recovery process to happen.
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ final class DynamicCheckoutInteractorDefaultChildProvider: DynamicCheckoutIntera
title: "",
isCardholderNameInputVisible: configuration.cardholderNameRequired,
shouldCollectCvc: configuration.cvcRequired,
primaryActionTitle: "",
primaryActionTitle: self.configuration.submitButtonTitle ?? String(resource: .DynamicCheckout.Button.pay),
cancelActionTitle: "",
billingAddress: billingAddressConfiguration,
isSavingAllowed: configuration.savingAllowed,
Expand All @@ -99,7 +99,7 @@ final class DynamicCheckoutInteractorDefaultChildProvider: DynamicCheckoutIntera
title: "",
shouldHorizontallyCenterCodeInput: false,
successMessage: "",
primaryActionTitle: "",
primaryActionTitle: self.configuration.submitButtonTitle ?? String(resource: .DynamicCheckout.Button.pay),
secondaryAction: nil,
inlineSingleSelectValuesLimit: configuration.alternativePayment.inlineSingleSelectValuesLimit,
skipSuccessScreen: true,
Expand All @@ -116,6 +116,9 @@ final class DynamicCheckoutInteractorDefaultChildProvider: DynamicCheckoutIntera
timeout: configuration.timeout,
showProgressIndicatorAfter: configuration.showProgressIndicatorAfter,
hideGatewayDetails: true,
confirmButton: configuration.confirmButton.map { button in
.init(title: button.title)
},
secondaryAction: configuration.cancelButton.map { configuration in
.cancel(title: "", disabledFor: configuration.disabledFor, confirmation: nil)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@ struct DynamicCheckoutAlternativePaymentView: View {
// MARK: - View

var body: some View {
NativeAlternativePaymentContentView(viewModel: viewModel, insets: 0)
.nativeAlternativePaymentSizeClass(.compact)
.nativeAlternativePaymentStyle(.init(dynamicCheckoutStyle: style))
VStack(spacing: POSpacing.large) {
NativeAlternativePaymentContentView(viewModel: viewModel, insets: 0)
.nativeAlternativePaymentSizeClass(.compact)
.nativeAlternativePaymentStyle(.init(dynamicCheckoutStyle: style))
ForEach(viewModel.state.actions.filter(\.isPrimary)) { button in
Button(button.title, action: button.action)
.buttonStyle(POAnyButtonStyle(erasing: style.actionsContainer.primary))
.disabled(!button.isEnabled)
.buttonLoading(button.isLoading)
}
}
}

// MARK: - Private Properties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@ struct DynamicCheckoutCardView: View {
// MARK: - View

var body: some View {
CardTokenizationContentView(viewModel: viewModel, insets: 0)
.cardTokenizationStyle(.init(dynamicCheckoutStyle: style))
VStack(spacing: POSpacing.large) {
CardTokenizationContentView(viewModel: viewModel, insets: 0)
.cardTokenizationStyle(.init(dynamicCheckoutStyle: style))
ForEach(viewModel.state.actions.filter(\.isPrimary)) { button in
Button(button.title, action: button.action)
.buttonStyle(POAnyButtonStyle(erasing: style.actionsContainer.primary))
.disabled(!button.isEnabled)
.buttonLoading(button.isLoading)
}
}
}

// MARK: - Private Properties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ final class DefaultDynamicCheckoutViewModel: ViewModel {
info: info,
content: createRegularPaymentContent(state: state, methodId: methodId),
contentId: state.snapshot.invoice.id,
submitButton: createSubmitAction(methodId: methodId, state: state)
submitButton: nil
)
return .regularPayment(payment)
}
Expand Down Expand Up @@ -377,37 +377,6 @@ final class DefaultDynamicCheckoutViewModel: ViewModel {
}
}

private func createSubmitAction(
methodId: String, state: DynamicCheckoutInteractorState.PaymentProcessing
) -> POActionsContainerActionViewModel? {
guard shouldResolveContent(for: methodId, state: state) else {
return nil
}
let isEnabled, isLoading: Bool
switch state.submission {
case .temporarilyUnavailable:
isEnabled = false
isLoading = false
case .possible:
isEnabled = true
isLoading = false
case .submitting:
isEnabled = true
isLoading = true
}
let viewModel = POActionsContainerActionViewModel(
id: ButtonId.submit,
title: interactor.configuration.submitButtonTitle ?? String(resource: .DynamicCheckout.Button.pay),
isEnabled: isEnabled,
isLoading: isLoading,
isPrimary: true,
action: { [weak self] in
self?.interactor.submit()
}
)
return viewModel
}

private func createCancelAction(
_ state: DynamicCheckoutInteractorState.PaymentProcessing
) -> POActionsContainerActionViewModel? {
Expand Down

0 comments on commit a952535

Please sign in to comment.