Repository: saleor/saleor
Pull request: Implement order events

- [x] For the gql type of OrderEvent we have to include:
	- [x] New fields for getting data from the JSON parameters. [2]
	- [x] A payment method field taken from the parameters.
	- [x] Tests.

<!-- yes. -->

- [x] The OrderEvent model should have:
	- [x] A field `user` containing who triggered the event. [4]
	- [x] (outside) should contain a bunch of functions taking data to generate event(s)

<!-- yes. -->

- [x] Update dashboard 1.0 to be compatible with the latest changes (only order side)
- [x] Update docs definitions
- [x] Document the different parameters generated by the order events (docstrings + sphinx-apidoc)
- [x] Update definitions (migrations & GQL)

- Introduce new order event types (OrderEventsEnum):
	- [x] the order was placed
	- [x] **new:** the order payment failed [failure event]
		- [x] Implement it
	- [x] the payment was captured (+ amount)
	- [x] a note was added to the order
	- [x] the order confirmation has been set
	- [x] **new:** digital link was sent to the customer -> `saleor/order/emails.py:46`
		- [x] Implement it
	- [x] Manual staff actions events:
		- [x] the tracking email was sent -> `saleor/dashboard/order/views.py:688`
		- [x] **new:** a draft was created
			- [x] Implement it
		- [x] a draft was placed
		- [x] **new:** added products (store a strings)
			- [x] Implement it
		- [x] **new:** removed products (idem)
			- [x] Implement it
		- [x] order was manually fulfilled
		- [x] order was manually marked as paid

<!-- yes. -->

<!-- yes. -->

- [x] An event should be called to be created and may create other events.
- [x] We need to migrate the old events to the new model.
- [x] Refactor logic to commit on the go instead of waiting on the user's green light.

[2] Allows to retrieve additional data from an event (e.g., an amount, a quantity, a message, etc.)
[4] We want to know who triggered the event, even if it's the customer itself or a staff user.

[!] Failed payment expected data (payment id, etc. for user support) https://i.imgur.com/ayYrCjr.png




### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [x] Privileged views and APIs are guarded by proper permission checks.
1. [x] All visible strings are translated with proper context.
1. [x] All data-formatting is locale-aware (dates, numbers, and so on).
1. [x] Database queries are optimized and the number of queries is constant.
1. [x] Database migration files are up to date.
1. [x] The changes are tested.
1. [x] The code is documented (docstrings, project documentation).
1. [x] GraphQL schema and type definitions are up to date.
1. [x] Changes are mentioned in the changelog.

### Magic Final Words
Closes #2443.


Problem statement:
Log order and customer actions and events
### What I'm trying to achieve
Keep track of whatever changes and actions were made by the staff.

This is useful to catch errors and check data changes.


### Describe a proposed solution

#### 1 — Models
Introduce a new model containing log entries. A possible model (based on django-admin's `LogEntry` model) would be the following:

| field name | content |
| ---------- | ---- |
| action_date | Datetime of the action |
| action_type | Action type from the enum: `CREATE\|EDIT\|DELETE` |
| user | FK to `User` |
| object | Object type name or verbose name if available (e.g.: `Product Variant`) |
| object_info | The object representation through `__str__` |

This model would introduce a new listing view: action logs.


#### 2 — Event trapping
To only log dashboard action, we cannot trap at the model level efficiently (unless we want to log the whole website).

##### 2.1 — Object creation and update
Object creation and update could be trapped by making a base form receiving the user request object (to catch the requester) and handling calls to `save(...)`.

##### 2.2 — Object deletion
We could implement a method `delete_object(request, object)` to replace `object.delete()` to trigger a log creation.

#### 3 — Log saving
As the dashboard doesn't depend on logging, we probably could make log entry creation as an asynchronous task.

Hints from issue discussion:
As part of that issue, we should also think of the unified API for using events in the code (see #3966).
- For the gql type of OrderEvent we have to include:
	- A order_customer field of type User. (Null if it's an automated task) [1]
	- New fields for getting data from the JSON parameters. [2]
	- A payment method field taken from the parameters.

- The OrderEvent model should have:
	- A field [insert name] containing who triggered the event. [4]

- Introduce new order event types (OrderEventsEnum):
	- the order was placed
	- the order payment failed [failure event]-> what data? How to get the data and the payment ID/toke.
	- the order is ready to fulfill
	- the order is ready to be captured
	- the payment was captured (+ amount)
	- a note was added to the order
	- the order confirmation has been set
	- digital link was sent to the customer
	- Manual staff actions events:
		- the tracking email was sent
		- a draft was created
		- a shipping method was selected (no data)
		- added products (data, [how to store?])
		- removed products (idem)
		- order was manually fulfilled
		- order was manually marked as paid

- CustomerEvents:
	- the order was placed
	- a note was added to the order
	- downloaded digital link

- An event should be called to be created and may create other events.

- We need to migrate the old events to the new model.

[1] Allows to fetch all the order events from a given customer.
[2] Allows to retrieve additional data from an event (e.g., an amount, a quantity, a message, etc.)
[4] We want to know who triggered the event, even if it's the customer itself or a staff user.

Commit message: Update migration files

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48d6613ae90..cf4cb893797 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,10 @@ All notable, unreleased changes to this project will be documented in this file.
 - Resort imports and remove unused imports - #4069 by @jxltom
 - Fix typo in CheckoutBillingAddressUpdate - #4073 by @jxltom
 - Required checkout ID for updating checkout's shipping and billing address - #4074 by @jxltom
+- Implement order events - #4018 by @NyanKiyosho 
+  - Implemented new events: `DRAFT_CREATED`, `DRAFT_ADDED_PRODUCTS`, `DRAFT_REMOVED_PRODUCTS`, and `PAYMENT_FAILED`;
+  - Implemented new mail events: `TRACKING_UPDATED`, `DIGITAL_LINKS`;
+  - Refactored logic of events, it's now easier than ever to add and send events.
 
 
 ## 2.5.0
diff --git a/docs/architecture/events.rst b/docs/architecture/events.rst
index 7fd659ed3f6..115e532b2e0 100644
--- a/docs/architecture/events.rst
+++ b/docs/architecture/events.rst
@@ -6,41 +6,110 @@ Events
     when certain actions are completed, such us creating the order,
     cancelling fulfillment or completing a payment.
 
-Order events
+Order Events
 ------------
 
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| Code                     | GraphQL API value        | Description                                                         |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``placed``               | ``PLACED``               | An order was placed by the customer.                                |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``draft_placed``         | ``PLACED_FROM_DRAFT``    | An order was created from draft by the staff user.                  |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``oversold_items``       | ``OVERSOLD_ITEMS``       | An order was created from draft, but some line items were oversold. |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``canceled``             | ``CANCELED``             | The order was cancelled.                                            |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``order_paid``           | ``ORDER_PAID``           | The order was fully paid by the customer.                           |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``marked_as_paid``       | ``MARKED_AS_PAID``       | The order was manually marked as fully paid by the staff user.      |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``updated``              | ``UPDATED``              | The order was updated.                                              |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``email_sent``           | ``EMAIL_SENT``           | An email was sent to the customer.                                  |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``captured``             | ``CAPTURED``             | The payment was captured.                                           |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``refunded``             | ``REFUNDED``             | The payment was refunded.                                           |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``voided``               | ``VOIDED``               | The payment was voided.                                             |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``fulfillment_canceled`` | ``FULFILLMENT_CANCELED`` | Fulfillment for one or more of the items was canceled.              |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``restocked_items``      | ``RESTOCKED_ITEMS``      | One or more of the order's items have been resocked                 |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``fulfilled_items``      | ``FULFILLED_ITEMS``      | One or more of the order's items have been fulfilled.               |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``note_added``           | ``NOTE_ADDED``           | A note was added to the order by the staff.                         |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``other``                | ``OTHER``                | Status used during reimporting of the legacy events.                |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| Code                       | GraphQL API value          | Description                                                         | Additional Parameters (GraphQL)                                                                 |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``draft_created``          | ``DRAFT_CREATED``          | A draft order was created by the staff user.                        | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``draft_added_products``   | ``DRAFT_ADDED_PRODUCTS``   | The staff user added products to a draft order.                     | ``lines`` a list of objects containing a ``quantity`` (int) and a ``item`` (string)             |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``draft_removed_products`` | ``DRAFT_REMOVED_PRODUCTS`` | The staff user removed products from a draft order.                 | ``lines`` a list of objects containing a ``quantity`` (int) and a ``item`` (string)             |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``placed``                 | ``PLACED``                 | An order was placed by the customer.                                | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``draft_placed``           | ``PLACED_FROM_DRAFT``      | An order was created from draft by the staff user.                  | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``oversold_items``         | ``OVERSOLD_ITEMS``         | An order was created from draft, but some line items were oversold. | ``oversold_items`` a list of items (strings)                                                    |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``canceled``               | ``CANCELED``               | The order was cancelled.                                            | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``order_paid``             | ``ORDER_PAID``             | The order was fully paid by the customer.                           | ``amount`` (float), ``payment_id`` (string), ``payment_gateway`` (string)                       |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``marked_as_paid``         | ``MARKED_AS_PAID``         | The order was manually marked as fully paid by the staff user.      | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``email_sent``             | ``EMAIL_SENT``             | An email was sent to the customer.                                  | ``email`` (string), ``email_type`` (``OrderEventsEmailsEnum``)                                  |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``captured``               | ``CAPTURED``               | The payment was captured.                                           | ``amount`` (float), ``payment_id`` (string), ``payment_gateway`` (string)                       |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``refunded``               | ``REFUNDED``               | The payment was refunded.                                           | ``amount`` (float), ``payment_id`` (string), ``payment_gateway`` (string)                       |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``voided``                 | ``VOIDED``                 | The payment was voided.                                             | ``amount`` (float), ``payment_id`` (string), ``payment_gateway`` (string)                       |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``payment_failed``         | ``PAYMENT_FAILED``         | The payment failed.                                                 | ``message`` (string), ``payment_id`` (string, optional), ``payment_gateway`` (string, optional) |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``fulfillment_canceled``   | ``FULFILLMENT_CANCELED``   | Fulfillment for one or more of the items was canceled.              | ``composed_id`` (int)                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``restocked_items``        | ``RESTOCKED_ITEMS``        | One or more of the order's items have been resocked                 | ``quantity`` (int)                                                                              |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``fulfilled_items``        | ``FULFILLED_ITEMS``        | One or more of the order's items have been fulfilled.               | ``fulfilled_items`` a list of objects containing a ``quantity`` (int) and a ``item`` (string)   |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``note_added``             | ``NOTE_ADDED``             | A note was added to the order by the staff.                         | ``message`` (string)                                                                            |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``other``                  | ``OTHER``                  | Status used during reimporting of the legacy events.                | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+
+Email Event Types
+-----------------
+
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``payment_confirmation``     | ``PAYMENT``          | The order has been fully paid.                                                   |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``shipping_confirmation``    | ``SHIPPING``         | The order has been shipped.                                                      |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``tracking_updated``         | ``TRACKING_UPDATED`` | The shipping tracking number has been updated.                                   |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``order_confirmation``       | ``ORDER``            | The order has been placed.                                                       |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``fulfillment_confirmation`` | ``FULFILLMENT``      | One or more of the order's items have been fulfilled (both physical or digital)  |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``digital_links``            | ``DIGITAL_LINKS``    | The links to the order's digital goods have been sent.                           |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+
+
+Events Design
+-------------
+
+The ``OrderEventManager`` class contains a bunch of static methods suffixed by
+``_event``. They take care of all the logic and required fields, then
+returns an instance of ``OrderEventManager`` to allow the user to commit or
+to add more events. This design has for objective to allow
+delayed (or not) event creation in the database.
+
+The main power of this design comes when one wants to create multiple
+order events in one go. Instead of generating multiple create query, one
+can simply pass the objects to django's |QuerySet.bulk_create|_ method.
+
+If one simply does not want to bulk create, delay the creation, one can
+directly run ``save()`` for the creation.
+
+.. |QuerySet.bulk_create| replace:: ``QuerySet.bulk_create``
+.. _QuerySet.bulk_create: https://docs.djangoproject.com/en/1.10/ref/models/querysets/#django.db.models.query.QuerySet.bulk_create
+
+Creating Events
+---------------
+
+To send an event, simply do the following:
+
+.. code-block:: python
+
+  from saleor.order.events import note_added_event
+
+  # returns an OrderEvent
+  note_added_event(
+      order=order, user=user, message='hello world!')
+
+If now you want to send a 'sent email' event you would do the following:
+
+.. code-block:: python
+
+  email_sent_event(
+      order=order, user=user, email_type=OrderEventsEmails.TRACKING_UPDATED)
+
+Notice how we are providing the email type.
+
+.. note::
+
+  The methods are using a ``model_action_event`` naming pattern.
diff --git a/saleor/checkout/utils.py b/saleor/checkout/utils.py
index 619410140c8..cbee0649664 100644
--- a/saleor/checkout/utils.py
+++ b/saleor/checkout/utils.py
@@ -23,6 +23,8 @@
 from ..discount.utils import (
     get_products_voucher_discount, get_shipping_voucher_discount,
     get_value_voucher_discount, increase_voucher_usage)
+from ..order.events import order_created_event
+from ..order.emails import send_order_confirmation
 from ..order.models import Order
 from ..shipping.models import ShippingMethod
 from . import AddressType, logger
@@ -835,7 +837,9 @@ def _process_user_data_for_order(checkout):
 
 
 @transaction.atomic
-def create_order(checkout: Checkout, tracking_code: str, discounts, taxes):
+def create_order(
+        checkout: Checkout, tracking_code: str, discounts, taxes,
+        user: User):
     """Create an order from the checkout.
 
     Each order will get a private copy of both the billing and the shipping
@@ -871,6 +875,15 @@ def create_order(checkout: Checkout, tracking_code: str, discounts, taxes):
 
     # assign checkout payments to the order
     checkout.payments.update(order=order)
+
+    # remove checkout after order is created
+    checkout.delete()
+
+    # Create the order placed
+    order_created_event(order=order, user=user)
+
+    # Send the order confirmation email
+    send_order_confirmation.delay(order.pk, user.pk)
     return order
 
 
diff --git a/saleor/checkout/views/summary.py b/saleor/checkout/views/summary.py
index 7939bdf6680..775a89ffb6c 100644
--- a/saleor/checkout/views/summary.py
+++ b/saleor/checkout/views/summary.py
@@ -7,8 +7,6 @@
 from ...core import analytics
 from ...core.exceptions import InsufficientStock
 from ...discount.models import NotApplicable
-from ...order import OrderEvents, OrderEventsEmails
-from ...order.emails import send_order_confirmation
 from ..forms import CheckoutNoteForm
 from ..utils import (
     create_order, get_checkout_context, get_taxes_for_checkout,
@@ -17,7 +15,7 @@
     update_billing_address_in_checkout_with_shipping)
 
 
-def handle_order_placement(request, checkout):
+def _handle_order_placement(request, checkout):
     """Try to create an order and redirect the user as necessary.
 
     This function creates an order from checkout and performs post-create actions
@@ -29,7 +27,8 @@ def handle_order_placement(request, checkout):
             checkout=checkout,
             tracking_code=analytics.get_client_id(request),
             discounts=request.discounts,
-            taxes=get_taxes_for_checkout(checkout, request.taxes))
+            taxes=get_taxes_for_checkout(checkout, request.taxes),
+            user=request.user)
     except InsufficientStock:
         return redirect('checkout:index')
     except NotApplicable:
@@ -38,15 +37,7 @@ def handle_order_placement(request, checkout):
                 'Checkout warning', 'Please review your checkout.'))
         return redirect('checkout:summary')
 
-    # remove checkout after order is created
-    checkout.delete()
-    order.events.create(type=OrderEvents.PLACED.value)
-    send_order_confirmation.delay(order.pk)
-    order.events.create(
-        type=OrderEvents.EMAIL_SENT.value,
-        parameters={
-            'email': order.get_user_current_email(),
-            'email_type': OrderEventsEmails.ORDER.value})
+    # Redirect the user to the payment page
     return redirect('order:payment', token=order.token)
 
 
@@ -67,7 +58,7 @@ def summary_with_shipping_view(request, checkout):
             checkout, user_addresses, request.POST or None, request.country))
 
     if updated:
-        return handle_order_placement(request, checkout)
+        return _handle_order_placement(request, checkout)
 
     taxes = get_taxes_for_checkout(checkout, request.taxes)
     ctx = get_checkout_context(checkout, request.discounts, taxes)
@@ -93,7 +84,7 @@ def anonymous_summary_without_shipping(request, checkout):
             checkout, request.POST or None, request.country))
 
     if updated:
-        return handle_order_placement(request, checkout)
+        return _handle_order_placement(request, checkout)
 
     taxes = get_taxes_for_checkout(checkout, request.taxes)
     ctx = get_checkout_context(checkout, request.discounts, taxes)
@@ -120,7 +111,7 @@ def summary_without_shipping(request, checkout):
         checkout, user_addresses, request.POST or None, request.country)
 
     if updated:
-        return handle_order_placement(request, checkout)
+        return _handle_order_placement(request, checkout)
 
     taxes = get_taxes_for_checkout(checkout, request.taxes)
     ctx = get_checkout_context(checkout, request.discounts, taxes)
diff --git a/saleor/dashboard/order/forms.py b/saleor/dashboard/order/forms.py
index f985c05378c..17f1254db98 100644
--- a/saleor/dashboard/order/forms.py
+++ b/saleor/dashboard/order/forms.py
@@ -13,11 +13,12 @@
 from ...discount.models import Voucher
 from ...discount.utils import decrease_voucher_usage, increase_voucher_usage
 from ...order import OrderStatus
+from ...order.events import (
+    draft_order_added_products_event, payment_failed_event)
 from ...order.models import Fulfillment, FulfillmentLine, Order, OrderLine
 from ...order.utils import (
     add_variant_to_order, cancel_fulfillment, cancel_order,
-    change_order_line_quantity, delete_order_line, fulfill_order_line,
-    recalculate_order)
+    change_order_line_quantity, fulfill_order_line, recalculate_order)
 from ...payment import ChargeStatus, CustomPaymentChoices, PaymentError
 from ...payment.utils import (
     clean_mark_order_as_paid, gateway_capture, gateway_refund, gateway_void,
@@ -271,12 +272,15 @@ def payment_error(self, message):
             None, pgettext_lazy(
                 'Payment form error', 'Payment gateway error: %s') % message)
 
-    def try_payment_action(self, action):
-        amount = self.cleaned_data['amount']
+    def try_payment_action(self, user, action, *args):
         try:
-            action(self.payment, amount)
+            action(self.payment, *args)
         except (PaymentError, ValueError) as e:
-            self.payment_error(str(e))
+            message = str(e)
+            self.payment_error(message)
+            payment_failed_event(
+                order=self.payment.order, user=user,
+                message=message, payment=self.payment)
             return False
         return True
 
@@ -291,8 +295,9 @@ def clean(self):
         if not self.payment.can_capture():
             raise forms.ValidationError(self.clean_error)
 
-    def capture(self):
-        return self.try_payment_action(gateway_capture)
+    def capture(self, user):
+        return self.try_payment_action(
+            user, gateway_capture, self.cleaned_data['amount'])
 
 
 class RefundPaymentForm(BasePaymentForm):
@@ -311,8 +316,9 @@ def clean(self):
                     'Payment form error',
                     'Manual payments can not be refunded'))
 
-    def refund(self):
-        return self.try_payment_action(gateway_refund)
+    def refund(self, user):
+        return self.try_payment_action(
+            user, gateway_refund, self.cleaned_data['amount'])
 
 
 class VoidPaymentForm(BasePaymentForm):
@@ -332,13 +338,8 @@ def clean(self):
         if not self.payment.can_void():
             raise forms.ValidationError(self.clean_error)
 
-    def void(self):
-        try:
-            gateway_void(self.payment)
-        except (PaymentError, ValueError) as e:
-            self.payment_error(str(e))
-            return False
-        return True
+    def void(self, user):
+        return self.try_payment_action(user, gateway_void)
 
 
 class OrderMarkAsPaidForm(forms.Form):
@@ -366,11 +367,11 @@ def __init__(self, *args, **kwargs):
         self.line = kwargs.pop('line')
         super().__init__(*args, **kwargs)
 
-    def cancel_line(self):
+    def cancel_line(self, user):
         if self.line.variant and self.line.variant.track_inventory:
             deallocate_stock(self.line.variant, self.line.quantity)
         order = self.line.order
-        delete_order_line(self.line)
+        change_order_line_quantity(user, self.line, self.line.quantity, 0)
         recalculate_order(order)
 
 
