Ledger

Forge Extension

Mount Ledger as a Forge application extension with automatic tenant isolation.

Ledger integrates with Forge as a first-class extension. This gives you automatic lifecycle management, tenant context extraction from Forge scope, and the ability to share the billing engine across other extensions in the same application.

What is the Forge integration?

Forge is a Go application framework with a standardized extension system. Each extension implements lifecycle hooks (Register, Start, RegisterRoutes, Stop) that Forge calls in order during the application boot sequence. Mounting Ledger as a Forge extension means:

  • The billing engine starts and stops with the application.
  • Database migrations run automatically on startup.
  • Tenant and app IDs are extracted from the Forge request scope -- no manual context.WithValue calls.
  • Other extensions can access engine to check entitlements, meter usage, or generate invoices.

Registering Ledger as a Forge extension

Create a Ledger extension and register it with your Forge app:

package main

import (
    "log/slog"
    "os"
    "time"

    "github.com/xraph/forge"
    "github.com/xraph/ledger"
    "github.com/xraph/ledger/store/memory"
)

func main() {
    app := forge.New()

    // Create the store (use postgres.New(pool) in production)
    store := memory.New()

    // Create the Ledger extension
    ledgerExt := NewLedgerExtension(store,
        ledger.WithLogger(slog.Default()),
        ledger.WithMeterConfig(100, 5*time.Second),
        ledger.WithEntitlementCacheTTL(30*time.Second),
    )

    app.RegisterExtension(ledgerExt)
    app.Run()
}

Extension lifecycle

The Ledger extension participates in all four Forge lifecycle phases:

Lifecycle eventBehaviour
RegisterCreates the *ledger.Ledger engine from the store and options
StartCalls engine.Start(ctx) which runs store.Migrate and starts the meter flush worker
RegisterRoutesMounts billing HTTP endpoints under /v1/billing (plans, subscriptions, invoices, usage)
StopCalls engine.Stop() which flushes remaining meter events and emits OnShutdown to all plugins

Building a Ledger extension

Here is a complete implementation of a Ledger Forge extension:

package main

import (
    "context"
    "net/http"

    "github.com/xraph/forge"
    "github.com/xraph/ledger"
    "github.com/xraph/ledger/store"
)

// LedgerExtension wraps Ledger as a Forge extension.
type LedgerExtension struct {
    store  store.Store
    opts   []ledger.Option
    engine *ledger.Ledger
}

// NewLedgerExtension creates a new Forge extension for Ledger.
func NewLedgerExtension(s store.Store, opts ...ledger.Option) *LedgerExtension {
    return &LedgerExtension{
        store: s,
        opts:  opts,
    }
}

// Name returns the extension name.
func (e *LedgerExtension) Name() string { return "ledger" }

// Register creates the billing engine.
func (e *LedgerExtension) Register(app *forge.App) error {
    e.engine = ledger.New(e.store, e.opts...)
    return nil
}

// Start runs migrations and starts the billing engine.
func (e *LedgerExtension) Start(ctx context.Context) error {
    return e.engine.Start(ctx)
}

// RegisterRoutes mounts billing HTTP endpoints.
func (e *LedgerExtension) RegisterRoutes(router *forge.Router) {
    billing := router.Group("/v1/billing")

    // Plan endpoints
    billing.POST("/plans", e.handleCreatePlan)
    billing.GET("/plans/:id", e.handleGetPlan)

    // Subscription endpoints
    billing.POST("/subscriptions", e.handleCreateSubscription)
    billing.GET("/subscriptions/:id", e.handleGetSubscription)
    billing.DELETE("/subscriptions/:id", e.handleCancelSubscription)

    // Usage endpoints
    billing.POST("/usage", e.handleMeterUsage)

    // Entitlement endpoints
    billing.GET("/entitlements/:feature", e.handleCheckEntitlement)

    // Invoice endpoints
    billing.POST("/invoices/generate", e.handleGenerateInvoice)
    billing.GET("/invoices/:id", e.handleGetInvoice)
}

// Stop shuts down the billing engine.
func (e *LedgerExtension) Stop() error {
    return e.engine.Stop()
}

