04 - Plans and Allowances
Most SaaS products offer pricing tiers - Free, Pro, Enterprise - each with a free monthly allowance of credits. bursar tracks per-user plan assignments and monthly usage windows, automatically falling back to the user's credit balance when the free allowance is consumed.
Think of the allowance as a monthly prepaid bucket. Every Pro user gets 50,000 free credits each month. Every Free user gets 5,000. These buckets reset automatically at the start of each billing period. In contrast, the credit balance (managed via add_credits and deduct_with_allowance from the previous notebook) is permanent - it only changes when credits are manually added or deducted.
Allowance tracking works through plan definitions embedded in the pricing configuration. Each plan specifies a free_allowance - the number of free credits per billing period. When a user is assigned to a plan, the system records the current billing period (a monthly window). Each time the user spends credits, the system first deducts from the free allowance. Once the allowance is exhausted, further spending draws from the user's purchased credit balance (pay-as-you-go).
This notebook uses MemoryStore purely to avoid the Postgres startup overhead for every code cell — plan management works identically on PostgresStore. As covered in Notebook 00, publishing a config with a plans section (via set_active_pricing(), exactly as shown below) is all PostgresStore needs; there is no separate table-seeding step.
import uuid
from datetime import datetime, timedelta
from bursar.interface.memory import MemoryStore
from bursar.manager import CreditManager
from bursar.engine import PricingEngine
from bursar.metrics import UsageMetrics, ToolCall
from bursar.interface.models import (
PricingConfigData, PlanDefinition,
CreditMetadata, SpendCap,
)
store = MemoryStore()
store.setup()
print("✔ MemoryStore ready.")
Persist plan definitions in pricing config
In bursar, plan definitions live inside the pricing configuration, right alongside the model pricing formulas. This keeps all pricing logic - both per-unit costs and subscription allowances - in a single place for easy maintenance.
Each plan has three key properties: an id (used to reference the plan when assigning users), a human-readable name, and a free_allowance (the number of free credits the user gets each billing period). By default the billing period is a monthly window that resets on calendar-month UTC boundaries (allowance_period="calendar_month"). Two other modes are available: "rolling_30d" (a rolling 30-day window) and "anniversary" (resets on the same day-of-month the user was assigned the plan). For both non-calendar modes, the window is anchored to when set_user_plan() was called for that user — not their account signup date — so re-assigning a plan re-anchors the cycle. When a user is assigned to a plan, the system records the period_start and period_end. The allowance resets automatically when the period ends.
This auto-reset makes the allowance fundamentally different from the balance. The allowance refills every period, while the balance only changes when credits are manually added or deducted through add_credits and deduct_with_allowance. A Pro user with 50,000 monthly allowance still needs a credit balance for usage beyond the free tier.
# Store pricing configuration with both model formulas and plan definitions.
# MemoryStore.set_active_pricing() extracts plan definitions from PricingConfigData.
# This keeps all pricing logic in one place for easy maintenance.
store.set_active_pricing(
PricingConfigData(
# Model pricing formulas — same format as PricingEngine.from_dict().
models={
"gpt-4o": "input_tokens * 5 + output_tokens * 15",
},
# Plan definitions — each plan specifies a free monthly allowance.
# "pro" tier: users get 50,000 free credits per month
# "free" tier: users get 5,000 free credits per month
plans={
"pro": PlanDefinition(
id="pro", name="Pro Tier",
free_allowance=50_000,
),
"free": PlanDefinition(
id="free", name="Free Tier",
free_allowance=5_000,
),
},
),
label="default",
)
print(" Pricing config stored with 2 plan definitions: Pro (50,000/mo) and Free (5,000/mo)")
Assign a user and check allowance
Once plans are configured, we can assign a user to a plan and check their remaining free allowance. The assignment is done via set_user_plan(), which links a user ID to a plan ID in the store.
The check_allowance() method returns an AllowanceResult that includes the plan ID, the current billing period's start and end dates, and the remaining allowance. Initially, a new Pro user has the full 50,000 allowance available - no credits have been consumed yet in the current billing period. The period dates tell you exactly when the allowance will reset.
# Generate a new user and assign them to the "pro" plan.
# set_user_plan() links the user ID to the plan definition stored earlier.
user = str(uuid.uuid4())
store.set_user_plan(user, "pro")
# Check how many free credits the user has remaining in this billing period.
# AllowanceResult contains:
# - plan_id: the name of the plan the user is on
# - period_start: the beginning of the current billing period (monthly window)
# - period_end: when the current period ends and the allowance resets
# - allowance_remaining: how many free credits are still available this period
allow = store.check_allowance(user)
print(f" Plan: {allow.plan_id}")
print(f" Period: {allow.period_start} → {allow.period_end}") # Monthly window
print(f" Remaining: {allow.allowance_remaining}") # Full 50,000 available since no usage yet
assert allow.allowance_remaining == 50_000
print(" ✓ Full 50 000 free allowance available")
Consume allowance
When a user makes a request that costs credits, the system should first consume the free allowance before drawing from the purchased balance. The increment_usage_window() method records usage against the user's allowance for the current billing period.
After calling increment_usage_window(), the next call to check_allowance() returns a reduced remaining amount. Once the allowance reaches zero, further requests use the user's purchased credit balance (pay-as-you-go). The allowance never goes negative - it stops at zero and the system switches to balance-based charging.
# Consume 3,000 credits from the user's free allowance.
# In production, this would be called alongside deduct_with_allowance() to
# also track how much of the free allowance has been used this period.
store.increment_usage_window(user, "pro", 3_000)
# Check the allowance again to confirm it was reduced by the correct amount.
allow2 = store.check_allowance(user)
print(f" Remaining after 3 000 used: {allow2.allowance_remaining}")
assert allow2.allowance_remaining == 47_000 # 50,000 - 3,000
print(" ✓ Allowance correctly reduced")
Free tier vs Pro tier
Different plans have different allowance amounts. The Free tier typically offers a small monthly allowance to let users evaluate the product, while the Pro tier offers substantially more for regular active usage.
In our configuration, the Free tier has a 5,000-credit monthly allowance - one-tenth of the Pro tier's 50,000. This means a Free user would exhaust their allowance after roughly 1,000 gpt-4o input tokens, while a Pro user could run over 10,000 tokens before hitting the cap. Once the allowance runs out, both tiers continue to work, but they charge against the user's purchased credit balance instead.
# Create a Free tier user and compare their allowance to the Pro user.
free_user = str(uuid.uuid4())
store.set_user_plan(free_user, "free")
free_allow = store.check_allowance(free_user)
print(f" Free user allowance: {free_allow.allowance_remaining}") # 5,000 — ten times less than Pro
assert free_allow.allowance_remaining == 5_000
print(" ✓ Free tier gets 5 000/month")
Rolling 30-day allowance windows
Calendar-month resets are simple but can feel arbitrary to a user who signs up on the 28th and loses most of their first month's allowance three days later. allowance_period="rolling_30d" fixes this: the allowance resets exactly 30 days after the plan was assigned, not on the 1st of the month. "anniversary" (not shown here) is the middle ground — it resets monthly, but on the day-of-month the plan was assigned rather than the 1st.
You never compute the window yourself: CreditManager.check_allowance() resolves period_start/period_end for whichever mode the plan uses and returns them directly. (Internally, the store layer keys usage rows by an explicit date so a PostgresStore/SupabaseStore restart doesn't lose track of which window is current — but that's plumbing, not something you need to think about.)
One boundary case worth knowing: if a user's allowance period ends mid-session — say they have 4,000 credits remaining and the window rolls over between one request and the next — the very next check_allowance() call already reflects the new period: a full, fresh allowance, not the stale 4,000. There is no partial carryover between periods in either direction.
from bursar.manager import CreditManager
store.set_active_pricing(
PricingConfigData(
models={"_default": "input_tokens * 1"},
plans={
"startup": PlanDefinition(
id="startup", name="Startup Tier", free_allowance=20_000,
allowance_period="rolling_30d",
),
},
),
label="rolling",
)
manager = CreditManager(store=store)
rolling_user = str(uuid.uuid4())
store.set_user_plan(rolling_user, "startup")
allow3 = manager.check_allowance(rolling_user)
print(f" Plan: {allow3.plan_id}")
print(f" Period: {allow3.period_start} → {allow3.period_end}") # 30-day window starting today
print(f" Remaining: {allow3.allowance_remaining}")
assert allow3.allowance_remaining == 20_000
print(" ✓ rolling_30d window resolved automatically by the manager")