Skip to main content

14 - Subscription Billing

Every SaaS product eventually wires credits to a payment provider: a signup bonus fires when an account is created, a subscription grant fires on every successful renewal, and a yearly plan still needs to feel like "so many credits a month" even though money changed hands only once a year. bursar does not talk to Stripe (or any payment provider) directly — see Architecture — so all three scenarios start the same way: your webhook handler receives an event, decides it is legitimate, and calls a plain bursar method. This notebook walks through the three canonical shapes and the bursar primitive each one maps to.

Signup bonus that expires is nothing new — it is add_credits() from Notebook 05, granted into an expiring tier. Subscription renewal is new: grant_subscription_cycle() is a single idempotent call meant to be invoked directly from a webhook handler. It expires whatever was left over from last cycle (replace_prior=True, the default) before granting the new amount, and treats the provider's own webhook event id as the idempotency key so a redelivered webhook is a safe no-op instead of a double grant. Yearly plan, monthly reset is the trick question: you do not grant a new balance every month. You configure free_allowance + allowance_period on the plan once, call set_user_plan() once when the yearly webhook fires, and the existing allowance engine from Notebook 04 resets itself every month — resolve_allowance_window is lazy-on-read, so there is nothing to schedule.

This notebook also introduces lazy_expiry=True, a CreditManager constructor flag that makes credit expiry lazy-on-read like allowances and lease TTLs already are — no sweep_expired_credits() cron call is required for the demonstration below (though a periodic sweep is still worth keeping around; see the closing note).

We use MemoryStore throughout, as Notebooks 04 and 13 did — none of grant_subscription_cycle, lazy_expiry, or the allowance engine is Postgres-specific.

What we will do in this section: grant an expiring signup bonus and watch lazy_expiry reap a short-lived grant with no sweep call; simulate a Stripe-style renewal webhook and prove that redelivering it does not double-grant; and configure a yearly plan with a monthly allowance reset that requires zero additional code once the webhook fires.

import time
import uuid
from datetime import UTC, datetime, timedelta
from decimal import Decimal
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, TierDefinition,
CreditMetadata,
)

store = MemoryStore()
store.setup()

# Three tiers: "gifted" credits drain first and expire (Notebook 13),
# "subscription" credits are the current billing cycle's grant (cleared
# administratively by grant_subscription_cycle's replace_prior, not by a
# per-grant TTL), and "purchased" is the permanent, default bucket
# everything else falls back to.
store.set_active_pricing(
PricingConfigData(
models={"_default": "input_tokens * 1"},
tiers={
"gifted": TierDefinition(name="Gifted Credits", priority=10, expires=True),
"subscription": TierDefinition(name="Subscription Credits", priority=20, expires=False),
"purchased": TierDefinition(
name="Purchased Credits", priority=30, expires=False, is_default=True,
),
},
),
label="subscription-billing-demo",
)

# lazy_expiry=True makes expiry lazy-on-read (like allowances and lease TTLs
# already are) -- no periodic sweep_expired_credits() cron required for users
# who are actively transacting. See the closing note for the full tradeoff.
manager = CreditManager(store=store, lazy_expiry=True)
print("✔ MemoryStore ready, tiers configured, CreditManager(lazy_expiry=True).")

Signup bonus that expires in 30 days

A new signup should feel rewarded immediately, but a free gift that never expires is a permanent discount you didn't mean to offer. The fix is the same add_credits() call from Notebook 05, aimed at an expiring tier: pass tier="gifted" and an explicit expires_at 30 days out — equivalently, configure the tier's own default_ttl_days=30 and omit expires_at entirely (see Pricing Configuration). Either way this is an ordinary webhook-driven grant: your signup handler decides the user just signed up and calls add_credits() once — there is no bursar-specific Stripe integration involved.

user = str(uuid.uuid4())