// Engine returns the underlying Ledger engine for use by other extensions.
func (e *LedgerExtension) Engine() *ledger.Ledger {
    return e.engine
}

Automatic tenant context extraction

In a Forge application, tenant scope is set by authentication middleware. Ledger extracts tenant_id and app_id from the Go context. In Forge, you bridge from the Forge scope to Ledger's context values:

// tenantMiddleware extracts tenant info from Forge scope and sets
// the context values that Ledger expects.
func tenantMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Extract from Forge scope (e.g., JWT claims, API key lookup)
        tenantID := r.Header.Get("X-Tenant-ID")
        appID := r.Header.Get("X-App-ID")

        // Set the context values that Ledger reads
        ctx := context.WithValue(r.Context(), "tenant_id", tenantID)
        ctx = context.WithValue(ctx, "app_id", appID)

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

Register the middleware on your Forge router:

func (e *LedgerExtension) RegisterRoutes(router *forge.Router) {
    billing := router.Group("/v1/billing")
    billing.Use(tenantMiddleware)

    // ... routes as above
}

Now every handler automatically has the correct tenant context. For example:

func (e *LedgerExtension) handleCheckEntitlement(w http.ResponseWriter, r *http.Request) {
    featureKey := forge.Param(r, "feature")

    // r.Context() already has tenant_id and app_id from the middleware
    result, err := e.engine.Entitled(r.Context(), featureKey)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // Return JSON response
    forge.JSON(w, http.StatusOK, result)
}

func (e *LedgerExtension) handleMeterUsage(w http.ResponseWriter, r *http.Request) {
    var req struct {
        FeatureKey string `json:"feature_key"`
        Quantity   int64  `json:"quantity"`
    }
    if err := forge.Bind(r, &req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    if err := e.engine.Meter(r.Context(), req.FeatureKey, req.Quantity); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusAccepted)
}

Accessing the engine from other extensions

After Forge calls Register, other extensions can obtain the Ledger engine to check entitlements or meter usage in their own handlers:

// In another Forge extension:
type APIExtension struct {
    ledger *LedgerExtension
}

func (a *APIExtension) Register(app *forge.App) error {
    // Get the Ledger extension by name
    ext := app.Extension("ledger")
    a.ledger = ext.(*LedgerExtension)
    return nil
}

func (a *APIExtension) handleAPIRequest(w http.ResponseWriter, r *http.Request) {
    // Check entitlement before processing the API request
    result, err := a.ledger.Engine().Entitled(r.Context(), "api_calls")
    if err != nil || !result.Allowed {
        http.Error(w, "quota exceeded", http.StatusTooManyRequests)
        return
    }

    // Process the request...

    // Record the usage
    a.ledger.Engine().Meter(r.Context(), "api_calls", 1)
}

Adding plugins (audit, metrics)

Pass Ledger plugins through the options when creating the extension. The audit hook and observability plugins are built-in:

import (
    audithook "github.com/xraph/ledger/audit_hook"
    "github.com/xraph/ledger/observability"
)

// Create an audit recorder (bridges to your audit trail backend)
auditRecorder := audithook.RecorderFunc(func(ctx context.Context, event *audithook.AuditEvent) error {
    slog.Info("audit",
        "action", event.Action,
        "resource", event.Resource,
        "outcome", event.Outcome,
    )
    return nil
})

// Create the extension with plugins
ledgerExt := NewLedgerExtension(store,
    ledger.WithLogger(slog.Default()),
    ledger.WithMeterConfig(100, 5*time.Second),
    ledger.WithEntitlementCacheTTL(30*time.Second),
    ledger.WithPlugin(audithook.New(auditRecorder)),
    ledger.WithPlugin(observability.NewMetricsExtension(metricsFactory)),
)

The audit hook emits structured audit events for plan creation, subscription changes, invoice generation, and quota violations. The observability plugin tracks counters and histograms for all lifecycle events.

Grove database integration

When your Forge app uses the Grove extension to manage database connections, Ledger can automatically resolve a grove.DB from the DI container and construct the correct store backend (PostgreSQL, SQLite, or MongoDB) based on the driver type.

Using the default grove database