@@ -403,14 +404,15 @@ def clean_quantity(self):
                             self.initial_quantity + variant.quantity_available)})  # noqa
         return quantity
 
-    def save(self):
+    def save(self, user):
         quantity = self.cleaned_data['quantity']
         variant = self.instance.variant
         if variant and variant.track_inventory:
             # update stock allocation
             delta = quantity - self.initial_quantity
             allocate_stock(variant, delta)
-        change_order_line_quantity(self.instance, quantity)
+        change_order_line_quantity(
+            user, self.instance, self.initial_quantity, quantity)
         recalculate_order(self.instance.order)
         return self.instance
 
@@ -441,8 +443,8 @@ def clean(self):
                     "This order can't be canceled"))
         return data
 
-    def cancel_order(self):
-        cancel_order(self.order, self.cleaned_data.get('restock'))
+    def cancel_order(self, user):
+        cancel_order(user, self.order, self.cleaned_data.get('restock'))
 
 
 class CancelFulfillmentForm(forms.Form):
@@ -471,8 +473,9 @@ def clean(self):
                     'This fulfillment can\'t be canceled'))
         return data
 
-    def cancel_fulfillment(self):
-        cancel_fulfillment(self.fulfillment, self.cleaned_data.get('restock'))
+    def cancel_fulfillment(self, user):
+        cancel_fulfillment(
+            user, self.fulfillment, self.cleaned_data.get('restock'))
 
 
 class FulfillmentTrackingNumberForm(forms.ModelForm):
@@ -569,15 +572,17 @@ def clean(self):
                 self.add_error('quantity', error)
         return cleaned_data
 
-    def save(self):
+    def save(self, user):
         """Add variant to order.
 
         Updates stocks and order.
         """
         variant = self.cleaned_data.get('variant')
         quantity = self.cleaned_data.get('quantity')
-        add_variant_to_order(
+        line = add_variant_to_order(
             self.order, variant, quantity, self.discounts, self.taxes)
+        draft_order_added_products_event(
+            order=self.order, user=user, order_lines=[(line.quantity, line)])
         recalculate_order(self.order)
 
 
diff --git a/saleor/dashboard/order/views.py b/saleor/dashboard/order/views.py
index e4e0bdc92b8..c9065b716fd 100644
--- a/saleor/dashboard/order/views.py
+++ b/saleor/dashboard/order/views.py
@@ -15,10 +15,16 @@
 from ...core.exceptions import InsufficientStock
 from ...core.utils import get_paginator_items
 from ...core.utils.taxes import get_taxes_for_address
-from ...order import OrderEvents, OrderEventsEmails, OrderStatus
+from ...order import OrderStatus
 from ...order.emails import (
-    send_fulfillment_confirmation, send_fulfillment_update,
+    send_fulfillment_confirmation_to_customer, send_fulfillment_update,
     send_order_confirmation)
+from ...order.events import (
+    OrderEvents, OrderEventsEmails, draft_order_created_event,
+    email_sent_event, fulfillment_fulfilled_items_event,
+    fulfillment_tracking_updated_event, order_created_event,
+    order_note_added_event, order_updated_address_event,
+    payment_captured_event, payment_refunded_event, payment_voided_event)
 from ...order.models import Fulfillment, FulfillmentLine, Order
 from ...order.utils import update_order_prices, update_order_status
 from ...shipping.models import ShippingMethod
@@ -57,11 +63,15 @@ def order_list(request):
 @permission_required('order.manage_orders')
 def order_create(request):
     display_gross_prices = request.site.settings.display_gross_prices
+    msg = pgettext_lazy(
+        'Dashboard message related to an order', 'Draft order created')
     order = Order.objects.create(
         status=OrderStatus.DRAFT, display_gross_prices=display_gross_prices)
-    msg = pgettext_lazy(
-        'Dashboard message related to an order',
-        'Draft order created')
+
+    # Create the draft creation event
+    draft_order_created_event(order=order, user=request.user)
+
+    # Send success message and redirect to the draft details
     messages.success(request, msg)
     return redirect('dashboard:order-details', order_pk=order.pk)
 
@@ -77,17 +87,15 @@ def create_order_from_draft(request, order_pk):
         msg = pgettext_lazy(
             'Dashboard message related to an order',
             'Order created from draft order')
-        order.events.create(
-            user=request.user,
-            type=OrderEvents.PLACED_FROM_DRAFT.value)
+
+        order_created_event(order=order, user=request.user, from_draft=True)
         messages.success(request, msg)
+
         if form.cleaned_data.get('notify_customer'):
-            send_order_confirmation.delay(order.pk)
-            order.events.create(
-                parameters={
-                    'email': order.get_user_current_email(),
-                    'email_type': OrderEventsEmails.ORDER.value},
-                type=OrderEvents.EMAIL_SENT.value)
+            send_order_confirmation.delay(order.pk, request.user.pk)
+            email_sent_event(
+                order=order, email_type=OrderEventsEmails.ORDER,
+                user=request.user)
         return redirect('dashboard:order-details', order_pk=order.pk)
     elif form.errors:
         status = 400
@@ -123,7 +131,7 @@ def order_details(request, order_pk):
     payment = order.get_last_payment()
     ctx = {
         'order': order, 'all_payments': all_payments, 'payment': payment,
-        'notes': order.events.filter(type=OrderEvents.NOTE_ADDED.value),
+        'notes': order.events.filter(type=OrderEvents.NOTE_ADDED),
         'events': order.events.order_by('-date').all(),
         'order_fulfillments': order.fulfillments.all()}
     return TemplateResponse(request, 'dashboard/order/detail.html', ctx)
@@ -136,10 +144,9 @@ def order_add_note(request, order_pk):
     form = OrderNoteForm(request.POST or None)
     status = 200
     if form.is_valid():
-        message = form.cleaned_data['message']
-        order.events.create(
-            user=request.user, type=OrderEvents.NOTE_ADDED.value,
-            parameters={'message': message})
+        order_note_added_event(
+            order=order, user=request.user,
+            message=form.cleaned_data['message'])
         msg = pgettext_lazy(
             'Dashboard message related to an order',
             'Added note')
@@ -163,14 +170,13 @@ def capture_payment(request, order_pk, payment_pk):
     form = CapturePaymentForm(
         request.POST or None, payment=payment,
         initial={'amount': amount.amount})
-    if form.is_valid() and form.capture():
+    if form.is_valid() and form.capture(request.user):
         msg = pgettext_lazy(
             'Dashboard message related to a payment',
             'Captured %(amount)s') % {'amount': prices_i18n.amount(amount)}
-        order.events.create(
-            parameters={'amount': amount},
-            user=request.user,
-            type=OrderEvents.PAYMENT_CAPTURED.value)
+        payment_captured_event(
+            order=order, user=request.user,
+            amount=amount.amount, payment=payment)
         messages.success(request, msg)
         return redirect('dashboard:order-details', order_pk=order.pk)
     status = 400 if form.errors else 200
@@ -192,16 +198,14 @@ def refund_payment(request, order_pk, payment_pk):
     amount = payment.captured_amount
     form = RefundPaymentForm(
         request.POST or None, payment=payment, initial={'amount': amount})
-    if form.is_valid() and form.refund():
+    if form.is_valid() and form.refund(request.user):
         amount = form.cleaned_data['amount']
         msg = pgettext_lazy(
             'Dashboard message related to a payment',
             'Refunded %(amount)s') % {
                 'amount': prices_i18n.amount(payment.get_captured_amount())}
-        order.events.create(
-            parameters={'amount': amount},
-            user=request.user,
-            type=OrderEvents.PAYMENT_REFUNDED.value)
+        payment_refunded_event(
+            order=order, user=request.user, amount=amount, payment=payment)
         messages.success(request, msg)
         return redirect('dashboard:order-details', order_pk=order.pk)
     status = 400 if form.errors else 200
@@ -221,11 +225,10 @@ def void_payment(request, order_pk, payment_pk):
     order = get_object_or_404(orders, pk=order_pk)
     payment = get_object_or_404(order.payments, pk=payment_pk)
     form = VoidPaymentForm(request.POST or None, payment=payment)
-    if form.is_valid() and form.void():
+    if form.is_valid() and form.void(request.user):
         msg = pgettext_lazy('Dashboard message', 'Voided payment')
-        order.events.create(
-            user=request.user,
-            type=OrderEvents.PAYMENT_VOIDED.value)
+        payment_voided_event(
+            order=order, user=request.user, payment=payment)
         messages.success(request, msg)
         return redirect('dashboard:order-details', order_pk=order.pk)
     status = 400 if form.errors else 200
@@ -252,7 +255,7 @@ def orderline_change_quantity(request, order_pk, line_pk):
                 'variant': line.variant, 'old_quantity': old_quantity,
                 'new_quantity': line.quantity}
         with transaction.atomic():
-            form.save()
+            form.save(request.user)
             messages.success(request, msg)
         return redirect('dashboard:order-details', order_pk=order.pk)
     elif form.errors:
@@ -274,7 +277,7 @@ def orderline_cancel(request, order_pk, line_pk):
             'Dashboard message related to an order line',
             'Canceled item %s') % line
         with transaction.atomic():
-            form.cancel_line()
+            form.cancel_line(request.user)
             messages.success(request, msg)
         return redirect('dashboard:order-details', order_pk=order.pk)
     elif form.errors:
@@ -301,7 +304,7 @@ def add_variant_to_order(request, order_pk):
             'variant': form.cleaned_data.get('variant')}
         try:
             with transaction.atomic():
-                form.save()
+                form.save(request.user)
             msg = pgettext_lazy(
                 'Dashboard message related to an order',
                 'Added %(quantity)d x %(variant)s') % msg_dict
@@ -344,9 +347,8 @@ def order_address(request, order_pk, address_type):
         if update_prices:
             update_order_prices(order, request.discounts)
         if not order.is_draft():
-            order.events.create(
-                user=request.user,
-                type=OrderEvents.UPDATED.value)
+            order_updated_address_event(
+                order=order, user=request.user, address=address)
         messages.success(request, success_msg)
         return redirect('dashboard:order-details', order_pk=order_pk)
     ctx = {'order': order, 'address_type': address_type, 'form': form}
@@ -476,25 +478,22 @@ def order_voucher_edit(request, order_pk):
 @staff_member_required
 @permission_required('order.manage_orders')
 def cancel_order(request, order_pk):
-    order = get_object_or_404(Order.objects.confirmed(), pk=order_pk)
+    orders = Order.objects.confirmed().prefetch_related('lines')
+    order = get_object_or_404(orders, pk=order_pk)
+
     status = 200
     form = CancelOrderForm(request.POST or None, order=order)
+
     if form.is_valid():
         msg = pgettext_lazy('Dashboard message', 'Order canceled')
         with transaction.atomic():
-            form.cancel_order()
-            if form.cleaned_data.get('restock'):
-                order.events.create(
-                    user=request.user,
-                    type=OrderEvents.UPDATED.value)
-            order.events.create(
-                user=request.user,
-                type=OrderEvents.CANCELED.value)
+            form.cancel_order(request.user)
         messages.success(request, msg)
         return redirect('dashboard:order-details', order_pk=order.pk)
         # TODO: send status confirmation email
     elif form.errors:
         status = 400
+
     ctx = {'form': form, 'order': order}
     return TemplateResponse(
         request, 'dashboard/order/modal/cancel_order.html', ctx,
@@ -539,9 +538,6 @@ def mark_order_as_paid(request, order_pk):
     if form.is_valid():
         with transaction.atomic():
             form.save()
-            order.events.create(
-                user=request.user,
-                type=OrderEvents.ORDER_MARKED_AS_PAID.value)
         msg = pgettext_lazy(
             'Dashboard message',
             'Order manually marked as paid')
@@ -598,12 +594,18 @@ def fulfill_order_lines(request, order_pk):
             if line_form.cleaned_data.get('quantity') > 0]
         if forms_to_save:
             fulfillment = form.save()
+            quantities = []
+            order_lines = []
             quantity_fulfilled = 0
             for line_form in forms_to_save:
                 line = line_form.save(commit=False)
                 line.fulfillment = fulfillment
                 line.save()
-                quantity_fulfilled += line_form.cleaned_data.get('quantity')
+
+                quantity = line_form.cleaned_data.get('quantity')
+                quantity_fulfilled += quantity
+                quantities.append(quantity)
+                order_lines.append(line)
             # update to refresh prefetched lines quantity_fulfilled
             order = orders.get(pk=order_pk)
             update_order_status(order)
@@ -613,18 +615,14 @@ def fulfill_order_lines(request, order_pk):
                 'Fulfilled %(quantity_fulfilled)d items',
                 number='quantity_fulfilled') % {
                     'quantity_fulfilled': quantity_fulfilled}
-            order.events.create(
-                parameters={'quantity': quantity_fulfilled},
-                user=request.user,
-                type=OrderEvents.FULFILLMENT_FULFILLED_ITEMS.value)
+
+            fulfillment_fulfilled_items_event(
+                order=order, user=request.user,
+                quantities=quantities, order_lines=order_lines)
+
             if form.cleaned_data.get('send_mail'):
-                send_fulfillment_confirmation.delay(order.pk, fulfillment.pk)
-                order.events.create(
-                    parameters={
-                        'email': order.get_user_current_email(),
-                        'email_type': OrderEventsEmails.SHIPPING.value},
-                    user=request.user,
-                    type=OrderEvents.EMAIL_SENT.value)
+                send_fulfillment_confirmation_to_customer(
+                    order, fulfillment, request.user)
         else:
             msg = pgettext_lazy(
                 'Dashboard message related to an order', 'No items fulfilled')
@@ -652,16 +650,7 @@ def cancel_fulfillment(request, order_pk, fulfillment_pk):
             'Dashboard message', 'Fulfillment #%(fulfillment)s canceled') % {
                 'fulfillment': fulfillment.composed_id}
         with transaction.atomic():
-            form.cancel_fulfillment()
-            if form.cleaned_data.get('restock'):
-                order.events.create(
-                    parameters={'quantity': fulfillment.get_total_quantity()},
-                    user=request.user,
-                    type=OrderEvents.FULFILLMENT_RESTOCKED_ITEMS.value)
-            order.events.create(
-                user=request.user,
-                parameters={'composed_id': fulfillment.composed_id},
-                type=OrderEvents.FULFILLMENT_CANCELED.value)
+            form.cancel_fulfillment(request.user)
         messages.success(request, msg)
         return redirect('dashboard:order-details', order_pk=order.pk)
     elif form.errors:
@@ -683,16 +672,16 @@ def change_fulfillment_tracking(request, order_pk, fulfillment_pk):
         request.POST or None, instance=fulfillment)
     if form.is_valid():
         form.save()
-        order.events.create(
-            user=request.user, type=OrderEvents.UPDATED.value)
+        fulfillment_tracking_updated_event(
+            order=order, user=request.user,
+            tracking_number=request.POST.get('tracking_number'),
+            fulfillment=fulfillment)
         if form.cleaned_data.get('send_mail'):
+            email_sent_event(
+                order=order, email_type=OrderEventsEmails.TRACKING_UPDATED,
+                user=request.user)
             send_fulfillment_update.delay(order.pk, fulfillment.pk)
-            order.events.create(
-                parameters={
-                    'email': order.get_user_current_email(),
-                    'email_type': OrderEventsEmails.SHIPPING.value},
-                user=request.user,
-                type=OrderEvents.EMAIL_SENT.value)
+
         msg = pgettext_lazy(
             'Dashboard message',
             'Fulfillment #%(fulfillment)s tracking number updated') % {
diff --git a/saleor/dashboard/templatetags/orders.py b/saleor/dashboard/templatetags/orders.py
new file mode 100644
index 00000000000..ea312a1cdc2
--- /dev/null
+++ b/saleor/dashboard/templatetags/orders.py
@@ -0,0 +1,151 @@
+from django.conf import settings
+from django.template import Library
+from django.utils.translation import npgettext_lazy, pgettext_lazy
+from django_prices.templatetags import prices_i18n
+from prices import Money
+
+from ...order.events import OrderEvents, OrderEventsEmails
+from ...order.models import OrderEvent
+
+register = Library()
+
+EMAIL_CHOICES = {
+    OrderEventsEmails.PAYMENT: pgettext_lazy(
+        'Email type', 'Payment confirmation'),
+    OrderEventsEmails.SHIPPING: pgettext_lazy(
+        'Email type', 'Shipping confirmation'),
+    OrderEventsEmails.FULFILLMENT: pgettext_lazy(
+        'Email type', 'Fulfillment confirmation'),
+    OrderEventsEmails.ORDER: pgettext_lazy(
+        'Email type', 'Order confirmation')}
+
+
+def get_money_from_params(amount):
+    """Money serialization changed at one point, as for now it's serialized
+    as a dict. But we keep those settings for the legacy data.
+
+    Can be safely removed after migrating to Dashboard 2.0
+    """
+    if isinstance(amount, Money):
+        return amount
+    if isinstance(amount, dict):
+        return Money(amount=amount['amount'], currency=amount['currency'])
+    return Money(amount, settings.DEFAULT_CURRENCY)
+
+
+@register.simple_tag
+def display_order_event(order_event: OrderEvent):
+    """This function is used to keep the backwards compatibility
+        with the old dashboard and new type of order events
+        (storing enums instead of messages)
+        """
+    event_type = order_event.type
+    params = order_event.parameters
+    if event_type == OrderEvents.PLACED_FROM_DRAFT:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'Order placed from draft order')
+    if event_type == OrderEvents.PAYMENT_VOIDED:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'Payment was voided by %(user_name)s' % {
+                'user_name': order_event.user})
+    if event_type == OrderEvents.PAYMENT_REFUNDED:
+        amount = get_money_from_params(params['amount'])
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'Successfully refunded: %(amount)s' % {
+                'amount': prices_i18n.amount(amount)})
+    if event_type == OrderEvents.PAYMENT_CAPTURED:
+        amount = get_money_from_params(params['amount'])
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'Successfully captured: %(amount)s' % {
+                'amount': prices_i18n.amount(amount)})
+    if event_type == OrderEvents.ORDER_MARKED_AS_PAID:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'Order manually marked as paid')
+    if event_type == OrderEvents.CANCELED:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'Order was canceled')
+    if event_type == OrderEvents.FULFILLMENT_RESTOCKED_ITEMS:
+        return npgettext_lazy(
+            'Dashboard message related to an order',
+            'We restocked %(quantity)d item',
+            'We restocked %(quantity)d items',
+            number='quantity') % {'quantity': params['quantity']}
+    if event_type == OrderEvents.NOTE_ADDED:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            '%(user_name)s added note: %(note)s' % {
+                'note': params['message'],
+                'user_name': order_event.user})
+    if event_type == OrderEvents.FULFILLMENT_CANCELED:
+        return pgettext_lazy(
+            'Dashboard message',
+            'Fulfillment #%(fulfillment)s canceled by %(user_name)s') % {
+                'fulfillment': params['composed_id'],
+                'user_name': order_event.user}
+    if event_type == OrderEvents.FULFILLMENT_FULFILLED_ITEMS:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'Fulfilled some items')
+    if event_type == OrderEvents.PLACED:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'Order was placed')
+    if event_type == OrderEvents.ORDER_FULLY_PAID:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'Order was fully paid')
+    if event_type == OrderEvents.EMAIL_SENT:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            '%(email_type)s email was sent to the customer '
+            '(%(email)s)') % {
+                'email_type': EMAIL_CHOICES[params['email_type']],
+                'email': params['email']}
+    if event_type == OrderEvents.TRACKING_UPDATED:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'Fulfillment #%(fulfillment)s tracking was updated to'
+            ' %(tracking_number)s by %(user_name)s') % {
+                'fulfillment': params['composed_id'],
+                'tracking_number': params['tracking_number'],
+                'user_name': order_event.user}
+    if event_type == OrderEvents.DRAFT_CREATED:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'The draft was created by %(user_name)s') % {
+                'user_name': order_event.user}
+    if event_type == OrderEvents.DRAFT_ADDED_PRODUCTS:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            '%(user_name)s added some products') % {
+                'user_name': order_event.user}
+    if event_type == OrderEvents.DRAFT_REMOVED_PRODUCTS:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            '%(user_name)s removed some products') % {
+                'user_name': order_event.user}
+    if event_type == OrderEvents.OVERSOLD_ITEMS:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            '%(user_name)s placed the order by bypassing oversold items') % {
+                'user_name': order_event.user}
+    if event_type == OrderEvents.UPDATED_ADDRESS:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'The order address was updated by %(user_name)s') % {
+                'user_name': order_event.user}
+    if event_type == OrderEvents.PAYMENT_FAILED:
+        return pgettext_lazy(
+            'Dashboard message related to an order',
+            'The payment was failed by %(user_name)s') % {
+                'user_name': order_event.user}
+
+    if event_type == OrderEvents.OTHER:
+        return order_event.parameters['message']
+    raise ValueError('Not supported event type: %s' % (event_type))
diff --git a/saleor/graphql/account/enums.py b/saleor/graphql/account/enums.py
index 46e513a33e1..ebf9aae0120 100644
--- a/saleor/graphql/account/enums.py
+++ b/saleor/graphql/account/enums.py
@@ -1,10 +1,9 @@
 import graphene
 
 from ...checkout import AddressType
