ledger

package module
v1.9.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 3, 2026 License: MIT Imports: 29 Imported by: 4

README

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.

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.

Documentation

Overview

Package ledger provides a composable usage-based billing engine for Go applications.

Ledger is designed as a library, not a service. Import it directly into your Go application and run it against your own database. It provides:

  • Entitlement checks against a plan's limits, cached in the store
  • Usage metering with buffered, batched ingestion
  • Flexible pricing models (graduated, volume, flat tiers)
  • Invoice generation with base, overage, seat, discount and tax lines
  • Pluggable payment provider integration through provider.Provider (no provider implementation ships with the module)
  • An audit trail bridge (package audithook) for Chronicle or any recorder
  • Metrics through a small MetricFactory interface (package observability)

Quick Start

Create a ledger instance with your preferred store:

import (
    "github.com/xraph/grove"
    "github.com/xraph/grove/drivers/pgdriver"
    "github.com/xraph/ledger"
    "github.com/xraph/ledger/store/postgres"
)

// Open a Grove database
pg := pgdriver.New()
if err := pg.Open(ctx, databaseURL); err != nil {
    log.Fatal(err)
}
db, err := grove.Open(pg)
if err != nil {
    log.Fatal(err)
}

// Create ledger
l := ledger.New(postgres.New(db))

// Start the ledger (migrates the store, then starts background workers)
if err := l.Start(ctx); err != nil {
    log.Fatal(err)
}
defer l.Stop()

Core Concepts

Plans define what features are available and at what limits:

p := &plan.Plan{
    Name:     "Pro",
    Slug:     "pro",
    Currency: "usd",
    Status:   plan.StatusActive,
    Features: []plan.Feature{
        {Key: "api_calls", Limit: 10000, Type: plan.FeatureMetered, Period: plan.PeriodMonthly},
        {Key: "seats", Limit: 5, Type: plan.FeatureSeat},
        {Key: "sso", Limit: 1, Type: plan.FeatureBoolean},
    },
}
err := l.CreatePlan(ctx, p)

Subscriptions connect tenants to plans:

sub := &subscription.Subscription{TenantID: tenantID, PlanID: p.ID}
err := l.CreateSubscription(ctx, sub)

Entitlements check if a tenant can use a feature. Entitled, Check, Meter and MeterWithKey read the tenant and app from the context. Set them with WithTenant; the plain string keys "tenant_id" and "app_id" still work:

ctx = ledger.WithTenant(ctx, tenantID, appID)
result, err := l.Entitled(ctx, "api_calls")
if result.Allowed {
    // Process API call
    l.Meter(ctx, "api_calls", 1)
}

Entitled reads a store outage as no access. Check returns the same answer but reports an outage as an error wrapping ErrUnavailable. MeterWithKey is Meter for an event with an idempotency key: the store counts a key once, and a failed write is retried instead of dropped. The key is global across tenants and apps, so namespace it, for example "<tenant>/<app>/<message id>".

All monetary calculations use integer arithmetic to avoid floating-point precision issues. The Money type represents amounts in the smallest currency unit (cents for USD, pence for GBP, etc).

Integration

Ledger works with the rest of the Forgery ecosystem:

  • Forge: the extension package registers the engine as a Forge extension
  • Grove: the PostgreSQL, SQLite and MongoDB stores run on a *grove.DB
  • Chronicle: package audithook turns billing events into audit records
  • go-utils: logging, and metrics through package observability

TypeID

All entities use TypeID for globally unique, type-safe identifiers:

plan_01h2xcejqtf2nbrexx3vqjhp41  // Plan ID
sub_01h2xcejqtf2nbrexx3vqjhp41   // Subscription ID
inv_01h455vb4pex5vsknk084sn02q   // Invoice ID

TypeIDs are K-sortable, making them ideal for database indexes and providing natural time-ordering of entities.

Index

Constants

This section is empty.

Variables

