diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index aba01e31ba..4f0dc3218c 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -32,8 +32,8 @@ email string The customer em locale string The locale used for communication with this customer datetime datetime Time of order creation expires datetime The order will expire, if it is still pending by this time -payment_date date Date of payment receipt -payment_provider string Payment provider used for this order +payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt +payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order total money (string) Total value of this order comment string Internal comment on this order checkin_attention boolean If ``True``, the check-in app should show a warning @@ -74,6 +74,8 @@ downloads list of objects List of ticket download options. ├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) └ url string Download URL +payments list of objects List of payment processes (see below) +refunds list of objects List of refund processes (see below) last_modified datetime Last modification of this object ===================================== ========================== ======================================================= @@ -108,6 +110,11 @@ last_modified datetime Last modificati The attributes ``order.last_modified`` as well as the corresponding filters to the resource have been added. An endpoint for order creation as well as ``…/mark_refunded/`` has been added. +.. versionchanged:: 2.0 + + The ``order.payment_date`` and ``order.payment_provider`` attributes have been deprecated in favor of the new + nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. + .. _order-position-resource: Order position resource @@ -167,9 +174,45 @@ pdf_data object Data object req The attributes ``pseudonymization_id`` and ``pdf_data`` have been added. +.. _order-payment-resource: -Order endpoints ---------------- +Order payment resource +---------------------- + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +local_id integer Internal ID of this payment, starts at 1 for every order +state string Payment state, one of ``created``, ``pending``, ``confirmed``, ``canceled``, ``pending``, ``failed``, or ``refunded`` +amount money (string) Payment amount +created datetime Date and time of creation of this payment +payment_date datetime Date and time of completion of this payment (or ``null``) +provider string Identification string of the payment provider +===================================== ========================== ======================================================= + +.. _order-payment-resource: + +Order refund resource +--------------------- + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +local_id integer Internal ID of this payment, starts at 1 for every order +state string Payment state, one of ``created``, ``transit``, ``external``, ``canceled``, ``failed``, or ``done`` +source string How this refund has been created, one of ``buyer``, ``admin``, or ``external`` +amount money (string) Payment amount +created datetime Date and time of creation of this payment +payment_date datetime Date and time of completion of this payment (or ``null``) +provider string Identification string of the payment provider +===================================== ========================== ======================================================= + +List of all orders +------------------ .. versionchanged:: 1.15 @@ -275,7 +318,18 @@ Order endpoints "output": "pdf", "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/" } - ] + ], + "payments": [ + { + "local_id": 1, + "state": "confirmed", + "amount": "23.00", + "created": "2017-12-01T10:00:00Z", + "payment_date": "2017-12-04T12:13:12Z", + "provider": "banktransfer" + } + ], + "refunds": [] } ] } @@ -296,6 +350,9 @@ Order endpoints :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. +Fetching individual orders +-------------------------- + .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/ Returns information on one order, identified by its order code. @@ -390,7 +447,18 @@ Order endpoints "output": "pdf", "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/" } - ] + ], + "payments": [ + { + "local_id": 1, + "state": "confirmed", + "amount": "23.00", + "created": "2017-12-01T10:00:00Z", + "payment_date": "2017-12-04T12:13:12Z", + "provider": "banktransfer" + } + ], + "refunds": [] } :param organizer: The ``slug`` field of the organizer to fetch @@ -401,6 +469,9 @@ Order endpoints :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 404: The requested order does not exist. +Order ticket download +--------------------- + .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/download/(output)/ Download tickets for an order, identified by its order code. Depending on the chosen output, the response might @@ -442,6 +513,9 @@ Order endpoints :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few seconds. +Creating orders +--------------- + .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/ Creates a new order. @@ -487,21 +561,23 @@ Order endpoints * ``code`` (optional) * ``status`` (optional) – Defaults to pending for non-free orders and paid for free orders. You can only set this to - ``"n"`` for pending or ``"p"`` for paid. If you create a paid order, the ``order_paid`` signal will **not** be - sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and then call - the ``mark_paid`` API method. + ``"n"`` for pending or ``"p"`` for paid. We will create a payment object for this order either in state ``created`` + or in state ``confirmed``, depending on this value. If you create a paid order, the ``order_paid`` signal will + **not** be sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and + then call the ``mark_paid`` API method. * ``consume_carts`` (optional) – A list of cart IDs. All cart positions with these IDs will be deleted if the order creation is successful. Any quotas that become free by this operation will be credited to your order creation. * ``email`` * ``locale`` * ``payment_provider`` – The identifier of the payment provider set for this order. This needs to be an existing - payment provider. You should use ``"free"`` for free orders. - * ``payment_info`` (optional) – You can pass a nested JSON object that will be set as the internal ``payment_info`` - value of the order. How this value is handled is up to the payment provider and you should only use this if you - know the specific payment provider in detail. Please keep in mind that the payment provider will not be called - to do anything about this (i.e. if you pass a bank account to a debit provider, *no* charge will be created), - this is just informative in case you *handled the payment already*. + payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` for all + orders you create as paid. + * ``payment_info`` (optional) – You can pass a nested JSON object that will be set as the internal ``info`` + value of the payment object that will be created. How this value is handled is up to the payment provider and you + should only use this if you know the specific payment provider in detail. Please keep in mind that the payment + provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no* + charge will be created), this is just informative in case you *handled the payment already*. * ``comment`` (optional) * ``checkin_attention`` (optional) * ``invoice_address`` (optional) @@ -618,6 +694,9 @@ Order endpoints :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this order. +Order state operations +---------------------- + .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/ Marks a pending or expired order as successfully paid. @@ -853,8 +932,8 @@ Order endpoints :statuscode 404: The requested order does not exist. -Order position endpoints ------------------------- +List of all order positions +--------------------------- .. versionchanged:: 1.15 @@ -958,6 +1037,9 @@ Order position endpoints :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. +Fetching individual positions +----------------------------- + .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ Returns information on one order position, identified by its internal ID. @@ -1026,6 +1108,9 @@ Order position endpoints :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 404: The requested order position does not exist. +Order position ticket download +------------------------------ + .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/download/(output)/ Download tickets for one order position, identified by its internal ID. @@ -1067,3 +1152,467 @@ Order position endpoints :statuscode 404: The requested order position or download provider does not exist. :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few seconds. + + +Order payment endpoints +----------------------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/ + + Returns a list of all payments for an order. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/ 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": [ + { + "local_id": 1, + "state": "confirmed", + "amount": "23.00", + "created": "2017-12-01T10:00:00Z", + "payment_date": "2017-12-04T12:13:12Z", + "provider": "banktransfer" + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param order: The ``code`` field of the order to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order does not exist. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/ + + Returns information on one payment, identified by its order-local ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/1/ 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 + + { + "local_id": 1, + "state": "confirmed", + "amount": "23.00", + "created": "2017-12-01T10:00:00Z", + "payment_date": "2017-12-04T12:13:12Z", + "provider": "banktransfer" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to fetch + :param local_id: The ``local_id`` field of the payment to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order or payment does not exist. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/confirm/ + + Marks a payment as confirmed. Only allowed in states ``pending`` and ``created``. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/1/confirm/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + {"force": false} + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "local_id": 1, + "state": "confirmed", + ... + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to fetch + :param local_id: The ``local_id`` field of the payment to modify + :statuscode 200: no error + :statuscode 400: Invalid request or payment state + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order or payment does not exist. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/cancel/ + + Marks a payment as canceled. Only allowed in states ``pending`` and ``created``. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/1/cancel/ 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 + + { + "local_id": 1, + "state": "canceled", + ... + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to fetch + :param local_id: The ``local_id`` field of the payment to modify + :statuscode 200: no error + :statuscode 400: Invalid request or payment state + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order or payment does not exist. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/refund/ + + Create and execute a manual refund. Only available in ``confirmed`` state. Returns a refund resource, not + a payment resource! + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/1/refund/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "amount": "23.00", + "mark_refunded": false + } + + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "local_id": 1, + "source": "admin", + "state": "done", + ... + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to fetch + :param local_id: The ``local_id`` field of the payment to modify + :statuscode 200: no error + :statuscode 400: Invalid request, payment state, or operation not supported by the payment provider + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order or payment does not exist. + + +Order refund endpoints +---------------------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/ + + Returns a list of all refunds for an order. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/ 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": [ + { + "local_id": 1, + "state": "done", + "source": "admin", + "amount": "23.00", + "payment": 1, + "created": "2017-12-01T10:00:00Z", + "execution_date": "2017-12-04T12:13:12Z", + "provider": "banktransfer" + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param order: The ``code`` field of the order to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order does not exist. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/ + + Returns information on one refund, identified by its order-local ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/1/ 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 + + { + "local_id": 1, + "state": "done", + "source": "admin", + "amount": "23.00", + "payment": 1, + "created": "2017-12-01T10:00:00Z", + "execution_date": "2017-12-04T12:13:12Z", + "provider": "banktransfer" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to fetch + :param local_id: The ``local_id`` field of the refund to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order or refund does not exist. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/ + + Creates a refund manually. + + .. warning:: We recommend to only use this endpoint for refunds with payment provider ``manual``. This endpoint also + does not check for mismatching amounts etc. Be careful! + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "state": "created", + "source": "admin", + "amount": "23.00", + "payment": 1, + "execution_date": null, + "provider": "manual" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "local_id": 1, + "state": "created", + "source": "admin", + "amount": "23.00", + "payment": 1, + "created": "2017-12-01T10:00:00Z", + "execution_date": null, + "provider": "manual" + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param order: The ``code`` field of the order to fetch + :statuscode 200: no error + :statuscode 400: Invalid data supplied + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order does not exist. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/done/ + + Marks a refund as completed. Only allowed in states ``transit`` and ``created``. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/1/done/ 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 + + { + "local_id": 1, + "state": "done", + .... + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to fetch + :param local_id: The ``local_id`` field of the refund to modify + :statuscode 200: no error + :statuscode 400: Invalid request or refund state + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order or refund does not exist. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/ + + Acts on an external refund, either marks the order as refunded or pending. Only allowed in state ``external``. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/1/done/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + {"mark_refunded": false} + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "local_id": 1, + "state": "done", + .... + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to fetch + :param local_id: The ``local_id`` field of the refund to modify + :statuscode 200: no error + :statuscode 400: Invalid request or refund state + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order or refund does not exist. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/cancel/ + + Marks a refund as canceled. Only allowed in states ``transit``, ``external``, and ``created``. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/1/cancel/ 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 + + { + "local_id": 1, + "state": "canceled", + .... + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to fetch + :param local_id: The ``local_id`` field of the refund to modify + :statuscode 200: no error + :statuscode 400: Invalid request or refund state + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order or refund does not exist. diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst index d4ac408553..d0669e5938 100644 --- a/doc/development/api/index.rst +++ b/doc/development/api/index.rst @@ -10,8 +10,9 @@ Contents: exporter ticketoutput payment + payment_2.0 invoice shredder customview general - quality + quality \ No newline at end of file diff --git a/doc/development/api/payment.rst b/doc/development/api/payment.rst index 01edd2bf34..817b5299f8 100644 --- a/doc/development/api/payment.rst +++ b/doc/development/api/payment.rst @@ -9,6 +9,10 @@ is very similar to creating an export output. Please read :ref:`Creating a plugin ` first, if you haven't already. +.. warning:: We changed our payment provider API a lot in pretix 2.x. Our documentation page on :ref:`payment2.0` + might be insightful even if you do not have a payment provider to port, as it outlines the rationale + behind the current design. + Provider registration --------------------- @@ -31,7 +35,7 @@ that the plugin will provide:: The provider class ------------------ -.. class:: pretix.base.payment.BasePaymentProvider +.. py:class:: pretix.base.payment.BasePaymentProvider The central object of each payment provider is the subclass of ``BasePaymentProvider``. @@ -54,58 +58,62 @@ The provider class This is an abstract attribute, you **must** override this! - .. autoattribute:: is_enabled + .. autoattribute:: public_name - .. automethod:: calculate_fee + .. autoattribute:: is_enabled .. autoattribute:: settings_form_fields .. automethod:: settings_content_render - .. automethod:: render_invoice_text + .. automethod:: is_allowed .. automethod:: payment_form_render .. automethod:: payment_form - .. automethod:: is_allowed - .. autoattribute:: payment_form_fields - .. automethod:: checkout_prepare - .. automethod:: payment_is_valid_session + .. automethod:: checkout_prepare + .. automethod:: checkout_confirm_render This is an abstract method, you **must** override this! - .. automethod:: payment_perform + .. automethod:: execute_payment + + .. automethod:: calculate_fee .. automethod:: order_pending_mail_render - .. automethod:: order_pending_render + .. automethod:: payment_pending_render - This is an abstract method, you **must** override this! + .. autoattribute:: abort_pending_allowed + + .. automethod:: render_invoice_text .. automethod:: order_change_allowed .. automethod:: order_can_retry - .. automethod:: order_prepare + .. automethod:: payment_prepare - .. automethod:: order_paid_render + .. automethod:: payment_control_render - .. automethod:: order_control_render + .. automethod:: payment_refund_supported - .. automethod:: order_control_refund_render + .. automethod:: payment_partial_refund_supported - .. automethod:: order_control_refund_perform - - .. automethod:: is_implicit + .. automethod:: execute_refund .. automethod:: shred_payment_info + .. autoattribute:: is_implicit + + .. autoattribute:: is_meta + Additional views ---------------- diff --git a/doc/development/api/payment_2.0.rst b/doc/development/api/payment_2.0.rst new file mode 100644 index 0000000000..2acbfd78f0 --- /dev/null +++ b/doc/development/api/payment_2.0.rst @@ -0,0 +1,127 @@ +.. highlight:: python + :linenothreshold: 5 + +.. _`payment2.0`: + +Porting a payment provider from pretix 1.x to pretix 2.x +======================================================== + +In pretix 2.x, we changed large parts of the payment provider API. This documentation details the changes we made +and shows you how you can make an existing pretix 1.x payment provider compatible with pretix 2.x + +Conceptual overview +------------------- + +In pretix 1.x, an order was always directly connected to a payment provider for the full life of an order. As long as +an order was unpaid, this could still be changed in some cases, but once an order was paid, no changes to the payment +provider were possible any more. Additionally, the internal state of orders allowed orders only to be fully paid or +not paid at all. This leads to a couple of consequences: + +* Payment-related functions (like "execute payment" or "do a refund") always operated on full orders. + +* Changing the total of an order was basically impossible once an order was paid, since there was no concept of + partial payments or partial refunds. + +* Payment provider plugins needed to take complicated steps to detect cases that require human intervention, like e.g. + + * An order has expired, no quota is left to revive it, but a payment has been received + + * A payment has been received for a canceled order + + * A payment has been received for an order that has already been paid with a different payment method + + * An external payment service notified us of a refund/dispute + + We noticed that we copied and repeated large portions of code in all our official payment provider plugins, just + to deal with some of these cases. + +* Sometimes, there is the need to mark an order as refunded within pretix, without automatically triggering a refund + with an external API. Every payment method needed to implement a user interface for this independently. + +* If a refund was not possible automatically, there was no way user to track which payments actually have been refunded + manually and which are still left to do. + +* When the payment with one payment provider failed and the user changed to a different payment provider, all + information about the first payment was lost from the order object and could only be retrieved from order log data, + which also made it hard to design a data shredder API to get rid of this data. + +In pretix 2.x, we introduced two new models, :py:class:`OrderPayment ` and +:py:class:`OrderRefund `. Each instance of these is connected to an order and +represents one single attempt to pay or refund a specific amount of money. Each one of these has an individual state, +can individually fail or succeed, and carries an amount variable that can differ from the order total. + +This has the following advantages: + +* The system can now detect orders that are over- or underpaid, independent of the payment providers in use. + +* Therefore, we can now allow partial payments, partial refunds, and changing paid orders, and automatically detect + the cases listed above and notify the user. + +Payment providers now interact with those payment and refund objects more than with orders. + +Your to-do list +--------------- + +Payment processing +"""""""""""""""""" + +* The method ``BasePaymentProvider.order_pending_render`` has been removed and replaced by a new + ``BasePaymentProvider.payment_pending_render(request, payment)`` method that is passed an ``OrderPayment`` + object instead of an ``Order``. + +* The method ``BasePaymentProvider.payment_perform`` has been removed and replaced by a new method + ``BasePaymentProvider.execute_payment(request, payment)`` that is passed an ``OrderPayment`` + object instead of an ``Order``. + +* The function ``pretix.base.services.mark_order_paid`` has been removed, instead call ``payment.confirm()`` + on a pending ``OrderPayment`` object. If no further payments are required for this order, this will also + mark the order as paid automatically. Note that ``payment.confirm()`` can still throw a ``QuotaExceededException``, + however it will still mark the payment as complete (not the order!), so you should catch this exception and + inform the user, but not abort the transaction. + +* A new property ``BasePaymentProvider.abort_pending_allowed`` has been introduced. Only if set, the user will + be able to retry a payment or switch the payment method when the order currently has a payment object in + state ``"pending"``. This replaces ``BasePaymentProvider.order_can_retry``, which no longer exists. + +* The methods ``BasePaymentProvider.retry_prepare`` and ``BasePaymentProvider.order_prepare`` have both been + replaced by a new method ``BasePaymentProvider.payment_prepare(request, payment)`` that is passed an ``OrderPayment`` + object instead of an ``Order``. **Keep in mind that this payment object might have an amount property that + differs from the order total, if the order is already partially paid.** + +* The method ``BasePaymentProvider.order_paid_render`` has been removed. + +* The method ``BasePaymentProvider.order_control_render`` has been removed and replaced by a new method + ``BasePaymentProvider.payment_control_render(request, payment)`` that is passed an ``OrderPayment`` + object instead of an ``Order``. + +* There's no need to manually deal with excess payments or duplicate payments anymore, just setting the ``OrderPayment`` + methods to the correct state will do the job. + +Creating refunds +"""""""""""""""" + +* The methods ``BasePaymentProvider.order_control_refund_render`` and ``BasePaymentProvider.order_control_refund_perform`` + have been removed. + +* Two new boolean methods ``BasePaymentProvider.payment_refund_supported(payment)`` and ``BasePaymentProvider.payment_partial_refund_supported(payment)`` + have been introduced. They should be set to return ``True`` if and only if the payment API allows to *automatically* + transfer the money back to the customer. + +* A new method ``BasePaymentProvider.execute_refund(refund)`` has been introduced. This method is called using a + ``OrderRefund`` object in ``"created"`` state and is expected to transfer the money back and confirm success with + calling ``refund.done()``. This will only ever be called if either ``BasePaymentProvider.payment_refund_supported(payment)`` + or ``BasePaymentProvider.payment_partial_refund_supported(payment)`` return ``True``. + +Processing external refunds +""""""""""""""""""""""""""" + +* If e.g. a webhook API notifies you that a payment has been disputed or refunded with the external API, you are + expected to call ``OrderPayment.create_external_refund(self, amount, execution_date, info='{}')`` on this payment. + This will create and return an appropriate ``OrderRefund`` object and send out a notification. However, it will not + mark the order as refunded, but will ask the event organizer for a decision. + +Data shredders +"""""""""""""" + +* The method ``BasePaymentProvider.shred_payment_info`` is no longer passed an order, but instead **either** + an ``OrderPayment`` **or** an ``OrderRefund``. diff --git a/doc/development/implementation/models.rst b/doc/development/implementation/models.rst index 2c6c7e22ba..b49ce6c35b 100644 --- a/doc/development/implementation/models.rst +++ b/doc/development/implementation/models.rst @@ -86,6 +86,15 @@ Carts and Orders .. autoclass:: pretix.base.models.OrderPosition :members: +.. autoclass:: pretix.base.models.OrderFee + :members: + +.. autoclass:: pretix.base.models.OrderPayment + :members: + +.. autoclass:: pretix.base.models.OrderRefund + :members: + .. autoclass:: pretix.base.models.CartPosition :members: diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index f1e7590622..edc146f362 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -7,6 +7,7 @@ from django.utils.translation import ugettext_lazy from django_countries.fields import Country from rest_framework import serializers from rest_framework.exceptions import ValidationError +from rest_framework.relations import SlugRelatedField from rest_framework.reverse import reverse from pretix.api.serializers.i18n import I18nAwareModelSerializer @@ -14,7 +15,9 @@ from pretix.base.models import ( Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition, Question, QuestionAnswer, ) -from pretix.base.models.orders import CartPosition, OrderFee +from pretix.base.models.orders import ( + CartPosition, OrderFee, OrderPayment, OrderRefund, +) from pretix.base.pdf import get_variables from pretix.base.signals import register_ticket_outputs @@ -156,23 +159,61 @@ class OrderPositionSerializer(I18nAwareModelSerializer): self.fields.pop('pdf_data') +class OrderPaymentTypeField(serializers.Field): + # TODO: Remove after pretix 2.2 + def to_representation(self, instance: Order): + t = None + for p in instance.payments.all(): + t = p.provider + return t + + +class OrderPaymentDateField(serializers.DateField): + # TODO: Remove after pretix 2.2 + def to_representation(self, instance: Order): + t = None + for p in instance.payments.all(): + t = p.payment_date or t + if t: + + return super().to_representation(t.date()) + + class OrderFeeSerializer(I18nAwareModelSerializer): class Meta: model = OrderFee fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule') +class OrderPaymentSerializer(I18nAwareModelSerializer): + class Meta: + model = OrderPayment + fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider') + + +class OrderRefundSerializer(I18nAwareModelSerializer): + payment = SlugRelatedField(slug_field='local_id', read_only=True) + + class Meta: + model = OrderRefund + fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider') + + class OrderSerializer(I18nAwareModelSerializer): invoice_address = InvoiceAddressSerializer() positions = OrderPositionSerializer(many=True) fees = OrderFeeSerializer(many=True) downloads = OrderDownloadsField(source='*') + payments = OrderPaymentSerializer(many=True) + refunds = OrderRefundSerializer(many=True) + payment_date = OrderPaymentDateField(source='*') + payment_provider = OrderPaymentTypeField(source='*') class Meta: model = Order fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', 'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads', - 'checkin_attention', 'last_modified') + 'checkin_attention', 'last_modified', 'payments', 'refunds') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -410,6 +451,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer): def create(self, validated_data): fees_data = validated_data.pop('fees') if 'fees' in validated_data else [] positions_data = validated_data.pop('positions') if 'positions' in validated_data else [] + payment_provider = validated_data.pop('payment_provider') + payment_info = validated_data.pop('payment_info', '{}') + if 'invoice_address' in validated_data: ia = InvoiceAddress(**validated_data.pop('invoice_address')) else: @@ -467,14 +511,32 @@ class OrderCreateSerializer(I18nAwareModelSerializer): order.set_expires(subevents=[p.get('subevent') for p in positions_data]) order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00')) order.meta_info = "{}" - if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID: - order.payment_provider = 'free' - order.status = Order.STATUS_PAID - elif order.payment_provider == "free" and order.total != Decimal('0.00'): - raise ValidationError('You cannot use the "free" payment provider for non-free orders.') - if validated_data.get('status') == Order.STATUS_PAID: - order.payment_date = now() order.save() + + if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID: + order.status = Order.STATUS_PAID + order.save() + order.payments.create( + amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED + ) + elif payment_provider == "free" and order.total != Decimal('0.00'): + raise ValidationError('You cannot use the "free" payment provider for non-free orders.') + elif validated_data.get('status') == Order.STATUS_PAID: + order.payments.create( + amount=order.total, + provider=payment_provider, + info=payment_info, + payment_date=now(), + state=OrderPayment.PAYMENT_STATE_CONFIRMED + ) + elif payment_provider: + order.payments.create( + amount=order.total, + provider=payment_provider, + info=payment_info, + state=OrderPayment.PAYMENT_STATE_CREATED + ) + if ia: ia.order = order ia.save() @@ -522,3 +584,27 @@ class InvoiceSerializer(I18nAwareModelSerializer): 'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines', 'foreign_currency_display', 'foreign_currency_rate', 'foreign_currency_rate_date', 'internal_reference') + + +class OrderRefundCreateSerializer(I18nAwareModelSerializer): + payment = serializers.IntegerField(required=False, allow_null=True) + provider = serializers.CharField(required=True, allow_null=False, allow_blank=False) + info = CompatibleJSONField(required=False) + + class Meta: + model = OrderRefund + fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info') + + def create(self, validated_data): + pid = validated_data.pop('payment', None) + if pid: + try: + p = self.context['order'].payments.get(local_id=pid) + except OrderPayment.DoesNotExist: + raise ValidationError('Unknown payment ID.') + else: + p = None + + order = OrderRefund(order=self.context['order'], payment=p, **validated_data) + order.save() + return order diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index e7739790e2..cd084c461f 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -42,6 +42,10 @@ item_router = routers.DefaultRouter() item_router.register(r'variations', item.ItemVariationViewSet) item_router.register(r'addons', item.ItemAddOnViewSet) +order_router = routers.DefaultRouter() +order_router.register(r'payments', order.PaymentViewSet) +order_router.register(r'refunds', order.RefundViewSet) + # Force import of all plugins to give them a chance to register URLs with the router for app in apps.get_app_configs(): if hasattr(app, 'PretixPluginMeta'): @@ -57,6 +61,7 @@ urlpatterns = [ include(question_router.urls)), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/checkinlists/(?P[^/]+)/', include(checkinlist_router.urls)), + url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/orders/(?P[^/]+)/', include(order_router.urls)), url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"), url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"), url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"), diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 0f860c9143..90c40061ff 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -6,6 +6,7 @@ from django.db import transaction from django.db.models import Q from django.db.models.functions import Concat from django.http import FileResponse +from django.shortcuts import get_object_or_404 from django.utils.timezone import make_aware, now from django_filters.rest_framework import DjangoFilterBackend, FilterSet from rest_framework import serializers, status, viewsets @@ -19,12 +20,15 @@ from rest_framework.response import Response from pretix.api.models import OAuthAccessToken from pretix.api.serializers.order import ( - InvoiceSerializer, OrderCreateSerializer, OrderPositionSerializer, - OrderSerializer, + InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer, + OrderPositionSerializer, OrderRefundCreateSerializer, + OrderRefundSerializer, OrderSerializer, ) from pretix.base.models import ( - Invoice, Order, OrderPosition, Quota, TeamAPIToken, + Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota, + TeamAPIToken, ) +from pretix.base.payment import PaymentException from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, regenerate_invoice, @@ -32,7 +36,7 @@ from pretix.base.services.invoices import ( from pretix.base.services.mail import SendMailException from pretix.base.services.orders import ( OrderError, cancel_order, extend_order, mark_order_expired, - mark_order_paid, mark_order_refunded, + mark_order_refunded, ) from pretix.base.services.tickets import ( get_cachedticket_for_order, get_cachedticket_for_position, @@ -70,7 +74,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): def get_queryset(self): return self.request.event.orders.prefetch_related( 'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options', - 'positions__answers__question', 'fees' + 'positions__answers__question', 'fees', 'payments', 'refunds', 'refunds__payment' ).select_related( 'invoice_address' ) @@ -122,14 +126,33 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): order = self.get_object() if order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED): + + ps = order.pending_sum try: - mark_order_paid( - order, manual=True, - user=request.user if request.user.is_authenticated else None, - auth=request.auth, + p = order.payments.get( + state__in=(OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED), + provider='manual', + amount=ps ) + except OrderPayment.DoesNotExist: + order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING, + OrderPayment.PAYMENT_STATE_CREATED)) \ + .update(state=OrderPayment.PAYMENT_STATE_CANCELED) + p = order.payments.create( + state=OrderPayment.PAYMENT_STATE_CREATED, + provider='manual', + amount=ps, + fee=None + ) + + try: + p.confirm(auth=self.request.auth, + user=self.request.user if request.user.is_authenticated else None, + count_waitinglist=False) except Quota.QuotaExceededException as e: return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + except PaymentException as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) except SendMailException: pass @@ -170,7 +193,6 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): ) order.status = Order.STATUS_PENDING - order.payment_manual = True order.save() order.log_action( 'pretix.event.order.unpaid', @@ -366,6 +388,205 @@ class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet): return resp +class PaymentViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = OrderPaymentSerializer + queryset = OrderPayment.objects.none() + permission = 'can_view_orders' + write_permission = 'can_change_orders' + lookup_field = 'local_id' + + def get_queryset(self): + order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event) + return order.payments.all() + + @detail_route(methods=['POST']) + def confirm(self, request, **kwargs): + payment = self.get_object() + force = request.data.get('force', False) + + if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED): + return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST) + + try: + payment.confirm(user=self.request.user if self.request.user.is_authenticated else None, + auth=self.request.auth, + count_waitinglist=False, + force=force) + except Quota.QuotaExceededException as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + except PaymentException as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + except SendMailException: + pass + return self.retrieve(request, [], **kwargs) + + @detail_route(methods=['POST']) + def refund(self, request, **kwargs): + payment = self.get_object() + amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value( + request.data.get('amount', str(payment.amount)) + ) + mark_refunded = request.data.get('mark_refunded', False) + + if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED: + return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST) + + full_refund_possible = payment.payment_provider.payment_refund_supported(payment) + partial_refund_possible = payment.payment_provider.payment_partial_refund_supported(payment) + available_amount = payment.amount - payment.refunded_amount + + if amount <= 0: + return Response({'amount': ['Invalid refund amount.']}, status=status.HTTP_400_BAD_REQUEST) + if amount > available_amount: + return Response( + {'amount': ['Invalid refund amount, only {} are available to refund.'.format(available_amount)]}, + status=status.HTTP_400_BAD_REQUEST) + if amount != payment.amount and not partial_refund_possible: + return Response({'amount': ['Partial refund not available for this payment method.']}, + status=status.HTTP_400_BAD_REQUEST) + if amount == payment.amount and not full_refund_possible: + return Response({'amount': ['Full refund not available for this payment method.']}, + status=status.HTTP_400_BAD_REQUEST) + r = payment.order.refunds.create( + payment=payment, + source=OrderRefund.REFUND_SOURCE_ADMIN, + state=OrderRefund.REFUND_STATE_CREATED, + amount=amount, + provider=payment.provider + ) + + try: + r.payment_provider.execute_refund(r) + except PaymentException as e: + r.state = OrderRefund.REFUND_STATE_FAILED + r.save() + return Response({'detail': 'External error: {}'.format(str(e))}, + status=status.HTTP_400_BAD_REQUEST) + else: + payment.order.log_action('pretix.event.order.refund.created', { + 'local_id': r.local_id, + 'provider': r.provider, + }, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) + if payment.order.pending_sum > 0: + if mark_refunded: + mark_order_refunded(payment.order, + user=self.request.user if self.request.user.is_authenticated else None, + auth=self.request.auth) + else: + payment.order.status = Order.STATUS_PENDING + payment.order.set_expires( + now(), + payment.order.event.subevents.filter( + id__in=payment.order.positions.values_list('subevent_id', flat=True)) + ) + payment.order.save() + return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK) + + @detail_route(methods=['POST']) + def cancel(self, request, **kwargs): + payment = self.get_object() + + if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED): + return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST) + + with transaction.atomic(): + payment.state = OrderPayment.PAYMENT_STATE_CANCELED + payment.save() + payment.order.log_action('pretix.event.order.payment.canceled', { + 'local_id': payment.local_id, + 'provider': payment.provider, + }, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) + return self.retrieve(request, [], **kwargs) + + +class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): + serializer_class = OrderRefundSerializer + queryset = OrderRefund.objects.none() + permission = 'can_view_orders' + write_permission = 'can_change_orders' + lookup_field = 'local_id' + + def get_queryset(self): + order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event) + return order.refunds.all() + + @detail_route(methods=['POST']) + def cancel(self, request, **kwargs): + refund = self.get_object() + + if refund.state not in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT, + OrderRefund.REFUND_STATE_EXTERNAL): + return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST) + + with transaction.atomic(): + refund.state = OrderRefund.REFUND_STATE_CANCELED + refund.save() + refund.order.log_action('pretix.event.order.refund.canceled', { + 'local_id': refund.local_id, + 'provider': refund.provider, + }, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) + return self.retrieve(request, [], **kwargs) + + @detail_route(methods=['POST']) + def process(self, request, **kwargs): + refund = self.get_object() + + if refund.state != OrderRefund.REFUND_STATE_EXTERNAL: + return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST) + + refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) + if request.data.get('mark_refunded', False): + mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None, + auth=self.request.auth) + else: + refund.order.status = Order.STATUS_PENDING + refund.order.set_expires( + now(), + refund.order.event.subevents.filter( + id__in=refund.order.positions.values_list('subevent_id', flat=True)) + ) + refund.order.save() + return self.retrieve(request, [], **kwargs) + + @detail_route(methods=['POST']) + def done(self, request, **kwargs): + refund = self.get_object() + + if refund.state not in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT): + return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST) + + refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) + return self.retrieve(request, [], **kwargs) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['order'] = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event) + return ctx + + def create(self, request, *args, **kwargs): + serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context()) + serializer.is_valid(raise_exception=True) + with transaction.atomic(): + self.perform_create(serializer) + r = serializer.instance + serializer = OrderRefundSerializer(r, context=serializer.context) + + r.order.log_action( + 'pretix.event.order.refund.created', { + 'local_id': r.local_id, + 'provider': r.provider, + }, + user=request.user if request.user.is_authenticated else None, + auth=request.auth + ) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def perform_create(self, serializer): + serializer.save() + + class InvoiceFilter(FilterSet): refers = django_filters.CharFilter(method='refers_qs') number = django_filters.CharFilter(method='nr_qs') diff --git a/src/pretix/base/exporters/invoices.py b/src/pretix/base/exporters/invoices.py index 6af2c22f1b..dac9fe7e38 100644 --- a/src/pretix/base/exporters/invoices.py +++ b/src/pretix/base/exporters/invoices.py @@ -5,9 +5,12 @@ from zipfile import ZipFile import dateutil.parser from django import forms +from django.db.models import Exists, OuterRef, Q from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ +from pretix.base.models import OrderPayment + from ..exporter import BaseExporter from ..services.invoices import invoice_pdf_task from ..signals import register_data_exporters @@ -21,7 +24,14 @@ class InvoiceExporter(BaseExporter): qs = self.event.invoices.filter(shredded=False) if form_data.get('payment_provider'): - qs = qs.filter(order__payment_provider=form_data.get('payment_provider')) + qs = qs.annotate( + has_payment_with_provider=Exists( + OrderPayment.objects.filter( + Q(order=OuterRef('pk')) & Q(provider=form_data.get('payment_provider')) + ) + ) + ) + qs = qs.filter(has_payment_with_provider=1) if form_data.get('date_from'): date_value = form_data.get('date_from') @@ -84,10 +94,10 @@ class InvoiceExporter(BaseExporter): (k, v.verbose_name) for k, v in self.event.get_payment_providers().items() ], required=False, - help_text=_('Only include invoices for orders that are currently set to this payment provider. ' - 'Note that this might include some invoices of other payment providers or misses ' - 'some invoices if the payment provider of an order has been changed and a new invoice ' - 'has been generated.') + help_text=_('Only include invoices for orders that have at least one payment attempt ' + 'with this payment provider. ' + 'Note that this might include some invoices of orders which in the end have been ' + 'fully or partially paid with a different provider.') )), ] ) diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index 5e35d66edf..82ef52581b 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -5,13 +5,13 @@ from decimal import Decimal import pytz from defusedcsv import csv from django import forms -from django.db.models import Sum +from django.db.models import DateTimeField, Max, OuterRef, Subquery, Sum from django.dispatch import receiver from django.utils.formats import localize from django.utils.translation import ugettext as _, ugettext_lazy from pretix.base.models import InvoiceAddress, Order, OrderPosition -from pretix.base.models.orders import OrderFee +from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund from ..exporter import BaseExporter from ..signals import register_data_exporters @@ -55,7 +55,19 @@ class OrderListExporter(BaseExporter): tz = pytz.timezone(self.event.settings.timezone) writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",") - qs = self.event.orders.all().select_related('invoice_address').prefetch_related('invoices') + p_date = OrderPayment.objects.filter( + order=OuterRef('pk'), + state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), + payment_date__isnull=False + ).order_by().values('order').annotate( + m=Max('payment_date') + ).values( + 'm' + ) + + qs = self.event.orders.annotate( + payment_date=Subquery(p_date, output_field=DateTimeField()) + ).select_related('invoice_address').prefetch_related('invoices') if form_data['paid_only']: qs = qs.filter(status=Order.STATUS_PAID) tax_rates = self._get_all_tax_rates(qs) @@ -63,7 +75,7 @@ class OrderListExporter(BaseExporter): headers = [ _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'), _('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'), - _('Payment date'), _('Payment type'), _('Fees'), _('Order locale') + _('Date of last payment'), _('Fees'), _('Order locale') ] for tr in tax_rates: @@ -77,11 +89,6 @@ class OrderListExporter(BaseExporter): writer.writerow(headers) - provider_names = { - k: v.verbose_name - for k, v in self.event.get_payment_providers().items() - } - full_fee_sum_cache = { o['order__id']: o['grosssum'] for o in OrderFee.objects.values('tax_rate', 'order__id').order_by().annotate(grosssum=Sum('value')) @@ -114,7 +121,8 @@ class OrderListExporter(BaseExporter): order.invoice_address.street, order.invoice_address.zipcode, order.invoice_address.city, - order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old, + order.invoice_address.country if order.invoice_address.country else + order.invoice_address.country_old, order.invoice_address.vat_id, ] except InvoiceAddress.DoesNotExist: @@ -122,14 +130,14 @@ class OrderListExporter(BaseExporter): row += [ order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '', - provider_names.get(order.payment_provider, order.payment_provider), localize(full_fee_sum_cache.get(order.id) or Decimal('0.00')), order.locale, ] for tr in tax_rates: taxrate_values = sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')}) - fee_taxrate_values = fee_sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')}) + fee_taxrate_values = fee_sum_cache.get((order.id, tr), + {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')}) row += [ localize(taxrate_values['grosssum'] + fee_taxrate_values['grosssum']), @@ -144,6 +152,77 @@ class OrderListExporter(BaseExporter): return '{}_orders.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8") +class PaymentListExporter(BaseExporter): + identifier = 'paymentlistcsv' + verbose_name = ugettext_lazy('List of payments and refunds (CSV)') + + @property + def export_form_fields(self): + return OrderedDict( + [ + ('successful_only', + forms.BooleanField( + label=_('Only successful payments'), + initial=True, + required=False + )), + ] + ) + + def render(self, form_data: dict): + output = io.StringIO() + tz = pytz.timezone(self.event.settings.timezone) + writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",") + + provider_names = { + k: v.verbose_name + for k, v in self.event.get_payment_providers().items() + } + + payments = OrderPayment.objects.filter( + order__event=self.event, + ).order_by('created') + refunds = OrderRefund.objects.filter( + order__event=self.event + ).order_by('created') + + if form_data['successful_only']: + payments = payments.filter( + state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), + ) + refunds = refunds.filter( + state=OrderRefund.REFUND_STATE_DONE, + ) + + objs = sorted(list(payments) + list(refunds), key=lambda o: o.created) + + headers = [ + _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'), + _('Amount'), _('Payment method') + ] + writer.writerow(headers) + + for obj in objs: + if isinstance(obj, OrderPayment) and obj.payment_date: + d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d') + elif isinstance(obj, OrderRefund) and obj.execution_date: + d2 = obj.execution_date.astimezone(tz).date().strftime('%Y-%m-%d') + else: + d2 = '' + row = [ + obj.order.code, + obj.full_id, + obj.created.astimezone(tz).date().strftime('%Y-%m-%d'), + d2, + obj.get_state_display(), + localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)), + provider_names.get(obj.provider, obj.provider) + ] + writer.writerow(row) + + return '{}_payments.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8") + + class QuotaListExporter(BaseExporter): identifier = 'quotalistcsv' verbose_name = ugettext_lazy('Quota availabilities (CSV)') @@ -180,6 +259,11 @@ def register_orderlist_exporter(sender, **kwargs): return OrderListExporter +@receiver(register_data_exporters, dispatch_uid="exporter_paymentlist") +def register_paymentlist_exporter(sender, **kwargs): + return PaymentListExporter + + @receiver(register_data_exporters, dispatch_uid="exporter_quotalist") def register_quotalist_exporter(sender, **kwargs): return QuotaListExporter diff --git a/src/pretix/base/migrations/0096_auto_20180722_0801.py b/src/pretix/base/migrations/0096_auto_20180722_0801.py new file mode 100644 index 0000000000..f197472e11 --- /dev/null +++ b/src/pretix/base/migrations/0096_auto_20180722_0801.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-07-22 08:01 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0095_auto_20180604_1129'), + ] + + operations = [ + migrations.CreateModel( + name='OrderPayment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('local_id', models.PositiveIntegerField()), + ('state', models.CharField(choices=[('created', 'created'), ('pending', 'pending'), ('confirmed', 'confirmed'), ('canceled', 'canceled'), ('failed', 'failed'), ('refunded', 'refunded')], max_length=190)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Amount')), + ('created', models.DateTimeField(auto_now_add=True)), + ('payment_date', models.DateTimeField(blank=True, null=True)), + ('provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')), + ('info', models.TextField(blank=True, null=True, verbose_name='Payment information')), + ('migrated', models.BooleanField(default=False)), + ], + options={ + 'ordering': ('local_id',), + }, + ), + migrations.CreateModel( + name='OrderRefund', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('local_id', models.PositiveIntegerField()), + ('state', models.CharField(choices=[('external', 'started externally'), ('created', 'created'), ('transit', 'in transit'), ('done', 'done'), ('failed', 'failed'), ('canceled', 'canceled')], max_length=190)), + ('source', models.CharField(choices=[('admin', 'Organizer'), ('buyer', 'Customer'), ('external', 'External')], max_length=190)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Amount')), + ('created', models.DateTimeField(auto_now_add=True)), + ('execution_date', models.DateTimeField(blank=True, null=True)), + ('provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')), + ('info', models.TextField(blank=True, null=True, verbose_name='Payment information')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='pretixbase.Order', verbose_name='Order')), + ('payment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='pretixbase.OrderPayment')), + ], + options={ + 'ordering': ('local_id',), + }, + ), + migrations.AlterModelOptions( + name='quota', + options={'ordering': ('name',), 'verbose_name': 'Quota', 'verbose_name_plural': 'Quotas'}, + ), + migrations.AlterField( + model_name='orderfee', + name='fee_type', + field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('other', 'Other fees'), ('giftcard', 'Gift card')], max_length=100), + ), + migrations.AlterField( + model_name='team', + name='can_change_organizer_settings', + field=models.BooleanField(default=False, help_text='Someone with this setting can get access to most data of all of your events, i.e. via privacy reports, so be careful who you add to this team!', verbose_name='Can change organizer settings'), + ), + migrations.AlterField( + model_name='user', + name='require_2fa', + field=models.BooleanField(default=False, verbose_name='Two-factor authentication is required to log in'), + ), + migrations.AddField( + model_name='orderpayment', + name='fee', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='pretixbase.OrderFee'), + ), + migrations.AddField( + model_name='orderpayment', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='pretixbase.Order', verbose_name='Order'), + ), + ] diff --git a/src/pretix/base/migrations/0097_auto_20180722_0804.py b/src/pretix/base/migrations/0097_auto_20180722_0804.py new file mode 100644 index 0000000000..a4faa8800d --- /dev/null +++ b/src/pretix/base/migrations/0097_auto_20180722_0804.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-07-22 08:04 +from __future__ import unicode_literals + +from django.db import migrations + + +def create_payments(apps, schema_editor): + Order = apps.get_model('pretixbase', 'Order') # noqa + OrderPayment = apps.get_model('pretixbase', 'OrderPayment') # noqa + OrderRefund = apps.get_model('pretixbase', 'OrderRefund') # noqa + payments = [] + refunds = [] + for o in Order.objects.filter(payments__isnull=True).iterator(): + if o.status == 'n' or o.status == 'e': + payments.append(OrderPayment( + local_id=1, + state='created', + amount=o.total, + order=o, + provider=o.payment_provider, + info=o.payment_info, + migrated=True, + fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(), + )) + pass + elif o.status == 'p': + payments.append(OrderPayment( + local_id=1, + state='confirmed', + amount=o.total, + order=o, + provider=o.payment_provider, + payment_date=o.payment_date, + info=o.payment_info, + migrated=True, + fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(), + )) + elif o.status == 'r': + p = OrderPayment.objects.create( + local_id=1, + state='refunded', + amount=o.total, + order=o, + provider=o.payment_provider, + payment_date=o.payment_date, + info=o.payment_info, + migrated=True, + fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(), + ) + refunds.append(OrderRefund( + local_id=1, + state='done', + amount=o.total, + order=o, + provider=o.payment_provider, + info=o.payment_info, + source='admin', + payment=p + )) + elif o.status == 'c': + payments.append(OrderPayment( + local_id=1, + state='canceled', + amount=o.total, + order=o, + provider=o.payment_provider, + payment_date=o.payment_date, + info=o.payment_info, + migrated=True, + fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(), + )) + + if len(payments) > 500: + OrderPayment.objects.bulk_create(payments) + payments.clear() + if len(refunds) > 500: + OrderRefund.objects.bulk_create(refunds) + refunds.clear() + if len(payments) > 0: + OrderPayment.objects.bulk_create(payments) + if len(refunds) > 0: + OrderRefund.objects.bulk_create(refunds) + + +def notifications(apps, schema_editor): + NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting') + for n in NotificationSetting.objects.filter(action_type='pretix.event.action_required'): + n.pk = None + n.action_type = 'pretix.event.order.refund.created.externally' + n.save() + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0096_auto_20180722_0801'), + ] + + operations = [ + migrations.RunPython(create_payments, migrations.RunPython.noop), + migrations.RunPython(notifications, migrations.RunPython.noop), + migrations.RemoveField( + model_name='order', + name='payment_date', + ), + migrations.RemoveField( + model_name='order', + name='payment_info', + ), + migrations.RemoveField( + model_name='order', + name='payment_manual', + ), + migrations.RemoveField( + model_name='order', + name='payment_provider', + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index d95b441a4e..d483000373 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -15,9 +15,9 @@ from .log import LogEntry from .notifications import NotificationSetting from .orders import ( AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition, - InvoiceAddress, Order, OrderPosition, QuestionAnswer, - cachedcombinedticket_name, cachedticket_name, generate_position_secret, - generate_secret, + InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, + QuestionAnswer, cachedcombinedticket_name, cachedticket_name, + generate_position_secret, generate_secret, ) from .organizer import ( Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite, diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 181c9722f9..0f058fb45d 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -561,7 +561,7 @@ class Event(EventMixin, LoggedModel): def has_payment_provider(self): result = False for provider in self.get_payment_providers().values(): - if provider.is_enabled and provider.identifier not in ('free', 'boxoffice'): + if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting'): result = True break return result diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index dd5562bcb0..fb5de7d2fd 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -52,7 +52,7 @@ class LogEntry(models.Model): all = models.Manager() class Meta: - ordering = ('-datetime',) + ordering = ('-datetime', '-id') def display(self): from ..signals import logentry_display diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index a5b609c446..4b0fd8923f 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1,5 +1,6 @@ import copy import json +import logging import os import string from datetime import datetime, time, timedelta @@ -9,8 +10,11 @@ from typing import Any, Dict, List, Union import dateutil import pytz from django.conf import settings -from django.db import models -from django.db.models import F, Sum +from django.db import models, transaction +from django.db.models import ( + Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When, +) +from django.db.models.functions import Coalesce from django.db.models.signals import post_delete from django.dispatch import receiver from django.urls import reverse @@ -31,6 +35,8 @@ from .base import LoggedModel from .event import Event, SubEvent from .items import Item, ItemVariation, Question, QuestionOption, Quota +logger = logging.getLogger(__name__) + def generate_secret(): return get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits) @@ -76,12 +82,6 @@ class Order(LoggedModel): :type datetime: datetime :param expires: The date until this order has to be paid to guarantee the fulfillment :type expires: datetime - :param payment_date: The date of the payment completion (null if not yet paid) - :type payment_date: datetime - :param payment_provider: The payment provider selected by the user - :type payment_provider: str - :param payment_info: Arbitrary information stored by the payment provider - :type payment_info: str :param total: The total amount of the order, including the payment fee :type total: decimal.Decimal :param comment: An internal comment that will only be visible to staff, and never displayed to the user @@ -136,23 +136,6 @@ class Order(LoggedModel): expires = models.DateTimeField( verbose_name=_("Expiration date") ) - payment_date = models.DateTimeField( - verbose_name=_("Payment date"), - null=True, blank=True - ) - payment_provider = models.CharField( - null=True, blank=True, - max_length=255, - verbose_name=_("Payment provider") - ) - payment_info = models.TextField( - verbose_name=_("Payment information"), - null=True, blank=True - ) - payment_manual = models.BooleanField( - verbose_name=_("Payment state was manually modified"), - default=False - ) total = models.DecimalField( decimal_places=2, max_digits=10, verbose_name=_("Total amount") @@ -199,6 +182,68 @@ class Order(LoggedModel): except TypeError: return None + @property + def pending_sum(self): + total = self.total + if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED): + total = 0 + payment_sum = self.payments.filter( + state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED) + ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') + refund_sum = self.refunds.filter( + state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, + OrderRefund.REFUND_STATE_CREATED) + ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') + return total - payment_sum + refund_sum + + @classmethod + def annotate_overpayments(cls, qs): + payment_sum = OrderPayment.objects.filter( + state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), + order=OuterRef('pk') + ).order_by().values('order').annotate(s=Sum('amount')).values('s') + refund_sum = OrderRefund.objects.filter( + state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, + OrderRefund.REFUND_STATE_CREATED), + order=OuterRef('pk') + ).order_by().values('order').annotate(s=Sum('amount')).values('s') + external_refund = OrderRefund.objects.filter( + state=OrderRefund.REFUND_STATE_EXTERNAL, + order=OuterRef('pk') + ) + pending_refund = OrderRefund.objects.filter( + state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT), + order=OuterRef('pk') + ) + + qs = qs.annotate( + payment_sum=Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)), + refund_sum=Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)), + has_external_refund=Exists(external_refund), + has_pending_refund=Exists(pending_refund), + ).annotate( + pending_sum_t=F('total') - Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0), + pending_sum_rc=-1 * F('payment_sum') + Coalesce(F('refund_sum'), 0), + ).annotate( + is_overpaid=Case( + When(~Q(status__in=[Order.STATUS_REFUNDED, Order.STATUS_CANCELED]) & Q(pending_sum_t__lt=0), + then=Value('1')), + When(Q(status__in=[Order.STATUS_REFUNDED, Order.STATUS_CANCELED]) & Q(pending_sum_rc__lt=0), + then=Value('1')), + When(Q(status__in=[Order.STATUS_EXPIRED, Order.STATUS_PENDING]) & Q(pending_sum_t__lte=0), + then=Value('1')), + default=Value('0'), + output_field=models.IntegerField() + ), + is_underpaid=Case( + When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=0), + then=Value('1')), + default=Value('0'), + output_field=models.IntegerField() + ) + ) + return qs + @property def full_code(self): """ @@ -711,10 +756,441 @@ class AbstractPosition(models.Model): else self.variation.quotas.filter(subevent=self.subevent)) +class OrderPayment(models.Model): + """ + Represents a payment or payment attempt for an order. + + + :param id: A globally unique ID for this payment + :type id: + :param local_id: An ID of this payment, counting from one for every order independently. + :type local_id: int + :param state: The state of the payment, one of ``created``, ``pending``, ``confirmed``, ``failed``, + ``canceled``, or ``refunded``. + :type state: str + :param amount: The payment amount + :type amount: Decimal + :param order: The order that is paid + :type order: Order + :param created: The creation time of this record + :type created: datetime + :param payment_date: The completion time of this payment + :type payment_date: datetime + :param provider: The payment provider in use + :type provider: str + :param info: Provider-specific meta information (in JSON format) + :type info: str + :param fee: The ``OrderFee`` object used to track the fee for this order. + :type fee: pretix.base.models.OrderFee + """ + PAYMENT_STATE_CREATED = 'created' + PAYMENT_STATE_PENDING = 'pending' + PAYMENT_STATE_CONFIRMED = 'confirmed' + PAYMENT_STATE_FAILED = 'failed' + PAYMENT_STATE_CANCELED = 'canceled' + PAYMENT_STATE_REFUNDED = 'refunded' + + PAYMENT_STATES = ( + (PAYMENT_STATE_CREATED, pgettext_lazy('payment_state', 'created')), + (PAYMENT_STATE_PENDING, pgettext_lazy('payment_state', 'pending')), + (PAYMENT_STATE_CONFIRMED, pgettext_lazy('payment_state', 'confirmed')), + (PAYMENT_STATE_CANCELED, pgettext_lazy('payment_state', 'canceled')), + (PAYMENT_STATE_FAILED, pgettext_lazy('payment_state', 'failed')), + (PAYMENT_STATE_REFUNDED, pgettext_lazy('payment_state', 'refunded')), + ) + local_id = models.PositiveIntegerField() + state = models.CharField( + max_length=190, choices=PAYMENT_STATES + ) + amount = models.DecimalField( + decimal_places=2, max_digits=10, + verbose_name=_("Amount") + ) + order = models.ForeignKey( + Order, + verbose_name=_("Order"), + related_name='payments', + on_delete=models.PROTECT + ) + created = models.DateTimeField( + auto_now_add=True + ) + payment_date = models.DateTimeField( + null=True, blank=True + ) + provider = models.CharField( + null=True, blank=True, + max_length=255, + verbose_name=_("Payment provider") + ) + info = models.TextField( + verbose_name=_("Payment information"), + null=True, blank=True + ) + fee = models.ForeignKey( + 'OrderFee', + null=True, blank=True, related_name='payments' + ) + migrated = models.BooleanField(default=False) + + class Meta: + ordering = ('local_id',) + + @property + def info_data(self): + """ + This property allows convenient access to the data stored in the ``info`` + attribute by automatically encoding and decoding the content as JSON. + """ + return json.loads(self.info) if self.info else {} + + @info_data.setter + def info_data(self, d): + self.info = json.dumps(d) + + @cached_property + def payment_provider(self): + """ + Cached access to an instance of the payment provider in use. + """ + return self.order.event.get_payment_providers().get(self.provider) + + def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text=''): + """ + Marks the payment as complete. If possible, this also marks the order as paid if no further + payment is required + + :param count_waitinglist: Whether, when calculating quota, people on the waiting list should be taken into + consideration (default: ``True``). + :type count_waitinglist: boolean + :param force: Whether this payment should be marked as paid even if no remaining + quota is available (default: ``False``). + :type force: boolean + :param send_mail: Whether an email should be sent to the user about this event (default: ``True``). + :type send_mail: boolean + :param user: The user who performed the change + :param auth: The API auth token that performed the change + :param mail_text: Additional text to be included in the email + :type mail_text: str + :raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False`` + """ + from pretix.base.signals import order_paid + from pretix.base.services.invoices import generate_invoice, invoice_qualified + from pretix.base.services.mail import SendMailException + from pretix.multidomain.urlreverse import build_absolute_uri + + self.state = self.PAYMENT_STATE_CONFIRMED + self.payment_date = now() + self.save() + + self.order.log_action('pretix.event.order.payment.confirmed', { + 'local_id': self.local_id, + 'provider': self.provider, + }, user=user, auth=auth) + + if self.order.status == Order.STATUS_PAID: + return + + payment_sum = self.order.payments.filter( + state__in=(self.PAYMENT_STATE_CONFIRMED, self.PAYMENT_STATE_REFUNDED) + ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') + refund_sum = self.order.refunds.filter( + state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, + OrderRefund.REFUND_STATE_CREATED) + ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') + if payment_sum - refund_sum < self.order.total: + return + + with self.order.event.lock(): + can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist) + if not force and can_be_paid is not True: + raise Quota.QuotaExceededException(can_be_paid) + self.order.status = Order.STATUS_PAID + self.order.save() + + self.order.log_action('pretix.event.order.paid', { + 'provider': self.provider, + 'info': self.info, + 'date': self.payment_date, + 'force': force + }, user=user, auth=auth) + order_paid.send(self.order.event, order=self.order) + + invoice = None + if invoice_qualified(self.order): + invoices = self.order.invoices.filter(is_cancellation=False).count() + cancellations = self.order.invoices.filter(is_cancellation=True).count() + gen_invoice = ( + (invoices == 0 and self.order.event.settings.get('invoice_generate') in ('True', 'paid')) or + 0 < invoices <= cancellations + ) + if gen_invoice: + invoice = generate_invoice( + self.order, + trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment + ) + + if send_mail: + with language(self.order.locale): + try: + invoice_name = self.order.invoice_address.name + invoice_company = self.order.invoice_address.company + except InvoiceAddress.DoesNotExist: + invoice_name = "" + invoice_company = "" + email_template = self.order.event.settings.mail_text_order_paid + email_context = { + 'event': self.order.event.name, + 'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={ + 'order': self.order.code, + 'secret': self.order.secret + }), + 'downloads': self.order.event.settings.get('ticket_download', as_type=bool), + 'invoice_name': invoice_name, + 'invoice_company': invoice_company, + 'payment_info': mail_text + } + email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code} + try: + self.order.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.order_paid', user, + invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [] + ) + except SendMailException: + logger.exception('Order paid email could not be sent') + + @property + def refunded_amount(self): + """ + The sum of all refund amounts in ``done``, ``transit``, or ``created`` states associated + with this payment. + """ + return self.refunds.filter( + state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, + OrderRefund.REFUND_STATE_CREATED) + ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') + + @property + def full_id(self): + """ + The full human-readable ID of this payment, constructed by the order code and the ``local_id`` + field with ``-P-`` in between. + :return: + """ + return '{}-P-{}'.format(self.order.code, self.local_id) + + def save(self, *args, **kwargs): + if not self.local_id: + self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1 + super().save(*args, **kwargs) + + def create_external_refund(self, amount=None, execution_date=None, info='{}'): + """ + This should be called to create an OrderRefund object when a refund has triggered + by an external source, e.g. when a credit card payment has been refunded by the + credit card provider. + + :param amount: Amount to refund. If not given, the full payment amount will be used. + :type amount: Decimal + :param execution_date: Date of the refund. Defaults to the current time. + :type execution_date: datetime + :param info: Additional information, defaults to ``"{}"``. + :type info: str + :return: OrderRefund + """ + r = self.order.refunds.create( + state=OrderRefund.REFUND_STATE_EXTERNAL, + source=OrderRefund.REFUND_SOURCE_EXTERNAL, + amount=amount if amount is not None else self.amount, + order=self.order, + payment=self, + execution_date=execution_date or now(), + provider=self.provider, + info=info + ) + self.order.log_action('pretix.event.order.refund.created.externally', { + 'local_id': r.local_id, + 'provider': r.provider, + }) + return r + + +class OrderRefund(models.Model): + """ + Represents a refund or refund attempt for an order. + + :param id: A globally unique ID for this refund + :type id: + :param local_id: An ID of this refund, counting from one for every order independently. + :type local_id: int + :param state: The state of the refund, one of ``created``, ``transit``, ``external``, ``canceled``, + ``failed``, or ``done``. + :type state: str + :param source: How this refund was started, one of ``buyer``, ``admin``, or ``external``. + :param amount: The refund amount + :type amount: Decimal + :param order: The order that is refunded + :type order: Order + :param created: The creation time of this record + :type created: datetime + :param execution_date: The completion time of this refund + :type execution_date: datetime + :param provider: The payment provider in use + :type provider: str + :param info: Provider-specific meta information in JSON format + :type info: dict + """ + # REFUND_STATE_REQUESTED = 'requested' + # REFUND_STATE_APPROVED = 'approved' + REFUND_STATE_EXTERNAL = 'external' + REFUND_STATE_TRANSIT = 'transit' + REFUND_STATE_DONE = 'done' + # REFUND_STATE_REJECTED = 'rejected' + REFUND_STATE_CANCELED = 'canceled' + REFUND_STATE_CREATED = 'created' + REFUND_STATE_FAILED = 'failed' + + REFUND_STATES = ( + # (REFUND_STATE_REQUESTED, pgettext_lazy('refund_state', 'requested')), + # (REFUND_STATE_APPROVED, pgettext_lazy('refund_state', 'approved')), + (REFUND_STATE_EXTERNAL, pgettext_lazy('refund_state', 'started externally')), + (REFUND_STATE_CREATED, pgettext_lazy('refund_state', 'created')), + (REFUND_STATE_TRANSIT, pgettext_lazy('refund_state', 'in transit')), + (REFUND_STATE_DONE, pgettext_lazy('refund_state', 'done')), + (REFUND_STATE_FAILED, pgettext_lazy('refund_state', 'failed')), + # (REFUND_STATE_REJECTED, pgettext_lazy('refund_state', 'rejected')), + (REFUND_STATE_CANCELED, pgettext_lazy('refund_state', 'canceled')), + ) + + REFUND_SOURCE_BUYER = 'buyer' + REFUND_SOURCE_ADMIN = 'admin' + REFUND_SOURCE_EXTERNAL = 'external' + + REFUND_SOURCES = ( + (REFUND_SOURCE_ADMIN, pgettext_lazy('refund_source', 'Organizer')), + (REFUND_SOURCE_BUYER, pgettext_lazy('refund_source', 'Customer')), + (REFUND_SOURCE_EXTERNAL, pgettext_lazy('refund_source', 'External')), + ) + + local_id = models.PositiveIntegerField() + state = models.CharField( + max_length=190, choices=REFUND_STATES + ) + source = models.CharField( + max_length=190, choices=REFUND_SOURCES + ) + amount = models.DecimalField( + decimal_places=2, max_digits=10, + verbose_name=_("Amount") + ) + order = models.ForeignKey( + Order, + verbose_name=_("Order"), + related_name='refunds', + on_delete=models.PROTECT + ) + payment = models.ForeignKey( + OrderPayment, + null=True, blank=True, + related_name='refunds', + on_delete=models.PROTECT + ) + created = models.DateTimeField( + auto_now_add=True + ) + execution_date = models.DateTimeField( + null=True, blank=True + ) + provider = models.CharField( + null=True, blank=True, + max_length=255, + verbose_name=_("Payment provider") + ) + info = models.TextField( + verbose_name=_("Payment information"), + null=True, blank=True + ) + + class Meta: + ordering = ('local_id',) + + @property + def info_data(self): + """ + This property allows convenient access to the data stored in the ``info`` + attribute by automatically encoding and decoding the content as JSON. + """ + return json.loads(self.info) if self.info else {} + + @info_data.setter + def info_data(self, d): + self.info = json.dumps(d) + + @cached_property + def payment_provider(self): + """ + Cached access to an instance of the payment provider in use. + """ + return self.order.event.get_payment_providers().get(self.provider) + + @transaction.atomic + def done(self, user=None, auth=None): + """ + Marks the refund as complete. This does not modify the state of the order. + + :param user: The user who performed the change + :param user: The API auth token that performed the change + """ + self.state = self.REFUND_STATE_DONE + self.execution_date = self.execution_date or now() + self.save() + + self.order.log_action('pretix.event.order.refund.done', { + 'local_id': self.local_id, + 'provider': self.provider, + }, user=user, auth=auth) + + if self.payment and self.payment.refunded_amount >= self.payment.amount: + self.payment.state = OrderPayment.PAYMENT_STATE_REFUNDED + self.payment.save(update_fields=['state']) + + @property + def full_id(self): + """ + The full human-readable ID of this refund, constructed by the order code and the ``local_id`` + field with ``-R-`` in between. + :return: + """ + return '{}-R-{}'.format(self.order.code, self.local_id) + + def save(self, *args, **kwargs): + if not self.local_id: + self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1 + super().save(*args, **kwargs) + + class OrderFee(models.Model): """ - An OrderFee objet represents a fee that is added to the order total independently of + An OrderFee object represents a fee that is added to the order total independently of the actual positions. This might for example be a payment or a shipping fee. + + :param value: Gross price of this fee + :type value: Decimal + :param order: Order this fee is charged with + :type order: Order + :param fee_type: The type of the fee, currently ``payment``, ``shipping``, ``service``, ``giftcard``, or ``other``. + :type fee_type: str + :param description: A human-readable description of the fee + :type description: str + :param internal_type: An internal string to group fees by, e.g. the identifier string of a payment provider + :type internal_type: str + :param tax_rate: The tax rate applied to this fee + :type tax_rate: Decimal + :param tax_rule: The tax rule applied to this fee + :type tax_rule: TaxRule + :param tax_value: The tax amount included in the price + :type tax_value: Decimal """ FEE_TYPE_PAYMENT = "payment" FEE_TYPE_SHIPPING = "shipping" @@ -813,6 +1289,18 @@ class OrderPosition(AbstractPosition): :param order: The order this position is a part of :type order: Order + :param positionid: A local ID of this position, counted for each order individually + :type positionid: int + :param tax_rate: The tax rate applied to this position + :type tax_rate: Decimal + :param tax_rule: The tax rule applied to this position + :type tax_rule: TaxRule + :param tax_value: The tax amount included in the price + :type tax_value: Decimal + :param secret: The secret used for ticket QR codes + :type secret: str + :param pseudonymization_id: The QR code content for lead scanning + :type pseudonymization_id: str """ positionid = models.PositiveIntegerField(default=1) order = models.ForeignKey( diff --git a/src/pretix/base/notifications.py b/src/pretix/base/notifications.py index 4eb8360efd..7494636cf8 100644 --- a/src/pretix/base/notifications.py +++ b/src/pretix/base/notifications.py @@ -229,6 +229,12 @@ def register_default_notification_types(sender, **kwargs): _('Order changed'), _('Order {order.code} has been changed.') ), + ParametrizedOrderNotificationType( + sender, + 'pretix.event.order.refund.created.externally', + _('External refund of payment'), + _('An external refund for {order.code} has occurred.') + ), ParametrizedOrderNotificationType( sender, 'pretix.event.order.refunded', diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index e1ce381baf..03abf8e0d6 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1,3 +1,4 @@ +import json import logging from collections import OrderedDict from decimal import ROUND_HALF_UP, Decimal @@ -6,7 +7,6 @@ from typing import Any, Dict, Union import pytz from django import forms from django.conf import settings -from django.contrib import messages from django.core.exceptions import ImproperlyConfigured from django.dispatch import receiver from django.forms import Form @@ -14,13 +14,18 @@ from django.http import HttpRequest from django.template.loader import get_template from django.utils.timezone import now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ -from i18nfield.forms import I18nFormField, I18nTextarea +from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput from i18nfield.strings import LazyI18nString -from pretix.base.models import CartPosition, Event, Order, Quota +from pretix.base.forms import PlaceholderValidator +from pretix.base.models import ( + CartPosition, Event, Order, OrderPayment, OrderRefund, Quota, +) from pretix.base.reldate import RelativeDateField, RelativeDateWrapper from pretix.base.settings import SettingsSandbox from pretix.base.signals import register_payment_providers +from pretix.base.templatetags.money import money_filter +from pretix.base.templatetags.rich_text import rich_text from pretix.helpers.money import DecimalTextInput from pretix.presale.views import get_cart_total from pretix.presale.views.cart import get_or_create_cart_id @@ -131,6 +136,16 @@ class BasePaymentProvider: """ raise NotImplementedError() # NOQA + @property + def abort_pending_allowed(self) -> bool: + """ + Whether or not a user can abort a payment in pending start to switch to another + payment method. This returns ``False`` by default which is no guarantee that + aborting a pending payment can never happen, it just hides the frontend button + to avoid users accidentally committing double payments. + """ + return False + @property def settings_form_fields(self) -> dict: """ @@ -360,7 +375,7 @@ class BasePaymentProvider: def payment_form_render(self, request: HttpRequest) -> str: """ - When the user selects this provider as his preferred payment method, + When the user selects this provider as their preferred payment method, they will be shown the HTML you return from this method. The default implementation will call :py:meth:`checkout_form` @@ -375,8 +390,8 @@ class BasePaymentProvider: def checkout_confirm_render(self, request) -> str: """ - If the user has successfully filled in his payment data, they will be redirected - to a confirmation page which lists all details of his order for a final review. + If the user has successfully filled in their payment data, they will be redirected + to a confirmation page which lists all details of their order for a final review. This method should return the HTML which should be displayed inside the 'Payment' box on this page. @@ -385,11 +400,19 @@ class BasePaymentProvider: """ raise NotImplementedError() # NOQA + def payment_pending_render(self, request: HttpRequest, payment: OrderPayment) -> str: + """ + Render customer-facing instructions on how to proceed with a pending payment + + :return: HTML + """ + return "" + def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]: """ - Will be called after the user selects this provider as his payment method. + Will be called after the user selects this provider as their payment method. If you provided a form to the user to enter payment data, this method should - at least store the user's input into his session. + at least store the user's input into their session. This method should return ``False`` if the user's input was invalid, ``True`` if the input was valid and the frontend should continue with default behavior @@ -404,7 +427,7 @@ class BasePaymentProvider: If your payment method requires you to redirect the user to an external provider, this might be the place to do so. - .. IMPORTANT:: If this is called, the user has not yet confirmed his or her order. + .. IMPORTANT:: If this is called, the user has not yet confirmed their order. You may NOT do anything which actually moves money. :param cart: This dictionary contains at least the following keys: @@ -439,26 +462,29 @@ class BasePaymentProvider: """ raise NotImplementedError() # NOQA - def payment_perform(self, request: HttpRequest, order: Order) -> str: + def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str: """ After the user has confirmed their purchase, this method will be called to complete the payment process. This is the place to actually move the money if applicable. - If you need any special behavior, you can return a string + You will be passed an :py:class:`pretix.base.models.OrderPayment` object that contains + the amount of money that should be paid. + + If you need any special behavior, you can return a string containing the URL the user will be redirected to. If you are done with your process you should return the user to the order's detail page. - If the payment is completed, you should call ``pretix.base.services.orders.mark_order_paid(order, provider, info)`` - with ``provider`` being your :py:attr:`identifier` and ``info`` being any string - you might want to store for later usage. Please note that ``mark_order_paid`` might - raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this - order is over and some of the items are sold out. You should use the exception message - to display a meaningful error to the user. + If the payment is completed, you should call ``payment.confirm()``. Please note that ``this`` might + raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this order is over and + some of the items are sold out. You should use the exception message to display a meaningful error + to the user. The default implementation just returns ``None`` and therefore leaves the order unpaid. The user will be redirected to the order's detail page by default. On errors, you should raise a ``PaymentException``. + :param order: The order object + :param payment: An ``OrderPayment`` instance """ return None @@ -472,19 +498,6 @@ class BasePaymentProvider: """ return "" - def order_pending_render(self, request: HttpRequest, order: Order) -> str: - """ - If the user visits a detail page of an order which has not yet been paid but - this payment method was selected during checkout, this method will be called - to provide HTML content for the 'payment' box on the page. - - It should contain instructions on how to continue with the payment process, - either in form of text or buttons/links/etc. - - :param order: The order object - """ - raise NotImplementedError() # NOQA - def order_change_allowed(self, order: Order) -> bool: """ Will be called to check whether it is allowed to change the payment method of @@ -494,39 +507,16 @@ class BasePaymentProvider: :param order: The order object """ - if self.settings._total_max is not None and order.total > Decimal(self.settings._total_max): + ps = order.pending_sum + if self.settings._total_max is not None and ps > Decimal(self.settings._total_max): return False - if self.settings._total_min is not None and order.total < Decimal(self.settings._total_min): + if self.settings._total_min is not None and ps < Decimal(self.settings._total_min): return False return self._is_still_available(order=order) - def order_can_retry(self, order: Order) -> bool: - """ - Will be called if the user views the detail page of an unpaid order to determine - whether the user should be presented with an option to retry the payment. The default - implementation always returns False. - - If you want to enable retrials for your payment method, the best is to just return - ``self._is_still_available()`` from this method to disable it as soon as the method - gets disabled or the methods end date is reached. - - The retry workflow is also used if a user switches to this payment method for an existing - order! - - :param order: The order object - """ - return False - - def retry_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]: - """ - Deprecated, use order_prepare instead - """ - raise DeprecationWarning('retry_prepare is deprecated, use order_prepare instead') - return self.order_prepare(request, order) - - def order_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]: + def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]: """ Will be called if the user retries to pay an unpaid order (after the user filled in e.g. the form returned by :py:meth:`payment_form`) or if the user changes the payment @@ -547,22 +537,9 @@ class BasePaymentProvider: else: return False - def order_paid_render(self, request: HttpRequest, order: Order) -> str: + def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str: """ - Will be called if the user views the detail page of a paid order which is - associated with this payment provider. - - It should return HTML code which should be displayed to the user or None, - if there is nothing to say (like the default implementation does). - - :param order: The order object - """ - return None - - def order_control_render(self, request: HttpRequest, order: Order) -> str: - """ - Will be called if the *event administrator* views the detail page of an order - which is associated with this payment provider. + Will be called if the *event administrator* views the details of a payment. It should return HTML code containing information regarding the current payment status and, if applicable, next steps. @@ -571,62 +548,44 @@ class BasePaymentProvider: :param order: The order object """ - return _('Payment provider: %s' % self.verbose_name) + return '' - def order_control_refund_render(self, order: Order, request: HttpRequest=None) -> str: + def payment_refund_supported(self, payment: OrderPayment) -> bool: """ - Will be called if the event administrator clicks an order's 'refund' button. - This can be used to display information *before* the order is being refunded. - - It should return HTML code which should be displayed to the user. It should - contain information about to which extend the money will be refunded - automatically. - - :param order: The order object - :param request: The HTTP request - - .. versionchanged:: 1.6 - - The parameter ``request`` has been added. + Will be called to check if the provider supports automatic refunding for this + payment. """ - return '
%s
' % _('The money can not be automatically refunded, ' - 'please transfer the money back manually.') + return False - def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]: + def payment_partial_refund_supported(self, payment: OrderPayment) -> bool: """ - Will be called if the event administrator confirms the refund. - - This should transfer the money back (if possible). You can return the URL the - user should be redirected to if you need special behavior or None to continue - with default behavior. - - On failure, you should use Django's message framework to display an error message - to the user. - - The default implementation sets the Order's state to refunded and shows a success - message. - - :param request: The HTTP request - :param order: The order object + Will be called to check if the provider supports automatic partial refunding for this + payment. """ - from pretix.base.services.orders import mark_order_refunded + return False - mark_order_refunded(order, user=request.user) - messages.success(request, _('The order has been marked as refunded. Please transfer the money ' - 'back to the buyer manually.')) + def execute_refund(self, refund: OrderRefund): + """ + Will be called to execute an refund. Note that refunds have an amount property and can be partial. - def shred_payment_info(self, order: Order): + This should transfer the money back (if possible). + On success, you should call ``refund.done()``. + On failure, you should raise a PaymentException. + """ + raise PaymentException(_('Automatic refunds are not supported by this payment provider.')) + + def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]): """ When personal data is removed from an event, this method is called to scrub payment-related data - from an order. By default, it removes all info from the ``payment_info`` attribute. You can override + from a payment or refund. By default, it removes all info from the ``info`` attribute. You can override this behavior if you want to retain attributes that are not personal data on their own, i.e. a reference to a transaction in an external system. You can also override this to scrub more data, e.g. data from external sources that is saved in LogEntry objects or other places. :param order: An order """ - order.payment_info = None - order.save(update_fields=['payment_info']) + obj.info = '{}' + obj.save(update_fields=['info']) class PaymentException(Exception): @@ -634,25 +593,13 @@ class PaymentException(Exception): class FreeOrderProvider(BasePaymentProvider): - - @property - def is_implicit(self) -> bool: - return True - - @property - def is_enabled(self) -> bool: - return True - - @property - def identifier(self) -> str: - return "free" + is_implicit = True + is_enabled = True + identifier = "free" def checkout_confirm_render(self, request: HttpRequest) -> str: return _("No payment is required as this order only includes products which are free of charge.") - def order_pending_render(self, request: HttpRequest, order: Order) -> str: - pass - def payment_is_valid_session(self, request: HttpRequest) -> bool: return True @@ -660,10 +607,9 @@ class FreeOrderProvider(BasePaymentProvider): def verbose_name(self) -> str: return _("Free of charge") - def payment_perform(self, request: HttpRequest, order: Order): - from pretix.base.services.orders import mark_order_paid + def execute_payment(self, request: HttpRequest, payment: OrderPayment): try: - mark_order_paid(order, 'free', send_mail=False) + payment.confirm() except Quota.QuotaExceededException as e: raise PaymentException(str(e)) @@ -671,32 +617,7 @@ class FreeOrderProvider(BasePaymentProvider): def settings_form_fields(self) -> dict: return {} - def order_control_refund_render(self, order: Order) -> str: - return '' - - def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]: - """ - Will be called if the event administrator confirms the refund. - - This should transfer the money back (if possible). You can return the URL the - user should be redirected to if you need special behavior or None to continue - with default behavior. - - On failure, you should use Django's message framework to display an error message - to the user. - - The default implementation sets the Order's state to refunded and shows a success - message. - - :param request: The HTTP request - :param order: The order object - """ - from pretix.base.services.orders import mark_order_refunded - - mark_order_refunded(order, user=request.user) - messages.success(request, _('The order has been marked as refunded.')) - - def is_allowed(self, request: HttpRequest, total: Decimal) -> bool: + def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: from .services.cart import get_fees total = get_cart_total(request) @@ -713,10 +634,9 @@ class BoxOfficeProvider(BasePaymentProvider): identifier = "boxoffice" verbose_name = _("Box office") - def payment_perform(self, request: HttpRequest, order: Order): - from pretix.base.services.orders import mark_order_paid + def execute_payment(self, request: HttpRequest, payment: OrderPayment): try: - mark_order_paid(order, 'boxoffice', send_mail=False) + payment.confirm() except Quota.QuotaExceededException as e: raise PaymentException(str(e)) @@ -724,22 +644,136 @@ class BoxOfficeProvider(BasePaymentProvider): def settings_form_fields(self) -> dict: return {} - def order_control_refund_render(self, order: Order) -> str: - return '' - - def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]: - from pretix.base.services.orders import mark_order_refunded - - mark_order_refunded(order, user=request.user) - messages.success(request, _('The order has been marked as refunded.')) - - def is_allowed(self, request: HttpRequest, total: Decimal) -> bool: + def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: return False def order_change_allowed(self, order: Order) -> bool: return False +class ManualPayment(BasePaymentProvider): + identifier = 'manual' + verbose_name = _('Manual payment') + + @property + def is_implicit(self): + return 'pretix.plugins.manualpayment' not in self.event.plugins + + def is_allowed(self, request: HttpRequest, total: Decimal=None): + return 'pretix.plugins.manualpayment' in self.event.plugins + + def order_change_allowed(self, order: Order): + return 'pretix.plugins.manualpayment' in self.event.plugins + + @property + def public_name(self): + return str(self.settings.get('public_name', as_type=LazyI18nString)) + + @property + def settings_form_fields(self): + d = OrderedDict( + [ + ('public_name', I18nFormField( + label=_('Payment method name'), + widget=I18nTextInput, + )), + ('checkout_description', I18nFormField( + label=_('Payment process description during checkout'), + help_text=_('This text will be shown during checkout when the user selects this payment method. ' + 'It should give a short explanation on this payment method.'), + widget=I18nTextarea, + )), + ('email_instructions', I18nFormField( + label=_('Payment process description in order confirmation emails'), + help_text=_('This text will be included for the {payment_info} placeholder in order confirmation ' + 'mails. It should instruct the user on how to proceed with the payment. You can use' + 'the placeholders {order}, {total}, {currency} and {total_with_currency}'), + widget=I18nTextarea, + validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])], + )), + ('pending_description', I18nFormField( + label=_('Payment process description for pending orders'), + help_text=_('This text will be shown on the order confirmation page for pending orders. ' + 'It should instruct the user on how to proceed with the payment. You can use' + 'the placeholders {order}, {total}, {currency} and {total_with_currency}'), + widget=I18nTextarea, + validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])], + )), + ] + list(super().settings_form_fields.items()) + ) + d.move_to_end('_enabled', last=False) + return d + + def payment_form_render(self, request) -> str: + return rich_text( + str(self.settings.get('checkout_description', as_type=LazyI18nString)) + ) + + def checkout_prepare(self, request, total): + return True + + def payment_is_valid_session(self, request): + return True + + def checkout_confirm_render(self, request): + return self.payment_form_render(request) + + def format_map(self, order): + return { + 'order': order.code, + 'total': order.total, + 'currency': self.event.currency, + 'total_with_currency': money_filter(order.total, self.event.currency) + } + + def order_pending_mail_render(self, order) -> str: + msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order)) + return msg + + def order_pending_render(self, request, order) -> str: + return rich_text( + str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(order)) + ) + + +class OffsettingProvider(BasePaymentProvider): + is_enabled = True + identifier = "offsetting" + verbose_name = _("Offsetting") + is_implicit = True + + def execute_payment(self, request: HttpRequest, payment: OrderPayment): + try: + payment.confirm() + except Quota.QuotaExceededException as e: + raise PaymentException(str(e)) + + def execute_refund(self, refund: OrderRefund): + code = refund.info_data['orders'][0] + order = self.event.orders.get(code=code) + p = order.payments.create( + state=OrderPayment.PAYMENT_STATE_PENDING, + amount=refund.amount, + payment_date=now(), + provider='offsetting', + info=json.dumps({'orders': [refund.order.code]}) + ) + p.confirm() + + @property + def settings_form_fields(self) -> dict: + return {} + + def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: + return False + + def order_change_allowed(self, order: Order) -> bool: + return False + + def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str: + return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders'])) + + @receiver(register_payment_providers, dispatch_uid="payment_free") def register_payment_provider(sender, **kwargs): - return [FreeOrderProvider, BoxOfficeProvider] + return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment] diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 877bf1f788..416574159b 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -18,7 +18,9 @@ from django.utils.translation import pgettext, ugettext as _ from i18nfield.strings import LazyI18nString from pretix.base.i18n import language -from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order +from pretix.base.models import ( + Invoice, InvoiceAddress, InvoiceLine, Order, OrderPayment, +) from pretix.base.models.tax import EU_CURRENCIES from pretix.base.services.async import TransactionAwareTask from pretix.base.settings import GlobalSettingsObject @@ -31,16 +33,19 @@ logger = logging.getLogger(__name__) @transaction.atomic def build_invoice(invoice: Invoice) -> Invoice: - with language(invoice.locale): - payment_provider = invoice.event.get_payment_providers().get(invoice.order.payment_provider) + lp = invoice.order.payments.last() + open_payment = None + if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED): + open_payment = lp + with language(invoice.locale): invoice.invoice_from = invoice.event.settings.get('invoice_address_from') introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString) additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString) footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString) - if payment_provider: - payment = payment_provider.render_invoice_text(invoice.order) + if open_payment and open_payment.payment_provider: + payment = open_payment.payment_provider.render_invoice_text(invoice.order) else: payment = "" diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 5835bfa0a4..f650833530 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -13,6 +13,7 @@ from django.db import transaction from django.db.models import F, Max, Q, Sum from django.dispatch import receiver from django.utils.formats import date_format +from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import ugettext as _ @@ -21,12 +22,12 @@ from pretix.base.i18n import ( LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language, ) from pretix.base.models import ( - CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota, - User, Voucher, + CartPosition, Event, Item, ItemVariation, Order, OrderPayment, + OrderPosition, Quota, User, Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.orders import ( - CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee, + CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee, OrderRefund, generate_position_secret, generate_secret, ) from pretix.base.models.organizer import TeamAPIToken @@ -40,8 +41,7 @@ from pretix.base.services.locking import LockTimeoutException from pretix.base.services.mail import SendMailException from pretix.base.services.pricing import get_price from pretix.base.signals import ( - allow_ticket_download, order_fee_calculation, order_paid, order_placed, - periodic_task, + allow_ticket_download, order_fee_calculation, order_placed, periodic_task, ) from pretix.celery_app import app from pretix.multidomain.urlreverse import build_absolute_uri @@ -79,99 +79,8 @@ error_messages = { logger = logging.getLogger(__name__) -def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None, - force: bool=False, send_mail: bool=True, user: User=None, mail_text='', - count_waitinglist=True, auth=None) -> Order: - """ - Marks an order as paid. This sets the payment provider, info and date and returns - the order object. - - :param provider: The payment provider that marked this as paid - :type provider: str - :param info: The information to store in order.payment_info - :type info: str - :param date: The date the payment was received (if you pass ``None``, the current - time will be used). - :type date: datetime - :param force: Whether this payment should be marked as paid even if no remaining - quota is available (default: ``False``). - :type force: boolean - :param send_mail: Whether an email should be sent to the user about this event (default: ``True``). - :type send_mail: boolean - :param user: The user that performed the change - :param mail_text: Additional text to be included in the email - :type mail_text: str - :raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False`` - """ - if order.status == Order.STATUS_PAID: - return order - - with order.event.lock() as now_dt: - can_be_paid = order._can_be_paid(count_waitinglist=count_waitinglist) - if not force and can_be_paid is not True: - raise Quota.QuotaExceededException(can_be_paid) - order.payment_provider = provider or order.payment_provider - order.payment_info = info or order.payment_info - order.payment_date = date or now_dt - if manual is not None: - order.payment_manual = manual - order.status = Order.STATUS_PAID - order.save() - - order.log_action('pretix.event.order.paid', { - 'provider': provider, - 'info': info, - 'date': date or now_dt, - 'manual': manual, - 'force': force - }, user=user, auth=auth) - order_paid.send(order.event, order=order) - - invoice = None - if invoice_qualified(order): - invoices = order.invoices.filter(is_cancellation=False).count() - cancellations = order.invoices.filter(is_cancellation=True).count() - gen_invoice = ( - (invoices == 0 and order.event.settings.get('invoice_generate') in ('True', 'paid')) or - 0 < invoices <= cancellations - ) - if gen_invoice: - invoice = generate_invoice( - order, - trigger_pdf=not send_mail or not order.event.settings.invoice_email_attachment - ) - - if send_mail: - with language(order.locale): - try: - invoice_name = order.invoice_address.name - invoice_company = order.invoice_address.company - except InvoiceAddress.DoesNotExist: - invoice_name = "" - invoice_company = "" - email_template = order.event.settings.mail_text_order_paid - email_context = { - 'event': order.event.name, - 'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={ - 'order': order.code, - 'secret': order.secret - }), - 'downloads': order.event.settings.get('ticket_download', as_type=bool), - 'invoice_name': invoice_name, - 'invoice_company': invoice_company, - 'payment_info': mail_text - } - email_subject = _('Payment received for your order: %(code)s') % {'code': order.code} - try: - order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_paid', user, - invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else [] - ) - except SendMailException: - logger.exception('Order paid email could not be sent') - - return order +def mark_order_paid(*args, **kwargs): + raise NotImplementedError("This method is no longer supported since pretix 1.17.") def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None): @@ -215,7 +124,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User @transaction.atomic -def mark_order_refunded(order, user=None, api_token=None): +def mark_order_refunded(order, user=None, auth=None, api_token=None): """ Mark this order as refunded. This sets the payment status and returns the order object. :param order: The order to change @@ -229,7 +138,7 @@ def mark_order_refunded(order, user=None, api_token=None): order.status = Order.STATUS_REFUNDED order.save() - order.log_action('pretix.event.order.refunded', user=user, api_token=api_token) + order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token) i = order.invoices.filter(is_cancellation=False).last() if i: generate_cancellation(i) @@ -434,20 +343,22 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid fees = [] total = sum([c.price for c in positions]) payment_fee = payment_provider.calculate_fee(total) + pf = None if payment_fee: - fees.append(OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, - internal_type=payment_provider.identifier)) + pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, + internal_type=payment_provider.identifier) + fees.append(pf) for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total, meta_info=meta_info, positions=positions): fees += resp - return fees + return fees, pf def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None, meta_info: dict=None): - fees = _get_fees(positions, payment_provider, address, meta_info, event) + fees, pf = _get_fees(positions, payment_provider, address, meta_info, event) total = sum([c.price for c in positions]) + sum([c.value for c in fees]) with transaction.atomic(): @@ -458,7 +369,6 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d datetime=now_dt, locale=locale, total=total, - payment_provider=payment_provider.identifier, meta_info=json.dumps(meta_info or {}), ) order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions])) @@ -479,6 +389,13 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d fee.tax_rule = None # TODO: deprecate fee.save() + order.payments.create( + state=OrderPayment.PAYMENT_STATE_CREATED, + provider=payment_provider, + amount=total, + fee=pf + ) + OrderPosition.transform_cart_positions(positions, order) order.log_action('pretix.event.order.placed') if meta_info: @@ -528,7 +445,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str], # send_mail will trigger PDF generation later if order.email: - if order.payment_provider == 'free': + if payment_provider == 'free': email_template = event.settings.mail_text_order_free log_entry = 'pretix.event.order.email.order_free' else: @@ -678,8 +595,6 @@ class OrderChangeManager: 'not_pending_or_paid': _('Only pending or paid orders can be changed.'), 'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however ' 'no quota is available.'), - 'paid_price_change': _('Currently, paid orders can only be changed in a way that does not change the total ' - 'price of the order as partial payments or refunds are not yet supported.'), 'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'), 'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'), 'subevent_required': _('You need to choose a subevent for the new position.'), @@ -840,28 +755,43 @@ class OrderChangeManager: raise OrderError(self.error_messages['free_to_paid']) def _check_paid_price_change(self): - if self.order.status == Order.STATUS_PAID and self._totaldiff != 0: - raise OrderError(self.error_messages['paid_price_change']) + if self.order.status == Order.STATUS_PAID and self._totaldiff > 0: + self.order.status = Order.STATUS_PENDING + self.order.set_expires( + now(), + self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True)) + ) + self.order.save() + elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0: + if self.order.pending_sum <= Decimal('0.00'): + self.order.status = Order.STATUS_PAID + self.order.save() def _check_paid_to_free(self): if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)): # if the order becomes free, mark it paid using the 'free' provider # this could happen if positions have been made cheaper or removed (_totaldiff < 0) # or positions got split off to a new order (split_order with positive total) + p = self.order.payments.create( + state=OrderPayment.PAYMENT_STATE_CREATED, + provider='free', + amount=0, + fee=None + ) try: - mark_order_paid( - self.order, 'free', send_mail=False, count_waitinglist=False, - user=self.user - ) + p.confirm(send_mail=False, count_waitinglist=False, user=self.user) except Quota.QuotaExceededException: raise OrderError(self.error_messages['paid_to_free_exceeded']) if self.split_order and self.split_order.total == 0: + p = self.split_order.payments.create( + state=OrderPayment.PAYMENT_STATE_CREATED, + provider='free', + amount=0, + fee=None + ) try: - mark_order_paid( - self.split_order, 'free', send_mail=False, count_waitinglist=False, - user=self.user - ) + p.confirm(send_mail=False, count_waitinglist=False, user=self.user) except Quota.QuotaExceededException: raise OrderError(self.error_messages['paid_to_free_exceeded']) @@ -1002,7 +932,11 @@ class OrderChangeManager: split_order.total = sum([p.price for p in split_positions]) if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID: - payment_fee = self._get_payment_provider().calculate_fee(split_order.total) + pp = self._get_payment_provider() + if pp: + payment_fee = pp.calculate_fee(split_order.total) + else: + payment_fee = Decimal('0.00') fee = split_order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0] fee.value = payment_fee fee._calculate_tax() @@ -1021,41 +955,89 @@ class OrderChangeManager: split_order.save() + if split_order.status == Order.STATUS_PAID: + split_order.payments.create( + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + amount=split_order.total, + payment_date=now(), + provider='offsetting', + info=json.dumps({'orders': [self.order.code]}) + ) + self.order.refunds.create( + state=OrderRefund.REFUND_STATE_DONE, + amount=split_order.total, + execution_date=now(), + provider='offsetting', + info=json.dumps({'orders': [split_order.code]}) + ) + if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last(): generate_invoice(split_order) return split_order - def _recalculate_total_and_payment_fee(self): - self.order.total = sum([p.price for p in self.order.positions.all()]) + @cached_property + def open_payment(self): + lp = self.order.payments.last() + if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, + OrderPayment.PAYMENT_STATE_REFUNDED): + return lp - if self.order.status != Order.STATUS_PAID: - # Do not change payment fees of paid orders - payment_fee = Decimal('0.00') - if self.order.total != 0: - prov = self._get_payment_provider() + @cached_property + def completed_payment_sum(self): + payment_sum = self.order.payments.filter( + state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED) + ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') + refund_sum = self.order.refunds.filter( + state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE) + ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') + return payment_sum - refund_sum + + def _recalculate_total_and_payment_fee(self): + total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()]) + payment_fee = Decimal('0.00') + if self.open_payment: + current_fee = Decimal('0.00') + fee = None + if self.open_payment.fee: + fee = self.open_payment.fee + current_fee = self.open_payment.fee.value + total -= current_fee + + if self.order.pending_sum - current_fee != 0: + prov = self.open_payment.payment_provider if prov: - payment_fee = prov.calculate_fee(self.order.total) + payment_fee = prov.calculate_fee(total - self.completed_payment_sum) if payment_fee: - fee = self.order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0] + fee = fee or OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, order=self.order) fee.value = payment_fee fee._calculate_tax() fee.save() - else: - self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).delete() + if not self.open_payment.fee: + self.open_payment.fee = fee + self.open_payment.save() + elif fee: + fee.delete() - self.order.total += sum([f.value for f in self.order.fees.all()]) + self.order.total = total + payment_fee self.order.save() def _payment_fee_diff(self): - prov = self._get_payment_provider() - if self.order.status != Order.STATUS_PAID and prov: - # payment fees of paid orders do not change - old_fee = OrderFee.objects.filter(order=self.order, fee_type=OrderFee.FEE_TYPE_PAYMENT).aggregate(s=Sum('value'))['s'] or 0 - new_total = sum([p.price for p in self.order.positions.all()]) + self._totaldiff - if new_total != 0: - new_fee = prov.calculate_fee(new_total) - self._totaldiff += new_fee - old_fee + total = self.order.total + self._totaldiff + if self.open_payment: + current_fee = Decimal('0.00') + if self.open_payment and self.open_payment.fee: + current_fee = self.open_payment.fee.value + total -= current_fee + + # Do not change payment fees of paid orders + payment_fee = Decimal('0.00') + if self.order.pending_sum - current_fee != 0: + prov = self.open_payment.payment_provider + if prov: + payment_fee = prov.calculate_fee(total - self.completed_payment_sum) + + self._totaldiff += payment_fee - current_fee def _reissue_invoice(self): i = self.order.invoices.filter(is_cancellation=False).last() @@ -1121,7 +1103,6 @@ class OrderChangeManager: if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID): raise OrderError(self.error_messages['not_pending_or_paid']) self._check_free_to_paid() - self._check_paid_price_change() self._check_quotas() self._check_complete_cancel() self._perform_operations() @@ -1129,6 +1110,7 @@ class OrderChangeManager: self._reissue_invoice() self._clear_tickets_cache() self.order.touch() + self._check_paid_price_change() self._check_paid_to_free() if self.notify: @@ -1144,9 +1126,12 @@ class OrderChangeManager: CachedCombinedTicket.objects.filter(order=self.split_order).delete() def _get_payment_provider(self): - pprov = self.order.event.get_payment_providers().get(self.order.payment_provider) + lp = self.order.payments.last() + if not lp: + return None + pprov = lp.payment_provider if not pprov: - raise OrderError(error_messages['internal']) + return None return pprov diff --git a/src/pretix/base/shredder.py b/src/pretix/base/shredder.py index 9942b8aae0..39a2376789 100644 --- a/src/pretix/base/shredder.py +++ b/src/pretix/base/shredder.py @@ -15,8 +15,8 @@ from pretix.api.serializers.order import ( from pretix.api.serializers.waitinglist import WaitingListSerializer from pretix.base.i18n import LazyLocaleException from pretix.base.models import ( - CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPosition, - QuestionAnswer, + CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment, + OrderPosition, OrderRefund, QuestionAnswer, ) from pretix.base.services.invoices import invoice_pdf_task from pretix.base.signals import register_data_shredders @@ -331,10 +331,14 @@ class PaymentInfoShredder(BaseDataShredder): @transaction.atomic def shred_data(self): provs = self.event.get_payment_providers() - for o in self.event.orders.all(): - pprov = provs.get(o.payment_provider) + for obj in OrderPayment.objects.filter(order__event=self.event): + pprov = provs.get(obj.provider) if pprov: - pprov.shred_payment_info(o) + pprov.shred_payment_info(obj) + for obj in OrderRefund.objects.filter(order__event=self.event): + pprov = provs.get(obj.provider) + if pprov: + pprov.shred_payment_info(obj) @receiver(register_data_shredders, dispatch_uid="shredders_builtin") diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 1235df8cae..bde091dfd5 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -7,8 +7,8 @@ from django.utils.timezone import now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from pretix.base.models import ( - Checkin, Event, Invoice, Item, Order, OrderPosition, Organizer, Question, - QuestionAnswer, SubEvent, + Checkin, Event, Invoice, Item, Order, OrderPayment, OrderPosition, + OrderRefund, Organizer, Question, QuestionAnswer, SubEvent, ) from pretix.base.signals import register_payment_providers from pretix.control.forms.widgets import Select2 @@ -152,14 +152,21 @@ class OrderFilterForm(FilterForm): qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]) elif s == 'ne': qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED]) - else: + elif s in ('p', 'n', 'e', 'c', 'r'): qs = qs.filter(status=s) if fdata.get('ordering'): qs = qs.order_by(self.get_order_by()) if fdata.get('provider'): - qs = qs.filter(payment_provider=fdata.get('provider')) + qs = qs.annotate( + has_payment_with_provider=Exists( + OrderPayment.objects.filter( + Q(order=OuterRef('pk')) & Q(provider=fdata.get('provider')) + ) + ) + ) + qs = qs.filter(has_payment_with_provider=1) return qs @@ -187,6 +194,23 @@ class EventOrderFilterForm(OrderFilterForm): answer = forms.CharField( required=False ) + status = forms.ChoiceField( + label=_('Order status'), + choices=( + ('', _('All orders')), + ('p', _('Paid')), + ('n', _('Pending')), + ('o', _('Pending (overdue)')), + ('np', _('Pending or paid')), + ('e', _('Expired')), + ('ne', _('Pending or expired')), + ('c', _('Canceled')), + ('r', _('Refunded')), + ('overpaid', _('Overpaid')), + ('underpaid', _('Underpaid')), + ), + required=False, + ) def __init__(self, *args, **kwargs): self.event = kwargs.pop('event') @@ -247,6 +271,18 @@ class EventOrderFilterForm(OrderFilterForm): ) qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True) + if fdata.get('status') == 'overpaid': + qs = qs.filter( + Q(~Q(status__in=[Order.STATUS_REFUNDED, Order.STATUS_CANCELED]) & Q(pending_sum_t__lt=0)) + | Q(Q(status__in=[Order.STATUS_REFUNDED, Order.STATUS_CANCELED]) & Q(pending_sum_rc__lt=0)) + | Q(Q(status__in=[Order.STATUS_EXPIRED, Order.STATUS_PENDING]) & Q(pending_sum_rc__lte=0)) + ) + elif fdata.get('status') == 'underpaid': + qs = qs.filter( + status=Order.STATUS_PAID, + pending_sum_t__gt=0 + ) + return qs @@ -793,3 +829,43 @@ class VoucherFilterForm(FilterForm): qs = qs.order_by(self.get_order_by()) return qs + + +class RefundFilterForm(FilterForm): + provider = forms.ChoiceField( + label=_('Payment provider'), + choices=[ + ('', _('All payment providers')), + ], + required=False, + ) + status = forms.ChoiceField( + label=_('Refund status'), + choices=( + ('', _('All open refunds')), + ('all', _('All refunds')), + ) + OrderRefund.REFUND_STATES, + required=False, + ) + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event') + super().__init__(*args, **kwargs) + self.fields['provider'].choices += [(k, v.verbose_name) for k, v + in self.event.get_payment_providers().items()] + + def filter_qs(self, qs): + fdata = self.cleaned_data + qs = super().filter_qs(qs) + + if fdata.get('provider'): + qs = qs.filter(provider=fdata.get('provider')) + + if fdata.get('status'): + if fdata.get('status') != 'all': + qs = qs.filter(state=fdata.get('status')) + else: + qs = qs.filter(state__in=[OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT, + OrderRefund.REFUND_STATE_EXTERNAL]) + + return qs diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index be795e21cd..e0489c71fe 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -344,3 +344,47 @@ class OrderMailForm(forms.Form): validators=[PlaceholderValidator(['{expire_date}', '{event}', '{code}', '{date}', '{url}', '{invoice_name}', '{invoice_company}'])] ) + + +class OrderRefundForm(forms.Form): + action = forms.ChoiceField( + required=False, + widget=forms.RadioSelect, + choices=( + ('mark_refunded', _('Mark the complete order as refunded. The order will be canceled and all tickets will ' + 'no longer work. This can not be reverted.')), + ('mark_pending', _('Mark the order as pending and allow the user to pay the open amount with another ' + 'payment method.')), + ('do_nothing', _('Do nothing and keep the order as it is.')), + ) + ) + mode = forms.ChoiceField( + required=False, + widget=forms.RadioSelect, + choices=( + ('full', 'Full refund'), + ('partial', 'Partial refund'), + ) + ) + partial_amount = forms.DecimalField( + required=False, max_digits=10, decimal_places=2, + localize=True + ) + + def __init__(self, *args, **kwargs): + self.order = kwargs.pop('order') + super().__init__(*args, **kwargs) + change_decimal_field(self.fields['partial_amount'], self.order.event.currency) + + def clean_partial_amount(self): + max_amount = self.order.total - self.order.pending_sum + val = self.cleaned_data.get('partial_amount') + if val is not None and (val > max_amount or val <= 0): + raise ValidationError(_('The refund amount needs to be positive and less than {}.').format(max_amount)) + return val + + def clean(self): + data = self.cleaned_data + if data.get('mode') == 'partial' and not data.get('partial_amount'): + raise ValidationError(_('You need to specify an amount for a partial refund.')) + return data diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 3df1653a8d..71e6b808ab 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -173,7 +173,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.order.comment': _('The order\'s internal comment has been updated.'), 'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been ' 'toggled.'), - 'pretix.event.order.payment.changed': _('The payment method has been changed.'), + 'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'), 'pretix.event.order.email.sent': _('An unidentified type email has been sent.'), 'pretix.event.order.email.custom_sent': _('A custom email has been sent.'), 'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket ' @@ -186,6 +186,13 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.order.email.order_paid': _('An email has been sent to notify the user that payment has been received.'), 'pretix.event.order.email.order_placed': _('An email has been sent to notify the user that the order has been received and requires payment.'), 'pretix.event.order.email.resend': _('An email with a link to the order detail page has been resent to the user.'), + 'pretix.event.order.payment.confirmed': _('Payment {local_id} has been confirmed.'), + 'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'), + 'pretix.event.order.payment.started': _('Payment {local_id} has been started.'), + 'pretix.event.order.refund.created': _('Refund {local_id} has been created.'), + 'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'), + 'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'), + 'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'), 'pretix.control.auth.user.created': _('The user has been created.'), 'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'), 'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'), diff --git a/src/pretix/control/templates/pretixcontrol/event/base.html b/src/pretix/control/templates/pretixcontrol/event/base.html index 5bc7965e3d..d2fa47e8f9 100644 --- a/src/pretix/control/templates/pretixcontrol/event/base.html +++ b/src/pretix/control/templates/pretixcontrol/event/base.html @@ -89,6 +89,12 @@ {% trans "Overview" %} +
  • + + {% trans "Refunds" %} + +
  • diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index 59d5044647..84fa72920d 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -15,6 +15,25 @@ {% endif %} + {% if has_overpaid_orders %} + + {% endif %} + {% if has_pending_refunds %} +
    + {% blocktrans trimmed %} + This event contains pending refunds that you should take care of. + {% endblocktrans %} + {% trans "Show pending refunds" %} +
    + {% endif %} {% if actions|length > 0 %}
    diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 174e5d1e93..7512419d83 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -25,7 +25,8 @@