Skip to content

Commit 4514a45

Browse files
author
Ben Creech
committed
Add event search, payment_intent events, and off_session 3DS
A few different small things in this commit: 1. Add support for querying events by type and creation date. 2. Produce payment_intent.succeeded and payment_intent.payment_failed events 3. Add 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.
1 parent caa0e94 commit 4514a45

File tree

2 files changed

+271
-17
lines changed

2 files changed

+271
-17
lines changed

localstripe/resources.py

+140-17
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)
@@ -1115,6 +1120,47 @@ def _api_update(cls, id, **data):
11151120
def _api_delete(cls, id):
11161121
raise UserError(405, 'Method Not Allowed')
11171122

1123+
@classmethod
1124+
def _api_list_all(cls, url, type=None, created=None, limit=None,
1125+
starting_after=None, **kwargs):
1126+
if kwargs:
1127+
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))
1128+
1129+
filters = []
1130+
try:
1131+
if type is not None:
1132+
assert _type(type) is str
1133+
filters.append(lambda obj: obj.type == type)
1134+
if created is not None:
1135+
assert _type(created) is dict
1136+
gt = try_convert_to_int(created.pop('gt', None))
1137+
if gt is not None:
1138+
filters.append(lambda obj: obj.created > gt)
1139+
1140+
gte = try_convert_to_int(created.pop('gte', None))
1141+
if gte is not None:
1142+
filters.append(lambda obj: obj.created >= gte)
1143+
1144+
lt = try_convert_to_int(created.pop('lt', None))
1145+
if lt is not None:
1146+
filters.append(lambda obj: obj.created < lt)
1147+
1148+
lte = try_convert_to_int(created.pop('lte', None))
1149+
if lte is not None:
1150+
filters.append(lambda obj: obj.created <= lte)
1151+
1152+
assert not created # no other params are supported
1153+
except AssertionError:
1154+
raise UserError(400, 'Bad request')
1155+
1156+
li = super()._api_list_all(
1157+
url, limit=limit, starting_after=starting_after
1158+
)
1159+
1160+
li._list = [obj for obj in li._list if all(f(obj) for f in filters)]
1161+
1162+
return li
1163+
11181164

11191165
class Invoice(StripeObject):
11201166
object = 'invoice'
@@ -1830,7 +1876,8 @@ class PaymentIntent(StripeObject):
18301876

18311877
def __init__(self, amount=None, currency=None, customer=None,
18321878
payment_method=None, metadata=None, payment_method_types=None,
1833-
capture_method=None, payment_method_options=None, **kwargs):
1879+
capture_method=None, payment_method_options=None,
1880+
off_session=None, **kwargs):
18341881
if kwargs:
18351882
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))
18361883

