Skip to main content
Version: 2.x

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.

MethodParametersReturns
addCreditsuserId: string, amount: ExactAmount, options: AddCreditsOptionsPromise<AddCreditsResult>
deductCreditsuserId: string, amount: ExactAmount, options: DeductCreditsOptionsPromise<AddCreditsResult>
refundCreditsentryId: string, options: RefundCreditsOptionsPromise<RefundResult>
getBalanceuserId: stringPromise<BalanceResult>
getAvailableuserId: stringPromise<AvailableResult>
getBucketBalancesuserId: stringPromise<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.

MethodParametersReturns
deductuserId: string, metrics: UsageMetrics, options: DeductOptionsPromise<DeductionResult>
deductFlatJobuserId: string, jobName: string, options: DeductFlatJobOptionsPromise<DeductionResult>
revokeCreditsByEntryTypeuserId: string, entryType: stringPromise<RevokeCreditsResult>
sweepExpiredCreditsdryRun = falsePromise<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",
});
MethodParametersReturns
reserveuserId: string, metricsOrAmount, options: ReserveOptionsPromise<LeaseResult>
settleuserId: string, leaseId: string, metricsOrAmount, options: SettleOptions | nullPromise<DeductionResult>
releaseuserId: string, leaseId: stringPromise<ReleaseResult>
renewuserId: string, leaseId: string, ttl?: number | nullPromise<LeaseResult>
runBilleduserId: 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");
MethodParametersReturns
setUserPlanuserId: string, planKey: string, planAssignedAt?: Date | nullPromise<void>
getUserPlanuserId: stringPromise<GetUserPlanResult>
checkAllowanceuserId: stringPromise<AllowanceResult>
checkFeatureuserId: string, feature: stringPromise<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,
});
}
MethodParametersReturns
listLedgerEntriesuserId: string, options?: { entryTypes?, fromDate?, toDate?, limit?, cursor? }Promise<LedgerPage>
getLedgerEntryuserId: string, entryId: stringPromise<LedgerEntry | null>
listUsageEntriesuserId: string, options?: { fromDate?, toDate?, limit?, cursor? }Promise<LedgerPage>
listUsageChargesuserId: 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.

MethodParametersReturns
spendByUserstart: Date, end: DatePromise<SpendByUserRow[]>
spendByModelstart: Date, end: DatePromise<SpendByModelRow[]>
topUserslimit: number, start: Date, end: DatePromise<TopUserRow[]>
dailySpendstart: Date, end: DatePromise<DailySpendRow[]>
aggregateStatsstart: Date, end: DatePromise<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" },
);
MethodParametersReturns
deductTeamteamId: string, userId: string, metrics: UsageMetrics, options: DeductTeamOptionsPromise<TeamDeductionResult>

See Credit lifecycle for the full account model, and Leases and financial safety for lease guarantees.