Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions payments/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class PaymentStatus:
REFUNDED = "refunded"
ERROR = "error"
INPUT = "input"
CANCELLED = "cancelled"

CHOICES = [
(WAITING, pgettext_lazy("payment status", "Waiting for confirmation")),
Expand All @@ -44,6 +45,7 @@ class PaymentStatus:
(REFUNDED, pgettext_lazy("payment status", "Refunded")),
(ERROR, pgettext_lazy("payment status", "Error")),
(INPUT, pgettext_lazy("payment status", "Input")),
(CANCELLED, pgettext_lazy("payment status", "Cancelled")),
]


Expand Down
3 changes: 3 additions & 0 deletions payments/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ def release(self, payment):
def refund(self, payment, amount=None):
raise NotImplementedError

def cancel(self, payment):
raise NotImplementedError


PROVIDER_CACHE = {}

Expand Down
3 changes: 3 additions & 0 deletions payments/dummy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,6 @@ def release(self, payment):

def refund(self, payment, amount=None):
return amount or 0

def cancel(self, payment):
return None
45 changes: 45 additions & 0 deletions payments/dummy/test_dummy.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,48 @@ def test_provider_switches_payment_status_on_get_form(payment):
provider = DummyProvider()
provider.get_form(payment, data={})
assert payment.status == PaymentStatus.INPUT


def test_cancel_waiting_payment(payment):
provider = DummyProvider()
payment.status = PaymentStatus.WAITING
provider.cancel(payment)


def test_cancel_input_payment(payment):
provider = DummyProvider()
payment.status = PaymentStatus.INPUT
provider.cancel(payment)


def test_process_data_sets_cancelled_via_verification_result(payment):
provider = DummyProvider()
payment.status = PaymentStatus.WAITING
request = MagicMock()
request.GET = {"verification_result": PaymentStatus.CANCELLED}
response = provider.process_data(payment, request)
assert payment.status == PaymentStatus.CANCELLED
assert response.status_code == 302
assert response["location"] == payment.get_failure_url()


def test_cancelled_payment_redirects_to_failure(payment):
provider = DummyProvider()
payment.status = PaymentStatus.CANCELLED
request = MagicMock()
request.GET = {}
response = provider.process_data(payment, request)
assert response["location"] == payment.get_failure_url()


def test_get_form_with_cancelled_status(payment):
provider = DummyProvider()
data = {
"status": PaymentStatus.CANCELLED,
"fraud_status": FraudStatus.UNKNOWN,
"gateway_response": "3ds-disabled",
"verification_result": "",
}
with pytest.raises(RedirectNeeded) as exc:
provider.get_form(payment, data)
assert exc.value.args[0] == payment.get_failure_url()
15 changes: 15 additions & 0 deletions payments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,21 @@ def refund(self, amount=None):
self.change_status(PaymentStatus.REFUNDED)
self.save()

def cancel(self):
"""Cancel a payment.

Only payments that have not been processed can be cancelled.
For pre-authorized payments, use release() instead.
For confirmed payments, use refund() instead.

Note that not all providers support this method.
"""
if self.status not in [PaymentStatus.WAITING, PaymentStatus.INPUT]:
raise ValueError("Only waiting or input payments can be cancelled.")
provider = provider_factory(self.variant, self)
provider.cancel(self)
self.change_status(PaymentStatus.CANCELLED)

@property
def attrs(self):
"""A JSON-serialised wrapper around `extra_data`.
Expand Down
45 changes: 45 additions & 0 deletions payments/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,48 @@ def test_amex():
"amex",
"American Express",
)


def test_cancel_with_wrong_status_confirmed():
payment = Payment(variant="default", status=PaymentStatus.CONFIRMED)
with pytest.raises(
ValueError,
match="Only waiting or input payments can be cancelled\\.",
):
payment.cancel()


def test_cancel_with_wrong_status_preauth():
payment = Payment(variant="default", status=PaymentStatus.PREAUTH)
with pytest.raises(
ValueError,
match="Only waiting or input payments can be cancelled\\.",
):
payment.cancel()


def test_cancel_with_wrong_status_refunded():
payment = Payment(variant="default", status=PaymentStatus.REFUNDED)
with pytest.raises(
ValueError,
match="Only waiting or input payments can be cancelled\\.",
):
payment.cancel()


@patch("payments.dummy.DummyProvider.cancel")
def test_cancel_waiting_payment_successfully(mocked_cancel_method):
with patch.object(BasePayment, "save"):
payment = Payment(variant="default", status=PaymentStatus.WAITING)
payment.cancel()
assert payment.status == PaymentStatus.CANCELLED
assert mocked_cancel_method.call_count == 1


@patch("payments.dummy.DummyProvider.cancel")
def test_cancel_input_payment_successfully(mocked_cancel_method):
with patch.object(BasePayment, "save"):
payment = Payment(variant="default", status=PaymentStatus.INPUT)
payment.cancel()
assert payment.status == PaymentStatus.CANCELLED
assert mocked_cancel_method.call_count == 1
32 changes: 32 additions & 0 deletions testapp/testapp/testmain/migrations/0003_alter_payment_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 5.2.8 on 2025-11-17 18:12
from __future__ import annotations

from django.db import migrations
from django.db import models


class Migration(migrations.Migration):
dependencies = [
("testmain", "0002_payment_billing_phone"),
]

operations = [
migrations.AlterField(
model_name="payment",
name="status",
field=models.CharField(
choices=[
("waiting", "Waiting for confirmation"),
("preauth", "Pre-authorized"),
("confirmed", "Confirmed"),
("rejected", "Rejected"),
("refunded", "Refunded"),
("error", "Error"),
("input", "Input"),
("cancelled", "Cancelled"),
],
default="waiting",
max_length=10,
),
),
]