Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ releases, in reverse chronological order.
v4.0.0
------

- **Breaking** Webhook error responses in ``static_callback`` endpoint now
return JSON instead of raising ``Http404``. Error responses include
``variant`` and ``error_code`` fields for easier debugging. This helps
developers identify which payment provider is having issues when viewing
webhook logs in provider dashboards (Stripe, PayPal, etc.).

**Migration guide:**
- If you're using webhook systems (Stripe, PayPal, etc.), no changes needed
- they expect JSON.
- If you have custom code checking for ``Http404`` exceptions from webhook
endpoints, update to handle JSON responses with appropriate HTTP status
codes (400, 404, etc.).
- Payment tokens are no longer exposed in 404 error responses for security.

- Fixed ``StripeProviderV3`` not setting ``captured_amount`` on payment
confirmation in ``process_data()`` and ``status()``, which broke refunds.
- ``StripeProvider``, which was deprecated in v3.0.0, has been dropped. Use
Expand Down
84 changes: 84 additions & 0 deletions payments/test_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Tests for webhook URL endpoints."""

from __future__ import annotations

from unittest.mock import Mock
from unittest.mock import patch

from django.test import TestCase

from payments import PaymentError


class StaticCallbackTestCase(TestCase):
"""Test the static_callback webhook endpoint."""

def test_invalid_provider_variant_returns_json_400(self):
"""Test that invalid provider variant returns JSON 400 with debug info."""
response = self.client.post("/payments/process/invalid-variant/")

assert response.status_code == 400
assert response["Content-Type"] == "application/json"

data = response.json()
assert data["error"] == "Invalid payment provider"
assert data["variant"] == "invalid-variant"

@patch("payments.urls.provider_factory")
def test_missing_token_returns_json_400(self, mock_factory):
"""Test that missing token returns JSON 400 with debug info."""
mock_provider = Mock()
mock_provider.get_token_from_request.return_value = None
mock_factory.return_value = mock_provider

response = self.client.post("/payments/process/dummy/")

assert response.status_code == 400
assert response["Content-Type"] == "application/json"

data = response.json()
assert data["error"] == "Could not extract payment token from webhook"
assert data["variant"] == "dummy"

@patch("payments.urls.provider_factory")
def test_payment_error_includes_variant_and_code(self, mock_factory):
"""Test that PaymentError includes variant and error_code in response."""
mock_provider = Mock()
mock_provider.get_token_from_request.side_effect = PaymentError(
code=400, message="Invalid signature"
)
mock_factory.return_value = mock_provider

response = self.client.post("/payments/process/dummy/")

assert response.status_code == 400
assert response["Content-Type"] == "application/json"

data = response.json()
assert data["error"] == "Invalid signature"
assert data["variant"] == "dummy"
assert data["error_code"] == 400

@patch("payments.urls.process_data")
@patch("payments.urls.provider_factory")
def test_payment_not_found_returns_json_404(self, mock_factory, mock_process):
"""Test that payment not found returns JSON 404 without token exposure."""
from django.http import Http404

mock_provider = Mock()
mock_provider.get_token_from_request.return_value = (
"550e8400-e29b-41d4-a716-446655440000" # Realistic UUID token
)
mock_factory.return_value = mock_provider
mock_process.side_effect = Http404("Payment not found")

response = self.client.post("/payments/process/dummy/")

assert response.status_code == 404
assert response["Content-Type"] == "application/json"

data = response.json()
assert data["error"] == "Payment not found"
assert data["variant"] == "dummy"
# Token should not be exposed in error response for security
assert "token" not in data
46 changes: 41 additions & 5 deletions payments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

from django.db.transaction import atomic
from django.http import Http404
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.urls import path
from django.urls import re_path
from django.views.decorators.csrf import csrf_exempt

from . import PaymentError
from . import get_payment_model
from .core import provider_factory

Expand All @@ -23,6 +25,8 @@ def process_data(request, token, provider=None):
Calls process_data of an appropriate provider.

Raises Http404 if variant does not exist.
Note: When called via static_callback, Http404 exceptions are caught
and converted to JSON error responses for webhook systems.
"""
Payment = get_payment_model()
payment = get_object_or_404(Payment, token=token)
Expand All @@ -37,15 +41,47 @@ def process_data(request, token, provider=None):
@csrf_exempt
@atomic
def static_callback(request, variant):
"""
Handle webhooks sent to a static provider endpoint.

Returns JSON responses for known error cases to provide machine-readable
feedback to webhook systems (e.g., Stripe, PayPal).

Unexpected exceptions will propagate and result in 500 errors, which
will be logged by standard Django error handling and reported to Sentry.
"""
try:
provider = provider_factory(variant)
except ValueError as e:
raise Http404("No such provider") from e
except ValueError:
return JsonResponse(
{"error": "Invalid payment provider", "variant": variant}, status=400
)

try:
token = provider.get_token_from_request(request=request, payment=None)
except PaymentError as e:
return JsonResponse(
{"error": str(e), "variant": variant, "error_code": e.code},
status=e.code or 400,
)

token = provider.get_token_from_request(request=request, payment=None)
if not token:
raise Http404("Invalid response")
return process_data(request, token, provider)
return JsonResponse(
{
"error": "Could not extract payment token from webhook",
"variant": variant,
},
status=400,
)

try:
return process_data(request, token, provider)
except Http404:
# Don't expose full token in error response for security
return JsonResponse(
{"error": "Payment not found", "variant": variant},
status=404,
)


urlpatterns = [
Expand Down
16 changes: 16 additions & 0 deletions test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import os

from django.urls import include
from django.urls import path

PROJECT_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "payments"))
TEMPLATES = [
{
Expand All @@ -14,3 +17,16 @@
PAYMENT_HOST = "example.com"

INSTALLED_APPS = ["payments", "django.contrib.sites"]

ROOT_URLCONF = "test_settings"

urlpatterns = [
path("payments/", include("payments.urls")),
]

DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
Loading