Repository: saleor/saleor

Issue:
"Release payment" action requires dedicated implementation to work
Currently capture/refund/release actions in dashboard all inherit from `saleor.dashboard.order.forms.ManagePaymentForm`, that requests user to enter amount, and then uses this amount to call `Payment`-provided method that performs actuall payment op.

This approach works for capture and refund flows, but errors for release because `Payment.release` doesnt take any arguments, making this feature effectively broken.


Discussion:

Resolving patch:
diff --git a/saleor/dashboard/order/forms.py b/saleor/dashboard/order/forms.py
--- a/saleor/dashboard/order/forms.py
+++ b/saleor/dashboard/order/forms.py
@@ -92,14 +92,31 @@ def refund(self):
         return self.try_payment_action(self.payment.refund)
 
 
-class ReleasePaymentForm(ManagePaymentForm):
+class ReleasePaymentForm(forms.Form):
 
-    clean_status = PaymentStatus.PREAUTH
-    clean_error = pgettext_lazy(
-        'Payment form error', 'Only pre-authorized payments can be released')
+    def __init__(self, *args, **kwargs):
+        self.payment = kwargs.pop('payment')
+        super().__init__(*args, **kwargs)
+
+    def clean(self):
+        if self.payment.status != PaymentStatus.PREAUTH:
+            raise forms.ValidationError(
+                pgettext_lazy(
+                    'Payment form error',
+                    'Only pre-authorized payments can be released'))
+
+    def payment_error(self, message):
+        self.add_error(
+            None, pgettext_lazy(
+                'Payment form error', 'Payment gateway error: %s') % message)
 
     def release(self):
-        return self.try_payment_action(self.payment.release)
+        try:
+            self.payment.release()
+        except (PaymentError, ValueError) as e:
+            self.payment_error(str(e))
+            return False
+        return True
 
 
 class CancelOrderLineForm(forms.Form):


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,6 +9,7 @@
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.utils.encoding import smart_text
 from PIL import Image
+from payments import FraudStatus, PaymentStatus
 from prices import Money
 
 from saleor.account.models import Address, User
@@ -500,6 +501,63 @@ def order_with_variant_from_different_stocks(order_with_lines_and_stock):
     return order_with_lines_and_stock
 
 
+@pytest.fixture()
+def payment_waiting(order_with_lines_and_stock):
+    return order_with_lines_and_stock.payments.create(
+        variant='default', status=PaymentStatus.WAITING,
+        fraud_status=FraudStatus.ACCEPT, currency='USD',
+        total=order_with_lines_and_stock.total_gross.amount)
+
+
+@pytest.fixture()
+def payment_preauth(order_with_lines_and_stock):
+    return order_with_lines_and_stock.payments.create(
+        variant='default', status=PaymentStatus.PREAUTH,
+        fraud_status=FraudStatus.ACCEPT, currency='USD',
+        total=order_with_lines_and_stock.total_gross.amount)
+
+
+@pytest.fixture()
+def payment_confirmed(order_with_lines_and_stock):
+    order_amount = order_with_lines_and_stock.total_gross.amount
+    return order_with_lines_and_stock.payments.create(
+        variant='default', status=PaymentStatus.CONFIRMED,
+        fraud_status=FraudStatus.ACCEPT, currency='USD',
+        total=order_amount, captured_amount=order_amount)
+
+
+@pytest.fixture()
+def payment_rejected(order_with_lines_and_stock):
+    return order_with_lines_and_stock.payments.create(
+        variant='default', status=PaymentStatus.REJECTED,
+        fraud_status=FraudStatus.ACCEPT, currency='USD',
+        total=order_with_lines_and_stock.total_gross.amount)
+
+
+@pytest.fixture()
+def payment_refunded(order_with_lines_and_stock):
+    return order_with_lines_and_stock.payments.create(
+        variant='default', status=PaymentStatus.REFUNDED,
+        fraud_status=FraudStatus.ACCEPT, currency='USD',
+        total=order_with_lines_and_stock.total_gross.amount)
+
+
+@pytest.fixture()
+def payment_error(order_with_lines_and_stock):
+    return order_with_lines_and_stock.payments.create(
+        variant='default', status=PaymentStatus.ERROR,
+        fraud_status=FraudStatus.ACCEPT, currency='USD',
+        total=order_with_lines_and_stock.total_gross.amount)
+
+
+@pytest.fixture()
+def payment_input(order_with_lines_and_stock):
+    return order_with_lines_and_stock.payments.create(
+        variant='default', status=PaymentStatus.INPUT,
+        fraud_status=FraudStatus.ACCEPT, currency='USD',
+        total=order_with_lines_and_stock.total_gross.amount)
+
+
 @pytest.fixture()
 def sale(db, default_category):
     sale = Sale.objects.create(name="Sale", value=5)
