Ledger

Multi-Tenancy

How Ledger isolates billing data across organizations and customers for secure multi-tenant SaaS.

Ledger is built for multi-tenant SaaS applications. Every billing entity is scoped to an organization (tenant boundary), ensuring complete data isolation. This scoping is enforced at the store layer — cross-tenant access is structurally impossible.

Tenant isolation

All billing entities belong to a specific tenant:

type Plan struct {
    ID           PlanID `json:"id"`
    TenantID     string `json:"tenant_id"` // Organization/tenant identifier
    Name         string `json:"name"`
    // ...
}

type Customer struct {
    ID       CustomerID `json:"id"`
    TenantID string     `json:"tenant_id"`
    Email    string     `json:"email"`
    // ...
}

type Subscription struct {
    ID         SubscriptionID `json:"id"`
    TenantID   string         `json:"tenant_id"`
    CustomerID CustomerID     `json:"customer_id"`
    // ...
}

Context injection

Tenant identifiers are injected into the Go context using helper functions:

import "github.com/xraph/ledger"

ctx = ledger.WithTenant(ctx, "org-acme-corp")

Extraction

Retrieve the tenant value from any context:

tenantID := ledger.TenantFromContext(ctx) // "org-acme-corp"

Returns an empty string if no tenant is set.

Store enforcement

The PostgreSQL store enforces tenant scoping on every query:

  • All Create operations automatically set tenant_id from context
  • All Get, List, Update, and Delete queries filter by tenant_id
  • Cross-tenant access returns ErrNotFound even if the entity exists

Example store implementation:

func (s *PostgresStore) GetSubscription(ctx context.Context, id SubscriptionID) (*Subscription, error) {
    tenantID := ledger.TenantFromContext(ctx)
    if tenantID == "" {
        return nil, ledger.ErrNoTenant
    }

    var sub Subscription
    err := s.db.GetContext(ctx, &sub,
        "SELECT * FROM subscriptions WHERE id = $1 AND tenant_id = $2",
        id, tenantID,
    )

    if err == sql.ErrNoRows {
        return nil, ledger.ErrSubscriptionNotFound
    }

    return &sub, err
}

Tenant hierarchy

For complex B2B2C scenarios, Ledger supports hierarchical tenancy:

Organization (tenant_id: "org-acme")
  ├── Workspace A (tenant_id: "org-acme/workspace-a")
  ├── Workspace B (tenant_id: "org-acme/workspace-b")
  └── Workspace C (tenant_id: "org-acme/workspace-c")

Use path-like tenant IDs and query prefixes:

// List all plans for organization and its workspaces
plans, err := store.ListPlans(ctx, PlanFilters{
    TenantPrefix: "org-acme",
})

API integration

The HTTP API extracts tenant ID from the request (typically from auth headers or JWT claims) and injects it into the context:

func TenantMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Extract from JWT claim, header, or subdomain
        tenantID := extractTenantID(r)

        // Inject into context
        ctx := ledger.WithTenant(r.Context(), tenantID)

        // All downstream handlers automatically scoped
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Common extraction strategies:

From JWT claims

func extractTenantID(r *http.Request) string {
    claims := jwt.GetClaims(r.Context())
    return claims["tenant_id"].(string)
}

From subdomain

func extractTenantID(r *http.Request) string {
    host := r.Host
    parts := strings.Split(host, ".")
    if len(parts) > 0 {
        return parts[0] // "acme" from "acme.app.com"
    }
    return ""
}

From header

func extractTenantID(r *http.Request) string {
    return r.Header.Get("X-Tenant-ID")
}

Database schema

All tables include a tenant_id column with composite indexes:

