Get a quote

Implementing SaaS Pricing Tiers in Go with PostgreSQL: The Real Production Decisions

The pricing model you choose for a SaaS product is not just a business decision. It determines how you store subscription data, how you enforce feature access in real time, how you count and limit usage, and how you handle edge cases after your first 100 tenants. Most teams treat pricing as a configuration problem and discover it is actually a data modeling problem.

The pricing model you choose for a SaaS product is not just a business decision. It determines how you store subscription data, how you enforce feature access in real time, how you count and limit usage, and how you handle the edge cases that appear after your first 100 tenants. Most teams treat pricing as a configuration problem and discover it is actually a data modeling problem when they try to add a third pricing tier.

The data model that handles tier complexity

The first version of pricing logic in most SaaS backends stores the plan as a string field on the tenant row: plan = "starter" | "growth" | "enterprise". This works until you need to know what each plan includes, what changes when you upgrade, or what happens when a tenant's trial expires.

The model that handles tier complexity cleanly separates three concepts: the plan definition (what it includes), the subscription (which tenant is on which plan), and usage records (what the tenant has consumed).

CREATE TABLE plans (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name         TEXT NOT NULL UNIQUE,
    price_cents  INTEGER NOT NULL,
    currency     TEXT NOT NULL DEFAULT 'USD',
    interval     TEXT NOT NULL DEFAULT 'month',
    features     JSONB NOT NULL DEFAULT '{}',
    limits       JSONB NOT NULL DEFAULT '{}',
    is_active    BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE subscriptions (
    id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id            UUID NOT NULL REFERENCES tenants(id),
    plan_id              UUID NOT NULL REFERENCES plans(id),
    status               TEXT NOT NULL DEFAULT 'active',
    current_period_start TIMESTAMPTZ NOT NULL,
    current_period_end   TIMESTAMPTZ NOT NULL,
    stripe_subscription_id TEXT
);

The features JSONB column holds a map of boolean flags:

{"advanced_reporting": true, "api_access": true, "white_label": false}

The limits JSONB column holds numeric limits:

{"users": 25, "monthly_api_calls": 50000, "storage_gb": 50}

This structure lets you define new plans, change limits, and add features without any code changes. Plan definitions live in the database, not in constants scattered across the application.

Feature gating in Go

Feature checks need to be fast. A middleware or handler that hits PostgreSQL on every request to check whether the tenant's plan includes a feature adds 5 to 15ms to every response. The pattern that works: cache the subscription data in Redis per tenant, invalidate the cache when the subscription changes.

type SubscriptionData struct {
    PlanID   uuid.UUID       `json:"plan_id"`
    Status   string          `json:"status"`
    Features map[string]bool `json:"features"`
    Limits   map[string]int  `json:"limits"`
}

func (s *SubscriptionService) HasFeature(ctx context.Context, tenantID uuid.UUID, feature string) (bool, error) {
    sub, err := s.getCachedSubscription(ctx, tenantID)
    if err != nil {
        return false, err
    }
    if sub.Status != "active" && sub.Status != "trialing" {
        return false, nil
    }
    return sub.Features[feature], nil
}

The cache key is sub:{tenant_id} with a 5-minute TTL. A webhook handler from Stripe invalidates it immediately when a subscription changes. This design means feature checks add no meaningful latency to normal request handling.

Usage in a handler:

func (h *ReportHandler) HandleAdvancedReport(w http.ResponseWriter, r *http.Request) {
    tenantID := TenantIDFromCtx(r.Context())
    allowed, err := h.subs.HasFeature(r.Context(), tenantID, "advanced_reporting")
    if err != nil || !allowed {
        http.Error(w, "upgrade your plan to access this feature", http.StatusPaymentRequired)
        return
    }
    // proceed with report generation
}

Usage limits and real-time enforcement

Soft limits require a count of current usage. For limits that change infrequently (number of team members, connected integrations), query PostgreSQL directly. For limits that increment with every operation (API calls per month, transactions per day), use Redis counters.

func (s *UsageService) RecordAndCheckAPICall(ctx context.Context, tenantID uuid.UUID) (bool, error) {
    key := fmt.Sprintf("usage:%s:api_calls:%s", tenantID, currentMonth())

    pipe := s.redis.Pipeline()
    incrCmd := pipe.Incr(ctx, key)
    pipe.Expire(ctx, key, 35*24*time.Hour)
    _, err := pipe.Exec(ctx)
    if err != nil {
        return true, nil // fail open to avoid blocking on Redis errors
    }

    count := incrCmd.Val()
    sub, err := s.getCachedSubscription(ctx, tenantID)
    if err != nil {
        return true, nil
    }
    limit := int64(sub.Limits["monthly_api_calls"])
    return count <= limit, nil
}

The fail-open pattern (returning true on Redis errors) is intentional for non-critical limits. Blocking all API calls because Redis is temporarily unavailable is worse than briefly exceeding a usage limit. For payment-related limits, invert this: fail closed.

Trial handling and expiration

Free trials need a consistent expiration mechanism. The pattern that works: a cron job that runs every hour, finds subscriptions where status = 'trialing' and trial_end < NOW(), and transitions them to status = 'trial_expired'. Feature gates return false for expired trials.

func (s *SubscriptionService) ExpireTrials(ctx context.Context) error {
    rows, err := s.queries.GetExpiredTrials(ctx)
    if err != nil {
        return err
    }
    for _, sub := range rows {
        if err := s.queries.UpdateSubscriptionStatus(ctx, sub.ID, "trial_expired"); err != nil {
            s.logger.Error("failed to expire trial",
                slog.String("subscription_id", sub.ID.String()),
                slog.String("error", err.Error()))
            continue
        }
        s.cache.Delete(ctx, fmt.Sprintf("sub:%s", sub.TenantID))
        s.notifier.SendTrialExpiredEmail(ctx, sub.TenantID)
    }
    return nil
}

The expiration job is idempotent: running it twice produces the same result. This matters when the job runs twice due to a deployment overlap or a retry after a failure.

Handling MENA payment realities

Stripe handles subscription billing automatically for card payments. In Lebanon and parts of the Gulf, a meaningful percentage of customers pay via bank transfer, cash, or OMT. This means the subscription system needs a manual activation path alongside the Stripe-automated path.

A pending_payment subscription status, visible to the tenant as "awaiting payment confirmation," lets the operations team activate accounts after confirming offline payments. The feature gate logic checks for active and trialing but not pending_payment. This prevents access without requiring complex Stripe integration for every payment method.

The operations dashboard for manually managing these subscriptions should be a simple internal admin interface that shows pending subscriptions, the amount owed, and a one-click activation button. This is not glamorous work but it is the difference between a SaaS that can sell in Lebanon and one that cannot.

Plan changes and proration

When a tenant upgrades mid-period, proration logic needs a defined policy. The three common approaches: prorate the remaining days of the current period, charge the full new plan price at upgrade and refund on the next renewal, or wait until the next billing cycle. Each has tradeoffs. Stripe's proration API handles this automatically for Stripe-managed subscriptions. For manual subscriptions, define the policy explicitly and enforce it in code rather than handling it case by case.

Key lessons from production

Pricing tiers are a data modeling problem disguised as a business configuration problem. The model that scales: plans defined in the database with JSONB features and limits, subscriptions that hold status and period data, Redis caching for feature checks, Redis counters for high-frequency usage limits, and a manual activation path for offline payment markets like Lebanon.

Teams that build this correctly from the start avoid the refactor that happens when the fourth pricing tier cannot fit into the existing constants without touching dozens of different code paths.


Ready to build your SaaS backend?

Voxire builds SaaS platforms in Go with PostgreSQL for companies launching in Lebanon and across the MENA region. We handle pricing logic, subscription management, and payment integration as part of complete backend engagements.

https://voxire.com/get-a-quote/

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.

Back to blog
Chat on WhatsApp