Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add event search, payment_intent events, and off_session 3DS #238

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 140 additions & 17 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 @@ -1115,6 +1120,47 @@ def _api_update(cls, id, **data):
def _api_delete(cls, id):
raise UserError(405, 'Method Not Allowed')

@classmethod
def _api_list_all(cls, url, type=None, created=None, limit=None,
starting_after=None, **kwargs):
if kwargs:
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))

filters = []
try:
if type is not None:
assert _type(type) is str
filters.append(lambda obj: obj.type == type)
if created is not None:
assert _type(created) is dict
gt = try_convert_to_int(created.pop('gt', None))
if gt is not None:
filters.append(lambda obj: obj.created > gt)

gte = try_convert_to_int(created.pop('gte', None))
if gte is not None:
filters.append(lambda obj: obj.created >= gte)

lt = try_convert_to_int(created.pop('lt', None))
if lt is not None:
filters.append(lambda obj: obj.created < lt)

lte = try_convert_to_int(created.pop('lte', None))
if lte is not None:
filters.append(lambda obj: obj.created <= lte)

assert not created # no other params are supported
except AssertionError:
raise UserError(400, 'Bad request')

li = super()._api_list_all(
url, limit=limit, starting_after=starting_after
)

li._list = [obj for obj in li._list if all(f(obj) for f in filters)]

return li


class Invoice(StripeObject):
object = 'invoice'
Expand Down Expand Up @@ -1830,7 +1876,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 +1897,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,23 +1921,27 @@ 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

def _on_success(self):
schedule_webhook(Event('payment_intent.succeeded', self))
if self.invoice:
invoice = Invoice._api_retrieve(self.invoice)
invoice._on_payment_success()

def _report_failure(self):
schedule_webhook(Event('payment_intent.payment_failed', self))
if self.invoice:
invoice = Invoice._api_retrieve(self.invoice)
invoice._on_payment_failure_now()

self.latest_charge._raise_failure()

def _on_failure_later(self):
def _report_async_failure(self):
schedule_webhook(Event('payment_intent.payment_failed', self))
if self.invoice:
invoice = Invoice._api_retrieve(self.invoice)
invoice._on_payment_failure_later()
Expand All @@ -1905,7 +1958,7 @@ def _create_charge(self, on_failure_now):
charge.payment_intent = self.id
self.latest_charge = charge
charge._initialize_charge(self._on_success, on_failure_now,
self._on_failure_later)
self._report_async_failure)

@property
def status(self):
Expand Down Expand Up @@ -1963,7 +2016,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 +2048,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 +2204,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 +2340,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 @@ -2607,6 +2687,8 @@ def __init__(self, charge=None, payment_intent=None, amount=None,
charge = payment_intent_obj.latest_charge.id

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

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

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

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

def _attaching_is_declined(self):
if self.type == 'sepa_debit':
if self.type in ('card', 'sepa_debit'):
return PaymentMethod._attaching_is_declined(self)
return False

def _charging_is_declined(self):
if self.type == 'sepa_debit':
if self.type in ('card', 'sepa_debit'):
return PaymentMethod._charging_is_declined(self)
return False

Expand Down Expand Up @@ -2804,7 +2892,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 @@ -2836,10 +2924,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
Loading
Loading