Skip to content
xraphPublic

About

Composable usage-based billing engine for Go

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

163 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ledger, a usage-based billing engine for Go with metering, entitlements, plans and invoices

Ledger: usage-based billing engine for Go

Ledger is a Go library for usage-based billing in SaaS products. You define plans and a feature catalog, subscribe your tenants, meter usage, check entitlements and quotas on the request path, and generate invoices with tiered pricing and coupons. It runs inside your process against your own database (PostgreSQL, SQLite, MongoDB or in-memory), and it ships an extension for Forge so a Forge app can register it with one line.

Go 1.26+ CI Go Reference GitHub Stars

Documentation · Quick start · Features · Stores · Forge · Configuration · FAQ

Quick start

You need Go 1.26 or later.

go get github.com/xraph/ledger@latest

This program uses the in-memory store, so there's nothing to install. It creates a $49 monthly plan with an API call quota and a single sign-on flag, subscribes a tenant, checks the quota, records one call and bills the period:

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/xraph/ledger"
	"github.com/xraph/ledger/plan"
	"github.com/xraph/ledger/store/memory"
	"github.com/xraph/ledger/subscription"
	"github.com/xraph/ledger/types"
)

func main() {
	ctx := context.Background()

	l := ledger.New(memory.New())
	if err := l.Start(ctx); err != nil {
		log.Fatal(err)
	}
	defer l.Stop()

	pro := &plan.Plan{
		Name:     "Pro",
		Slug:     "pro",
		Currency: "usd",
		Status:   plan.StatusActive,
		AppID:    "my-app",
		Features: []plan.Feature{
			{Key: "api_calls", Name: "API calls", Type: plan.FeatureMetered, Limit: 10000, Period: plan.PeriodMonthly},
			{Key: "sso", Name: "Single sign-on", Type: plan.FeatureBoolean, Limit: 1},
		},
		Pricing: &plan.Pricing{
			BaseAmount:    types.USD(4900), // $49.00
			BillingPeriod: plan.PeriodMonthly,
		},
	}
	if err := l.CreatePlan(ctx, pro); err != nil {
		log.Fatal(err)
	}

	sub := &subscription.Subscription{TenantID: "acme", AppID: "my-app", PlanID: pro.ID}
	if err := l.CreateSubscription(ctx, sub); err != nil {
		log.Fatal(err)
	}

	// Entitled and Meter read the tenant and app from the context.
	ctx = ledger.WithTenant(ctx, "acme", "my-app")

	res, err := l.Entitled(ctx, "api_calls")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("api_calls allowed=%v remaining=%d\n", res.Allowed, res.Remaining)

	if err := l.Meter(ctx, "api_calls", 1); err != nil {
		log.Fatal(err)
	}

	inv, err := l.GenerateInvoice(ctx, sub.ID)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("invoice", inv.ID, "total", inv.Total)
}

Run it with go run . and you'll see something like this:

api_calls allowed=true remaining=10000
invoice inv_01m4xw3arsegprh53198t2n0dg total $49.00

You should know two things before you go further. First, Entitled and Meter take the tenant and app from the context, and you set them with ledger.WithTenant(ctx, tenantID, appID). Code that puts the plain strings "tenant_id" and "app_id" in the context keeps working, and WithTenant wins when both are there. If either one is missing, Meter returns ErrInvalidInput and Entitled answers not allowed. Second, Meter doesn't write anything on the spot: it drops the event into a buffer that a background worker flushes to the store every 5 seconds, or sooner once 100 events have piled up, which means the usage you just recorded only counts toward your next quota check or invoice after that flush has happened. Stop flushes the rest.

Counting an event once, and telling an outage from a no

Meter is fire and forget. If the write to the store fails, the event is dropped and the meter events metric counts it. When you can't afford that, call l.MeterWithKey(ctx, "api_calls", 1, key) with a key that names the thing you're billing for, such as a message id. The store counts a key once, so a replay adds nothing.

The key is unique across every tenant and every app, not just the tenant in your context. Every store dedupes on the key alone, so if two tenants both send the key 42, the second event is dropped and nothing tells you. Namespace the key yourself, for example <tenant>/<app>/<message id>. It has to be non-empty, valid UTF-8, free of NUL bytes and at most 255 bytes, or you get ErrInvalidInput.

A keyed event whose write fails is held and retried after one second, then two, doubling up to thirty, ahead of newer events, until the store takes it. Stop makes one last attempt. Held events count against the buffer along with the ones waiting in it, so a long outage ends in ErrMeterBufferFull from MeterWithKey and not in unbounded memory. Once you've called Stop, Meter and MeterWithKey return an error wrapping ErrStoreClosed instead of accepting events nothing will flush.

