diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPage.swift index 691b9ff1ae0..58b75206590 100644 --- a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPage.swift +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPage.swift @@ -45,3 +45,9 @@ extension StripeAPI { } } + +extension StripeAPI.VerificationPage { + func copyWithNewMissings(newMissings: Set) -> StripeAPI.VerificationPage { + return StripeAPI.VerificationPage(biometricConsent: self.biometricConsent, documentCapture: self.documentCapture, documentSelect: self.documentSelect, individual: self.individual, countryNotListed: self.countryNotListed, individualWelcome: self.individualWelcome, phoneOtp: self.phoneOtp, fallbackUrl: self.fallbackUrl, id: self.id, livemode: self.livemode, requirements: StripeAPI.VerificationPageRequirements(missing: newMissings), selfie: self.selfie, status: self.status, submitted: self.submitted, success: self.success, unsupportedClient: self.unsupportedClient) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageData.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageData.swift index 27cc11913d8..942a23e6f93 100644 --- a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageData.swift +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageData.swift @@ -22,6 +22,21 @@ extension StripeAPI { let status: Status /// If true, the associated VerificationSession has been submitted for processing. let submitted: Bool + + /// If true, the associated VerificationSession has been closed and can no longer be modified. + /// After submitting, closed might be false if needs to fallback from phone verification to document verification. + let closed: Bool + } + +} + +extension StripeAPI.VerificationPageData { + /// When submitted but is not closed and there is still missing requirements, need to fallback. + func needsFallback() -> Bool { + return submitted && !closed && !requirements.missing.isEmpty } + func submittedAndClosed() -> Bool { + return submitted && closed + } } diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageClearData.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageClearData.swift index 7c1011e5adb..56bb9ae5895 100644 --- a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageClearData.swift +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageClearData.swift @@ -20,6 +20,7 @@ extension StripeAPI { let dob: Bool? let name: Bool? let address: Bool? + let phoneOtp: Bool? } } @@ -36,7 +37,8 @@ extension StripeAPI.VerificationPageClearData { idNumber: fields.contains(.idNumber), dob: fields.contains(.dob), name: fields.contains(.name), - address: fields.contains(.address) + address: fields.contains(.address), + phoneOtp: fields.contains(.phoneOtp) ) } } diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift index 75f321efae6..5ccb0888b5e 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift @@ -239,17 +239,41 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { else { // Transition to generic error screen transitionWithVerificaionPageDataResult( - nil, + updateDataResult, completion: completion ) return } + // If finished collecting, submit and transition if updateData.requirements.missing.isEmpty { - apiClient.submitIdentityVerificationPage().observe(on: .main) { [weak self] result in - self?.isVerificationPageSubmitted = (try? result.get())?.submitted == true - self?.transitionWithVerificaionPageDataResult( - result, + apiClient.submitIdentityVerificationPage().observe(on: .main) { [weak self] submittedData in + guard let self = self else { return } + self.isVerificationPageSubmitted = (try? submittedData.get())?.submittedAndClosed() == true + + // Checking the response of submit + guard case .success(let resultData) = submittedData + else { + self.isVerificationPageSubmitted = false + self.transitionWithVerificaionPageDataResult(submittedData, completion: completion) + return + } + + self.isVerificationPageSubmitted = resultData.submitted == true && resultData.closed == true + + if resultData.needsFallback() { + // Checking the buffered VerificationPageResponse, update its missings with the new missings + guard let verificationPageResponse = try? self.verificationPageResponse?.get() else { + assertionFailure("Fail to get VerificationPageResponse is nil") + return + } + self.verificationPageResponse = .success(verificationPageResponse.copyWithNewMissings(newMissings: resultData.requirements.missing)) + // clear collected data + self.collectedData = StripeAPI.VerificationPageCollectedData() + + } + self.transitionWithVerificaionPageDataResult( + submittedData, completion: completion ) } @@ -358,8 +382,8 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { func sendCannotVerifyPhoneOtpAndTransition( completion: @escaping() -> Void ) { - apiClient.cannotPhoneVerifyOtp().observe(on: .main) { [weak self] result in - self?.transitionWithVerificaionPageDataResult(result, completion: completion) + apiClient.cannotPhoneVerifyOtp().observe(on: .main) { [weak self] updatedDataResult in + self?.transitionWithUpdatedDataResult(result: updatedDataResult) } } diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetFlowController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetFlowController.swift index b5a83eb570e..e32a940348c 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetFlowController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetFlowController.swift @@ -250,8 +250,11 @@ extension VerificationSheetFlowController: VerificationSheetFlowControllerProtoc // been submitted and they can't go back to edit their input. let isSuccessState = nextViewController is SuccessViewController + // If it's biometric consent, it's either the first screen of a doc type verification, or the first doc-fallback screen of phone type verification, don't show go back. + let isBiometricConsent = nextViewController is BiometricConsentViewController + // Don't display a back button, so replace the navigation stack - if isTransitioningFromLoading || isTransitioningFromDebug || isSuccessState { + if isTransitioningFromLoading || isTransitioningFromDebug || isSuccessState || isBiometricConsent { navigationController.setViewControllers([nextViewController], animated: shouldAnimate) } else { navigationController.pushViewController(nextViewController, animated: shouldAnimate) @@ -328,8 +331,8 @@ extension VerificationSheetFlowController: VerificationSheetFlowControllerProtoc let missingRequirements = updateDataResponse?.requirements.missing ?? staticContent.requirements.missing - // Show success screen if submitted - if updateDataResponse?.submitted == true { + // Show success screen if submitted and closed + if updateDataResponse?.submittedAndClosed() == true { return completion( SuccessViewController( successContent: staticContent.success, diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/PhoneOtpViewController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/PhoneOtpViewController.swift index 37ecf5e5475..0b5db1011de 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/PhoneOtpViewController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/PhoneOtpViewController.swift @@ -80,10 +80,10 @@ class PhoneOtpViewController: IdentityFlowViewController { let bodyText = { if let localPhoneNumber = sheetController.collectedData.phone { // If phone number is collected locally use the non-nil number - return phoneOtpContent.body.replacingOccurrences(of: "&phone_number&", with: localPhoneNumber.phoneNumber?.suffix(4) ?? "") + return phoneOtpContent.body.replacingOccurrences(of: "{phone_number}", with: localPhoneNumber.phoneNumber?.suffix(4) ?? "") } else { // Otherwise use the server provided non-nil number - return phoneOtpContent.body.replacingOccurrences(of: "&phone_number&", with: phoneOtpContent.redactedPhoneNumber ?? "") + return phoneOtpContent.body.replacingOccurrences(of: "{phone_number}", with: phoneOtpContent.redactedPhoneNumber ?? "") } }() @@ -100,9 +100,13 @@ class PhoneOtpViewController: IdentityFlowViewController { fatalError("init(coder:) has not been implemented") } - override func viewDidLoad() { - super.viewDidLoad() + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) updateUI() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) generateOtp() } diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/Views/PhoneOtpView.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/Views/PhoneOtpView.swift index a87cefea78a..64e9eaac4f8 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/Views/PhoneOtpView.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/Views/PhoneOtpView.swift @@ -95,6 +95,7 @@ class PhoneOtpView: UIView { ]) addAndPinSubview(stackView) + configure(with: viewModel) } required init?(coder: NSCoder) { diff --git a/StripeIdentity/StripeIdentityTests/Helpers/IdentityMockData.swift b/StripeIdentity/StripeIdentityTests/Helpers/IdentityMockData.swift index ca8ff2eba73..2de06395dd3 100644 --- a/StripeIdentity/StripeIdentityTests/Helpers/IdentityMockData.swift +++ b/StripeIdentity/StripeIdentityTests/Helpers/IdentityMockData.swift @@ -41,6 +41,7 @@ enum VerificationPageDataMock: String, MockData { case noErrors = "VerificationPageData_no_errors" case noErrorsNeedback = "VerificationPageData_no_errors_needback" case submitted = "VerificationPageData_submitted" + case submittedNotClosed = "VerificationPageData_submitted_not_closed" static func noErrorsWithMissings( with missingRequirements: Set @@ -53,7 +54,8 @@ enum VerificationPageDataMock: String, MockData { missing: missingRequirements ), status: noErrorsResponse.status, - submitted: noErrorsResponse.submitted + submitted: noErrorsResponse.submitted, + closed: noErrorsResponse.closed ) } } @@ -168,7 +170,7 @@ enum VerificationPageDataUpdateMock { enum PhoneOtpPageMock { static let`default` = StripeAPI.VerificationPageStaticContentPhoneOtpPage( title: "Enter verification code", - body: "Enter the code sent to you phone &phone_number& to continue.", + body: "Enter the code sent to you phone {phone_number} to continue.", redactedPhoneNumber: "(***)*****35", errorOtpMessage: "Error confirming verification code", resendButtonText: "Resend code", diff --git a/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_200.json b/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_200.json index 81492e16bb3..b922abed318 100644 --- a/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_200.json +++ b/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_200.json @@ -2,6 +2,7 @@ "id": "VS_123", "status": "requires_input", "submitted": false, + "closed": false, "requirements": { "errors": [ { diff --git a/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_no_errors.json b/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_no_errors.json index d7d05ef32ca..27969130537 100644 --- a/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_no_errors.json +++ b/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_no_errors.json @@ -2,6 +2,7 @@ "id": "VS_123", "status": "requires_input", "submitted": false, + "closed": false, "requirements": { "errors": [], "missing": [] diff --git a/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_no_errors_needback.json b/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_no_errors_needback.json index 1ed15d46b83..d8bc99728d0 100644 --- a/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_no_errors_needback.json +++ b/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_no_errors_needback.json @@ -2,6 +2,7 @@ "id": "VS_123", "status": "requires_input", "submitted": false, + "closed": false, "requirements": { "errors": [], "missing": [ diff --git a/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_submitted.json b/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_submitted.json index b5b1afe285d..f75a1aea4f7 100644 --- a/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_submitted.json +++ b/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_submitted.json @@ -2,6 +2,7 @@ "id": "VS_123", "status": "requires_input", "submitted": true, + "closed": true, "requirements": { "errors": [], "missing": [] diff --git a/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_submitted_not_closed.json b/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_submitted_not_closed.json new file mode 100644 index 00000000000..ff8eac2a93e --- /dev/null +++ b/StripeIdentity/StripeIdentityTests/Mock Files/VerificationPageData/VerificationPageData_submitted_not_closed.json @@ -0,0 +1,16 @@ +{ + "id": "VS_123", + "status": "requires_input", + "submitted": true, + "closed": false, + "requirements": { + "errors": [], + "missing": [ + "biometric_consent", + "id_document_front", + "id_document_back", + "id_document_type" + ] + }, + +} diff --git a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/Coordinators/VerificationSheetControllerTest.swift b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/Coordinators/VerificationSheetControllerTest.swift index 2b8088b8ddb..9c7a0d5a6ab 100644 --- a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/Coordinators/VerificationSheetControllerTest.swift +++ b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/Coordinators/VerificationSheetControllerTest.swift @@ -172,7 +172,8 @@ final class VerificationSheetControllerTest: XCTestCase { idNumber: false, dob: false, name: false, - address: false + address: false, + phoneOtp: false ), collectedData: mockData ) @@ -525,6 +526,62 @@ final class VerificationSheetControllerTest: XCTestCase { ) } + func testSaveDataSubmitsFallbackResponse() throws { + // Mock initial VerificationPage request successful + controller.verificationPageResponse = .success(try VerificationPageMock.response200.make()) + + // Mock time to submit + mockFlowController.isFinishedCollecting = true + + let mockDataResponse = try VerificationPageDataMock.noErrors.make() + let mockSubmitResponse = try VerificationPageDataMock.submittedNotClosed.make() + let mockData = VerificationPageDataUpdateMock.default.collectedData! + + // Mock number of attempted scans + controller.analyticsClient.countDidStartDocumentScan(for: .front) + controller.analyticsClient.countDidStartDocumentScan(for: .back) + controller.analyticsClient.countDidStartDocumentScan(for: .back) + + // Save data + controller.saveAndTransition(from: .biometricConsent, collectedData: mockData) { + self.exp.fulfill() + } + + // Respond to save data request with success + mockAPIClient.verificationPageData.respondToRequests(with: .success(mockDataResponse)) + + let submitRequestExp = expectation(description: "submit request made") + mockAPIClient.verificationSessionSubmit.callBackOnRequest { + submitRequestExp.fulfill() + } + wait(for: [submitRequestExp], timeout: 1) + + // Verify submit request + XCTAssertEqual(mockAPIClient.verificationSessionSubmit.requestHistory.count, 1) + mockAPIClient.verificationSessionSubmit.respondToRequests( + with: .success(mockSubmitResponse) + ) + + // Verify completion block is called + wait(for: [exp], timeout: 1) + + // Verify missing got updated + XCTAssertEqual(try controller.verificationPageResponse?.get().requirements.missing, mockSubmitResponse.requirements.missing) + + // Verify collectedData got cleared + XCTAssertEqual(controller.collectedData, StripeAPI.VerificationPageCollectedData()) + + // Verify submitted is false + XCTAssertEqual(controller.isVerificationPageSubmitted, false) + + // Verify response sent to flowController + wait(for: [mockFlowController.didTransitionToNextScreenExp], timeout: 1) + XCTAssertEqual( + try? mockFlowController.transitionedWithUpdateDataResult?.get(), + mockSubmitResponse + ) + } + func testSaveDataSubmitsErrorResponse() throws { let mockError = NSError(domain: "", code: 0, userInfo: nil) diff --git a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/PhoneOtpViewControllerTest.swift b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/PhoneOtpViewControllerTest.swift index 72ec35aab42..b344625546c 100644 --- a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/PhoneOtpViewControllerTest.swift +++ b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/PhoneOtpViewControllerTest.swift @@ -33,6 +33,7 @@ final class PhoneOtpViewControllerTest: XCTestCase { vc = PhoneOtpViewController(phoneOtpContent: phoneOtpContent, sheetController: mockSheetController) + vc.viewDidAppear(false) } func testGenerateCodeOnceWhenLoads() {