Skip to content

Commit

Permalink
Add UPI prototype (#1415)
Browse files Browse the repository at this point in the history
* Add UPI prototype

* Move UPI support to playground

* remove prints

* Update playground for Indian merchants

* Update PaymentSheetTestPlayground.swift

* Add UPI snapshot

* Update PaymentSheetSnapshotTests.swift

* Use CompatibleColor

* respect appearance api in polling vc

* Mark polling VC as internal

* clean up

* Move VPA validation to own file

* update todos

* Update STPIntentAction.swift

* Handle todo

* Localize countdown timer

* Remove post_confirm_handling_pi_status_specs from form spec

* Remove upi next action, update intent directly

* Revert "Remove post_confirm_handling_pi_status_specs from form spec"

This reverts commit 1989a15ba322c1e426595d49b1ecc7b49577f517.

* add back india to playground

* Remove UPI from luxe spec

* Add UPI automated UI tests

* Clean up some white space

* Add STPVPANumberValidatorTest

* PR feedback
  • Loading branch information
porter-stripe authored Sep 19, 2022
1 parent b5d0749 commit ddf04ed
Show file tree
Hide file tree
Showing 30 changed files with 744 additions and 75 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,15 @@ class PaymentSheetTestPlayground: UIViewController {
case eur
case aud
case gbp
case inr
}

enum MerchantCountryCode: String, CaseIterable {
case US
case GB
case AU
case FR
case IN
}

enum IntentMode: String, CaseIterable {
Expand Down Expand Up @@ -229,8 +231,8 @@ class PaymentSheetTestPlayground: UIViewController {
super.viewDidLoad()

// Enable experimental payment methods.
// PaymentSheet.supportedPaymentMethods += [.link]

PaymentSheet.supportedPaymentMethods += [.UPI]
checkoutButton.addTarget(self, action: #selector(didTapCheckoutButton), for: .touchUpInside)
checkoutButton.isEnabled = false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,26 @@ class PaymentSheetSnapshotTests: FBSnapshotTestCase {
presentPaymentSheet(darkMode: false)
verify(paymentSheet.bottomSheetViewController.view!)
}

func testPaymentSheet_LPM_upi_only() {
PaymentSheet.supportedPaymentMethods += [.UPI] // TODO: (porter) Remove when UPI launches

stubSessions(fileMock: .elementsSessionsPaymentMethod_200,
responseCallback: { data in
return self.updatePaymentMethodDetail(data: data, variables: ["<paymentMethods>": "\"upi\"",
"<currency>": "\"inr\""])
})
stubPaymentMethods(stubRequestCallback: nil, fileMock: .saved_payment_methods_200)
stubCustomers()

preparePaymentSheet(currency: "inr",
override_payment_methods_types: ["upi"],
automaticPaymentMethods: false,
useLink: false)
presentPaymentSheet(darkMode: false)
verify(paymentSheet.bottomSheetViewController.view!)

}
private func updatePaymentMethodDetail(data: Data, variables: [String:String]) -> Data {
var template = String(data: data, encoding: .utf8)!
for (templateKey, templateValue) in variables {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,56 @@ class PaymentSheetUITest: XCTestCase {

// no pay button tap because linked account is stubbed/fake in UI test
}

func testUPIPaymentMethod() throws {
loadPlayground(app, settings: [
"customer_mode": "new",
"merchant_country_code": "IN",
"currency": "INR"
])

app.buttons["Checkout (Complete)"].tap()

let payButton = app.buttons["Pay ₹50.99"]
guard let upi = scroll(collectionView: app.collectionViews.firstMatch, toFindCellWithId: "UPI") else {
XCTFail()
return
}
upi.tap()

XCTAssertFalse(payButton.isEnabled)
let vpa = app.textFields["VPA number"]
vpa.tap()
vpa.typeText("payment.success@stripeupi")
vpa.typeText(XCUIKeyboardKey.return.rawValue)

payButton.tap()
}

func testUPIPaymentMethod_invalidVPA() throws {
loadPlayground(app, settings: [
"customer_mode": "new",
"merchant_country_code": "IN",
"currency": "INR"
])

app.buttons["Checkout (Complete)"].tap()

let payButton = app.buttons["Pay ₹50.99"]
guard let upi = scroll(collectionView: app.collectionViews.firstMatch, toFindCellWithId: "UPI") else {
XCTFail()
return
}
upi.tap()

XCTAssertFalse(payButton.isEnabled)
let vpa = app.textFields["VPA number"]
vpa.tap()
vpa.typeText("payment.success")
vpa.typeText(XCUIKeyboardKey.return.rawValue)

XCTAssertFalse(payButton.isEnabled)
}
}

// MARK: - Link
Expand Down
21 changes: 20 additions & 1 deletion Stripe.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,9 @@
3CD1D3A127C8682E001575BB /* ConnectionsSDKAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD1D3A027C8682D001575BB /* ConnectionsSDKAvailability.swift */; };
448895AF245255D800F7D0C2 /* STPPaymentMethodPrzelewy24ParamsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 448895AE245255D800F7D0C2 /* STPPaymentMethodPrzelewy24ParamsTests.m */; };
44BDCFDF245A46CC007EE6D5 /* STPPaymentMethodBancontactParamsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 44BDCFDE245A46CC007EE6D5 /* STPPaymentMethodBancontactParamsTests.m */; };
61078DA428C278B3007C7001 /* PollingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61078DA328C278B3007C7001 /* PollingViewController.swift */; };
61078DA828C7C49C007C7001 /* PaymentSheetFormFactory+UPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61078DA728C7C49C007C7001 /* PaymentSheetFormFactory+UPI.swift */; };
61078DAA28C7F28F007C7001 /* IntentStatusPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61078DA928C7F28F007C7001 /* IntentStatusPoller.swift */; };
61202525285AD33F00B55402 /* AutoCompleteViewControllerSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61202524285AD33F00B55402 /* AutoCompleteViewControllerSnapshotTests.swift */; };
612A871A285788D400E91CA8 /* MKPlacemark+PaymentSheetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612A8719285788D400E91CA8 /* MKPlacemark+PaymentSheetTests.swift */; };
612A871C2857EC5400E91CA8 /* String+AutoComplete.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612A871B2857EC5400E91CA8 /* String+AutoComplete.swift */; };
Expand All @@ -494,7 +497,9 @@
6164582C27E963C800FEAB8E /* [email protected] in Resources */ = {isa = PBXBuildFile; fileRef = 6164582B27E963C800FEAB8E /* [email protected] */; };
6164582E27E964A800FEAB8E /* [email protected] in Resources */ = {isa = PBXBuildFile; fileRef = 6164582D27E964A800FEAB8E /* [email protected] */; };
6169CD1E28512EA300CEAD22 /* AddressSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6169CD1D28512EA300CEAD22 /* AddressSearchResult.swift */; };
616B573F28CA42DB0026B4E4 /* [email protected] in Resources */ = {isa = PBXBuildFile; fileRef = 616B573E28CA42DB0026B4E4 /* [email protected] */; };
6171960E2864D3B90040ECE3 /* AutoCompleteConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6171960D2864D3B80040ECE3 /* AutoCompleteConstants.swift */; };
61806A1028CB99F500C33002 /* Date+Distance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61806A0F28CB99F500C33002 /* Date+Distance.swift */; };
618E787B26EFDD310034A01F /* ServerErrorMapperTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618E787A26EFDD310034A01F /* ServerErrorMapperTest.swift */; };
61924D47273999E1003CF2DB /* [email protected] in Resources */ = {isa = PBXBuildFile; fileRef = 61924D45273999E1003CF2DB /* [email protected] */; };
6198555D27DBF4A1003F8951 /* [email protected] in Resources */ = {isa = PBXBuildFile; fileRef = 6198555B27DBF4A1003F8951 /* [email protected] */; };
Expand Down Expand Up @@ -1517,6 +1522,9 @@
448895AE245255D800F7D0C2 /* STPPaymentMethodPrzelewy24ParamsTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPPaymentMethodPrzelewy24ParamsTests.m; sourceTree = "<group>"; };
44BDCFDE245A46CC007EE6D5 /* STPPaymentMethodBancontactParamsTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPPaymentMethodBancontactParamsTests.m; sourceTree = "<group>"; };
4A0D74F918F6106100966D7B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
61078DA328C278B3007C7001 /* PollingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollingViewController.swift; sourceTree = "<group>"; };
61078DA728C7C49C007C7001 /* PaymentSheetFormFactory+UPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaymentSheetFormFactory+UPI.swift"; sourceTree = "<group>"; };
61078DA928C7F28F007C7001 /* IntentStatusPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentStatusPoller.swift; sourceTree = "<group>"; };
61202524285AD33F00B55402 /* AutoCompleteViewControllerSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewControllerSnapshotTests.swift; sourceTree = "<group>"; };
612A8719285788D400E91CA8 /* MKPlacemark+PaymentSheetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MKPlacemark+PaymentSheetTests.swift"; sourceTree = "<group>"; };
612A871B2857EC5400E91CA8 /* String+AutoComplete.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AutoComplete.swift"; sourceTree = "<group>"; };
Expand All @@ -1530,7 +1538,9 @@
6164582D27E964A800FEAB8E /* [email protected] */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "[email protected]"; sourceTree = "<group>"; };
6169CD1D28512EA300CEAD22 /* AddressSearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressSearchResult.swift; sourceTree = "<group>"; };
6169CD1F28512EC700CEAD22 /* MapKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.3.sdk/System/iOSSupport/System/Library/Frameworks/MapKit.framework; sourceTree = DEVELOPER_DIR; };
616B573E28CA42DB0026B4E4 /* [email protected] */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "[email protected]"; sourceTree = "<group>"; };
6171960D2864D3B80040ECE3 /* AutoCompleteConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteConstants.swift; sourceTree = "<group>"; };
61806A0F28CB99F500C33002 /* Date+Distance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Distance.swift"; sourceTree = "<group>"; };
618E787A26EFDD310034A01F /* ServerErrorMapperTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerErrorMapperTest.swift; sourceTree = "<group>"; };
61924D45273999E1003CF2DB /* [email protected] */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "[email protected]"; sourceTree = "<group>"; };
6198555B27DBF4A1003F8951 /* [email protected] */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "[email protected]"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2149,6 +2159,7 @@
B66FA0B1267D6F03008D7F1D /* [email protected] */,
6164582B27E963C800FEAB8E /* [email protected] */,
61924D45273999E1003CF2DB /* [email protected] */,
616B573E28CA42DB0026B4E4 /* [email protected] */,
);
path = PaymentMethods;
sourceTree = "<group>";
Expand Down Expand Up @@ -2974,6 +2985,7 @@
children = (
B6E9B91A266EE54F00C1308D /* FormSpec */,
B66F0CA326717B8C0097C2E8 /* PaymentSheetFormFactory.swift */,
61078DA728C7C49C007C7001 /* PaymentSheetFormFactory+UPI.swift */,
B645878827EAAA660011FA64 /* PaymentSheetFormFactory+Card.swift */,
B64C503E27BC721800E95B66 /* PaymentSheetFormFactory+FormSpec.swift */,
);
Expand All @@ -2985,11 +2997,11 @@
children = (
B6AEC92627ED3BEB0084CD67 /* Elements */,
61EA8CED26DD84DF00B2879D /* Error+PaymentSheet.swift */,
61806A0F28CB99F500C33002 /* Date+Distance.swift */,
B6BB89CF266EF7F8005E044F /* Intent.swift */,
61DBE71D27308195008565C8 /* KlarnaHelper.swift */,
B6689185265324C600A5488F /* New Payment Method Screen */,
B6E40E8C254253E400A5BABD /* BottomSheet */,
B6E40E8C254253E400A5BABD /* PanModal */,
6BD80544282C87B20049857B /* PaymentMethodType.swift */,
B648F38E25E45A770009FB36 /* PaymentOption+Images.swift */,
D0E845642887327D00CB0461 /* PaymentSheet-LinkConfirmOption.swift */,
Expand Down Expand Up @@ -3031,6 +3043,8 @@
B694F27428874DA20006DD60 /* Address */,
3667949F25B8DF8B0094831B /* BottomSheet3DS2ViewController.swift */,
36F61202254C888F006656BD /* BottomSheetViewController.swift */,
61078DA328C278B3007C7001 /* PollingViewController.swift */,
61078DA928C7F28F007C7001 /* IntentStatusPoller.swift */,
B684476625538740005C4089 /* ChoosePaymentOptionViewController.swift */,
B65E749425832A290080D9B3 /* LoadingViewController.swift */,
36F61204254C888F006656BD /* PaymentSheetViewController.swift */,
Expand Down Expand Up @@ -3961,6 +3975,7 @@
F35E2DB2267ABA6700BE074B /* [email protected] in Resources */,
3180E1032592BB1800CE3D7E /* [email protected] in Resources */,
3180E0E12592BB1100CE3D7E /* [email protected] in Resources */,
616B573F28CA42DB0026B4E4 /* [email protected] in Resources */,
D0BEB409273CABFC0031D677 /* [email protected] in Resources */,
6198556027DBF4E6003F8951 /* [email protected] in Resources */,
3180E10A2592BB1800CE3D7E /* [email protected] in Resources */,
Expand Down Expand Up @@ -4313,6 +4328,7 @@
31D4D6882512EBAC00809066 /* UIToolbar+Stripe_InputAccessory.swift in Sources */,
366ECD36254B4AFA0082868E /* STPCardNumberInputTextFieldValidator.swift in Sources */,
61EC82F8288EF13E003D741F /* STPAnalyticsClient+Address.swift in Sources */,
61806A1028CB99F500C33002 /* Date+Distance.swift in Sources */,
D0E152922810D2F900BCB49F /* LinkSettings.swift in Sources */,
D092E37127C06F2F00B72609 /* PaymentSheet-Configuration+Link.swift in Sources */,
61D30DB926D5B5F2002872DE /* TestModeView.swift in Sources */,
Expand Down Expand Up @@ -4363,6 +4379,7 @@
316F811A25410B12000A80B5 /* STPPaymentMethodOXXOParams.swift in Sources */,
D03B1DDA2819F4C1009F4C9A /* LinkNavigationBar.swift in Sources */,
6145429D2850EC3E002A2901 /* ManualEntryButton.swift in Sources */,
61078DAA28C7F28F007C7001 /* IntentStatusPoller.swift in Sources */,
D075F87827443E3F00585EB8 /* SeparatorLabel.swift in Sources */,
317ABF40251196A600CC59EF /* STPCameraView.swift in Sources */,
3176C2142519723B00300ADE /* STPPaymentCardTextField.swift in Sources */,
Expand Down Expand Up @@ -4588,6 +4605,7 @@
B67243172524E3E5002E1AAF /* STPPaymentMethodPrzelewy24.swift in Sources */,
B67BC546257B024200B7349B /* PaymentMethodTypeCollectionView.swift in Sources */,
D0AF32DB2833005700BDE839 /* PayWithLinkViewController-SignUpViewModel.swift in Sources */,
61078DA828C7C49C007C7001 /* PaymentSheetFormFactory+UPI.swift in Sources */,
310AF46B271E0961007339F4 /* STPPaymentMethodCard.swift in Sources */,
36ADAE182523A5B100302DFB /* STPPaymentIntentLastPaymentError.swift in Sources */,
31F2E8782524143F004D4B5E /* STPPaymentResult.swift in Sources */,
Expand Down Expand Up @@ -4668,6 +4686,7 @@
D0D28E622757398200098245 /* Button+Link.swift in Sources */,
D092E37D27C5A01700B72609 /* STPAnalyticsClient+Link.swift in Sources */,
D0A621472886034800F7876D /* PayWithLinkController.swift in Sources */,
61078DA428C278B3007C7001 /* PollingViewController.swift in Sources */,
3111BE802513057C00288D28 /* STPMultiFormTextField.swift in Sources */,
319490592513CC6200AD8F0B /* STPImageLibrary.swift in Sources */,
B6D9CEAB2514809B00AAD424 /* STPPaymentMethodCardNetworks.swift in Sources */,
Expand Down
10 changes: 10 additions & 0 deletions Stripe/BottomSheetViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import UIKit
@_spi(STP) import StripeUICore

protocol BottomSheetContentViewController: UIViewController {

/// - Note: Implementing `navigationBar` as a computed variable will result in undefined behavior.
var navigationBar: SheetNavigationBar { get }
var requiresFullScreen: Bool { get }
func didTapOrSwipeToDismiss()
Expand Down Expand Up @@ -283,6 +285,14 @@ extension BottomSheetViewController: PaymentSheetAuthenticationContext {
}
_ = popContentViewController()
}

func present(_ viewController: BottomSheetContentViewController) {
pushContentViewController(viewController)
}

func dismiss(_ viewController: BottomSheetContentViewController) {
_ = popContentViewController()
}
}

