Skip to content

Commit 973e402

Browse files
committed
Create webhook endpoint to process transactions received by OpenPix
1 parent 434907d commit 973e402

6 files changed

Lines changed: 348 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,4 @@ target/
6363
.ipynb_checkpoints
6464

6565
.venv
66+
.*.swp

pretix_pix_openpix/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.urls import re_path
2+
3+
from . import views
4+
5+
urlpatterns = [
6+
re_path(r"^_pretix_pix_openpix/webhook/", views.webhook, name="webhook"),
7+
]

pretix_pix_openpix/views.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import json
2+
import logging
3+
from http import HTTPStatus
4+
5+
from django.http import HttpResponse
6+
from django.views.decorators.csrf import csrf_exempt
7+
from django_scopes import scopes_disabled
8+
9+
from pretix.base.models import Order, OrderPayment
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
@csrf_exempt
15+
def webhook(request):
16+
event_body = request.body.decode("utf-8").strip()
17+
18+
try:
19+
data = json.loads(event_body)
20+
except json.decoder.JSONDecodeError:
21+
return HttpResponse(status=HTTPStatus.OK)
22+
23+
event = data.get("event") or ""
24+
25+
if event == "OPENPIX:TRANSACTION_RECEIVED":
26+
pix = data.get("pix") or {}
27+
identifier = pix.get("transactionID")
28+
value = pix.get("value", 0.0) / 100
29+
30+
logger.info("%s received for order %s", event, identifier)
31+
with scopes_disabled():
32+
order_payment = OrderPayment.objects.filter(
33+
order__code=identifier,
34+
order__status=Order.STATUS_PENDING,
35+
amount=value,
36+
provider="pix_openpix",
37+
state__in=(
38+
OrderPayment.PAYMENT_STATE_CREATED,
39+
OrderPayment.PAYMENT_STATE_PENDING,
40+
),
41+
).last()
42+
if order_payment:
43+
order_payment.confirm()
44+
45+
return HttpResponse(status=HTTPStatus.OK)

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ requires = [
3636
homepage = "https://github.com/lhc/pretix-pix-openpix"
3737
repository = "https://github.com/lhc/pretix-pix-openpix"
3838

39+
[tool.pytest.ini_options]
40+
DJANGO_SETTINGS_MODULE = "pretix.testutils.settings"
41+
3942
[tool.setuptools]
4043
include-package-data = true
4144

tests/conftest.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,124 @@
1-
# put your pytest fixtures here
1+
from datetime import datetime, timedelta, timezone
2+
from decimal import Decimal
3+
4+
import pytest
5+
6+
from pretix.base.models import Event, Order, OrderPayment, Organizer, SalesChannel
7+
8+
9+
@pytest.fixture
10+
def organizer(db):
11+
return Organizer.objects.create(name="Test Organizer", slug="test-organizer")
12+
13+
14+
@pytest.fixture
15+
def event(db, organizer):
16+
return Event.objects.create(
17+
organizer=organizer,
18+
name="Test Event",
19+
slug="test_event",
20+
date_from=datetime(2025, 8, 20, 10, 0, 0, tzinfo=timezone.utc),
21+
plugins="pretix.plugins.pix_openpix",
22+
)
23+
24+
25+
@pytest.fixture
26+
def order(db, event):
27+
sales_channel = SalesChannel.objects.create(
28+
organizer=event.organizer,
29+
label="Test Sales Channel",
30+
identifier="SALES-CHANNEL",
31+
type="SALES-CHANNEL",
32+
)
33+
34+
_order = Order.objects.create(
35+
code="FOOBAR",
36+
event=event,
37+
email="dummy@dummy.test",
38+
status=Order.STATUS_PENDING,
39+
expires=datetime.now() + timedelta(days=10),
40+
total=Decimal("100.0"),
41+
sales_channel=sales_channel,
42+
)
43+
OrderPayment.objects.create(
44+
local_id=1,
45+
state=OrderPayment.PAYMENT_STATE_CREATED,
46+
amount=_order.total,
47+
order=_order,
48+
provider="pix_openpix",
49+
process_initiated=True,
50+
)
51+
52+
return _order
53+
54+
55+
@pytest.fixture
56+
def create_payload():
57+
def _create_payload(
58+
*,
59+
order_code="CODE",
60+
payload_event="OPENPIX:TRANSACTION_RECEIVED",
61+
order_total=Decimal("100.0")
62+
):
63+
value = 10000
64+
65+
return {
66+
"event": "OPENPIX:TRANSACTION_RECEIVED",
67+
"pixQrCode": {
68+
"name": order_code,
69+
"value": value,
70+
"comment": "good",
71+
"identifier": order_code,
72+
"correlationID": order_code,
73+
"paymentLinkID": "d6e6864a-d7f1-4f1f-b487-ba833b207248",
74+
"createdAt": "2025-08-29T00:43:58.047Z",
75+
"updatedAt": "2025-08-29T00:43:58.047Z",
76+
"brCode": "00020126660014br.gov.bcb.pix01367fc202c1-40a2-49cc-912f-5d2c1c3919c70204good520value00530398654044.005802BR5925Laboratorio_Hacker_de_Cam6009Sao_Paulo62090505P0HXH6304C3CE",
77+
"paymentLinkUrl": "https://woovi-sandbox.com/pay/d6e6864a-d7f1-4f1f-b487-ba833b207248",
78+
"qrCodeImage": "https://api.woovi-sandbox.com/openpix/charge/brcode/image/d6e6864a-d7f1-4f1f-b487-ba833b207248.png",
79+
"pixKey": "7fc202c1-40a2-49cc-912f-5d2c1c3919c7",
80+
},
81+
"pix": {
82+
"payer": {
83+
"name": "Cliente Teste",
84+
"taxID": {"taxID": "44720743000101", "type": "BR:CNPJ"},
85+
"correlationID": "02c308a7-9dc9-4df7-8346-035463066094",
86+
},
87+
"value": value,
88+
"time": "2025-08-29T00:44:11.881Z",
89+
"endToEndId": "Ef6223604800442e9852227415e7b6141",
90+
"transactionID": order_code,
91+
"infoPagador": "OpenPix PixQrCode testing",
92+
"status": "CONFIRMED",
93+
"type": "PAYMENT",
94+
"pixQrCode": {
95+
"name": order_code,
96+
"value": value,
97+
"comment": "good",
98+
"identifier": order_code,
99+
"correlationID": order_code,
100+
"paymentLinkID": "d6e6864a-d7f1-4f1f-b487-ba833b207248",
101+
"createdAt": "2025-08-29T00:43:58.047Z",
102+
"updatedAt": "2025-08-29T00:43:58.047Z",
103+
"brCode": "00020126660014br.gov.bcb.pix01367fc202c1-40a2-49cc-912f-5d2c1c3919c70204good520value00530398654044.005802BR5925Laboratorio_Hacker_de_Cam6009Sao_Paulo62090505P0HXH6304C3CE",
104+
"paymentLinkUrl": "https://woovi-sandbox.com/pay/d6e6864a-d7f1-4f1f-b487-ba833b207248",
105+
"qrCodeImage": "https://api.woovi-sandbox.com/openpix/charge/brcode/image/d6e6864a-d7f1-4f1f-b487-ba833b207248.png",
106+
"pixKey": "7fc202c1-40a2-49cc-912f-5d2c1c3919c7",
107+
},
108+
"createdAt": "2025-08-29T00:44:11.883Z",
109+
"globalID": "UGl4VHJhbnNhY3Rpb246NjhiMGY3ZGJiZWM4MDI4ZGU4Y2UzODNl",
110+
},
111+
"company": {
112+
"id": "689a6d3abc44a20d2b4d3e6d",
113+
"name": "Laboratório Hacker de Campinas",
114+
"taxID": "35215073000185",
115+
},
116+
"account": {
117+
"accountId": "689a6d3abc44a20d2b4d3e86",
118+
"branch": "0001",
119+
"account": "0240",
120+
},
121+
"refunds": [],
122+
}
123+
124+
return _create_payload

tests/test_webhook.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from http import HTTPStatus
2+
3+
import pytest
4+
from django.urls import reverse
5+
6+
from pretix.base.models import Order
7+
8+
9+
def test_webhook_url(client, db):
10+
webhook_url = reverse("plugins:pretix_pix_openpix:webhook")
11+
12+
response = client.post(webhook_url)
13+
14+
assert response.status_code == HTTPStatus.OK
15+
16+
17+
def test_webhook_url_valid_payload(client, db, create_payload):
18+
payload = create_payload()
19+
20+
webhook_url = reverse("plugins:pretix_pix_openpix:webhook")
21+
22+
response = client.post(
23+
webhook_url,
24+
data=payload,
25+
headers={"Content-Type": "application/json"},
26+
)
27+
28+
assert response.status_code == HTTPStatus.OK
29+
30+
31+
def test_mark_order_as_paid(client, db, order, create_payload):
32+
assert order.status == Order.STATUS_PENDING
33+
34+
payload = create_payload(order_code=order.code, order_total=order.total)
35+
36+
webhook_url = reverse("plugins:pretix_pix_openpix:webhook")
37+
38+
response = client.post(
39+
webhook_url,
40+
data=payload,
41+
content_type="application/json",
42+
)
43+
44+
order.refresh_from_db()
45+
assert order.status == Order.STATUS_PAID
46+
assert response.status_code == HTTPStatus.OK
47+
48+
49+
def test_do_not_change_order_if_webhook_event_not_valid(
50+
client, db, order, create_payload
51+
):
52+
assert order.status == Order.STATUS_PENDING
53+
54+
payload = create_payload(order_code=order.code, order_total=order.total)
55+
payload["event"] = "OPENPIX:TRANSACTION_REFUND_RECEIVED"
56+
57+
webhook_url = reverse("plugins:pretix_pix_openpix:webhook")
58+
59+
response = client.post(
60+
webhook_url,
61+
data=payload,
62+
content_type="application/json",
63+
)
64+
65+
order.refresh_from_db()
66+
assert order.status == Order.STATUS_PENDING
67+
assert response.status_code == HTTPStatus.OK
68+
69+
70+
def test_invalid_json_payload_do_nothing(client, db, order):
71+
assert order.status == Order.STATUS_PENDING
72+
73+
payload = {"content": "not the expected JSON payload"}
74+
75+
webhook_url = reverse("plugins:pretix_pix_openpix:webhook")
76+
77+
response = client.post(
78+
webhook_url,
79+
data=payload,
80+
content_type="application/json",
81+
)
82+
83+
order.refresh_from_db()
84+
assert order.status == Order.STATUS_PENDING
85+
assert response.status_code == HTTPStatus.OK
86+
87+
88+
def test_missing_transaction_id_in_payload_do_nothing(client, db, order):
89+
assert order.status == Order.STATUS_PENDING
90+
91+
payload = {
92+
"event": "OPENPIX:TRANSACTION_RECEIVED",
93+
"pix": {
94+
"value": 10000,
95+
},
96+
}
97+
98+
webhook_url = reverse("plugins:pretix_pix_openpix:webhook")
99+
response = client.post(
100+
webhook_url,
101+
data=payload,
102+
content_type="application/json",
103+
)
104+
105+
order.refresh_from_db()
106+
assert order.status == Order.STATUS_PENDING
107+
assert response.status_code == HTTPStatus.OK
108+
109+
110+
def test_missing_value_in_payload_do_nothing(client, db, order):
111+
assert order.status == Order.STATUS_PENDING
112+
113+
payload = {
114+
"event": "OPENPIX:TRANSACTION_RECEIVED",
115+
"pix": {
116+
"transactionID": order.code,
117+
},
118+
}
119+
120+
webhook_url = reverse("plugins:pretix_pix_openpix:webhook")
121+
122+
response = client.post(
123+
webhook_url,
124+
data=payload,
125+
content_type="application/json",
126+
)
127+
128+
order.refresh_from_db()
129+
assert order.status == Order.STATUS_PENDING
130+
assert response.status_code == HTTPStatus.OK
131+
132+
133+
def test_identifier_does_not_refer_to_existing_order(client, db, create_payload):
134+
payload = create_payload(order_code="NOT-VALID-ORDER")
135+
136+
webhook_url = reverse("plugins:pretix_pix_openpix:webhook")
137+
138+
response = client.post(
139+
webhook_url,
140+
data=payload,
141+
content_type="application/json",
142+
)
143+
144+
assert response.status_code == HTTPStatus.OK
145+
146+
147+
@pytest.mark.parametrize(
148+
"order_status", [Order.STATUS_PAID, Order.STATUS_EXPIRED, Order.STATUS_CANCELED]
149+
)
150+
def test_when_order_is_not_pending_do_nothing(
151+
order_status, client, db, order, create_payload
152+
):
153+
order.status = order_status
154+
order.save()
155+
156+
payload = create_payload(order_code=order.code, order_total=order.total)
157+
158+
webhook_url = reverse("plugins:pretix_pix_openpix:webhook")
159+
160+
response = client.post(
161+
webhook_url,
162+
data=payload,
163+
content_type="application/json",
164+
)
165+
166+
order.refresh_from_db()
167+
assert order.status == order_status
168+
assert response.status_code == HTTPStatus.OK

0 commit comments

Comments
 (0)