# A signup webhook fires once, at account creation: grant a 500-credit gift
# that expires in 30 days. This is an ordinary add_credits() call -- the same
# API as Notebook 05 -- just aimed at the "gifted" tier so it drains before
# any purchased or subscription credits (Notebook 13). tx_type="purchase" is
# not a typo: sweep_expired_credits() (lazy or periodic) only ever considers
# "purchase"/"adjustment" transactions eligible for expiry, exactly as
# Notebook 05 already does for its own expiring grant.
bonus = manager.add_credits(
user, 500, tx_type="purchase", tier="gifted",
expires_at=datetime.now(UTC) + timedelta(days=30),
)
print(f" Granted {bonus.amount} into tier={bonus.tier!r}; balance={bonus.new_balance}")
assert bonus.tier == "gifted"

lazy_expiry=True: no cron required

Historically, an expiring grant like this only left the balance via an explicit, separately-scheduled sweep_expired_credits() call (Notebook 05) — allowances and lease TTLs were always lazy-on-read, but expiry was the odd one out. The manager above was constructed with lazy_expiry=True, which closes that gap: expiry is now checked lazily on the same balance-affecting calls (get_balance, deduct, add_credits, …) that everything else already uses, for whichever users are actively making them.

To see this without waiting 30 days, grant a second gifted credit that expires in under a second, sleep past it, and read the balance — with no sweep_expired_credits() call anywhere in this cell.

# Stand in for "30 days from now" by granting a second gifted credit that
# expires in under a second.
soon = datetime.now(UTC) + timedelta(milliseconds=500)
manager.add_credits(user, 15, tx_type="purchase", tier="gifted", expires_at=soon)
time.sleep(0.8)

# No sweep_expired_credits() call anywhere in this cell. lazy_expiry=True on
# the manager means the expired 15-credit grant is reaped automatically the
# next time a balance-affecting call touches this user -- here, a plain
# get_balance().
balance = manager.get_balance(user)
print(f" Balance after lazy expiry (no sweep called): {balance.balance}")
assert balance.balance == Decimal(500) # only the short-lived 15 gifted credits expired
print(" ✓ lazy_expiry reaped the expired grant on read -- no cron job involved")

A subscription-renewal webhook (illustrative, provider-agnostic)

A subscription renewal should (a) clear out whatever was left of last cycle's grant — unused AI credits typically do not roll over — and (b) grant the new cycle's amount, as a single operation your webhook handler can call without thinking about the two steps separately. grant_subscription_cycle() does both: replace_prior=True (the default) expires the leftover tier balance before granting the new amount, and emits a credits.cycle_renewed event on success.

The payload below is a plain dict shaped like a Stripe invoice.payment_succeeded event purely to make the example concrete — bursar ships no Stripe SDK integration and does not parse provider payloads for you. Extracting the event id, mapping the customer to your user_id, and deciding how many credits a renewal is worth are all your application's code, not bursar's.

# --- Illustrative, provider-agnostic webhook payload -----------------------
# Shaped like a Stripe `invoice.payment_succeeded` event purely to make this
# concrete. bursar has no Stripe SDK dependency and never parses provider
# payloads for you -- this is NOT a real Stripe SDK object.
renewal_payload = {
"id": "evt_1PqRstUvWxYz0001", # the provider's webhook event id
"type": "invoice.payment_succeeded",
"data": {
"object": {
"customer": user, # maps to your bursar user_id
"subscription": "sub_1PqAbC",
"billing_reason": "subscription_cycle",
},
},
}

# Your own price -> credits mapping. bursar never converts currency to
# credits for you (see Architecture: Scope & boundaries) -- you decide what
# a renewal is worth.
PRO_MONTHLY_CREDITS = Decimal(5_000)


def handle_subscription_renewed(payload):
"""Illustrative webhook handler -- call this from your own webhook route."""
return manager.grant_subscription_cycle(
payload["data"]["object"]["customer"],
PRO_MONTHLY_CREDITS,
plan_key="pro",
idempotency_key=payload["id"], # provider's event id -> safe redelivery
)


first = handle_subscription_renewed(renewal_payload)
print(f" Granted {first.amount} into tier={first.tier!r}; balance={first.new_balance}")
assert first.tier == "subscription"

Webhook redelivery is a safe no-op

Webhooks are typically at-least-once: a slow response, a transient 5xx, or the provider's own retry policy can (and will) redeliver the exact same event. Without an idempotency key, calling the handler twice would grant the cycle's credits twice. Because idempotency_key=payload["id"] was threaded through to grant_subscription_cycle(), the second delivery below leaves the balance exactly where the first delivery left it.