+from ...graphql.core.enums import to_enum
 
-AddressTypeEnum = graphene.Enum(
-    'AddressTypeEnum',
-    [(code.upper(), code) for code, name in AddressType.CHOICES])
+AddressTypeEnum = to_enum(AddressType, type_name='AddressTypeEnum')
 
 
 class StaffMemberStatus(graphene.Enum):
diff --git a/saleor/graphql/checkout/mutations.py b/saleor/graphql/checkout/mutations.py
index a83ccf2d52a..d62bd4a6ad6 100644
--- a/saleor/graphql/checkout/mutations.py
+++ b/saleor/graphql/checkout/mutations.py
@@ -16,8 +16,6 @@
 from ...core.exceptions import InsufficientStock
 from ...core.utils.taxes import get_taxes_for_address
 from ...discount import models as voucher_model
-from ...order import OrderEvents, OrderEventsEmails
-from ...order.emails import send_order_confirmation
 from ...payment import PaymentError
 from ...payment.utils import gateway_process_payment
 from ...shipping.models import ShippingMethod as ShippingMethodModel
@@ -465,27 +463,22 @@ def perform_mutation(cls, _root, info, checkout_id):
         taxes = get_taxes_for_checkout(checkout, info.context.taxes)
         clean_checkout(checkout, taxes, info.context.discounts)
 
+        payment = checkout.get_last_active_payment()
+
         try:
+            # FIXME: we shouldn't create the order, but only do checks
             order = create_order(
                 checkout=checkout,
                 tracking_code=analytics.get_client_id(info.context),
-                discounts=info.context.discounts, taxes=taxes)
+                discounts=info.context.discounts,
+                taxes=get_taxes_for_checkout(checkout, info.context.taxes),
+                user=info.context.user)
         except InsufficientStock:
             raise ValidationError('Insufficient product stock.')
         except voucher_model.NotApplicable:
             raise ValidationError('Voucher not applicable')
 
-        payment = checkout.get_last_active_payment()
-
-        # remove checkout after checkout is created
-        checkout.delete()
-        order.events.create(type=OrderEvents.PLACED.value)
-        send_order_confirmation.delay(order.pk)
-        order.events.create(
-            type=OrderEvents.EMAIL_SENT.value,
-            parameters={
-                'email': order.get_user_current_email(),
-                'email_type': OrderEventsEmails.ORDER.value})
+        payment.order = order
 
         try:
             gateway_process_payment(
diff --git a/saleor/graphql/core/enums.py b/saleor/graphql/core/enums.py
index 15311f6820c..3547bce8aab 100644
--- a/saleor/graphql/core/enums.py
+++ b/saleor/graphql/core/enums.py
@@ -11,6 +11,35 @@ class ReportingPeriod(graphene.Enum):
     THIS_MONTH = 'THIS_MONTH'
 
 
+def to_enum(enum_cls, *, type_name=None, **options) -> graphene.Enum:
+    """
+    Create a graphene enum from a class containing a set of options.
+
+    :param enum_cls:
+        The class to build the enum from.
+    :param type_name:
+        The name of the type. Default is the class name + 'Enum'.
+    :param options:
+        - description:
+            Contains the type description (default is the class's docstring)
+        - deprecation_reason:
+            Contains the deprecation reason.
+            The default is enum_cls.__deprecation_reason__ or None.
+    :return:
+    """
+
+    # note this won't work until
+    # https://github.com/graphql-python/graphene/issues/956 is fixed
+    deprecation_reason = getattr(enum_cls, '__deprecation_reason__', None)
+    if deprecation_reason:
+        options.setdefault('deprecation_reason', deprecation_reason)
+
+    type_name = type_name or (enum_cls.__name__ + 'Enum')
+    enum_data = [(
+        str_to_enum(code.upper()), code) for code, name in enum_cls.CHOICES]
+    return graphene.Enum(type_name, enum_data, **options)
+
+
 TaxRateType = graphene.Enum(
     'TaxRateType',
     [(str_to_enum(rate[0]), rate[0]) for rate in CoreTaxRateType.CHOICES])
diff --git a/saleor/graphql/order/bulk_mutations/orders.py b/saleor/graphql/order/bulk_mutations/orders.py
index 64ca78cbb12..5c995002467 100644
--- a/saleor/graphql/order/bulk_mutations/orders.py
+++ b/saleor/graphql/order/bulk_mutations/orders.py
@@ -1,6 +1,8 @@
 import graphene
 
-from ....order import OrderEvents, models
+from ....order import models
+from ....order.events import (
+    fulfillment_restocked_items_event, order_canceled_event)
 from ....order.utils import cancel_order
 from ...core.mutations import BaseBulkMutation
 from ..mutations.orders import clean_order_cancel
@@ -33,12 +35,9 @@ def perform_mutation(cls, root, info, ids, **data):
     @classmethod
     def bulk_action(cls, queryset, user, restock):
         for order in queryset:
-            cancel_order(order=order, restock=restock)
+            cancel_order(user=user, order=order, restock=restock)
             if restock:
-                order.events.create(
-                    type=OrderEvents.FULFILLMENT_RESTOCKED_ITEMS.value,
-                    user=user,
-                    parameters={'quantity': order.get_total_quantity()})
-            else:
-                order.events.create(
-                    type=OrderEvents.CANCELED.value, user=user)
+                fulfillment_restocked_items_event(
+                    order=order, user=user, fulfillment=order)
+
+            order_canceled_event(order=order, user=user)
diff --git a/saleor/graphql/order/enums.py b/saleor/graphql/order/enums.py
index 0267806277b..ac2364337d7 100644
--- a/saleor/graphql/order/enums.py
+++ b/saleor/graphql/order/enums.py
@@ -1,9 +1,10 @@
 import graphene
 
+from ...graphql.core.enums import to_enum
 from ...order import OrderEvents, OrderEventsEmails
 
-OrderEventsEnum = graphene.Enum.from_enum(OrderEvents)
-OrderEventsEmailsEnum = graphene.Enum.from_enum(OrderEventsEmails)
+OrderEventsEnum = to_enum(OrderEvents)
+OrderEventsEmailsEnum = to_enum(OrderEventsEmails)
 
 
 class OrderStatusFilter(graphene.Enum):
diff --git a/saleor/graphql/order/mutations/draft_orders.py b/saleor/graphql/order/mutations/draft_orders.py
index 3f868c7d02f..f01eaa2543b 100644
--- a/saleor/graphql/order/mutations/draft_orders.py
+++ b/saleor/graphql/order/mutations/draft_orders.py
@@ -5,7 +5,11 @@
 from ....account.models import User
 from ....core.exceptions import InsufficientStock
 from ....core.utils.taxes import ZERO_TAXED_MONEY
-from ....order import OrderEvents, OrderStatus, models
+from ....order import OrderStatus, models
+from ....order.events import (
+    draft_order_added_products_event, draft_order_created_event,
+    draft_order_oversold_items_event, draft_order_removed_products_event,
+    order_created_event)
 from ....order.utils import (
     add_variant_to_order, allocate_stock, change_order_line_quantity,
     delete_order_line, recalculate_order)
@@ -100,8 +104,9 @@ def clean_input(cls, info, instance, data):
             cleaned_input['billing_address'] = billing_address
         return cleaned_input
 
-    @classmethod
-    def save(cls, info, instance, cleaned_input):
+    @staticmethod
+    def _save_addresses(instance, cleaned_input):
+        # Create the draft creation event
         shipping_address = cleaned_input.get('shipping_address')
         if shipping_address:
             shipping_address.save()
@@ -110,15 +115,46 @@ def save(cls, info, instance, cleaned_input):
         if billing_address:
             billing_address.save()
             instance.billing_address = billing_address.get_copy()
-        super().save(info, instance, cleaned_input)
-        instance.save(update_fields=['billing_address', 'shipping_address'])
-        variants = cleaned_input.get('variants')
-        quantities = cleaned_input.get('quantities')
+
+    @staticmethod
+    def _save_lines(info, instance, quantities, variants):
         if variants and quantities:
+            lines = []
             for variant, quantity in zip(variants, quantities):
+                lines.append((quantity, variant))
                 add_variant_to_order(
                     instance, variant, quantity, allow_overselling=True,
                     track_inventory=False)
+
+            # New event
+            draft_order_added_products_event(
+                order=instance, user=info.context.user, order_lines=lines)
+
+    @classmethod
+    def _commit_changes(cls, info, instance, cleaned_input):
+        created = instance.pk
+        super().save(info, instance, cleaned_input)
+
+        # Create draft created event if the instance is from scratch
+        if not created:
+            draft_order_created_event(order=instance, user=info.context.user)
+
+        instance.save(update_fields=['billing_address', 'shipping_address'])
+
+    @classmethod
+    def save(cls, info, instance, cleaned_input):
+        # Process addresses
+        cls._save_addresses(instance, cleaned_input)
+
+        # Save any changes create/update the draft
+        cls._commit_changes(info, instance, cleaned_input)
+
+        # Process any lines to add
+        cls._save_lines(
+            info, instance,
+            cleaned_input.get('quantities'), cleaned_input.get('variants'))
+
+        # Post-process the results
         recalculate_order(instance)
 
 
@@ -192,15 +228,15 @@ def perform_mutation(cls, _root, info, id):
             except InsufficientStock:
                 allocate_stock(line.variant, line.variant.quantity_available)
                 oversold_items.append(str(line))
+
+        order_created_event(
+            order=order, user=info.context.user, from_draft=True)
+
         if oversold_items:
-            order.events.create(
-                type=OrderEvents.OVERSOLD_ITEMS.value,
-                user=info.context.user,
-                parameters={'oversold_items': oversold_items})
-
-        order.events.create(
-            type=OrderEvents.PLACED_FROM_DRAFT.value,
-            user=info.context.user)
+            draft_order_oversold_items_event(
+                order=order, user=info.context.user,
+                oversold_items=oversold_items)
+
         return DraftOrderComplete(order=order)
 
 
@@ -228,7 +264,7 @@ def perform_mutation(cls, _root, info, **data):
         if order.status != OrderStatus.DRAFT:
             raise ValidationError({'id': 'Only draft orders can be edited.'})
 
-        lines = []
+        lines_to_add = []
         for input_line in data.get('input'):
             variant_id = input_line['variant_id']
             variant = cls.get_node_or_error(
@@ -236,14 +272,22 @@ def perform_mutation(cls, _root, info, **data):
             quantity = input_line['quantity']
             if quantity > 0:
                 if variant:
-                    line = add_variant_to_order(
-                        order, variant, quantity, allow_overselling=True)
-                    lines.append(line)
+                    lines_to_add.append((quantity, variant))
             else:
                 raise ValidationError({
                     'quantity':
                     'Ensure this value is greater than or equal to 1.'})
 
+        # Add the lines
+        lines = [
+            add_variant_to_order(
+                order, variant, quantity, allow_overselling=True)
+            for quantity, variant in lines_to_add]
+
+        # Create the event
+        draft_order_added_products_event(
+            order=order, user=info.context.user, order_lines=lines_to_add)
+
         recalculate_order(order)
         return DraftOrderLinesCreate(order=order, order_lines=lines)
 
@@ -271,6 +315,12 @@ def perform_mutation(cls, _root, info, id):
         db_id = line.id
         delete_order_line(line)
         line.id = db_id
+
+        # Create the removal event
+        draft_order_removed_products_event(
+            order=order, user=info.context.user,
+            order_lines=[(line.quantity, line)])
+
         recalculate_order(order)
         return DraftOrderLineDelete(order=order, order_line=line)
 
@@ -292,6 +342,7 @@ class Meta:
 
     @classmethod
     def clean_input(cls, info, instance, data):
+        instance.old_quantity = instance.quantity
         cleaned_input = super().clean_input(info, instance, data)
         if instance.order.status != OrderStatus.DRAFT:
             raise ValidationError({'id': 'Only draft orders can be edited.'})
@@ -305,7 +356,9 @@ def clean_input(cls, info, instance, data):
 
     @classmethod
     def save(cls, info, instance, cleaned_input):
-        change_order_line_quantity(instance, instance.quantity)
+        change_order_line_quantity(
+            info.context.user, instance,
+            instance.old_quantity, instance.quantity)
         recalculate_order(instance.order)
 
     @classmethod
diff --git a/saleor/graphql/order/mutations/fulfillments.py b/saleor/graphql/order/mutations/fulfillments.py
index d49dbf44b74..ef1f573ba13 100644
--- a/saleor/graphql/order/mutations/fulfillments.py
+++ b/saleor/graphql/order/mutations/fulfillments.py
@@ -2,8 +2,10 @@
 from django.core.exceptions import ValidationError
 from django.utils.translation import npgettext_lazy, pgettext_lazy
 
-from ....order import OrderEvents, OrderEventsEmails, models
-from ....order.emails import send_fulfillment_confirmation
+from ....order import models
+from ....order.emails import send_fulfillment_confirmation_to_customer
+from ....order.events import (
+    fulfillment_fulfilled_items_event, fulfillment_tracking_updated_event)
 from ....order.utils import (
     cancel_fulfillment, fulfill_order_line, update_order_status)
 from ...core.mutations import BaseMutation
@@ -11,15 +13,6 @@
 from ..types import OrderLine
 
 
-def send_fulfillment_confirmation_to_customer(order, fulfillment, user):
-    send_fulfillment_confirmation.delay(order.pk, fulfillment.pk)
-    order.events.create(
-        parameters={
-            'email': order.get_user_current_email(),
-            'email_type': OrderEventsEmails.FULFILLMENT.value},
-        type=OrderEvents.EMAIL_SENT.value, user=user)
-
-
 class FulfillmentLineInput(graphene.InputObjectType):
     order_line_id = graphene.ID(
         description='The ID of the order line.', name='orderLineId')
@@ -113,13 +106,13 @@ def save(cls, user, fulfillment, order, cleaned_input):
 
         fulfillment.lines.bulk_create(fulfillment_lines)
         update_order_status(order)
-        order.events.create(
-            parameters={'quantity': sum(quantities)},
-            type=OrderEvents.FULFILLMENT_FULFILLED_ITEMS.value,
-            user=user)
+        fulfillment_fulfilled_items_event(
+            order=order, user=user,
+            quantities=quantities, order_lines=order_lines)
+
         if cleaned_input.get('notify_customer', True):
-            send_fulfillment_confirmation_to_customer(
-                order, fulfillment, user)
+            send_fulfillment_confirmation_to_customer(order, fulfillment, user)
+
         return fulfillment
 
     @classmethod
@@ -162,12 +155,9 @@ def perform_mutation(cls, _root, info, **data):
         fulfillment.tracking_number = tracking_number
         fulfillment.save()
         order = fulfillment.order
-        order.events.create(
-            parameters={
-                'tracking_number': tracking_number,
-                'fulfillment': fulfillment.composed_id},
-            type=OrderEvents.TRACKING_UPDATED.value,
-            user=info.context.user)
+        fulfillment_tracking_updated_event(
+            order=order, user=info.context.user,
+            tracking_number=tracking_number, fulfillment=fulfillment)
         return FulfillmentUpdateTracking(fulfillment=fulfillment, order=order)
 
 
@@ -202,17 +192,5 @@ def perform_mutation(cls, _root, info, **data):
             raise ValidationError({'fulfillment': err_msg})
 
         order = fulfillment.order
-        cancel_fulfillment(fulfillment, restock)
-
-        # FIXME: move event creation to `cancel_fulfillment`
-        if restock:
-            order.events.create(
-                parameters={'quantity': fulfillment.get_total_quantity()},
-                type=OrderEvents.FULFILLMENT_RESTOCKED_ITEMS.value,
-                user=info.context.user)
-        elif order:
-            order.events.create(
-                parameters={'composed_id': fulfillment.composed_id},
-                type=OrderEvents.FULFILLMENT_CANCELED.value,
-                user=info.context.user)
+        cancel_fulfillment(info.context.user, fulfillment, restock)
         return FulfillmentCancel(fulfillment=fulfillment, order=order)
diff --git a/saleor/graphql/order/mutations/orders.py b/saleor/graphql/order/mutations/orders.py
index 4325286755f..1b70257e4b7 100644
--- a/saleor/graphql/order/mutations/orders.py
+++ b/saleor/graphql/order/mutations/orders.py
@@ -3,7 +3,10 @@
 
 from ....account.models import User
 from ....core.utils.taxes import ZERO_TAXED_MONEY
-from ....order import OrderEvents, models
+from ....order import models
+from ....order.events import (
+    order_note_added_event, payment_captured_event, payment_failed_event,
+    payment_refunded_event, payment_voided_event)
 from ....order.utils import cancel_order
 from ....payment import CustomPaymentChoices, PaymentError
 from ....payment.utils import (
@@ -41,10 +44,14 @@ def clean_order_cancel(order):
         raise ValidationError({'order': 'This order can\'t be canceled.'})
 
 
-def clean_order_capture(payment):
+def clean_payment(payment):
     if not payment:
         raise ValidationError({
             'payment': 'There\'s no payment associated with the order.'})
+
+
+def clean_order_capture(payment):
+    clean_payment(payment)
     if not payment.is_active:
         raise ValidationError({
             'payment': 'Only pre-authorized payments can be captured'})
@@ -52,17 +59,30 @@ def clean_order_capture(payment):
 
 def clean_void_payment(payment):
     """Check for payment errors."""
+    clean_payment(payment)
     if not payment.is_active:
         raise ValidationError({
             'payment': 'Only pre-authorized payments can be voided'})
 
 
-def clean_refund_payment(payment, amount):
+def clean_refund_payment(payment):
+    clean_payment(payment)
     if payment.gateway == CustomPaymentChoices.MANUAL:
         raise ValidationError({
             'payment': 'Manual payments can not be refunded.'})
 
 
+def try_payment_action(order, user, payment, func, *args, **kwargs):
+    try:
+        func(*args, **kwargs)
+    except (PaymentError, ValueError) as e:
+        message = str(e)
+        payment_failed_event(
+            order=order, user=user, message=message, payment=payment)
+        raise ValidationError({'payment': message})
+    return True
+
+
 class OrderUpdateInput(graphene.InputObjectType):
     billing_address = AddressInput(
         description='Billing address of the customer.')
@@ -176,11 +196,9 @@ class Meta:
     def perform_mutation(cls, _root, info, **data):
         order = cls.get_node_or_error(
             info, data.get('id'), only_type=Order)
-        event = order.events.create(
-            type=OrderEvents.NOTE_ADDED.value,
-            user=info.context.user,
-            parameters={
-                'message': data.get('input')['message']})
+        event = order_note_added_event(
+            order=order, user=info.context.user,
+            message=data.get('input')['message'])
         return OrderAddNote(order=order, event=event)
 
 
@@ -203,15 +221,7 @@ def perform_mutation(cls, _root, info, restock, **data):
         order = cls.get_node_or_error(
             info, data.get('id'), only_type=Order)
         clean_order_cancel(order)
-        cancel_order(order=order, restock=restock)
-        if restock:
-            order.events.create(
-                type=OrderEvents.FULFILLMENT_RESTOCKED_ITEMS.value,
-                user=info.context.user,
-                parameters={'quantity': order.get_total_quantity()})
-        else:
-            order.events.create(
-                type=OrderEvents.CANCELED.value, user=info.context.user)
+        cancel_order(user=info.context.user, order=order, restock=restock)
         return OrderCancel(order=order)
 
 
@@ -230,10 +240,10 @@ class Meta:
     def perform_mutation(cls, _root, info, **data):
         order = cls.get_node_or_error(
             info, data.get('id'), only_type=Order)
-        try:
-            clean_mark_order_as_paid(order)
-        except PaymentError as e:
-            raise ValidationError({'payment': str(e)})
+
+        try_payment_action(
+            order, info.context.user, None, clean_mark_order_as_paid, order)
+
         mark_order_as_paid(order, info.context.user)
         return OrderMarkAsPaid(order=order)
 
@@ -262,15 +272,13 @@ def perform_mutation(cls, _root, info, amount, **data):
         payment = order.get_last_payment()
         clean_order_capture(payment)
 
-        try:
-            gateway_capture(payment, amount)
-        except PaymentError as e:
-            raise ValidationError({'payment': str(e)})
+        try_payment_action(
+            order, info.context.user, payment,
+            gateway_capture, payment, amount)
 
-        order.events.create(
-            parameters={'amount': amount},
-            type=OrderEvents.PAYMENT_CAPTURED.value,
-            user=info.context.user)
+        payment_captured_event(
+            order=order, user=info.context.user,
+            amount=amount, payment=payment)
         return OrderCapture(order=order)
 
 
@@ -291,13 +299,12 @@ def perform_mutation(cls, _root, info, **data):
             info, data.get('id'), only_type=Order)
         payment = order.get_last_payment()
         clean_void_payment(payment)
-        try:
-            gateway_void(payment)
-        except (PaymentError, ValueError) as e:
-            raise ValidationError({'payment': str(e)})
-        order.events.create(
-            type=OrderEvents.PAYMENT_VOIDED.value,
-            user=info.context.user)
+
+        try_payment_action(
+            order, info.context.user, payment, gateway_void, payment)
+
+        payment_voided_event(
+            order=order, user=info.context.user, payment=payment)
         return OrderVoid(order=order)
 
 
@@ -323,15 +330,12 @@ def perform_mutation(cls, _root, info, amount, **data):
         order = cls.get_node_or_error(
             info, data.get('id'), only_type=Order)
         payment = order.get_last_payment()
-        clean_refund_payment(payment, amount)
+        clean_refund_payment(payment)
 
-        try:
-            gateway_refund(payment, amount)
-        except PaymentError as e:
-            raise ValidationError({'payment': str(e)})
+        try_payment_action(
+            order, info.context.user, payment, gateway_refund, payment, amount)
 
-        order.events.create(
-            type=OrderEvents.PAYMENT_REFUNDED.value,
-            user=info.context.user,
-            parameters={'amount': amount})
+        payment_refunded_event(
+            order=order, user=info.context.user,
+            amount=amount, payment=payment)
         return OrderRefund(order=order)
diff --git a/saleor/graphql/order/resolvers.py b/saleor/graphql/order/resolvers.py
index afb4c0e0f31..9228f0ec2e7 100644
--- a/saleor/graphql/order/resolvers.py
+++ b/saleor/graphql/order/resolvers.py
@@ -1,7 +1,9 @@
 import graphene
 import graphene_django_optimizer as gql_optimizer
 
-from ...order import OrderEvents, OrderStatus, models
+from ...order import OrderStatus, models
+from ...order.events import OrderEvents
+from ...order.models import OrderEvent
 from ...order.utils import sum_order_totals
 from ..utils import filter_by_period, filter_by_query_param
 from .enums import OrderStatusFilter
@@ -56,9 +58,9 @@ def resolve_order(info, order_id):
 def resolve_homepage_events():
     # Filter only selected events to be displayed on homepage.
     types = [
-        OrderEvents.PLACED.value, OrderEvents.PLACED_FROM_DRAFT.value,
-        OrderEvents.ORDER_FULLY_PAID.value]
-    return models.OrderEvent.objects.filter(type__in=types)
+        OrderEvents.PLACED, OrderEvents.PLACED_FROM_DRAFT,
+        OrderEvents.ORDER_FULLY_PAID]
+    return OrderEvent.objects.filter(type__in=types)
 
 
 def resolve_order_by_token(token):
diff --git a/saleor/graphql/order/types.py b/saleor/graphql/order/types.py
index a7f7f54a96f..78030154b66 100644
--- a/saleor/graphql/order/types.py
+++ b/saleor/graphql/order/types.py
@@ -17,6 +17,11 @@
 from .utils import applicable_shipping_methods, validate_draft_order
 
 
+class OrderEventLineObject(graphene.ObjectType):
+    quantity = graphene.Int(description='The variant quantity.')
+    item = graphene.String(description='The variant name.')
+
+
 class OrderEvent(CountableDjangoObjectType):
     date = graphene.types.datetime.DateTime(
         description='Date when event happened at in ISO 8601 format.')
@@ -25,11 +30,15 @@ class OrderEvent(CountableDjangoObjectType):
         User, id=graphene.Argument(graphene.ID),
         description='User who performed the action.')
     message = graphene.String(
-        description='Content of a note added to the order.')
+        description='Content of the event.')
     email = graphene.String(description='Email of the customer')
     email_type = OrderEventsEmailsEnum(
         description='Type of an email sent to the customer')
     amount = graphene.Float(description='Amount of money.')
