End-to-End Billing Example
Complete walkthrough of a billing pipeline from plan creation through payment collection.
This guide builds a complete usage-based billing pipeline from scratch. You will create a plan with metered and boolean features, subscribe a tenant, record usage, check entitlements, and generate an invoice.
Prerequisites
- Go 1.22 or later
- A Go module (
go mod init)
go get github.com/xraph/ledger1. Initialize the engine
Create a Ledger engine backed by the in-memory store. In production you would swap this for a PostgreSQL store.
package main
import (
"context"
"fmt"
"log"
"log/slog"
"time"
"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()
// Create an in-memory store (swap for postgres.New(pool) in production)
store := memory.New()
// Build the billing engine
engine := ledger.New(store,
ledger.WithLogger(slog.Default()),
ledger.WithMeterConfig(50, 2*time.Second), // flush every 50 events or 2s
ledger.WithEntitlementCacheTTL(30*time.Second), // cache entitlement checks for 30s
)
// Start background workers (meter flush, migrations)
if err := engine.Start(ctx); err != nil {
log.Fatal("start:", err)
}
defer engine.Stop()
runBillingPipeline(ctx, engine)
}WithMeterConfig controls how usage events are batched before being flushed to the store. The first argument is the batch size threshold, the second is the maximum time between flushes. WithEntitlementCacheTTL sets how long entitlement check results are cached to avoid repeated database queries.
2. Create a SaaS plan
Define a plan with a base subscription fee, metered API calls with graduated pricing tiers, and a boolean feature for priority support.
func runBillingPipeline(ctx context.Context, engine *ledger.Ledger) {
// ── Step 1: Create a plan ─────────────────────────
proPlan := &plan.Plan{
Name: "Pro Plan",
Slug: "pro",
Description: "For growing teams that need more API calls and priority support.",
Currency: "usd",
Status: plan.StatusActive,
AppID: "myapp",
TrialDays: 14,
Features: []plan.Feature{
{
Key: "api_calls",
Name: "API Calls",
Type: plan.FeatureMetered,
Limit: 10000, // 10k included per month
Period: plan.PeriodMonthly,
SoftLimit: true, // allow overage (billed extra)
},
{
Key: "seats",
Name: "Team Seats",
Type: plan.FeatureSeat,
Limit: 10,
},
{
Key: "priority_support",
Name: "Priority Support",
Type: plan.FeatureBoolean,
Limit: 1, // enabled (0 would mean disabled)
},
},
Pricing: &plan.Pricing{
BaseAmount: types.USD(4999), // $49.99 per month
BillingPeriod: plan.PeriodMonthly,
Tiers: []plan.PriceTier{
{
// First 10,000 API calls are included in the base price
FeatureKey: "api_calls",
Type: plan.TierGraduated,
UpTo: 10000,
UnitAmount: types.Zero("usd"),
Priority: 1,
},
{
// 10,001 - 50,000 at $0.005 each
FeatureKey: "api_calls",
Type: plan.TierGraduated,
UpTo: 50000,
UnitAmount: types.USD(0), // would be sub-cent; use FlatAmount per tier
FlatAmount: types.USD(20000), // $200 flat for this tier
Priority: 2,
},
{
// 50,001+ at $0.002 each (unlimited)
FeatureKey: "api_calls",
Type: plan.TierGraduated,
UpTo: -1, // unlimited
UnitAmount: types.USD(0),
FlatAmount: types.USD(10000), // $100 flat per additional 50k
Priority: 3,
},
},
},
Metadata: map[string]string{
"stripe_product_id": "prod_abc123",
},
}
if err := engine.CreatePlan(ctx, proPlan); err != nil {
log.Fatal("create plan:", err)
}
fmt.Printf("Plan created: %s (%s)\n", proPlan.Name, proPlan.ID)Key points:
- Metered features (
FeatureMetered) track usage over a billing period. SetSoftLimit: trueto allow overage rather than hard-blocking at the limit. - Boolean features (
FeatureBoolean) are on/off toggles. ALimitof1means enabled,0means disabled. - Seat features (
FeatureSeat) track a count of concurrent seats. - Graduated pricing charges different rates as usage moves through tiers. Each
PriceTierdefines a range (UpTo) and a unit or flat amount. - Money values are always in the smallest currency unit.
types.USD(4999)is $49.99.
3. Create a subscription
Subscribe a tenant to the plan. The engine auto-generates a TypeID and sets the billing period.
// ── Step 2: Subscribe a tenant ────────────────────
now := time.Now()
trialEnd := now.AddDate(0, 0, proPlan.TrialDays)
sub := &subscription.Subscription{
TenantID: "acme-corp",
PlanID: proPlan.ID,
Status: subscription.StatusTrialing,
AppID: "myapp",
TrialStart: &now,
TrialEnd: &trialEnd,
Metadata: map[string]string{
"stripe_subscription_id": "sub_xyz789",
},
}
if err := engine.CreateSubscription(ctx, sub); err != nil {
log.Fatal("create subscription:", err)
}
fmt.Printf("Subscription created: %s (status: %s)\n", sub.ID, sub.Status)
fmt.Printf(" Trial ends: %s\n", trialEnd.Format(time.RFC3339))
fmt.Printf(" Period: %s - %s\n",
sub.CurrentPeriodStart.Format(time.DateOnly),
sub.CurrentPeriodEnd.Format(time.DateOnly),
)The engine automatically:
- Assigns a
sub_*TypeID if none is set. - Sets
CurrentPeriodStartandCurrentPeriodEnd(defaults to one month). - Invalidates the entitlement cache for this tenant so subsequent checks reflect the new subscription.
4. Set tenant context and record usage
Ledger extracts tenant_id and app_id from Go context values. Every call to Meter, Entitled, and Remaining requires these values.
// ── Step 3: Set tenant context ────────────────────
tenantCtx := context.WithValue(ctx, "tenant_id", "acme-corp")
tenantCtx = context.WithValue(tenantCtx, "app_id", "myapp")
// ── Step 4: Record usage events ───────────────────
// Simulate API call usage throughout the day
for i := 0; i < 150; i++ {
if err := engine.Meter(tenantCtx, "api_calls", 1); err != nil {
log.Printf("meter error: %v", err)
}
}
fmt.Println("Recorded 150 API call events")
// Record a batch of usage (e.g. from a nightly job)
if err := engine.Meter(tenantCtx, "api_calls", 500); err != nil {
log.Printf("meter error: %v", err)
}
fmt.Println("Recorded batch of 500 API calls")
// Wait for the meter flush worker to persist events
time.Sleep(3 * time.Second)Meter() is non-blocking. Events are buffered in a channel and flushed to the store in batches by a background goroutine. If the buffer is full (default capacity: 10,000), the call returns ledger.ErrMeterBufferFull.
5. Check entitlements
Query whether the tenant can use a feature. The engine checks the subscription, plan, and current usage.
// ── Step 5: Check entitlements ────────────────────
// Check metered feature
result, err := engine.Entitled(tenantCtx, "api_calls")
if err != nil {
log.Fatal("entitlement check:", err)
}
fmt.Printf("\nAPI Calls entitlement:\n")
fmt.Printf(" Allowed: %t\n", result.Allowed)
fmt.Printf(" Used: %d\n", result.Used)
fmt.Printf(" Limit: %d\n", result.Limit)
fmt.Printf(" Remaining: %d\n", result.Remaining)
if result.SoftLimit {
fmt.Printf(" (soft limit - overage allowed)\n")
}
// Check boolean feature
supportResult, err := engine.Entitled(tenantCtx, "priority_support")
if err != nil {
log.Fatal("entitlement check:", err)
}
fmt.Printf("\nPriority Support: %t\n", supportResult.Allowed)
// Use the Remaining shorthand
remaining, err := engine.Remaining(tenantCtx, "api_calls")
if err != nil {
log.Fatal("remaining:", err)
}
fmt.Printf("API calls remaining this month: %d\n", remaining)The Entitled method returns an entitlement.Result struct:
| Field | Type | Description |
|---|---|---|
Allowed | bool | Whether the tenant can use the feature |
Feature | string | The feature key that was checked |
Used | int64 | Current usage in this billing period |
Limit | int64 | Plan limit (-1 means unlimited) |
Remaining | int64 | Units remaining (-1 means unlimited) |
SoftLimit | bool | Whether overage is allowed |
Reason | string | Human-readable reason if denied |
Results are cached for the duration of entitlementCacheTTL to keep latency under 1ms for hot paths.
6. Generate an invoice
Generate an invoice for the current subscription period. The engine calculates base fees and metered usage charges.
// ── Step 6: Generate an invoice ───────────────────
inv, err := engine.GenerateInvoice(tenantCtx, sub.ID)
if err != nil {
log.Fatal("generate invoice:", err)
}
fmt.Printf("\nInvoice: %s\n", inv.ID)
fmt.Printf(" Status: %s\n", inv.Status)
fmt.Printf(" Currency: %s\n", inv.Currency)
fmt.Printf(" Period: %s - %s\n",
inv.PeriodStart.Format(time.DateOnly),
inv.PeriodEnd.Format(time.DateOnly),
)
fmt.Printf(" Subtotal: %s\n", inv.Subtotal.String())
fmt.Printf(" Tax: %s\n", inv.TaxAmount.String())
fmt.Printf(" Discount: %s\n", inv.DiscountAmount.String())
fmt.Printf(" Total: %s\n", inv.Total.String())
fmt.Printf(" Line items:\n")
for _, li := range inv.LineItems {
fmt.Printf(" - [%s] %s: qty=%d, amount=%s\n",
li.Type, li.Description, li.Quantity, li.Amount.String())
}The generated invoice includes:
- A base subscription fee line item from
Pricing.BaseAmount. - Overage line items for metered features where current usage exceeds the included limit.
- The invoice starts in
StatusDraft. Use the store'sMarkInvoicePaidmethod after collecting payment.
7. Cancel the subscription
Cancel at the end of the current billing period, or immediately.
// ── Step 7: Cancel at end of period ───────────────
if err := engine.CancelSubscription(tenantCtx, sub.ID, false); err != nil {
log.Fatal("cancel:", err)
}
fmt.Println("\nSubscription scheduled for cancellation at period end")
// Retrieve updated subscription
updated, _ := engine.GetSubscription(tenantCtx, sub.ID)
fmt.Printf(" Cancel at: %s\n", updated.CancelAt.Format(time.RFC3339))
// To cancel immediately instead:
// engine.CancelSubscription(tenantCtx, sub.ID, true)
}Passing false to CancelSubscription schedules cancellation at CurrentPeriodEnd. Passing true cancels immediately and sets CanceledAt to now.
Complete flow diagram
CreatePlan Create the product catalog
|
CreateSubscription Bind a tenant to a plan
|
Meter(...) Record usage events (non-blocking, batched)
|
Entitled(...) Gate features by checking usage vs. limits
|
GenerateInvoice Produce a detailed invoice at period end
|
MarkInvoicePaid Record payment after collectionNext steps
- Custom Store -- implement a PostgreSQL, MySQL, or Redis storage backend.
- Custom Plugin -- add Slack alerts, metrics, or webhook emission.
- Forge Extension -- mount Ledger into a Forge application with automatic tenant isolation.