View Source
var (
	// General errors
	ErrNotFound      = errors.New("ledger: not found")
	ErrAlreadyExists = errors.New("ledger: already exists")
	ErrInvalidInput  = errors.New("ledger: invalid input")
	ErrUnauthorized  = errors.New("ledger: unauthorized")
	ErrForbidden     = errors.New("ledger: forbidden")

	// Plan errors
	ErrPlanNotFound     = errors.New("ledger: plan not found")
	ErrPlanArchived     = errors.New("ledger: plan is archived")
	ErrPlanInUse        = errors.New("ledger: plan is in use by subscriptions")
	ErrFeatureNotFound  = errors.New("ledger: feature not found")
	ErrFeatureArchived  = errors.New("ledger: feature is archived")
	ErrInvalidPricing   = errors.New("ledger: invalid pricing configuration")
	ErrDuplicateFeature = errors.New("ledger: duplicate feature key")

	// ErrInvalidCreditPool is returned when a plan's credit pools are malformed.
	ErrInvalidCreditPool = errors.New("ledger: invalid credit pool")

	// Subscription errors
	ErrSubscriptionNotFound = errors.New("ledger: subscription not found")
	ErrSubscriptionExists   = errors.New("ledger: subscription already exists")
	ErrSubscriptionCanceled = errors.New("ledger: subscription is canceled")
	ErrSubscriptionExpired  = errors.New("ledger: subscription is expired")
	ErrInvalidUpgrade       = errors.New("ledger: invalid plan upgrade")
	ErrInvalidDowngrade     = errors.New("ledger: invalid plan downgrade")
	ErrTrialExpired         = errors.New("ledger: trial period has expired")
	ErrNoActiveSubscription = errors.New("ledger: no active subscription")

	// Metering errors
	ErrMeterBufferFull = errors.New("ledger: meter buffer full")
	ErrInvalidQuantity = errors.New("ledger: invalid usage quantity")
	ErrDuplicateEvent  = errors.New("ledger: duplicate usage event")
	ErrEventTooOld     = errors.New("ledger: usage event too old")

	// Entitlement errors
	ErrQuotaExceeded    = errors.New("ledger: quota exceeded")
	ErrFeatureDisabled  = errors.New("ledger: feature disabled")
	ErrHardLimitReached = errors.New("ledger: hard limit reached")
	ErrSoftLimitReached = errors.New("ledger: soft limit reached (warning)")
	ErrNoEntitlement    = errors.New("ledger: no entitlement for feature")

	// Invoice errors
	ErrInvoiceNotFound   = errors.New("ledger: invoice not found")
	ErrInvoiceFinalized  = errors.New("ledger: invoice already finalized")
	ErrInvoicePaid       = errors.New("ledger: invoice already paid")
	ErrInvoiceVoided     = errors.New("ledger: invoice is voided")
	ErrInvoiceIncomplete = errors.New("ledger: invoice incomplete")
	ErrInvalidDiscount   = errors.New("ledger: invalid discount")

	// Coupon errors
	ErrCouponNotFound       = errors.New("ledger: coupon not found")
	ErrCouponExpired        = errors.New("ledger: coupon expired")
	ErrCouponInvalid        = errors.New("ledger: coupon invalid")
	ErrCouponExhausted      = errors.New("ledger: coupon redemptions exhausted")
	ErrCouponNotStarted     = errors.New("ledger: coupon not yet valid")
	ErrCouponAlreadyApplied = errors.New("ledger: coupon already applied to this subscription")

	// Provider errors
	ErrProviderNotFound      = errors.New("ledger: provider not found")
	ErrProviderSync          = errors.New("ledger: provider sync failed")
	ErrProviderWebhook       = errors.New("ledger: webhook validation failed")
	ErrProviderNotConfigured = errors.New("ledger: provider not configured")

	// Store errors
	ErrStoreNotReady     = errors.New("ledger: store not ready")
	ErrStoreClosed       = errors.New("ledger: store is closed")
	ErrTransactionFailed = errors.New("ledger: transaction failed")
	ErrMigrationFailed   = errors.New("ledger: migration failed")

	// ErrUnavailable means the store could not answer, as opposed to the
	// answer being no. Check returns it so a caller can tell an outage from a
	// tenant with no access.
	ErrUnavailable = errors.New("ledger: unavailable")

	// Cache errors
	ErrCacheMiss       = errors.New("ledger: cache miss")
	ErrCacheInvalidate = errors.New("ledger: cache invalidation failed")
)

Sentinel errors for common failure scenarios.

View Source
var (
	USD  = types.USD
	EUR  = types.EUR
	GBP  = types.GBP
	JPY  = types.JPY
	CAD  = types.CAD
	AUD  = types.AUD
	Zero = types.Zero
	Sum  = types.Sum
)

Re-export Money constructors

View Source
var NewEntity = types.NewEntity

Re-export Entity constructor

Functions

func IsNotFound

func IsNotFound(err error) bool

IsNotFound returns true if the error is a not found error.

func IsQuotaError

func IsQuotaError(err error) bool

IsQuotaError returns true if the error is related to quota/limits.

func IsRetryable

func IsRetryable(err error) bool

IsRetryable returns true if the error is temporary and the operation can be retried.

func ValidateFeature

func ValidateFeature(f *feature.Feature) error

ValidateFeature checks the shape of a catalog feature: a key, a known type and period, and a default limit of -1 (unlimited) or more. The feature writes themselves store whatever they are given, so the dashboard contract and the provider imports call this first. A problem wraps ErrInvalidInput, and the text after its prefix says what is wrong. It does not look at the status: a draft feature is a valid shape, and each caller decides which statuses it accepts.

func WithTenant

func WithTenant(ctx context.Context, tenantID, appID string) context.Context

WithTenant returns a context that scopes Meter, MeterWithKey, Entitled, Check and Remaining to a tenant and an app. It takes precedence over the plain "tenant_id" and "app_id" string keys older callers set, which still work when WithTenant is not used.

Types

type Entity

type Entity = types.Entity

Entity is re-exported from types package.

type ID

type ID = id.ID

ID is the primary identifier type for all Ledger entities.