diff --git a/tests/dashboard/test_order.py b/tests/dashboard/test_order.py
--- a/tests/dashboard/test_order.py
+++ b/tests/dashboard/test_order.py
@@ -3,7 +3,7 @@
 
 import pytest
 from django.urls import reverse
-from payments import FraudStatus, PaymentStatus
+from payments import PaymentStatus
 from prices import Money, TaxedMoney
 from tests.utils import get_redirect_location, get_url_path
 
@@ -18,47 +18,382 @@
 
 @pytest.mark.integration
 @pytest.mark.django_db
-def test_view_capture_order_payment(admin_client, order_with_lines_and_stock):
+def test_view_capture_order_payment_preauth(
+        admin_client, order_with_lines_and_stock, payment_preauth):
     order = order_with_lines_and_stock
-    payment = order.payments.create(
-        variant='default', status=PaymentStatus.PREAUTH,
-        fraud_status=FraudStatus.ACCEPT, currency='USD', total='100.0')
+    url = reverse(
+        'dashboard:capture-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_preauth.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {
+            'csrfmiddlewaretoken': 'hello',
+            'amount': str(order.total.gross.amount)})
+    assert response.status_code == 302
+    assert order.payments.last().status == PaymentStatus.CONFIRMED
+    assert order.payments.last().get_captured_price() == order.total
+
 
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_capture_order_invalid_payment_waiting_status(
+        admin_client, order_with_lines_and_stock, payment_waiting):
+    order = order_with_lines_and_stock
     url = reverse(
         'dashboard:capture-payment', kwargs={
-            'order_pk': order.pk,
-            'payment_pk': payment.pk})
+            'order_pk': order.pk, 'payment_pk': payment_waiting.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.WAITING
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_capture_order_invalid_payment_confirmed_status(
+        admin_client, order_with_lines_and_stock, payment_confirmed):
+    order = order_with_lines_and_stock
+    url = reverse(
+        'dashboard:capture-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_confirmed.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.CONFIRMED
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_capture_order_invalid_payment_rejected_status(
+        admin_client, order_with_lines_and_stock, payment_rejected):
+    order = order_with_lines_and_stock
+    url = reverse(
+        'dashboard:capture-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_rejected.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.REJECTED
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_capture_order_invalid_payment_refunded_status(
+        admin_client, order_with_lines_and_stock, payment_refunded):
+    order = order_with_lines_and_stock
+    url = reverse(
+        'dashboard:capture-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_refunded.pk})
     response = admin_client.get(url)
     assert response.status_code == 200
 
     response = admin_client.post(
         url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.REFUNDED
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_capture_order_invalid_payment_error_status(
+        admin_client, order_with_lines_and_stock, payment_error):
+    order = order_with_lines_and_stock
+    url = reverse(
+        'dashboard:capture-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_error.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.ERROR
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_capture_order_invalid_payment_input_status(
+        admin_client, order_with_lines_and_stock, payment_input):
+    order = order_with_lines_and_stock
+    url = reverse(
+        'dashboard:capture-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_input.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.INPUT
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_refund_order_payment_confirmed(
+        admin_client, order_with_lines_and_stock, payment_confirmed):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:refund-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_confirmed.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {
+            'csrfmiddlewaretoken': 'hello',
+            'amount': str(payment_confirmed.captured_amount)})
     assert response.status_code == 302
+    assert order.payments.last().status == PaymentStatus.REFUNDED
     assert order.payments.last().get_captured_price() == TaxedMoney(
-        net=Money(20, 'USD'), gross=Money(20, 'USD'))
+        net=Money(0, 'USD'), gross=Money(0, 'USD'))
 
 
 @pytest.mark.integration
 @pytest.mark.django_db
-def test_view_refund_order_payment(admin_client, order_with_lines_and_stock):
+def test_view_refund_order_invalid_payment_waiting_status(
+        admin_client, order_with_lines_and_stock, payment_waiting):
     order = order_with_lines_and_stock
-    payment = order.payments.create(
-        variant='default', status=PaymentStatus.CONFIRMED,
-        fraud_status=FraudStatus.ACCEPT, currency='USD', total='100.0',
-        captured_amount='100.0')
 
     url = reverse(
         'dashboard:refund-payment', kwargs={
-            'order_pk': order.pk,
-            'payment_pk': payment.pk})
+            'order_pk': order.pk, 'payment_pk': payment_waiting.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.WAITING
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_refund_order_invalid_payment_preauth_status(
+        admin_client, order_with_lines_and_stock, payment_preauth):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:refund-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_preauth.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.PREAUTH
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_refund_order_invalid_payment_rejected_status(
+        admin_client, order_with_lines_and_stock, payment_rejected):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:refund-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_rejected.pk})
     response = admin_client.get(url)
     assert response.status_code == 200
 
     response = admin_client.post(
         url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.REJECTED
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_refund_order_invalid_payment_refunded_status(
+        admin_client, order_with_lines_and_stock, payment_refunded):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:refund-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_refunded.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.REFUNDED
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_refund_order_invalid_payment_error_status(
+        admin_client, order_with_lines_and_stock, payment_error):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:refund-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_error.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.ERROR
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_refund_order_invalid_payment_input_status(
+        admin_client, order_with_lines_and_stock, payment_input):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:refund-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_input.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(
+        url, {'csrfmiddlewaretoken': 'hello', 'amount': '20.00'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.INPUT
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_release_order_payment_preauth(
+        admin_client, order_with_lines_and_stock, payment_preauth):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:release-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_preauth.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(url, {
+        'csrfmiddlewaretoken': 'hello'})
     assert response.status_code == 302
+    assert order.payments.last().status == PaymentStatus.REFUNDED
     assert order.payments.last().get_captured_price() == TaxedMoney(
-        net=Money(80, 'USD'), gross=Money(80, 'USD'))
+        net=Money(0, 'USD'), gross=Money(0, 'USD'))
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_release_order_invalid_payment_waiting_status(
+        admin_client, order_with_lines_and_stock, payment_waiting):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:release-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_waiting.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(url, {
+        'csrfmiddlewaretoken': 'hello'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.WAITING
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_release_order_invalid_payment_confirmed_status(
+        admin_client, order_with_lines_and_stock, payment_confirmed):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:release-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_confirmed.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(url, {
+        'csrfmiddlewaretoken': 'hello'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.CONFIRMED
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_release_order_invalid_payment_rejected_status(
+        admin_client, order_with_lines_and_stock, payment_rejected):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:release-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_rejected.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(url, {
+        'csrfmiddlewaretoken': 'hello'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.REJECTED
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_release_order_invalid_payment_refunded_status(
+        admin_client, order_with_lines_and_stock, payment_refunded):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:release-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_refunded.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(url, {
+        'csrfmiddlewaretoken': 'hello'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.REFUNDED
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_release_order_invalid_payment_error_status(
+        admin_client, order_with_lines_and_stock, payment_error):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:release-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_error.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(url, {
+        'csrfmiddlewaretoken': 'hello'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.ERROR
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+def test_view_release_order_invalid_payment_input_status(
+        admin_client, order_with_lines_and_stock, payment_input):
+    order = order_with_lines_and_stock
+
+    url = reverse(
+        'dashboard:release-payment', kwargs={
+            'order_pk': order.pk, 'payment_pk': payment_input.pk})
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(url, {
+        'csrfmiddlewaretoken': 'hello'})
+    assert response.status_code == 400
+    assert order.payments.last().status == PaymentStatus.INPUT
 
 
 @pytest.mark.integration