A super simple Pricing Engine

Mike HoganMike Hogan
5 min read

As simple as possible, and no simpler

Customers of Breezbook want pricing rules to support their business. Bookings tomorrow should be cheaper (or more expensive) than bookings today. Weekends, nights and holidays should be more expensive. Offers are cheaper. Bookings involving travel are costed per the mile - and so on.

As a software engineer with more than 30 years of experience, I've often grappled with the challenge of creating flexible, maintainable pricing systems. Today, I want to share a recent approach I've developed that simplifies the pricing engine while providing powerful customization options.

The Challenge

In many SaaS and enterprise applications, pricing rules can quickly become complex. We often need to account for various factors such as time of day, day of the week, holidays, and special promotions. As these rules accumulate, pricing engines can become unwieldy, difficult to maintain, and challenging to extend.

The Solution: Separating Concerns

The key insight that drove my design was to separate the pricing engine's core logic from the calculation of pricing factors. Here's a look at how this plays out in practice:

const addMoreForWeekend: PricingRule = {
    id: 'add-more-for-weekend',
    name: 'Add More For Weekend',
    description: 'Add more for weekend',
    requiredFactors: [pricingFactorName('dayOfWeek')],
    context: {
        weekendDays: ['Saturday', 'Sunday']
    },
    mutations: [
        {
            condition: jexlExpression('weekendDays | includes(dayOfWeek)'),
            mutation: perHour(add(200)),
            description: 'Add £2 per-hour on weekends',
        },
    ],
    applyAllOrFirst: 'all'
}

Let's break down the key components:

  1. Required Factors: The rule specifies what information it needs (in this case, the day of the week). The pricing engine has no in-built knowledge of dayOfWeek - the client code defines whatever factors it wants, and provides values too.

  2. Context: Static data used in the rule's logic is stored here, making it easy to modify without changing the core rule.

  3. Mutations: These define how the price should change when the condition is met.

  4. JEXL Expressions: I use JEXL (JavaScript Expression Language) for flexible, readable conditions.

Benefits of This Approach

  1. Simplicity: The pricing engine only needs to apply rules based on provided factors, simplifying its core logic.

  2. Flexibility: Rules can be easily added, removed, or modified without changing the engine itself.

  3. Maintainability: Separating factor calculation from rule application makes both easier to update and maintain.

  4. Performance: Factor calculation can be optimized or cached on the client side as needed.

Real-World Application

To illustrate the flexibility of this approach, let's look at a more complex rule:

const addMoreForEvening: PricingRule = {
    id: 'add-more-for-evening',
    name: 'Add More For Evening',
    description: 'Add more for evening hours between 18:00 and 24:00',
    requiredFactors: [
        parameterisedPricingFactor('hourCount', 'numberOfEveningHours', {
            startingTime: time24("18:00"),
            endingTime: time24("24:00")
        })
    ],
    mutations: [
        {
            condition: jexlExpression('numberOfEveningHours > 0'),
            mutation: add(jexlExpression('numberOfEveningHours * 100')),
            description: 'Add £1 per-hour for evening bookings',
        },
    ],
    applyAllOrFirst: 'all'
};

This rule introduces a parameterized pricing factor, allowing for complex time-based calculations without complicating the rule structure itself.

You can find the actual use of three similar pricing rules at this permalink.

What are Pricing Factors?

A pricing factor is an abstraction of any value that might be involved in a pricing calculation. It's essentially a name-value pair.

This abstraction is what enables the pricing engine to be so small, cohesive and separated from any application domain.

It is up to the client code of the pricing engine to define what pricing factors matter to it's domain. It could be any of the typical values - customer, service, time, duration - or something more obscure for novel applications like weather forecast or values from a Machine Learning process.

The client code is also responsible for providing values for each pricing factor, and providing it to the PricingEngine at the time of application.

Adding Technical Depth: The Pricing Engine Behind the Rules

While the pricing rules form the heart of our system, the underlying engine that processes these rules is equally important. Here are a few key features of our pricing engine that enable the flexibility and power of our rule-based approach. You can find the code here at this permalink:

  1. JEXL Integration: I've integrated the JavaScript Expression Language (JEXL) to allow for dynamic, string-based expressions in our rules. This means business users can write complex conditions without needing to modify the core code.

     private jexlInstance = new jexl.Jexl();
    

    I'm not 100% sold on JEXL yet, but it's use is well encapsulated. Any other expression library could easily be included, like CEL or whatever. So let's see how this pans out.

  2. Custom JEXL Transforms: I've extended JEXL with custom transforms like 'filter', 'length', and 'includes'. This allows for more expressive and powerful rule conditions.

     this.jexlInstance.addTransform('filter', function (arr: any[], path: string, expression: string) {
         // Implementation details...
     });
    
  3. Flexible Mutation Types: The engine supports various types of price mutations, including simple addition, multiplication, per-hour adjustments, and even custom JEXL-based mutations. This allows for a wide range of pricing strategies.

     private applyMutation(mutation: Mutation, context: any): Price {
         // Different mutation types handled here...
     }
    
  4. Context-Aware Execution: Each rule is executed with a context that includes not just the pricing factors, but also the current price and any external context provided. This allows rules to build upon each other and react to the current state of the pricing calculation.

     private executeRule(rule: PricingRule, currentPrice: Price, factors: PricingFactor[], externalContext: Record<string, any>): PricingResult {
         // Context creation and rule execution...
     }
    
  5. Detailed Pricing Results: The engine doesn't just return a final price – it provides a detailed breakdown of how that price was calculated, including all the adjustments made by each rule. This transparency is crucial for both debugging and explaining price changes to customers.

     export interface PricingResult {
         finalPrice: Price;
         basePrice: Price;
         adjustments: PriceAdjustment[];
         report?: Record<string, any>;
     }
    

These technical features work together to create a pricing engine that's not just powerful, but also flexible and transparent. By separating the rule definitions from the engine logic, I've created a system that can adapt to changing business needs without requiring constant code changes.

The final test that matters of course, is that it's thus far a pleasure to work with.

0
Subscribe to my newsletter

Read articles from Mike Hogan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mike Hogan
Mike Hogan

Building an open source booking and appointments software stack - https://github.com/cozemble/breezbook