Credits and money
How an amount is represented, which pools a turn draws from, how provider cost becomes a charge, and what the workspace sees of it.
A credit is money: an integer number of micros in the one currency your deployment bills in. After this page you can follow a single AI turn from the provider’s price to the workspace’s balance. The scaffold already bills in USD at four times provider cost, with the allowances in lib/plans.ts.
Money is micros with a currency
@intelligo-dev/core/money represents every amount as a Money: an integer count of millionths of one major unit, and the ISO-4217 code it is denominated in. Micros, because one turn on a cheap model costs a fraction of a cent. The currency travels with the amount, so add, subtract and compare throw a MoneyError with code currency_mismatch rather than treat euros as dollars.
import {
add,
convert,
formatMoney,
fromMajor,
toMinor,
} from "@intelligo-dev/core/money";
const total = add(fromMajor(15, "USD"), fromMajor(5, "USD"));
// { amount: 20_000_000, currency: "USD" }
formatMoney(total, "en-US"); // "$20.00"
toMinor(total); // 2000, the integer a payment provider takes
add(total, fromMajor(10, "EUR")); // throws MoneyError: currency_mismatch
convert(total, "EUR", 920_000); // 18_400_000 micros EUR, at a rate you state| Function | What it does |
|---|---|
money, fromMajor, fromMinor |
Build an amount from micros, major units (12.34) or minor units (1234) |
toMajor, toMinor |
Read it back; toMinor rounds up and knows zero- and three-decimal currencies |
add, subtract, compare |
Same-currency arithmetic; a mismatch throws |
multiply |
Scale by a plain factor, rounding up |
convert |
Change currency at an explicit rate in micros; there is no default rate |
formatMoney |
Format for a locale; sub-cent amounts keep two significant digits |
The three pools
A workspace spends from three pools, all in the deployment’s billing currency.
| Pool | Where it comes from | Stored in |
|---|---|---|
| Plan allowance | monthlyAllowance on the workspace’s plan in lib/plans.ts |
monthly_usage, one row a month |
| Top-up balance | Credit bundles bought through checkout; the Stripe webhook adds each bundle’s grant |
credit_balances |
| Trial grant | registerTrialConfig in the composition root, provisioned by provisionTrialCredits from lib/workspace-bootstrap.ts; off by default |
trial_credits |
Admission sums all three. Settlement charges the plan allowance first and sends whatever is left to exactly one other pool: the top-up balance, or the trial grant when the turn was admitted on trial credits and the grant still covers the remainder. The trial is the fallback, used only when allowance plus top-ups cannot cover a turn. SettlementOutcome reports the split, and charged always equals plan + topup + trial.
The allowance is counted per calendar month, in UTC, so every host agrees on where a month starts. monthly_usage holds one row per workspace per month, a new month starts from zero, and unused allowance does not carry over. resetMonthlyQuota, which the Stripe webhook calls on invoice.paid, creates the current month’s row when it is missing; it does not clear a row that exists. Top-ups and the trial grant do not reset.
A plan’s monthlyAllowance must name the billing currency. If it names another, the allowance counts as zero, because converting it would mean inventing a rate.
One turn, in money
The execution boundary describes the lifecycle. In money terms, with the scaffold’s bindings:
- Admit.
checkEntitlementcallsreserveQuota. Under a per-workspace lock it reads the pools, subtracts other active reservations, and compares what remains withestimateWorstCaseCharge: the model’smaxOutputTokensagainst a 16,000-token input budget. If the turn fits, it inserts a reservation for that amount. Ongoogle/gemini-2.5-flashat the defaults the hold is about $0.10. - Settle.
settleUsagecallsrecordTokenUsage, which prices the real tokens withchargeFor, writes ausage_recordsrow, debits the pools and settles the reservation in one transaction. - Fail.
releaseHoldcallsreleaseReservation. Nothing is charged. A reservation nobody settles stops counting after ten minutes (RESERVATION_TTL_MS).
A refusal carries a stable code:
export type QuotaRefusalCode =
| "insufficient_credits"
| "allowance_depleted"
| "billing_not_configured"
| "unknown_model";| Code | Meaning | From /api/chat |
|---|---|---|
insufficient_credits |
Some balance remains, but less than the worst-case charge of one turn on the model | 402 QUOTA_EXCEEDED |
allowance_depleted |
Nothing remains in any pool | 402 QUOTA_EXCEEDED |
unknown_model |
The model id has no registered price, so the turn cannot be estimated | 503 MODEL_UNAVAILABLE |
billing_not_configured |
No product or plans are registered | 503 BILLING_NOT_CONFIGURED |
The chat transport puts the code in the response body as reasonCode. estimateQuota makes the same decision without locking or reserving; it is for banners and previews and must never gate a run.
From provider cost to charge
Providers quote USD per million tokens. chargeFor multiplies tokens by the registered price, applies the margin, then converts into the billing currency. Both steps round up.
export type BillingRate = {
currency: CurrencyCode;
usdRateMicros: number;
marginBp: number;
};| Field | Meaning | Scaffold value |
|---|---|---|
currency |
What every ledger row is denominated in | "USD" |
usdRateMicros |
What one USD costs in that currency, in micros; 920_000 is 0.92 |
1_000_000 |
marginBp |
Multiplier over provider cost, in basis points; 40_000 is 4× |
DEFAULT_MARGIN_BP (40_000) |
A turn of 2,000 input and 500 output tokens on google/gemini-2.5-flash costs the provider 1,850 micros. At 4× that is 7,400 micros, $0.0074 charged. A euro deployment at 920_000 charges 6,808 micros EUR.
The rate is a fixed number you state, not a live feed. It lives in the single billing_settings row. ensureBillingSettingsRow in composeIntelligo() seeds that row and leaves an existing one alone, so editing the call after the first boot changes nothing. To change them later, call updateBillingSettings({ usdRateMicros, marginBp }) — from an admin action or a script; it validates the values and drops the cache. Changing currency does not convert the balances a ledger already holds. getBillingSettings caches it for 60 seconds per process. Each usage_records row stores the provider cost, margin and rate it was charged at, so a later change never rewrites history.
What the workspace sees
- The usage block shows the period’s charge, tokens and runs from
summarizeExecutions, the share of the allowance used fromgetQuotaThresholds, and the trial state. The billing settings block shows the top-up balance and sells bundles. - The chat page calls
getChatQuotaState, an estimate, to show a banner before a turn would be refused. - After each settlement
checkNotificationTriggersruns. At 80% and 100% of the allowance, and at 20% left or depletion of a trial, the workspace owner gets an in-app notification and an email, once per period. getUsageSummaryreturns tokens and request counts by model, agent and day, for a dashboard of your own.
Next
- Models and pricing: register per-model prices.
- Plans and features: set the allowance each plan grants.
- Billing with Stripe: sell subscriptions and top-ups.