CREATE TABLE subscriptions (
    id TEXT PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    customer_id TEXT NOT NULL,
    plan_id TEXT NOT NULL,
    status TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Composite indexes for tenant-scoped queries
CREATE INDEX idx_subscriptions_tenant_customer ON subscriptions(tenant_id, customer_id);
CREATE INDEX idx_subscriptions_tenant_status ON subscriptions(tenant_id, status);
CREATE INDEX idx_subscriptions_tenant_created ON subscriptions(tenant_id, created_at);

Per-tenant plans

Each tenant can have its own set of plans:

// Tenant A creates a "Startup" plan
ctxA := ledger.WithTenant(ctx, "tenant-a")
planA := &Plan{
    ID:       id.NewPlanID(),
    TenantID: "tenant-a",
    Name:     "Startup",
}
store.CreatePlan(ctxA, planA)

// Tenant B creates a different "Startup" plan
ctxB := ledger.WithTenant(ctx, "tenant-b")
planB := &Plan{
    ID:       id.NewPlanID(),
    TenantID: "tenant-b",
    Name:     "Startup", // Same name, different tenant
}
store.CreatePlan(ctxB, planB)

// Plans are isolated
plansA, _ := store.ListPlans(ctxA, PlanFilters{}) // Only sees planA
plansB, _ := store.ListPlans(ctxB, PlanFilters{}) // Only sees planB

Shared plans (optional)

For marketplace scenarios where plans are shared across tenants, use a global tenant:

// Create global plan (no tenant)
globalCtx := ledger.WithTenant(ctx, "")
globalPlan := &Plan{
    ID:       id.NewPlanID(),
    TenantID: "", // Global
    Name:     "Enterprise",
}
store.CreatePlan(globalCtx, globalPlan)

// Tenants can subscribe to global plans
sub := &Subscription{
    ID:         id.NewSubscriptionID(),
    TenantID:   "tenant-a",
    CustomerID: customerID,
    PlanID:     globalPlan.ID,
}
store.CreateSubscription(ctxA, sub)

Usage isolation

Usage events are strictly scoped to the customer's tenant:

// Tenant A records usage
ctxA := ledger.WithTenant(ctx, "tenant-a")
eventA := &UsageEvent{
    ID:         id.NewEventID(),
    TenantID:   "tenant-a",
    CustomerID: customerA,
    EventName:  "api_call",
    Value:      1,
}
meter.RecordEvent(ctxA, eventA)

// Tenant B cannot see tenant A's usage
ctxB := ledger.WithTenant(ctx, "tenant-b")
events, _ := store.ListUsageEvents(ctxB, UsageFilters{
    CustomerID: customerA, // Wrong tenant
})
// events is empty — tenant isolation enforced

Invoice isolation

Invoices are tenant-scoped and can only be accessed by the owning tenant:

// Generate invoice for tenant A customer
ctxA := ledger.WithTenant(ctx, "tenant-a")
invoice := GenerateInvoice(ctxA, subscriptionA)

// Tenant B cannot access the invoice
ctxB := ledger.WithTenant(ctx, "tenant-b")
_, err := store.GetInvoice(ctxB, invoice.ID)
// err == ErrInvoiceNotFound

Payment provider isolation

Each tenant can have its own payment provider configuration:

type TenantConfig struct {
    TenantID string `json:"tenant_id"`

    // Stripe configuration for this tenant
    StripeAccountID string `json:"stripe_account_id,omitempty"`
    StripeSecretKey string `json:"stripe_secret_key,omitempty"`

    // Paddle configuration for this tenant
    PaddleVendorID string `json:"paddle_vendor_id,omitempty"`
    PaddleAPIKey   string `json:"paddle_api_key,omitempty"`
}

Payments are processed using the tenant's provider credentials:

func (s *BillingService) ProcessPayment(ctx context.Context, invoiceID InvoiceID) error {
    tenantID := ledger.TenantFromContext(ctx)
    config := s.getTenantConfig(tenantID)

    // Use tenant-specific Stripe account
    client := stripe.NewClient(config.StripeSecretKey)
    // ...
}

Security best practices

  1. Always set tenant context — Never query without tenant context in production
  2. Validate tenant in auth — Ensure JWT/session matches requested tenant
  3. Index tenant_id — All queries filter by tenant, so index it
  4. Audit cross-tenant access — Log all queries and check for missing tenant context
  5. Test isolation — Verify cross-tenant access returns empty/not-found
  6. Encrypt tenant data — Use database-level encryption for sensitive tenant data

Example: multi-tenant setup

// Tenant 1: Create customer and subscription
ctx1 := ledger.WithTenant(context.Background(), "tenant-acme")

customer1 := &Customer{
    ID:       id.NewCustomerID(),
    TenantID: "tenant-acme",
    Email:    "user@acme.com",
    Name:     "Acme User",
}
store.CreateCustomer(ctx1, customer1)

sub1 := &Subscription{
    ID:         id.NewSubscriptionID(),
    TenantID:   "tenant-acme",
    CustomerID: customer1.ID,
    PlanID:     planID,
    Status:     "active",
}
store.CreateSubscription(ctx1, sub1)

// Tenant 2: Different customer and subscription
ctx2 := ledger.WithTenant(context.Background(), "tenant-globex")

customer2 := &Customer{
    ID:       id.NewCustomerID(),
    TenantID: "tenant-globex",
    Email:    "user@globex.com",
    Name:     "Globex User",
}
store.CreateCustomer(ctx2, customer2)

// Tenant 1 cannot see tenant 2's customers
customers, _ := store.ListCustomers(ctx1, CustomerFilters{})
// customers contains only customer1

Tenant analytics

Each tenant can query their own billing analytics:

// Revenue by month for tenant
revenue, err := analytics.GetMonthlyRevenue(ctx, MonthlyRevenueFilters{
    StartMonth: "2024-01",
    EndMonth:   "2024-12",
})

// MRR for tenant
mrr, err := analytics.GetMRR(ctx)

// Churn rate for tenant
churn, err := analytics.GetChurnRate(ctx, ChurnFilters{
    Period: "month",
})

All analytics are automatically scoped to the tenant from context.

On this page