Create your first metered charge
This tutorial creates an isolated Bursar tenant, publishes one pricing rule, grants prepaid credits, and records a usage charge. The final ledger entry proves which account was charged, why it was charged, and which balance resulted.
Prerequisites
Prepare these dependencies before continuing:
- Python 3.12 or 3.13 for the Bursar command-line interface (CLI)
- PostgreSQL 16 or newer with
pg_partman5.x andpg_jsonschema0.3+ available to the migration role - A PostgreSQL migration-owner connection and permission to create two least-privilege login roles
- Node.js 22 or newer if you will use the TypeScript example
- Go 1.25 or newer if you will use the Go example
:::caution Use a development database
bursar migrate creates and secures the bursar schema. Run this tutorial against a development database, not an existing production tenant.
:::
1. Install the package and schema
Install the Python package with PostgreSQL support. The package supplies the CLI used by both SDK integrations.
python -m pip install "bursar[postgres]"
export BURSAR_MIGRATION_DATABASE_URL="postgresql://bursar_migrator:password@localhost:5432/bursar"
bursar migrate
As the migration owner, create separate operator and application logins. Set real passwords through your database provider or secret tooling rather than placing them in shell history:
CREATE ROLE bursar_app
LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS;
GRANT bursar_client TO bursar_app WITH INHERIT FALSE, SET TRUE;
CREATE ROLE bursar_ops
LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS;
GRANT bursar_operator TO bursar_ops WITH INHERIT FALSE, SET TRUE;
Give each login only the role shown above, then supply its connection through your normal secret manager. These illustrative local URLs keep the two trust boundaries explicit:
export BURSAR_OPERATOR_DATABASE_URL="postgresql://bursar_ops:password@localhost:5432/bursar"
export DATABASE_URL="postgresql://bursar_app:password@localhost:5432/bursar"
export BURSAR_PROVIDER_ENVIRONMENT="test"
BURSAR_PROVIDER_ENVIRONMENT selects an isolated financial namespace. Use
test for this tutorial and provider test credentials; production uses live.
TypeScript applications also install the Node.js package:
npm install @zonastery/bursar pg
Go applications install the versioned module. The Python package still owns the migration CLI, so every SDK uses the same ordered SQL baseline:
go get github.com/Zonastery/bursar/golang/v2
bursar migrate applies the ordered SQL baseline and records each file checksum. Re-running the command is a no-op. The command fails if an applied migration file has changed.
2. Create a tenant
Create a tenant with an explicit universally unique identifier (UUID). Every store binds to one tenant before it reads or writes business data.
bursar tenant create acme \
--id 018f7f5f-7b4a-7000-8000-000000000001 \
--display-name "Acme"
export BURSAR_TENANT_ID="018f7f5f-7b4a-7000-8000-000000000001"
3. Define and publish pricing
Save this minimal configuration as pricing.yaml. It defines one metered operation, one rate card, one credit bucket, and one plan.
version: 1
catalog:
default_plan: standard
pricing:
operations:
completion:
measures:
input_tokens: { unit: token }
dimensions:
model: { type: string }
rate_cards:
standard:
operations:
completion:
unmatched:
action: charge
charge:
type: per_unit
measure: input_tokens
rate: "0.25"
unit_size: "1000"
credits:
buckets:
purchased: { priority: 10 }
default_bucket: purchased
plans:
standard:
rate_card: standard
allowed_operations: [completion]
Validate the file before publishing it:
bursar config validate pricing.yaml
bursar config set pricing.yaml --label "quickstart"
bursar config list
The schema rejects unknown fields. Exact decimal values such as rate and unit_size remain strings so parsing does not introduce floating-point values.
4. Construct the facade
Read the connection and tenant identifiers from the environment, then bind one store to the tenant.
- Python
- TypeScript
- Go
import os
from bursar import Bursar, PostgresStore
database_url = os.environ["DATABASE_URL"]
tenant_id = os.environ["BURSAR_TENANT_ID"]
provider_environment = os.environ["BURSAR_PROVIDER_ENVIRONMENT"]
store = PostgresStore(
database_url,
tenant_id=tenant_id,
provider_environment=provider_environment,
)
bursar = Bursar(credit_store=store)
import {
Bursar,
PostgresStore,
type ProviderEnvironment,
} from "@zonastery/bursar";
const databaseUrl = process.env.DATABASE_URL!;
const tenantId = process.env.BURSAR_TENANT_ID!;
const providerEnvironment = process.env
.BURSAR_PROVIDER_ENVIRONMENT! as ProviderEnvironment;
const store = new PostgresStore({
postgres: databaseUrl,
tenantId,
providerEnvironment,
});
const bursar = new Bursar({ creditStore: store });
package main
import (
"context"
"log"
"os"
bursar "github.com/Zonastery/bursar/golang/v2"
)
func main() {
ctx := context.Background()
store, err := bursar.NewPostgresStore(ctx, os.Getenv("DATABASE_URL"), bursar.PostgresStoreOptions{
TenantID: os.Getenv("BURSAR_TENANT_ID"),
ProviderEnvironment: bursar.ProviderEnvironmentTest,
})
if err != nil {
log.Fatal(err)
}
defer store.Close()
sdk, err := bursar.New(bursar.Options{CreditStore: store})
if err != nil {
log.Fatal(err)
}
if err := sdk.LoadCatalog(ctx); err != nil {
log.Fatal(err)
}
}
Keep the store tenant-bound for its full lifetime. Never set a session-scoped tenant value on a shared connection pool.
5. Create and fund an account
Create a stable account identifier in your application database. Notify Bursar after signup, then post a purchase with a replay-safe idempotency key.
- Python
- TypeScript
- Go
from decimal import Decimal
account_id = "11111111-1111-4111-8111-111111111111"
bursar.accounts.on_account_created(
account_id,
event_key="signup:11111111",
)
grant = bursar.credits.add_credits(
account_id,
Decimal("100.000000"),
entry_type="purchase",
idempotency_key="purchase:quickstart:11111111",
)
print(grant.entry_id, grant.new_balance)
const accountId = "11111111-1111-4111-8111-111111111111";
await bursar.accounts.onAccountCreated({
accountId,
eventKey: "signup:11111111",
});
const grant = await bursar.credits.addCredits(accountId, "100.000000", {
type: "purchase",
idempotencyKey: "purchase:quickstart:11111111",
});
console.log(grant.entryId, grant.newBalance);
accountID := "11111111-1111-4111-8111-111111111111"
if _, err := sdk.Accounts.OnAccountCreated(ctx, bursar.AccountCreatedInput{
AccountID: accountID,
EventKey: "signup:11111111",
}); err != nil {
log.Fatal(err)
}
grant, err := sdk.Credits.AddCredits(ctx, accountID, bursar.MustAmount("100.000000"), bursar.AddCreditsOptions{
Type: "purchase",
IdempotencyKey: "purchase:quickstart:11111111",
})
if err != nil {
log.Fatal(err)
}
log.Println(grant.EntryID, grant.NewBalance)
The signup event assigns the standard plan from catalog.default_plan. The purchase appends one ledger entry and creates spendable credit in the purchased bucket.
6. Meter and charge usage
Record 800 input tokens. The rate card prices 1,000 tokens at 0.25 credits, so this call charges 0.20 credits.
- Python
- TypeScript
- Go
from bursar.metrics import UsageMetrics
usage = UsageMetrics(
operation="completion",
measures={"input_tokens": Decimal("800")},
dimensions={"model": "example-model"},
)
charge = bursar.credits.deduct(
account_id,
usage,
idempotency_key="request:quickstart:0195",
)
print(charge.amount, charge.balance_after, charge.entry_id)
const usage = {
operation: "completion",
measures: { input_tokens: "800" },
dimensions: { model: "example-model" },
};
const charge = await bursar.credits.deduct(accountId, usage, {
idempotencyKey: "request:quickstart:0195",
});
console.log(charge.amount, charge.balanceAfter, charge.entryId);
engine, err := sdk.Catalog.Engine()
if err != nil {
log.Fatal(err)
}
quote, err := engine.Calculate(bursar.UsageMetrics{
Operation: "completion",
Measures: map[string]bursar.Amount{"input_tokens": bursar.MustAmount("800")},
Dimensions: map[string]any{"model": "example-model"},
})
if err != nil {
log.Fatal(err)
}
charge, err := sdk.Credits.Deduct(ctx, accountID, quote.Total, bursar.DeductWithAllowanceOptions{
Operation: "completion",
IdempotencyKey: "request:quickstart:0195",
OperationUsageOptions: bursar.OperationUsageOptions{
Measures: map[string]bursar.Amount{"input_tokens": bursar.MustAmount("800")},
Dimensions: map[string]any{"model": "example-model"},
},
})
if err != nil {
log.Fatal(err)
}
log.Println(charge.Amount, charge.EntryID)
Call deduct again with the same idempotency key and payload. Bursar returns the original result without adding another ledger entry. Reusing the key with a different payload returns a conflict.
7. Verify the ledger
Read the canonical balance and the latest entries. The account should hold 99.800000 credits after the 0.200000 charge.
- Python
- TypeScript
- Go
balance = bursar.credits.get_balance(account_id)
page = bursar.credits.list_ledger_entries(account_id, limit=10)
print(balance.balance)
for entry in page.items:
print(entry.entry_type, entry.amount, entry.created_at)
const balance = await bursar.credits.getBalance(accountId);
const page = await bursar.credits.listLedgerEntries(accountId, { limit: 10 });
console.log(balance.balance);
for (const entry of page.items) {
console.log(entry.entryType, entry.amount, entry.createdAt);
}
balance, err := sdk.Credits.GetBalance(ctx, accountID)
if err != nil {
log.Fatal(err)
}
page, err := sdk.Credits.ListLedgerEntries(ctx, accountID, bursar.ListLedgerEntriesOptions{Limit: 10})
if err != nil {
log.Fatal(err)
}
log.Println(balance.Balance)
for _, entry := range page.Items {
log.Println(entry.EntryType, entry.Amount)
}
The ledger is the accounting history. Do not maintain a second application-level balance counter.
Continue with a production workflow
- Follow Manage the credit lifecycle for refunds, expiry, revocation, and cursor-based ledger reads
- Follow Protect long-running work for reserve, renew, settle, and release workflows
- Read Configuration and catalog revisions before adding plans, quotas, entitlements, or payment offers
- Run the executable tutorial collection to explore the same APIs against disposable PostgreSQL environments