Get a quote

Building a Dynamic Pricing and Discount Engine in Go for SaaS Platforms

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.Decimal for 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.
Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

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/

Back to blog
Chat on WhatsApp