From 4514a451ea36559bd098d11000c12960a65d5f2c Mon Sep 17 00:00:00 2001 From: Ben Creech Date: Sat, 19 Oct 2024 18:22:22 -0400 Subject: [PATCH] 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. --- localstripe/resources.py | 157 ++++++++++++++++++++++++++++++++++----- test.sh | 131 ++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 17 deletions(-) diff --git a/localstripe/resources.py b/localstripe/resources.py index 418270b..afa2371 100644 --- a/localstripe/resources.py +++ b/localstripe/resources.py @@ -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) @@ -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' @@ -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())) @@ -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') @@ -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() @@ -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): @@ -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) @@ -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', @@ -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', @@ -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, @@ -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__() @@ -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 @@ -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': { @@ -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): diff --git a/test.sh b/test.sh index c5cee33..159a7bb 100755 --- a/test.sh +++ b/test.sh @@ -1068,6 +1068,12 @@ payment_intent=$( -d capture_method=manual \ | grep -oE 'pi_\w+' | head -n 1) +# we don't get a payment_intent.succeeded event from the pre-auth: +succeded_event=$( + curl -sSfg -u $SK: "$HOST/v1/events?type=payment_intent.succeeded" \ + | grep -oE "^ \"id\": \"$payment_intent\"" || true) +[ -z "$succeded_event" ] + # payment_intent was not captured captured=$( curl -sSfg -u $SK: $HOST/v1/payment_intents/$payment_intent \ @@ -1088,6 +1094,12 @@ captured=$( | grep -oE '"status": "succeeded"') [ -n "$captured" ] +# we do get a payment_intent.succeeded event from the capture: +succeded_event=$( + curl -sSfg -u $SK: "$HOST/v1/events?type=payment_intent.succeeded" \ + | grep -oE "^ \"id\": \"$payment_intent\"" || true) +[ -n "$succeded_event" ] + # difference between pre-auth and capture is refunded refunded=$( curl -sSfg -u $SK: $HOST/v1/payment_intents/$payment_intent \ @@ -1165,6 +1177,37 @@ code=$( -X POST -o /dev/null -w "%{http_code}") [ "$code" = 402 ] +# we get a payment_intent.payment_failed event: +failed_event=$( + curl -sSfg -u $SK: "$HOST/v1/events?type=payment_intent.payment_failed" \ + | grep -oE "^ \"id\": \"$payment_intent\"" || true) +[ -n "$failed_event" ] + +# we don't get a payment_intent.succeeded event: +cus=$(curl -sSfg -u $SK: $HOST/v1/customers \ + -d email=james.robinson@example.com \ + | grep -oE 'cus_\w+' | head -n 1) + +## test event timestamp filtering: +first_created=$( + curl -sSfg -u $SK: "$HOST/v1/events" \ + | grep -oP -m 1 'created": \K([0-9]+)' || true) +[ -n "$first_created" ] + +total_count=$(curl -sSfg -u $SK: $HOST/v1/events \ + | grep -oP '^ "total_count": \K([0-9]+)') +[ "$total_count" -gt 1 ] + +count=$( + curl -sSfg -u $SK: "$HOST/v1/events?created[lte]=$first_created" \ + | grep -oP '^ "total_count": \K([0-9]+)') +[ "$count" -le "$total_count" ] + +count=$( + curl -sSfg -u $SK: "$HOST/v1/events?created[lte]=$first_created&created[gt]=9999999999" \ + | grep -oP '^ "total_count": \K([0-9]+)') +[ "$count" -eq 0 ] + # Create a customer with card 4000000000000341 (that fails upon payment) and # make sure creating the subscription doesn't fail (although it creates it with # status 'incomplete'). This how Stripe behaves, see @@ -1194,3 +1237,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=on_session@example.com \ + | 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=off_session@example.com \ + | 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" ]