Skip to content

Commit

Permalink
Add support for off_session 3D Secure
Browse files Browse the repository at this point in the history
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.)
  • Loading branch information
Ben Creech committed Nov 9, 2024
1 parent 6b0ff20 commit 7570a14
Show file tree
Hide file tree
Showing 2 changed files with 177 additions and 12 deletions.
101 changes: 89 additions & 12 deletions localstripe/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,13 +434,18 @@ def __init__(self, source=None, **kwargs):
self.tokenization_method = None

self.customer = None
self._authenticated = False

@property
def last4(self):
return self._card_number[-4:]

def _requires_authentication(self):
return PaymentMethod._requires_authentication(self)
def _setup_requires_authentication(self, usage=None):
return PaymentMethod._setup_requires_authentication(self, usage)

def _payment_requires_authentication(self, off_session=False):
return PaymentMethod._payment_requires_authentication(
self, off_session)

def _attaching_is_declined(self):
return PaymentMethod._attaching_is_declined(self)
Expand Down Expand Up @@ -1830,7 +1835,8 @@ class PaymentIntent(StripeObject):

def __init__(self, amount=None, currency=None, customer=None,
payment_method=None, metadata=None, payment_method_types=None,
capture_method=None, payment_method_options=None, **kwargs):
capture_method=None, payment_method_options=None,
off_session=None, **kwargs):
if kwargs:
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))

