Skip to content

Commit

Permalink
Add checkout and order promotions (#14696)
Browse files Browse the repository at this point in the history
* Refactor fetching variants for predicates (#14670)

* Refactor method for fetching variants for predicate

* Limit querysets used for filtering

* Fix failing tests

* Extend PromotionRule with checkout_and_order_predicate field (#14664)

* Fix migrations

* Allow defining `checkoutAndOrderPredicate` in promotion mutations (#15054)

* Add checkout_and_order_predicate field to promotion_rule graphql type.

* Add reward_type field.

* Add predicate types.

* Temporarily turn checkoutAndOrderPredicate field into JSON.

* Add input types.

* Validate new input fields.

* Cover validations with tests.

* Remove stuff from previous approach.

* Do not allow mixing predicates.

* Fix promotion rule validation

* Adjust clean_promotion_rule validator

* Extend PromotionCreate validation

* Unify promotion rule validations

* Rename PredicateType ORDER field to CHECKOUT_AND_ORDER

* Add more tests to PromotionRuleUpdate

* Update promotion/validators.py file

* Add docstrings to functions in promotion validators.py file

* Add tests for promotion validators

* Add validation for price based predicate and rule with mixed currencies

* Add mising test for PromotionRuleCreate

* Clear graphql/discount/utils.py

* Fix migrations and removed not needed changes

* Apply code review suggestions

* Refactor get_predicate_type function.

---------

Co-authored-by: IKarbowiak <iga.karbowiak@saleor.io>

* Update changelog

* Include promotion discounts in checkout calculations  (#15052)

* Add CheckoutDiscountedObjectWhere

* Add fetch_promotion_rules_for_checkout method

* Introduce PredicateObjectType

* Add schema of including checkout and order discount

* Extend discount models

* Include checkout and order promotion discount in checkoucalculations

* Adjust create or update checkout discount - remove CheckoutDiscount when the voucher is assigned and update CheckoutOrder if any exist

* Adjust checkout discount creation for checkout and order promotions

* Delete CheckoutDiscount when no rule applies anymore

* Add tests for CheckoutLinesAdd

* Fix failing tests

* Add test for generate_checkout_payload_for_tax_calculation

* Include checkout discounts in CheckoutInfo dataloader

* Add tests for checkout base calculations

* Add tests for webhooks

* Add tests for recalculate_checkout_discount

* Add tests for price_override

* Add tests for avatax

* Add test for calculate_checkout_total method for Avatax plugin

* Add test for checkout calculation

* Fix migrations

* Apply code review suggestions

* test_unable_to_have_promotion_rule_with_mixed_predicates_CORE_2125 (#15210)

* Adjust voucher assignement (#15189)

* Adjsut CheckoutAddPromoCode

* Adjust checkoutRemovePromoCode

* Update test for recalculate_checkout_discount

* Rename checkoutAndOrder discount to order discount (#15207)

* Rename checkoutAndOrder discount to order discount

* Rename occurrences in comments.

---------

Co-authored-by: zedzior <piotr.zabieglik@gmail.com>

* Fix failing tests

* Prevent race condition on `CheckoutDiscount` object creation. (#15212)

* Add unique constrain on CheckoutDiscount model.

* Use get_or_create when creating CheckoutDiscount.

* Make it working.

* Add test.

* Add additional test.

* Adjust checkout discounted object where filter (#15187)

* Adjust total_price filtering in CheckoutDiscountedObjectWhere

* Add filtering by subtotal_price in CheckoutDiscountedObjectWhere

* Apply code review suggestiins and change DiscountedObjectPredicateInput to DiscountedObjectWhereInput

* Fix failing CI

* Fix failing test

* Fix DiscountedObjectWhereInput - include AND, OR operators

* Add rules limit for checkout and order promotions. (#15200)

* Add CHECKOUT_AND_ORDER_RULES_LIMIT env variable

* Add limit check to promotion validators

* Add test for promotion_rule_create mutation.

* Add validation to promotion_create mutation; cover with test.

* Add TODO comment; fix linter.

* Check the number of all checkout and order rules in database, instead of rules asocciated with given promotion.

* Extend errors with rulesLimit and exceedBy fields.

* Rename checkoutAndOrder; fix tests.

* Add order promotions when populating db with dummy data. (#15232)

* test_apply_promotion_with_order_predicate_eq_amount_on_checkout (#15222)

* Get rid of total discount (#15229)

* Get rid of total discount

* Fix failing test

* Adjust promotion rule type (#15213)

* Add predicate_type to PromotionRule model

* Add predicateType to PromotionRuleCreateInput

* Update changelog

* Make predicateType in PromotionRuleCreateInput optional, set the default value

* Fix failing e2e

* Adjust promotion validations

* Fix setting default value for predicateType

* Set predicateType on PromotionRule in sale mutations

* Fix failing tests

* Add type to Promotion

* Get rif of MIXED_PROMOTION_PREDICATES error code

* Update promotion rule validators

* Replace promotion fixture with catalogue_promotion

* Update update_products_discounted_prices_for_promotion_task

* Drop commented line in PromotionCreate

* Collapse migrations introduced for order predicate (#15252)

* Do not re-calculate the promotions when catalogue predicate is empty (#15251)

* Fix `OrderDiscount.type` when converting checkout to order. (#15248)

* Create proper 'OrderDiscount' object, when converting checkout to order.

* Add voucher and voucher code to 'OrderDiscount' when discount type is VOUCHER.

* Add test.

* Fix linter.

* Simplify 'OrderDiscount' create.

* Apply review changes.

* Fix linter.

* test_promotion_applied_on_checkout_with_specified_lte_gte_subtotal (#15261)

* Checkout and order promotions adjustments (#15279)

* Add missing labels

* Add atomic block for setting voucher on checkout and deleting the discount

* Get rid of list() on iterator()

* Fix failing test

* Fix filtering by order predicate (#15332)

* Convert camel case to snake case.

* Improve tests.

* Correct predicates in tests.

* Apply review comments.

* Order promotions performance. (#15347)

* Check if clear discounts is needed; call the function once

* Update base_prices if needed only.

* Fix tests.

* Refactor.

---------

Co-authored-by: Piotr Zabieglik <55899043+zedzior@users.noreply.github.com>
Co-authored-by: Renata <renata.gajzlerowicz@gmail.com>
Co-authored-by: zedzior <piotr.zabieglik@gmail.com>
Co-authored-by: Maciej Korycinski <maciej.korycinski@saleor.io>
  • Loading branch information
5 people committed Feb 7, 2024
1 parent b6f1b04 commit b2a6e00
Show file tree
Hide file tree
Showing 139 changed files with 7,797 additions and 870 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ All notable, unreleased changes to this project will be documented in this file.

### Breaking changes
- Drop `OrderBulkCreateInput.voucher` field. Use `OrderBulkCreateInput.voucherCode` instead. - #14553 by @zedzior
- Add new `type` field to `PromotionCreateInput`, the field will be required from 3.20 - #14696 by @IKarbowiak, @zedzior
- Do not stack promotion rules within the promotion. Only the best promotion rule will be applied within the promotion. Previously discounts from all rules within the promotion that gives the best discount were applied to the variant's price - #15309 by @korycins

### GraphQL API

- Add taxes to undiscounted prices - #14095 by @jakubkuc
- Mark as deprecated: `ordersTotal`, `reportProductSales` and `homepageEvents` - #14806 by @8r2y5
- Add `identifier` field to App graphql object. Identifier field is the same as Manifest.id field (explicit ID set by the app).
- Introduce `checkoutAndOrder` promotion rules that allow applying discounts during checkout calculations when the checkout meets certain conditions. - #14696 by @IKarbowiak, @zedzior

### Saleor Apps

Expand Down
77 changes: 53 additions & 24 deletions saleor/checkout/base_calculations.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from ..discount import DiscountValueType, VoucherType

if TYPE_CHECKING:
from decimal import Decimal

from ..channel.models import Channel
from .fetch import CheckoutInfo, CheckoutLineInfo, ShippingMethodInfo

Expand All @@ -26,7 +28,7 @@ def calculate_base_line_unit_price(
) -> Money:
"""Calculate line unit price including discounts and vouchers.
The price includes sales, specific product and applied once per order
The price includes catalogue promotions, specific product and applied once per order
voucher discounts.
The price does not include the entire order discount.
"""
Expand All @@ -41,12 +43,13 @@ def calculate_base_line_unit_price(
def calculate_base_line_total_price(
line_info: "CheckoutLineInfo",
channel: "Channel",
include_voucher: bool = True,
) -> Money:
"""Calculate line total price including discounts and vouchers.
The price includes sales, specific product and applied once per order
The price includes catalogue promotions, specific product and applied once per order
voucher discounts.
The price does not include the entire order discount.
The price does not include order promotions and the entire order vouchers.
"""
variant = line_info.variant
currency = line_info.channel_listing.currency
Expand All @@ -62,7 +65,7 @@ def calculate_base_line_total_price(
discount_amount = Money(discount.amount_value, line_info.line.currency)
total_price -= discount_amount

if line_info.voucher:
if include_voucher and line_info.voucher:
if not line_info.voucher.apply_once_per_order:
if line_info.voucher.discount_value_type == DiscountValueType.PERCENTAGE:
voucher_discount_amount = line_info.voucher.get_discount_amount_for(
Expand Down Expand Up @@ -116,7 +119,7 @@ def calculate_undiscounted_base_line_unit_price(
line_info: "CheckoutLineInfo",
channel: "Channel",
):
"""Calculate line unit price without including discounts and vouchers."""
"""Calculate line unit price without discounts and vouchers."""
variant = line_info.variant
variant_price = variant.get_base_price(
line_info.channel_listing, line_info.line.price_override
Expand All @@ -127,6 +130,7 @@ def calculate_undiscounted_base_line_unit_price(
def base_checkout_delivery_price(
checkout_info: "CheckoutInfo",
lines: Optional[Iterable["CheckoutLineInfo"]] = None,
include_voucher: bool = True,
) -> Money:
"""Calculate base (untaxed) price for any kind of delivery method."""
currency = checkout_info.checkout.currency
Expand All @@ -135,7 +139,7 @@ def base_checkout_delivery_price(

is_shipping_voucher = (
checkout_info.voucher.type == VoucherType.SHIPPING
if checkout_info.voucher
if include_voucher and checkout_info.voucher
else False
)

Expand Down Expand Up @@ -199,9 +203,9 @@ def base_checkout_total(
) -> Money:
"""Return the total cost of the checkout.
The price includes sales, shipping, specific product and applied once per order
voucher discounts.
The price does not include the entire order discount.
The price includes catalogue promotions, shipping, specific product
and applied once per order voucher discounts.
The price does not include order promotions and the entire order vouchers.
"""
currency = checkout_info.checkout.currency
subtotal = base_checkout_subtotal(lines, checkout_info.channel, currency)
Expand All @@ -214,17 +218,19 @@ def base_checkout_subtotal(
checkout_lines: Iterable["CheckoutLineInfo"],
channel: "Channel",
currency: str,
include_voucher: bool = True,
) -> Money:
"""Return the checkout subtotal value.
The price includes sales, specific product and applied once per order
The price includes catalogue promotions, specific product and applied once per order
voucher discounts.
The price does not include the entire order discount.
The price does not include order promotions and the entire order vouchers.
"""
line_totals = [
calculate_base_line_total_price(
line,
channel,
include_voucher=include_voucher,
)
for line in checkout_lines
]
Expand All @@ -245,13 +251,13 @@ def checkout_total(
shipping_price = base_checkout_delivery_price(checkout_info, lines)
discount = checkout_info.checkout.discount

# only entire_order discount with apply_once_per_order set to False is not
# already included in the total price
discount_not_included = (
checkout_info.voucher.type == VoucherType.ENTIRE_ORDER
# order promotion discount and entire_order voucher discount with
# apply_once_per_order set to False are not included in the total price yet
discounted_object_promotion = bool(checkout_info.discounts)
discount_not_included = discounted_object_promotion or (
checkout_info.voucher
and checkout_info.voucher.type == VoucherType.ENTIRE_ORDER
and not checkout_info.voucher.apply_once_per_order
if checkout_info.voucher
else False
)
# Discount is subtracted from both gross and net values, which may cause negative
# net value if we are having a discount that covers whole price.
Expand All @@ -268,21 +274,44 @@ def apply_checkout_discount_on_checkout_line(
):
"""Calculate the checkout line price with discounts.
Include the entire order voucher discount.
Include the entire order voucher discount or discount from order
promotion (this discount is applied only when voucher code is not set).
The discount amount is calculated for every line proportionally to
the rate of total line price to checkout total price.
"""
voucher = checkout_info.voucher
if (
not voucher
or voucher.apply_once_per_order
if voucher and (
voucher.apply_once_per_order
or voucher.type in [VoucherType.SHIPPING, VoucherType.SPECIFIC_PRODUCT]
):
return line_total_price

if not voucher and not checkout_info.discounts:
return line_total_price

total_discount_amount = checkout_info.checkout.discount_amount
line_total_price = line_total_price
currency = checkout_info.checkout.currency
return _get_discounted_checkout_line_price(
checkout_line_info,
lines,
line_total_price,
total_discount_amount,
checkout_info.channel,
)


def _get_discounted_checkout_line_price(
checkout_line_info: "CheckoutLineInfo",
lines: Iterable["CheckoutLineInfo"],
line_total_price: Money,
total_discount_amount: "Decimal",
channel: "Channel",
):
"""Apply checkout discount on checkout line price.
Propagate the discount amount proportionally to total prices of items.
Ensure that the sum of discounts is equal to the discount amount.
"""
currency = channel.currency_code

lines = list(lines)

Expand All @@ -299,7 +328,7 @@ def apply_checkout_discount_on_checkout_line(
lines_total_prices = [
calculate_base_line_total_price(
line_info,
checkout_info.channel,
channel,
).amount
for line_info in lines
if line_info.line.id != checkout_line_info.line.id
Expand Down
2 changes: 1 addition & 1 deletion saleor/checkout/calculations.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ def _fetch_checkout_prices_if_expired(
charge_taxes = get_charge_taxes_for_checkout(checkout_info, lines)
should_charge_tax = charge_taxes and not checkout.tax_exemption

create_or_update_discount_objects_from_promotion_for_checkout(lines)
create_or_update_discount_objects_from_promotion_for_checkout(checkout_info, lines)

if prices_entered_with_tax:
# If prices are entered with tax, we need to always calculate it anyway, to
Expand Down
38 changes: 29 additions & 9 deletions saleor/checkout/complete_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from ..core.transactions import transaction_with_commit_on_errors
from ..core.utils.url import validate_storefront_url
from ..discount import DiscountType, DiscountValueType
from ..discount.models import NotApplicable, OrderLineDiscount
from ..discount.models import CheckoutDiscount, NotApplicable, OrderLineDiscount
from ..discount.utils import (
get_sale_id,
increase_voucher_usage,
Expand Down Expand Up @@ -576,7 +576,7 @@ def _create_order(
tax_exemption=checkout_info.checkout.tax_exemption,
)

_handle_checkout_discount(order, checkout)
_create_order_discount(order, checkout_info)

order_lines: list[OrderLine] = []
order_line_discounts: list[OrderLineDiscount] = []
Expand Down Expand Up @@ -1053,12 +1053,28 @@ def _handle_allocations_of_order_lines(
)


def _handle_checkout_discount(order: "Order", checkout: "Checkout"):
if checkout.discount:
# store voucher as a fixed value as it this the simplest solution for now.
# This will be solved when we refactor the voucher logic to use .discounts
# relations
def _create_order_discount(order: "Order", checkout_info: "CheckoutInfo"):
checkout = checkout_info.checkout
checkout_discount = checkout.discounts.first()
is_voucher_discount = checkout.discount and not checkout_discount
is_promotion_discount = (
checkout_discount and checkout_discount.type == DiscountType.ORDER_PROMOTION
)

if is_promotion_discount:
checkout_discount = cast(CheckoutDiscount, checkout_discount)
discount_data = model_to_dict(checkout_discount)
discount_data["promotion_rule"] = checkout_discount.promotion_rule
del discount_data["checkout"]
order.discounts.create(**discount_data)

if is_voucher_discount:
# Currently, we don't create `CheckoutDiscount` of type VOUCHER, so if there is
# discount on checkout, but not related `CheckoutDiscount`, we assume it is
# a voucher discount.
# Store voucher as a fixed value as it this the simplest solution for now.
# This will be solved when we refactor the voucher logic to use .discounts
# relations.
order.discounts.create(
type=DiscountType.VOUCHER,
value_type=DiscountValueType.FIXED,
Expand All @@ -1067,6 +1083,10 @@ def _handle_checkout_discount(order: "Order", checkout: "Checkout"):
translated_name=checkout.translated_discount_name,
currency=checkout.currency,
amount_value=checkout.discount_amount,
voucher=checkout_info.voucher,
voucher_code=checkout_info.voucher_code.code
if checkout_info.voucher_code
else None,
)


Expand Down Expand Up @@ -1199,7 +1219,7 @@ def _create_order_from_checkout(
)

# checkout discount
_handle_checkout_discount(order, checkout_info.checkout)
_create_order_discount(order, checkout_info)

# lines
order_lines_info = _create_order_lines_from_checkout_lines(
Expand Down Expand Up @@ -1319,7 +1339,7 @@ def create_order_from_checkout(
# ensure that we are processing checkout on the current data.
checkout_lines, _ = fetch_checkout_lines(checkout, voucher=voucher)
checkout_info = fetch_checkout_info(
checkout, checkout_lines, manager, voucher=voucher
checkout, checkout_lines, manager, voucher=voucher, voucher_code=code
)
assign_checkout_user(user, checkout_info)

Expand Down
11 changes: 9 additions & 2 deletions saleor/checkout/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@
from ..account.models import Address, User
from ..channel.models import Channel
from ..discount.interface import VariantPromotionRuleInfo, VoucherInfo
from ..discount.models import CheckoutLineDiscount, Voucher, VoucherCode
from ..discount.models import (
CheckoutDiscount,
CheckoutLineDiscount,
Voucher,
VoucherCode,
)
from ..plugins.manager import PluginsManager
from ..product.models import (
Collection,
Expand Down Expand Up @@ -74,6 +79,7 @@ class CheckoutInfo:
all_shipping_methods: list["ShippingMethodData"]
tax_configuration: "TaxConfiguration"
valid_pick_up_points: list["Warehouse"]
discounts: list["CheckoutDiscount"]
voucher: Optional["Voucher"] = None
voucher_code: Optional["VoucherCode"] = None

Expand Down Expand Up @@ -423,7 +429,7 @@ def fetch_checkout_info(
if shipping_channel_listings is None:
shipping_channel_listings = channel.shipping_method_listings.all()

if not voucher:
if not voucher or not voucher_code:
voucher, voucher_code = get_voucher_for_checkout(
checkout, channel_slug=channel.slug
)
Expand All @@ -439,6 +445,7 @@ def fetch_checkout_info(
tax_configuration=tax_configuration,
all_shipping_methods=[],
valid_pick_up_points=[],
discounts=list(checkout.discounts.all()),
voucher=voucher,
voucher_code=voucher_code,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Generated by Django 3.2.23 on 2024-01-03 08:53

from decimal import Decimal

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
(
"checkout",
"0062_update_checkout_last_transaction_modified_at_and_refundable",
),
]

operations = [
migrations.AddField(
model_name="checkout",
name="base_subtotal_amount",
field=models.DecimalField(
decimal_places=3, default=Decimal("0"), max_digits=12
),
),
migrations.AddField(
model_name="checkout",
name="base_total_amount",
field=models.DecimalField(
decimal_places=3, default=Decimal("0"), max_digits=12
),
),
migrations.RunSQL(
"""
ALTER TABLE checkout_checkout
ALTER COLUMN base_subtotal_amount
SET DEFAULT 0;
""",
migrations.RunSQL.noop,
),
migrations.RunSQL(
"""
ALTER TABLE checkout_checkout
ALTER COLUMN base_total_amount
SET DEFAULT 0;
""",
migrations.RunSQL.noop,
),
]
Loading

0 comments on commit b2a6e00

Please sign in to comment.