Entitled reads a store outage as "no access", because the callers that exist today rely on that. If you'd rather decide for yourself whether to fail open or closed, call l.Check(ctx, "api_calls"). It returns the same Result from the same cache, plans and quotas, but a store failure comes back as an error that wraps ErrUnavailable. If your own context is cancelled or times out, you get that context error back instead, so it doesn't look like an outage.

Why Ledger?

Billing code usually gets written twice. You write a quick quota check first, and then, months later, finance asks for invoices that agree with it and you write the pricing logic again somewhere else. With Ledger both read the same plan, so the limit your API enforces is the limit your invoice prices.

You need Ledger gives you
Pricing plans Plans with metered, boolean and seat features, a base fee, trial days, and draft, active and archived states
Quota checks on the request path Entitled and Remaining, with hard or soft limits, -1 for unlimited, and results cached in the store for 30 seconds by default
Usage metering Meter, a buffered and batched ingest path that never blocks your handler
Invoices GenerateInvoice with base fee, overage, seat and discount line items, priced from graduated, volume or flat tiers
Discounts Percentage and fixed-amount coupons, applied to a subscription with ApplyCoupon
Subscription lifecycle Trials, plan changes, pause and resume, scheduled or immediate cancels, and a lifecycle clock that rolls periods forward and marks overdue invoices past due
Exact money types.Money, integer minor units with overflow-checked arithmetic
Your own database PostgreSQL, SQLite and MongoDB stores on Grove, plus an in-memory store for tests
Hooks into billing events A plugin registry with typed hooks, an audit trail bridge and a metrics plugin
Telemetry OpenTelemetry spans for lifecycle runs, meter flushes, invoicing and provider calls, plus metrics on Forge's /_/metrics or any go-utils sink

Every entity gets a TypeID with its own prefix (plan_, sub_, inv_, cpn_ and so on), so an ID in a log line tells you what it points at.

Plans, entitlements and invoices

Each plan feature does two jobs. Its Limit is the quota Entitled enforces, and for a metered feature it is also the allowance an invoice includes before overage starts. SoftLimit: true lets a tenant go over (the result says "over soft limit") and bills the extra through the price tiers you give that feature:

Pricing: &plan.Pricing{
	BaseAmount:    types.USD(4900),
	BillingPeriod: plan.PeriodMonthly,
	Tiers: []plan.PriceTier{
		{FeatureKey: "api_calls", Type: plan.TierGraduated, UpTo: 10000, UnitAmount: types.Zero("usd")},
		{FeatureKey: "api_calls", Type: plan.TierGraduated, UpTo: -1, UnitAmount: types.USD(1)},
	},
},

