Get a quote

Building a Loyalty Points and Rewards System in Go for SaaS Platforms

Loyalty programs fail in production when the points ledger drifts, tier calculations are inconsistent, or redemption races cause balance errors. Building this correctly in Go requires treating the points ledger like a financial ledger: immutable entries, always-positive balance invariants, and explicit expiry logic.

Loyalty programs fail in production when the points ledger drifts, tier calculations are inconsistent, or redemption races cause balance errors. The surface looks simple: customers earn points for purchases, redeem them for discounts, and progress through tiers. The implementation has the same correctness requirements as a financial system. Building this in Go for SaaS platforms in Lebanon and MENA.

The ledger data model

Points are not stored as a balance column on a customer record. A mutable balance column invites race conditions and makes auditing impossible. Instead, points are stored as an append-only ledger of transactions, and the balance is always derived by summing the ledger.

CREATE TABLE loyalty_accounts (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id    UUID NOT NULL,
    customer_id  UUID NOT NULL,
    tier         TEXT NOT NULL DEFAULT 'bronze',
    lifetime_pts BIGINT NOT NULL DEFAULT 0,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, customer_id)
);

CREATE TABLE points_ledger (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id    UUID NOT NULL,
    account_id   UUID NOT NULL REFERENCES loyalty_accounts(id),
    event_type   TEXT NOT NULL,
    points       BIGINT NOT NULL,
    reference_id UUID,
    expires_at   TIMESTAMPTZ,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT chk_nonzero CHECK (points != 0),
    CONSTRAINT chk_event_type CHECK (event_type IN (
        'order_complete', 'manual_adjust', 'signup_bonus',
        'referral', 'redemption', 'expiry'
    ))
);

CREATE INDEX idx_ledger_account ON points_ledger(account_id, created_at);
CREATE INDEX idx_ledger_expiry ON points_ledger(expires_at) WHERE expires_at IS NOT NULL;

Positive points values are credits; negative values are debits (redemptions, expiry adjustments). The reference_id links a ledger entry to the triggering event (order ID, redemption ID).

Computing the current balance

The balance at any point in time is the sum of non-expired credits minus all debits:

const balanceQuery = `
    SELECT COALESCE(SUM(points), 0)
    FROM points_ledger
    WHERE account_id = $1
      AND (
          expires_at IS NULL
          OR expires_at > now()
          OR points < 0
      )
`

Expired credits are excluded from the balance but remain in the ledger for audit history. Negative entries (debits) are always included regardless of expiry because a redemption that was valid at time of redemption should not be reversed by subsequent expiry logic.

For high-read systems, cache the balance with a short TTL and invalidate on each ledger write rather than computing it on every request.

Earning points: rule-based engine

Earning rules determine how many points a customer earns for an event. Rules are per-tenant and configurable:

type EarnRule struct {
    TenantID  uuid.UUID
    EventType string
    Multiplier float64
    PointsPerUnit int
    PerUnitField string
    TierBonus map[string]float64
}

func (r EarnRule) Calculate(amount int, tier string) int {
    base := float64(amount/100) * float64(r.PointsPerUnit)
    multiplier := r.Multiplier
    if bonus, ok := r.TierBonus[tier]; ok {
        multiplier *= bonus
    }
    return int(base * multiplier)
}

For an order_complete event with PointsPerUnit = 10 (10 points per $1 spent) and a gold tier bonus of 2x, an order of $50 earns 50 * 10 * 2 = 1000 points.

The earn transaction must be idempotent. If the order service calls the loyalty service twice for the same order (due to a retry), the ledger should not double-credit:

