Skip to content

Commit b104807

Browse files
author
Ben Creech
committed
Add support for off_session 3D Secure
This adds a simulation of a canonical Stripe test card that supports 3D Secure in off_session mode, if the configured properly with a setup_intent beforehand. This includes addition of a test-only _authenticate endpoint for setup_intents similar to the one that already exists for payment_intents. (Tests can POST to the _authenticate endpoint to simulate asynchronous interaction by the cardholder with 3D Secure challenges.)
1 parent caa0e94 commit b104807

File tree

2 files changed

+182
-15
lines changed

2 files changed

+182
-15
lines changed

localstripe/resources.py

+94-15
Original file line numberDiff line numberDiff line change
@@ -434,13 +434,18 @@ def __init__(self, source=None, **kwargs):
434434
self.tokenization_method = None
435435

436436
self.customer = None
437+
self._authenticated = False
437438

438439
@property
439440
def last4(self):
440441
return self._card_number[-4:]
441442

442-
def _requires_authentication(self):
443-
return PaymentMethod._requires_authentication(self)
443+
def _setup_requires_authentication(self, usage=None):
444+
return PaymentMethod._setup_requires_authentication(self, usage)
445+
446+
def _payment_requires_authentication(self, off_session=False):
447+
return PaymentMethod._payment_requires_authentication(
448+
self, off_session)
444449

445450
def _attaching_is_declined(self):
446451
return PaymentMethod._attaching_is_declined(self)
@@ -1830,7 +1835,8 @@ class PaymentIntent(StripeObject):
18301835

18311836
def __init__(self, amount=None, currency=None, customer=None,
18321837
payment_method=None, metadata=None, payment_method_types=None,
1833-
capture_method=None, payment_method_options=None, **kwargs):
1838+
capture_method=None, payment_method_options=None,
1839+
off_session=None, **kwargs):
18341840
if kwargs:
18351841
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))
18361842