With Limit: 10000 on the feature, the first 10,000 calls are covered by the base fee and every call after that costs a cent. CreatePlan checks the ladder when you save the plan, so a broken one (mixed tier types, a currency that doesn't match the plan) fails right there with invoice.ErrInvalidTiers, long before a billing run.

A plan can also include usage credit with Credits. Each plan.CreditPool is an amount of money per period, spent against the metered features it names, and it shows up on the invoice as a negative credit line (the total is Invoice.CreditAmount). A pool only cancels usage, never the base fee or seats, and a feature can belong to one pool on a plan:

pro.Credits = []plan.CreditPool{
	{Key: "ai", Amount: types.USD(2000), FeatureKeys: []string{"api_calls"}},
}

These are the engine methods you'll reach for most:

Area Methods on *ledger.Ledger
Plans and catalog CreatePlan, UpdatePlan, ActivatePlan, ArchivePlan, CreateFeature, ListFeatures
Subscriptions CreateSubscription, ChangePlan, PauseSubscription, ResumeSubscription, CancelSubscription
Usage and quota WithTenant, Meter, MeterWithKey, Entitled, Check, Remaining, InspectEntitlement
Invoices GenerateInvoice (with ledger.ForPeriod for a past period), FinalizeInvoice, MarkInvoicePaid, MarkInvoiceVoided, ExportInvoice
Coupons CreateCoupon, ApplyCoupon, ListAppliedCoupons
Lifecycle Advance, which runs AdvancePeriods, EnactCancels, EndTrials and MarkInvoicesPastDue in that order

ChangePlan takes effect from the next invoice. Nothing is prorated. The lifecycle clock runs Advance once a minute in the background, and you can turn it off with ledger.WithLifecycleInterval(0) if you want to call Advance from your own scheduler. Every change it makes is a conditional store write, so you can run it on several replicas at once.

Pick a store

ledger.New takes any store.Store. These ship with the module:

Store Package Use it for
In-memory github.com/xraph/ledger/store/memory Tests and local development
PostgreSQL github.com/xraph/ledger/store/postgres Production, through Grove's pgdriver
SQLite github.com/xraph/ledger/store/sqlite Single-node and embedded deployments, through Grove's sqlitedriver
MongoDB github.com/xraph/ledger/store/mongo Teams already on Mongo, through Grove's mongodriver (no transactions needed)

The three database stores take a *grove.DB. For PostgreSQL:

pg := pgdriver.New()
if err := pg.Open(ctx, os.Getenv("DATABASE_URL")); err != nil {
	log.Fatal(err)
}

db, err := grove.Open(pg)
if err != nil {
	log.Fatal(err)
}

l := ledger.New(postgres.New(db))
if err := l.Start(ctx); err != nil { // runs the store's migrations first
	log.Fatal(err)
}
defer l.Stop()

Start migrates the schema before it starts the workers. If you migrate separately, pass ledger.WithoutMigrate(). Every store passes the same conformance suite in store/storetest, and you can run it against your own store.Store too.

Collecting payment with Stripe

provider/stripe copies each finished Ledger invoice into Stripe and lets Stripe charge the tenant's saved card. Ledger stays the source of truth for plans and prices, so there's nothing to mirror on the Stripe side.

sp, err := stripe.New(stripe.Config{
	SecretKey:     os.Getenv("STRIPE_SECRET_KEY"),
	WebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
	Customers:     customers, // your stripe.CustomerStore: tenant id to Stripe customer id
})
if err != nil {
	return err
}
engine := ledger.New(store, ledger.WithPlugin(stripe.NewPlugin(sp)))

// in your webhook handler: check the signature, then pass the body on
if err := sp.Verify(payload, r.Header.Get("Stripe-Signature")); err != nil {
	return err
}
res, err := engine.HandleWebhook(ctx, "stripe", payload)

Create the webhook endpoint in Stripe on API version 2026-10-28.endive. Verify refuses an event from another release train, though stripe-go only compares the part after the dot, so any *.endive version gets through. Keep the Stripe invoice id that SyncInvoice returns, run one sync per invoice at a time, and give every refund its own refundKey (reused on retries). The Stripe provider page has the webhook handler in full.

Plugins, audit and metrics

Register a plugin with ledger.WithPlugin(p). A plugin implements plugin.Plugin (just Name()) plus any hook interfaces it cares about, such as OnSubscriptionCreated, OnQuotaExceeded, OnInvoicePaid or OnInvoicePastDue. The full list is in plugin/plugin.go.

Some plugin interfaces change what the engine computes. PricingStrategy, UsageAggregator, TaxCalculator and InvoiceFormatter let you replace tier pricing, usage totals, tax and export formats, and PaymentProviderPlugin connects a payment provider through the provider.Provider interface for sync, import and webhooks. Stripe is covered by the provider/stripe package (see below). For Paddle or anyone else, you write the provider.Provider yourself.

Two plugins come in the box:

Package What it does
github.com/xraph/ledger/audit_hook (package audithook) Turns plan, subscription, invoice and quota events into audit records through a Recorder you pass in, such as a small adapter over Chronicle
github.com/xraph/ledger/observability Counters and histograms for the same events, through NewMetricsExtension(factory)

You don't need that second plugin to see what the engine is doing. Ledger traces its own work on the global OpenTelemetry tracer: a lifecycle run that moved something, each meter flush, invoice generation, every payment provider call, and entitlement checks made inside a traced request. Idle clock ticks leave no trace. Pass ledger.WithMetrics(m) and it also records lifecycle runs and transitions, metered events (stored, lost to a failed flush, or refused by a full buffer), entitlement checks, invoice generation time, provider call time and failed plugin hooks. The Forge extension wires both for you, so in a Forge app they show up as forge_ledger_... on /_/metrics. The observability guide lists every span and metric.

Use it with Forge

The extension package wraps the engine as a Forge extension:

import (
	"github.com/xraph/forge"
	ledgerext "github.com/xraph/ledger/extension"
)

app := forge.NewApp(forge.AppConfig{
	Name: "billing",
	Extensions: []forge.Extension{
		ledgerext.New(ledgerext.WithMeterFlushInterval(2 * time.Second)),
	},
})

On Register the extension reads its config, picks a store, builds the engine and provides *ledger.Ledger to the container, so your services can resolve it with forge.Inject[*ledger.Ledger](app.Container()). Start and Stop start and stop the engine with the app, and the extension's health check pings the store.

The store is chosen in this order:

  1. ledgerext.WithStore(s), if you passed one.
  2. A named *grove.DB from the container, if you set WithGroveDatabase(name) or grove_database.
  3. The default *grove.DB in the container, if there is one. The driver (pg, sqlite or mongo) picks the store.
  4. The in-memory store, as a last resort. Fine for a demo, but nothing survives a restart.

The extension also registers a contract contributor for the Forge dashboard, with queries and commands for plans, features, subscriptions, usage, invoices and coupons. The React pages that call them live in forge-dashboard's plugin-ledger package.

Configuration

The extension reads YAML under extensions.ledger, and falls back to a top-level ledger key:

extensions:
  ledger:
    grove_database: ""          # named grove.DB in the container; empty uses the default one
    disable_migrate: false
    meter_batch_size: 100
    meter_flush_interval: 5s
    entitlement_cache_ttl: 30s
    lifecycle_interval: 1m
    disable_lifecycle: false
    app_id: ""                  # scopes the dashboard contributor to one app
    require_app_claim: false

YAML wins over programmatic options for strings and numbers, and a programmatic true wins for the boolean flags. These options set the same fields in code: WithStore, WithGroveDatabase, WithDisableMigrate, WithMeterBatchSize, WithMeterFlushInterval, WithEntitlementCacheTTL, WithDisableLifecycle, WithAppID, WithConfig and WithRequireConfig. Use WithPlugin to register a plugin and WithLedgerOption to pass any ledger.Option through to the engine.

Outside Forge, the engine takes ledger.WithLogger, WithPlugin, WithMeterConfig(batchSize, flushInterval), WithEntitlementCacheTTL, WithLifecycleInterval, WithClock and WithoutMigrate.

Documentation

If you want to Read
Get a first plan billing Getting started
Understand quotas and usage Entitlements, metering
Collect payment through Stripe Stripe provider
Bill a period Invoicing, billing cycles, coupons
Scope data per tenant and app Multi-tenancy
Pick and run a store PostgreSQL, SQLite, MongoDB, memory
Extend the engine Custom plugin, custom store
Wire it into Forge Forge extension guide

The Go API is on pkg.go.dev. If you're moving off the old templ dashboard, MIGRATION.md lists what changed.

FAQ

How do I check a usage quota in Go before serving a request?

Put the tenant and app in the context with ledger.WithTenant and call l.Entitled(ctx, "feature_key"), or l.Check if you need to tell a store outage from a real no. Allowed tells you whether to serve the request, and Used, Limit and Remaining tell you where the tenant stands. Then call l.Meter(ctx, "feature_key", 1) once you've done the work.

Does Ledger charge cards?

Not itself. Ledger computes what a tenant owes and tracks invoice state (FinalizeInvoice, MarkInvoicePaid, MarkInvoiceVoided). Collecting the money is a payment provider's job. provider/stripe does it for Stripe, and for anything else you write a provider.Provider and register it as a plugin.

Can I use Ledger without Forge?

Yes. The root package, the stores and the plugins have no Forge dependency in their API. You need Forge only if you import the extension package.

Which databases does Ledger support?

PostgreSQL, SQLite and MongoDB through Grove, and an in-memory store for tests. Anything else works if you implement store.Store and pass the conformance suite in store/storetest.

Does Ledger prorate plan changes?

Not today. ChangePlan switches the plan from the next invoice and leaves the current period alone.

Contributing

Issues and focused pull requests are welcome on GitHub. Run everything from the repo root with GOWORK=off:

GOWORK=off go build ./...
make test                      # go test -v ./...
go test -race -count=1 ./...   # what the release workflow runs
make check                     # fmt, vet and lint

The memory and SQLite stores run the conformance suite on every go test. PostgreSQL and MongoDB skip unless you set LEDGER_TEST_POSTGRES_DSN and LEDGER_TEST_MONGO_URI, so if you touch store/, point them at throwaway containers and run go test -v ./store/.... AGENTS.md has the container commands, the lint rules that catch people, and how releases go out. Skip make build, make run and make all: they point at a cmd/ledger that doesn't exist. Commits follow Conventional Commits.

License

MIT. See LICENSE.

Ledger is part of the forgery stack built on Forge, by Rex Raphael and contributors.

About

Composable usage-based billing engine for Go

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages