Table of Contents

Tutorial: Build a checkout pricing policy

Build the pricing policy for a web shop as one rule document: member discounts that stack with a big-basket bonus, free shipping over a threshold, a photo-ID check for alcohol, and loyalty points. Then wrap it in a small C# class that turns the rule outputs into a quote, and use a trace to answer "why didn't I get a discount?"

RuleWright 15 minutes

You will learn to:

  • combine AND and OR groups, and stack discounts with addToOutput
  • give a rule an else branch
  • test order lines with Any
  • compute values with divide and concat
  • keep the arithmetic that needs several outputs in C#, where it belongs

Step 1: Create the project

dotnet new console -n Checkout
cd Checkout
dotnet add package RuleWright

Add the checkout model (Checkout, Customer, Order, OrderLine) to the project.

Step 2: Write the policy

Save this as checkout-policy.json (download):

{
  "name": "Checkout policy",
  "description": "Discounts, shipping, age checks and loyalty points for the web shop.",
  "rules": [
    {
      "id": "member-discount",
      "description": "Adult VIPs and customers of five years or more get 10% off.",
      "priority": 40,
      "condition": {
        "type": "group",
        "operator": "AND",
        "rules": [
          { "field": "Customer.Age", "operator": "GreaterThanOrEqual", "value": 18 },
          {
            "type": "group",
            "operator": "OR",
            "rules": [
              { "field": "Customer.IsVip", "operator": "Equals", "value": true },
              { "field": "Customer.LoyaltyYears", "operator": "GreaterThanOrEqual", "value": 5 }
            ]
          }
        ]
      },
      "actions": [
        { "type": "addToOutput", "target": "DiscountPercent", "value": 10 },
        { "type": "appendToOutput", "target": "Notes", "value": "member discount" }
      ]
    },
    {
      "id": "big-basket",
      "description": "Another 5% on baskets of 200 or more, on top of any member discount.",
      "priority": 30,
      "condition": { "field": "Order.Total", "operator": "GreaterThanOrEqual", "value": 200 },
      "actions": [
        { "type": "addToOutput", "target": "DiscountPercent", "value": 5 },
        { "type": "appendToOutput", "target": "Notes", "value": "big basket" }
      ]
    },
    {
      "id": "age-check",
      "description": "Alcohol and tobacco need ID on delivery.",
      "priority": 20,
      "condition": {
        "field": "Order.Lines",
        "operator": "Any",
        "condition": { "field": "Category", "operator": "In", "value": ["alcohol", "tobacco"] }
      },
      "actions": [
        { "type": "setOutput", "target": "IdRequired", "value": true },
        { "type": "appendToOutput", "target": "Notes", "value": "ID required on delivery" }
      ]
    },
    {
      "id": "shipping",
      "description": "Free shipping from 50; otherwise a flat fee.",
      "priority": 10,
      "condition": { "field": "Order.Total", "operator": "GreaterThanOrEqual", "value": 50 },
      "actions": [ { "type": "setOutput", "target": "Shipping", "value": 0 } ],
      "else": [ { "type": "setOutput", "target": "Shipping", "value": 5.95 } ]
    },
    {
      "id": "loyalty-points",
      "description": "One point per ten dollars, for everyone.",
      "condition": { "field": "Order.Total", "operator": "GreaterThan", "value": 0 },
      "actions": [
        {
          "type": "setOutput",
          "target": "Points",
          "value": {
            "op": "divide",
            "operands": [ { "field": "Order.Total" }, 10 ]
          }
        },
        {
          "type": "setOutput",
          "target": "Greeting",
          "value": {
            "op": "concat",
            "operands": [
              "Thanks for your order, ",
              { "field": "Customer.Name" },
              "!"
            ]
          }
        }
      ]
    }
  ]
}

Why it's written this way

  • Discounts add up. Both discount rules use addToOutput, so an adult VIP with a 240 basket gets 10 + 5 = 15%. With setOutput, the rule that ran last would replace the other's value.
  • Shipping has an else. One rule states both outcomes, so there is no second rule with the opposite condition to keep in step.
  • The age check reads the lines. Any tests each order line, and Category resolves against the line, not the checkout.
  • Everyone gets points. loyalty-points matches any order with a total, and computes points and a greeting from the fact.
  • description is for people. The engine ignores it. Write down the business reason, because that is what the next person to edit the rule needs.

Step 3: Turn outputs into a quote

The rules decide percentages and flags. Turning them into money is ordinary code, and it needs two outputs together (the discount percentage and the shipping fee), which rules can't read from each other. A small class keeps that in one place:

using RuleWright.Core;
using RuleWright.Execution;