type ImportOption

type ImportOption func(*importConfig)

ImportOption configures an import from a payment provider.

func ImportInto

func ImportInto(appID string) ImportOption

ImportInto files the imported record under appID. The provider interface has no per-app scoping and several apps can share one provider account, so the caller names the app. A record the provider itself files under a different app is refused with that entity's not-found error, and nothing is stored. An empty appID is the shared catalog for a feature, and the no-app scope for a plan, a subscription or an invoice.

type InvoiceOption

type InvoiceOption func(*invoiceConfig)

InvoiceOption configures GenerateInvoice.

func ForPeriod

func ForPeriod(start, end time.Time) InvoiceOption

ForPeriod makes GenerateInvoice bill a period the subscription has already had, not its current one: the period the lifecycle clock just rolled over, say, or one a catch-up skipped (OnSubscriptionRenewed lists them). The period must be the current one or one before it on the subscription's cadence, must have started, and must end after the subscription was created; anything else is refused with ErrInvalidInput. A period a resume stretched counts with its stretched end, and the walk crosses it (see periodBelongsTo); a period from before an older stretch, which Ledger no longer remembers, is refused. A paused subscription's current period is not finished, so it cannot be invoiced until it is resumed and its end passes. A second live invoice for the same period is refused with ErrAlreadyExists, as for the current period. Nothing calls this on its own: Ledger never bills a period automatically.

Only the usage is the period's own. The base fee, the seats, the coupons and the prices come from the subscription and its plan as they are now, because ChangePlan leaves the period alone and Ledger keeps no history of any of them. A plan whose billing period changed no longer lines up with the periods before the change, and those are refused.

type Ledger

type Ledger struct {
	// contains filtered or unexported fields
}

Ledger is the main billing engine.

func New

func New(s store.Store, opts ...Option) *Ledger

New creates a new Ledger instance.

func (*Ledger) ActivatePlan

func (l *Ledger) ActivatePlan(ctx context.Context, planID id.PlanID) error

ActivatePlan makes a plan available for new subscriptions. The plan must validate first.

func (*Ledger) Advance

func (l *Ledger) Advance(ctx context.Context, now time.Time) (LifecycleReport, error)

Advance runs every lifecycle step once, as of now, in this order: it moves ended periods forward, enacts due cancellations, ends due trials, and marks overdue invoices past due. Periods go first so that a cancellation dated inside a missed period ends that period; the period step never moves past a cancellation. Cancellations go before trials so that a trial canceled before it ended is never announced as converted.

The background worker calls it on every tick. Call it yourself from your own scheduler with the worker turned off, or from a test with any now. It is safe to run on several replicas at once: every change is a conditional store write, and only the run whose write lands reports it and fires its hook. Each step keeps going past a row that fails; the error joins them.

A run that moves something, or fails, is traced as "run lifecycle" with a child span per step that wrote or failed. A run that moves nothing leaves no span, so a clock ticking every minute over an idle store adds no traces.

func (*Ledger) AdvancePeriods

func (l *Ledger) AdvancePeriods(ctx context.Context, now time.Time) ([]id.SubscriptionID, error)

AdvancePeriods moves every active, trialing or past-due subscription whose period has ended into the period that contains now, in one write however many periods it missed, and never past a scheduled cancellation. Periods follow the plan's billing period (see billingPeriod); a plan billed "none" never rolls. OnSubscriptionRenewed fires once per subscription moved, with every period that ended in the move.

One catch-up walks at most maxCatchUpPeriods periods, a hundred years of months. A subscription further behind than that, whose period ended before any date this engine could have set, is left where it is and reported in the error on every run. It needs its period set by hand in the store; the clock never guesses how it got there.

func (*Ledger) ApplyCoupon

func (l *Ledger) ApplyCoupon(ctx context.Context, subID id.SubscriptionID, code string) (*coupon.Coupon, error)

ApplyCoupon attaches a coupon to a subscription by code.

Every check that can fail runs before any write, so a rejected coupon leaves no application row and no incremented redemption count. The application row and the redemption count are written together through l.store.RedeemCoupon, which the store commits as one unit: a review of the previous two-write version found both of its assumptions false. A coupon read as under its cap could still be over its cap by the time the write landed, because the check and the write were two separate steps with no lock between them - a barrier test redeemed a coupon capped at 1 five times this way. And a row without a count turned out not to be recoverable either: a retry after a failure between the two writes saw the application row already there and returned ErrCouponAlreadyApplied, so the count never caught up. RedeemCoupon closes both gaps by enforcing the cap with a conditional increment inside the same unit that records the application, rather than a read followed by a write.

func (*Ledger) ArchiveFeature

func (l *Ledger) ArchiveFeature(ctx context.Context, featureID id.FeatureID) error

ArchiveFeature archives a catalog feature.

func (*Ledger) ArchivePlan

func (l *Ledger) ArchivePlan(ctx context.Context, planID id.PlanID) error

ArchivePlan stops new subscriptions to a plan. Existing subscriptions keep billing. Archiving an archived plan does nothing.

