mirror of
https://github.com/pretix/pretix.git
synced 2026-08-19 12:16:26 +00:00
handle captures that are declined (PRETIXEU-F8X & PRETIXEU-F93) (#6482)
* handle captures that are declined * handle failed captures not stored in sale * further fixes * fix tests
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib import messages
|
||||
@@ -361,7 +362,13 @@ def webhook(request, *args, **kwargs):
|
||||
if event_json['resource_type'] == 'checkout-order':
|
||||
payloadid = event_json['resource']['id']
|
||||
elif event_json['resource_type'] == 'refund' or event_json['resource_type'] == 'capture':
|
||||
payloadid = get_link(event_json['resource']['links'], 'up')['href'].split('/')[-1]
|
||||
payloadid = get_order_id(event_json.get('resource', {}).get('links', []))
|
||||
if payloadid is None:
|
||||
# if we get a PAYMENT.CAPTURE.DECLINED webhook because a capture wasn't created due to
|
||||
# violated validations, then it is labeled as a `capture` ressource_type but is in fact
|
||||
# an `order` ressource_type as there is no `capture`. So we have to fall back
|
||||
# See test_webhook_capture_declined for a redacted payload we've received
|
||||
payloadid = event_json['resource']['id']
|
||||
else:
|
||||
return HttpResponse("Not interested in this resource type", status=200)
|
||||
|
||||
@@ -426,6 +433,22 @@ def webhook(request, *args, **kwargs):
|
||||
payment.info = json.dumps(sale.dict())
|
||||
payment.save()
|
||||
|
||||
# the captures[] of the sales object only is populated if the capture request isn't rejected.
|
||||
# the capture request might be rejected if certain validations aren't met OR if the payment is
|
||||
# DECLINED, nevertheless we will get a webhook informing us about "PAYMENT.CAPTURE.DECLINED".
|
||||
# With no trace of it in `sale`.
|
||||
# So now we have to leave our current pattern of making only decisions based upon the
|
||||
# complete payment object (and checking whenever we receive a webhook), and instead need to fail
|
||||
# payment directly.
|
||||
# Otherwise we are caught in a loop:
|
||||
# 1. We get a webhook and get `sale`
|
||||
# 2. We see no proof of a capture attempt in `sale`
|
||||
# 3. We call execute_payment and trigger a new "PAYMENT.CAPTURE.DECLINED" webhook, GOTO 1
|
||||
if event_json['event_type'] == "PAYMENT.CAPTURE.DECLINED":
|
||||
payment.fail(log_data={'status': event_json['event_type']})
|
||||
logger.exception('PayPal Webhook PAYMENT.CAPTURE.DECLINED: {}'.format(event_json))
|
||||
return HttpResponse(status=200)
|
||||
|
||||
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['status'] in ('PARTIALLY_REFUNDED', 'REFUNDED', 'COMPLETED'):
|
||||
if event_json['resource_type'] == 'refund':
|
||||
try:
|
||||
@@ -473,6 +496,7 @@ def webhook(request, *args, **kwargs):
|
||||
if sale['status'] == 'COMPLETED':
|
||||
all_captures_completed = True
|
||||
any_pending_review = False
|
||||
any_failed = None
|
||||
for purchaseunit in sale['purchase_units']:
|
||||
for capture in purchaseunit['payments']['captures']:
|
||||
try:
|
||||
@@ -481,14 +505,19 @@ def webhook(request, *args, **kwargs):
|
||||
except ReferencedPayPalObject.MultipleObjectsReturned:
|
||||
pass
|
||||
|
||||
if capture['status'] not in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
|
||||
if capture['status'] in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
|
||||
pass
|
||||
elif capture['status'] in ("DECLINED", "FAILED"):
|
||||
all_captures_completed = False
|
||||
if capture['status_details']['reason'] == "PENDING_REVIEW":
|
||||
any_failed = True
|
||||
elif capture['status'] in ('PENDING'):
|
||||
all_captures_completed = False
|
||||
if capture.get('status_details', {}).get('reason', "") == "PENDING_REVIEW":
|
||||
any_pending_review = True
|
||||
else:
|
||||
raise ValueError("Unknown paypal capture state: {}".format(capture['status']))
|
||||
if all_captures_completed:
|
||||
try:
|
||||
payment.info = json.dumps(sale.dict())
|
||||
payment.save(update_fields=['info'])
|
||||
payment.confirm()
|
||||
prov.log_payment_duration(payment)
|
||||
except Quota.QuotaExceededException:
|
||||
@@ -496,6 +525,8 @@ def webhook(request, *args, **kwargs):
|
||||
if any_pending_review and payment.state != OrderPayment.PAYMENT_STATE_PENDING:
|
||||
payment.state = OrderPayment.PAYMENT_STATE_PENDING
|
||||
payment.save(update_fields=['state'])
|
||||
if any_failed:
|
||||
payment.fail()
|
||||
elif sale['status'] == 'APPROVED':
|
||||
try:
|
||||
request.session['payment_paypal_oid'] = payment.info_data['id']
|
||||
@@ -529,9 +560,17 @@ def isu_disconnect(request, **kwargs):
|
||||
}))
|
||||
|
||||
|
||||
def get_link(links, rel):
|
||||
ORDER_ID_RE = re.compile(r"/checkout/orders/([^/?]+)")
|
||||
|
||||
|
||||
def get_order_id(links):
|
||||
for link in links:
|
||||
if link['rel'] == rel:
|
||||
return link
|
||||
if link.get('rel', "") == "up" and link.get('href', None) is not None:
|
||||
return link['href'].split('/')[-1]
|
||||
|
||||
for link in links:
|
||||
match = ORDER_ID_RE.search(link.get("href", ""))
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
@@ -837,3 +837,62 @@ def test_webhook_pending_payment(env, client, monkeypatch):
|
||||
order.refresh_from_db()
|
||||
with scopes_disabled():
|
||||
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_PENDING
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_webhook_capture_declined(env, client, monkeypatch):
|
||||
order = env[1]
|
||||
order.status = Order.STATUS_PENDING
|
||||
order.save()
|
||||
with scopes_disabled():
|
||||
order.payments.update(state=OrderPayment.PAYMENT_STATE_CREATED)
|
||||
|
||||
pp_order = Result(get_test_order_review_pending())
|
||||
mock_orders_get_request = MagicMock(return_value=pp_order)
|
||||
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", mock_orders_get_request)
|
||||
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
|
||||
with scopes_disabled():
|
||||
ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(),
|
||||
reference="806440346Y391300T")
|
||||
|
||||
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_CREATED
|
||||
|
||||
client.post('/_paypal/webhook/', json.dumps(
|
||||
{
|
||||
"create_time": "2026-08-17T12:23:30.687Z",
|
||||
"event_type": "PAYMENT.CAPTURE.DECLINED",
|
||||
"event_version": "1.0",
|
||||
"id": "WH-XXXXXXXXXXXX-XXXXXXXXX",
|
||||
"links": [
|
||||
{
|
||||
"href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-XXXXXXXXXXXX-XXXXXXXXX",
|
||||
"method": "GET",
|
||||
"rel": "self"
|
||||
},
|
||||
{
|
||||
"href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-XXXXXXXXXXXX-XXXXXXXXX/resend",
|
||||
"method": "POST",
|
||||
"rel": "resend"
|
||||
}
|
||||
],
|
||||
"resource": {
|
||||
"amount": {},
|
||||
"custom_id": "Order ABC-12345",
|
||||
"disbursement_mode": "INSTANT",
|
||||
"final_capture": True,
|
||||
"id": "806440346Y391300T",
|
||||
"payee": {},
|
||||
"seller_protection": {},
|
||||
"seller_receivable_breakdown": {},
|
||||
"status": "DECLINED",
|
||||
"supplementary_data": {}
|
||||
},
|
||||
"resource_type": "capture",
|
||||
"resource_version": "2.0",
|
||||
"summary": "A payment capture for € 30.0 EUR was declined."
|
||||
}), content_type='application_json')
|
||||
|
||||
order = env[1]
|
||||
order.refresh_from_db()
|
||||
with scopes_disabled():
|
||||
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_FAILED
|
||||
|
||||
Reference in New Issue
Block a user