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
31 changes: 31 additions & 0 deletions payments/dummy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from payments.core import BasicProvider

from .forms import DummyForm
from .serializers import DummySerializer


class DummyProvider(BasicProvider):
Expand All @@ -23,6 +24,8 @@ class DummyProvider(BasicProvider):
You should only use this in development or in test servers.
"""

serializer_class = DummySerializer

def get_form(self, payment, data=None):
if payment.status == PaymentStatus.WAITING:
payment.change_status(PaymentStatus.INPUT)
Expand Down Expand Up @@ -57,6 +60,34 @@ def get_form(self, payment, data=None):
raise RedirectNeeded(payment.get_failure_url())
return form

def get_serializer(self, payment, data=None):
if payment.status == PaymentStatus.WAITING:
payment.change_status(PaymentStatus.INPUT)

serializer = DummySerializer(data=data)
serializer.is_valid(raise_exception=True)

new_status = serializer.validated_data["status"]
payment.change_status(new_status)
new_fraud_status = serializer.validated_data["fraud_status"]
payment.change_fraud_status(new_fraud_status)

gateway_response = serializer.validated_data.get("gateway_response")
verification_result = serializer.validated_data.get("verification_result")
if gateway_response or verification_result:
if gateway_response == "3ds-disabled":
pass
elif gateway_response == "failure":
# Gateway raises error (HTTP 500 for example)
raise URLError("Opps")
elif gateway_response == "payment-error":
raise PaymentError("Unsupported operation")

if new_status in [PaymentStatus.PREAUTH, PaymentStatus.CONFIRMED]:
return serializer

raise PaymentError("Payment unsuccessful")

def process_data(self, payment, request):
verification_result = request.GET.get("verification_result")
if verification_result:
Expand Down
30 changes: 30 additions & 0 deletions payments/dummy/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from __future__ import annotations

from rest_framework import serializers

from payments import FraudStatus
from payments import PaymentStatus
from payments.serializers import PaymentSerializer

RESPONSE_CHOICES = (
("3ds-disabled", "3DS disabled"),
("validation-failure", "3DS disabled"),
("failure", "Gateway connection error"),
("payment-error", "Gateway returned unsupported response"),
)


class DummySerializer(PaymentSerializer):
status = serializers.ChoiceField(choices=PaymentStatus.CHOICES)
fraud_status = serializers.ChoiceField(choices=FraudStatus.CHOICES)
gateway_response = serializers.ChoiceField(choices=RESPONSE_CHOICES)
verification_result = serializers.ChoiceField(
choices=PaymentStatus.CHOICES, required=False
)

def validate(self, data):
if data.get("gateway_response") == "validation-failure":
raise serializers.ValidationError(
"Provided data is not valid, please try again with correct data"
)
return data
79 changes: 79 additions & 0 deletions payments/dummy/test_dummy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
from urllib.parse import urlencode

import pytest
from rest_framework import serializers

from payments import FraudStatus
from payments import PaymentError
from payments import PaymentStatus
from payments import RedirectNeeded

from . import DummyProvider
from . import DummySerializer

VARIANT = "dummy-3ds"

Expand Down Expand Up @@ -171,3 +173,80 @@ def test_provider_switches_payment_status_on_get_form(payment):
provider = DummyProvider()
provider.get_form(payment, data={})
assert payment.status == PaymentStatus.INPUT


# Test cases for get_serializer
def test_provider_serializer_supports_non_3ds_transactions(payment):
provider = DummyProvider()
data = {
"status": PaymentStatus.PREAUTH,
"fraud_status": FraudStatus.UNKNOWN,
"gateway_response": "3ds-disabled",
}
serializer = provider.get_serializer(payment, data)
assert isinstance(serializer, DummySerializer)


def test_provider_serializer_raises_verification_result_needed(payment):
provider = DummyProvider()
data = {
"status": PaymentStatus.WAITING,
"fraud_status": FraudStatus.UNKNOWN,
"gateway_response": "validation-failure",
}
with pytest.raises(serializers.ValidationError) as exc:
provider.get_serializer(payment, data)

assert "non_field_errors" in exc.value.args[0]


def test_provider_serializer_supports_gateway_failure(payment):
provider = DummyProvider()
data = {
"status": PaymentStatus.WAITING,
"fraud_status": FraudStatus.UNKNOWN,
"gateway_response": "failure",
}
with pytest.raises(URLError):
provider.get_serializer(payment, data)


def test_provider_returns_serializer_on_success(payment):
provider = DummyProvider()
data = {
"status": PaymentStatus.PREAUTH,
"fraud_status": FraudStatus.UNKNOWN,
"gateway_response": "3ds-disabled",
}
serializer = provider.get_serializer(payment, data)
assert isinstance(serializer, DummySerializer)


def test_provider_serializer_raises_payment_error_on_failure(payment):
provider = DummyProvider()
data = {
"status": PaymentStatus.ERROR,
"fraud_status": FraudStatus.UNKNOWN,
"gateway_response": "3ds-disabled",
}
with pytest.raises(PaymentError):
provider.get_serializer(payment, data)


def test_provider_raises_payment_error_on_serializer(payment):
provider = DummyProvider()
data = {
"status": PaymentStatus.PREAUTH,
"fraud_status": FraudStatus.UNKNOWN,
"gateway_response": "payment-error",
}
with pytest.raises(PaymentError):
provider.get_serializer(payment, data)


def test_provider_switches_payment_status_on_get_serializer(payment):
provider = DummyProvider()
with pytest.raises(serializers.ValidationError):
provider.get_serializer(payment, data={})

assert payment.status == PaymentStatus.INPUT
19 changes: 19 additions & 0 deletions payments/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from __future__ import annotations

from rest_framework import serializers


class PaymentSerializer(serializers.Serializer):
def validate(self, attrs):
raise NotImplementedError

def get_metadata(self):
metadata = {}

for field_name, field in self.fields.items():
metadata[field_name] = {
"required": field.required,
"type": field.__class__.__name__,
"choices": getattr(field, "choices", None),
}
return metadata
43 changes: 43 additions & 0 deletions payments/test_serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from __future__ import annotations

from payments.dummy.serializers import DummySerializer


def test_serializer_get_metadata_returns_correct_structure():
metadata = DummySerializer().get_metadata()
# Fields that should exist
expected_fields = {
"status",
"fraud_status",
"gateway_response",
"verification_result",
}
assert expected_fields.issubset(set(metadata.keys()))
# For each, check required, type, choices keys
for _, meta in metadata.items():
assert "required" in meta
assert "type" in meta
assert "choices" in meta


def test_serializer_validation_success():
data = {
"status": "waiting",
"fraud_status": "unknown",
"gateway_response": "3ds-disabled",
}
serializer = DummySerializer(data=data)
serializer.is_valid()
assert not serializer.errors


def test_serializer_validation_failed():
data = {
"status": "waiting",
"fraud_status": "unknown",
"gateway_response": "validation-failure",
"verification_result": "confirmed",
}
serializer = DummySerializer(data=data)
serializer.is_valid()
assert serializer.errors
28 changes: 28 additions & 0 deletions payments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from django.urls import path
from django.urls import re_path
from django.views.decorators.csrf import csrf_exempt
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response

from . import get_payment_model
from .core import provider_factory
Expand Down Expand Up @@ -48,6 +51,23 @@ def static_callback(request, variant):
return process_data(request, token, provider)


@csrf_exempt
@api_view(["GET"])
def get_serializer_metadata(request, variant):
try:
provider = provider_factory(variant)
except ValueError:
return Response("No such provider", status=status.HTTP_404_NOT_FOUND)
try:
metadata = provider.serializer_class().get_metadata()
except AttributeError:
return Response(
"Provider doesn't serialization", status=status.HTTP_400_BAD_REQUEST
)

return Response(metadata)


urlpatterns = [
# A per-payment callback endpoint.
# Providers that use a unique URL for each payment will deliver webhook
Expand All @@ -62,4 +82,12 @@ def static_callback(request, variant):
static_callback,
name="static_process_payment",
),
# A static per-provider callback endpoint.
# Providers who support get_serializer will return
# serializer metadata using this endpoint.
re_path(
r"^process/(?P<variant>[a-z-]+)/serializer-metadata/$",
get_serializer_metadata,
name="serializer-metadata",
),
]
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies = [
"Django>=4.2,<5.3",
"requests>=1.2.0",
"django-phonenumber-field[phonenumberslite]>=5.0.0",
"djangorestframework>=3.14",
]
dynamic = ["version"]

Expand Down