diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 8e6e6f8fc0..39db1b081a 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -25,6 +25,7 @@ at :ref:`plugin-docs`. seats orders invoices + transactions vouchers discounts checkin diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst index 8e4aae6441..b98b6f1285 100644 --- a/doc/api/resources/invoices.rst +++ b/doc/api/resources/invoices.rst @@ -1,3 +1,5 @@ +.. _rest-invoices: + Invoices ======== diff --git a/doc/api/resources/transactions.rst b/doc/api/resources/transactions.rst new file mode 100644 index 0000000000..387b7b2882 --- /dev/null +++ b/doc/api/resources/transactions.rst @@ -0,0 +1,232 @@ +.. _rest-transactions: + +Transactions +============ + +Transactions are an additional way to think about orders. They are are an immutable, filterable view into an order's +history and are a good basis for financial reporting. + +Our financial model +------------------- + +You can think of a pretix order similar to a debtor account in double-entry bookkeeping. For example, the flow of an +order could look like this: + +===================================================== ==================== ===================== +Transaction Debit Credit +===================================================== ==================== ===================== +Order is placed with two tickets € 500 +Order is paid partially with a gift card € 200 +Remainder is paid with a credit card € 300 +One of the tickets is canceled **-** € 250 +Refund is made to the credit card **-** € 250 +**Balance** **€ 250** **€ 250** +===================================================== ==================== ===================== + +If an order is fully settled, the sums of both columns match. However, as the movements in both columns do not always +happen at the same time, at some times during the lifecycle of an order the sums are not balanced, in which case we +consider an order to be "pending payment" or "overpaid". + +In the API, the "Debit" column is represented by the "transaction" resource listed on this page. +In many cases, the left column *usually* also matches the data returned by the :ref:`rest-invoices` resource, but there +are two important differences: + +- pretix may be configured such that an invoice is not always generated for an order. In this case, only the transactions + return the full data set. + +- pretix does not enforce a new invoice to be created e.g. when a ticket is changed to a different subevent. However, + pretix always creates a new transaction whenever there is a change to a ticket that concerns the **price**, **tax rate**, + **product**, or **date** (in an event series). + +The :ref:`rest-orders` themselves are not a good representation of the "Debit" side of the table for accounting +purposes since they are not immutable: +They will only tell you the current state of the order, not what it was a week ago. + +The "Credit" column is represented by the :ref:`order-payment-resource` and :ref:`order-refund-resource`. + + +Resource description +-------------------- + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the transaction +order string Order code the transaction was created from +event string Event slug, only present on organizer-level API calls +created datetime The creation time of the transaction in the database +datetime datetime The time at which the transaction is financially relevant. + This is usually the same as created, but may vary for + retroactively created transactions after software bugs or + for data that preceeds this data model. +positionid integer Number of the position within the order this refers to, + is ``null`` for transactions that refer to a fee +count integer Number of items purchased, is negative for cancellations +item integer The internal ID of the item purchased (or ``null`` for fees) +variation integer The internal ID of the variation purchased (or ``null``) +subevent integer The internal ID of the event series date (or ``null``) +price money (string) Gross price of the transaction +tax_rate decimal (string) Tax rate applied in transaction +tax_rule integer The internal ID of the tax rule used (or ``null``) +tax_code string The selected tax code (or ``null``) +tax_value money (string) The computed tax value +fee_type string The type of fee (or ``null`` for products) +internal_type string Additional type classification of the fee (or ``null`` for products) +===================================== ========================== ======================================================= + +.. versionchanged:: 2025.7.0 + + This resource was added to the API. + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/transactions/ + + Returns a list of all transactions of an event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/transactions/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 123, + "order": "FOO", + "count": 1, + "created": "2017-12-01T10:00:00Z", + "datetime": "2017-12-01T10:00:00Z", + "item": null, + "variation": null, + "positionid": 1, + "price": "23.00", + "subevent": null, + "tax_code": "E", + "tax_rate": "0.00", + "tax_rule": 23, + "tax_value": "0.00", + "fee_type": null, + "internal_type": null + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string order: Only return transactions matching the given order code. + :query datetime_since: Only return transactions with a datetime at or after the given time. + :query datetime_before: Only return transactions with a datetime before the given time. + :query created_since: Only return transactions with a creation time at or after the given time. + :query created_before: Only return transactions with a creation time before the given time. + :query item: Only return transactions that match the given item ID. + :query item__in: Only return transactions that match one of the given item IDs (separated with a comma). + :query variation: Only return transactions that match the given variation ID. + :query variation__in: Only return transactions that match one of the given variation IDs (separated with a comma). + :query subevent: Only return transactions that match the given subevent ID. + :query subevent__in: Only return transactions that match one of the given subevent IDs (separated with a comma). + :query tax_rule: Only return transactions that match the given tax rule ID. + :query tax_rule__in: Only return transactions that match one of the given tax rule IDs (separated with a comma). + :query tax_code: Only return transactions that match the given tax code. + :query tax_code__in: Only return transactions that match one of the given tax codes (separated with a comma). + :query tax_rate: Only return transactions that match the given tax rate. + :query tax_rate__in: Only return transactions that match one of the given tax rates (separated with a comma). + :query fee_type: Only return transactions that match the given fee type. + :query fee_type__in: Only return transactions that match one of the given fee types (separated with a comma). + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``created``, and ``id``. + :param organizer: The ``slug`` field of a valid organizer + :param event: The ``slug`` field of a valid event + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it. + +.. http:get:: /api/v1/organizers/(organizer)/transactions/ + + Returns a list of all transactions of an organizer that you have access to. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/transactions/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 123, + "event": "sampleconf", + "order": "FOO", + "count": 1, + "created": "2017-12-01T10:00:00Z", + "datetime": "2017-12-01T10:00:00Z", + "item": null, + "variation": null, + "positionid": 1, + "price": "23.00", + "subevent": null, + "tax_code": "E", + "tax_rate": "0.00", + "tax_rule": 23, + "tax_value": "0.00", + "fee_type": null, + "internal_type": null + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string event: Only return transactions matching the given event slug. + :query string order: Only return transactions matching the given order code. + :query datetime_since: Only return transactions with a datetime at or after the given time. + :query datetime_before: Only return transactions with a datetime before the given time. + :query created_since: Only return transactions with a creation time at or after the given time. + :query created_before: Only return transactions with a creation time before the given time. + :query item: Only return transactions that match the given item ID. + :query item__in: Only return transactions that match one of the given item IDs (separated with a comma). + :query variation: Only return transactions that match the given variation ID. + :query variation__in: Only return transactions that match one of the given variation IDs (separated with a comma). + :query subevent: Only return transactions that match the given subevent ID. + :query subevent__in: Only return transactions that match one of the given subevent IDs (separated with a comma). + :query tax_rule: Only return transactions that match the given tax rule ID. + :query tax_rule__in: Only return transactions that match one of the given tax rule IDs (separated with a comma). + :query tax_code: Only return transactions that match the given tax code. + :query tax_code__in: Only return transactions that match one of the given tax codes (separated with a comma). + :query tax_rate: Only return transactions that match the given tax rate. + :query tax_rate__in: Only return transactions that match one of the given tax rates (separated with a comma). + :query fee_type: Only return transactions that match the given fee type. + :query fee_type__in: Only return transactions that match one of the given fee types (separated with a comma). + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``created``, and ``id``. + :param organizer: The ``slug`` field of a valid organizer + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 6cdec4b960..50c91c7f66 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -56,7 +56,7 @@ from pretix.base.models import ( ) from pretix.base.models.orders import ( BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, - PrintLog, RevokedTicketSecret, + PrintLog, RevokedTicketSecret, Transaction, ) from pretix.base.pdf import get_images, get_variables from pretix.base.services.cart import error_messages @@ -1783,3 +1783,23 @@ class BlockedTicketSecretSerializer(I18nAwareModelSerializer): class Meta: model = BlockedTicketSecret fields = ('id', 'secret', 'updated', 'blocked') + + +class TransactionSerializer(I18nAwareModelSerializer): + order = serializers.SlugRelatedField(slug_field="code", read_only=True) + + class Meta: + model = Transaction + fields = ( + "id", "order", "created", "datetime", "positionid", "count", "item", "variation", + "subevent", "price", "tax_rate", "tax_rule", "tax_code", "tax_value", "fee_type", + "internal_type" + ) + + +class OrganizerTransactionSerializer(TransactionSerializer): + event = serializers.SlugRelatedField(source="order.event", slug_field="slug", read_only=True) + + class Meta: + model = Transaction + fields = TransactionSerializer.Meta.fields + ("event",) diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index d853fa1dc6..aa89ece052 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -66,6 +66,7 @@ orga_router.register(r'orders', order.OrganizerOrderViewSet) orga_router.register(r'invoices', order.InvoiceViewSet) orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet) orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') +orga_router.register(r'transactions', order.OrganizerTransactionViewSet) team_router = routers.DefaultRouter() team_router.register(r'members', organizer.TeamMemberViewSet) @@ -83,6 +84,7 @@ event_router.register(r'quotas', item.QuotaViewSet) event_router.register(r'vouchers', voucher.VoucherViewSet) event_router.register(r'orders', order.EventOrderViewSet) event_router.register(r'orderpositions', order.OrderPositionViewSet) +event_router.register(r'transactions', order.TransactionViewSet) event_router.register(r'invoices', order.InvoiceViewSet) event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets') event_router.register(r'blockedsecrets', order.BlockedSecretViewSet, basename='blockedsecrets') diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 2ed339a6e1..f7d7a7ed16 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -57,9 +57,9 @@ from pretix.api.serializers.order import ( BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer, OrderPaymentSerializer, OrderPositionSerializer, OrderRefundCreateSerializer, - OrderRefundSerializer, OrderSerializer, PriceCalcSerializer, - PrintLogSerializer, RevokedTicketSecretSerializer, - SimulatedOrderSerializer, + OrderRefundSerializer, OrderSerializer, OrganizerTransactionSerializer, + PriceCalcSerializer, PrintLogSerializer, RevokedTicketSecretSerializer, + SimulatedOrderSerializer, TransactionSerializer, ) from pretix.api.serializers.orderchange import ( BlockNameSerializer, OrderChangeOperationSerializer, @@ -80,6 +80,7 @@ from pretix.base.models import ( ) from pretix.base.models.orders import ( BlockedTicketSecret, PrintLog, QuestionAnswer, RevokedTicketSecret, + Transaction, ) from pretix.base.payment import PaymentException from pretix.base.pdf import get_images @@ -2030,3 +2031,61 @@ class BlockedSecretViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return BlockedTicketSecret.objects.filter(event=self.request.event) + + +with scopes_disabled(): + class TransactionFilter(FilterSet): + order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact') + event = django_filters.CharFilter(field_name='order__event', lookup_expr='slug__iexact') + datetime_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte') + datetime_before = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='lt') + created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte') + created_before = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='lt') + + class Meta: + model = Transaction + fields = { + 'item': ['exact', 'in'], + 'variation': ['exact', 'in'], + 'subevent': ['exact', 'in'], + 'tax_rule': ['exact', 'in'], + 'tax_code': ['exact', 'in'], + 'tax_rate': ['exact', 'in'], + 'fee_type': ['exact', 'in'], + } + + +class TransactionViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = TransactionSerializer + queryset = Transaction.objects.none() + filter_backends = (DjangoFilterBackend, TotalOrderingFilter) + ordering = ('datetime', 'pk') + ordering_fields = ('datetime', 'created', 'id',) + filterset_class = TransactionFilter + permission = 'can_view_orders' + + def get_queryset(self): + return Transaction.objects.filter(order__event=self.request.event).select_related("order") + + +class OrganizerTransactionViewSet(TransactionViewSet): + serializer_class = OrganizerTransactionSerializer + permission = None + + def get_queryset(self): + qs = Transaction.objects.filter( + order__event__organizer=self.request.organizer + ).select_related("order", "order__event") + + if isinstance(self.request.auth, (TeamAPIToken, Device)): + qs = qs.filter( + order__event__in=self.request.auth.get_events_with_permission("can_view_orders"), + ) + elif self.request.user.is_authenticated: + qs = qs.filter( + order__event__in=self.request.user.get_events_with_permission("can_view_orders", request=self.request) + ) + else: + raise PermissionDenied("Unknown authentication scheme") + + return qs diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 5171edada9..7380efd1bb 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -64,6 +64,8 @@ event_permission_sub_urls = [ ('get', 'can_view_orders', 'revokedsecrets/1/', 404), ('get', 'can_view_orders', 'blockedsecrets/', 200), ('get', 'can_view_orders', 'blockedsecrets/1/', 404), + ('get', 'can_view_orders', 'transactions/', 200), + ('get', 'can_view_orders', 'transactions/1/', 404), ('get', 'can_view_orders', 'orders/', 200), ('get', 'can_view_orders', 'orderpositions/', 200), ('delete', 'can_change_orders', 'orderpositions/1/', 404), diff --git a/src/tests/api/test_transactions.py b/src/tests/api/test_transactions.py new file mode 100644 index 0000000000..f38bc2cbcc --- /dev/null +++ b/src/tests/api/test_transactions.py @@ -0,0 +1,254 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import copy +import datetime +import json +from decimal import Decimal + +import freezegun +import pytest +from django_scopes import scopes_disabled + +from pretix.base.models import Order, OrderPosition +from pretix.base.models.orders import OrderFee + + +@pytest.fixture +def item(event): + return event.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def taxrule(event): + return event.tax_rules.create(rate=Decimal("19.00"), code="S/standard") + + +@pytest.fixture +def order(event, item, device, taxrule): + with freezegun.freeze_time("2017-12-01T10:00:00"): + o = Order.objects.create( + code="FOO", + event=event, + email="dummy@dummy.test", + status=Order.STATUS_PENDING, + secret="k24fiuwvu8kxz3y1", + datetime=datetime.datetime( + 2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc + ), + expires=datetime.datetime( + 2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc + ), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + total=23, + locale="en", + ) + o.fees.create( + fee_type=OrderFee.FEE_TYPE_PAYMENT, + value=Decimal("0.25"), + tax_rate=Decimal("19.00"), + tax_value=Decimal("0.05"), + tax_rule=taxrule, + tax_code=taxrule.code, + ) + OrderPosition.objects.create( + order=o, + item=item, + variation=None, + price=Decimal("23"), + attendee_name_parts={"full_name": "Peter", "_scheme": "full"}, + secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w", + pseudonymization_id="ABCDEFGHKL", + positionid=1, + ) + o.create_transactions() + return o + + +TEST_TRANSACTION_RES_OP = { + "count": 1, + "created": "2017-12-01T10:00:00Z", + "datetime": "2017-12-01T10:00:00Z", + "fee_type": None, + "internal_type": None, + "item": None, + "order": "FOO", + "positionid": 1, + "price": "23.00", + "subevent": None, + "tax_code": None, + "tax_rate": "0.00", + "tax_rule": None, + "tax_value": "0.00", + "variation": None, +} +TEST_TRANSACTION_RES_FEE = { + "count": 1, + "created": "2017-12-01T10:00:00Z", + "datetime": "2017-12-01T10:00:00Z", + "fee_type": "payment", + "internal_type": "", + "item": None, + "order": "FOO", + "positionid": None, + "price": "0.25", + "subevent": None, + "tax_code": "S/standard", + "tax_rate": "19.00", + "tax_rule": 1, + "tax_value": "0.05", + "variation": None, +} + + +@pytest.mark.django_db +def test_transaction_list(token_client, organizer, event, order, item, taxrule): + res_op = copy.deepcopy(TEST_TRANSACTION_RES_OP) + res_fee = copy.deepcopy(TEST_TRANSACTION_RES_FEE) + with scopes_disabled(): + res_fee["id"] = order.transactions.get(fee_type="payment").pk + res_fee["tax_rule"] = taxrule.pk + res_op["id"] = order.transactions.get(item__isnull=False).pk + res_op["item"] = item.pk + + resp = token_client.get( + "/api/v1/organizers/{}/events/{}/transactions/".format( + organizer.slug, + event.slug, + ) + ) + assert resp.status_code == 200 + assert res_op in resp.data["results"] + assert res_fee in resp.data["results"] + assert resp.data["count"] == 2 + + resp = token_client.get( + "/api/v1/organizers/{}/events/{}/transactions/?order=FOO".format( + organizer.slug, + event.slug, + ) + ) + assert resp.data["count"] == 2 + resp = token_client.get( + "/api/v1/organizers/{}/events/{}/transactions/?order=BAR".format( + organizer.slug, + event.slug, + ) + ) + assert resp.data["count"] == 0 + + resp = token_client.get( + "/api/v1/organizers/{}/events/{}/transactions/?datetime_since=2017-12-01T09:00:00Z".format( + organizer.slug, + event.slug, + ) + ) + assert resp.data["count"] == 2 + resp = token_client.get( + "/api/v1/organizers/{}/events/{}/transactions/?datetime_since=2017-12-02T09:00:00Z".format( + organizer.slug, + event.slug, + ) + ) + assert resp.data["count"] == 0 + + resp = token_client.get( + "/api/v1/organizers/{}/events/{}/transactions/?item={}".format( + organizer.slug, event.slug, item.pk + ) + ) + assert resp.data["count"] == 1 + assert res_op in resp.data["results"] + + resp = token_client.get( + "/api/v1/organizers/{}/events/{}/transactions/?fee_type={}".format( + organizer.slug, event.slug, "payment" + ) + ) + assert resp.data["count"] == 1 + assert res_fee in resp.data["results"] + + +@pytest.mark.django_db +def test_order_detail(token_client, organizer, event, order, item, taxrule): + res_fee = copy.deepcopy(TEST_TRANSACTION_RES_FEE) + with scopes_disabled(): + tx = order.transactions.get(fee_type="payment") + res_fee["id"] = tx.pk + res_fee["tax_rule"] = taxrule.pk + resp = token_client.get( + "/api/v1/organizers/{}/events/{}/transactions/{}/".format( + organizer.slug, event.slug, tx.pk + ) + ) + assert resp.status_code == 200 + assert json.loads(json.dumps(res_fee)) == json.loads(json.dumps(resp.data)) + + +@pytest.mark.django_db +def test_organizer_list(token_client, team, organizer, event, order, item, taxrule): + resp = token_client.get( + "/api/v1/organizers/{}/transactions/".format( + organizer.slug, + ) + ) + assert resp.status_code == 200 + assert resp.data["count"] == 2 + assert "event" in resp.data["results"][0] + + resp = token_client.get( + "/api/v1/organizers/{}/transactions/?event=dummy".format( + organizer.slug, + ) + ) + assert resp.status_code == 200 + assert resp.data["count"] == 2 + + resp = token_client.get( + "/api/v1/organizers/{}/transactions/?event=test".format( + organizer.slug, + ) + ) + assert resp.status_code == 200 + assert resp.data["count"] == 0 + + team.all_events = False + team.save() + + resp = token_client.get( + "/api/v1/organizers/{}/transactions/".format( + organizer.slug, + ) + ) + assert resp.status_code == 200 + assert resp.data["count"] == 0 + + team.all_events = True + team.can_view_orders = False + team.save() + + resp = token_client.get( + "/api/v1/organizers/{}/transactions/".format( + organizer.slug, + ) + ) + assert resp.status_code == 200 + assert resp.data["count"] == 0