+    payment_id = graphene.String(
+        description='The payment ID from the payment gateway')
+    payment_gateway = graphene.String(
+        description='The payment gateway of the payment.')
     quantity = graphene.Int(description='Number of items.')
     composed_id = graphene.String(
         description='Composed id of the Fulfillment.')
@@ -37,6 +46,10 @@ class OrderEvent(CountableDjangoObjectType):
         description='User-friendly number of an order.')
     oversold_items = graphene.List(
         graphene.String, description='List of oversold lines names.')
+    lines = graphene.List(
+        OrderEventLineObject, description='The concerned lines.')
+    fulfilled_items = graphene.List(
+        OrderEventLineObject, description='The lines fulfilled.')
 
     class Meta:
         description = 'History log of the order.'
@@ -54,6 +67,12 @@ def resolve_amount(self, _info):
         amount = self.parameters.get('amount', None)
         return float(amount) if amount else None
 
+    def resolve_payment_id(self, _info):
+        return self.parameters.get('payment_id', None)
+
+    def resolve_payment_gateway(self, _info):
+        return self.parameters.get('payment_gateway', None)
+
     def resolve_quantity(self, _info):
         quantity = self.parameters.get('quantity', None)
         return int(quantity) if quantity else None
@@ -70,6 +89,12 @@ def resolve_oversold_items(self, _info):
     def resolve_order_number(self, _info):
         return self.order_id
 
+    def resolve_lines(self, _info):
+        lines = self.parameters.get('lines', None)
+        return [
+            OrderEventLineObject(**line) for line in lines
+        ] if lines else None
+
 
 class FulfillmentLine(CountableDjangoObjectType):
     order_line = graphene.Field(lambda: OrderLine)
diff --git a/saleor/graphql/payment/enums.py b/saleor/graphql/payment/enums.py
index 010568a7e83..f8e5a8c0af6 100644
--- a/saleor/graphql/payment/enums.py
+++ b/saleor/graphql/payment/enums.py
@@ -1,11 +1,10 @@
 import graphene
 
+from ...graphql.core.enums import to_enum
 from ...payment import GATEWAYS_ENUM, ChargeStatus
-from ..core.utils import str_to_enum
 
-PaymentChargeStatusEnum = graphene.Enum(
-    'PaymentChargeStatusEnum',
-    [(str_to_enum(code.upper()), code) for code, name in ChargeStatus.CHOICES])
+PaymentChargeStatusEnum = to_enum(
+    ChargeStatus, type_name='PaymentChargeStatusEnum')
 PaymentGatewayEnum = graphene.Enum.from_enum(GATEWAYS_ENUM)
 
 
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index f1f8f4abb08..3c28cdb35b9 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -1410,16 +1410,20 @@ input OrderDraftFilterInput {
 type OrderEvent implements Node {
   id: ID!
   date: DateTime
-  type: OrderEvents
+  type: OrderEventsEnum
   user(id: ID): User
   message: String
   email: String
-  emailType: OrderEventsEmails
+  emailType: OrderEventsEmailsEnum
   amount: Float
+  paymentId: String
+  paymentGateway: String
   quantity: Int
   composedId: String
   orderNumber: String
   oversoldItems: [String]
+  lines: [OrderEventLineObject]
+  fulfilledItems: [OrderEventLineObject]
 }
 
 type OrderEventCountableConnection {
@@ -1433,18 +1437,36 @@ type OrderEventCountableEdge {
   cursor: String!
 }
 
-enum OrderEvents {
+type OrderEventLineObject {
+  quantity: Int
+  item: String
+}
+
+enum OrderEventsEmailsEnum {
+  PAYMENT_CONFIRMATION
+  SHIPPING_CONFIRMATION
+  TRACKING_UPDATED
+  ORDER_CONFIRMATION
+  FULFILLMENT_CONFIRMATION
+  DIGITAL_LINKS
+}
+
+enum OrderEventsEnum {
+  DRAFT_CREATED
+  DRAFT_ADDED_PRODUCTS
+  DRAFT_REMOVED_PRODUCTS
   PLACED
   PLACED_FROM_DRAFT
   OVERSOLD_ITEMS
-  ORDER_MARKED_AS_PAID
   CANCELED
+  ORDER_MARKED_AS_PAID
   ORDER_FULLY_PAID
-  UPDATED
+  UPDATED_ADDRESS
   EMAIL_SENT
   PAYMENT_CAPTURED
   PAYMENT_REFUNDED
   PAYMENT_VOIDED
+  PAYMENT_FAILED
   FULFILLMENT_CANCELED
   FULFILLMENT_RESTOCKED_ITEMS
   FULFILLMENT_FULFILLED_ITEMS
@@ -1453,13 +1475,6 @@ enum OrderEvents {
   OTHER
 }
 
-enum OrderEventsEmails {
-  PAYMENT
-  SHIPPING
-  ORDER
-  FULFILLMENT
-}
-
 input OrderFilterInput {
   paymentStatus: PaymentChargeStatusEnum
   status: OrderStatusFilter
diff --git a/saleor/graphql/shipping/enums.py b/saleor/graphql/shipping/enums.py
index 6e3e697e7e2..119b9d1e7b8 100644
--- a/saleor/graphql/shipping/enums.py
+++ b/saleor/graphql/shipping/enums.py
@@ -1,7 +1,5 @@
-import graphene
-
+from ...graphql.core.enums import to_enum
 from ...shipping import ShippingMethodType
 
-ShippingMethodTypeEnum = graphene.Enum(
-    'ShippingMethodTypeEnum',
-    [(code.upper(), code) for code, name in ShippingMethodType.CHOICES])
+ShippingMethodTypeEnum = to_enum(
+    ShippingMethodType, type_name='ShippingMethodTypeEnum')
diff --git a/saleor/order/__init__.py b/saleor/order/__init__.py
index 554baf467da..dcddb9ccfc7 100644
--- a/saleor/order/__init__.py
+++ b/saleor/order/__init__.py
@@ -1,9 +1,4 @@
-from enum import Enum
-
-from django.conf import settings
-from django.utils.translation import npgettext_lazy, pgettext_lazy
-from django_prices.templatetags import prices_i18n
-from prices import Money
+from django.utils.translation import pgettext_lazy
 
 
 class OrderStatus:
@@ -46,162 +41,148 @@ class FulfillmentStatus:
             'Canceled'))]
 
 
-class OrderEvents(Enum):
+class OrderEvents:
+    """The different order event types."""
+
+    DRAFT_CREATED = 'draft_created'
+    DRAFT_ADDED_PRODUCTS = 'draft_added_products'
+    DRAFT_REMOVED_PRODUCTS = 'draft_removed_products'
+
     PLACED = 'placed'
-    PLACED_FROM_DRAFT = 'draft_placed'
+    PLACED_FROM_DRAFT = 'placed_from_draft'
+
     OVERSOLD_ITEMS = 'oversold_items'
-    ORDER_MARKED_AS_PAID = 'marked_as_paid'
     CANCELED = 'canceled'
-    ORDER_FULLY_PAID = 'order_paid'
-    UPDATED = 'updated'
+
+    ORDER_MARKED_AS_PAID = 'order_marked_as_paid'
+    ORDER_FULLY_PAID = 'order_fully_paid'
+
+    UPDATED_ADDRESS = 'updated_address'
 
     EMAIL_SENT = 'email_sent'
 
-    PAYMENT_CAPTURED = 'captured'
-    PAYMENT_REFUNDED = 'refunded'
-    PAYMENT_VOIDED = 'voided'
+    PAYMENT_CAPTURED = 'payment_captured'
+    PAYMENT_REFUNDED = 'payment_refunded'
+    PAYMENT_VOIDED = 'payment_voided'
+    PAYMENT_FAILED = 'payment_failed'
 
     FULFILLMENT_CANCELED = 'fulfillment_canceled'
-    FULFILLMENT_RESTOCKED_ITEMS = 'restocked_items'
-    FULFILLMENT_FULFILLED_ITEMS = 'fulfilled_items'
+    FULFILLMENT_RESTOCKED_ITEMS = 'fulfillment_restocked_items'
+    FULFILLMENT_FULFILLED_ITEMS = 'fulfillment_fulfilled_items'
     TRACKING_UPDATED = 'tracking_updated'
     NOTE_ADDED = 'note_added'
 
     # Used mostly for importing legacy data from before Enum-based events
     OTHER = 'other'
 
+    CHOICES = [
+        (DRAFT_CREATED, pgettext_lazy(
+            'Event from a staff user that created a draft order',
+            'The draft order was created')),
+        (DRAFT_ADDED_PRODUCTS, pgettext_lazy(
+            'Event from a staff user that added products to a draft order',
+            'Some products were added to the draft order')),
+        (DRAFT_REMOVED_PRODUCTS, pgettext_lazy(
+            'Event from a staff user that removed products from a draft order',
+            'Some products were removed from the draft order')),
+
+        (PLACED, pgettext_lazy(
+            'Event from a user or anonymous user that placed their order',
+            'The order was placed')),
+        (PLACED_FROM_DRAFT, pgettext_lazy(
+            'Event from a staff user that placed a draft order',
+            'The draft order was placed')),
+
+        (OVERSOLD_ITEMS, pgettext_lazy(
+            'Event from a staff user that placed a draft order by passing '
+            'oversold items',
+            'The draft order was placed with oversold items')),
+        (CANCELED, pgettext_lazy(
+            'Event from a staff user that canceled an order',
+            'The order was canceled')),
+
+        (ORDER_MARKED_AS_PAID, pgettext_lazy(
+            'Event from a staff user that manually marked an order as '
+            'fully paid',
+            'The order was manually marked as fully paid')),
+        (ORDER_FULLY_PAID, pgettext_lazy(
+            'Event from a payment that made the order to be fully paid',
+            'The order was fully paid')),
+
+        (UPDATED_ADDRESS, pgettext_lazy(
+            'Event from a staff user that updated an address of a '
+            'placed order',
+            'The address from the placed order was updated')),
+
+        (EMAIL_SENT, pgettext_lazy(
+            'Event generated from a user action that led to a '
+            'email being sent',
+            'The email was sent')),
+
+        (PAYMENT_CAPTURED, pgettext_lazy(
+            'Event from a user payment that successfully captured a '
+            'given amount of money',
+            'The payment was captured')),
+        (PAYMENT_REFUNDED, pgettext_lazy(
+            'Event from a staff user that successfully refunded a payment',
+            'The payment was refunded')),
+        (PAYMENT_VOIDED, pgettext_lazy(
+            'Event from a staff user that successfully voided an '
+            'authorized payment',
+            'The payment was voided')),
+        (PAYMENT_FAILED, pgettext_lazy(
+            'Event from a user that generated an unsuccessful payment',
+            'The payment was failed')),
+
+        (FULFILLMENT_CANCELED, pgettext_lazy(
+            'Event from a staff user that canceled a fulfillment',
+            'A fulfillment was canceled')),
+        (FULFILLMENT_RESTOCKED_ITEMS, pgettext_lazy(
+            'Event from a staff user that restocked the items that were used '
+            'for a fulfillment',
+            'The items of the fulfillment were restocked')),
+        (FULFILLMENT_FULFILLED_ITEMS, pgettext_lazy(
+            'Event from a staff user that fulfilled some items',
+            'Some items were fulfilled')),
+        (TRACKING_UPDATED, pgettext_lazy(
+            'Event from a staff user that updated the tracking code of an '
+            'existing fulfillment',
+            'The fulfillment\'s tracking code was updated')),
+        (NOTE_ADDED, pgettext_lazy(
+            'Event from an user that added a note to an order',
+            'A note was added to the order')),
+
+        (OTHER, pgettext_lazy(
+            'An other type of order event containing a message',
+            'An unknown order event containing a message'))]
+
+
+class OrderEventsEmails:
+    """The different order emails event types."""
 
-class OrderEventsEmails(Enum):
     PAYMENT = 'payment_confirmation'
     SHIPPING = 'shipping_confirmation'
+    TRACKING_UPDATED = 'tracking_updated'
     ORDER = 'order_confirmation'
     FULFILLMENT = 'fulfillment_confirmation'
+    DIGITAL_LINKS = 'digital_links'
 
-
-EMAIL_CHOICES = {
-    OrderEventsEmails.PAYMENT.value: pgettext_lazy(
-        'Email type', 'Payment confirmation'),
-    OrderEventsEmails.SHIPPING.value: pgettext_lazy(
-        'Email type', 'Shipping confirmation'),
-    OrderEventsEmails.FULFILLMENT.value: pgettext_lazy(
-        'Email type', 'Fulfillment confirmation'),
-    OrderEventsEmails.ORDER.value: pgettext_lazy(
-        'Email type', 'Order confirmation')}
-
-
-def get_money_from_params(amount):
-    """Money serialization changed at one point, as for now it's serialized
-    as a dict. But we keep those settings for the legacy data.
-
-    Can be safely removed after migrating to Dashboard 2.0
-    """
-    if isinstance(amount, Money):
-        return amount
-    if isinstance(amount, dict):
-        return Money(amount=amount['amount'], currency=amount['currency'])
-    return Money(amount, settings.DEFAULT_CURRENCY)
-
-
-def display_order_event(order_event):
-    """This function is used to keep the  backwards compatibility
-    with the old dashboard and new type of order events
-    (storing enums instead of messages)
-    """
-    event_type = order_event.type
-    params = order_event.parameters
-    if event_type == OrderEvents.PLACED_FROM_DRAFT.value:
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            'Order created from draft order by %(user_name)s' % {
-                'user_name': order_event.user})
-    if event_type == OrderEvents.PAYMENT_VOIDED.value:
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            'Payment was voided by %(user_name)s' % {
-                'user_name': order_event.user})
-    if event_type == OrderEvents.PAYMENT_REFUNDED.value:
-        amount = get_money_from_params(params['amount'])
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            'Successfully refunded: %(amount)s' % {
-                'amount': prices_i18n.amount(amount)})
-    if event_type == OrderEvents.PAYMENT_CAPTURED.value:
-        amount = get_money_from_params(params['amount'])
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            'Successfully captured: %(amount)s' % {
-                'amount': prices_i18n.amount(amount)})
-    if event_type == OrderEvents.ORDER_MARKED_AS_PAID.value:
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            'Order manually marked as paid by %(user_name)s' % {
-                'user_name': order_event.user})
-    if event_type == OrderEvents.CANCELED.value:
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            'Order was canceled by %(user_name)s' % {
-                'user_name': order_event.user})
-    if event_type == OrderEvents.FULFILLMENT_RESTOCKED_ITEMS.value:
-        return npgettext_lazy(
-            'Dashboard message related to an order',
-            'We restocked %(quantity)d item',
-            'We restocked %(quantity)d items',
-            number='quantity') % {'quantity': params['quantity']}
-    if event_type == OrderEvents.NOTE_ADDED.value:
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            '%(user_name)s added note: %(note)s' % {
-                'note': params['message'],
-                'user_name': order_event.user})
-    if event_type == OrderEvents.FULFILLMENT_CANCELED.value:
-        return pgettext_lazy(
-            'Dashboard message',
-            'Fulfillment #%(fulfillment)s canceled by %(user_name)s') % {
-                'fulfillment': params['composed_id'],
-                'user_name': order_event.user}
-    if event_type == OrderEvents.FULFILLMENT_FULFILLED_ITEMS.value:
-        return npgettext_lazy(
-            'Dashboard message related to an order',
-            'Fulfilled %(quantity_fulfilled)d item',
-            'Fulfilled %(quantity_fulfilled)d items',
-            number='quantity_fulfilled') % {
-                'quantity_fulfilled': params['quantity']}
-    if event_type == OrderEvents.PLACED.value:
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            'Order was placed')
-    if event_type == OrderEvents.ORDER_FULLY_PAID.value:
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            'Order was fully paid')
-    if event_type == OrderEvents.EMAIL_SENT.value:
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            '%(email_type)s email was sent to the customer '
-            '(%(email)s)') % {
-                'email_type': EMAIL_CHOICES[params['email_type']],
-                'email': params['email']}
-    if event_type == OrderEvents.UPDATED.value:
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            'Order details were updated by %(user_name)s' % {
-                'user_name': order_event.user})
-    if event_type == OrderEvents.TRACKING_UPDATED.value:
-        return pgettext_lazy(
-            'Dashboard message related to an order',
-            'Fulfillment #%(fulfillment)s tracking was updated to'
-            ' %(tracking_number)s by %(user_name)s') % {
-                'fulfillment': params['composed_id'],
-                'tracking_number': params['tracking_number'],
-                'user_name': order_event.user}
-    if event_type == OrderEvents.OVERSOLD_ITEMS.value:
-        return npgettext_lazy(
-            'Dashboard message related to an order',
-            '%(quantity)d line item oversold on this order.',
-            '%(quantity)d line items oversold on this order.',
-            number='quantity') % {
-                'quantity': len(params['oversold_items'])}
-
-    if event_type == OrderEvents.OTHER.value:
-        return order_event.parameters['message']
-    raise ValueError('Not supported event type: %s' % (event_type))
+    CHOICES = [
+        (PAYMENT, pgettext_lazy(
+            'A payment confirmation email was sent',
+            'The payment confirmation email was sent')),
+        (SHIPPING, pgettext_lazy(
+            'A shipping confirmation email was sent',
+            'The shipping confirmation email was sent')),
+        (TRACKING_UPDATED, pgettext_lazy(
+            'A tracking code update confirmation email was sent',
+            'The fulfillment tracking code email was sent')),
+        (ORDER, pgettext_lazy(
+            'A order confirmation email was sent',
+            'The order placement confirmation email was sent')),
+        (FULFILLMENT, pgettext_lazy(
+            'A fulfillment confirmation email was sent',
+            'The fulfillment confirmation email was sent')),
+        (DIGITAL_LINKS, pgettext_lazy(
+            'An email containing a or some digital link was sent',
+            'The email containing the digital links was sent'))]
diff --git a/saleor/order/emails.py b/saleor/order/emails.py
index 590829b7fbe..3aa15afb89b 100644
--- a/saleor/order/emails.py
+++ b/saleor/order/emails.py
@@ -6,6 +6,7 @@
 from ..core.emails import get_email_base_context
 from ..core.utils import build_absolute_uri
 from ..seo.schema.email import get_order_confirmation_markup
