diff --git a/pyright.pyproject.toml b/pyright.pyproject.toml index cc79387715..b02a0744c1 100644 --- a/pyright.pyproject.toml +++ b/pyright.pyproject.toml @@ -10,7 +10,6 @@ include = [ "src/openforms/dmn/registry.py", "src/openforms/formio/registry.py", "src/openforms/formio/rendering/registry.py", - "src/openforms/payments/registry.py", "src/openforms/pre_requests/base.py", "src/openforms/pre_requests/registry.py", "src/openforms/prefill/base.py", @@ -28,7 +27,7 @@ include = [ # Core forms app "src/openforms/forms/api/serializers/logic/action_serializers.py", # Payments - "src/openforms/payments/models.py", + "src/openforms/payments/", # Interaction with the outside world "src/openforms/contrib/zgw/service.py", "src/openforms/contrib/objects_api/", @@ -60,6 +59,10 @@ exclude = [ "src/openforms/contrib/objects_api/tests/", "src/openforms/contrib/objects_api/json_schema.py", "src/openforms/formio/formatters/tests/", + "src/openforms/payments/management/commands/checkpaymentemaildupes.py", + "src/openforms/payments/tests/", + "src/openforms/payments/contrib/demo/tests/", + "src/openforms/payments/contrib/ogone/tests/", "src/openforms/registrations/contrib/zgw_apis/tests/test_backend_partial_failure.py", "src/openforms/registrations/contrib/zgw_apis/tests/test_utils.py", ] diff --git a/src/openforms/forms/admin/form.py b/src/openforms/forms/admin/form.py index bd170f0628..efe14af5d6 100644 --- a/src/openforms/forms/admin/form.py +++ b/src/openforms/forms/admin/form.py @@ -12,7 +12,6 @@ from openforms.api.utils import underscore_to_camel from openforms.emails.models import ConfirmationEmailTemplate -from openforms.payments.admin import PaymentBackendChoiceFieldMixin from openforms.registrations.admin import RegistrationBackendFieldMixin from openforms.typing import StrOrPromise from openforms.utils.expressions import FirstNotBlank @@ -127,7 +126,6 @@ def expected_parameters(self): class FormAdmin( FormioConfigMixin, RegistrationBackendFieldMixin, - PaymentBackendChoiceFieldMixin, OrderedInlineModelAdminMixin, admin.ModelAdmin, ): diff --git a/src/openforms/payments/admin.py b/src/openforms/payments/admin.py index 3f1b71db2c..23654d0cc6 100644 --- a/src/openforms/payments/admin.py +++ b/src/openforms/payments/admin.py @@ -1,22 +1,8 @@ from django.contrib import admin -from .fields import PaymentBackendChoiceField from .models import SubmissionPayment -class PaymentBackendChoiceFieldMixin: - def formfield_for_dbfield(self, db_field, request, **kwargs): - if isinstance(db_field, PaymentBackendChoiceField): - assert not db_field.choices - _old = db_field.choices - db_field.choices = db_field._get_plugin_choices() - field = super().formfield_for_dbfield(db_field, request, **kwargs) - db_field.choices = _old - return field - - return super().formfield_for_dbfield(db_field, request, **kwargs) - - @admin.register(SubmissionPayment) class SubmissionPaymentAdmin(admin.ModelAdmin): fields = ( diff --git a/src/openforms/payments/api/fields.py b/src/openforms/payments/api/fields.py index 6cc7cdb92e..f82c9d70c0 100644 --- a/src/openforms/payments/api/fields.py +++ b/src/openforms/payments/api/fields.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from openforms.forms.models import Form + from ..registry import register as payment_register from .serializers import PaymentOptionSerializer @@ -20,7 +22,8 @@ def __init__(self, *args, **kwargs): def to_internal_value(self, data): raise NotImplementedError("read only") - def to_representation(self, form): + def to_representation(self, value): + assert isinstance(value, Form) request = self.context["request"] - temp = payment_register.get_options(request, form) + temp = payment_register.get_options(request, value) return super().to_representation(temp) diff --git a/src/openforms/payments/api/serializers.py b/src/openforms/payments/api/serializers.py index 124fdcbe95..4d82f5f280 100644 --- a/src/openforms/payments/api/serializers.py +++ b/src/openforms/payments/api/serializers.py @@ -23,7 +23,7 @@ class PaymentPluginSerializer(PluginBaseSerializer): class PaymentOptionSerializer(serializers.Serializer): # serializer for form identifier = serializers.CharField(label=_("Identifier"), read_only=True) - label = serializers.CharField( + label = serializers.CharField( # pyright: ignore[reportAssignmentType] label=_("Button label"), help_text=_("Button label"), read_only=True ) @@ -34,7 +34,7 @@ class PaymentInfoSerializer(serializers.Serializer): label=_("Request type"), choices=PaymentRequestType.choices, read_only=True ) url = serializers.URLField(label=_("URL"), read_only=True) - data = serializers.DictField( + data = serializers.DictField( # pyright: ignore[reportAssignmentType,reportIncompatibleMethodOverride] label=_("Data"), child=serializers.CharField(label=_("Value"), read_only=True), read_only=True, diff --git a/src/openforms/payments/base.py b/src/openforms/payments/base.py index 5a1b59ad29..33c35fd93a 100644 --- a/src/openforms/payments/base.py +++ b/src/openforms/payments/base.py @@ -1,9 +1,12 @@ +from __future__ import annotations + from dataclasses import dataclass -from typing import TYPE_CHECKING, Mapping +from typing import TYPE_CHECKING, Mapping, TypedDict from django.http import HttpRequest, HttpResponse from rest_framework import serializers +from rest_framework.request import Request from openforms.plugins.plugin import AbstractBasePlugin from openforms.utils.mixins import JsonSchemaSerializerMixin @@ -32,45 +35,53 @@ class EmptyOptions(JsonSchemaSerializerMixin, serializers.Serializer): pass -class BasePlugin(AbstractBasePlugin): +class Options(TypedDict): + pass + + +class BasePlugin[OptionsT: Options](AbstractBasePlugin): return_method = "GET" webhook_method = "POST" - configuration_options = EmptyOptions + configuration_options: type[serializers.Serializer] = EmptyOptions # override def start_payment( self, request: HttpRequest, - payment: "SubmissionPayment", + payment: SubmissionPayment, + options: OptionsT, ) -> PaymentInfo: raise NotImplementedError() def handle_return( - self, request: HttpRequest, payment: "SubmissionPayment" + self, + request: Request, + payment: SubmissionPayment, + options: OptionsT, ) -> HttpResponse: raise NotImplementedError() - def handle_webhook(self, request: HttpRequest) -> "SubmissionPayment": + def handle_webhook(self, request: Request) -> SubmissionPayment: raise NotImplementedError() # helpers - def get_start_url(self, request: HttpRequest, submission: "Submission") -> str: + def get_start_url(self, request: HttpRequest, submission: Submission) -> str: return reverse_plus( "payments:start", kwargs={"uuid": submission.uuid, "plugin_id": self.identifier}, request=request, ) - def get_return_url(self, request: HttpRequest, payment: "SubmissionPayment") -> str: + def get_return_url(self, request: HttpRequest, payment: SubmissionPayment) -> str: return reverse_plus( "payments:return", kwargs={"uuid": payment.uuid}, request=request, ) - def get_webhook_url(self, request: HttpRequest) -> str: + def get_webhook_url(self, request: HttpRequest | None) -> str: return reverse_plus( "payments:webhook", kwargs={"plugin_id": self.identifier}, diff --git a/src/openforms/payments/contrib/demo/plugin.py b/src/openforms/payments/contrib/demo/plugin.py index 5c51590385..62524b72c6 100644 --- a/src/openforms/payments/contrib/demo/plugin.py +++ b/src/openforms/payments/contrib/demo/plugin.py @@ -1,3 +1,5 @@ +from typing import TypedDict + from django.http import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ @@ -11,16 +13,20 @@ from ...registry import register +class NoOptions(TypedDict): + pass + + @register("demo") -class DemoPayment(BasePlugin): +class DemoPayment(BasePlugin[NoOptions]): verbose_name = _("Demo") is_demo_plugin = True - def start_payment(self, request, payment): + def start_payment(self, request, payment, options): url = self.get_return_url(request, payment) return PaymentInfo(url=url, data={}) - def handle_return(self, request, payment): + def handle_return(self, request, payment, options): payment.status = PaymentStatus.completed payment.save() diff --git a/src/openforms/payments/contrib/ogone/constants.py b/src/openforms/payments/contrib/ogone/constants.py index 817697dfce..d83b3ea5a0 100644 --- a/src/openforms/payments/contrib/ogone/constants.py +++ b/src/openforms/payments/contrib/ogone/constants.py @@ -1,5 +1,3 @@ -from typing import cast - from django.db import models from django.utils.translation import gettext_lazy as _ @@ -60,45 +58,42 @@ def as_payment_status(cls, ogone_status: str) -> str: return OGONE_TO_PAYMENT_STATUS[ogone_status] -OGONE_TO_PAYMENT_STATUS = cast( - dict[str, str], - { - OgoneStatus.invalid_or_incomplete: PaymentStatus.failed.value, - OgoneStatus.cancelled_by_customer: PaymentStatus.failed.value, - OgoneStatus.authorisation_declined: PaymentStatus.failed.value, - OgoneStatus.waiting_for_client_payment: PaymentStatus.processing.value, - OgoneStatus.waiting_authentication: PaymentStatus.processing.value, - OgoneStatus.authorised: PaymentStatus.processing.value, - OgoneStatus.authorised_waiting_external_result: PaymentStatus.processing.value, - OgoneStatus.authorisation_waiting: PaymentStatus.processing.value, - OgoneStatus.authorisation_not_known: PaymentStatus.processing.value, - OgoneStatus.standby: PaymentStatus.processing.value, - OgoneStatus.ok_with_scheduled_payments: PaymentStatus.processing.value, - OgoneStatus.not_ok_with_scheduled_payments: PaymentStatus.failed.value, - OgoneStatus.authorised_and_cancelled: PaymentStatus.failed.value, - OgoneStatus.author_deletion_waiting: PaymentStatus.failed.value, - OgoneStatus.author_deletion_uncertain: PaymentStatus.failed.value, - OgoneStatus.author_deletion_refused: PaymentStatus.failed.value, - OgoneStatus.payment_deleted: PaymentStatus.failed.value, - OgoneStatus.payment_deletion_pending: PaymentStatus.failed.value, - OgoneStatus.payment_deletion_uncertain: PaymentStatus.failed.value, - OgoneStatus.payment_deletion_refused: PaymentStatus.failed.value, - OgoneStatus.payment_deleted2: PaymentStatus.failed, # doubl.valuee - OgoneStatus.refund: PaymentStatus.failed.value, - OgoneStatus.refund_pending: PaymentStatus.failed.value, - OgoneStatus.refund_uncertain: PaymentStatus.failed.value, - OgoneStatus.refund_refused: PaymentStatus.failed.value, - OgoneStatus.payment_declined_by_the_acquirer: PaymentStatus.failed.value, - OgoneStatus.refund_processed_by_merchant: PaymentStatus.failed.value, - OgoneStatus.payment_requested: PaymentStatus.completed.value, - OgoneStatus.payment_processing: PaymentStatus.processing.value, - OgoneStatus.payment_uncertain: PaymentStatus.processing.value, - OgoneStatus.payment_refused: PaymentStatus.failed.value, - OgoneStatus.refund_declined_by_the_acquirer: PaymentStatus.failed.value, - OgoneStatus.payment_processed_by_merchant: PaymentStatus.failed.value, - OgoneStatus.being_processed: PaymentStatus.processing.value, - }, -) +OGONE_TO_PAYMENT_STATUS: dict[str, str] = { + OgoneStatus.invalid_or_incomplete.value: PaymentStatus.failed.value, + OgoneStatus.cancelled_by_customer.value: PaymentStatus.failed.value, + OgoneStatus.authorisation_declined.value: PaymentStatus.failed.value, + OgoneStatus.waiting_for_client_payment.value: PaymentStatus.processing.value, + OgoneStatus.waiting_authentication.value: PaymentStatus.processing.value, + OgoneStatus.authorised.value: PaymentStatus.processing.value, + OgoneStatus.authorised_waiting_external_result.value: PaymentStatus.processing.value, + OgoneStatus.authorisation_waiting.value: PaymentStatus.processing.value, + OgoneStatus.authorisation_not_known.value: PaymentStatus.processing.value, + OgoneStatus.standby.value: PaymentStatus.processing.value, + OgoneStatus.ok_with_scheduled_payments.value: PaymentStatus.processing.value, + OgoneStatus.not_ok_with_scheduled_payments.value: PaymentStatus.failed.value, + OgoneStatus.authorised_and_cancelled.value: PaymentStatus.failed.value, + OgoneStatus.author_deletion_waiting.value: PaymentStatus.failed.value, + OgoneStatus.author_deletion_uncertain.value: PaymentStatus.failed.value, + OgoneStatus.author_deletion_refused.value: PaymentStatus.failed.value, + OgoneStatus.payment_deleted.value: PaymentStatus.failed.value, + OgoneStatus.payment_deletion_pending.value: PaymentStatus.failed.value, + OgoneStatus.payment_deletion_uncertain.value: PaymentStatus.failed.value, + OgoneStatus.payment_deletion_refused.value: PaymentStatus.failed.value, + OgoneStatus.payment_deleted2.value: PaymentStatus.failed, # doubl.valuee + OgoneStatus.refund.value: PaymentStatus.failed.value, + OgoneStatus.refund_pending.value: PaymentStatus.failed.value, + OgoneStatus.refund_uncertain.value: PaymentStatus.failed.value, + OgoneStatus.refund_refused.value: PaymentStatus.failed.value, + OgoneStatus.payment_declined_by_the_acquirer.value: PaymentStatus.failed.value, + OgoneStatus.refund_processed_by_merchant.value: PaymentStatus.failed.value, + OgoneStatus.payment_requested.value: PaymentStatus.completed.value, + OgoneStatus.payment_processing.value: PaymentStatus.processing.value, + OgoneStatus.payment_uncertain.value: PaymentStatus.processing.value, + OgoneStatus.payment_refused.value: PaymentStatus.failed.value, + OgoneStatus.refund_declined_by_the_acquirer.value: PaymentStatus.failed.value, + OgoneStatus.payment_processed_by_merchant.value: PaymentStatus.failed.value, + OgoneStatus.being_processed.value: PaymentStatus.processing.value, +} assert set(OgoneStatus.values) == set( OGONE_TO_PAYMENT_STATUS.keys() diff --git a/src/openforms/payments/contrib/ogone/plugin.py b/src/openforms/payments/contrib/ogone/plugin.py index 3f7b45e444..ffba9e267d 100644 --- a/src/openforms/payments/contrib/ogone/plugin.py +++ b/src/openforms/payments/contrib/ogone/plugin.py @@ -1,6 +1,6 @@ import logging -from django.http import HttpResponseBadRequest, HttpResponseRedirect +from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -8,6 +8,7 @@ import requests from rest_framework import serializers from rest_framework.exceptions import ParseError +from rest_framework.request import Request from openforms.api.fields import PrimaryKeyRelatedAsChoicesField from openforms.config.data import Entry @@ -18,12 +19,13 @@ from ...base import BasePlugin from ...constants import PAYMENT_STATUS_FINAL, UserAction -from ...contrib.ogone.client import OgoneClient -from ...contrib.ogone.constants import OgoneStatus -from ...contrib.ogone.exceptions import InvalidSignature -from ...contrib.ogone.models import OgoneMerchant from ...models import SubmissionPayment from ...registry import register +from .client import OgoneClient +from .constants import OgoneStatus +from .exceptions import InvalidSignature +from .models import OgoneMerchant +from .typing import PaymentOptions logger = logging.getLogger(__name__) @@ -41,18 +43,17 @@ class OgoneOptionsSerializer(JsonSchemaSerializerMixin, serializers.Serializer): @register("ogone-legacy") -class OgoneLegacyPaymentPlugin(BasePlugin): +class OgoneLegacyPaymentPlugin(BasePlugin[PaymentOptions]): verbose_name = _("Ogone legacy") configuration_options = OgoneOptionsSerializer - def start_payment(self, request, payment: SubmissionPayment): + def start_payment( + self, request: HttpRequest, payment: SubmissionPayment, options: PaymentOptions + ): # decimal to cents amount_cents = int((payment.amount * 100).to_integral_exact()) - merchant = get_object_or_404( - OgoneMerchant, id=payment.plugin_options["merchant_id"] - ) - client = OgoneClient(merchant) + client = OgoneClient(options["merchant_id"]) return_url = self.get_return_url(request, payment) @@ -69,14 +70,13 @@ def start_payment(self, request, payment: SubmissionPayment): ) return info - def handle_return(self, request, payment: SubmissionPayment): + def handle_return( + self, request: Request, payment: SubmissionPayment, options: PaymentOptions + ): action = request.query_params.get(RETURN_ACTION_PARAM) payment_id = request.query_params[PAYMENT_ID_PARAM] - merchant = get_object_or_404( - OgoneMerchant, id=payment.plugin_options["merchant_id"] - ) - client = OgoneClient(merchant) + client = OgoneClient(options["merchant_id"]) try: params = client.get_validated_params(request.query_params) @@ -107,7 +107,7 @@ def handle_return(self, request, payment: SubmissionPayment): ) return HttpResponseRedirect(redirect_url) - def handle_webhook(self, request): + def handle_webhook(self, request: Request): # unvalidated data order_id = case_insensitive_get(request.data, "orderID") if not order_id: @@ -120,10 +120,10 @@ def handle_webhook(self, request): raise ParseError("missing PAYID") payment = get_object_or_404(SubmissionPayment, public_order_id=order_id) - merchant = get_object_or_404( - OgoneMerchant, id=payment.plugin_options["merchant_id"] - ) - client = OgoneClient(merchant) + options_serializer = self.configuration_options(data=payment.plugin_options) + options_serializer.is_valid(raise_exception=True) + options: PaymentOptions = options_serializer.validated_data + client = OgoneClient(options["merchant_id"]) try: params = client.get_validated_params(request.data) diff --git a/src/openforms/payments/contrib/ogone/typing.py b/src/openforms/payments/contrib/ogone/typing.py new file mode 100644 index 0000000000..c8944d68dd --- /dev/null +++ b/src/openforms/payments/contrib/ogone/typing.py @@ -0,0 +1,7 @@ +from typing import TypedDict + +from .models import OgoneMerchant + + +class PaymentOptions(TypedDict): + merchant_id: OgoneMerchant # FIXME: key is badly named in the serializer diff --git a/src/openforms/payments/models.py b/src/openforms/payments/models.py index 20257134e7..4bee299582 100644 --- a/src/openforms/payments/models.py +++ b/src/openforms/payments/models.py @@ -2,7 +2,7 @@ import uuid from decimal import Decimal -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from django.db import models, transaction from django.utils.translation import gettext_lazy as _ @@ -92,6 +92,7 @@ def get_completed_public_order_ids(self) -> list[str]: ... class SubmissionPayment(models.Model): + id: int | None uuid = models.UUIDField(_("UUID"), unique=True, default=uuid.uuid4) created = models.DateTimeField(auto_now_add=True) @@ -139,7 +140,9 @@ class SubmissionPayment(models.Model): help_text=_("The ID assigned to the payment by the payment provider."), ) - objects = SubmissionPaymentManager() + objects: ClassVar[ # pyright: ignore[reportIncompatibleVariableOverride] + SubmissionPaymentManager + ] = SubmissionPaymentManager() class Meta: verbose_name = _("submission payment details") diff --git a/src/openforms/payments/services.py b/src/openforms/payments/services.py index af8e0a0bec..f10626d2ac 100644 --- a/src/openforms/payments/services.py +++ b/src/openforms/payments/services.py @@ -10,9 +10,14 @@ def update_submission_payment_registration(submission: Submission): + if (registration_backend := submission.registration_backend) is None: + error = AttributeError("Submission has no registration backend") + logevent.registration_payment_update_failure(submission, error=error) + return + try: - plugin = register[submission.registration_backend.backend] - except (AttributeError, KeyError) as e: + plugin = register[registration_backend.backend] + except KeyError as e: logevent.registration_payment_update_failure(submission, error=e) return @@ -37,7 +42,8 @@ def update_submission_payment_registration(submission: Submission): try: plugin.update_payment_status(submission, options_serializer.validated_data) - payments.mark_registered() + # FIXME: pyright + custom querysets... + payments.mark_registered() # pyright: ignore[reportAttributeAccessIssue] except Exception as e: logevent.registration_payment_update_failure( submission, error=e, plugin=plugin diff --git a/src/openforms/payments/tests/test_views.py b/src/openforms/payments/tests/test_views.py index 1086cbd867..19b8cd6f2d 100644 --- a/src/openforms/payments/tests/test_views.py +++ b/src/openforms/payments/tests/test_views.py @@ -23,14 +23,14 @@ class Plugin(BasePlugin): return_method = "GET" webhook_method = "POST" - def start_payment(self, request, payment): + def start_payment(self, request, payment, options): return PaymentInfo(type="get", url="http://testserver/foo") - def handle_return(self, request, payment): + def handle_return(self, request, payment, options): return HttpResponseRedirect(payment.submission.form_url) def handle_webhook(self, request): - return None + return SubmissionPayment(id=None) class ViewsTests(TestCase): @@ -221,7 +221,7 @@ def handle_webhook(self, request): return payment def handle_return( - self, request: HttpRequest, payment: "SubmissionPayment" + self, request: HttpRequest, payment: "SubmissionPayment", options ) -> HttpResponse: return HttpResponseRedirect(payment.submission.form_url) diff --git a/src/openforms/payments/views.py b/src/openforms/payments/views.py index 09960d5c8f..a571b5e0ab 100644 --- a/src/openforms/payments/views.py +++ b/src/openforms/payments/views.py @@ -129,7 +129,9 @@ def post(self, request, uuid: str, plugin_id: str): submission.price, ) - info = plugin.start_payment(request, payment) + options = plugin.configuration_options(data=payment.plugin_options) + options.is_valid(raise_exception=True) + info = plugin.start_payment(request, payment, options.validated_data) logevent.payment_flow_start(payment, plugin) return Response(self.get_serializer(instance=info).data) @@ -222,8 +224,10 @@ def _handle_return(self, request, uuid: str): if plugin.return_method.upper() != request.method.upper(): raise MethodNotAllowed(request.method) + options = plugin.configuration_options(data=payment.plugin_options) try: - response = plugin.handle_return(request, payment) + options.is_valid(raise_exception=True) + response = plugin.handle_return(request, payment, options.validated_data) except Exception as e: logevent.payment_flow_failure(payment, plugin, e) raise @@ -383,7 +387,9 @@ def get_context_data(self, **kwargs): submission.price, ) - info = plugin.start_payment(self.request, payment) + options = plugin.configuration_options(data=payment.plugin_options) + options.is_valid(raise_exception=True) + info = plugin.start_payment(self.request, payment, options.validated_data) logevent.payment_flow_start(payment, plugin, from_email=True) context["url"] = info.url