@@ -1850,6 +1897,8 @@ def __init__(self, amount=None, currency=None, customer=None,
18501897
assert capture_method in ('automatic',
18511898
'automatic_async',
18521899
'manual')
1900+
if off_session is not None:
1901+
assert type(off_session) is bool
18531902
except AssertionError:
18541903
raise UserError(400, 'Bad request')
18551904

@@ -1872,23 +1921,27 @@ def __init__(self, amount=None, currency=None, customer=None,
18721921
self.invoice = None
18731922
self.next_action = None
18741923
self.capture_method = capture_method or 'automatic_async'
1924+
self.off_session = off_session or False
18751925

18761926
self._canceled = False
18771927
self._authentication_failed = False
18781928

18791929
def _on_success(self):
1930+
schedule_webhook(Event('payment_intent.succeeded', self))
18801931
if self.invoice:
18811932
invoice = Invoice._api_retrieve(self.invoice)
18821933
invoice._on_payment_success()
18831934

18841935
def _report_failure(self):
1936+
schedule_webhook(Event('payment_intent.payment_failed', self))
18851937
if self.invoice:
18861938
invoice = Invoice._api_retrieve(self.invoice)
18871939
invoice._on_payment_failure_now()
18881940

18891941
self.latest_charge._raise_failure()
18901942

1891-
def _on_failure_later(self):
1943+
def _report_async_failure(self):
1944+
schedule_webhook(Event('payment_intent.payment_failed', self))
18921945
if self.invoice:
18931946
invoice = Invoice._api_retrieve(self.invoice)
18941947
invoice._on_payment_failure_later()
@@ -1905,7 +1958,7 @@ def _create_charge(self, on_failure_now):
19051958
charge.payment_intent = self.id
19061959
self.latest_charge = charge
19071960
charge._initialize_charge(self._on_success, on_failure_now,
1908-
self._on_failure_later)
1961+
self._report_async_failure)
19091962

19101963
@property
19111964
def status(self):
@@ -1963,7 +2016,7 @@ def _api_create(cls, confirm=None, off_session=None, **data):
19632016
except AssertionError:
19642017
raise UserError(400, 'Bad request')
19652018

1966-
obj = super()._api_create(**data)
2019+
obj = super()._api_create(off_session=off_session, **data)
19672020

19682021
if confirm:
19692022
obj._confirm(on_failure_now=obj._report_failure)
@@ -1995,7 +2048,7 @@ def _api_confirm(cls, id, payment_method=None, **kwargs):
19952048
def _confirm(self, on_failure_now):
19962049
self._authentication_failed = False
19972050
payment_method = PaymentMethod._api_retrieve(self.payment_method)
1998-
if payment_method._requires_authentication():
2051+
if payment_method._payment_requires_authentication(self.off_session):
19992052
self.next_action = {
20002053
'type': 'use_stripe_sdk',
20012054
'use_stripe_sdk': {'type': 'three_d_secure_redirect',
@@ -2151,11 +2204,30 @@ def __init__(self, type=None, billing_details=None, card=None,
21512204

21522205
self.customer = None
21532206
self.metadata = metadata or {}
2207+
self._authenticated = False
2208+
2209+
def _setup_requires_authentication(self, usage=None):
2210+
if self.type == 'card':
2211+
if self._card_number == '4000002500003155':
2212+
# For this card, if we're setting up a payment method for
2213+
# off_session future payments, Stripe proactively forces
2214+
# 3DS authentication at setup time:
2215+
return usage == 'off_session'
2216+
2217+
return self._card_number in ('4000002760003184',
2218+
'4000008260003178',
2219+
'4000000000003220',
2220+
'4000000000003063',
2221+
'4000008400001629')
2222+
return False
21542223

2155-
def _requires_authentication(self):
2224+
def _payment_requires_authentication(self, off_session=False):
21562225
if self.type == 'card':
2157-
return self._card_number in ('4000002500003155',
2158-
'4000002760003184',
2226+
if self._card_number == '4000002500003155':
2227+
# See https://docs.stripe.com/testing#authentication-and-setup
2228+
return not (off_session and self._authenticated)
2229+
2230+
return self._card_number in ('4000002760003184',
21592231
'4000008260003178',
21602232
'4000000000003220',
21612233
'4000000000003063',
@@ -2268,6 +2340,14 @@ def _try_get_canonical_test_article(cls, id):
22682340
exp_month='12',
22692341
exp_year='2030',
22702342
cvc='123'))
2343+
if id == 'pm_card_authenticationRequiredOnSetup':
2344+
return PaymentMethod(
2345+
type='card',
2346+
card=dict(
2347+
number='4000002500003155',
2348+
exp_month='12',
2349+
exp_year='2030',
2350+
cvc='123'))
22712351

22722352
@classmethod
22732353
def _api_list_all(cls, url, customer=None, type=None, limit=None,
@@ -2607,6 +2687,8 @@ def __init__(self, charge=None, payment_intent=None, amount=None,
26072687
charge = payment_intent_obj.latest_charge.id
26082688

26092689
charge_obj = Charge._api_retrieve(charge)
2690+
if charge_obj.status == 'failed':
2691+
raise UserError(400, 'Cannot refund a failed payment.')
26102692

26112693
# All exceptions must be raised before this point.
26122694
super().__init__()
@@ -2707,18 +2789,24 @@ def __init__(self, type=None, currency=None, owner=None, metadata=None,
27072789
'mandate_url': 'https://fake/NXDSYREGC9PSMKWY',
27082790
}
27092791

2710-
def _requires_authentication(self):
2711-
if self.type == 'sepa_debit':
2712-
return PaymentMethod._requires_authentication(self)
2792+
def _setup_requires_authentication(self, usage=None):
2793+
if self.type in ('card', 'sepa_debit'):
2794+
return PaymentMethod._setup_requires_authentication(self, usage)
2795+
return False
2796+
2797+
def _payment_requires_authentication(self, off_session=False):
2798+
if self.type in ('card', 'sepa_debit'):
2799+
return PaymentMethod._payment_requires_authentication(
2800+
self, off_session)
27132801
return False
27142802

27152803
def _attaching_is_declined(self):
2716-
if self.type == 'sepa_debit':
2804+
if self.type in ('card', 'sepa_debit'):
27172805
return PaymentMethod._attaching_is_declined(self)
27182806
return False
27192807

27202808
def _charging_is_declined(self):
2721-
if self.type == 'sepa_debit':
2809+
if self.type in ('card', 'sepa_debit'):
27222810
return PaymentMethod._charging_is_declined(self)
27232811
return False
27242812

@@ -2804,7 +2892,7 @@ def _attach_pm(self, pm):
28042892
self.next_action = None
28052893
raise UserError(402, 'Your card was declined.',
28062894
{'code': 'card_declined'})
2807-
elif pm._requires_authentication():
2895+
elif pm._setup_requires_authentication(self.usage):
28082896
self.status = 'requires_action'
28092897
self.next_action = {'type': 'use_stripe_sdk',
28102898
'use_stripe_sdk': {
@@ -2836,10 +2924,45 @@ def _api_cancel(cls, id, use_stripe_sdk=None, client_secret=None,
28362924
obj.next_action = None
28372925
return obj
28382926

2927+
@classmethod
2928+
def _api_authenticate(cls, id, **kwargs):
2929+
"""This is a test-only endpoint to help test payment methods which
2930+
require authentication during setup.
2931+
2932+
E.g., for credit cards which are subject to the 3D Secure protocol,
2933+
when confirmed, SetupIntent may transition to the 'requires_action'
2934+
status, with a 'next_action' indicating some flow that usually
2935+
involves human interaction from the cardholder. This endpoint bypasses
2936+
that required action for test purposes.
2937+
"""
2938+
2939+
if kwargs:
2940+
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))
2941+
2942+
try:
2943+
assert type(id) is str and id.startswith('seti_')
2944+
except AssertionError:
2945+
raise UserError(400, 'Bad request')
2946+
2947+
obj = cls._api_retrieve(id)
2948+
2949+
if obj.status != 'requires_action':
2950+
raise UserError(400, 'Bad request')
2951+
2952+
pm = PaymentMethod._api_retrieve(obj.payment_method)
2953+
pm._authenticated = True
2954+
2955+
obj.status = 'succeeded'
2956+
obj.next_action = None
2957+
2958+
return obj
2959+
28392960

28402961
extra_apis.extend((
28412962
('POST', '/v1/setup_intents/{id}/confirm', SetupIntent._api_confirm),
2842-
('POST', '/v1/setup_intents/{id}/cancel', SetupIntent._api_cancel)))
2963+
('POST', '/v1/setup_intents/{id}/cancel', SetupIntent._api_cancel),
2964+
('POST', '/v1/setup_intents/{id}/_authenticate',
2965+
SetupIntent._api_authenticate)))
28432966

28442967

28452968
class Subscription(StripeObject):

0 commit comments

Comments
 (0)