func (s *LoyaltyService) CreditPoints(
    ctx context.Context,
    tenantID, accountID uuid.UUID,
    eventType string,
    referenceID uuid.UUID,
    points int,
    expiresAt *time.Time,
) error {
    return s.db.WithTx(ctx, func(tx *sql.Tx) error {
        // Idempotency check: no duplicate entry for same reference
        var existing int
        err := tx.QueryRowContext(ctx,
            `SELECT COUNT(*) FROM points_ledger
             WHERE account_id = $1 AND reference_id = $2 AND event_type = $3`,
            accountID, referenceID, eventType,
        ).Scan(&existing)
        if err != nil {
            return err
        }
        if existing > 0 {
            return nil // Already credited for this reference
        }

        // Insert ledger entry
        _, err = tx.ExecContext(ctx,
            `INSERT INTO points_ledger
             (tenant_id, account_id, event_type, points, reference_id, expires_at)
             VALUES ($1, $2, $3, $4, $5, $6)`,
            tenantID, accountID, eventType, points, referenceID, expiresAt,
        )
        if err != nil {
            return err
        }

        // Update lifetime points (used for tier calculation)
        _, err = tx.ExecContext(ctx,
            `UPDATE loyalty_accounts SET lifetime_pts = lifetime_pts + $1
             WHERE id = $2`,
            points, accountID,
        )
        return err
    })
}

Redemption with balance protection

Redemption must check that sufficient balance exists and record the debit atomically. Concurrent redemptions must not both pass the balance check:

func (s *LoyaltyService) RedeemPoints(
    ctx context.Context,
    tenantID, accountID uuid.UUID,
    points int,
    redemptionID uuid.UUID,
) error {
    return s.db.WithTx(ctx, func(tx *sql.Tx) error {
        // Lock the account row
        var currentBalance int
        err := tx.QueryRowContext(ctx,
            `SELECT COALESCE(SUM(p.points), 0)
             FROM points_ledger p
             JOIN loyalty_accounts a ON a.id = p.account_id
             WHERE a.id = $1 AND a.tenant_id = $2
               AND (p.expires_at IS NULL OR p.expires_at > now() OR p.points < 0)
             FOR UPDATE OF a`,
            accountID, tenantID,
        ).Scan(&currentBalance)
        if err != nil {
            return err
        }

        if currentBalance < points {
            return ErrInsufficientPoints
        }

        // Insert debit entry
        _, err = tx.ExecContext(ctx,
            `INSERT INTO points_ledger
             (tenant_id, account_id, event_type, points, reference_id)
             VALUES ($1, $2, 'redemption', $3, $4)`,
            tenantID, accountID, -points, redemptionID,
        )
        return err
    })
}

The FOR UPDATE OF a clause locks the account row, which serializes concurrent redemption attempts for the same account.

Tier progression logic

Tiers are calculated from lifetime_pts, which is never reduced (it accumulates all earned points regardless of redemptions or expiry):

type TierConfig struct {
    Name      string
    MinPoints int
    Multiplier float64
}

var defaultTiers = []TierConfig{
    {Name: "bronze",   MinPoints: 0,      Multiplier: 1.0},
    {Name: "silver",   MinPoints: 1000,   Multiplier: 1.5},
    {Name: "gold",     MinPoints: 5000,   Multiplier: 2.0},
    {Name: "platinum", MinPoints: 20000,  Multiplier: 3.0},
}

func TierForPoints(lifetimePts int) string {
    current := "bronze"
    for _, t := range defaultTiers {
        if lifetimePts >= t.MinPoints {
            current = t.Name
        }
    }
    return current
}

After each credit transaction, recalculate the tier and update the account if it has changed.

Points expiry

Expiry is handled by a scheduled job that runs nightly and inserts a debit entry for points that have expired:

func (s *LoyaltyService) ProcessExpiredPoints(ctx context.Context) error {
    rows, err := s.db.QueryContext(ctx, `
        SELECT DISTINCT account_id, tenant_id
        FROM points_ledger
        WHERE expires_at <= now()
          AND event_type != 'expiry'
          AND points > 0
    `)
    // For each affected account, compute expired amount and insert debit
    // ...
}

Expiry entries use event_type = 'expiry' so they are distinguishable in the ledger and in customer-facing transaction history.

Key lessons from production

  • Treat the points ledger as a financial ledger: append-only, no mutable balance columns.
  • Use lifetime points for tier calculation, never reducible points.
  • Idempotency keys on earn transactions prevent double-crediting on retries.
  • Redemption must use row locking to prevent concurrent over-redemption.
  • Expiry is a ledger debit entry, not a column update.
  • Cache balances with short TTL for high-read scenarios rather than summing the ledger on every request.
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 backend systems for companies in Lebanon and across MENA. If you need a loyalty or rewards module built correctly from the ground up, reach out at https://voxire.com/get-a-quote/

Back to blog
Chat on WhatsApp