Expand All @@ -1850,6 +1856,8 @@ def __init__(self, amount=None, currency=None, customer=None,
assert capture_method in ('automatic',
'automatic_async',
'manual')
if off_session is not None:
assert type(off_session) is bool
except AssertionError:
raise UserError(400, 'Bad request')

Expand All @@ -1872,6 +1880,7 @@ def __init__(self, amount=None, currency=None, customer=None,
self.invoice = None
self.next_action = None
self.capture_method = capture_method or 'automatic_async'
self.off_session = off_session or False

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

obj = super()._api_create(**data)
obj = super()._api_create(off_session=off_session, **data)

if confirm:
obj._confirm(on_failure_now=obj._report_failure)
Expand Down Expand Up @@ -1995,7 +2004,7 @@ def _api_confirm(cls, id, payment_method=None, **kwargs):
def _confirm(self, on_failure_now):
self._authentication_failed = False
payment_method = PaymentMethod._api_retrieve(self.payment_method)
if payment_method._requires_authentication():
if payment_method._payment_requires_authentication(self.off_session):
self.next_action = {
'type': 'use_stripe_sdk',
'use_stripe_sdk': {'type': 'three_d_secure_redirect',
Expand Down Expand Up @@ -2151,11 +2160,30 @@ def __init__(self, type=None, billing_details=None, card=None,

self.customer = None
self.metadata = metadata or {}
self._authenticated = False

def _setup_requires_authentication(self, usage=None):
if self.type == 'card':
if self._card_number == '4000002500003155':
# For this card, if we're setting up a payment method for
# off_session future payments, Stripe proactively forces
# 3DS authentication at setup time:
return usage == 'off_session'

return self._card_number in ('4000002760003184',
'4000008260003178',
'4000000000003220',
'4000000000003063',
'4000008400001629')
return False

def _requires_authentication(self):
def _payment_requires_authentication(self, off_session=False):
if self.type == 'card':
return self._card_number in ('4000002500003155',
'4000002760003184',
if self._card_number == '4000002500003155':
# See https://docs.stripe.com/testing#authentication-and-setup
return not (off_session and self._authenticated)

return self._card_number in ('4000002760003184',
'4000008260003178',
'4000000000003220',
'4000000000003063',
Expand Down Expand Up @@ -2268,6 +2296,14 @@ def _try_get_canonical_test_article(cls, id):
exp_month='12',
exp_year='2030',
cvc='123'))
if id == 'pm_card_authenticationRequiredOnSetup':
return PaymentMethod(
type='card',
card=dict(
number='4000002500003155',
exp_month='12',
exp_year='2030',
cvc='123'))

@classmethod
def _api_list_all(cls, url, customer=None, type=None, limit=None,
Expand Down Expand Up @@ -2709,9 +2745,15 @@ def __init__(self, type=None, currency=None, owner=None, metadata=None,
'mandate_url': 'https://fake/NXDSYREGC9PSMKWY',
}

def _requires_authentication(self):
def _setup_requires_authentication(self, usage=None):
if self.type == 'sepa_debit':
return PaymentMethod._setup_requires_authentication(self, usage)
return False

def _payment_requires_authentication(self, off_session=False):
if self.type == 'sepa_debit':
return PaymentMethod._requires_authentication(self)
return PaymentMethod._payment_requires_authentication(
self, off_session)
return False

def _attaching_is_declined(self):
Expand Down Expand Up @@ -2806,7 +2848,7 @@ def _attach_pm(self, pm):
self.next_action = None
raise UserError(402, 'Your card was declined.',
{'code': 'card_declined'})
elif pm._requires_authentication():
elif pm._setup_requires_authentication(self.usage):
self.status = 'requires_action'
self.next_action = {'type': 'use_stripe_sdk',
'use_stripe_sdk': {
Expand Down Expand Up @@ -2838,10 +2880,45 @@ def _api_cancel(cls, id, use_stripe_sdk=None, client_secret=None,
obj.next_action = None
return obj

@classmethod
def _api_authenticate(cls, id, **kwargs):
"""This is a test-only endpoint to help test payment methods which
require authentication during setup.
E.g., for credit cards which are subject to the 3D Secure protocol,
when confirmed, SetupIntent may transition to the 'requires_action'
status, with a 'next_action' indicating some flow that usually
involves human interaction from the cardholder. This endpoint bypasses
that required action for test purposes.
"""

if kwargs:
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))

try:
assert type(id) is str and id.startswith('seti_')
except AssertionError:
raise UserError(400, 'Bad request')

obj = cls._api_retrieve(id)

if obj.status != 'requires_action':
raise UserError(400, 'Bad request')

pm = PaymentMethod._api_retrieve(obj.payment_method)
pm._authenticated = True

obj.status = 'succeeded'
obj.next_action = None

return obj


extra_apis.extend((
('POST', '/v1/setup_intents/{id}/confirm', SetupIntent._api_confirm),
('POST', '/v1/setup_intents/{id}/cancel', SetupIntent._api_cancel)))
('POST', '/v1/setup_intents/{id}/cancel', SetupIntent._api_cancel),
('POST', '/v1/setup_intents/{id}/_authenticate',
SetupIntent._api_authenticate)))


class Subscription(StripeObject):
Expand Down
88 changes: 88 additions & 0 deletions test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1202,3 +1202,91 @@ status=$(
-d items[0][plan]=basique-annuel \
| grep -oE '"status": "incomplete"')
[ -n "$status" ]

### test 3D Secure with both on-session and off-session payments

# Set up for on-session payments. Doesn't require authentication at setup time,
# but does require authentication when we make a payment_intent:
cus=$(curl -sSfg -u $SK: $HOST/v1/customers \
-d [email protected] \
| grep -oE 'cus_\w+' | head -n 1)
res=$(curl -sSfg -u $SK: -X POST $HOST/v1/setup_intents -d usage=on_session)
seti=$(echo "$res" | grep '"id"' | grep -oE 'seti_\w+' | head -n 1)
seti_secret=$(echo $res | grep -oE 'seti_\w+_secret_\w+' | head -n 1)
res=$(curl -sSfg $HOST/v1/setup_intents/$seti/confirm \
-d key=pk_test_sldkjflaksdfj \
-d client_secret=$seti_secret \
-d payment_method_data[type]=card \
-d payment_method_data[card][number]=4000002500003155 \
-d payment_method_data[card][cvc]=242 \
-d payment_method_data[card][exp_month]=4 \
-d payment_method_data[card][exp_year]=2030 \
-d payment_method_data[billing_details][address][postal_code]=42424)
succeeded=$(echo "$res" | grep -oE '"status": "succeeded"' | head -n 1)
[ -n "$succeeded" ]
pm=$(echo "$res" | grep '"payment_method"' | grep -oE 'pm_\w+' | head -n 1)
curl -u $SK: $HOST/v1/payment_methods/$pm/attach -d customer=$cus
# requires authentication for on-session payments:
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
-d customer=$cus \
-d payment_method=$pm \
-d amount=1000 \
-d confirm=true \
-d currency=usd)
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
[ -n "$requires_action" ]
# requires authentication for off-session payments too:
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
-d customer=$cus \
-d payment_method=$pm \
-d amount=1000 \
-d confirm=true \
-d off_session=true \
-d currency=usd)
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
[ -n "$requires_action" ]

# Set up for off-session payments. Does require authentication at setup time,
# but doesn't require authentication when we make an offline payment_intent:
cus=$(curl -sSfg -u $SK: $HOST/v1/customers \
-d [email protected] \
| grep -oE 'cus_\w+' | head -n 1)
res=$(curl -sSfg -u $SK: -X POST $HOST/v1/setup_intents -d usage=off_session)
seti=$(echo "$res" | grep '"id"' | grep -oE 'seti_\w+' | head -n 1)
seti_secret=$(echo $res | grep -oE 'seti_\w+_secret_\w+' | head -n 1)
res=$(curl -sSfg $HOST/v1/setup_intents/$seti/confirm \
-d key=pk_test_sldkjflaksdfj \
-d client_secret=$seti_secret \
-d payment_method_data[type]=card \
-d payment_method_data[card][number]=4000002500003155 \
-d payment_method_data[card][cvc]=242 \
-d payment_method_data[card][exp_month]=4 \
-d payment_method_data[card][exp_year]=2030 \
-d payment_method_data[billing_details][address][postal_code]=42424)
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
[ -n "$requires_action" ]
# Do a backdoor authentication using this test-only authenticate endpoint:
res=$(curl -f -u $SK: -X POST $HOST/v1/setup_intents/$seti/_authenticate)
succeeded=$(echo "$res" | grep -oE '"status": "succeeded"' | head -n 1)
[ -n "$succeeded" ]
pm=$(echo "$res" | grep '"payment_method"' | grep -oE 'pm_\w+' | head -n 1)
curl -u $SK: $HOST/v1/payment_methods/$pm/attach -d customer=$cus
# still requires authentication for on-session payments:
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
-d customer=$cus \
-d payment_method=$pm \
-d amount=1000 \
-d confirm=true \
-d currency=usd)
requires_action=$(echo "$res" | grep -oE '"status": "requires_action"' | head -n 1)
[ -n "$requires_action" ]
# but doesn't require authentication for off-session payments:
res=$(curl -sSfg -u $SK: $HOST/v1/payment_intents \
-d customer=$cus \
-d payment_method=$pm \
-d amount=1000 \
-d confirm=true \
-d off_session=true \
-d currency=usd)
succeeded=$(echo "$res" | grep -oE '"status": "succeeded"' | head -n 1)
[ -n "$succeeded" ]

0 comments on commit 7570a14

Please sign in to comment.