Skip to content

Commit

Permalink
Support preauth and refunds on PaymentIntents
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Ben Creech committed Sep 15, 2024
1 parent 4617990 commit bb05041
Show file tree
Hide file tree
Showing 2 changed files with 176 additions and 15 deletions.
79 changes: 65 additions & 14 deletions localstripe/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()))

Expand All @@ -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')

Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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':
Expand Down Expand Up @@ -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)))

Expand Down Expand Up @@ -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
Expand All @@ -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')

Expand Down
112 changes: 111 additions & 1 deletion test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=$(
Expand All @@ -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' \
Expand Down Expand Up @@ -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" ]

0 comments on commit bb05041

Please sign in to comment.