feat(promotion, dashboard, core-flows, cart, types, utils, medusa): tax inclusive promotions (#12412)

* feat: tax inclusive promotions

* feat: add a totals test case

* feat: add integration test

* chore: changeset

* fix: typo

* chore: refactor

* fix: tests

* fix: rest of buyget action tests

* fix: cart spec

* chore: expand integration test with item level totals

* feat: add a few more test cases

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Frane Polić
2025-06-12 15:07:11 +02:00
committed by GitHub
co-authored by Oli Juhl
parent 08de1f54e4
commit 2621f00bb0
29 changed files with 1091 additions and 24 deletions
@@ -132,6 +132,7 @@ export const prepareAdjustmentsFromPromotionActionsStep = createStep(
.map((action) => ({
code: action.code,
amount: (action as AddItemAdjustmentAction).amount,
is_tax_inclusive: (action as AddItemAdjustmentAction).is_tax_inclusive,
item_id: (action as AddItemAdjustmentAction).item_id,
promotion_id: promotionsMap.get(action.code)?.id,
}))
+5
View File
@@ -22,6 +22,11 @@ export interface AdjustmentLineDTO {
*/
amount: BigNumberValue
/**
* Whether the adjustment is tax inclusive.
*/
is_tax_inclusive?: boolean
/**
* The raw amount to adjust the original amount with.
*/
@@ -236,6 +236,11 @@ export interface CreateAdjustmentDTO {
*/
amount: BigNumberInput
/**
* Whether the adjustment amount includes tax.
*/
is_tax_inclusive?: boolean
/**
* The description of the adjustment.
*/
@@ -10,8 +10,8 @@ import { AdminCreateCampaign } from "../../campaign"
export interface AdminCreatePromotionRule {
/**
* The operator used to check whether the buy rule applies on a cart.
* For example, `eq` means that the cart's value for the specified attribute
* The operator used to check whether the buy rule applies on a cart.
* For example, `eq` means that the cart's value for the specified attribute
* must match the specified value.
*/
operator: PromotionRuleOperatorValues
@@ -21,14 +21,14 @@ export interface AdminCreatePromotionRule {
description?: string | null
/**
* The attribute to compare against when checking whether a promotion can be applied on a cart.
*
*
* @example
* items.product_id
*/
attribute: string
/**
* The value to compare against when checking whether a promotion can be applied on a cart.
*
*
* @example
* prod_123
*/
@@ -54,7 +54,7 @@ export interface AdminCreateApplicationMethod {
value: number
/**
* The currency code of the application method.
*
*
* @example
* usd
*/
@@ -68,12 +68,12 @@ export interface AdminCreateApplicationMethod {
*/
type: ApplicationMethodTypeValues
/**
* The target type of the application method indicating whether the associated promotion is applied
* The target type of the application method indicating whether the associated promotion is applied
* to the cart's items, shipping methods, or the whole order.
*/
target_type: ApplicationMethodTargetTypeValues
/**
* The allocation value that indicates whether the associated promotion is applied on each
* The allocation value that indicates whether the associated promotion is applied on each
* item in a cart or split between the items in the cart.
*/
allocation?: ApplicationMethodAllocationValues
@@ -90,7 +90,7 @@ export interface AdminCreateApplicationMethod {
*/
apply_to_quantity?: number | null
/**
* The minimum quantity required for a `buyget` promotion to be applied. For example,
* The minimum quantity required for a `buyget` promotion to be applied. For example,
* if the promotion is a "Buy 2 shirts get 1 free", the value of this attribute is 2.
*/
buy_rules_min_quantity?: number | null
@@ -111,7 +111,7 @@ export interface AdminUpdateApplicationMethod {
max_quantity?: number | null
/**
* The currency code of the application method.
*
*
* @example
* usd
*/
@@ -121,12 +121,12 @@ export interface AdminUpdateApplicationMethod {
*/
type?: ApplicationMethodTypeValues
/**
* The target type of the application method indicating whether the associated promotion is applied
* The target type of the application method indicating whether the associated promotion is applied
* to the cart's items, shipping methods, or the whole order.
*/
target_type?: ApplicationMethodTargetTypeValues
/**
* The allocation value that indicates whether the associated promotion is applied on each
* The allocation value that indicates whether the associated promotion is applied on each
* item in a cart or split between the items in the cart.
*/
allocation?: ApplicationMethodAllocationValues
@@ -143,7 +143,7 @@ export interface AdminUpdateApplicationMethod {
*/
apply_to_quantity?: number | null
/**
* The minimum quantity required for a `buyget` promotion to be applied. For example,
* The minimum quantity required for a `buyget` promotion to be applied. For example,
* if the promotion is a "Buy 2 shirts get 1 free", the value of this attribute is 2.
*/
buy_rules_min_quantity?: number | null
@@ -155,11 +155,15 @@ export interface AdminCreatePromotion {
*/
code: string
/**
* Whether the promotion is applied automatically
* Whether the promotion is applied automatically
* or requires the customer to manually apply it
* by entering the code at checkout.
*/
is_automatic?: boolean
/**
* Whether the promotion is tax inclusive.
*/
is_tax_inclusive?: boolean
/**
* The type of promotion.
*/
@@ -188,7 +192,7 @@ export interface AdminUpdatePromotion {
*/
code?: string
/**
* Whether the promotion is applied automatically
* Whether the promotion is applied automatically
* or requires the customer to manually apply it
* by entering the code at checkout.
*/
@@ -19,23 +19,23 @@ export interface BasePromotionRule {
description?: string | null
/**
* The attribute to compare against when checking whether a promotion can be applied on a cart.
*
*
* @example
* items.product_id
*/
attribute?: string
/**
* The operator used to check whether the buy rule applies on a cart.
* For example, `eq` means that the cart's value for the specified attribute
* The operator used to check whether the buy rule applies on a cart.
* For example, `eq` means that the cart's value for the specified attribute
* must match the specified value.
*
*
* @example
* eq
*/
operator?: PromotionRuleOperatorValues
/**
* The values to compare against when checking whether a promotion can be applied on a cart.
*
*
* @example
* prod_123
*/
@@ -62,6 +62,7 @@ export interface BasePromotion {
code?: string
type?: PromotionTypeValues
is_automatic?: boolean
is_tax_inclusive?: boolean
application_method?: BaseApplicationMethod
rules?: BasePromotionRule[]
status?: PromotionStatusValues
@@ -60,6 +60,12 @@ export interface AddItemAdjustmentAction {
*/
amount: BigNumberInput
/**
* Whether the adjustment amount includes tax.
*/
is_tax_inclusive?: boolean
/**
/**
* The promotion's code.
*/
@@ -60,6 +60,11 @@ export interface PromotionDTO {
*/
is_automatic?: boolean
/**
* Whether the promotion is tax inclusive.
*/
is_tax_inclusive?: boolean
/**
* The associated application method.
*/
@@ -113,6 +118,11 @@ export interface CreatePromotionDTO {
*/
is_automatic?: boolean
/**
* Whether the promotion is tax inclusive.
*/
is_tax_inclusive?: boolean
/**
* The associated application method.
*/
@@ -557,6 +557,96 @@ describe("Total calculation", function () {
})
})
it("should calculate tax inclusive carts with items + taxes with tax inclusive adjustments", function () {
/**
* TAX INCLUSIVE CART
*
* Total price -> 120 tax inclusive
* Fixed discount -> 10 tax inclusive
* Tax rate -> 20%
*/
const cart = {
items: [
{
unit_price: 60,
quantity: 2,
is_tax_inclusive: true,
adjustments: [
{
amount: 10,
is_tax_inclusive: true,
},
],
tax_lines: [
{
rate: 20,
},
],
},
],
}
const serialized = JSON.parse(JSON.stringify(decorateCartTotals(cart)))
expect(serialized).toEqual({
items: [
{
unit_price: 60,
quantity: 2,
subtotal: 100,
tax_total: 18.333333333333332,
total: 110,
is_tax_inclusive: true,
original_total: 120,
original_tax_total: 20,
discount_subtotal: 8.333333333333334,
discount_tax_total: 1.6666666666666667,
discount_total: 10,
tax_lines: [
{
rate: 20,
total: 18.333333333333332,
subtotal: 20,
},
],
adjustments: [
{
is_tax_inclusive: true,
amount: 10, // <- amount is tax inclusive so it's equal to total
subtotal: 8.333333333333334,
total: 10,
},
],
},
],
subtotal: 100,
tax_total: 18.333333333333332,
total: 110, // total is 120 - 10 tax inclusive discount
original_item_subtotal: 100,
original_item_tax_total: 20,
original_item_total: 120,
original_tax_total: 20,
original_total: 120,
discount_subtotal: 8.333333333333334,
discount_tax_total: 1.6666666666666667,
discount_total: 10,
item_subtotal: 100,
item_tax_total: 18.333333333333332,
item_total: 110,
credit_line_subtotal: 0,
credit_line_tax_total: 0,
credit_line_total: 0,
})
})
it("should calculate carts with items + taxes + adjustments + shipping methods", function () {
const cart = {
items: [
@@ -8,7 +8,7 @@ export function calculateAdjustmentTotal({
includesTax,
taxRate,
}: {
adjustments: Pick<AdjustmentLineDTO, "amount">[]
adjustments: Pick<AdjustmentLineDTO, "amount" | "is_tax_inclusive">[]
includesTax?: boolean
taxRate?: BigNumberInput
}) {
@@ -25,7 +25,15 @@ export function calculateAdjustmentTotal({
}
const adjustmentAmount = MathBN.convert(adj.amount)
adjustmentsSubtotal = MathBN.add(adjustmentsSubtotal, adjustmentAmount)
if (adj.is_tax_inclusive && isDefined(taxRate)) {
adjustmentsSubtotal = MathBN.add(
adjustmentsSubtotal,
MathBN.div(adjustmentAmount, MathBN.add(1, taxRate))
)
} else {
adjustmentsSubtotal = MathBN.add(adjustmentsSubtotal, adjustmentAmount)
}
if (isDefined(taxRate)) {
const adjustmentSubtotal = includesTax
+1 -1
View File
@@ -22,7 +22,7 @@ export interface DecorateCartLikeInputDTO {
unit_price: BigNumberInput
is_tax_inclusive?: boolean
quantity: BigNumberInput
adjustments?: { amount: BigNumberInput }[]
adjustments?: { amount: BigNumberInput; is_tax_inclusive?: boolean }[]
tax_lines?: {
rate: BigNumberInput
}[]