Every SaaS platform eventually needs more than a fixed price list. Happy hour discounts, volume tiers, seasonal promotions, and promo codes are all legitimate requirements. Building a flexible pricing engine in Go means separating rule storage from evaluation, defining clear stacking policies, and making the calculation auditable.
Every SaaS platform eventually needs more than a fixed price list. Happy hour discounts, volume tiers, seasonal promotions, referral codes, and loyalty redemptions are all legitimate product requirements. Hard-coding these into your service logic creates a maintenance nightmare: every new promotion requires a code change and a deployment.
A configurable pricing engine separates rule storage from rule evaluation. Rules live in the database. The engine evaluates which rules apply to a given order context and calculates the final price. This is how we build pricing engines for restaurant SaaS and retail platforms in Lebanon and MENA.
The data model
CREATE TABLE pricing_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name TEXT NOT NULL,
rule_type TEXT NOT NULL,
discount_type TEXT NOT NULL,
discount_value NUMERIC(10, 4) NOT NULL,
max_discount NUMERIC(15, 2),
min_order_total NUMERIC(15, 2),
conditions JSONB NOT NULL DEFAULT '{}',
priority INT NOT NULL DEFAULT 100,
stacking TEXT NOT NULL DEFAULT 'exclusive',
active BOOLEAN NOT NULL DEFAULT true,
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_rule_type CHECK (rule_type IN (
'automatic', 'promo_code'
)),
CONSTRAINT chk_discount_type CHECK (discount_type IN (
'percentage', 'fixed_amount', 'fixed_price'
)),
CONSTRAINT chk_stacking CHECK (stacking IN (
'exclusive', 'additive', 'override'
))
);
CREATE TABLE promo_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
rule_id UUID NOT NULL REFERENCES pricing_rules(id),
code TEXT NOT NULL,
max_uses INT,
uses_count INT NOT NULL DEFAULT 0,
max_uses_per_customer INT,
expires_at TIMESTAMPTZ,
active BOOLEAN NOT NULL DEFAULT true,
UNIQUE (tenant_id, code)
);
The conditions JSONB field stores rule conditions in a flexible schema:
{
"time_ranges": [
{"days": ["monday","tuesday","wednesday","thursday","friday"],
"start": "17:00", "end": "19:00"}
],
"categories": ["beverages", "snacks"],
"min_items": 3
}
The rule type system
type DiscountType string
const (
DiscountPercentage DiscountType = "percentage"
DiscountFixedAmount DiscountType = "fixed_amount"
DiscountFixedPrice DiscountType = "fixed_price"
)
type PricingRule struct {
ID uuid.UUID
TenantID uuid.UUID
Name string
RuleType string
DiscountType DiscountType
DiscountValue decimal.Decimal
MaxDiscount *decimal.Decimal
MinOrderTotal *decimal.Decimal
Conditions RuleConditions
Priority int
Stacking string
StartsAt *time.Time
EndsAt *time.Time
}
type RuleConditions struct {
TimeRanges []TimeRange `json:"time_ranges,omitempty"`
Categories []string `json:"categories,omitempty"`
MinItems int `json:"min_items,omitempty"`
CustomerIDs []uuid.UUID `json:"customer_ids,omitempty"`
}
type TimeRange struct {
Days []string `json:"days"`
Start string `json:"start"` // "HH:MM" in tenant's local time
End string `json:"end"`
}
Rule evaluation
The engine receives an order context and returns the applicable discount:
type OrderContext struct {
TenantID uuid.UUID
CustomerID uuid.UUID
Items []OrderItem
Total decimal.Decimal
OrderTime time.Time
Timezone *time.Location
PromoCode string
}
type DiscountResult struct {
RuleID uuid.UUID
RuleName string
DiscountAmt decimal.Decimal
FinalPrice decimal.Decimal
AppliedRules []AppliedRule
}
func (e *PricingEngine) Evaluate(ctx context.Context, order OrderContext) (*DiscountResult, error) {
rules, err := e.rules.LoadActive(ctx, order.TenantID)
if err != nil {
return nil, err
}
var applicable []PricingRule
for _, rule := range rules {
if e.ruleApplies(rule, order) {
applicable = append(applicable, rule)
}
}
if len(applicable) == 0 {
return &DiscountResult{FinalPrice: order.Total}, nil
}
return e.applyRules(applicable, order), nil
}
Condition checking
func (e *PricingEngine) ruleApplies(rule PricingRule, order OrderContext) bool {
// Check time window
if rule.StartsAt != nil && order.OrderTime.Before(*rule.StartsAt) {
return false
}
if rule.EndsAt != nil && order.OrderTime.After(*rule.EndsAt) {
return false
}
// Check minimum order total
if rule.MinOrderTotal != nil && order.Total.LessThan(*rule.MinOrderTotal) {
return false
}
// Check time-of-day conditions
if len(rule.Conditions.TimeRanges) > 0 {
localTime := order.OrderTime.In(order.Timezone)
if !e.inAnyTimeRange(localTime, rule.Conditions.TimeRanges) {
return false
}
}
// Check item category conditions
if len(rule.Conditions.Categories) > 0 {
if !e.orderHasCategories(order.Items, rule.Conditions.Categories) {
return false
}
}
return true
}
func (e *PricingEngine) inAnyTimeRange(t time.Time, ranges []TimeRange) bool {
dayName := strings.ToLower(t.Weekday().String())
timeStr := t.Format("15:04")
for _, tr := range ranges {
for _, d := range tr.Days {
if d == dayName && timeStr >= tr.Start && timeStr < tr.End {
return true
}
}
}
return false
}
Stacking policy
Multiple rules may apply simultaneously. The stacking policy determines how they combine:
func (e *PricingEngine) applyRules(rules []PricingRule, order OrderContext) *DiscountResult {
// Sort by priority (lower number = higher priority)
sort.Slice(rules, func(i, j int) bool {
return rules[i].Priority < rules[j].Priority
})
var applied []AppliedRule
totalDiscount := decimal.Zero
for _, rule := range rules {
discount := e.calculateDiscount(rule, order.Total)
switch rule.Stacking {
case "exclusive":
// Only apply if no higher-priority exclusive rule already applied
if len(applied) == 0 {
applied = append(applied, AppliedRule{Rule: rule, Discount: discount})
totalDiscount = discount
}
case "additive":
// Stack on top of other discounts
applied = append(applied, AppliedRule{Rule: rule, Discount: discount})
totalDiscount = totalDiscount.Add(discount)
case "override":
// Replace all previously applied rules
applied = []AppliedRule{{Rule: rule, Discount: discount}}
totalDiscount = discount
}
}
finalPrice := order.Total.Sub(totalDiscount)
if finalPrice.IsNegative() {
finalPrice = decimal.Zero
}
return &DiscountResult{
DiscountAmt: totalDiscount,
FinalPrice: finalPrice,
AppliedRules: applied,
}
}
func (e *PricingEngine) calculateDiscount(rule PricingRule, total decimal.Decimal) decimal.Decimal {
var discount decimal.Decimal
switch rule.DiscountType {
case DiscountPercentage:
discount = total.Mul(rule.DiscountValue).Div(decimal.NewFromInt(100))
case DiscountFixedAmount:
discount = rule.DiscountValue
case DiscountFixedPrice:
discount = total.Sub(rule.DiscountValue)
if discount.IsNegative() {
discount = decimal.Zero
}
}
if rule.MaxDiscount != nil && discount.GreaterThan(*rule.MaxDiscount) {
discount = *rule.MaxDiscount
}
return discount
}
Using the decimal package for all price calculations is critical. Float arithmetic accumulates rounding errors at the fourth decimal place, which compounds across thousands of transactions into real currency discrepancies.
Promo code validation
func (s *PricingService) ValidatePromoCode(
ctx context.Context,
tenantID uuid.UUID,
code string,
customerID uuid.UUID,
) (*PricingRule, error) {
promo, err := s.promos.GetByCode(ctx, tenantID, code)
if err != nil {
return nil, ErrInvalidPromoCode
}
if !promo.Active {
return nil, ErrPromoCodeInactive
}
if promo.ExpiresAt != nil && time.Now().After(*promo.ExpiresAt) {
return nil, ErrPromoCodeExpired
}
if promo.MaxUses != nil && promo.UsesCount >= *promo.MaxUses {
return nil, ErrPromoCodeExhausted
}
if promo.MaxUsesPerCustomer != nil {
uses, err := s.promos.CustomerUseCount(ctx, promo.ID, customerID)
if err != nil {
return nil, err
}
if uses >= *promo.MaxUsesPerCustomer {
return nil, ErrPromoCodeLimitReached
}
}
rule, err := s.rules.GetByID(ctx, tenantID, promo.RuleID)
if err != nil {
return nil, err
}
return rule, nil
}
Making calculations auditable
Every order should store the full pricing calculation result alongside the order:
CREATE TABLE order_pricing_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL,
base_total NUMERIC(15, 2) NOT NULL,
discount_amt NUMERIC(15, 2) NOT NULL,
final_total NUMERIC(15, 2) NOT NULL,
applied_rules JSONB NOT NULL,
calculated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
This gives operations staff the ability to audit why a specific order had a specific price, and to detect if a promo code was misapplied.
Key lessons from production
- Store rules in the database. Hard-coded promotions require deployments to change.
- Use
decimal.Decimalfor all price calculations, never float. - Define explicit stacking policies: exclusive, additive, and override cover most real-world cases.
- Promo code validation must check usage limits per code and per customer atomically.
- Log the full pricing calculation for every order: it enables auditing and debugging.
- Load pricing rules with a short cache TTL (30-60 seconds) to avoid database hits on every order evaluation.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
We build SaaS platforms for restaurants and retail operators in Lebanon and across MENA. If you need a flexible pricing and discount engine that operators can configure without code changes, reach out at https://voxire.com/get-a-quote/