If the Grove extension registers a single database (or a default in multi-DB mode), use WithGroveDatabase with an empty name:

ext := extension.New(
    extension.WithGroveDatabase(""),
)

Using a named grove database

In multi-database setups, reference a specific database by name:

ext := extension.New(
    extension.WithGroveDatabase("billing"),
)

This resolves the grove.DB named "billing" from the DI container and auto-constructs the matching store. The driver type is detected automatically -- you do not need to import individual store packages.

Store resolution order

The extension resolves its store in this order:

  1. Explicit store -- if WithStore(s) was called, it is used directly and grove is ignored.
  2. Grove database -- if WithGroveDatabase(name) was called (or grove_database is set in YAML), the named or default grove.DB is resolved from DI.
  3. In-memory fallback -- if neither is configured, an in-memory store is used.

YAML configuration

The Ledger extension automatically loads configuration from your Forge app's YAML config files. It looks for the key extensions.ledger first, then falls back to ledger:

# forge.yaml (or app.yaml, config.yaml, etc.)
extensions:
  ledger:
    base_path: /billing
    grove_database: billing
    disable_routes: false
    disable_migrate: false
    meter_batch_size: 100
    meter_flush_interval: 5s
    entitlement_cache_ttl: 30s

Or at the top level:

ledger:
  base_path: /billing
  grove_database: ""
  meter_batch_size: 200
  meter_flush_interval: 10s
  entitlement_cache_ttl: 1m

Configuration reference

FieldYAML keyTypeDefaultDescription
BasePathbase_pathstring"/ledger"URL prefix for all ledger HTTP routes
GroveDatabasegrove_databasestring""Name of the grove.DB to resolve from DI; empty uses the default DB
DisableRoutesdisable_routesboolfalsePrevents HTTP route registration (engine-only mode)
DisableMigratedisable_migrateboolfalsePrevents auto-migration on startup
MeterBatchSizemeter_batch_sizeint100Number of usage events buffered before flushing to the store
MeterFlushIntervalmeter_flush_intervalduration5sMax time before the meter buffer is flushed
EntitlementCacheTTLentitlement_cache_ttlduration30sHow long entitlement check results are cached in-process

Merge behaviour

YAML config and programmatic options are merged at startup:

  1. If YAML config is found, it takes precedence for string and numeric fields.
  2. Programmatic bool flags (e.g. WithDisableRoutes(true)) override when true.
  3. Any zero-value fields after merging are filled with defaults from DefaultConfig().
// Programmatic options still work alongside YAML:
ext := extension.New(
    extension.WithStore(store),
    extension.WithDisableRoutes(true), // overrides YAML
)

Requiring YAML config

To make the extension fail if no config is found in YAML files:

ext := extension.New(
    extension.WithStore(store),
    extension.WithRequireConfig(true),
)

This returns an error during Register if neither extensions.ledger nor ledger is present in the config.

Complete example

Putting it all together, here is a Forge application with Ledger as a billing extension:

package main

import (
    "context"
    "log/slog"
    "os"
    "time"

    "github.com/xraph/forge"
    "github.com/xraph/ledger"
    audithook "github.com/xraph/ledger/audit_hook"
    "github.com/xraph/ledger/observability"
    "github.com/xraph/ledger/store/memory"
)

func main() {
    app := forge.New()
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    // Store setup
    store := memory.New()

    // Audit recorder
    recorder := audithook.RecorderFunc(func(ctx context.Context, event *audithook.AuditEvent) error {
        logger.Info("billing.audit",
            "action", event.Action,
            "resource", event.Resource,
            "severity", event.Severity,
        )
        return nil
    })

    // Create the Ledger extension with full configuration
    ledgerExt := NewLedgerExtension(store,
        ledger.WithLogger(logger),
        ledger.WithMeterConfig(100, 5*time.Second),
        ledger.WithEntitlementCacheTTL(30*time.Second),
        ledger.WithPlugin(audithook.New(recorder,
            audithook.WithLogger(logger),
        )),
        ledger.WithPlugin(observability.NewMetricsExtension(app.Metrics())),
    )

    // Register and run
    app.RegisterExtension(ledgerExt)
    app.Run()
}

Next steps

On this page