.. _`algorithms-pricing`: Pricing algorithms ================== With pretix being an e-commerce application, one of its core tasks is to determine the price of a purchase. With the complexity allowed by our range of features, this is not a trivial task and there are many edge cases that need to be clearly defined. The most challenging part about this is that there are many situations in which a price might change while the user is going through the checkout process and we're learning more information about them or their purchase. For example, prices change when * The cart expires and the listed prices changed in the meantime * The user adds an invoice address that triggers a change in taxation * The user chooses a custom price for an add-on product and adjusts the price later on * The user adds a voucher to their cart * An automatic discount is applied For the purposes of this page, we're making a distinction between "naive prices" (which are just a plain number like 23.00), and "taxed prices" (which are a combination of a net price, a tax rate, and a gross price, like 19.33 + 19% = 23.00). Computation of listed prices ---------------------------- When showing a list of products, e.g. on the event front page, we always need to show a price. This price is what we call the "listed price" later on. To compute the listed price, we first use the ``default_price`` attribute of the ``Item`` that is being shown. If we are showing an ``ItemVariation`` and that variation has a ``default_price`` set on itself, the variation's price takes precedence and replaces the item's price. If we're in an event series and there exists a ``SubEventItem`` or ``SubEventItemVariation`` with a price set, the subevent's price configuration takes precedence over both the item as well as the variation and replaces the listed price. Listed prices are naive prices. Before we actually show them to the user, we need to check if ``TaxRule.price_includes_tax`` is set to determine if we need to add tax or subtract tax to get to the taxed price. We then consider the event's ``display_net_prices`` setting to figure out which way to present the taxed price in the interface. Guarantees on listed prices --------------------------- One goal of all further logic is that if a user sees a listed price, they are guaranteed to get the product at that price as long as they complete their purchase within the cart expiration time frame. For example, if the cart expiration time is set to 30 minutes and someone puts a item listed at €23 in their cart at 4pm, they can still complete checkout at €23 until 4.30pm, even if the organizer decides to raise the price to €25 at 4.10pm. If they complete checkout after 4.30pm, their cart will be adjusted to the new price and the user will see a warning that the price has changed. Computation of cart prices -------------------------- Input """"" To ensure the guarantee mentioned above, even in the light of all possible dynamic changes, the ``listed_price`` is explicitly stored in the ``CartPosition`` model after the item has been added to the cart. If ``Item.free_price`` is set, the user is allowed to voluntarily increase the price. In this case, the user's input is stored as ``custom_price_input`` without much further validation for use further down below in the process. If ``display_net_prices`` is set, the user's input is also considered to be a net price and ``custom_price_input_is_net`` is stored for the cart position. In any other case, the user's input is considered to be a gross price based on the tax rules' default tax rate. The computation of prices in the cart always starts from the ``listed_price``. The ``list_price`` is only computed when adding the product to the cart or when extending the cart's lifetime after it expired. All other steps such as creating an order based on the cart trust ``list_price`` without further checks. Vouchers """""""" As a first step, the cart is checked for any voucher that should be applied to the position. If such a voucher exists, it's discount (percentage or fixed) is applied to the listed price. The result of this is stored to ``price_after_voucher``. Since ``listed_price`` naive, ``price_after_voucher`` is naive as well. As a consequence, if you have a voucher configured to "set the price to €10", it depends on ``TaxRule.price_includes_tax`` again whether this is €10 including or excluding taxes. The ``price_after_voucher`` is only computed when adding the product to the cart or when extending the cart's lifetime after it expired. It is also checked again when the order is created, since the available discount might have changed due to the voucher's budget being (almost) exhausted. Line price """""""""" The next step computes the final price of this position if it is the only position in the cart. This happens in "reverse order", i.e. before the computation can be performed for a cart position, the step needs to be performed on all of its bundled positions. The sum of ``price_after_voucher`` of all bundled positions is now called ``bundled_sum``. First, the value from ``price_after_voucher`` will be processed by the applicable ``TaxRule.tax()`` (which is complex in itself but is not documented here in detail at the moment). If ``custom_price_input`` is not set, ``bundled_sum`` will be subtracted from the gross price and the net price is adjusted accordingly. The result is stored as ``tax_rate`` and ``line_price_gross`` in the cart position. If ``custom_price_input`` is set, the value will be compared to either the gross or the net value of the ``tax()`` result, depending on ``custom_price_input_is_net``. If the comparison yields that the custom price is higher, ``tax()`` will be called again . Then, ``bundled_sum`` will be subtracted from the gross price and the result is stored like above. The computation of ``line_price_gross`` from ``price_after_voucher``, ``custom_price_input``, and tax settings is repeated after every change of anything in the cart or after every change of the invoice address. Discounts --------- After ``line_price_gross`` has been computed for all positions, the discount engine will run to apply any automatic discounts. Organizers can add rules for automatic discounts in the pretix backend. These rules are ordered and will be applied in order. Every cart position can only be "used" by one discount rule. "Used" can either mean that the price of the position was actually discounted, but it can also mean that the position was required to enable a discount for a different position, e.g. in case of a "buy 3 for the price of 2" offer. The algorithm for applying an individual discount rule first starts with eliminating all products that do not match the rule based on its product scope. Then, the algorithm is handled differently for different configurations. Case 1: Discount based on minimum value without respect to subevents """""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" * Check whether the gross sum of all positions is at least ``condition_min_value``, otherwise abort. * Reduce the price of all positions by ``benefit_discount_matching_percent``. * Mark all positions as "used" to hide them from further rules Case 2: Discount based on minimum number of tickets without respect to subevents """""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" * Check whether the number of all positions is at least ``condition_min_count``, otherwise abort. * If ``benefit_only_apply_to_cheapest_n_maches`` is set, * Sort all positions by price. * Reduce the price of the first ``n_positions // condition_min_count * benefit_only_apply_to_cheapest_n_matches`` positions by ``benefit_discount_matching_percent``. * Mark the first ``n_positions // condition_min_count * condition_min_count`` as "used" to hide them from further rules. * Mark all positions as "used" to hide them from further rules. * Else, * Reduce the price of all positions by ``benefit_discount_matching_percent``. * Mark all positions as "used" to hide them from further rules. Case 3: Discount only for products of the same subevent """"""""""""""""""""""""""""""""""""""""""""""""""""""" * Split the cart into groups based on the subevent. * Proceed with case 1 or 2 for every group. Case 4: Discount only for products of distinct subevents """""""""""""""""""""""""""""""""""""""""""""""""""""""" * Let ``subevents`` be a list of distinct subevents in the cart. * Let ``positions[subevent]`` be a list of positions for every subevent. * Let ``current_group`` be the current group and ``groups`` the list of all groups. * Repeat * Order ``subevents`` by the length of their ``positions[subevent]`` list, starting with the longest list. Do not count positions that are part of ``current_group`` already. * Let ``candidates`` be the concatenation of all ``positions[subevent]`` lists with the same length as the longest list. * If ``candidates`` is empty, abort the repetition. * Order ``candidates`` by their price, starting with the lowest price. * Pick one entry from ``candidates`` and put it into ``current_group``. If ``current_group`` is shorter than ``benefit_only_apply_to_cheapest_n_matches``, we pick from the start (lowest price), otherwise we pick from the end (highest price) * If ``current_group`` is now ``condition_min_count``, remove all entries from ``current_group`` from ``positions[…]``, add ``current_group`` to ``groups``, and reset ``current_group`` to an empty group. * For every position still left in a ``positions[…]`` list, try if there is any ``group`` in groups that it can still be added to without violating the rule of distinct subevents * For every group in ``groups``, proceed with case 1 or 2. Flowchart --------- .. image:: /images/cart_pricing.png