func (*Ledger) CancelSubscription

func (l *Ledger) CancelSubscription(ctx context.Context, subID id.SubscriptionID, immediately bool) error

CancelSubscription cancels a subscription, now or at the end of its current period. A subscription that is already canceled or expired is refused, so a cancel on it can neither move its cancel_at nor announce the cancellation again. One that is still running can be canceled again while a scheduled cancel waits: a scheduled cancel sets cancel_at to the current period end once more, and an immediate one stops it now.

OnSubscriptionCanceled fires only when the subscription stopped now. A cancel dated later fires OnSubscriptionCancelScheduled, and the lifecycle clock fires OnSubscriptionCanceled when it enacts it. That holds even when the period it ends is already over: the subscription keeps running until the clock's next run cancels it as of that period's end.

func (*Ledger) ChangePlan

func (l *Ledger) ChangePlan(ctx context.Context, subID id.SubscriptionID, planID id.PlanID, quantity map[string]int64) (*subscription.Subscription, error)

ChangePlan moves a subscription to another active plan in the same app. The change takes effect from the next invoice; nothing is prorated. A nil quantity keeps the current seat counts.

func (*Ledger) Check

func (l *Ledger) Check(ctx context.Context, featureKey string) (*entitlement.Result, error)

Check answers the same question as Entitled, from the same cache, plans and quotas, but it does not turn a store failure into "no access". A subscription lookup that fails with anything other than ErrNoActiveSubscription or ErrSubscriptionNotFound, and a usage aggregate that fails, return a nil result and an error that wraps ErrUnavailable (and the store's own error), so a caller can fail open or closed on purpose. A failure caused by the caller's own cancelled or expired context comes back as that context error, not as ErrUnavailable.

func (*Ledger) CreateCoupon

func (l *Ledger) CreateCoupon(ctx context.Context, c *coupon.Coupon) error

CreateCoupon validates and stores a new coupon. A code is unique within an app. A new coupon's redemption count is always zero.

func (*Ledger) CreateFeature

func (l *Ledger) CreateFeature(ctx context.Context, f *feature.Feature) error

CreateFeature creates a new catalog feature.

func (*Ledger) CreatePlan

func (l *Ledger) CreatePlan(ctx context.Context, p *plan.Plan) error

CreatePlan validates and stores a new billing plan. It normalises the plan first (see normalisePlan), and refuses a slug the app already uses with ErrAlreadyExists.

func (*Ledger) CreateSubscription

func (l *Ledger) CreateSubscription(ctx context.Context, sub *subscription.Subscription) error

CreateSubscription creates a new subscription.

A subscription must name its tenant. Every store reads an empty tenant id as "every tenant", so one without a tenant would later be billed for other customers' usage. It is refused with an error wrapping ErrInvalidInput and nothing is written. The app id may stay empty: deployments that never set one still bill their base fee.

func (*Ledger) DeleteCoupon

func (l *Ledger) DeleteCoupon(ctx context.Context, couponID id.CouponID) error

DeleteCoupon removes a coupon. Subscriptions it was applied to stop receiving its discount on their next invoice.

func (*Ledger) DeleteFeature

func (l *Ledger) DeleteFeature(ctx context.Context, featureID id.FeatureID) error

DeleteFeature deletes a catalog feature.

func (*Ledger) DeletePlan

func (l *Ledger) DeletePlan(ctx context.Context, planID id.PlanID) error

DeletePlan removes a plan no subscription references, in any status. A plan with subscriptions is archived instead, because invoicing reads the plan.

func (*Ledger) EnactCancels

func (l *Ledger) EnactCancels(ctx context.Context, now time.Time) ([]id.SubscriptionID, error)

EnactCancels cancels every subscription whose cancel_at is at or before now and that is not already canceled or expired, paused ones included, and sets canceled_at to cancel_at. OnSubscriptionCanceled fires for each, with the row read back after the write: an operator's scheduled cancel landing between the list and the write moves cancel_at, and the stored canceled_at is the one that counts.

A running subscription whose period ended before its cancel_at is caught up first, as AdvancePeriods would, so the cancel ends the period it falls in and the periods before it are announced. Inside one Advance the period step has already done that; this covers another replica's cancel step landing between this replica's period list and its write, and a caller running EnactCancels on its own. The advance is conditional like any other, so a row another replica already moved is left alone.

func (*Ledger) EndTrials

func (l *Ledger) EndTrials(ctx context.Context, now time.Time) ([]id.SubscriptionID, error)

EndTrials makes every trialing subscription whose trial_end is at or before now active. A paused subscription's trial is left alone. OnSubscriptionTrialEnded fires for each.

func (*Ledger) Entitled

func (l *Ledger) Entitled(ctx context.Context, featureKey string) (*entitlement.Result, error)

Entitled checks if the current tenant can use a feature.

It runs on most requests, so its span is only started under a caller's span: a check made outside any trace does not begin one.

A store that cannot answer reads as no access here: a failed subscription lookup comes back as "no active subscription" with a nil error. Use Check when you need to tell an outage from a real no.

func (*Ledger) ExportInvoice

func (l *Ledger) ExportInvoice(ctx context.Context, invID id.InvoiceID, format string) ([]byte, error)

ExportInvoice renders an invoice through the formatter registered for format.

func (*Ledger) FinalizeInvoice

func (l *Ledger) FinalizeInvoice(ctx context.Context, invID id.InvoiceID) error

FinalizeInvoice transitions an invoice from draft to pending.

func (*Ledger) GenerateInvoice

func (l *Ledger) GenerateInvoice(ctx context.Context, subID id.SubscriptionID, opts ...InvoiceOption) (inv *invoice.Invoice, err error)

GenerateInvoice generates an invoice for a subscription period.

It assembles line items in a fixed order: the base fee, then metered usage overage per feature in plan order, then seat charges per seat feature in plan order, then one credit line per plan credit pool with usage to cancel, then one discount line per applied coupon, then one tax line. Every Money the function builds is normalised to one lowercased currency taken from the plan, so two values built from different casings of the same currency (e.g. "USD" and "usd") never trip Money.Add/Subtract's case-sensitive mismatch panic. Tax is computed on the net amount (subtotal less credit and discounts, clamped at zero), not the gross subtotal, so a discounted invoice is never taxed on money the customer was never charged.

Every sum it builds (subtotal, credit, discount, net, tax and total) is checked. An amount that overflows an int64 fails generation with an error wrapping types.ErrOverflow that names the stage, and nothing is stored: a wrapped figure must never become an invoice.

A subscription with an empty tenant id is refused with an error wrapping ErrInvalidInput before anything else is read for it. Every store reads an empty tenant as "every tenant", so billing one would total other customers' usage. An empty app id is allowed and still bills the base fee; only a feature totalled by a plugin aggregator refuses it (see aggregateUsage).

Usage is always the billed period's own: the events from its start to its end, or to now while it is still running (see usageWindow). That holds for the current period and for one named with ForPeriod, so two invoices for consecutive periods never count the same event. A feature's reset period (plan.Feature.Period) plays no part in billing; it shapes quota checks alone.

With ForPeriod it bills a period the subscription has already had instead of its current one. Only usage is historical: the plan, its prices, the seat counts and the coupons are all read as they are now, because Ledger keeps no history of them and ChangePlan leaves the period alone.

func (*Ledger) GetActiveSubscription

func (l *Ledger) GetActiveSubscription(ctx context.Context, tenantID, appID string) (*subscription.Subscription, error)

GetActiveSubscription retrieves the active subscription for a tenant.

func (*Ledger) GetFeature

func (l *Ledger) GetFeature(ctx context.Context, featureID id.FeatureID) (*feature.Feature, error)

GetFeature retrieves a catalog feature by ID.

func (*Ledger) GetFeatureByKey

func (l *Ledger) GetFeatureByKey(ctx context.Context, key, appID string) (*feature.Feature, error)

GetFeatureByKey retrieves a catalog feature by key and app scope.

func (*Ledger) GetPlan

func (l *Ledger) GetPlan(ctx context.Context, planID id.PlanID) (*plan.Plan, error)

GetPlan retrieves a plan by ID.

func (*Ledger) GetPlanBySlug

func (l *Ledger) GetPlanBySlug(ctx context.Context, slug, appID string) (*plan.Plan, error)

GetPlanBySlug retrieves a plan by slug.

func (*Ledger) GetSubscription

func (l *Ledger) GetSubscription(ctx context.Context, subID id.SubscriptionID) (*subscription.Subscription, error)

GetSubscription retrieves a subscription by ID.

func (*Ledger) HandleWebhook

func (l *Ledger) HandleWebhook(ctx context.Context, providerName string, payload []byte) (res *provider.WebhookResult, err error)

HandleWebhook routes an incoming webhook payload to the correct provider.

func (*Ledger) HasProviders

func (l *Ledger) HasProviders() bool

HasProviders returns true if any payment provider plugins are registered.

func (*Ledger) Health

func (l *Ledger) Health(ctx context.Context) error

Health checks the health of the Ledger by pinging its store.

func (*Ledger) ImportFeatureFromProvider

func (l *Ledger) ImportFeatureFromProvider(ctx context.Context, providerName, providerID string, opts ...ImportOption) (_ *feature.Feature, err error)

ImportFeatureFromProvider pulls a catalog feature from the provider and creates it. A key the catalog already uses is refused with ErrAlreadyExists, which also refuses importing the same feature twice.

func (*Ledger) ImportInvoiceFromProvider

func (l *Ledger) ImportInvoiceFromProvider(ctx context.Context, providerName, providerID string, opts ...ImportOption) (_ *invoice.Invoice, err error)

ImportInvoiceFromProvider pulls an invoice from the provider and stores it. Its subscription must already be this app's subscription for the invoice's tenant. It is refused with ErrAlreadyExists when the provider id is already stored, or when the subscription already has a live invoice for the same period: the rule GenerateInvoice keeps, because two would bill twice.

func (*Ledger) ImportPlanFromProvider

func (l *Ledger) ImportPlanFromProvider(ctx context.Context, providerName, providerID string, opts ...ImportOption) (_ *plan.Plan, err error)

ImportPlanFromProvider pulls a plan from the provider and creates it through CreatePlan, so it is validated like any other plan and a slug the app already uses is refused with ErrAlreadyExists. That is also what refuses importing the same plan twice.

func (*Ledger) ImportSubscriptionFromProvider

func (l *Ledger) ImportSubscriptionFromProvider(ctx context.Context, providerName, providerID string, opts ...ImportOption) (_ *subscription.Subscription, err error)

ImportSubscriptionFromProvider pulls a subscription from the provider and creates it through CreateSubscription. Its plan must already be a plan in the same app, so the plan is imported first. A provider id the tenant already has in the app is refused with ErrAlreadyExists.

func (*Ledger) InspectEntitlement

func (l *Ledger) InspectEntitlement(ctx context.Context, tenantID, appID, featureKey string) (*entitlement.Result, error)

InspectEntitlement answers an entitlement question for a named tenant and app, fresh from the store, without touching the cache or firing plugin events. It is for operators looking at a customer, not for enforcement.

func (*Ledger) InvoiceFormats

func (l *Ledger) InvoiceFormats() []string

InvoiceFormats names every registered invoice formatter, sorted.

func (*Ledger) LifecycleInterval

func (l *Ledger) LifecycleInterval() time.Duration

LifecycleInterval reports how often the lifecycle clock runs. Zero means it is off.

func (*Ledger) ListAppliedCoupons

func (l *Ledger) ListAppliedCoupons(ctx context.Context, subID id.SubscriptionID) ([]*coupon.Coupon, error)

ListAppliedCoupons returns the coupons attached to a subscription, oldest application first.

func (*Ledger) ListFeatures

func (l *Ledger) ListFeatures(ctx context.Context, appID string, opts feature.ListOpts) ([]*feature.Feature, error)

ListFeatures lists catalog features for an app.

func (*Ledger) ListGlobalFeatures

func (l *Ledger) ListGlobalFeatures(ctx context.Context, opts feature.ListOpts) ([]*feature.Feature, error)

ListGlobalFeatures lists catalog features with no app scope.

func (*Ledger) ListPaymentMethods

func (l *Ledger) ListPaymentMethods(ctx context.Context, tenantID string) (methods []provider.PaymentMethod, err error)

ListPaymentMethods lists payment methods from the provider for a tenant. Payment methods are always fetched live and never stored locally.

func (*Ledger) MarkInvoicePaid

func (l *Ledger) MarkInvoicePaid(ctx context.Context, invID id.InvoiceID, paidAt time.Time, paymentRef string) error

MarkInvoicePaid marks an invoice as paid.

func (*Ledger) MarkInvoiceVoided

func (l *Ledger) MarkInvoiceVoided(ctx context.Context, invID id.InvoiceID, reason string) error

MarkInvoiceVoided marks an invoice as voided with a reason.

func (*Ledger) MarkInvoicesPastDue

func (l *Ledger) MarkInvoicesPastDue(ctx context.Context, now time.Time) ([]id.InvoiceID, error)

MarkInvoicesPastDue moves every pending invoice whose due date is before now to past_due. The subscription is left as it is: its entitlements carry on. OnInvoicePastDue fires for each.

func (*Ledger) Meter

func (l *Ledger) Meter(ctx context.Context, featureKey string, quantity int64) error

Meter records a usage event (non-blocking).

The event is buffered and written by a background worker. If that write fails the event is dropped and counted. Use MeterWithKey when the event must survive a store outage and must not count twice. After Stop it returns an error wrapping ErrStoreClosed.

func (*Ledger) MeterWithKey

func (l *Ledger) MeterWithKey(ctx context.Context, featureKey string, quantity int64, idempotencyKey string) error

MeterWithKey is Meter for an event that carries an idempotency key. The store counts a key once, so sending the same key again, from a retry of your own or a replay, adds nothing.

The key is unique across every tenant and every app, not within the tenant in the context. Every store dedupes on the key alone, so two tenants that send the same key have the second event dropped without an error. Namespace it yourself, for example "<tenant>/<app>/<message id>".

A key must be non-empty, valid UTF-8, free of NUL bytes and at most 255 bytes long, or the call returns ErrInvalidInput. A key a store would always reject would otherwise sit in the retry queue until Stop.

A keyed event whose write fails is not dropped. The worker keeps it and tries again with a growing wait (one second, doubling to thirty), ahead of newer events, until the store takes it or Stop makes a last attempt. While events are held they count against the buffer together with the events waiting in it, so a long outage ends in ErrMeterBufferFull from this method, not in unbounded memory.

func (*Ledger) PauseSubscription

func (l *Ledger) PauseSubscription(ctx context.Context, subID id.SubscriptionID) (*subscription.Subscription, error)

PauseSubscription pauses an active or trialing subscription and records when, in paused_at. While it is paused the lifecycle clock leaves its period and its trial alone, so the billing cycle stands still until ResumeSubscription.

func (*Ledger) ProviderNames

func (l *Ledger) ProviderNames() []string

ProviderNames names every registered payment provider, sorted.

func (*Ledger) Remaining

func (l *Ledger) Remaining(ctx context.Context, featureKey string) (int64, error)

Remaining returns the remaining quota for a feature.

func (*Ledger) ResumeSubscription

func (l *Ledger) ResumeSubscription(ctx context.Context, subID id.SubscriptionID) (*subscription.Subscription, error)

ResumeSubscription resumes a paused subscription. A subscription the lifecycle clock canceled while it was paused stays canceled.

A pause stops the billing clock, so the resume stretches the period by the length of the pause: current_period_end moves on by now minus paused_at, and current_period_start stays. The stretched period carries one base fee and is billed once its new end passes, like any other; the lifecycle clock lists it in Renewal.Ended then, and the periods after it follow the clock's anchor from its new end, usually that end's day of the month. A cancel scheduled for the period end moves with it. Ledger remembers the most recent stretch (Subscription.Stretch), so ForPeriod can still prove the periods on both sides of it.

A trial still running when the subscription was paused resumes as a trial, and its end moves on by the same length, so the customer gets the trial days they had left; the clock ends it, and fires OnSubscriptionTrialEnded, once that later date passes.

A subscription paused before Ledger recorded paused_at has no pause start. Its period and its trial end stay where they were, and it resumes as a trial only if that end is still ahead.

Everything is one conditional store write that lands only while the subscription is still paused and still carries the paused_at read here.

func (*Ledger) Start

func (l *Ledger) Start(ctx context.Context) error

Start begins background workers.

func (*Ledger) Stop

func (l *Ledger) Stop() error

Stop shuts down the Ledger.

func (*Ledger) Store

func (l *Ledger) Store() store.Store

Store returns the underlying ledger store.

func (*Ledger) SyncFeatureToProvider

func (l *Ledger) SyncFeatureToProvider(ctx context.Context, featureID id.FeatureID) (res *provider.SyncResult, err error)

SyncFeatureToProvider pushes a local feature to the payment provider.

func (*Ledger) SyncInvoiceToProvider

func (l *Ledger) SyncInvoiceToProvider(ctx context.Context, invID id.InvoiceID) (res *provider.SyncResult, err error)

SyncInvoiceToProvider pushes a local invoice to the payment provider.

An invoice whose total is zero has nothing to collect, so it is not sent: it is marked paid with the payment reference "zero-total" and the result carries no provider id. The provider is not called. Only a pending or past-due invoice is settled this way. A zero-total invoice that is already paid returns the same success without writing anything, so a retry is safe. A voided one returns ErrInvoiceVoided, and any other status (a draft, say) an error wrapping ErrInvalidInput.

func (*Ledger) SyncPlanToProvider

func (l *Ledger) SyncPlanToProvider(ctx context.Context, planID id.PlanID) (res *provider.SyncResult, err error)

SyncPlanToProvider pushes a local plan to the payment provider.

func (*Ledger) SyncSubscriptionToProvider

func (l *Ledger) SyncSubscriptionToProvider(ctx context.Context, subID id.SubscriptionID) (res *provider.SyncResult, err error)

SyncSubscriptionToProvider pushes a local subscription to the payment provider.

func (*Ledger) UpdateCoupon

func (l *Ledger) UpdateCoupon(ctx context.Context, c *coupon.Coupon) error

UpdateCoupon saves the mutable fields of an existing coupon: name, redemption cap, validity window and metadata. The code, type, value, currency and app are fixed once created, because applied coupons are priced from them on every future invoice. The redemption count is never written here.

func (*Ledger) UpdateFeature

func (l *Ledger) UpdateFeature(ctx context.Context, f *feature.Feature) error

UpdateFeature updates a catalog feature.

func (*Ledger) UpdatePlan

func (l *Ledger) UpdatePlan(ctx context.Context, p *plan.Plan) error

UpdatePlan saves a plan's new state. The app and currency are fixed once a plan exists, because subscriptions, coupons and invoices are all priced in that currency.

type LifecycleReport

type LifecycleReport struct {
	PeriodsAdvanced []id.SubscriptionID
	CancelsEnacted  []id.SubscriptionID
	TrialsEnded     []id.SubscriptionID
	InvoicesPastDue []id.InvoiceID
}

LifecycleReport lists what one Advance run changed.

func (LifecycleReport) Empty

func (r LifecycleReport) Empty() bool

Empty reports whether the run changed nothing.

type Money

type Money = types.Money

Money is re-exported from types package.

type MultiError

type MultiError struct {
	Errors []error
}

MultiError represents multiple errors that occurred.

func (*MultiError) Add

func (e *MultiError) Add(err error)

Add adds an error to the multi-error.

func (MultiError) Error

func (e MultiError) Error() string

func (MultiError) First

func (e MultiError) First() error

First returns the first error or nil.

func (MultiError) HasErrors

func (e MultiError) HasErrors() bool

HasErrors returns true if there are any errors.

type Option

type Option func(*Ledger)

Option configures a Ledger instance.

func WithClock

func WithClock(now func() time.Time) Option

WithClock sets where the engine reads the time for its decisions. Tests use it. A nil clock keeps time.Now.

These follow the clock: the lifecycle worker's Advance, a new subscription's creation time, first period and trial, the periods GenerateInvoice will bill, coupon validity, and an invoice's due date.

These stay on the wall clock: UpdatedAt and other change stamps, the timestamp on a metered usage event, the moment an immediate cancel or a void is recorded, the store's own usage windows, and the expiry of the entitlement cache.

func WithEntitlementCacheTTL

func WithEntitlementCacheTTL(ttl time.Duration) Option

WithEntitlementCacheTTL sets the entitlement cache TTL.

func WithLifecycleInterval

func WithLifecycleInterval(d time.Duration) Option

WithLifecycleInterval sets how often the lifecycle clock runs Advance in the background (default: one minute). Zero or less turns the clock off, for a deployment that calls Advance from its own scheduler.

func WithLogger

func WithLogger(logger log.Logger) Option

WithLogger sets the logger.

func WithMeterConfig

func WithMeterConfig(batchSize int, flushInterval time.Duration) Option

WithMeterConfig configures metering parameters.

func WithMetrics

func WithMetrics(m gometrics.Metrics) Option

WithMetrics records the engine's metrics on m: lifecycle runs and transitions, metered events, entitlement checks, invoice generation, payment provider calls and plugin hook failures. In a Forge app pass app.Metrics(); the Forge extension does that for you. A nil m, the default, records nothing.

Spans need no option. The engine starts them on the global OpenTelemetry tracer provider, which forge.New installs.

func WithPlugin

func WithPlugin(p plugin.Plugin) Option

WithPlugin registers a plugin.

func WithoutMigrate

func WithoutMigrate() Option

WithoutMigrate makes Start skip the store migration and start only the background workers, for a deployment that migrates its schema separately.

type Prefix

type Prefix = id.Prefix

Prefix identifies the entity type encoded in a TypeID.

type ValidationError

type ValidationError struct {
	Field   string
	Message string
}

ValidationError represents a validation failure with details.

func (ValidationError) Error

func (e ValidationError) Error() string

Directories

Path Synopsis
Package audithook bridges Ledger lifecycle events to an audit trail backend.
Package audithook bridges Ledger lifecycle events to an audit trail backend.
Package extension provides the Forge extension adapter for Ledger.
Package extension provides the Forge extension adapter for Ledger.
contract
Package contract wires Ledger into the Forge dashboard's contract path.
Package contract wires Ledger into the Forge dashboard's contract path.
Package feature provides standalone, reusable feature definitions for the Ledger billing engine.
Package feature provides standalone, reusable feature definitions for the Ledger billing engine.
Package id defines TypeID-based identity types for all Ledger entities.
Package id defines TypeID-based identity types for all Ledger entities.
internal
telemetry
Package telemetry is the tracing and metrics plumbing the engine and the plugin registry share.
Package telemetry is the tracing and metrics plumbing the engine and the plugin registry share.
telemetrytest
Package telemetrytest holds what ledger's tests use to see its telemetry: a span recorder installed as the global tracer provider, and a metrics sink that keeps every series it hands out.
Package telemetrytest holds what ledger's tests use to see its telemetry: a span recorder installed as the global tracer provider, and a metrics sink that keeps every series it hands out.
Package observability provides a metrics extension for Ledger that records lifecycle event counts via go-utils MetricFactory.
Package observability provides a metrics extension for Ledger that records lifecycle event counts via go-utils MetricFactory.
Package plugin provides an extensible plugin system for Ledger.
Package plugin provides an extensible plugin system for Ledger.
Package provider defines the contract that payment provider plugins (Stripe, Braintree, etc.) must implement.
Package provider defines the contract that payment provider plugins (Stripe, Braintree, etc.) must implement.
stripe
Package stripe is a Stripe payment provider for Ledger.
Package stripe is a Stripe payment provider for Ledger.
mongo
Package mongo provides a MongoDB Store implementation for Ledger using Grove ORM.
Package mongo provides a MongoDB Store implementation for Ledger using Grove ORM.
postgres
Package postgres provides a PostgreSQL Store implementation for Ledger using Grove ORM.
Package postgres provides a PostgreSQL Store implementation for Ledger using Grove ORM.
sqlite
Package sqlite provides a SQLite Store implementation for Ledger using Grove ORM.
Package sqlite provides a SQLite Store implementation for Ledger using Grove ORM.
storetest
Package storetest provides a conformance suite every store.Store implementation must pass, plus constructors for each backend.
Package storetest provides a conformance suite every store.Store implementation must pass, plus constructors for each backend.
Package types provides common types used across Ledger.
Package types provides common types used across Ledger.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL