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 bybenefit_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 andgroups
the list of all groups.Repeat
Order
subevents
by the length of theirpositions[subevent]
list, starting with the longest list. Do not count positions that are part ofcurrent_group
already.Let
candidates
be the concatenation of allpositions[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 intocurrent_group
. Ifcurrent_group
is shorter thanbenefit_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 nowcondition_min_count
, remove all entries fromcurrent_group
frompositions[…]
, addcurrent_group
togroups
, and resetcurrent_group
to an empty group.
For every position still left in a
positions[…]
list, try if there is anygroup
in groups that it can still be added to without violating the rule of distinct subeventsFor every group in
groups
, proceed with case 1 or 2.