Implement a Custom Pricing Service
What you will build
Section titled “What you will build”By the end of this tutorial, you will have a custom pricing service that:
- Intercepts price resolution at runtime
- Applies volume-based discount tiers
- Calculates customer-specific pricing from negotiated contracts
- Enforces minimum margin floors so discounts never go below cost
- Supports time-bounded promotional overrides
The built-in pricing engine in Commerce handles static price lists well. When your pricing logic requires runtime calculations — contract terms, dynamic market adjustments, or multi-factor discount stacking — you need a custom pricing service.
Before you start
Section titled “Before you start”Ensure you have a Commerce project with products and at least one price list configured. You should be comfortable with .NET dependency injection and the Commerce pricing interfaces.
Step 1: Understand the pricing pipeline
Section titled “Step 1: Understand the pricing pipeline”Commerce resolves prices through a pipeline. When a page or cart requests a price, the system:
- Identifies the variant by catalog key
- Looks up applicable price entries (market, currency, customer group, quantity)
- Selects the best matching price
- Applies any promotions or discount rules
- Returns the final price
Your custom service replaces or extends step 2 and 3. You intercept the price lookup and inject your own logic before the system selects the final price.
Step 2: Create the pricing service interface
Section titled “Step 2: Create the pricing service interface”Define a clean interface for your pricing rules engine. This separates the rule evaluation from the Commerce integration.
public interface IPricingRulesEngine
{
PriceResult CalculatePrice(
PriceContext context);
}
public class PriceContext
{
public string VariantCode { get; set; }
public string MarketId { get; set; }
public string CurrencyCode { get; set; }
public decimal Quantity { get; set; }
public string CustomerGroup { get; set; }
public string CustomerId { get; set; }
public DateTime EvaluationDate { get; set; }
}
public class PriceResult
{
public decimal UnitPrice { get; set; }
public decimal OriginalPrice { get; set; }
public string AppliedRule { get; set; }
public decimal DiscountPercentage { get; set; }
} Step 3: Implement volume discount rules
Section titled “Step 3: Implement volume discount rules”Create the first pricing rule: tiered discounts based on order quantity.
public class VolumeDiscountRule : IPricingRule
{
private static readonly List<VolumeTier> _tiers = new()
{
new(100, 0.05m), // 5% off at 100+ units
new(500, 0.10m), // 10% off at 500+
new(1000, 0.15m), // 15% off at 1000+
new(5000, 0.22m), // 22% off at 5000+
};
public PriceAdjustment Evaluate(
PriceContext context, decimal basePrice)
{
var tier = _tiers
.Where(t => context.Quantity >= t.MinQuantity)
.OrderByDescending(t => t.MinQuantity)
.FirstOrDefault();
if (tier == null) return PriceAdjustment.None;
return new PriceAdjustment
{
DiscountPercent = tier.DiscountPercent,
RuleName = $"Volume: {tier.MinQuantity}+ units"
};
}
}
public record VolumeTier(
int MinQuantity, decimal DiscountPercent); Step 4: Implement customer contract pricing
Section titled “Step 4: Implement customer contract pricing”Add a rule that looks up negotiated contract prices per customer.
public class ContractPricingRule : IPricingRule
{
private readonly IContractRepository _contracts;
public ContractPricingRule(
IContractRepository contracts)
{
_contracts = contracts;
}
public PriceAdjustment Evaluate(
PriceContext context, decimal basePrice)
{
var contract = _contracts.GetActiveContract(
context.CustomerId,
context.VariantCode,
context.EvaluationDate);
if (contract == null)
return PriceAdjustment.None;
var contractPrice = contract.NegotiatedPrice;
var discount = (basePrice - contractPrice) / basePrice;
return new PriceAdjustment
{
FixedPrice = contractPrice,
DiscountPercent = discount,
RuleName = $"Contract: {contract.ContractId}"
};
}
} Step 5: Add time-based promotional pricing
Section titled “Step 5: Add time-based promotional pricing”Create a rule for time-bounded promotions that automatically activate and expire.
public class PromotionalPricingRule : IPricingRule
{
private readonly IPromotionRepository _promos;
public PromotionalPricingRule(
IPromotionRepository promos)
{
_promos = promos;
}
public PriceAdjustment Evaluate(
PriceContext context, decimal basePrice)
{
var promo = _promos.GetActivePromotion(
context.VariantCode,
context.MarketId,
context.EvaluationDate);
if (promo == null)
return PriceAdjustment.None;
var promoPrice = promo.Type switch
{
PromoType.PercentOff => basePrice *
(1 - promo.Value / 100m),
PromoType.FixedPrice => promo.Value,
PromoType.AmountOff => basePrice - promo.Value,
_ => basePrice
};
return new PriceAdjustment
{
FixedPrice = promoPrice,
RuleName = $"Promo: {promo.Name}"
};
}
} Step 6: Enforce minimum margin floors
Section titled “Step 6: Enforce minimum margin floors”Add a safety rule that prevents any discount from pushing the price below your cost plus a minimum margin.
public class MarginFloorRule : IPricingRule
{
private readonly ICostService _costService;
private const decimal MinMarginPercent = 0.15m;
public MarginFloorRule(ICostService costService)
{
_costService = costService;
}
public PriceAdjustment Evaluate(
PriceContext context, decimal calculatedPrice)
{
var cost = _costService
.GetCost(context.VariantCode);
if (cost == null)
return PriceAdjustment.None;
var floorPrice = cost.Value /
(1 - MinMarginPercent);
if (calculatedPrice >= floorPrice)
return PriceAdjustment.None;
return new PriceAdjustment
{
FixedPrice = floorPrice,
RuleName = "Margin floor enforced"
};
}
} Step 7: Build the rules engine
Section titled “Step 7: Build the rules engine”Combine all rules into a pipeline that evaluates them in priority order and selects the best price for the customer.
public class PricingRulesEngine : IPricingRulesEngine
{
private readonly IEnumerable<IPricingRule> _rules;
private readonly IPriceDetailService _basePrices;
public PricingRulesEngine(
IEnumerable<IPricingRule> rules,
IPriceDetailService basePrices)
{
_rules = rules;
_basePrices = basePrices;
}
public PriceResult CalculatePrice(
PriceContext context)
{
var basePrice = _basePrices
.GetBasePrice(context.VariantCode,
context.MarketId, context.CurrencyCode);
if (basePrice == 0)
return null;
var bestPrice = basePrice;
var appliedRule = "List price";
foreach (var rule in _rules)
{
var adjustment = rule.Evaluate(
context, basePrice);
if (adjustment == PriceAdjustment.None)
continue;
var candidatePrice = adjustment.FixedPrice
?? basePrice * (1 - adjustment.DiscountPercent);
if (candidatePrice < bestPrice)
{
bestPrice = candidatePrice;
appliedRule = adjustment.RuleName;
}
}
return new PriceResult
{
UnitPrice = Math.Round(bestPrice, 2),
OriginalPrice = basePrice,
AppliedRule = appliedRule,
DiscountPercentage = (basePrice - bestPrice)
/ basePrice * 100
};
}
} Step 8: Register the pricing service
Section titled “Step 8: Register the pricing service”Register your custom pricing service with the dependency injection container so Commerce uses it instead of the default price resolver.
public void ConfigureServices(IServiceCollection services)
{
// Register individual pricing rules
services.AddScoped<IPricingRule, ContractPricingRule>();
services.AddScoped<IPricingRule, VolumeDiscountRule>();
services.AddScoped<IPricingRule, PromotionalPricingRule>();
services.AddScoped<IPricingRule, MarginFloorRule>();
// Register the engine
services.AddScoped<IPricingRulesEngine,
PricingRulesEngine>();
// Override the default Commerce price resolver
services.Intercept<IPriceService>(
(locator, defaultService) =>
new CustomPriceService(
defaultService,
locator.GetInstance<IPricingRulesEngine>()));
} Step 9: Add pricing diagnostics
Section titled “Step 9: Add pricing diagnostics”Build a diagnostic endpoint that shows which rules were evaluated and which one won. This is essential for debugging pricing issues in production.
[ApiController]
[Route("api/pricing")]
public class PricingDiagnosticsController
: ControllerBase
{
private readonly IPricingRulesEngine _engine;
public PricingDiagnosticsController(
IPricingRulesEngine engine)
{
_engine = engine;
}
[HttpGet("diagnose/{variantCode}")]
[Authorize(Roles = "CommerceAdmins")]
public IActionResult Diagnose(
string variantCode,
[FromQuery] decimal quantity = 1,
[FromQuery] string customerId = null)
{
var context = new PriceContext
{
VariantCode = variantCode,
Quantity = quantity,
CustomerId = customerId,
EvaluationDate = DateTime.UtcNow,
MarketId = "US",
CurrencyCode = "USD"
};
var result = _engine.CalculatePrice(context);
return Ok(new
{
result.UnitPrice,
result.OriginalPrice,
result.AppliedRule,
result.DiscountPercentage,
EvaluatedAt = context.EvaluationDate
});
}
} Step 10: Test the pricing service
Section titled “Step 10: Test the pricing service”Verify that each rule works independently and that the rules interact correctly when multiple apply.
| Scenario | Input | Expected result |
|---|---|---|
| Base price only | SKU “HB-01”, qty 1, no customer | List price returned |
| Volume discount | SKU “HB-01”, qty 500 | 10% discount applied |
| Contract pricing | SKU “HB-01”, Acme customer | Contract price wins |
| Contract + volume | SKU “HB-01”, qty 1000, Acme | Lower of contract or volume price |
| Promotional override | SKU “HB-01” during sale period | Promo price applied |
| Margin floor enforcement | Any scenario where discount exceeds cost+15% | Floor price enforced |
| Diagnostics endpoint | GET /api/pricing/diagnose/HB-01?quantity=500 | Shows applied rule and calculation details |
Run the diagnostics endpoint for each scenario and confirm the AppliedRule field matches expectations. Pay special attention to the margin floor — it should override any discount that would push the price below cost plus your minimum margin.