Skip to main content

10 -- Credit Expiry

Free trial credits should expire after 14 days. Purchased credits might expire in 12 months. Promotional bonuses may expire in 60 days. ducto's credit expiry feature handles all of these scenarios with a single sweep_expired_credits() function. The pattern is simple: when you add credits to a user's balance, you can set an optional expires_at timestamp. If you set it, a background sweep job finds all expired grants and deducts them from the user's available balance.

The sweep is a safe, transactional operation. It only removes grants whose expires_at is in the past. Permanent credits (those added without an expires_at) are never touched. This means you can mix expiring and permanent credits in the same user balance: free trial credits expire, purchased credits persist. The sweep also supports a dry-run mode that lets you preview what would be removed before making any changes. This is essential for production deployments where you want to verify the sweep logic before executing it.

Think of the sweep like a refrigerator cleanout: you check the expiration dates on all items, identify anything past its prime (dry run), and then throw away only the expired ones (real sweep). You would never throw away food without checking the labels first, and you should never run a sweep in production without a dry-run preview. The dry_run=True flag is your safety net.

ducto does not ship a built-in scheduler for the sweep. In production, you would run sweep_expired_credits() on a cron schedule (for example, once per hour via a Celery beat task or a cron job). The sweep is idempotent: running it multiple times only removes newly expired grants each time. Already-expired grants are only removed once.

The expiry feature integrates with the rest of the ducto credit system. When expired credits are swept, the balance is updated atomically. If you use events, the sweep emits standard lifecycle events so your monitoring and alerting pipelines can track credit expiry as a normal credit operation. This makes expiry auditable and visible in your existing dashboards.

What we will do in this section: create a mix of expiring and permanent credits for a test user, preview the sweep with dry_run=True, execute the real sweep, and confirm that only expired credits were removed.

from datetime import datetime, timedelta
from ducto.interface.postgres import PostgresStore
from ducto.manager import CreditManager
from ducto.engine import PricingEngine
from ducto.metrics import UsageMetrics, ToolCall
from ducto.interface.models import (
PricingConfigData, PlanDefinition,
CreditMetadata,
)
from shared import start_postgres_store, cleanup

store, pgdata = start_postgres_store()
import uuid
from datetime import timedelta
print("✔ PostgresStore ready.")

Add credits that expire

To demonstrate the expiry feature, we need two types of credits: one that is eligible for expiry (has an expires_at in the past) and one that is permanent (no expires_at). This mirrors a real-world scenario where a user has both promotional credits that expire after a trial period and purchased credits that never expire.

The add_credits method accepts an optional expires_at parameter. If you pass a datetime in the past, the credit grant is immediately sweepable. If you omit expires_at or pass None, the grant is permanent and will never be touched by the sweep. You can mix both types freely for the same user.

When you call get_balance, the balance includes ALL credits -- both expiring and permanent -- because the sweep has not run yet. The balance only decreases after sweep_expired_credits() removes the expired grants. This means you should always run the sweep before reporting balances to users in production.

What we will do in this section: add 5 000 credits that expired one hour ago (immediately sweepable) and 10 000 permanent credits with no expiry.

# Create a unique test user so we start with a clean balance.
user = str(uuid.uuid4())

# Add 5 000 credits that expired 1 hour ago.
# The expires_at timestamp is set to datetime.now() minus one hour,
# meaning these credits are immediately eligible for sweeping.
# This simulates a free trial grant whose time window has closed.
past = datetime.now() - timedelta(hours=1)
store.add_credits(user, 5_000, type="purchase", expires_at=past)

# Add 10 000 permanent credits with no expiry date.
# These credits have no expires_at timestamp, so the sweep
# will never remove them. They persist in the balance forever.
store.add_credits(user, 10_000, type="purchase")

# Before the sweep runs, the total balance includes both types.
# The expired grant still shows up because no sweep has occurred.
print(f" Total balance before sweep: {store.get_balance(user).balance} credits")

Dry-run sweep (preview)

The dry-run pattern is one of the most important safety features in the credit expiry system. Before you execute a sweep that permanently removes credits from user balances, you should always preview what it would do. The dry_run=True flag makes sweep_expired_credits() a read-only operation: it scans all grants, finds expired ones, and reports the results without modifying any balances.

This is especially important in production environments where a misconfigured sweep could accidentally remove credits that should not expire. For example, if you accidentally set expires_at on all grants including purchases, a real sweep would deduct everything. The dry run lets you catch this before any balances are affected.

The SweepResult object includes three fields: expired_count (the number of expired grants found), expired_amount (total credits that would be removed), and dry_run (a boolean indicating whether this was a preview). When dry_run=True, the user's balance remains unchanged because no writes occurred.

What we will do in this section: call sweep_expired_credits(dry_run=True), inspect the preview result, and confirm the balance is still the same.

# Dry-run mode: identify expired grants without modifying balances.
# The dry_run=True parameter makes this a read-only inspection.
# No credits are actually removed during the dry run.
preview = store.sweep_expired_credits(dry_run=True)
print(f" Would expire: {preview.expired_count} grant(s)")
print(f" Amount: {preview.expired_amount} credits")
print(f" Dry run: {preview.dry_run}")

# The balance is unchanged because the dry run is read-only.
# The expired credits are still included in the available balance
# until we execute the real sweep below.
print(f" Balance during dry run: {store.get_balance(user).balance} credits (unchanged)")

Execute the sweep

Now that we have previewed the sweep and confirmed that it correctly identifies the 5 000 expired credit grant, we can execute the real sweep. Setting dry_run=False (or omitting it, since False is the default) tells the store to permanently remove expired grants from the user's balance.

The real sweep performs the same scan as the dry run, but this time it commits the changes. Each expired grant is deducted atomically, and the user's balance is updated to reflect the removal. Only grants with expires_at in the past are affected. Permanent credits (those without expires_at) are never removed.

After the sweep, calling get_balance returns only the non-expired credits. The SweepResult for the real sweep is identical in structure to the dry run, but the dry_run field is False. You can compare the dry run and real sweep results to confirm that the correct amount was removed.

What we will do in this section: execute sweep_expired_credits(dry_run=False) and verify that expired credits are removed from the balance.

# Execute the real sweep with dry_run=False.
# This permanently deducts expired grants from the user balance.
# Only grants whose expires_at is in the past are removed.
result = store.sweep_expired_credits(dry_run=False)
print(f" Expired: {result.expired_count} grant(s), {result.expired_amount} credits removed")
print(f" Dry run: {result.dry_run}")

# The balance now reflects only the non-expired credits.
# The 5 000 credit expired grant has been deducted.
print(f" Balance after sweep: {store.get_balance(user).balance} credits")

Non-expiring credits preserved

The most important guarantee of the sweep system is that permanent credits are never affected. When we added 10 000 credits without an expires_at timestamp, those credits are permanent. The sweep only considers grants that have a non-null expires_at value that is in the past.

This design means you can freely mix grant types for the same user. Free trial credits, promotional bonuses, and purchased subscription credits can all coexist in a single balance. The sweep methodically processes only grants with past expires_at values, leaving permanent credits untouched.

The balance after the sweep should be exactly 10 000 credits -- the full amount of the permanent grant. This confirms that the expiry system correctly preserves non-expiring balances while removing only the expired grants.

What we will do in this section: call get_balance and confirm that only the 10 000 permanent credits remain.

# After the sweep, verify that permanent credits are preserved.
# The 5 000 expired grant was removed; the 10 000 permanent
# grant remains because it has no expires_at timestamp.
remaining = store.get_balance(user)
print(f" Remaining balance: {remaining.balance} credits")
cleanup(pgdata)