@@ -1850,6 +1856,8 @@ def __init__(self, amount=None, currency=None, customer=None,
18501856
assert capture_method in ('automatic',
18511857
'automatic_async',
18521858
'manual')
1859+
if off_session is not None:
1860+
assert type(off_session) is bool
18531861
except AssertionError:
18541862
raise UserError(400, 'Bad request')
18551863

@@ -1872,6 +1880,7 @@ def __init__(self, amount=None, currency=None, customer=None,
18721880
self.invoice = None
18731881
self.next_action = None
18741882
self.capture_method = capture_method or 'automatic_async'
1883+
self.off_session = off_session or False
18751884

18761885
self._canceled = False
18771886
self._authentication_failed = False
@@ -1963,7 +1972,7 @@ def _api_create(cls, confirm=None, off_session=None, **data):
19631972
except AssertionError:
19641973
raise UserError(400, 'Bad request')
19651974

1966-
obj = super()._api_create(**data)
1975+
obj = super()._api_create(off_session=off_session, **data)
19671976

19681977
if confirm:
19691978
obj._confirm(on_failure_now=obj._report_failure)
@@ -1995,7 +2004,7 @@ def _api_confirm(cls, id, payment_method=None, **kwargs):
19952004
def _confirm(self, on_failure_now):
19962005
self._authentication_failed = False
19972006
payment_method = PaymentMethod._api_retrieve(self.payment_method)
1998-
if payment_method._requires_authentication():
2007+
if payment_method._payment_requires_authentication(self.off_session):
19992008
self.next_action = {
20002009
'type': 'use_stripe_sdk',
20012010
'use_stripe_sdk': {'type': 'three_d_secure_redirect',
@@ -2151,11 +2160,30 @@ def __init__(self, type=None, billing_details=None, card=None,
21512160

21522161
self.customer = None
21532162
self.metadata = metadata or {}
2163+
self._authenticated = False
21542164

2155-
def _requires_authentication(self):
2165+
def _setup_requires_authentication(self, usage=None):
21562166
if self.type == 'card':
2157-
return self._card_number in ('4000002500003155',
2158-
'4000002760003184',
2167+
if self._card_number == '4000002500003155':
2168+
# For this card, if we're setting up a payment method for
2169+
# off_session future payments, Stripe proactively forces
2170+
# 3DS authentication at setup time:
2171+
return usage == 'off_session'
2172+
2173+
return self._card_number in ('4000002760003184',
2174+
'4000008260003178',
2175+
'4000000000003220',
2176+
'4000000000003063',
2177+
'4000008400001629')
2178+
return False
2179+
2180+
def _payment_requires_authentication(self, off_session=False):
2181+
if self.type == 'card':
2182+
if self._card_number == '4000002500003155':
2183+
# See https://docs.stripe.com/testing#authentication-and-setup
2184+
return not (off_session and self._authenticated)
2185+
2186+
return self._card_number in ('4000002760003184',
21592187
'4000008260003178',
21602188
'4000000000003220',
21612189
'4000000000003063',
@@ -2268,6 +2296,14 @@ def _try_get_canonical_test_article(cls, id):
22682296
exp_month='12',
22692297
exp_year='2030',
22702298
cvc='123'))
2299+
if id == 'pm_card_authenticationRequiredOnSetup':
2300+
return PaymentMethod(
2301+
type='card',
2302+
card=dict(
2303+
number='4000002500003155',
2304+
exp_month='12',
2305+
exp_year='2030',
2306+
cvc='123'))
22712307

22722308
@classmethod
22732309
def _api_list_all(cls, url, customer=None, type=None, limit=None,
@@ -2607,6 +2643,8 @@ def __init__(self, charge=None, payment_intent=None, amount=None,
26072643
charge = payment_intent_obj.latest_charge.id
26082644

26092645
charge_obj = Charge._api_retrieve(charge)
2646+
if charge_obj.status == 'failed':
2647+
raise UserError(400, 'Cannot refund a failed payment.')
26102648

26112649
# All exceptions must be raised before this point.
26122650
super().__init__()
@@ -2707,18 +2745,24 @@ def __init__(self, type=None, currency=None, owner=None, metadata=None,
27072745
'mandate_url': 'https://fake/NXDSYREGC9PSMKWY',
27082746
}
27092747

2710-
def _requires_authentication(self):
2711-
if self.type == 'sepa_debit':
2712-
return PaymentMethod._requires_authentication(self)
2748+
def _setup_requires_authentication(self, usage=None):
2749+
if self.type in ('card', 'sepa_debit'):
2750+
return PaymentMethod._setup_requires_authentication(self, usage)
2751+
return False
2752+
2753+
def _payment_requires_authentication(self, off_session=False):
2754+
if self.type in ('card', 'sepa_debit'):
2755+
return PaymentMethod._payment_requires_authentication(
2756+
self, off_session)
27132757
return False
27142758

27152759
def _attaching_is_declined(self):
2716-
if self.type == 'sepa_debit':
2760+
if self.type in ('card', 'sepa_debit'):
27172761
return PaymentMethod._attaching_is_declined(self)
27182762
return False
27192763

27202764
def _charging_is_declined(self):
2721-
if self.type == 'sepa_debit':
2765+
if self.type in ('card', 'sepa_debit'):
27222766
return PaymentMethod._charging_is_declined(self)
27232767
return False
27242768

@@ -2804,7 +2848,7 @@ def _attach_pm(self, pm):
28042848
self.next_action = None
28052849
raise UserError(402, 'Your card was declined.',
28062850
{'code': 'card_declined'})
2807-
elif pm._requires_authentication():
2851+
elif pm._setup_requires_authentication(self.usage):
28082852
self.status = 'requires_action'
28092853
self.next_action = {'type': 'use_stripe_sdk',
28102854
'use_stripe_sdk': {
@@ -2836,10 +2880,45 @@ def _api_cancel(cls, id, use_stripe_sdk=None, client_secret=None,
28362880
obj.next_action = None
28372881
return obj
28382882

2883+
@classmethod
2884+
def _api_authenticate(cls, id, **kwargs):
2885+
"""This is a test-only endpoint to help test payment methods which
2886+
require authentication during setup.
2887+
2888+
E.g., for credit cards which are subject to the 3D Secure protocol,
2889+
when confirmed, SetupIntent may transition to the 'requires_action'
2890+
status, with a 'next_action' indicating some flow that usually
2891+
involves human interaction from the cardholder. This endpoint bypasses
2892+
that required action for test purposes.
2893+
"""
2894+
2895+
if kwargs:
2896+
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))
2897+
2898+
try:
2899+
assert type(id) is str and id.startswith('seti_')
2900+
except AssertionError:
2901+
raise UserError(400, 'Bad request')
2902+
2903+
obj = cls._api_retrieve(id)
2904+
2905+
if obj.status != 'requires_action':
2906+
raise UserError(400, 'Bad request')
2907+
2908+
pm = PaymentMethod._api_retrieve(obj.payment_method)
2909+
pm._authenticated = True
2910+
2911+
obj.status = 'succeeded'
2912+
obj.next_action = None
2913+
2914+
return obj
2915+
28392916

28402917
extra_apis.extend((
28412918
('POST', '/v1/setup_intents/{id}/confirm', SetupIntent._api_confirm),
2842-
('POST', '/v1/setup_intents/{id}/cancel', SetupIntent._api_cancel)))
2919+
('POST', '/v1/setup_intents/{id}/cancel', SetupIntent._api_cancel),
2920+
('POST', '/v1/setup_intents/{id}/_authenticate',
2921+
SetupIntent._api_authenticate)))
28432922

28442923

28452924
class Subscription(StripeObject):

test.sh

+88
Original file line numberDiff line numberDiff line change
@@ -1194,3 +1194,91 @@ status=$(
11941194
-d items[0][plan]=basique-annuel \
11951195
| grep -oE '"status": "incomplete"')
11961196
[ -n "$status" ]
1197+
1198+
### test 3D Secure with both on-session and off-session payments
1199+
1200+
# Set up for on-session payments. Doesn't require authentication at setup time,
1201+
# but does require authentication when we make a payment_intent:
1202+
cus=$(curl -sSfg -u $SK: $HOST/v1/customers \
1203+
-d email=on_session@example.com \
1204+
| grep -oE 'cus_\w+' | head -n 1)
1205+
res=$(curl -sSfg -u $SK: -X POST $HOST/v1/setup_intents -d usage=on_session)
1206+
seti=$(echo "$res" | grep '"id"' | grep -oE 'seti_\w+' | head -n 1)
1207+
seti_secret=$(echo $res | grep -oE 'seti_\w+_secret_\w+' | head -n 1)
1208+
res=$(curl -sSfg $HOST/v1/setup_intents/$seti/confirm \
1209+
-d key=pk_test_sldkjflaksdfj \
1210+
-d client_secret=$seti_secret \
1211+
-d payment_method_data[type]=card \
1212+
-d payment_method_data[card][number]=4000002500003155 \
1213+
-d payment_method_data[card][cvc]=242 \
1214+
-d payment_method_data[card][exp_month]=4 \
1215+
-d payment_method_data[card][exp_year]=2030 \
1216+
-d payment_method_data[billing_details][address][postal_code]=42424)
1217+
succeeded=$(echo "$res" | grep -oE '"status": "succeeded"' | head -n 1)
1218+
[ -n "$succeeded" ]
1219+
pm=$(echo "$res" | grep '"payment_method"' | grep -oE 'pm_\w+' | head -n 1)
1220+
curl -u $SK: $HOST/v1/payment_methods/$pm/attach -d customer=$cus
1221+
# requires authentication for on-session payments:
1222+
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
1223+
-d customer=$cus \
1224+
-d payment_method=$pm \
1225+
-d amount=1000 \
1226+
-d confirm=true \
1227+
-d currency=usd)
1228+
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
1229+
[ -n "$requires_action" ]
1230+
# requires authentication for off-session payments too:
1231+
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
1232+
-d customer=$cus \
1233+
-d payment_method=$pm \
1234+
-d amount=1000 \
1235+
-d confirm=true \
1236+
-d off_session=true \
1237+
-d currency=usd)
1238+
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
1239+
[ -n "$requires_action" ]
1240+
1241+
# Set up for off-session payments. Does require authentication at setup time,
1242+
# but doesn't require authentication when we make an offline payment_intent:
1243+
cus=$(curl -sSfg -u $SK: $HOST/v1/customers \
1244+
-d email=off_session@example.com \
1245+
| grep -oE 'cus_\w+' | head -n 1)
1246+
res=$(curl -sSfg -u $SK: -X POST $HOST/v1/setup_intents -d usage=off_session)
1247+
seti=$(echo "$res" | grep '"id"' | grep -oE 'seti_\w+' | head -n 1)
1248+
seti_secret=$(echo $res | grep -oE 'seti_\w+_secret_\w+' | head -n 1)
1249+
res=$(curl -sSfg $HOST/v1/setup_intents/$seti/confirm \
1250+
-d key=pk_test_sldkjflaksdfj \
1251+
-d client_secret=$seti_secret \
1252+
-d payment_method_data[type]=card \
1253+
-d payment_method_data[card][number]=4000002500003155 \
1254+
-d payment_method_data[card][cvc]=242 \
1255+
-d payment_method_data[card][exp_month]=4 \
1256+
-d payment_method_data[card][exp_year]=2030 \
1257+
-d payment_method_data[billing_details][address][postal_code]=42424)
1258+
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
1259+
[ -n "$requires_action" ]
1260+
# Do a backdoor authentication using this test-only authenticate endpoint:
1261+
res=$(curl -f -u $SK: -X POST $HOST/v1/setup_intents/$seti/_authenticate)
1262+
succeeded=$(echo "$res" | grep -oE '"status": "succeeded"' | head -n 1)
1263+
[ -n "$succeeded" ]
1264+
pm=$(echo "$res" | grep '"payment_method"' | grep -oE 'pm_\w+' | head -n 1)
1265+
curl -u $SK: $HOST/v1/payment_methods/$pm/attach -d customer=$cus
1266+
# still requires authentication for on-session payments:
1267+
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
1268+
-d customer=$cus \
1269+
-d payment_method=$pm \
1270+
-d amount=1000 \
1271+
-d confirm=true \
1272+
-d currency=usd)
1273+
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
1274+
[ -n "$requires_action" ]
1275+
# but doesn't require authentication for off-session payments:
1276+
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
1277+
-d customer=$cus \
1278+
-d payment_method=$pm \
1279+
-d amount=1000 \
1280+
-d confirm=true \
1281+
-d off_session=true \
1282+
-d currency=usd)
1283+
succeeded=$(echo "$res" | grep -oE '"status": "succeeded"' | head -n 1)
1284+
[ -n "$succeeded" ]

0 commit comments

Comments
 (0)