Ledger

Entitlement Checking

Sub-millisecond access control with Redis caching for feature gates and usage limits.

Entitlement Checking determines whether a customer has access to a feature or has exceeded usage limits. It's optimized for sub-millisecond response times using aggressive Redis caching and read-optimized data structures.

Structure

type Check struct {
    SubscriptionID id.SubscriptionID
    CustomerID     id.CustomerID
    Feature        string
    Allowed        bool
    Reason         string
    UsageCurrent   int64
    UsageLimit     int64
    CachedAt       time.Time
}

Checking entitlements

The primary API is entitlement.Check:

import "github.com/xraph/ledger/entitlement"

result, err := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "api-calls",
})

if !result.Allowed {
    return fmt.Errorf("access denied: %s", result.Reason)
}

// Feature is allowed - proceed
processAPICall(ctx)

Check reasons

When Allowed is false, Reason explains why:

ReasonDescription
no_subscriptionCustomer has no active subscription
feature_not_includedFeature not in current plan
limit_exceededUsage limit reached
subscription_inactiveSubscription is canceled/past_due
trial_expiredTrial ended without payment method

Caching strategy

Entitlement checks are cached in Redis with a 60-second TTL:

// First check: queries database and caches result
check1, _ := entitlement.Check(ctx, req) // ~10ms

// Second check: returns from cache
check2, _ := entitlement.Check(ctx, req) // <1ms

Cache keys are structured as:

entitlement:{customer_id}:{feature}

The cache is invalidated when:

  • Subscription changes (upgrade, downgrade, cancel)
  • Usage is recorded for the feature
  • Plan is modified

Feature types

Boolean features

Simple on/off access:

check, _ := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "priority-support",
})

if check.Allowed {
    routeToSupport("priority-queue")
}

Metered features

Features with usage limits:

check, _ := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "api-calls",
})

if !check.Allowed {
    return fmt.Errorf("quota exceeded: %d/%d calls used",
        check.UsageCurrent, check.UsageLimit)
}

fmt.Printf("Remaining: %d calls\n", check.UsageLimit - check.UsageCurrent)

Licensed features

Seat-based features:

check, _ := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "team-members",
    Quantity:   1, // Adding 1 member
})

if !check.Allowed {
    return fmt.Errorf("team full: %d/%d seats used",
        check.UsageCurrent, check.UsageLimit)
}

Batch checking

Check multiple features at once:

results, err := entitlement.CheckBatch(ctx, &entitlement.BatchRequest{
    CustomerID: customer.ID,
    Features:   []string{"api-calls", "team-members", "priority-support"},
})

for feature, check := range results {
    if !check.Allowed {
        log.Printf("%s: denied - %s", feature, check.Reason)
    }
}

Batch checks are performed in parallel and return a map.

Soft limits vs hard limits

Ledger supports both enforcement modes:

Hard limit (default)

Access is denied when limit is reached:

check, _ := entitlement.Check(ctx, req)
if !check.Allowed {
    return ErrQuotaExceeded
}

Soft limit (monitor only)

Access is allowed but overage is tracked:

check, _ := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "api-calls",
    Enforce:    false, // Soft limit
})

// Always allowed, but check.UsageCurrent may exceed check.UsageLimit
if check.UsageCurrent > check.UsageLimit {
    // Log overage event
    events.Emit(ctx, &events.OverageEvent{
        CustomerID: customer.ID,
        Feature:    "api-calls",
        Overage:    check.UsageCurrent - check.UsageLimit,
    })
}

Real-time usage updates

When usage is metered, entitlements are recalculated automatically:

// Record usage
meter.Record(ctx, &meter.Event{
    SubscriptionID: subscription.ID,
    Meter:          "api-calls",
    Value:          1,
})

// Cache invalidated, next check queries fresh usage
check, _ := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "api-calls",
})

Middleware integration

Use entitlement checks in HTTP middleware:

func EntitlementMiddleware(feature string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            customerID := getCustomerID(r.Context())

            check, err := entitlement.Check(r.Context(), &entitlement.Request{
                CustomerID: customerID,
                Feature:    feature,
            })

            if err != nil || !check.Allowed {
                http.Error(w, check.Reason, http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

// Usage:
router.Handle("/api/v1/analytics", EntitlementMiddleware("analytics-access")(analyticsHandler))

Pre-warming the cache

For critical features, pre-warm the cache on subscription creation:

func onSubscriptionCreated(ctx context.Context, sub *subscription.Subscription) {
    plan, _ := store.GetPlan(ctx, sub.PlanID)

    for _, feature := range plan.Features {
        // Prime the cache
        entitlement.Check(ctx, &entitlement.Request{
            CustomerID: sub.CustomerID,
            Feature:    feature.Name,
        })
    }
}

Performance targets

Ledger's entitlement system is optimized for:

MetricTargetActual
Cache hit latency<1ms~0.3ms
Cache miss latency<10ms~8ms
Cache hit rate>95%~98%
Concurrent checks10K/sec15K/sec

Store interface

type Store interface {
    CheckEntitlement(ctx context.Context, req *Request) (*Check, error)
    CheckBatch(ctx context.Context, req *BatchRequest) (map[string]*Check, error)
    InvalidateCache(ctx context.Context, customerID id.CustomerID, feature string) error
    GetEntitlements(ctx context.Context, customerID id.CustomerID) ([]*Check, error)
}

API routes

MethodPathDescription
POST/ledger/entitlements/checkCheck single entitlement
POST/ledger/entitlements/check-batchCheck multiple entitlements
GET/ledger/entitlements/{customerId}List all entitlements
POST/ledger/entitlements/{customerId}/invalidateInvalidate cache

On this page