From bb05041362ac37fdb692da09269c8ce561fba0a2 Mon Sep 17 00:00:00 2001 From: Ben Creech Date: Mon, 2 Sep 2024 23:09:50 -0400 Subject: [PATCH] Support preauth and refunds on PaymentIntents Before this commit, localstripe already supported Charges with pre-auths and refunds, and it supported PaymentIntents without pre-auths or refunds. Since PaymentIntents are either preferred or supported for most APIs now, let's support pre-auths on PaymentIntents! Since the PaymentIntent implementation in localstripe just proxies most stuff onto Charge objects, we can proxy pre-auths onto them too. Same for refunds. We are already using this at Via (ridewithvia.com) to test our Stripe integration. The tests are a big copy-paste-modify on the Charges tests, which already seemed pretty comprehensive... and we add tests for refunds on both Charges and PaymentIntents. --- localstripe/resources.py | 79 ++++++++++++++++++++++----- test.sh | 112 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 15 deletions(-) diff --git a/localstripe/resources.py b/localstripe/resources.py index 8823dc3f..5d74d942 100644 --- a/localstripe/resources.py +++ b/localstripe/resources.py @@ -589,25 +589,27 @@ def _api_capture(cls, id, amount=None, **kwargs): raise UserError(400, 'Bad request') obj = cls._api_retrieve(id) + obj._capture(amount) + return obj + def _capture(self, amount): if amount is None: - amount = obj.amount + amount = self.amount amount = try_convert_to_int(amount) try: - assert type(amount) is int and 0 <= amount <= obj.amount - assert obj.captured is False + assert type(amount) is int and 0 <= amount <= self.amount + assert self.captured is False except AssertionError: raise UserError(400, 'Bad request') def on_success(): - obj.captured = True - if amount < obj.amount: - refunded = obj.amount - amount - Refund(obj.id, refunded) + self.captured = True + if amount < self.amount: + refunded = self.amount - amount + Refund(charge=self.id, amount=refunded) - obj._trigger_payment(on_success) - return obj + self._trigger_payment(on_success) @property def paid(self): @@ -1810,7 +1812,8 @@ class PaymentIntent(StripeObject): _id_prefix = 'pi_' def __init__(self, amount=None, currency=None, customer=None, - payment_method=None, metadata=None, **kwargs): + payment_method=None, metadata=None, payment_method_types=None, + capture_method=None, payment_method_options=None, **kwargs): if kwargs: raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys())) @@ -1826,6 +1829,10 @@ def __init__(self, amount=None, currency=None, customer=None, assert (payment_method.startswith('pm_') or payment_method.startswith('src_') or payment_method.startswith('card_')) + if capture_method is not None: + assert capture_method in ('automatic', + 'automatic_async', + 'manual') except AssertionError: raise UserError(400, 'Bad request') @@ -1847,6 +1854,7 @@ def __init__(self, amount=None, currency=None, customer=None, self.metadata = metadata or {} self.invoice = None self.next_action = None + self.capture_method = capture_method or 'automatic_async' self._canceled = False self._authentication_failed = False @@ -1873,7 +1881,8 @@ def on_failure_later(): charge = Charge(amount=self.amount, currency=self.currency, customer=self.customer, - source=self.payment_method) + source=self.payment_method, + capture=(self.capture_method != "manual")) self.latest_charge = charge charge._trigger_payment(on_success, on_failure_now, on_failure_later) @@ -1887,6 +1896,9 @@ def status(self): return 'requires_action' if self.latest_charge is None: return 'requires_confirmation' + if (self.latest_charge.status == 'succeeded' and + not self.latest_charge.captured): + return 'requires_capture' if self.latest_charge.status == 'succeeded': return 'succeeded' elif self.latest_charge.status == 'failed': @@ -2020,10 +2032,25 @@ def _api_authenticate(cls, id, client_secret=None, success=False, return obj + @classmethod + def _api_capture(cls, id, amount_to_capture=None, **kwargs): + if kwargs: + raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys())) + + try: + assert type(id) is str and id.startswith('pi_') + except AssertionError: + raise UserError(400, 'Bad request') + + obj = cls._api_retrieve(id) + obj.latest_charge._capture(amount=amount_to_capture) + return obj + extra_apis.extend(( ('POST', '/v1/payment_intents/{id}/confirm', PaymentIntent._api_confirm), ('POST', '/v1/payment_intents/{id}/cancel', PaymentIntent._api_cancel), + ('POST', '/v1/payment_intents/{id}/capture', PaymentIntent._api_capture), ('POST', '/v1/payment_intents/{id}/_authenticate', PaymentIntent._api_authenticate))) @@ -2488,24 +2515,40 @@ class Refund(StripeObject): object = 'refund' _id_prefix = 're_' - def __init__(self, charge=None, amount=None, metadata=None, **kwargs): + def __init__(self, charge=None, payment_intent=None, amount=None, + metadata=None, **kwargs): if kwargs: raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys())) amount = try_convert_to_int(amount) try: - assert type(charge) is str and charge.startswith('ch_') + if charge is not None: + assert type(charge) is str and charge.startswith('ch_') + assert payment_intent is None + elif payment_intent is not None: + assert (type(payment_intent) is str and + payment_intent.startswith('pi_')) + else: + raise UserError(400, 'Expected charge or payment_intent') if amount is not None: assert type(amount) is int and amount > 0 except AssertionError: raise UserError(400, 'Bad request') + if payment_intent is not None: + payment_intent_obj = PaymentIntent._api_retrieve(payment_intent) + if payment_intent_obj.status == 'requires_payment_method': + raise UserError(400, 'Cannot refund a failed payment.') + + charge = payment_intent_obj.latest_charge.id + charge_obj = Charge._api_retrieve(charge) # All exceptions must be raised before this point. super().__init__() self.charge = charge + self.payment_intent = payment_intent self.metadata = metadata or {} self.amount = amount self.date = self.created @@ -2525,10 +2568,18 @@ def __init__(self, charge=None, amount=None, metadata=None, **kwargs): self.balance_transaction = txn.id @classmethod - def _api_list_all(cls, url, charge=None, limit=None, starting_after=None): + def _api_list_all(cls, url, charge=None, payment_intent=None, limit=None, + starting_after=None): try: if charge is not None: assert type(charge) is str and charge.startswith('ch_') + assert payment_intent is None + elif payment_intent is not None: + assert (type(payment_intent) is str and + payment_intent.startswith('pi_')) + payment_intent_obj = PaymentIntent._api_retrieve( + payment_intent) + charge = payment_intent_obj.latest_charge.id except AssertionError: raise UserError(400, 'Bad request') diff --git a/test.sh b/test.sh index 9651654c..9ecd648a 100755 --- a/test.sh +++ b/test.sh @@ -367,7 +367,7 @@ captured=$( refunded=$( curl -sSfg -u $SK: $HOST/v1/charges/$charge \ | grep -oE '"amount_refunded": 0,') -[ -n "$captured" ] +[ -n "$refunded" ] # cannot capture an already captured charge code=$( @@ -376,6 +376,20 @@ code=$( -X POST) [ "$code" = 400 ] +# now refund it: +succeeded=$( + curl -sSfg -u $SK: $HOST/v1/refunds \ + -d charge=$charge \ + -X POST \ + | grep -oE '"status": "succeeded"') +[ -n "$succeeded" ] + +# the charge agrees that it was refunded: +refunded=$( + curl -sSfg -u $SK: $HOST/v1/charges/$charge \ + | grep -oE '"amount_refunded": 1000,') +[ -n "$refunded" ] + sepa_cus=$( curl -sSfg -u $SK: $HOST/v1/customers \ -d description='I pay with SEPA debit' \ @@ -965,3 +979,99 @@ charge=$(curl -sSfgG -u $SK: $HOST/v1/invoices \ -d expand[]=data.charge.refunds \ | grep -oE '"charge": null,') [ -n "$charge" ] + +### test payment_intents, which are supported and often preferred instead of +### charges in many APIs: + +# new payment_intents are captured by default +captured=$( + curl -sSfg -u $SK: $HOST/v1/payment_intents \ + -d customer=$cus \ + -d payment_method=$card \ + -d amount=1000 \ + -d confirm=true \ + -d currency=usd \ + | grep -oE '"captured": true,') +[ -n "$captured" ] + +# create a pre-auth payment_intent +payment_intent=$( + curl -sSfg -u $SK: $HOST/v1/payment_intents \ + -d customer=$cus \ + -d payment_method=$card \ + -d amount=1000 \ + -d confirm=true \ + -d currency=usd \ + -d capture_method=manual \ + | grep -oE 'pi_\w+' | head -n 1) + +# payment_intent was not captured +captured=$( + curl -sSfg -u $SK: $HOST/v1/payment_intents/$payment_intent \ + | grep -oE '"status": "requires_capture"') +[ -n "$captured" ] + +# cannot capture more than pre-authed amount +code=$( + curl -sg -o /dev/null -w "%{http_code}" \ + -u $SK: $HOST/v1/payment_intents/$payment_intent/capture \ + -d amount=2000) +[ "$code" = 400 ] + +# can capture less than the pre-auth amount +captured=$( + curl -sSfg -u $SK: $HOST/v1/payment_intents/$payment_intent/capture \ + -d amount_to_capture=800 \ + | grep -oE '"status": "succeeded"') +[ -n "$captured" ] + +# difference between pre-auth and capture is refunded +refunded=$( + curl -sSfg -u $SK: $HOST/v1/payment_intents/$payment_intent \ + | grep -oE '"amount_refunded": 200,') +[ -n "$captured" ] + +# create a pre-auth payment_intent +payment_intent=$( + curl -sSfg -u $SK: $HOST/v1/payment_intents \ + -d customer=$cus \ + -d payment_method=$card \ + -d amount=1000 \ + -d confirm=true \ + -d currency=usd \ + -d capture_method=manual \ + | grep -oE 'pi_\w+' | head -n 1) + +# capture the full amount (default) +captured=$( + curl -sSfg -u $SK: $HOST/v1/payment_intents/$payment_intent/capture \ + -X POST \ + | grep -oE '"captured": true,') +[ -n "$captured" ] + +# none is refunded +refunded=$( + curl -sSfg -u $SK: $HOST/v1/payment_intents/$payment_intent \ + | grep -oE '"amount_refunded": 0,') +[ -n "$refunded" ] + +# cannot capture an already captured payment_intent +code=$( + curl -sg -o /dev/null -w "%{http_code}" \ + -u $SK: $HOST/v1/payment_intents/$payment_intent/capture \ + -X POST) +[ "$code" = 400 ] + +# now refund it: +succeeded=$( + curl -sSfg -u $SK: $HOST/v1/refunds \ + -d payment_intent=$payment_intent \ + -X POST \ + | grep -oE '"status": "succeeded"') +[ -n "$succeeded" ] + +# the payment_intent agrees that it was refunded: +refunded=$( + curl -sSfg -u $SK: $HOST/v1/payment_intents/$payment_intent \ + | grep -oE '"amount_refunded": 1000,') +[ -n "$refunded" ]