# Stripe (and most providers) redeliver webhooks on ambiguous responses or
# timeouts. The exact same payload -- same event id -- arrives a second time.
second = handle_subscription_renewed(renewal_payload)
print(f" Second delivery: balance={second.new_balance}")

assert second.new_balance == first.new_balance # no double grant
print(" ✓ redelivered webhook was a safe no-op -- idempotency_key did its job")

Yearly plan, monthly reset — reuse the allowance engine

A yearly subscription still wants to feel like "50,000 credits a month," not one enormous up-front grant. The tempting-but-wrong approach is to build a small scheduler that calls add_credits() on the first of every month. Do not build that scheduler — bursar already has one, and it is lazy: configure the plan's free_allowance and allowance_period ("calendar_month", or "anniversary" to align to the subscription's actual monthly anniversary instead of the 1st — see Notebook 04 / Pricing Configuration), call set_user_plan() once when the yearly webhook fires, and resolve_allowance_window computes the correct window on every check_allowance() call from then on — with zero additional code and zero cron.

To prove the reset happens automatically — without calling anything new in bursar — this demo uses MemoryStore's injectable clock (the same mechanism bursar's own test suite uses to fast-forward billing periods) to simulate January becoming February.

# MemoryStore's injectable clock -- the same mechanism bursar's own test
# suite uses (see python/tests/test_store.py) to fast-forward billing
# periods without a wall-clock sleep.
clock_box = {"now": datetime(2026, 1, 15, tzinfo=UTC)}
annual_store = MemoryStore(clock=lambda: clock_box["now"])
annual_store.setup()

annual_store.set_active_pricing(
PricingConfigData(
models={"_default": "input_tokens * 1"},
plans={
"pro-annual": PlanDefinition(
id="pro-annual", name="Pro (Annual)",
free_allowance=50_000,
allowance_period="calendar_month",
),
},
),
label="annual-plan-demo",
)
annual_manager = CreditManager(store=annual_store)
annual_user = str(uuid.uuid4())

# The yearly webhook fires ONCE, when the subscription is purchased or
# renewed. There is no monthly grant call anywhere in this notebook cell.
annual_store.set_user_plan(annual_user, "pro-annual")

january = annual_manager.check_allowance(annual_user)
print(f" January: {january.period_start} -> {january.period_end}, remaining={january.allowance_remaining}")
assert january.allowance_remaining == 50_000

# Some usage happens in January...
annual_store.increment_usage_window(annual_user, "pro-annual", Decimal(20_000))

# ...and then time passes. Advancing the fake clock is the ONLY thing that
# changes below -- no set_user_plan(), no add_credits(), no sweep.
clock_box["now"] = datetime(2026, 2, 3, tzinfo=UTC)

february = annual_manager.check_allowance(annual_user)
print(f" February: {february.period_start} -> {february.period_end}, remaining={february.allowance_remaining}")
assert february.allowance_remaining == 50_000 # reset automatically; January's usage did not carry over
print(" ✓ resolve_allowance_window reset the window with zero additional bursar calls")

Recap

  • Signup bonus that expiresadd_credits(tier=..., expires_at=...) (Notebook 05, aimed at an expiring tier).
  • Subscription renewalgrant_subscription_cycle(user, amount, idempotency_key=webhook_event_id, plan_key=...)replace_prior=True clears last cycle's leftover automatically, and the idempotency key makes at-least-once webhook delivery safe.
  • Yearly plan, monthly reset → configure free_allowance + allowance_period and call set_user_plan() once; never build a scheduler for this.
  • lazy_expiry=True guarantees correctness for active users on every balance-affecting call, with no cron — but a periodic sweep_expired_credits() is still worth keeping for dormant accounts and for the credits.expired event, which lazy expiry only fires for users who are actually transacting.
  • bursar stays payment-provider-agnostic throughout: nothing above talks to Stripe. Bridging credits.cycle_renewed / credits.expired to your own durable webhook or queue system is your responsibility — CreditEventEmitter is in-process and synchronous, not a durable bus. See Subscription Integration for the full decision guide.