/// <summary>Applies the checkout policy to a basket and turns its outputs into a quote.</summary>
public sealed class CheckoutPricer
{
    private readonly RuleWrightEngine _engine;
    private readonly LoadedRuleSet _policy;

    public CheckoutPricer(RuleWrightEngine engine, LoadedRuleSet policy)
    {
        _engine = engine;
        _policy = policy;
    }

    public Quote Price(Checkout checkout)
    {
        RuleEvaluationResult result = _engine.Evaluate(_policy, checkout);
        IReadOnlyDictionary<string, object?> outputs = result.Outputs;

        // Outputs are plain values: long for whole JSON numbers, decimal otherwise, or null.
        decimal percent = outputs.TryGetValue("DiscountPercent", out object? p) ? Convert.ToDecimal(p) : 0m;
        decimal shipping = Convert.ToDecimal(outputs["Shipping"]);
        decimal discount = Math.Round(checkout.Order.Total * percent / 100m, 2);

        return new Quote(
            Subtotal: checkout.Order.Total,
            Discount: discount,
            Shipping: shipping,
            Total: checkout.Order.Total - discount + shipping,
            Points: Convert.ToInt32(Math.Floor(Convert.ToDecimal(outputs["Points"]))),
            IdRequired: outputs.ContainsKey("IdRequired"),
            Notes: outputs.TryGetValue("Notes", out object? n) ? ((List<object?>)n!).Cast<string>().ToList() : new List<string>(),
            Greeting: (string?)outputs["Greeting"] ?? "");
    }
}

public sealed record Quote(
    decimal Subtotal, decimal Discount, decimal Shipping, decimal Total,
    int Points, bool IdRequired, List<string> Notes, string Greeting);

Step 4: Load it once, price every basket

At startup:

using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;

RuleWrightEngine engine = new RuleWrightBuilder()
    .UseJsonReader(new SystemTextJsonReader())
    .Build();

LoadedRuleSet policy = engine.LoadRuleSet(File.ReadAllText("checkout-policy.json"));
var pricer = new CheckoutPricer(engine, policy);

Then per basket. Here, Aroha (34, a VIP, 240 including a bottle of wine) and Ben (17, 35 of stationery):

using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;

foreach (Checkout checkout in new[] { Facts.Vip(), Facts.Newcomer() })
{
    Quote quote = pricer.Price(checkout);

    Console.WriteLine(quote.Greeting);
    Console.WriteLine($"  Subtotal  {quote.Subtotal,8:N2}");
    Console.WriteLine($"  Discount  {-quote.Discount,8:N2}");
    Console.WriteLine($"  Shipping  {quote.Shipping,8:N2}");
    Console.WriteLine($"  Total     {quote.Total,8:N2}");
    Console.WriteLine($"  Points    {quote.Points,8}");
    if (quote.IdRequired)
    {
        Console.WriteLine("  ** Photo ID required on delivery **");
    }
    Console.WriteLine($"  Notes: {(quote.Notes.Count == 0 ? "none" : string.Join("; ", quote.Notes))}");
    Console.WriteLine();
}

Output

Thanks for your order, Aroha!
  Subtotal    240.00
  Discount    -36.00
  Shipping      0.00
  Total       204.00
  Points          24
  ** Photo ID required on delivery **
  Notes: member discount; big basket; ID required on delivery

Thanks for your order, Ben!
  Subtotal     35.00
  Discount      0.00
  Shipping      5.95
  Total        40.95
  Points           3
  Notes: none

Step 5: Answer "why didn't I get a discount?"

Ben writes to support: why no member discount? Ask the engine for a trace of his checkout:

using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;

// "Why didn't Ben get a discount?" Ask for a trace and read the member-discount rule.
RuleEvaluationResult result = engine.Evaluate(policy, Facts.Newcomer(), new EvaluationOptions { EnableTrace = true });
RuleTrace member = result.Trace!.Rules.Single(r => r.RuleId == "member-discount");

Console.WriteLine($"member-discount fired: {member.Fired}");
foreach (ConditionTraceNode check in member.Condition!.Children)
{
    Console.WriteLine($"  {check.Passed?.ToString() ?? "not evaluated",-14} {check.Description}");
}

Output

member-discount fired: False
  False          Customer.Age GreaterThanOrEqual 18
  not evaluated  OR

He fails the age check, so the OR that looks at VIP status and loyalty was never evaluated: under 18, nothing else matters. That is the answer for the support ticket, straight from the engine.

Going further

  • Change the policy without redeploying. Keep the document outside the build and reload it when it changes.
  • Test it. Test your rule documents writes table-driven tests for exactly this policy.
  • Serve it. Host rules behind a web API evaluates it over HTTP.
  • Warm it up. Evaluate an empty Checkout at startup so every rule compiles before the first real customer. See Handle errors.