Credits service
bursar.credits owns credit balances, usage charging, the lease lifecycle,
plans, ledger history, analytics, and team charging. Every method is async,
and every monetary amount is a Decimal quantized to six decimal places with
half-up rounding. The account model is bucket-based: credits live in named
buckets (e.g. promotional priority 1, purchased priority 10) and are
consumed highest-priority-first.
Balances and adjustments
const grant = await bursar.credits.addCredits(userId, "100", {
type: "purchase",
idempotencyKey: "purchase:1",
});
const charge = await bursar.credits.deductCredits(userId, "5", {
entryType: "adjustment",
idempotencyKey: "operation:1",
});
const refund = await bursar.credits.refundCredits(charge.entryId, {
reason: "clawback",
idempotencyKey: "refund:operation:1",
});
addCredits and deductCredits move a raw amount; refundCredits reverses a
prior entry. Every successful monetary result identifies its canonical ledger
row (entryId for adjustments, refundEntryId for refunds). getBalance,
getAvailable, and getBucketBalances are advisory reads.
Pass raw credit amounts as decimal strings or Decimal instances. Native
JavaScript numbers are rejected so financial input cannot arrive through a
binary floating-point value. Monetary result fields are Decimal instances.
Idempotency — read carefully
All three adjustment methods require a caller-stable idempotencyKey:
retrying with the same key replays the original result instead of
double-applying.
| Method | Parameters | Returns |
|---|---|---|
addCredits | userId: string, amount: ExactAmount, options: AddCreditsOptions | Promise<AddCreditsResult> |
deductCredits | userId: string, amount: ExactAmount, options: DeductCreditsOptions | Promise<AddCreditsResult> |
refundCredits | entryId: string, options: RefundCreditsOptions | Promise<RefundResult> |
getBalance | userId: string | Promise<BalanceResult> |
getAvailable | userId: string | Promise<AvailableResult> |
getBucketBalances | userId: string | Promise<BucketBalancesResult> |
Metered charging
deduct(userId, metrics, options) prices a
UsageMetrics event with the active pricing engine and charges the cost in
one atomic, idempotency-keyed transaction. The idempotency key is the
replay guard for metered usage: the same key returns the same
DeductionResult instead of double-charging, so every replayable mutation
requires the caller to supply a stable key.
const result = await bursar.credits.deduct(
userId,
{
operation: "completion",
measures: { input_tokens: 500, output_tokens: 200 },
dimensions: { model: "gpt-4o" },
},
{ idempotencyKey: "request:9f8c" },
);
deduct enforces the user's plan: unentitled operations, allowance limits,
and quotas can block the charge. feature additionally requires a plan
feature. deductFlatJob and grantSubscriptionCycle are specialized siblings
for flat-rate jobs and billing-cycle grants. revokeCreditsByEntryType
removes all remaining lots sourced by one matching ledger operation; it is a
broad cohort operation, not a single-transaction refund.
sweepExpiredCredits(dryRun = false) expires eligible credit lots.
| Method | Parameters | Returns |
|---|---|---|
deduct | userId: string, metrics: UsageMetrics, options: DeductOptions | Promise<DeductionResult> |
deductFlatJob | userId: string, jobName: string, options: DeductFlatJobOptions | Promise<DeductionResult> |
revokeCreditsByEntryType | userId: string, entryType: string | Promise<RevokeCreditsResult> |
sweepExpiredCredits | dryRun = false | Promise<SweepResult> |
AI and batch usage accounting
Bursar records financial events, not workflow execution state. Charge a
fixed-price workload once with deductFlatJob; keep its stages, retries,
latency, model calls, and parent/child trace structure in your workflow store
and OpenTelemetry backend. For dynamically priced inference, use deduct when
the amount is known up front or a lease when the final provider usage is known
only after completion.
Each deduction or lease settlement creates a canonical credit_usage_charges
receipt. Put only bounded values needed for pricing and reconciliation in its
measures, dimensions, and metadata. Use recordUsage only when you
intentionally need a priced receipt without another account debit, such as
usage billed by an external system; it is not a tracing API.
Detailed receipt payloads use monthly PostgreSQL partitions by default and follow the configured retention policy. When ClickHouse is enabled, those payloads and usage analytics are projected there instead. Operational AI telemetry—including prompts, completions, latency, retries, errors, and router decisions—belongs in OpenTelemetry/Langfuse.
Leases
When the final cost is only known after the work runs, reserve the estimate,
do the work, then settle the actual cost. reserve is the only admission
gate; a lease whose TTL expires before settlement is released by the store's
reaper. settle bills the actual amount and finalizes the lease; release
returns an unused hold; renew extends an active lease without changing its
captured policy snapshot.
runBilled(userId, options) wraps reserve → work → settle in one call:
options.estimate prices the hold, options.doWork runs the operation and
resolves { result, actual }, and any rejection from doWork releases the
lease and re-throws. Long jobs may call renew from inside doWork; a crash
between reserve and settle is covered by the lease TTL.
const lease = await bursar.credits.reserve(userId, estimate, {
idempotencyKey: "run:7:reserve",
ttl: 300,
});
// ... do the work ...
await bursar.credits.settle(userId, lease.leaseId, actual, {
idempotencyKey: "run:7:settle",
});
| Method | Parameters | Returns |
|---|---|---|
reserve | userId: string, metricsOrAmount, options: ReserveOptions | Promise<LeaseResult> |
settle | userId: string, leaseId: string, metricsOrAmount, options: SettleOptions | null | Promise<DeductionResult> |
release | userId: string, leaseId: string | Promise<ReleaseResult> |
renew | userId: string, leaseId: string, ttl?: number | null | Promise<LeaseResult> |
runBilled | userId: string, options: RunBilledOptions<T> | Promise<{ result: T; deduction: DeductionResult }> |
ReserveOptions carries idempotencyKey, operationType, billingMode,
ttl, metadata, feature, and model; SettleOptions carries
idempotencyKey, metadata, and feature. A caller-supplied settlement key
is validated like every other replay key; if it is omitted, Bursar derives the
stable key from the lease ID because a lease can settle only once.
Plans
setUserPlan(userId, planKey, planAssignedAt?) assigns a plan from the active
catalog and emits credits.plan_changed. getUserPlan returns the current
plan. checkAllowance reports the plan's credit allowance (bucket + window)
state, and checkFeature resolves a feature value against the plan's
entitlements.
await bursar.credits.setUserPlan(userId, "pro");
const allowance = await bursar.credits.checkAllowance(userId);
const voice = await bursar.credits.checkFeature(userId, "voice_mode");
| Method | Parameters | Returns |
|---|---|---|
setUserPlan | userId: string, planKey: string, planAssignedAt?: Date | null | Promise<void> |
getUserPlan | userId: string | Promise<GetUserPlanResult> |
checkAllowance | userId: string | Promise<AllowanceResult> |
checkFeature | userId: string, feature: string | Promise<CheckFeatureResult> |
Ledger and usage charges
All history views share one cursor contract: stable (createdAt, entryId)
ordering, cursor-only pagination, and no offset support. listLedgerEntries
is the full account ledger; listUsageEntries filters it to usage entries;
listUsageCharges returns metered usage charges (including allowance-covered
events).
let page = await bursar.credits.listLedgerEntries(userId, { limit: 50 });
const entry = await bursar.credits.getLedgerEntry(
userId,
page.items[0].entryId,
);
while (page.nextCursor) {
page = await bursar.credits.listLedgerEntries(userId, {
limit: 50,
cursor: page.nextCursor,
});
}
| Method | Parameters | Returns |
|---|---|---|
listLedgerEntries | userId: string, options?: { entryTypes?, fromDate?, toDate?, limit?, cursor? } | Promise<LedgerPage> |
getLedgerEntry | userId: string, entryId: string | Promise<LedgerEntry | null> |
listUsageEntries | userId: string, options?: { fromDate?, toDate?, limit?, cursor? } | Promise<LedgerPage> |
listUsageCharges | userId: string, options?: { fromDate?, toDate?, limit?, cursor? } | Promise<UsageChargePage> |
Analytics
Usage analytics aggregate across all users of the tenant within a
[start, end) window. These are optional store capabilities — custom stores
that omit them reject with CapabilityNotSupportedError.
| Method | Parameters | Returns |
|---|---|---|
spendByUser | start: Date, end: Date | Promise<SpendByUserRow[]> |
spendByModel | start: Date, end: Date | Promise<SpendByModelRow[]> |
topUsers | limit: number, start: Date, end: Date | Promise<TopUserRow[]> |
dailySpend | start: Date, end: Date | Promise<DailySpendRow[]> |
aggregateStats | start: Date, end: Date | Promise<AggregateStats> |
Teams
Team management is a store-level capability: createTeam, getTeamBalance,
addTeamMember, removeTeamMember, and listTeamMembers live on the
CreditStore (call them on your store object), while
bursar.credits.deductTeam charges the shared pool through the facade.
await store.createTeam(teamId, { displayName: "Acme" });
await store.addTeamMember(teamId, userId);
await bursar.credits.deductTeam(
teamId,
userId,
{
operation: "execution",
measures: { jobs: 1 },
dimensions: { model: "gpt-4o" },
},
{ idempotencyKey: "team:run:3" },
);
| Method | Parameters | Returns |
|---|---|---|
deductTeam | teamId: string, userId: string, metrics: UsageMetrics, options: DeductTeamOptions | Promise<TeamDeductionResult> |
See Credit lifecycle for the full account model, and Leases and financial safety for lease guarantees.