+from .events import OrderEventsEmails, email_sent_event
 from .models import Fulfillment, Order
 
 CONFIRM_ORDER_TEMPLATE = 'order/confirm_order'
@@ -26,12 +27,12 @@ def collect_data_for_email(order_pk, template):
     email_context = get_email_base_context()
     email_context['order_details_url'] = build_absolute_uri(
         reverse('order:details', kwargs={'token': order.token}))
+    email_context['order'] = order
 
     # Order confirmation template requires additional information
     if template == CONFIRM_ORDER_TEMPLATE:
         email_markup = get_order_confirmation_markup(order)
-        email_context.update(
-            {'order': order, 'schema_markup': email_markup})
+        email_context['schema_markup'] = email_markup
 
     return {
         'recipient_list': [recipient_email], 'template_name': template,
@@ -52,10 +53,13 @@ def collect_data_for_fullfillment_email(order_pk, template, fulfillment_pk):
 
 
 @shared_task
-def send_order_confirmation(order_pk):
+def send_order_confirmation(order_pk, user_pk=None):
     """Sends order confirmation email."""
     email_data = collect_data_for_email(order_pk, CONFIRM_ORDER_TEMPLATE)
     send_templated_mail(**email_data)
+    email_sent_event(
+        order=email_data['context']['order'],
+        email_type=OrderEventsEmails.ORDER, user=None, user_pk=user_pk)
 
 
 @shared_task
@@ -65,6 +69,19 @@ def send_fulfillment_confirmation(order_pk, fulfillment_pk):
     send_templated_mail(**email_data)
 
 
+def send_fulfillment_confirmation_to_customer(order, fulfillment, user):
+    send_fulfillment_confirmation.delay(order.pk, fulfillment.pk)
+
+    email_sent_event(
+        order=order, user=user, email_type=OrderEventsEmails.FULFILLMENT)
+
+    # If digital lines were sent in the fulfillment email,
+    # trigger the event
+    if any((line for line in order if line.variant.is_digital())):
+        email_sent_event(
+            order=order, user=user, email_type=OrderEventsEmails.DIGITAL_LINKS)
+
+
 @shared_task
 def send_fulfillment_update(order_pk, fulfillment_pk):
     email_data = collect_data_for_fullfillment_email(
diff --git a/saleor/order/events.py b/saleor/order/events.py
new file mode 100644
index 00000000000..3cc053b7294
--- /dev/null
+++ b/saleor/order/events.py
@@ -0,0 +1,210 @@
+from decimal import Decimal
+from typing import Dict, List, Optional, Tuple, Union
+
+from django.contrib.auth.base_user import AbstractBaseUser
+
+from . import OrderEvents, OrderEventsEmails
+from ..account.models import Address
+from ..order.models import Fulfillment, Order, OrderLine
+from ..payment.models import Payment
+from ..product.models import ProductVariant
+from .models import OrderEvent
+
+UserType = AbstractBaseUser
+
+
+def _lines_per_quantity_to_str_line_list(quantities_per_order_line):
+    return [{
+        'quantity': quantity,
+        'item': str(line)
+    } for quantity, line in quantities_per_order_line]
+
+
+def _get_payment_data(
+        amount: Optional[Decimal], payment: Payment) -> Dict:
+    return {
+        'parameters': {
+            'amount': amount,
+            'payment_id': payment.token,
+            'payment_gateway': payment.gateway}}
+
+
+def _new_event(**data) -> OrderEvent:
+    return OrderEvent.objects.create(**data)
+
+
+def email_sent_event(
+        *, order: Order, user: Optional[UserType],
+        email_type: OrderEventsEmails, user_pk: int = None):
+
+    if user is not None and not user.is_anonymous:
+        kwargs = {'user': user}
+    elif user_pk:
+        kwargs = {'user_id': user_pk}
+    else:
+        kwargs = {}
+
+    return _new_event(
+        order=order, type=OrderEvents.EMAIL_SENT,
+        parameters={
+            'email': order.get_user_current_email(),
+            'email_type': email_type}, **kwargs)
+
+
+def email_resent_event(
+        *, order: Order, user: UserType, email_type: OrderEventsEmails):
+    raise NotImplementedError
+
+
+def draft_order_created_event(*, order: Order, user: UserType):
+    return _new_event(
+        order=order, type=OrderEvents.DRAFT_CREATED, user=user)
+
+
+def draft_order_added_products_event(
+        *, order: Order, user: UserType,
+        order_lines: List[Tuple[int, ProductVariant]]):
+
+    return _new_event(
+        order=order, type=OrderEvents.DRAFT_ADDED_PRODUCTS,
+        user=user,
+        parameters={
+            'lines': _lines_per_quantity_to_str_line_list(order_lines)})
+
+
+def draft_order_removed_products_event(
+        *, order: Order, user: UserType,
+        order_lines: List[Tuple[int, ProductVariant]]):
+
+    return _new_event(
+        order=order, type=OrderEvents.DRAFT_REMOVED_PRODUCTS,
+        user=user,
+        parameters={
+            'lines': _lines_per_quantity_to_str_line_list(order_lines)})
+
+
+def order_created_event(*, order: Order, user: UserType, from_draft=False):
+    event_type = (
+        OrderEvents.PLACED_FROM_DRAFT if from_draft else OrderEvents.PLACED
+    )
+
+    if user.is_anonymous:
+        user = None
+
+    return _new_event(
+        order=order, type=event_type, user=user)
+
+
+def draft_order_oversold_items_event(
+        *, order: Order, user: UserType,
+        oversold_items: List[str]):
+    return _new_event(
+        order=order, type=OrderEvents.OVERSOLD_ITEMS, user=user,
+        parameters={
+            'oversold_items': oversold_items})
+
+
+def order_canceled_event(*, order: Order, user: UserType):
+    return _new_event(
+        order=order, type=OrderEvents.CANCELED, user=user)
+
+
+def order_manually_marked_as_paid_event(*, order: Order, user: UserType):
+    return _new_event(
+        order=order, type=OrderEvents.ORDER_MARKED_AS_PAID, user=user)
+
+
+def order_fully_paid_event(*, order: Order):
+    return _new_event(order=order, type=OrderEvents.ORDER_FULLY_PAID)
+
+
+def payment_captured_event(
+        *, order: Order, user: UserType,
+        amount: Decimal, payment: Payment):
+    return _new_event(
+        order=order, type=OrderEvents.PAYMENT_CAPTURED, user=user,
+        **_get_payment_data(amount, payment))
+
+
+def payment_refunded_event(
+        *, order: Order, user: UserType,
+        amount: Decimal, payment: Payment):
+    return _new_event(
+        order=order, type=OrderEvents.PAYMENT_REFUNDED, user=user,
+        **_get_payment_data(amount, payment))
+
+
+def payment_voided_event(*, order: Order, user: UserType, payment: Payment):
+    return _new_event(
+        order=order, type=OrderEvents.PAYMENT_VOIDED, user=user,
+        **_get_payment_data(None, payment))
+
+
+def payment_failed_event(
+        *, order: Order, user: UserType, message: str,
+        payment: Payment):
+
+    parameters = {'message': message}
+
+    if payment:
+        parameters.update({
+            'gateway': payment.gateway,
+            'payment_id': payment.token})
+
+    return _new_event(
+        order=order, type=OrderEvents.PAYMENT_FAILED, user=user,
+        parameters=parameters)
+
+
+def fulfillment_canceled_event(
+        *, order: Order, user: UserType,
+        fulfillment: Fulfillment):
+    return _new_event(
+        order=order, type=OrderEvents.FULFILLMENT_CANCELED, user=user,
+        parameters={'composed_id': fulfillment.composed_id})
+
+
+def fulfillment_restocked_items_event(
+        *, order: Order, user: UserType,
+        fulfillment: Union[Order, Fulfillment]):
+    return _new_event(
+        order=order, type=OrderEvents.FULFILLMENT_RESTOCKED_ITEMS, user=user,
+        parameters={
+            'quantity': fulfillment.get_total_quantity()})
+
+
+def fulfillment_fulfilled_items_event(
+        *, order: Order, user: UserType,
+        quantities: List[int],
+        order_lines: List[OrderLine]):
+    return _new_event(
+        order=order, type=OrderEvents.FULFILLMENT_FULFILLED_ITEMS, user=user,
+        parameters={
+            'lines': _lines_per_quantity_to_str_line_list(
+                zip(quantities, order_lines))})
+
+
+def fulfillment_tracking_updated_event(
+        *, order: Order, user: UserType,
+        tracking_number: str,
+        fulfillment: Fulfillment):
+    return _new_event(
+        order=order, type=OrderEvents.TRACKING_UPDATED, user=user,
+        parameters={
+            'tracking_number': tracking_number,
+            'fulfillment': fulfillment.composed_id})
+
+
+def order_note_added_event(*, order: Order, user: UserType, message: str):
+    return _new_event(
+        order=order, type=OrderEvents.NOTE_ADDED, user=user,
+        parameters={
+            'message': message})
+
+
+def order_updated_address_event(
+        *, order: Order, user: UserType, address: Address):
+    return _new_event(
+        order=order, type=OrderEvents.UPDATED_ADDRESS, user=user,
+        parameters={
+            'new_address': str(address)})
diff --git a/saleor/order/migrations/0054_move_data_to_order_events.py b/saleor/order/migrations/0054_move_data_to_order_events.py
index aff850b8e7b..9f30537bd75 100644
--- a/saleor/order/migrations/0054_move_data_to_order_events.py
+++ b/saleor/order/migrations/0054_move_data_to_order_events.py
@@ -13,7 +13,7 @@ def move_order_note_to_events(apps, schema_editor):
 
     for note in OrderNote.objects.all():
         OrderEvent.objects.create(
-            type=OrderEvents.NOTE_ADDED.value,
+            type='note_added',
             user=note.user,
             parameters={'message': note.content},
             order=note.order,
@@ -29,7 +29,7 @@ def move_order_history_entry_to_events(apps, schema_editor):
 
     for entry in OrderHistoryEntry.objects.all():
         OrderEvent.objects.create(
-            type=OrderEvents.OTHER.value,
+            type=OrderEvents.OTHER,
             parameters={'message': entry.content},
             date=entry.date,
             order=entry.order,
diff --git a/saleor/order/migrations/0066_auto_20181023_0319.py b/saleor/order/migrations/0066_auto_20181023_0319.py
index 134dc915287..483effcb5fd 100644
--- a/saleor/order/migrations/0066_auto_20181023_0319.py
+++ b/saleor/order/migrations/0066_auto_20181023_0319.py
@@ -1,13 +1,12 @@
 # Generated by Django 2.1.2 on 2018-10-23 08:19
 
 from django.db import migrations
-from saleor.order import OrderEvents
+
 
 def change_released_to_voided_in_order_events(apps, schema):
     PAYMENT_RELEASED = 'released'
     OrderEvent = apps.get_model('order', 'OrderEvent')
-    OrderEvent.objects.filter(
-        type=PAYMENT_RELEASED).update(type=OrderEvents.PAYMENT_VOIDED.value)
+    OrderEvent.objects.filter(type=PAYMENT_RELEASED).update(type='payment_voided')
 
 
 class Migration(migrations.Migration):
diff --git a/saleor/order/migrations/0070_drop_update_event_and_rename_events.py b/saleor/order/migrations/0070_drop_update_event_and_rename_events.py
new file mode 100644
index 00000000000..3c6097c8a54
--- /dev/null
+++ b/saleor/order/migrations/0070_drop_update_event_and_rename_events.py
@@ -0,0 +1,67 @@
+# Generated by Django 2.2 on 2019-05-07 10:13
+
+from enum import Enum
+
+from django.db import migrations
+from django.db.models import CharField
+
+from saleor.order.events import OrderEvents
+
+
+class OldOrderEvents(Enum):
+    PLACED = 'placed'
+    PLACED_FROM_DRAFT = 'draft_placed'
+    OVERSOLD_ITEMS = 'oversold_items'
+    ORDER_MARKED_AS_PAID = 'marked_as_paid'
+    CANCELED = 'canceled'
+    ORDER_FULLY_PAID = 'order_paid'
+    UPDATED = 'updated'
+
+    EMAIL_SENT = 'email_sent'
+
+    PAYMENT_CAPTURED = 'captured'
+    PAYMENT_REFUNDED = 'refunded'
+    PAYMENT_VOIDED = 'voided'
+
+    FULFILLMENT_CANCELED = 'fulfillment_canceled'
+    FULFILLMENT_RESTOCKED_ITEMS = 'restocked_items'
+    FULFILLMENT_FULFILLED_ITEMS = 'fulfilled_items'
+    TRACKING_UPDATED = 'tracking_updated'
+    NOTE_ADDED = 'note_added'
+
+    OTHER = 'other'
+
+
+def _replace_old_namings(apps, *_args, **_kwargs):
+    cls = apps.get_model('order', 'OrderEvent')
+
+    for event_type in OldOrderEvents:
+        cls.objects.filter(type=event_type.value).update(type=event_type.name.lower())
+
+
+def _move_updated_events_to_other(apps, *_args, **_kwargs):
+    cls = apps.get_model('order', 'OrderEvent')
+
+    for event in cls.objects.filter(type='updated').all():
+        event.type = OrderEvents.OTHER
+        event.parameters['message'] = (
+            'Order details were updated by %(user_name)s' % {
+                'user_name': event.user})
+        event.save(update_fields=['type', 'parameters'])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('order', '0069_auto_20190225_2305'),
+    ]
+
+    operations = [
+        migrations.RunPython(_move_updated_events_to_other),
+        migrations.RunPython(_replace_old_namings),
+        migrations.AlterField(
+            model_name='orderevent',
+            name='type',
+            field=CharField(choices=[('DRAFT_CREATED', 'draft_created'), ('DRAFT_ADDED_PRODUCTS', 'draft_added_products'), ('DRAFT_REMOVED_PRODUCTS', 'draft_removed_products'), ('PLACED', 'placed'), ('PLACED_FROM_DRAFT', 'placed_from_draft'), ('OVERSOLD_ITEMS', 'oversold_items'), ('CANCELED', 'canceled'), ('ORDER_MARKED_AS_PAID', 'order_marked_as_paid'), ('ORDER_FULLY_PAID', 'order_fully_paid'), ('UPDATED_ADDRESS', 'updated_address'), ('EMAIL_SENT', 'email_sent'), ('PAYMENT_CAPTURED', 'payment_captured'), ('PAYMENT_REFUNDED', 'payment_refunded'), ('PAYMENT_VOIDED', 'payment_voided'), ('PAYMENT_FAILED', 'payment_failed'), ('FULFILLMENT_CANCELED', 'fulfillment_canceled'), ('FULFILLMENT_RESTOCKED_ITEMS', 'fulfillment_restocked_items'), ('FULFILLMENT_FULFILLED_ITEMS', 'fulfillment_fulfilled_items'), ('TRACKING_UPDATED', 'tracking_updated'), ('NOTE_ADDED', 'note_added'), ('OTHER', 'other')], max_length=255),
+        ),
+    ]
diff --git a/saleor/order/models.py b/saleor/order/models.py
index 09168139a9e..77b2af65741 100644
--- a/saleor/order/models.py
+++ b/saleor/order/models.py
@@ -22,7 +22,7 @@
 from ..discount.models import Voucher
 from ..payment import ChargeStatus, TransactionKind
 from ..shipping.models import ShippingMethod
-from . import FulfillmentStatus, OrderEvents, OrderStatus, display_order_event
+from . import FulfillmentStatus, OrderEvents, OrderStatus
 
 
 class OrderQueryset(models.QuerySet):
@@ -402,14 +402,17 @@ class FulfillmentLine(models.Model):
 class OrderEvent(models.Model):
     """Model used to store events that happened during the order lifecycle.
 
-        Args:
-            parameters: Values needed to display the event on the storefront
-            type: Type of an order
+    Args:
+        parameters: Values needed to display the event on the storefront
+        type: Type of an order
     """
+
     date = models.DateTimeField(default=now, editable=False)
     type = models.CharField(
         max_length=255,
-        choices=((event.name, event.value) for event in OrderEvents))
+        choices=[(
+            type_name.upper(), type_name)
+            for type_name, _ in OrderEvents.CHOICES])
     order = models.ForeignKey(
         Order, related_name='events', on_delete=models.CASCADE)
     parameters = JSONField(
@@ -423,6 +426,3 @@ class Meta:
 
     def __repr__(self):
         return 'OrderEvent(type=%r, user=%r)' % (self.type, self.user)
-
-    def get_event_display(self):
-        return display_order_event(self)
diff --git a/saleor/order/utils.py b/saleor/order/utils.py
index 260a9d195f7..724c320b2cc 100644
--- a/saleor/order/utils.py
+++ b/saleor/order/utils.py
@@ -5,6 +5,9 @@
 from django.shortcuts import get_object_or_404, redirect
 from prices import Money, TaxedMoney
 
+from .events import draft_order_added_products_event, \
+    draft_order_removed_products_event, fulfillment_restocked_items_event, \
+    fulfillment_canceled_event, order_canceled_event
 from ..account.utils import store_user_address
 from ..checkout import AddressType
 from ..core.utils.taxes import (
@@ -165,13 +168,18 @@ def update_order_prices(order, discounts):
     recalculate_order(order)
 
 
-def cancel_order(order, restock):
+def cancel_order(user, order, restock):
     """Cancel order and associated fulfillments.
 
     Return products to corresponding stocks if restock is set to True.
     """
+
+    order_canceled_event(order=order, user=user)
     if restock:
+        fulfillment_restocked_items_event(
+            order=order, user=user, fulfillment=order)
         restock_order_lines(order)
+
     for fulfillment in order.fulfillments.all():
         fulfillment.status = FulfillmentStatus.CANCELED
         fulfillment.save(update_fields=['status'])
@@ -205,12 +213,16 @@ def update_order_status(order):
         order.save(update_fields=['status'])
 
 
-def cancel_fulfillment(fulfillment, restock):
+def cancel_fulfillment(user, fulfillment, restock):
     """Cancel fulfillment.
 
     Return products to corresponding stocks if restock is set to True.
     """
+    fulfillment_canceled_event(
+        order=fulfillment.order, user=user, fulfillment=fulfillment)
     if restock:
+        fulfillment_restocked_items_event(
+            order=fulfillment.order, user=user, fulfillment=fulfillment)
         restock_fulfillment_lines(fulfillment)
     for line in fulfillment:
         order_line = line.order_line
@@ -273,7 +285,7 @@ def add_variant_to_order(
     return line
 
 
-def change_order_line_quantity(line, new_quantity):
+def change_order_line_quantity(user, line, old_quantity, new_quantity):
     """Change the quantity of ordered items in a order line."""
     if new_quantity:
         line.quantity = new_quantity
@@ -281,6 +293,18 @@ def change_order_line_quantity(line, new_quantity):
     else:
         delete_order_line(line)
 
+    quantity_diff = old_quantity - new_quantity
+
+    # Create the removal event
+    if quantity_diff > 0:
+        draft_order_removed_products_event(
+            order=line.order, user=user,
+            order_lines=[(quantity_diff, line)])
+    elif quantity_diff < 0:
+        draft_order_added_products_event(
+            order=line.order, user=user,
+            order_lines=[(quantity_diff * -1, line)])
+
 
 def delete_order_line(line):
     """Delete an order line from an order."""
diff --git a/saleor/payment/utils.py b/saleor/payment/utils.py
index 231412783a9..4ebf6591257 100644
--- a/saleor/payment/utils.py
+++ b/saleor/payment/utils.py
@@ -12,8 +12,10 @@
 from ..account.models import Address, User
 from ..checkout.models import Checkout
 from ..core import analytics
-from ..order import OrderEvents, OrderEventsEmails, utils as order_utils
+from ..order import utils as order_utils
 from ..order.emails import send_payment_confirmation
+from ..order.events import order_manually_marked_as_paid_event, \
+    order_fully_paid_event, email_sent_event, OrderEventsEmails
 from ..order.models import Order
 from . import (
     ChargeStatus, CustomPaymentChoices, GatewayError, OperationType,
@@ -74,17 +76,16 @@ def create_payment_information(
 
 
 def handle_fully_paid_order(order):
-    order.events.create(type=OrderEvents.ORDER_FULLY_PAID.value)
+    order_fully_paid_event(order=order)
+
     if order.get_user_current_email():
+        email_sent_event(
+            order=order, email_type=OrderEventsEmails.PAYMENT, user=None)
         send_payment_confirmation.delay(order.pk)
-        order.events.create(
-            type=OrderEvents.EMAIL_SENT.value,
-            parameters={
-                'email': order.get_user_current_email(),
-                'email_type': OrderEventsEmails.PAYMENT.value})
 
         if order_utils.order_needs_automatic_fullfilment(order):
             order_utils.automatically_fulfill_digital_lines(order)
+
     try:
         analytics.report_order(order.tracking_client_id, order)
     except Exception:
@@ -173,8 +174,7 @@ def mark_order_as_paid(order: Order, request_user: User):
     payment.charge_status = ChargeStatus.FULLY_CHARGED
     payment.captured_amount = order.total.gross.amount
     payment.save(update_fields=['captured_amount', 'charge_status'])
-    order.events.create(
-        type=OrderEvents.ORDER_MARKED_AS_PAID.value, user=request_user)
+    order_manually_marked_as_paid_event(order=order, user=request_user)
 
 
 def create_transaction(
@@ -339,6 +339,7 @@ def validate_gateway_response(response: GatewayResponse):
             'Gateway response needs to be json serializable')
 
 
+@transaction.atomic
 def _gateway_postprocess(transaction, payment):
     transaction_kind = transaction.kind
 
diff --git a/templates/dashboard/order/detail.html b/templates/dashboard/order/detail.html
index 4c13e00d856..0158888f047 100644
--- a/templates/dashboard/order/detail.html
+++ b/templates/dashboard/order/detail.html
@@ -7,6 +7,7 @@
 {% load static %}
 {% load status %}
 {% load voucher %}
+{% load orders %}
 
 {% block title %}
   {{ order }} - {% trans "Orders" context "Dashboard orders list" %} - {{ block.super }}
@@ -628,7 +629,7 @@ <h5 class="data-table-title">
         {% for event in events %}
           <li class="event" data-date="{{ event.date }}">
             <div class="content">
-              {{ event.get_event_display|default:"" }}{% if event.user %} by {{ event.user }}{% endif %}
+              {% display_order_event event %}{% if event.user %} by {{ event.user }}{% endif %}
             </div>
           </li>
         {% endfor %}
diff --git a/tests/api/test_fulfillment.py b/tests/api/test_fulfillment.py
index 3e38f04688f..8aeef544368 100644
--- a/tests/api/test_fulfillment.py
+++ b/tests/api/test_fulfillment.py
@@ -3,7 +3,7 @@
 import graphene
 import pytest
 
-from saleor.order import OrderEvents, OrderEventsEmails
+from saleor.order.events import OrderEvents
 from saleor.order.models import FulfillmentStatus
 from tests.api.utils import get_graphql_content
 
@@ -35,11 +35,10 @@
 """
 
 
-@patch(
-    'saleor.graphql.order.mutations.fulfillments.'
-    'send_fulfillment_confirmation')
+@patch('saleor.graphql.order.mutations.fulfillments.'
+       'send_fulfillment_confirmation_to_customer')
 def test_create_fulfillment(
-        mock_send_fulfillment_confirmation, staff_api_client, order_with_lines,
+        mock_email_fulfillment, staff_api_client, order_with_lines,
         staff_user, permission_manage_orders):
     order = order_with_lines
     query = CREATE_FULFILLMENT_QUERY
@@ -47,7 +46,6 @@ def test_create_fulfillment(
     order_line = order.lines.first()
     order_line_id = graphene.Node.to_global_id('OrderLine', order_line.id)
     tracking = 'Flames tracking'
-    assert not order.events.all()
     variables = {
         'order': order_id,
         'lines': [{'orderLineId': order_line_id, 'quantity': 1}],
@@ -61,25 +59,13 @@ def test_create_fulfillment(
     assert data['trackingNumber'] == tracking
     assert len(data['lines']) == 1
 
-    event_fulfillment, event_email_sent = order.events.all()
-    assert event_fulfillment.type == (
-        OrderEvents.FULFILLMENT_FULFILLED_ITEMS.value)
-    assert event_fulfillment.parameters == {'quantity': 1}
-    assert event_fulfillment.user == staff_user
-
-    assert event_email_sent.type == OrderEvents.EMAIL_SENT.value
-    assert event_email_sent.user == staff_user
-    assert event_email_sent.parameters == {
-        'email': order.user_email,
-        'email_type': OrderEventsEmails.FULFILLMENT.value}
-
-    assert mock_send_fulfillment_confirmation.delay.called
+    assert mock_email_fulfillment.call_count == 1
 
 
 @patch(
     'saleor.graphql.order.mutations.fulfillments.'
-    'send_fulfillment_confirmation')
-def test_create_fulfillment_with_emtpy_quantity(
+    'send_fulfillment_confirmation_to_customer')
+def test_create_fulfillment_with_empty_quantity(
         mock_send_fulfillment_confirmation, staff_api_client, order_with_lines,
         staff_user, permission_manage_orders):
     order = order_with_lines
@@ -105,7 +91,7 @@ def test_create_fulfillment_with_emtpy_quantity(
     assert data['fulfillmentOrder'] == 1
     assert data['status'] == FulfillmentStatus.FULFILLED.upper()
 
-    assert mock_send_fulfillment_confirmation.delay.called
+    assert mock_send_fulfillment_confirmation.called
 
 
 @pytest.mark.parametrize(
@@ -188,9 +174,15 @@ def test_cancel_fulfillment_restock_items(
     content = get_graphql_content(response)
     data = content['data']['orderFulfillmentCancel']['fulfillment']
     assert data['status'] == FulfillmentStatus.CANCELED.upper()
-    event_restocked_items = fulfillment.order.events.get()
+    event_cancelled, event_restocked_items = fulfillment.order.events.all()
+    assert event_cancelled.type == (
+        OrderEvents.FULFILLMENT_CANCELED)
+    assert event_cancelled.parameters == {
+        'composed_id': fulfillment.composed_id}
+    assert event_cancelled.user == staff_user
+
     assert event_restocked_items.type == (
-        OrderEvents.FULFILLMENT_RESTOCKED_ITEMS.value)
+        OrderEvents.FULFILLMENT_RESTOCKED_ITEMS)
     assert event_restocked_items.parameters == {
         'quantity': fulfillment.get_total_quantity()}
     assert event_restocked_items.user == staff_user
@@ -216,17 +208,16 @@ def test_cancel_fulfillment(
     assert data['status'] == FulfillmentStatus.CANCELED.upper()
     event_cancel_fulfillment = fulfillment.order.events.get()
     assert event_cancel_fulfillment.type == (
-        OrderEvents.FULFILLMENT_CANCELED.value)
+        OrderEvents.FULFILLMENT_CANCELED)
     assert event_cancel_fulfillment.parameters == {
         'composed_id': fulfillment.composed_id}
     assert event_cancel_fulfillment.user == staff_user
 
 
-@patch(
-    'saleor.graphql.order.mutations.fulfillments.'
-    'send_fulfillment_confirmation')
+@patch('saleor.graphql.order.mutations.fulfillments.'
+       'send_fulfillment_confirmation_to_customer')
 def test_create_digital_fulfillment(
-        mock_send_fulfillment_confirmation, digital_content, staff_api_client,
+        mock_email_fulfillment, digital_content, staff_api_client,
         order_with_lines, staff_user, permission_manage_orders):
     order = order_with_lines
     query = CREATE_FULFILLMENT_QUERY
@@ -240,7 +231,6 @@ def test_create_digital_fulfillment(
     second_line_id = graphene.Node.to_global_id('OrderLine', second_line.id)
 
     tracking = 'Flames tracking'
-    assert not order.events.all()
     variables = {
         'order': order_id,
         'lines': [
@@ -251,19 +241,4 @@ def test_create_digital_fulfillment(
         query, variables, permissions=[permission_manage_orders])
     get_graphql_content(response)
 
-    event_fulfillment, event_email_sent = order.events.all()
-    assert event_fulfillment.type == (
-        OrderEvents.FULFILLMENT_FULFILLED_ITEMS.value)
-    assert event_fulfillment.parameters == {'quantity': 2}
-    assert event_fulfillment.user == staff_user
-
-    assert event_email_sent.type == OrderEvents.EMAIL_SENT.value
-    assert event_email_sent.user == staff_user
-    assert event_email_sent.parameters == {
-        'email': order.user_email,
-        'email_type': OrderEventsEmails.FULFILLMENT.value}
-
-    digital_content.refresh_from_db()
-    assert digital_content.urls.count() == 1
-    assert digital_content.urls.all()[0].line == order_line
-    assert mock_send_fulfillment_confirmation.delay.called
+    assert mock_email_fulfillment.call_count == 1
diff --git a/tests/api/test_homepage.py b/tests/api/test_homepage.py
index f6455656c2f..ac0fc6f773a 100644
--- a/tests/api/test_homepage.py
+++ b/tests/api/test_homepage.py
@@ -1,5 +1,3 @@
-from saleor.order import OrderEvents
-
 from .utils import get_graphql_content
 
 
@@ -20,8 +18,6 @@ def test_homepage_events(order_events, staff_api_client, permission_manage_order
         query, permissions=[permission_manage_orders])
     content = get_graphql_content(response)
     edges = content['data']['homepageEvents']['edges']
-    only_types = [
-        OrderEvents.PLACED, OrderEvents.PLACED_FROM_DRAFT,
-        OrderEvents.ORDER_FULLY_PAID]
-    only_types = {t.name for t in only_types}
+    only_types = {
+        'PLACED', 'PLACED_FROM_DRAFT', 'ORDER_FULLY_PAID'}
     assert {edge['node']['type'] for edge in edges} == only_types
diff --git a/tests/api/test_order.py b/tests/api/test_order.py
index 12e8e6e643f..b4861ec45f4 100644
--- a/tests/api/test_order.py
+++ b/tests/api/test_order.py
@@ -9,15 +9,17 @@
 
 from saleor.core.utils.taxes import ZERO_TAXED_MONEY
 from saleor.graphql.core.enums import ReportingPeriod
-from saleor.graphql.order.enums import OrderEventsEmailsEnum
 from saleor.graphql.order.mutations.orders import (
     clean_order_cancel, clean_order_capture, clean_refund_payment,
-    clean_void_payment)
+    try_payment_action)
 from saleor.graphql.order.utils import validate_draft_order
 from saleor.graphql.payment.types import PaymentChargeStatusEnum
-from saleor.order import OrderEvents, OrderEventsEmails, OrderStatus
+from saleor.order import OrderStatus
+from saleor.order.events import (
+    OrderEvents, OrderEventsEmails, fulfillment_fulfilled_items_event,
+    payment_captured_event)
 from saleor.order.models import Order, OrderEvent
-from saleor.payment import ChargeStatus, CustomPaymentChoices
+from saleor.payment import ChargeStatus, CustomPaymentChoices, PaymentError
 from saleor.payment.models import Payment
 from saleor.shipping.models import ShippingMethod
 
@@ -275,48 +277,118 @@ def test_nested_order_events_query(
         query OrdersQuery {
             orders(first: 1) {
                 edges {
-                node {
-                    events {
-                        date
-                        type
-                        user {
+                    node {
+                        events {
+                            date
+                            type
+                            user {
+                                email
+                            }
+                            message
                             email
-                        }
-                        message
-                        email
-                        emailType
-                        amount
-                        quantity
-                        composedId
-                        orderNumber
+                            emailType
+                            amount
+                            quantity
+                            composedId
+                            orderNumber
+                            lines {
+                                quantity
+                                item
+                            }
+                            paymentId
+                            paymentGateway
                         }
                     }
                 }
             }
         }
     """
-    event = fulfilled_order.events.create(
-        type=OrderEvents.OTHER.value,
-        user=staff_user,
-        parameters={
-            'message': 'Example note',
-            'email_type': OrderEventsEmails.PAYMENT.value,
-            'amount': '80.00',
-            'quantity': '10',
-            'composed_id': '10-10'})
+    line = fulfilled_order.lines.first()
+
+    event = fulfillment_fulfilled_items_event(
+        order=fulfilled_order, user=staff_user,
+        quantities=[line.quantity], order_lines=[line])
+    event.parameters.update({
+        'message': 'Example note',
+        'email_type': OrderEventsEmails.PAYMENT,
+        'amount': '80.00',
+        'quantity': '10',
+        'composed_id': '10-10'})
+    event.save()
+
     staff_api_client.user.user_permissions.add(permission_manage_orders)
     response = staff_api_client.post_graphql(query)
     content = get_graphql_content(response)
     data = content['data']['orders']['edges'][0]['node']['events'][0]
     assert data['message'] == event.parameters['message']
     assert data['amount'] == float(event.parameters['amount'])
-    assert data['emailType'] == OrderEventsEmailsEnum.PAYMENT.name
+    assert data['emailType'] == 'PAYMENT_CONFIRMATION'
     assert data['quantity'] == int(event.parameters['quantity'])
     assert data['composedId'] == event.parameters['composed_id']
     assert data['user']['email'] == staff_user.email
-    assert data['type'] == OrderEvents.OTHER.value.upper()
+    assert data['type'] == 'FULFILLMENT_FULFILLED_ITEMS'
     assert data['date'] == event.date.isoformat()
     assert data['orderNumber'] == str(fulfilled_order.pk)
+    assert data['lines'] == [{
+        'quantity': line.quantity,
+        'item': str(line)}]
+    assert data['paymentId'] is None
+    assert data['paymentGateway'] is None
+
+
+def test_payment_information_order_events_query(
+        staff_api_client, permission_manage_orders, order, payment_dummy,
+        staff_user):
+    query = """
+        query OrdersQuery {
+            orders(first: 1) {
+                edges {
+                    node {
+                        events {
+                            type
+                            user {
+                                email
+                            }
+                            message
+                            email
+                            emailType
+                            amount
+                            quantity
+                            composedId
+                            orderNumber
+                            lines {
+                                item
+                            }
+                            paymentId
+                            paymentGateway
+                        }
+                    }
+                }
+            }
+        }
+    """
+
+    amount = order.total.gross.amount
+
+    payment_captured_event(
+        order=order, user=staff_user, amount=amount, payment=payment_dummy)
+
+    staff_api_client.user.user_permissions.add(permission_manage_orders)
+    response = staff_api_client.post_graphql(query)
+    content = get_graphql_content(response)
+    data = content['data']['orders']['edges'][0]['node']['events'][0]
+
+    assert data['message'] is None
+    assert data['amount'] == amount
+    assert data['emailType'] is None
+    assert data['quantity'] is None
+    assert data['composedId'] is None
+    assert data['lines'] is None
+    assert data['user']['email'] == staff_user.email
+    assert data['type'] == 'PAYMENT_CAPTURED'
+    assert data['orderNumber'] == str(order.pk)
+    assert data['paymentId'] == payment_dummy.token
+    assert data['paymentGateway'] == payment_dummy.gateway
 
 
 def test_non_staff_user_can_only_see_his_order(user_api_client, order):
@@ -343,9 +415,9 @@ def test_non_staff_user_can_only_see_his_order(user_api_client, order):
 
 
 def test_draft_order_create(
-        staff_api_client, permission_manage_orders, customer_user,
-        product_without_shipping, shipping_method, variant, voucher,
-        graphql_address_data):
+        staff_api_client, permission_manage_orders, staff_user,
+        customer_user, product_without_shipping, shipping_method,
+        variant, voucher, graphql_address_data):
     variant_0 = variant
     query = """
     mutation draftCreate(
@@ -378,6 +450,10 @@ def test_draft_order_create(
                 }
         }
     """
+
+    # Ensure no events were created yet
+    assert not OrderEvent.objects.exists()
+
     user_id = graphene.Node.to_global_id('User', customer_user.id)
     variant_0_id = graphene.Node.to_global_id('ProductVariant', variant_0.id)
     variant_1 = product_without_shipping.variants.first()
@@ -416,6 +492,12 @@ def test_draft_order_create(
     assert order.shipping_address.first_name == graphql_address_data[
         'firstName']
 
+    # Ensure the correct event was created
+    created_draft_event = OrderEvent.objects.get(
+        type=OrderEvents.DRAFT_CREATED)
+    assert created_draft_event.user == staff_user
+    assert created_draft_event.parameters == {}
+
 
 def test_draft_order_update(
         staff_api_client, permission_manage_orders, order_with_lines):
@@ -443,6 +525,29 @@ def test_draft_order_update(
     assert data['userEmail'] == email
 
 
+def test_draft_order_update_doing_nothing_generates_no_events(
+        staff_api_client, permission_manage_orders, order_with_lines):
+    assert not OrderEvent.objects.exists()
+
+    query = """
+        mutation draftUpdate($id: ID!) {
+            draftOrderUpdate(id: $id, input: {}) {
+                errors {
+                    field
+                    message
+                }
+            }
+        }
+        """
+    order_id = graphene.Node.to_global_id('Order', order_with_lines.id)
+    response = staff_api_client.post_graphql(
+        query, {'id': order_id}, permissions=[permission_manage_orders])
+    get_graphql_content(response)
+
+    # Ensure not event was created
+    assert not OrderEvent.objects.exists()
+
+
 def test_draft_order_delete(
         staff_api_client, permission_manage_orders, order_with_lines):
     order = order_with_lines
@@ -457,7 +562,7 @@ def test_draft_order_delete(
         """
     order_id = graphene.Node.to_global_id('Order', order.id)
     variables = {'id': order_id}
-    response = staff_api_client.post_graphql(
+    staff_api_client.post_graphql(
         query, variables, permissions=[permission_manage_orders])
     with pytest.raises(order._meta.model.DoesNotExist):
         order.refresh_from_db()
@@ -544,6 +649,10 @@ def test_draft_order_complete(
             }
         }
         """
+
+    # Ensure no events were created
+    assert not OrderEvent.objects.exists()
+
     line_1, line_2 = order.lines.order_by('-quantity').all()
     line_1.quantity = 1
     line_1.save(update_fields=['quantity'])
@@ -558,14 +667,14 @@ def test_draft_order_complete(
     data = content['data']['draftOrderComplete']['order']
     order.refresh_from_db()
     assert data['status'] == order.status.upper()
-    missing_stock_event, draft_placed_event = OrderEvent.objects.all()
+    draft_placed_event, missing_stock_event = OrderEvent.objects.all()
 
     assert missing_stock_event.user == staff_user
-    assert missing_stock_event.type == OrderEvents.OVERSOLD_ITEMS.value
+    assert missing_stock_event.type == OrderEvents.OVERSOLD_ITEMS
     assert missing_stock_event.parameters == {'oversold_items': [str(line_2)]}
 
     assert draft_placed_event.user == staff_user
-    assert draft_placed_event.type == OrderEvents.PLACED_FROM_DRAFT.value
+    assert draft_placed_event.type == OrderEvents.PLACED_FROM_DRAFT
     assert draft_placed_event.parameters == {}
 
 
@@ -746,14 +855,21 @@ def test_require_draft_order_when_creating_lines(
 
 
 def test_draft_order_line_update(
-        draft_order, permission_manage_orders, staff_api_client):
+        draft_order, permission_manage_orders, staff_api_client, staff_user):
     query = DRAFT_ORDER_LINE_UPDATE_MUTATION
     order = draft_order
     line = order.lines.first()
     new_quantity = 1
+    removed_quantity = 2
     line_id = graphene.Node.to_global_id('OrderLine', line.id)
     variables = {'lineId': line_id, 'quantity': new_quantity}
 
+    # Ensure the line has the expected quantity
+    assert line.quantity == 3
+
+    # No event should exist yet
+    assert not OrderEvent.objects.exists()
+
     # mutation should fail without proper permissions
     response = staff_api_client.post_graphql(query, variables)
     assert_no_permission(response)
@@ -765,6 +881,12 @@ def test_draft_order_line_update(
     data = content['data']['draftOrderLineUpdate']
     assert data['orderLine']['quantity'] == new_quantity
 
+    removed_items_event = OrderEvent.objects.last()  # type: OrderEvent
+    assert removed_items_event.type == OrderEvents.DRAFT_REMOVED_PRODUCTS
+    assert removed_items_event.user == staff_user
+    assert removed_items_event.parameters == {
+        'lines': [{'quantity': removed_quantity, 'item': str(line)}]}
+
     # mutation should fail when quantity is lower than 1
     variables = {'lineId': line_id, 'quantity': 0}
     response = staff_api_client.post_graphql(query, variables)
@@ -1006,7 +1128,7 @@ def test_order_add_note(
     assert data['event']['message'] == message
 
     event = order.events.get()
-    assert event.type == OrderEvents.NOTE_ADDED.value
+    assert event.type == OrderEvents.NOTE_ADDED
     assert event.user == staff_user
     assert event.parameters == {'message': message}
 
@@ -1037,7 +1159,7 @@ def test_order_cancel_and_restock(
     order.refresh_from_db()
     order_event = order.events.last()
     assert order_event.parameters['quantity'] == quantity
-    assert order_event.type == OrderEvents.FULFILLMENT_RESTOCKED_ITEMS.value
+    assert order_event.type == OrderEvents.FULFILLMENT_RESTOCKED_ITEMS
     assert data['status'] == order.status.upper()
 
 
@@ -1054,7 +1176,7 @@ def test_order_cancel(
     data = content['data']['orderCancel']['order']
     order.refresh_from_db()
     order_event = order.events.last()
-    assert order_event.type == OrderEvents.CANCELED.value
+    assert order_event.type == OrderEvents.CANCELED
     assert data['status'] == order.status.upper()
 
 
@@ -1092,17 +1214,18 @@ def test_order_capture(
     assert data['totalCaptured']['amount'] == float(amount)
 
     event_order_paid = order.events.first()
-    assert event_order_paid.type == OrderEvents.ORDER_FULLY_PAID.value
+    assert event_order_paid.type == OrderEvents.ORDER_FULLY_PAID
     assert event_order_paid.user is None
 
     event_email_sent, event_captured = list(order.events.all())[-2:]
     assert event_email_sent.user is None
     assert event_email_sent.parameters == {
         'email': order.user_email,
-        'email_type': OrderEventsEmails.PAYMENT.value}
-    assert event_captured.type == OrderEvents.PAYMENT_CAPTURED.value
+        'email_type': OrderEventsEmails.PAYMENT}
+    assert event_captured.type == OrderEvents.PAYMENT_CAPTURED
     assert event_captured.user == staff_user
-    assert event_captured.parameters == {'amount': str(amount)}
+    assert event_captured.parameters == {
+        'amount': str(amount), 'payment_gateway': 'dummy', 'payment_id': ''}
 
 
 def test_paid_order_mark_as_paid(
@@ -1161,7 +1284,7 @@ def test_order_mark_as_paid(
     assert data['isPaid'] == True == order.is_fully_paid()
 
     event_order_paid = order.events.first()
-    assert event_order_paid.type == OrderEvents.ORDER_MARKED_AS_PAID.value
+    assert event_order_paid.type == OrderEvents.ORDER_MARKED_AS_PAID
     assert event_order_paid.user == staff_user
 
 
@@ -1196,7 +1319,7 @@ def test_order_void(
         ChargeStatus.NOT_CHARGED)
     assert data['paymentStatusDisplay'] == payment_status_display
     event_payment_voided = order.events.last()
-    assert event_payment_voided.type == OrderEvents.PAYMENT_VOIDED.value
+    assert event_payment_voided.type == OrderEvents.PAYMENT_VOIDED
     assert event_payment_voided.user == staff_user
 
 
@@ -1219,7 +1342,7 @@ def test_order_void_payment_error(staff_api_client, permission_manage_orders,
 def test_order_refund(
         staff_api_client, permission_manage_orders,
         payment_txn_captured):
-    order = order = payment_txn_captured.order
+    order = payment_txn_captured.order
     query = """
         mutation refundOrder($id: ID!, $amount: Decimal!) {
             orderRefund(id: $id, amount: $amount) {
@@ -1249,16 +1372,64 @@ def test_order_refund(
 
     order_event = order.events.last()
     assert order_event.parameters['amount'] == str(amount)
-    assert order_event.type == OrderEvents.PAYMENT_REFUNDED.value
+    assert order_event.type == OrderEvents.PAYMENT_REFUNDED
 
 
-def test_clean_order_void_payment():
-    payment = MagicMock(spec=Payment)
-    payment.is_active = False
-    with pytest.raises(ValidationError) as e:
-        clean_void_payment(payment)
-    msg = 'Only pre-authorized payments can be voided'
-    assert e.value.error_dict['payment'][0].message == msg
+@pytest.mark.parametrize('requires_amount, mutation_name', (
+    (True, 'orderRefund'),
+    (False, 'orderVoid'),
+    (True, 'orderCapture')))
+def test_clean_payment_without_payment_associated_to_order(
+        staff_api_client, permission_manage_orders,
+        order, requires_amount, mutation_name):
+
+    assert not OrderEvent.objects.exists()
+
+    additional_arguments = ', amount: 2' if requires_amount else ''
+    query = """
+        mutation %(mutationName)s($id: ID!) {
+          %(mutationName)s(id: $id %(args)s) {
+            errors {
+              field
+              message
+            }
+          }
+        }
+    """ % {'mutationName': mutation_name, 'args': additional_arguments}
+
+    order_id = graphene.Node.to_global_id('Order', order.id)
+    variables = {'id': order_id}
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_orders])
+    errors = get_graphql_content(response)['data'][mutation_name].get('errors')
+
+    message = 'There\'s no payment associated with the order.'
+
+    assert errors, 'expected an error'
+    assert errors == [{'field': 'payment', 'message': message}]
+    assert not OrderEvent.objects.exists()
+
+
+def test_try_payment_action_generates_event(order, staff_user, payment_dummy):
+    message = 'The payment did a oopsie!'
+    assert not OrderEvent.objects.exists()
+
+    def _test_operation():
+        raise PaymentError(message)
+
+    with pytest.raises(
+            ValidationError, message={'payment': message}):
+        try_payment_action(
+            order=order, user=staff_user, payment=payment_dummy,
+            func=_test_operation)
+
+    error_event = OrderEvent.objects.get()  # type: OrderEvent
+    assert error_event.type == OrderEvents.PAYMENT_FAILED
+    assert error_event.user == staff_user
+    assert error_event.parameters == {
+        'message': message,
+        'gateway': payment_dummy.gateway,
+        'payment_id': payment_dummy.token}
 
 
 def test_clean_order_refund_payment():
@@ -1266,7 +1437,7 @@ def test_clean_order_refund_payment():
     payment.gateway = CustomPaymentChoices.MANUAL
     amount = Mock(spec='string')
     with pytest.raises(ValidationError) as e:
-        clean_refund_payment(payment, amount)
+        clean_refund_payment(payment)
     msg = 'Manual payments can not be refunded.'
     assert e.value.error_dict['payment'][0].message == msg
 
@@ -1499,12 +1670,11 @@ def test_order_bulk_cancel_with_restock(
     content = get_graphql_content(response)
     data = content['data']['orderBulkCancel']
     assert data['count'] == expected_count
-    event = order_with_lines.events.first()
-    assert event.type == OrderEvents.FULFILLMENT_RESTOCKED_ITEMS.value
+    event = order_with_lines.events.all()[1]
+    assert event.type == OrderEvents.FULFILLMENT_RESTOCKED_ITEMS
     assert event.user == staff_api_client.user
 
 
-
 @pytest.mark.parametrize('orders_filter, count', [
     (
       {
diff --git a/tests/conftest.py b/tests/conftest.py
index 8790da89e2f..e952609b1cd 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -25,7 +25,8 @@
 from saleor.discount import VoucherType
 from saleor.discount.models import Sale, Voucher, VoucherTranslation
 from saleor.menu.models import Menu, MenuItem
-from saleor.order import OrderEvents, OrderStatus
+from saleor.order import OrderStatus
+from saleor.order.events import OrderEvents
 from saleor.order.models import FulfillmentStatus, Order, OrderEvent
 from saleor.order.utils import fulfill_order_line, recalculate_order
 from saleor.page.models import Page
@@ -561,8 +562,8 @@ def order_with_lines(
 
 @pytest.fixture()
 def order_events(order):
-    for event_type in OrderEvents:
-        OrderEvent.objects.create(type=event_type.value, order=order)
+    for event_type, _ in OrderEvents.CHOICES:
+        OrderEvent.objects.create(type=event_type, order=order)
 
 
 @pytest.fixture()
@@ -976,6 +977,8 @@ def digital_content(category, media_root):
         product=product, sku='SKU_554', cost_price=Money(1, 'USD'), quantity=5,
         quantity_allocated=3)
 
+    assert product_variant.is_digital()
+
     image_file, image_name = create_image()
     d_content = DigitalContent.objects.create(
         content_file=image_file, product_variant=product_variant,
diff --git a/tests/dashboard/test_order.py b/tests/dashboard/test_order.py
index 18408c8b786..b6f79243fb7 100644
--- a/tests/dashboard/test_order.py
+++ b/tests/dashboard/test_order.py
@@ -1,5 +1,6 @@
 import json
 from decimal import Decimal
+from unittest.mock import patch
 
 import pytest
 from django.urls import reverse
@@ -12,9 +13,11 @@
 from saleor.dashboard.order.utils import (
     remove_customer_from_order, save_address_in_order,
     update_order_with_user_addresses)
+from saleor.dashboard.templatetags.orders import display_order_event
 from saleor.discount.utils import increase_voucher_usage
-from saleor.order import (
-    FulfillmentStatus, OrderEvents, OrderEventsEmails, OrderStatus)
+from saleor.order import FulfillmentStatus, OrderStatus
+from saleor.order.emails import send_fulfillment_confirmation_to_customer
+from saleor.order.events import OrderEvents, OrderEventsEmails
 from saleor.order.models import Order, OrderEvent, OrderLine
 from saleor.order.utils import add_variant_to_order, change_order_line_quantity
 from saleor.payment import ChargeStatus, TransactionKind
@@ -258,7 +261,8 @@ def test_view_void_order_invalid_payment_refunded_status(
 
 @pytest.mark.integration
 @pytest.mark.parametrize('track_inventory', (True, False))
-def test_view_cancel_order_line(admin_client, draft_order, track_inventory):
+def test_view_cancel_order_line(
+        admin_client, draft_order, track_inventory, admin_user):
     lines_before = draft_order.lines.all()
     lines_before_count = lines_before.count()
     line = lines_before.first()
@@ -268,6 +272,8 @@ def test_view_cancel_order_line(admin_client, draft_order, track_inventory):
     line.variant.track_inventory = track_inventory
     line.variant.save()
 
+    assert not OrderEvent.objects.exists()
+
     url = reverse(
         'dashboard:orderline-cancel', kwargs={
             'order_pk': draft_order.pk,
@@ -292,6 +298,12 @@ def test_view_cancel_order_line(admin_client, draft_order, track_inventory):
     else:
         assert line.variant.quantity_allocated == quantity_allocated_before
 
+    removed_items_event = OrderEvent.objects.last()
+    assert removed_items_event.type == OrderEvents.DRAFT_REMOVED_PRODUCTS
+    assert removed_items_event.user == admin_user
+    assert removed_items_event.parameters == {
+        'lines': [{'quantity': line.quantity, 'item': str(line)}]}
+
     url = reverse(
         'dashboard:orderline-cancel', kwargs={
             'order_pk': draft_order.pk,
@@ -306,7 +318,7 @@ def test_view_cancel_order_line(admin_client, draft_order, track_inventory):
 @pytest.mark.integration
 @pytest.mark.parametrize('track_inventory', (True, False))
 def test_view_change_order_line_quantity(
-        admin_client, draft_order, track_inventory):
+        admin_client, draft_order, track_inventory, admin_user):
     lines_before_quantity_change = draft_order.lines.all()
     lines_before_quantity_change_count = lines_before_quantity_change.count()
     line = lines_before_quantity_change.first()
@@ -314,6 +326,8 @@ def test_view_change_order_line_quantity(
     line.variant.track_inventory = track_inventory
     line.variant.save()
 
+    assert not OrderEvent.objects.exists()
+
     url = reverse(
         'dashboard:orderline-change-quantity',
         kwargs={'order_pk': draft_order.pk, 'line_pk': line.pk})
@@ -338,6 +352,12 @@ def test_view_change_order_line_quantity(
     else:
         assert line.variant.quantity_allocated == 3
 
+    removed_items_event = OrderEvent.objects.last()
+    assert removed_items_event.type == OrderEvents.DRAFT_REMOVED_PRODUCTS
+    assert removed_items_event.user == admin_user
+    assert removed_items_event.parameters == {
+        'lines': [{'quantity': 1, 'item': str(line)}]}
+
     line.refresh_from_db()
     # source line quantity should be decreased to 2
     assert line.quantity == 2
@@ -384,7 +404,7 @@ def test_dashboard_change_quantity_form(request_checkout_with_item, order):
     form = ChangeQuantityForm(
         {'quantity': 1}, instance=order_line)
     assert form.is_valid()
-    form.save()
+    form.save(None)
     order_line.variant.refresh_from_db()
     assert order_line.variant.quantity_allocated == quantity_before
 
@@ -392,14 +412,14 @@ def test_dashboard_change_quantity_form(request_checkout_with_item, order):
     form = ChangeQuantityForm(
         {'quantity': 2}, instance=order_line)
     assert form.is_valid()
-    form.save()
+    form.save(None)
     order_line.variant.refresh_from_db()
     assert order_line.variant.quantity_allocated == quantity_before + 1
 
     # Decrease quantity
     form = ChangeQuantityForm({'quantity': 1}, instance=order_line)
     assert form.is_valid()
-    form.save()
+    form.save(None)
     order_line.variant.refresh_from_db()
     assert order_line.variant.quantity_allocated == quantity_before
 
@@ -407,8 +427,8 @@ def test_dashboard_change_quantity_form(request_checkout_with_item, order):
 def test_ordered_item_change_quantity(transactional_db, order_with_lines):
     assert not order_with_lines.events.count()
     lines = order_with_lines.lines.all()
-    change_order_line_quantity(lines[1], 0)
-    change_order_line_quantity(lines[0], 0)
+    change_order_line_quantity(None, lines[1], lines[1].quantity, 0)
+    change_order_line_quantity(None, lines[0], lines[0].quantity, 0)
     assert order_with_lines.get_total_quantity() == 0
 
 
@@ -462,13 +482,15 @@ def test_view_fulfillment_packing_slips_without_shipping(
     assert response['content-type'] == 'application/pdf'
 
 
-def test_view_add_variant_to_order(admin_client, order_with_lines):
+def test_view_add_variant_to_order(admin_client, order_with_lines, admin_user):
     order_with_lines.status = OrderStatus.DRAFT
     order_with_lines.save()
     variant = ProductVariant.objects.get(sku='SKU_A')
     line = OrderLine.objects.get(product_sku='SKU_A')
     line_quantity_before = line.quantity
 
+    assert not OrderEvent.objects.exists()
+
     added_quantity = 2
     url = reverse(
         'dashboard:add-variant-to-order',
@@ -483,6 +505,12 @@ def test_view_add_variant_to_order(admin_client, order_with_lines):
         'dashboard:order-details', kwargs={'order_pk': order_with_lines.pk})
     assert line.quantity == line_quantity_before + added_quantity
 
+    removed_items_event = OrderEvent.objects.last()
+    assert removed_items_event.type == OrderEvents.DRAFT_ADDED_PRODUCTS
+    assert removed_items_event.user == admin_user
+    assert removed_items_event.parameters == {
+        'lines': [{'quantity': line.quantity, 'item': str(line)}]}
+
 
 def test_view_change_fulfillment_tracking(admin_client, fulfilled_order):
     fulfillment = fulfilled_order.fulfillments.first()
@@ -502,9 +530,11 @@ def test_view_change_fulfillment_tracking(admin_client, fulfilled_order):
     assert fulfillment.tracking_number == tracking_number
 
 
-def test_view_order_create(admin_client):
-    url = reverse('dashboard:order-create')
+def test_view_order_create(admin_client, admin_user):
+    # Ensure no events were created yet
+    assert not OrderEvent.objects.exists()
 
+    url = reverse('dashboard:order-create')
     response = admin_client.post(url, {})
 
     assert response.status_code == 302
@@ -515,6 +545,11 @@ def test_view_order_create(admin_client):
     assert get_redirect_location(response) == redirect_url
     assert order.status == OrderStatus.DRAFT
 
+    created_draft_event = OrderEvent.objects.get()
+    assert created_draft_event.type == OrderEvents.DRAFT_CREATED
+    assert created_draft_event.user == admin_user
+    assert created_draft_event.parameters == {}
+
 
 def test_view_create_from_draft_order_valid(admin_client, draft_order):
     order = draft_order
@@ -974,10 +1009,62 @@ def test_view_mark_order_as_paid(admin_client, order_with_lines):
     order_with_lines.refresh_from_db()
     assert order_with_lines.is_fully_paid()
     assert order_with_lines.events.filter(
-        type=OrderEvents.ORDER_MARKED_AS_PAID.value).exists()
+        type=OrderEvents.ORDER_MARKED_AS_PAID).exists()
+
+
+@patch('saleor.order.utils.emails.send_fulfillment_confirmation')
+@pytest.mark.parametrize('has_standard,has_digital', (
+        (True, True),
+        (True, False),
+        (False, True)))
+def test_send_fulfillment_order_lines_mails(
+        mocked_send_fulfillment_confirmation,
+        staff_user,
+        fulfilled_order, fulfillment, digital_content,
+        has_standard, has_digital):
+
+    order = fulfilled_order
+    assert order.lines.count() == 2
+
+    if not has_standard:
+        line = order.lines.all()[0]
+        line.variant = digital_content.product_variant
+        assert line.is_digital
+        line.save()
+
+    if has_digital:
+        line = order.lines.all()[1]
+        line.variant = digital_content.product_variant
+        assert line.is_digital
+        line.save()
+
+    send_fulfillment_confirmation_to_customer(
+        order=order, fulfillment=fulfillment, user=staff_user)
+    events = OrderEvent.objects.all()
+
+    mocked_send_fulfillment_confirmation.delay.assert_called_once_with(
+        order.pk, fulfillment.pk)
+
+    # Ensure the standard fulfillment event was triggered
+    assert events[0].user == staff_user
+    assert events[0].parameters == {
+        'email': order.user_email,
+        'email_type': OrderEventsEmails.FULFILLMENT}
+
+    if has_digital:
+        assert len(events) == 2
+        assert events[1].user == staff_user
+        assert events[1].parameters == {
+            'email': order.user_email,
+            'email_type': OrderEventsEmails.DIGITAL_LINKS}
+    else:
+        assert len(events) == 1
 
 
-def test_view_fulfill_order_lines(admin_client, order_with_lines):
+@patch('saleor.dashboard.order.views.'
+       'send_fulfillment_confirmation_to_customer')
+def test_view_fulfill_order_lines(
+        mock_email_fulfillment, admin_client, order_with_lines):
     url = reverse(
         'dashboard:fulfill-order-lines',
         kwargs={'order_pk': order_with_lines.pk})
@@ -1000,6 +1087,7 @@ def test_view_fulfill_order_lines(admin_client, order_with_lines):
     order_with_lines.refresh_from_db()
     for line in order_with_lines.lines.all():
         assert line.quantity_unfulfilled == 0
+    assert mock_email_fulfillment.call_count == 1
 
 
 def test_view_fulfill_order_lines_with_empty_quantity(admin_client, order_with_lines):
@@ -1100,12 +1188,12 @@ def test_view_add_order_note(admin_client, order_with_lines):
     assert order_with_lines.events.first().parameters['message'] == note_content  # noqa
 
 
-@pytest.mark.parametrize('type', [e.value for e in OrderEvents])
+@pytest.mark.parametrize('type', [value for value, _ in OrderEvents.CHOICES])
 def test_order_event_display(admin_user, type, order):
     parameters = {
         'message': 'Example Note',
         'quantity': 12,
-        'email_type': OrderEventsEmails.PAYMENT.value,
+        'email_type': OrderEventsEmails.PAYMENT,
         'email': 'example@example.com',
         'amount': {'_type': 'Money', 'amount': '10.00', 'currency': 'USD'},
         'composed_id': 12,
@@ -1113,7 +1201,7 @@ def test_order_event_display(admin_user, type, order):
         'oversold_items': ['Blue Shirt', 'Red Shirt']}
     event = OrderEvent(
         user=admin_user, order=order, parameters=parameters, type=type)
-    event.get_event_display()
+    display_order_event(event)
 
 
 def test_filter_order_by_status(admin_client):
diff --git a/tests/test_checkout.py b/tests/test_checkout.py
index b59f90d81af..b2c84e8a1a6 100644
--- a/tests/test_checkout.py
+++ b/tests/test_checkout.py
@@ -236,8 +236,8 @@ def test_view_checkout_shipping_method_without_address(
     assert get_redirect_location(response) == redirect_url
 
 
-@patch('saleor.checkout.views.summary.send_order_confirmation')
-def test_view_checkout_summary(
+@patch('saleor.checkout.utils.send_order_confirmation')
+def test_view_checkout_summary_anonymous_user(
         mock_send_confirmation, client, shipping_zone, address,
         request_checkout_with_item):
     request_checkout_with_item.shipping_address = address
@@ -258,13 +258,15 @@ def test_view_checkout_summary(
     assert order.user_email == 'test@example.com'
     redirect_url = reverse('order:payment', kwargs={'token': order.token})
     assert response.request['PATH_INFO'] == redirect_url
-    mock_send_confirmation.delay.assert_called_once_with(order.pk)
+
+    # we expect the user to be anonymous, thus None
+    mock_send_confirmation.delay.assert_called_once_with(order.pk, None)
 
     # checkout should be deleted after order is created
     assert request_checkout_with_item.pk is None
 
 
-@patch('saleor.checkout.views.summary.send_order_confirmation')
+@patch('saleor.checkout.utils.send_order_confirmation')
 def test_view_checkout_summary_authorized_user(
         mock_send_confirmation, authorized_client, customer_user,
         shipping_zone, address, request_checkout_with_item):
@@ -287,10 +289,11 @@ def test_view_checkout_summary_authorized_user(
     assert order.user_email == customer_user.email
     redirect_url = reverse('order:payment', kwargs={'token': order.token})
     assert response.request['PATH_INFO'] == redirect_url
-    mock_send_confirmation.delay.assert_called_once_with(order.pk)
+    mock_send_confirmation.delay.assert_called_once_with(
+        order.pk, customer_user.pk)
 
 
-@patch('saleor.checkout.views.summary.send_order_confirmation')
+@patch('saleor.checkout.utils.send_order_confirmation')
 def test_view_checkout_summary_save_language(
         mock_send_confirmation, authorized_client, customer_user,
         shipping_zone, address, request_checkout_with_item, settings):
@@ -323,7 +326,8 @@ def test_view_checkout_summary_save_language(
     assert order.language_code == user_language
     redirect_url = reverse('order:payment', kwargs={'token': order.token})
     assert response.request['PATH_INFO'] == redirect_url
-    mock_send_confirmation.delay.assert_called_once_with(order.pk)
+    mock_send_confirmation.delay.assert_called_once_with(
+        order.pk, customer_user.pk)
 
 
 def test_view_checkout_summary_without_address(request_checkout_with_item, client):
@@ -529,7 +533,8 @@ def test_create_order_insufficient_stock(
 
     with pytest.raises(InsufficientStock):
         create_order(
-            request_checkout, 'tracking_code', discounts=None, taxes=None)
+            request_checkout, 'tracking_code', discounts=None, taxes=None,
+            user=customer_user)
 
 
 def test_create_order_doesnt_duplicate_order(
@@ -541,18 +546,28 @@ def test_create_order_doesnt_duplicate_order(
     checkout.shipping_method = shipping_method
     checkout.save()
 
-    order_1 = create_order(checkout, tracking_code='', discounts=None, taxes=None)
-    assert order_1.checkout_token == checkout_with_item.token
-    order_2 = create_order(checkout, tracking_code='', discounts=None, taxes=None)
-    assert order_1.pk == order_2.pk
+    with patch.object(checkout, 'delete') as mocked_delete:
+        order_1 = create_order(
+            checkout, tracking_code='', discounts=None, taxes=None,
+            user=customer_user)
+        assert order_1.checkout_token == checkout.token
+
+        order_2 = create_order(
+            checkout, tracking_code='', discounts=None, taxes=None,
+            user=customer_user)
+        assert order_1.pk == order_2.pk
+
+        assert mocked_delete.call_count == 1
 
 
-def test_note_in_created_order(request_checkout_with_item, address):
+def test_note_in_created_order(
+        request_checkout_with_item, address, customer_user):
     request_checkout_with_item.shipping_address = address
     request_checkout_with_item.note = 'test_note'
     request_checkout_with_item.save()
     order = create_order(
-        request_checkout_with_item, 'tracking_code', discounts=None, taxes=None)
+        request_checkout_with_item, 'tracking_code', discounts=None,
+        taxes=None, user=customer_user)
     assert order.customer_note == request_checkout_with_item.note
 
 
diff --git a/tests/test_emails.py b/tests/test_emails.py
index 14d2a30e77a..8cd4cd7cbcd 100644
--- a/tests/test_emails.py
+++ b/tests/test_emails.py
@@ -50,8 +50,7 @@ def test_collect_data_for_email(order):
     template = emails.CONFIRM_PAYMENT_TEMPLATE
     email_data = emails.collect_data_for_email(order.pk, template)
     email_context = email_data['context']
-    # Those properties should be present only for order confirmation email
-    assert 'order' not in email_context
+    # This properties should be present only for order confirmation email
     assert 'schema_markup' not in email_context
 
 
diff --git a/tests/test_order.py b/tests/test_order.py
index 209dda5c3eb..942102c35f1 100644
--- a/tests/test_order.py
+++ b/tests/test_order.py
@@ -294,7 +294,7 @@ def test_restock_fulfillment_lines(fulfilled_order):
 
 
 def test_cancel_order(fulfilled_order):
-    cancel_order(fulfilled_order, restock=False)
+    cancel_order(None, fulfilled_order, restock=False)
     assert all([
         f.status == FulfillmentStatus.CANCELED
         for f in fulfilled_order.fulfillments.all()])
@@ -306,7 +306,7 @@ def test_cancel_fulfillment(fulfilled_order):
     line_1 = fulfillment.lines.first()
     line_2 = fulfillment.lines.first()
 
-    cancel_fulfillment(fulfillment, restock=False)
+    cancel_fulfillment(None, fulfillment, restock=False)
 
     assert fulfillment.status == FulfillmentStatus.CANCELED
     assert fulfilled_order.status == OrderStatus.UNFULFILLED
@@ -440,7 +440,8 @@ def test_update_order_prices(order_with_lines):
 
 
 def test_order_payment_flow(
-        request_checkout_with_item, client, address, shipping_zone, settings):
+        request_checkout_with_item, client, address, customer_user,
+        shipping_zone, settings):
     request_checkout_with_item.shipping_address = address
     request_checkout_with_item.billing_address = address.get_copy()
     request_checkout_with_item.email = 'test@example.com'
@@ -449,7 +450,8 @@ def test_order_payment_flow(
     request_checkout_with_item.save()
 
     order = create_order(
-        request_checkout_with_item, 'tracking_code', discounts=None, taxes=None)
+        request_checkout_with_item, 'tracking_code', discounts=None,
+        taxes=None, user=customer_user)
 
     # Select payment
     url = reverse('order:payment', kwargs={'token': order.token})
@@ -553,7 +555,7 @@ def test_order_weight_add_new_variant(order_with_lines, product):
 def test_order_weight_change_line_quantity(order_with_lines):
     line = order_with_lines.lines.first()
     new_quantity = line.quantity + 2
-    change_order_line_quantity(line, new_quantity)
+    change_order_line_quantity(None, line, new_quantity, line.quantity)
     order_with_lines.refresh_from_db()
     assert order_with_lines.weight == _calculate_order_weight_from_lines(
         order_with_lines)
diff --git a/tests/test_order_utils.py b/tests/test_order_utils.py
new file mode 100644
index 00000000000..b4573d3d8e3
--- /dev/null
+++ b/tests/test_order_utils.py
@@ -0,0 +1,41 @@
+import pytest
+
+from saleor.order.events import OrderEvents
+from saleor.order.models import OrderEvent
+from saleor.order.utils import change_order_line_quantity
+
+
+@pytest.mark.parametrize(
+    'previous_quantity,new_quantity,added_count,removed_count', (
+        (5, 2, 0, 3),
+        (2, 5, 3, 0),
+        (2, 0, 0, 2),
+        (5, 5, 0, 0)))
+def test_change_quantity_generates_proper_event(
+        previous_quantity, new_quantity, added_count, removed_count,
+        order_with_lines, staff_user):
+
+    assert not OrderEvent.objects.exists()
+
+    line = order_with_lines.lines.last()
+    line.quantity = previous_quantity
+
+    change_order_line_quantity(
+        staff_user, line, previous_quantity, new_quantity)
+
+    if removed_count:
+        expected_type = OrderEvents.DRAFT_REMOVED_PRODUCTS
+        expected_quantity = removed_count
+    elif added_count:
+        expected_type = OrderEvents.DRAFT_ADDED_PRODUCTS
+        expected_quantity = added_count
+    else:
+        # No event should have occurred
+        assert not OrderEvent.objects.exists()
+        return
+
+    new_event = OrderEvent.objects.last()  # type: OrderEvent
+    assert new_event.type == expected_type
+    assert new_event.user == staff_user
+    assert new_event.parameters == {
+        'lines': [{'quantity': expected_quantity, 'item': str(line)}]}
diff --git a/tests/test_payment.py b/tests/test_payment.py
index e80d07be0dd..ad3dcd2466f 100644
--- a/tests/test_payment.py
+++ b/tests/test_payment.py
@@ -6,7 +6,7 @@
 from django.core.exceptions import ImproperlyConfigured
 from django.template.loader import get_template
 
-from saleor.order import OrderEvents, OrderEventsEmails
+from saleor.order.events import OrderEvents, OrderEventsEmails
 from saleor.order.views import PAYMENT_TEMPLATE
 from saleor.payment import (
     ChargeStatus, GatewayError, OperationType, PaymentError, TransactionKind,
@@ -106,7 +106,7 @@ def test_handle_fully_paid_order_no_email(
 
     handle_fully_paid_order(order)
     event = order.events.get()
-    assert event.type == OrderEvents.ORDER_FULLY_PAID.value
+    assert event.type == OrderEvents.ORDER_FULLY_PAID
     assert not mock_send_payment_confirmation.called
 
 
@@ -114,12 +114,12 @@ def test_handle_fully_paid_order_no_email(
 def test_handle_fully_paid_order(mock_send_payment_confirmation, order):
     handle_fully_paid_order(order)
     event_order_paid, event_email_sent = order.events.all()
-    assert event_order_paid.type == OrderEvents.ORDER_FULLY_PAID.value
+    assert event_order_paid.type == OrderEvents.ORDER_FULLY_PAID
 
-    assert event_email_sent.type == OrderEvents.EMAIL_SENT.value
+    assert event_email_sent.type == OrderEvents.EMAIL_SENT
     assert event_email_sent.parameters == {
         'email': order.get_user_current_email(),
-        'email_type': OrderEventsEmails.PAYMENT.value}
+        'email_type': OrderEventsEmails.PAYMENT}
 
     mock_send_payment_confirmation.assert_called_once_with(order.pk)
 
@@ -159,7 +159,7 @@ def test_mark_as_paid(admin_user, draft_order):
     assert payment.charge_status == ChargeStatus.FULLY_CHARGED
     assert payment.captured_amount == draft_order.total.gross.amount
     assert draft_order.events.last().type == (
-        OrderEvents.ORDER_MARKED_AS_PAID.value)
+        OrderEvents.ORDER_MARKED_AS_PAID)
 
 
 def test_clean_mark_order_as_paid(payment_txn_preauth):


Code review comments:
diff_hunk: @@ -77,17 +87,15 @@ def create_order_from_draft(request, order_pk):
         msg = pgettext_lazy(
             'Dashboard message related to an order',
             'Order created from draft order')
-        order.events.create(
-            user=request.user,
-            type=OrderEvents.PLACED_FROM_DRAFT.value)
+
+        order_created_event(order=order, user=request.user, from_draft=True)
         messages.success(request, msg)
+
         if form.cleaned_data.get('notify_customer'):
-            send_order_confirmation.delay(order.pk)
-            order.events.create(
-                parameters={
-                    'email': order.get_user_current_email(),
-                    'email_type': OrderEventsEmails.ORDER.value},
-                type=OrderEvents.EMAIL_SENT.value)
+            send_order_confirmation.delay(order.pk, request.user.pk)
+            email_sent_event(
path: saleor/dashboard/order/views.py
text: @user1:
This event is already created within `send_order_confirmation` task.

diff_hunk: @@ -465,27 +463,22 @@ def perform_mutation(cls, _root, info, checkout_id):
         taxes = get_taxes_for_checkout(checkout, info.context.taxes)
         clean_checkout(checkout, taxes, info.context.discounts)
 
+        payment = checkout.get_last_active_payment()
+
         try:
+            # FIXME: we shouldn't create the order, but only do checks
path: saleor/graphql/checkout/mutations.py
text: @user1:
I think this is fine as it is - the same happens in the old storefront.

@author:
The issue is that if the payment fails, the order is placed anyway. Is this really the expected behavior? 

@user1:
We have an issue for that: https://github.com/mirumee/saleor/issues/3877

@user1:
So we'll handle that anyway but we can do it in a separate PR.

diff_hunk: @@ -15,10 +15,16 @@
 from ...core.exceptions import InsufficientStock
 from ...core.utils import get_paginator_items
 from ...core.utils.taxes import get_taxes_for_address
-from ...order import OrderEvents, OrderEventsEmails, OrderStatus
+from ...order import OrderStatus
 from ...order.emails import (
-    send_fulfillment_confirmation, send_fulfillment_update,
+    send_fulfillment_confirmation_to_customer, send_fulfillment_update,
     send_order_confirmation)
+from ...order.events import (
path: saleor/dashboard/order/views.py
text: @user1:
We could also do `from ...order import events` to not have to worry about formatting and ordering of these all functions here.

diff_hunk: @@ -6,41 +6,110 @@ Events
     when certain actions are completed, such us creating the order,
     cancelling fulfillment or completing a payment.
 
-Order events
+Order Events
 ------------
 
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| Code                     | GraphQL API value        | Description                                                         |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``placed``               | ``PLACED``               | An order was placed by the customer.                                |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``draft_placed``         | ``PLACED_FROM_DRAFT``    | An order was created from draft by the staff user.                  |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``oversold_items``       | ``OVERSOLD_ITEMS``       | An order was created from draft, but some line items were oversold. |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``canceled``             | ``CANCELED``             | The order was cancelled.                                            |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``order_paid``           | ``ORDER_PAID``           | The order was fully paid by the customer.                           |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``marked_as_paid``       | ``MARKED_AS_PAID``       | The order was manually marked as fully paid by the staff user.      |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``updated``              | ``UPDATED``              | The order was updated.                                              |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``email_sent``           | ``EMAIL_SENT``           | An email was sent to the customer.                                  |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``captured``             | ``CAPTURED``             | The payment was captured.                                           |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``refunded``             | ``REFUNDED``             | The payment was refunded.                                           |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``voided``               | ``VOIDED``               | The payment was voided.                                             |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``fulfillment_canceled`` | ``FULFILLMENT_CANCELED`` | Fulfillment for one or more of the items was canceled.              |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``restocked_items``      | ``RESTOCKED_ITEMS``      | One or more of the order's items have been resocked                 |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``fulfilled_items``      | ``FULFILLED_ITEMS``      | One or more of the order's items have been fulfilled.               |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``note_added``           | ``NOTE_ADDED``           | A note was added to the order by the staff.                         |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
-| ``other``                | ``OTHER``                | Status used during reimporting of the legacy events.                |
-+--------------------------+--------------------------+---------------------------------------------------------------------+
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| Code                       | GraphQL API value          | Description                                                         | Additional Parameters (GraphQL)                                                                 |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``draft_created``          | ``DRAFT_CREATED``          | A draft order was created by the staff user.                        | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``draft_added_products``   | ``DRAFT_ADDED_PRODUCTS``   | The staff user added products to a draft order.                     | ``lines`` a list of objects containing a ``quantity`` (int) and a ``item`` (string)             |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``draft_removed_products`` | ``DRAFT_REMOVED_PRODUCTS`` | The staff user removed products from a draft order.                 | ``lines`` a list of objects containing a ``quantity`` (int) and a ``item`` (string)             |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``placed``                 | ``PLACED``                 | An order was placed by the customer.                                | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``draft_placed``           | ``PLACED_FROM_DRAFT``      | An order was created from draft by the staff user.                  | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``oversold_items``         | ``OVERSOLD_ITEMS``         | An order was created from draft, but some line items were oversold. | ``oversold_items`` a list of items (strings)                                                    |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``canceled``               | ``CANCELED``               | The order was cancelled.                                            | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``order_paid``             | ``ORDER_PAID``             | The order was fully paid by the customer.                           | ``amount`` (float), ``payment_id`` (string), ``payment_gateway`` (string)                       |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``marked_as_paid``         | ``MARKED_AS_PAID``         | The order was manually marked as fully paid by the staff user.      | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``email_sent``             | ``EMAIL_SENT``             | An email was sent to the customer.                                  | ``email`` (string), ``email_type`` (``OrderEventsEmailsEnum``)                                  |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``captured``               | ``CAPTURED``               | The payment was captured.                                           | ``amount`` (float), ``payment_id`` (string), ``payment_gateway`` (string)                       |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``refunded``               | ``REFUNDED``               | The payment was refunded.                                           | ``amount`` (float), ``payment_id`` (string), ``payment_gateway`` (string)                       |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``voided``                 | ``VOIDED``                 | The payment was voided.                                             | ``amount`` (float), ``payment_id`` (string), ``payment_gateway`` (string)                       |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``payment_failed``         | ``PAYMENT_FAILED``         | The payment failed.                                                 | ``message`` (string), ``payment_id`` (string, optional), ``payment_gateway`` (string, optional) |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``fulfillment_canceled``   | ``FULFILLMENT_CANCELED``   | Fulfillment for one or more of the items was canceled.              | ``composed_id`` (int)                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``restocked_items``        | ``RESTOCKED_ITEMS``        | One or more of the order's items have been resocked                 | ``quantity`` (int)                                                                              |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``fulfilled_items``        | ``FULFILLED_ITEMS``        | One or more of the order's items have been fulfilled.               | ``fulfilled_items`` a list of objects containing a ``quantity`` (int) and a ``item`` (string)   |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``note_added``             | ``NOTE_ADDED``             | A note was added to the order by the staff.                         | ``message`` (string)                                                                            |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+| ``other``                  | ``OTHER``                  | Status used during reimporting of the legacy events.                | None.                                                                                           |
++----------------------------+----------------------------+---------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
+
+Email Event Types
+-----------------
+
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``payment_confirmation``     | ``PAYMENT``          | The order has been fully paid.                                                   |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``shipping_confirmation``    | ``SHIPPING``         | The order has been shipped.                                                      |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``tracking_updated``         | ``TRACKING_UPDATED`` | The shipping tracking number has been updated.                                   |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``order_confirmation``       | ``ORDER``            | The order has been placed.                                                       |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``fulfillment_confirmation`` | ``FULFILLMENT``      | One or more of the order's items have been fulfilled (both physical or digital)  |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+| ``digital_links``            | ``DIGITAL_LINKS``    | The links to the order's digital goods have been sent.                           |
++------------------------------+----------------------+----------------------------------------------------------------------------------+
+
+
+Events Design
+-------------
+
+The ``OrderEventManager`` class contains a bunch of static methods suffixed by
path: docs/architecture/events.rst
text: @user1:
We need to update this section accordingly to current design.