Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adjustments (Order price calculation) #29

Closed
michaelbromley opened this issue Oct 1, 2018 · 4 comments
Closed

Adjustments (Order price calculation) #29

michaelbromley opened this issue Oct 1, 2018 · 4 comments
Projects

Comments

@michaelbromley
Copy link
Member

(Relates to #26)

The price of a ProductVariant is stored as an integer representing the price of the item in cents (or pence etc) without any taxes applied.

When putting together an order, the final price of the OrderItem is therefore subject to:

  1. Taxes (e.g. VAT)
  2. Promotions (buy 1, get 1 free etc.)

Furthermore, the overall Order price is the aggregate of each OrderItem as well as:

  1. Shipping
  2. Promotions (10%discount voucher etc.)

The price modifications listed above are known as Adjustments.

How Adjustments work

adjustments

  1. An AdjustmentSource defines the type of adjustment (tax, promotion or shipping) and also holds logic used to determine whether it should be applied to a given OrderItem or Order.
  2. If it should be applied, then a new Adjustment is created for that OrderItem or Order (known as target).
  3. The Adjustment holds the amount in cents by which the price of the target should be modified - positive for taxes and shipping and negative for promotions.

Determining whether to apply to a target

The basic idea is that each AdjustmentSource would have a function which if called for each potential target (e.g. each OrderItem in an Order) and this function should return true if an Adjustment should be applied or false if not.

The hard part will be figuring out how to allow the administrator to write this function. We don't want arbitrary JavaScript to be written and executed. A couple of alternatives are:

  • Create a form-based interface which can express the common conditions (minimum quantity, minimum value, date restrictions etc) and common results (fixed discount, percentage discount etc).
    ✅ Foolproof - no arbitrary or unexpected code allowed
    ✅ Forms inputs are familiar
    👎 In order to express all possible combinations of condition & result, the form becomes very complex
    👎 Limited expressiveness and less efficient for power users.
  • Create a DSL which can be used to write a written description of the conditions and results. This DSL would have to be verifiable and highlight errors in real-time like how VSCode works with TypeScript.
    ✅ Very expressive and potentially efficient.
    ✅ Potentially much more readable than forms.
    👎 Very complex to implement - if no existing solution exists, I'd have to create our own DSL -> JavaScript "compiler".

Research is needed to figure out the real costs & benefits of each approach, including:

  • Tabulate all possible combinations of adjustment condition / adjustment result to see just how much we need to support
  • Research existing DSL solutions. If there is nothing readily available, get an idea of how much work it would take to implement one, including an editor interface which provides good US for the administrator.
@michaelbromley
Copy link
Member Author

Design Proposal

Firstly, I looked into creating a DSL (e.g. using Chevrotain) and this seems far too complex for the task at hand within the given time constrains. Perhaps it would be an interesting exploration for a future release.

I am formulating a general-purpose pattern which could cover all types of Adjustment in a way that gives sensible defaults but also allows arbitrary extension by a developer:

Adjustment Conditions

Each Adjustment has one or more conditions, which are rules which decide whether or not to apply the Adjustment to the target in question. A condition would have 2 representations: a form with inputs for configuring the condition in the UI, and a predicate function which is evaluated when deciding whether to apply the Adjustment.

We can define a data structure which encompasses both of these representations:

export interface AdjustmentCondition {
  name: string;
  args: Array<{ name: string; type: 'int' | 'money' | 'string' | 'datetime'; }>;
  predicate: (target: OrderItem | Order, args: { [argName: string]: any; }) => boolean;
}

// example
const minimumOrderPriceCondition: AdjustmentCondition  = {
  name: 'Minimum order price',
  args: [
    { name: 'price', type: 'money' }
  ],
  predicate: (order, args) => {
    return order.price > args.price;
  },
}

There would be a number of commonly-used conditions built-in to the framework (minimum order price, minimum item quantity, date range, voucher code applied etc) but creating custom conditions would be as simple as passing a new AdjustmentCondition into a customAdjustmentConditions array in the VendureConfig object.

Adjustment Actions

Once it has been established that an AdjustmentSource should be applied to the target (i.e. every AdjustmentCondition's predicate evaluates to "true"), then one or more AdjustmentActions will be applied to the target.

Actions would be defined in a similar way to conditions - with configuration defining the arguments and used to determine how the form is generated in the admin ui, and a function which uses those arguments to return an adjustment amount:

export interface AdjustmentAction {
  name: string;
  args: Array<{ name: string; type: 'percentage' | 'money' }>;
  calculate: (target: OrderItem | Order, args: { [argName: string]: any; }) => number;
}

const orderPercentageDiscountAction: AdjustmentAction = {
  name: 'Percentage discount on order',
  args: [
    { name: 'percentage', type: 'percentage' },
  ],
  calculate: (order, args) => {
    return order.price * args.percentage;
  },
};

As with conditions, custom actions could be defined in the config object.

Example Adjustments:

Using the above pattern of 1..n conditions and 1..n actions, here is how we could formulate some typical adjustments:

Buy 1 widget, get 1 free

Conditions: OrderItem minimum, 2
Actions: Item discount, buy x get x free

10% off orders over £100

Conditions: Order total greater than, 10000
Actions: Order discount, 10%

Tax Adjustments

Tax adjustments would be handled in much the same way, but we would should additionally have a "tax category" field in the ProductVariant entity which points at a given AdjustmentSource representing that tax category.

In the UK for example, the current VAT rates are zero (0%), reduced (5%) and standard (20%). Here are rates in other EU countries

Taxes will further need to take into account the location of the customer, which will need to be available in the calculate() function.

Shipping Adjustments

Shipping adjustments will have conditions relating to the contents, weight and destination of the Order. The calculate() function will likely refer to either some custom logic applicable to the carrier, or a price table.

michaelbromley added a commit that referenced this issue Oct 4, 2018
@michaelbromley michaelbromley mentioned this issue Oct 9, 2018
michaelbromley added a commit that referenced this issue Oct 17, 2018
Relates to #31 #26 #29
This merge introduces the basis of what seems to be a workable tax &
promotions system. There is still more to do, most importantly solving
the problem of how the admin can set the gross price of a
ProductVariant, as well as working out how the typical range of
promotion actions can be implemented (buy 1 get 1 free, money off order
etc).

However, this work can now be continued on the master branch.
@michaelbromley
Copy link
Member Author

michaelbromley commented Oct 19, 2018

Update

Tax

The tax system is now implemented to a degree that it seems like it is a workable design. (ff31e03). Currently taxes are only applied to OrderItems, not Orders as a whole.

Shipping

I now think shipping might work better as a specialized type of OrderLine, so we can optionally apply taxes to it using the existing tax method.

Promotions

Promotions are currently implemented to a basic degree, but now the design needs to be refined so that we can be sure it is flexible enough to cover the cases we need to support.

Conditions we need to support

  • Date range This is trivial to implement
  • Minimum order total including the option of counting before or after tax
  • Order contains at least n of a given ProductVariant Then need a way to store a reference to the variant
  • Order contains at least n of a given product collection Collections might be implemented using the same system as Categories - namely one or more FacetValues.
  • Order contains at least $n of a given ProductVariant
  • Order contains at least $n of a given product collection
  • Order has a Voucher applied First need to implement Vouchers
  • Customer's nth order
  • Shipping destination (country / zone)

Actions we need to support

  • Fixed discount on Order
  • Percentage discount on Order
  • Fixed discount on matching products (individual or collection)
  • Percentage discount on matching products (individual or collection)
  • Fixed discount on cheapest product in collection
  • Percentage discount on cheapest product in collection
  • Buy n get 1 free Can be used for BOGOF or free gift promotions
  • Shipping discount

@michaelbromley michaelbromley added this to To do in Alpha Dec 31, 2018
@michaelbromley michaelbromley moved this from To do to In progress in Alpha Jan 1, 2019
@michaelbromley
Copy link
Member Author

"Collections" in promotions

In the comment above I mention "collections" as a means to limit the scope of a promotion. For the Alpha, rather than implementing another type of entity, we can do it with FacetValues.

So, e.g. the admin could create a new Facet named "promotions" with the value "spring offer", and then apply that FacetValue to all Products which are to be included in the "Spring Offer" promotion.

Then there should be a PromotionCondition of order contains at least n with facet values, which accepts as an argument 1) the number n and 2) an array of facet value ids, which are applied with a logical AND operation (i.e. the product must have all the given facet values assigned to it)

And a corresponding PromotionAction of apply percentage discount on products with facet values

@michaelbromley
Copy link
Member Author

With 96209cd, async conditions and actions are now supported in Promotions, as well as a mechanism for injecting helper methods via the PromotionUtils object. Also I've made a start on tests for the order calculation with promotions. Therefore I'd call this done, and for specific aspects of the promotions, individual issues can be created (e.g. implementing vouchers).

@michaelbromley michaelbromley moved this from In progress to Done in Alpha Jan 15, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
No open projects
Alpha
  
Done
Development

No branches or pull requests

1 participant