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

feat(POM-420): support nAPM capture confirmation #355

Merged
merged 11 commits into from
Sep 23, 2024
2 changes: 1 addition & 1 deletion .github/actions/select-xcode/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ runs:
using: "composite"
steps:
- name: Select Xcode Version
run: sudo xcode-select -s '/Applications/Xcode_15.4.app/Contents/Developer'
run: sudo xcode-select -s '/Applications/Xcode_16.app/Contents/Developer'
shell: bash
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ final class AlternativePaymentsViewModel: ObservableObject {
secondaryAction: .cancel(),
paymentConfirmation: .init(
showProgressIndicatorAfter: 5,
confirmButton: .init(),
secondaryAction: .cancel(disabledFor: 10)
)
)
Expand Down
5 changes: 4 additions & 1 deletion Scripts/Test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ set -euo pipefail
PROJECT='ProcessOut.xcodeproj'
DESTINATION=$(./Scripts/TestDestination.swift)

# todo(andrii-vysotskyi): re-enable Checkout3DS wrapper testing
# ProcessOutCheckout3DS is not compatible with Xcode 16

# Run Tests
for PRODUCT in "ProcessOut" "ProcessOutUI" "ProcessOutCheckout3DS"; do
for PRODUCT in "ProcessOut" "ProcessOutUI"; do
xcodebuild clean test \
-destination "$DESTINATION" \
-project $PROJECT \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ public enum PONativeAlternativePaymentMethodEvent {
/// to make capture happen.
case willWaitForCaptureConfirmation(additionalActionExpected: Bool)

/// This event is triggered during the capture stage when the user confirms that they have completed
/// any required external action (if applicable). Once the event is triggered, the implementation
/// proceeds with the actual capture process.
case didConfirmPayment

/// Event is sent after payment was confirmed to be captured. This is a final event.
case didCompletePayment

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,21 @@ public struct POButtonStyle<ProgressStyle: ProgressViewStyle>: ButtonStyle {
isEnabled: isEnabled, isLoading: isLoading, isPressed: configuration.isPressed
)
ZStack {
ProgressView()
.progressViewStyle(progressStyle)
.opacity(isLoading ? 1 : 0)
configuration.label
.textStyle(currentStyle.title)
.lineLimit(1)
.opacity(isLoading ? 0 : 1)
if isLoading {
ProgressView()
.progressViewStyle(progressStyle)
} else {
configuration.label
.textStyle(currentStyle.title)
.lineLimit(1)
}
}
.padding(Constants.padding)
.frame(maxWidth: .infinity, minHeight: Constants.minHeight)
.background(currentStyle.backgroundColor)
.border(style: currentStyle.border)
.shadow(style: currentStyle.shadow)
.contentShape(.rect)
.contentShape(.standardHittableRect)
.animation(.default, value: isLoading)
.animation(.default, value: isEnabled)
.allowsHitTesting(isEnabled && !isLoading)
Expand Down
34 changes: 34 additions & 0 deletions Sources/ProcessOutUI/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1813,6 +1813,40 @@
}
}
},
"native-alternative-payment.confirm-capture-button.title" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "لقد دفعت"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "I’ve paid"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "J'ai payé"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Płatność wykonana"
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "translated",
"value" : "Já paguei"
}
}
}
},
"native-alternative-payment.email.placeholder" : {
"localizations" : {
"ar" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ import Foundation
/// Configuration specific to native APM payment confirmation.
public struct PONativeAlternativePaymentConfirmationConfiguration { // swiftlint:disable:this type_name

/// Confirmation button configuration.
public struct ConfirmButton {

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

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

/// Boolean value that specifies whether module should wait for payment confirmation from PSP or will
/// complete right after all user's input is submitted. Default value is `true`.
public let waitsConfirmation: Bool
Expand All @@ -26,6 +38,13 @@ public struct PONativeAlternativePaymentConfirmationConfiguration { // swiftlint
/// Default value is `false`.
public let hideGatewayDetails: Bool

/// 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 secondaryAction: PONativeAlternativePaymentConfiguration.SecondaryAction?
Expand All @@ -36,12 +55,14 @@ public struct PONativeAlternativePaymentConfirmationConfiguration { // swiftlint
timeout: TimeInterval = 180,
showProgressIndicatorAfter: TimeInterval? = nil,
hideGatewayDetails: Bool = false,
confirmButton: ConfirmButton? = nil,
secondaryAction: PONativeAlternativePaymentConfiguration.SecondaryAction? = nil
) {
self.waitsConfirmation = waitsConfirmation
self.timeout = timeout
self.showProgressIndicatorAfter = showProgressIndicatorAfter
self.hideGatewayDetails = hideGatewayDetails
self.confirmButton = confirmButton
self.secondaryAction = secondaryAction
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,22 @@ final class NativeAlternativePaymentDefaultInteractor:
}
}

func confirmCapture() {
confirmCapture(force: false)
}

override func cancel() {
// todo(andrii-vysotskyi): allow cancellation in all states except sink
logger.debug("Will attempt to cancel payment.")
switch state {
case .started(let state) where state.isCancellable:
setFailureStateUnchecked(error: POFailure(code: .cancelled))
case .awaitingCapture(let state) where state.isCancellable:
captureCancellable?.cancel()
if let cancellable = state.cancellable {
cancellable.cancel()
} else {
setFailureStateUnchecked(error: POFailure(code: .cancelled))
}
default:
logger.debug("Ignored cancellation attempt from unsupported state: \(state)")
}
Expand All @@ -117,8 +126,6 @@ final class NativeAlternativePaymentDefaultInteractor:
private let logger: POLogger
private let completion: (Result<Void, POFailure>) -> Void

private var captureCancellable: AnyCancellable?

// MARK: - Starting State

@MainActor
Expand All @@ -140,8 +147,7 @@ final class NativeAlternativePaymentDefaultInteractor:
}
let startedState = State.Started(
gateway: details.gateway,
amount: details.invoice.amount,
currencyCode: details.invoice.currencyCode,
invoice: details.invoice,
parameters: await createParameters(specifications: details.parameters),
isCancellable: disableDuration(of: configuration.secondaryAction).isZero
)
Expand Down Expand Up @@ -213,36 +219,58 @@ final class NativeAlternativePaymentDefaultInteractor:
setSubmittedUnchecked()
return
}
let actionMessage = parameterValues?.customerActionMessage ?? gateway.customerActionMessage
send(event: .willWaitForCaptureConfirmation(additionalActionExpected: actionMessage != nil))
let customerActionMessage = parameterValues?.customerActionMessage ?? gateway.customerActionMessage
let additionalActionExpected = customerActionMessage != nil
send(event: .willWaitForCaptureConfirmation(additionalActionExpected: additionalActionExpected))
let (logoImage, actionImage) = await imagesRepository.images(
at: logoUrl(gateway: gateway, parameterValues: parameterValues), gateway.customerActionImageUrl
)
let shouldConfirmCapture = additionalActionExpected && configuration.paymentConfirmation.confirmButton != nil
let awaitingCaptureState = State.AwaitingCapture(
paymentProviderName: parameterValues?.providerName,
logoImage: logoImage,
actionMessage: actionMessage,
actionImage: actionImage,
paymentProvider: .init(name: parameterValues?.providerName, image: logoImage),
customerAction: customerActionMessage.map { message in
.init(message: message, image: actionImage)
},
isCancellable: disableDuration(of: configuration.paymentConfirmation.secondaryAction).isZero,
isDelayed: false
isDelayed: false,
shouldConfirmCapture: shouldConfirmCapture
)
setStateUnchecked(.awaitingCapture(awaitingCaptureState))
logger.info("Waiting for invoice capture confirmation")
if !shouldConfirmCapture {
confirmCapture(force: true)
}
enableCaptureCancellationAfterDelay()
}

private func confirmCapture(force: Bool) {
guard case .awaitingCapture(var currentState) = state else {
logger.debug("Ignoring attempt to confirm capture from unsupported state: \(state).")
return
}
guard (currentState.shouldConfirmCapture || force) && currentState.cancellable == nil else {
logger.debug("Payment is already being captured, ignored.")
return
}
if !force {
delegate?.nativeAlternativePaymentMethodDidEmitEvent(.didConfirmPayment)
}
let request = PONativeAlternativePaymentCaptureRequest(
invoiceId: configuration.invoiceId,
gatewayConfigurationId: configuration.gatewayConfigurationId,
timeout: configuration.paymentConfirmation.timeout
)
let task = Task {
let task = Task { @MainActor in
do {
try await invoicesService.captureNativeAlternativePayment(request: request)
await setCapturedStateUnchecked(gateway: gateway, parameterValues: parameterValues)
await setCapturedStateUnchecked(paymentProvider: currentState.paymentProvider)
} catch {
setFailureStateUnchecked(error: error)
}
}
captureCancellable = AnyCancellable(task.cancel)
enableCaptureCancellationAfterDelay()
currentState.cancellable = AnyCancellable(task.cancel)
currentState.shouldConfirmCapture = false
self.state = .awaitingCapture(currentState)
logger.info("Waiting for invoice capture confirmation")
schedulePaymentConfirmationDelay()
}

Expand All @@ -265,24 +293,25 @@ final class NativeAlternativePaymentDefaultInteractor:
private func setCapturedStateUnchecked(
gateway: PONativeAlternativePaymentMethodTransactionDetails.Gateway,
parameterValues: PONativeAlternativePaymentMethodParameterValues?
) async {
let logoImage = await imagesRepository.image(
at: logoUrl(gateway: gateway, parameterValues: parameterValues)
)
let paymentProvider = State.PaymentProvider(name: parameterValues?.providerName, image: logoImage)
await setCapturedStateUnchecked(paymentProvider: paymentProvider)
}

@MainActor
private func setCapturedStateUnchecked(
paymentProvider: NativeAlternativePaymentInteractorState.PaymentProvider
) async {
logger.info("Did receive invoice capture confirmation")
guard configuration.paymentConfirmation.waitsConfirmation else {
logger.info("Should't wait for confirmation, so setting submitted state instead of captured.")
setSubmittedUnchecked()
return
}
let capturedState: State.Captured
if case .awaitingCapture(let awaitingCaptureState) = state {
capturedState = State.Captured(
paymentProviderName: awaitingCaptureState.paymentProviderName, logoImage: awaitingCaptureState.logoImage
)
} else {
let logoImage = await imagesRepository.image(
at: logoUrl(gateway: gateway, parameterValues: parameterValues)
)
capturedState = State.Captured(paymentProviderName: parameterValues?.providerName, logoImage: logoImage)
}
let capturedState = State.Captured(paymentProvider: paymentProvider)
setStateUnchecked(.captured(capturedState))
send(event: .didCompletePayment)
if !configuration.skipSuccessScreen {
Expand Down Expand Up @@ -370,14 +399,13 @@ final class NativeAlternativePaymentDefaultInteractor:

// MARK: - Cancellation Availability

@MainActor
private func enableCancellationAfterDelay() {
let disabledFor = disableDuration(of: configuration.secondaryAction)
guard disabledFor > 0 else {
logger.debug("Cancel action is not set or initially enabled.")
return
}
Task {
Task { @MainActor in
try? await Task.sleep(seconds: disabledFor)
switch state {
case .started(var state):
Expand All @@ -392,14 +420,13 @@ final class NativeAlternativePaymentDefaultInteractor:
}
}

@MainActor
private func enableCaptureCancellationAfterDelay() {
let disabledFor = disableDuration(of: configuration.paymentConfirmation.secondaryAction)
guard disabledFor > 0 else {
logger.debug("Confirmation cancel action is not set or initially enabled.")
return
}
Task {
Task { @MainActor in
try? await Task.sleep(seconds: disabledFor)
guard case .awaitingCapture(var awaitingState) = state else {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ protocol NativeAlternativePaymentInteractor: Interactor<NativeAlternativePayment
/// Submits parameters.
func submit()

/// Confirms that capture preconditions are satisfied and implementation could proceed with capture.
///
/// - NOTE: Implementation does nothing if manual confirmation is not needed.
func confirmCapture()

/// Notifies interactor that user requested cancel confirmation.
func didRequestCancelConfirmation()
}
Loading
Loading