mirror of
https://github.com/pretix/pretix.git
synced 2026-08-27 13:24:41 +00:00
BasePaymentProvider & PayPal2: allow to cancel pending payments on a per payment basis (Z#23240966) (#6472)
* move payment into pending on PENDING_REVIEW webhook * mark approved payment as pending * extend BasePaymentProvider to gate aborting pending payments on a payment per payment basis * add timeout to paypal after which a pending payment can be canceled * formatting * add missing negation * cleanup abort_pending_allowed methods * Apply suggestions from code review Co-authored-by: pajowu <pajowu@pajowu.de> * check all capture elements * rename method and change defaults * remove left over Constant * flake8 . --------- Co-authored-by: pajowu <pajowu@pajowu.de>
This commit is contained in:
co-authored by
pajowu
parent
4e5fbacf6d
commit
c4a5a9a84d
@@ -330,9 +330,24 @@ class BasePaymentProvider:
|
|||||||
payment method. This returns ``False`` by default which is no guarantee that
|
payment method. This returns ``False`` by default which is no guarantee that
|
||||||
aborting a pending payment can never happen, it just hides the frontend button
|
aborting a pending payment can never happen, it just hides the frontend button
|
||||||
to avoid users accidentally committing double payments.
|
to avoid users accidentally committing double payments.
|
||||||
|
If the decision doesn't depend on the specific payment, then only implementing
|
||||||
|
``abort_pending_allowed`` is enough, ``payment_abort_pending_allowed(payment: OrderPayment)``
|
||||||
|
is expected to take this into account.
|
||||||
|
As a consumer only evaluate ``payment_abort_pending_allowed(payment: OrderPayment)``
|
||||||
|
to check if aborting this pending payment is possible.
|
||||||
"""
|
"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _payment_abort_pending_allowed(self, payment: OrderPayment) -> bool:
|
||||||
|
"""
|
||||||
|
Experimental: This might change during upcomming releases.
|
||||||
|
Whether or not a user can abort a payment in pending state to switch to another
|
||||||
|
payment method. This returns ``self.abort_pending_allowed`` by default which is
|
||||||
|
no guarantee that aborting a pending payment can never happen, it just hides the
|
||||||
|
frontend button to avoid users accidentally committing double payments.
|
||||||
|
"""
|
||||||
|
return self.abort_pending_allowed
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requires_invoice_immediately(self):
|
def requires_invoice_immediately(self):
|
||||||
"""
|
"""
|
||||||
@@ -1019,10 +1034,12 @@ class BasePaymentProvider:
|
|||||||
On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method).
|
On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method).
|
||||||
On failure, you should raise a PaymentException.
|
On failure, you should raise a PaymentException.
|
||||||
"""
|
"""
|
||||||
if payment.state == OrderPayment.PAYMENT_STATE_PENDING and not self.abort_pending_allowed:
|
|
||||||
raise PaymentException(_(
|
if payment.state == OrderPayment.PAYMENT_STATE_PENDING:
|
||||||
"This payment is already being processed and can not be canceled any more."
|
if not self._payment_abort_pending_allowed(payment):
|
||||||
))
|
raise PaymentException(_(
|
||||||
|
"This payment is already being processed and cannot be canceled any more."
|
||||||
|
))
|
||||||
|
|
||||||
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||||
payment.save(update_fields=['state'])
|
payment.save(update_fields=['state'])
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
@@ -193,6 +193,31 @@ class PaypalSettingsHolder(BasePaymentProvider):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)),
|
)),
|
||||||
|
('allow_retries_during_compliance_hold',
|
||||||
|
forms.BooleanField(
|
||||||
|
label=_('Allow further payments during compliance hold'),
|
||||||
|
help_text=_(
|
||||||
|
'PayPals fraud prevention might block processing of individual payments for a considerable amount '
|
||||||
|
'of time. The payment is marked as "pending" during this time window. You can allow your customers to '
|
||||||
|
'start another payment attempts during that window. This might result in them being charged twice if the'
|
||||||
|
'original payment is approved.'
|
||||||
|
),
|
||||||
|
required=False
|
||||||
|
)),
|
||||||
|
('timeout_payment_during_compliance_hold',
|
||||||
|
forms.IntegerField(
|
||||||
|
label=_('Timeout further payment attempts'),
|
||||||
|
help_text=_(
|
||||||
|
'Time duration in minutes after which another payment attempt is possible, while the last payment is '
|
||||||
|
'still under investigation.'
|
||||||
|
),
|
||||||
|
required=False,
|
||||||
|
widget=forms.NumberInput(
|
||||||
|
attrs={
|
||||||
|
'data-checkbox-dependency': '#id_payment_paypal_allow_retries_during_compliance_hold',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -515,8 +540,16 @@ class PaypalMethod(BasePaymentProvider):
|
|||||||
'XPF': 0,
|
'XPF': 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@property
|
def _payment_abort_pending_allowed(self, payment) -> bool:
|
||||||
def abort_pending_allowed(self):
|
if not self.settings.get('allow_retries_during_compliance_hold', as_type=bool, default=False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if payment.info_data.get('create_time', False):
|
||||||
|
create_time = datetime.fromisoformat(payment.info_data['create_time'])
|
||||||
|
duration = self.settings.get('timeout_payment_during_compliance_hold', as_type=int, default=10)
|
||||||
|
if datetime.now(tz=timezone.utc) - create_time > timedelta(minutes=duration):
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _create_paypal_order(self, request, payment=None, cart_total=None):
|
def _create_paypal_order(self, request, payment=None, cart_total=None):
|
||||||
@@ -678,6 +711,8 @@ class PaypalMethod(BasePaymentProvider):
|
|||||||
else:
|
else:
|
||||||
pp_captured_order = response.result
|
pp_captured_order = response.result
|
||||||
payment.info = json.dumps(pp_captured_order.dict())
|
payment.info = json.dumps(pp_captured_order.dict())
|
||||||
|
if pp_captured_order.status == 'APPROVED':
|
||||||
|
payment.state = OrderPayment.PAYMENT_STATE_PENDING
|
||||||
payment.save()
|
payment.save()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -857,14 +892,20 @@ class PaypalMethod(BasePaymentProvider):
|
|||||||
logger.info('{}: {} - paypal payment processing time'.format(str(payment.global_id), str(duration)))
|
logger.info('{}: {} - paypal payment processing time'.format(str(payment.global_id), str(duration)))
|
||||||
|
|
||||||
def payment_pending_render(self, request, payment) -> str:
|
def payment_pending_render(self, request, payment) -> str:
|
||||||
retry = True
|
stuck_in_compliance = False
|
||||||
|
retry = self._payment_abort_pending_allowed(payment)
|
||||||
try:
|
try:
|
||||||
if (
|
for purchase_unit in payment.info_data['purchase_units']:
|
||||||
payment.info
|
for capture in purchase_unit['payments']['captures']:
|
||||||
and payment.info_data['purchase_units'][0]['payments']['captures'][0]['status'] == 'PENDING'
|
if capture['status'] == "PENDING":
|
||||||
):
|
stuck_in_compliance = True
|
||||||
retry = False
|
except KeyError:
|
||||||
except (KeyError, IndexError):
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
if payment.info_data.get('status') == "APPROVED":
|
||||||
|
stuck_in_compliance = True
|
||||||
|
except (KeyError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
error = payment.info_data.get("error", {})
|
error = payment.info_data.get("error", {})
|
||||||
@@ -872,7 +913,8 @@ class PaypalMethod(BasePaymentProvider):
|
|||||||
|
|
||||||
template = get_template('pretixplugins/paypal2/pending.html')
|
template = get_template('pretixplugins/paypal2/pending.html')
|
||||||
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
|
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
|
||||||
'retry': retry, 'order': payment.order, 'is_known_issue': is_known_issue}
|
'stuck_in_compliance': stuck_in_compliance, 'retry': retry, 'order': payment.order,
|
||||||
|
'is_known_issue': is_known_issue}
|
||||||
return template.render(ctx)
|
return template.render(ctx)
|
||||||
|
|
||||||
def matching_id(self, payment: OrderPayment):
|
def matching_id(self, payment: OrderPayment):
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse
|
|||||||
|
|
||||||
settings_hierarkey.add_default('payment_paypal_debug_buyer_country', '', str)
|
settings_hierarkey.add_default('payment_paypal_debug_buyer_country', '', str)
|
||||||
settings_hierarkey.add_default('payment_paypal_method_wallet', True, bool)
|
settings_hierarkey.add_default('payment_paypal_method_wallet', True, bool)
|
||||||
|
settings_hierarkey.add_default('payment_paypal_allow_retries_during_compliance_hold', False, bool)
|
||||||
|
settings_hierarkey.add_default('payment_paypal_timeout_payment_during_compliance_hold', 10, int)
|
||||||
|
|
||||||
|
|
||||||
def _nonce(request):
|
def _nonce(request):
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
Your payment has failed due to a known issue within PayPal. Please try again, there is a high chance of the
|
Your payment has failed due to a known issue within PayPal. Please try again, there is a high chance of the
|
||||||
payment succeeding on a second or third attempt. You can also try other payment methods, if available.
|
payment succeeding on a second or third attempt. You can also try other payment methods, if available.
|
||||||
{% endblocktrans %}</div>
|
{% endblocktrans %}</div>
|
||||||
{% else %}
|
{% elif stuck_in_compliance %}
|
||||||
<p>{% blocktrans trimmed %}
|
<p>{% blocktrans trimmed %}
|
||||||
Our attempt to execute your payment via PayPal has failed. Please try again or contact us.
|
Your payment is being processed by PayPal. This takes longer than usual. You can wait until PayPal
|
||||||
|
acknowledges the payment or you can try paying again with this or another payment method.
|
||||||
|
This might result in you being charged twice in case PayPal allows your initial payment attempt.
|
||||||
|
Please contact us to resolve this case.
|
||||||
{% endblocktrans %}</p>
|
{% endblocktrans %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -471,8 +471,8 @@ def webhook(request, *args, **kwargs):
|
|||||||
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED,
|
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED,
|
||||||
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED):
|
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED):
|
||||||
if sale['status'] == 'COMPLETED':
|
if sale['status'] == 'COMPLETED':
|
||||||
any_captures = False
|
|
||||||
all_captures_completed = True
|
all_captures_completed = True
|
||||||
|
any_pending_review = False
|
||||||
for purchaseunit in sale['purchase_units']:
|
for purchaseunit in sale['purchase_units']:
|
||||||
for capture in purchaseunit['payments']['captures']:
|
for capture in purchaseunit['payments']['captures']:
|
||||||
try:
|
try:
|
||||||
@@ -483,9 +483,9 @@ def webhook(request, *args, **kwargs):
|
|||||||
|
|
||||||
if capture['status'] not in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
|
if capture['status'] not in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
|
||||||
all_captures_completed = False
|
all_captures_completed = False
|
||||||
else:
|
if capture['status_details']['reason'] == "PENDING_REVIEW":
|
||||||
any_captures = True
|
any_pending_review = True
|
||||||
if any_captures and all_captures_completed:
|
if all_captures_completed:
|
||||||
try:
|
try:
|
||||||
payment.info = json.dumps(sale.dict())
|
payment.info = json.dumps(sale.dict())
|
||||||
payment.save(update_fields=['info'])
|
payment.save(update_fields=['info'])
|
||||||
@@ -493,6 +493,9 @@ def webhook(request, *args, **kwargs):
|
|||||||
prov.log_payment_duration(payment)
|
prov.log_payment_duration(payment)
|
||||||
except Quota.QuotaExceededException:
|
except Quota.QuotaExceededException:
|
||||||
pass
|
pass
|
||||||
|
if any_pending_review and payment.state != OrderPayment.PAYMENT_STATE_PENDING:
|
||||||
|
payment.state = OrderPayment.PAYMENT_STATE_PENDING
|
||||||
|
payment.save(update_fields=['state'])
|
||||||
elif sale['status'] == 'APPROVED':
|
elif sale['status'] == 'APPROVED':
|
||||||
try:
|
try:
|
||||||
request.session['payment_paypal_oid'] = payment.info_data['id']
|
request.session['payment_paypal_oid'] = payment.info_data['id']
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
|
|||||||
pp = lp.payment_provider
|
pp = lp.payment_provider
|
||||||
ctx['last_payment_info'] = pp.payment_pending_render(self.request, ctx['last_payment'])
|
ctx['last_payment_info'] = pp.payment_pending_render(self.request, ctx['last_payment'])
|
||||||
|
|
||||||
if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed:
|
if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp._payment_abort_pending_allowed(lp):
|
||||||
ctx['can_pay'] = False
|
ctx['can_pay'] = False
|
||||||
|
|
||||||
ctx['can_pay'] = ctx['can_pay'] and self.order._can_be_paid() is True
|
ctx['can_pay'] = ctx['can_pay'] and self.order._can_be_paid() is True
|
||||||
@@ -611,7 +611,8 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView):
|
|||||||
|
|
||||||
if self.open_payment:
|
if self.open_payment:
|
||||||
pp = self.open_payment.payment_provider
|
pp = self.open_payment.payment_provider
|
||||||
if self.open_payment.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed:
|
if self.open_payment.state == OrderPayment.PAYMENT_STATE_PENDING and not pp._payment_abort_pending_allowed(
|
||||||
|
self.open_payment):
|
||||||
messages.error(request, _('A payment is currently pending for this order.'))
|
messages.error(request, _('A payment is currently pending for this order.'))
|
||||||
return redirect(self.get_order_url())
|
return redirect(self.get_order_url())
|
||||||
|
|
||||||
@@ -1718,7 +1719,7 @@ class OrderChangeMixin:
|
|||||||
|
|
||||||
if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING:
|
if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING:
|
||||||
for p in self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_PENDING):
|
for p in self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_PENDING):
|
||||||
if not p.payment_provider.abort_pending_allowed:
|
if not p.payment_provider._payment_abort_pending_allowed(p):
|
||||||
raise OrderError(_('You may not change your order in a way that requires additional payment while '
|
raise OrderError(_('You may not change your order in a way that requires additional payment while '
|
||||||
'we are processing your current payment. Please check back after your current '
|
'we are processing your current payment. Please check back after your current '
|
||||||
'payment has been accepted.'))
|
'payment has been accepted.'))
|
||||||
|
|||||||
@@ -244,6 +244,61 @@ def get_test_refund():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_test_order_review_pending():
|
||||||
|
return {'id': '806440346Y391300T',
|
||||||
|
'intent': 'CAPTURE',
|
||||||
|
'status': 'COMPLETED',
|
||||||
|
'purchase_units': [{'reference_id': 'default',
|
||||||
|
'amount': {'currency_code': 'EUR', 'value': '43.59'},
|
||||||
|
'payee': {'email_address': 'dummy-facilitator@dummy.dummy',
|
||||||
|
'merchant_id': 'G6R2B9YXADKWW'},
|
||||||
|
'description': 'Order JWJGC for PayPal v2',
|
||||||
|
'custom_id': 'Order PAYPALV2-JWJGC',
|
||||||
|
'soft_descriptor': 'MARTINFACIL',
|
||||||
|
'payments': {'captures': [{'id': '22A4162004478570J',
|
||||||
|
'status': 'PENDING',
|
||||||
|
'status_details': {
|
||||||
|
'reason': 'PENDING_REVIEW'
|
||||||
|
},
|
||||||
|
'amount': {'currency_code': 'EUR', 'value': '43.59'},
|
||||||
|
'final_capture': True,
|
||||||
|
'disbursement_mode': 'INSTANT',
|
||||||
|
'seller_protection': {'status': 'ELIGIBLE',
|
||||||
|
'dispute_categories': [
|
||||||
|
'ITEM_NOT_RECEIVED',
|
||||||
|
'UNAUTHORIZED_TRANSACTION']},
|
||||||
|
'seller_receivable_breakdown': {
|
||||||
|
'gross_amount': {'currency_code': 'EUR',
|
||||||
|
'value': '43.59'},
|
||||||
|
'paypal_fee': {'currency_code': 'EUR', 'value': '1.18'},
|
||||||
|
'net_amount': {'currency_code': 'EUR',
|
||||||
|
'value': '42.41'}},
|
||||||
|
'custom_id': 'Order PAYPALV2-JWJGC',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J',
|
||||||
|
'rel': 'self',
|
||||||
|
'method': 'GET'},
|
||||||
|
{
|
||||||
|
'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund',
|
||||||
|
'rel': 'refund',
|
||||||
|
'method': 'POST'},
|
||||||
|
{
|
||||||
|
'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T',
|
||||||
|
'rel': 'up',
|
||||||
|
'method': 'GET'}],
|
||||||
|
'create_time': '2022-04-28T12:00:22Z',
|
||||||
|
'update_time': '2022-04-28T12:00:22Z'}]}}],
|
||||||
|
'payer': {'name': {'given_name': 'test', 'surname': 'buyer'},
|
||||||
|
'email_address': 'dummy@dummy.dummy',
|
||||||
|
'payer_id': 'Q739JNKWH67HE',
|
||||||
|
'address': {'country_code': 'DE'}},
|
||||||
|
'create_time': '2022-04-28T11:59:59Z',
|
||||||
|
'update_time': '2022-04-28T12:00:22Z',
|
||||||
|
'links': [{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T',
|
||||||
|
'rel': 'self',
|
||||||
|
'method': 'GET'}]}
|
||||||
|
|
||||||
|
|
||||||
class Object():
|
class Object():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -690,3 +745,95 @@ def test_webhook_refund2(env, client, monkeypatch):
|
|||||||
assert r.payment == order.payments.first()
|
assert r.payment == order.payments.first()
|
||||||
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
|
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
|
||||||
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL
|
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_webhook_pending_payment(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(
|
||||||
|
{
|
||||||
|
"id": "WH-0AH02875JL566842H-2AF03788XV8252724",
|
||||||
|
"create_time": "2022-04-28T12:00:37.077Z",
|
||||||
|
"resource_type": "capture",
|
||||||
|
"event_type": "PAYMENT.CAPTURE.PENDING",
|
||||||
|
"summary": "Payment pending for € 43.59 EUR",
|
||||||
|
"resource": {
|
||||||
|
"update_time": "2022-04-28T12:00:22Z",
|
||||||
|
"create_time": "2022-04-28T11:59:59Z",
|
||||||
|
"amount": {
|
||||||
|
"currency_code": "EUR",
|
||||||
|
"value": "43.59"
|
||||||
|
},
|
||||||
|
"custom_id": "Order PAYPALV2-JWJGC",
|
||||||
|
"final_capture": True,
|
||||||
|
"id": "22A4162004478570J",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"href": "https://api.sandbox.paypal.com/v2/payments/captures/5M631111V9599860P",
|
||||||
|
"method": "GET",
|
||||||
|
"rel": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "https://api.sandbox.paypal.com/v2/payments/captures/5M631111V9599860P/refund",
|
||||||
|
"method": "POST",
|
||||||
|
"rel": "refund"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T",
|
||||||
|
"method": "GET",
|
||||||
|
"rel": "up"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"payee": {
|
||||||
|
"email_address": "sb-ybfun52428692@business.example.com",
|
||||||
|
"merchant_id": "DLM8QKDR3CFZW"
|
||||||
|
},
|
||||||
|
"seller_protection": {
|
||||||
|
"status": "NOT_ELIGIBLE"
|
||||||
|
},
|
||||||
|
"status": "PENDING",
|
||||||
|
"status_details": {
|
||||||
|
"reason": "PENDING_REVIEW"
|
||||||
|
},
|
||||||
|
"supplementary_data": {
|
||||||
|
"related_ids": {
|
||||||
|
"order_id": "9L827155WD164573M"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-0AH02875JL566842H-2AF03788XV8252724",
|
||||||
|
"method": "GET",
|
||||||
|
"rel": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-0AH02875JL566842H-2AF03788XV8252724/resend",
|
||||||
|
"method": "POST",
|
||||||
|
"rel": "resend"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event_version": "1.0",
|
||||||
|
"resource_version": "2.0"
|
||||||
|
}
|
||||||
|
), content_type='application_json')
|
||||||
|
|
||||||
|
order = env[1]
|
||||||
|
order.refresh_from_db()
|
||||||
|
with scopes_disabled():
|
||||||
|
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_PENDING
|
||||||
|
|||||||
Reference in New Issue
Block a user