// MARK: - UIViewControllerTransitioningDelegate
Expand Down
22 changes: 22 additions & 0 deletions Stripe/Date+Distance.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Date+Distance.swift
// StripeiOS
//
// Created by Nick Porter on 9/9/22.
// Copyright © 2022 Stripe, Inc. All rights reserved.
//

import Foundation

extension Date {

public func compatibleDistance(to other: Date) -> TimeInterval {
if #available(iOS 13.0, *) {
return self.distance(to: other)
}

return TimeInterval(
Calendar.autoupdatingCurrent.dateComponents([Calendar.Component.second], from: self, to: other).second ?? 0
)
}
}
2 changes: 2 additions & 0 deletions Stripe/Enums+CustomStringConvertible.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ extension STPIntentActionType: CustomStringConvertible {
return "verifyWithMicrodeposits"
case .weChatPayRedirectToApp:
return "weChatPayRedirectToApp"
case .upiAwaitNotification:
return "upiAwaitNotification"
}
}
}
Expand Down
1 change: 1 addition & 0 deletions Stripe/Images.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ enum Image: String, CaseIterable, ImageMaker {
case pm_type_sepa = "icon-pm-sepa"
case pm_type_paypal = "icon-pm-paypal"
case pm_type_link = "icon-pm-link"
case pm_type_upi = "icon-pm-upi"

// Icons/symbols
case icon_checkmark = "icon_checkmark"
Expand Down
95 changes: 95 additions & 0 deletions Stripe/IntentStatusPoller.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//
// IntentStatusPoller.swift
// StripeiOS
//
// Created by Nick Porter on 9/6/22.
// Copyright © 2022 Stripe, Inc. All rights reserved.
//

import Foundation
import StripeCore

protocol IntentStatusPollerDelegate: AnyObject {
func didUpdate(paymentIntent: STPPaymentIntent)
}

class IntentStatusPoller {
let apiClient: STPAPIClient
let clientSecret: String
let maxRetries: Int

private var lastStatus: STPPaymentIntentStatus = .unknown
private var retryCount = 0
weak var delegate: IntentStatusPollerDelegate?

var isPolling: Bool = false {
didSet {
// Start polling if we weren't already polling
if !oldValue && isPolling {
forcePoll()
}
}
}

init(apiClient: STPAPIClient, clientSecret: String, maxRetries: Int) {
self.apiClient = apiClient
self.clientSecret = clientSecret
self.maxRetries = maxRetries
}

// MARK: Public APIs

public func beginPolling() {
isPolling = true
}

public func suspendPolling() {
isPolling = false
}

public func forcePoll() {
fetchStatus(forcePoll: true)
}

// MARK: Private functions

private func fetchStatus(forcePoll: Bool = false) {
guard forcePoll || (isPolling && retryCount < maxRetries) else { return }
retryCount += 1

apiClient.retrievePaymentIntent(withClientSecret: clientSecret) { [weak self] paymentIntent, error in
print("PI status")
print(paymentIntent?.status as Any)
print(self?.retryCount as Any)
guard let isPolling = self?.isPolling else {
return
}

// If latest status is different than last known status notify our delegate
if let paymentIntent = paymentIntent,
paymentIntent.status != self?.lastStatus,
isPolling {
self?.lastStatus = paymentIntent.status
self?.delegate?.didUpdate(paymentIntent: paymentIntent)
}

// If we are polling and have retries left, schedule a status fetch
if isPolling, let maxRetries = self?.maxRetries, let retryCount = self?.retryCount {
self?.retryWithExponentialDelay(retryCount: maxRetries - retryCount) {
self?.fetchStatus()
}
}
}
}

private func retryWithExponentialDelay(retryCount: Int, block: @escaping () -> ()) {
// Add some backoff time
let delayTime = TimeInterval(
pow(Double(1 + maxRetries - retryCount), Double(2))
)

DispatchQueue.main.asyncAfter(deadline: .now() + delayTime) {
block()
}
}
}
Loading

0 comments on commit ddf04ed

Please sign in to comment.