Skip to main content

02 - Credit Lifecycle

Credits flow through a lifecycle: they are added, reserved, deducted, and sometimes refunded. Understanding this lifecycle is critical to building reliable credit systems that prevent race conditions and double-spending.

ducto uses a reserve-then-deduct pattern - think of it like a hotel booking. First you reserve a room (holding credits), then you check in (deducting them). If you cancel, the hold releases. This pattern prevents race conditions where two concurrent operations could both check the balance, see 10,000 credits, and each try to deduct 8,000 - resulting in a negative balance of -6,000.

Without the reserve step, naive balance-check-then-deduct code has a built-in race condition: between checking the balance and deducting, another operation can also check the balance. Both operations see the same available amount and both proceed, causing an overdraft. The reserve step establishes an ordering: the first reservation succeeds and reduces available credits; the second sees the reduced amount and blocks.

The full lifecycle involves four operations managed by PostgresStore: add_credits() deposits credits into a user's account, reserve_credits() holds a portion pending an expensive operation, deduct_credits() finalizes the hold into a permanent deduction, and refund_credits() reverses a completed deduction and restores the balance.

Each operation returns a result object that provides unique transaction identifiers and updated balance information. These identifiers form an audit trail: every deduction references a reservation, every refund references a deduction. This makes it possible to trace the full history of every credit.

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
print("✔ PostgresStore ready.")

Add credits

The first operation in any credit lifecycle is adding credits to a user's account. PostgresStore.add_credits() creates a new credit entry with a unique transaction ID and returns the user's updated balance.

Each deposit has a type label - such as "signup_bonus", "purchase", or "adjustment" - which serves as an audit category. This makes it possible to later query how many credits came from signups versus purchases versus manual adjustments.

# Generate a unique user ID for this demonstration.
# In production, this would be the user's UUID from your auth system.
user = str(uuid.uuid4())

# Deposit 10,000 credits as a signup bonus into the user's account.
# The add_credits() method returns an AddCreditsResult object containing:
# - transaction_id: a unique identifier for this deposit, used for audit trails and future refunds
# - new_balance: the user's total credit balance after the deposit completes
r = store.add_credits(user, 10_000, type="signup_bonus")
print(f" Tx: {r.transaction_id}") # Unique reference for this deposit
print(f" Balance: {r.new_balance}") # User now has 10,000 credits available to spend

Reserve then deduct (two-phase commit)

Once a user has credits, the next step is typically spending them. Rather than deducting directly, ducto uses a two-phase commit: first reserve the amount, then deduct.

The reserve phase places a hold on the credits. The user's available balance decreases immediately, but the credits are not yet consumed. The reservation returns a reservation_id that the subsequent deduction references. This two-step process prevents race conditions: if two requests try to reserve different amounts simultaneously, the second reservation sees the reduced balance from the first.

The deduction phase consumes the held credits. It references the original reservation by its reservation_id, which ensures that only the holder of the reservation can finalize the spend. This prevents one request from spending credits that another request reserved.

# Step 1: Reserve 2,000 credits for a pending model inference operation.
# reserve_credits() holds the specified amount and reduces the available balance immediately.
# No other operation can spend these reserved credits.
# Returns a ReserveResult object with:
# - reservation_id: a unique key needed later to deduct or release the hold
# - balance: the remaining available balance after placing the hold
res = store.reserve_credits(user, 2_000, operation_type="model_inference")
print(f" Reservation: {res.reservation_id}") # Save this ID for the deduction step
print(f" Balance: {res.balance}") # 10,000 - 2,000 = 8,000 credits remain available

# Step 2: Deduct the reserved credits to finalize the transaction.
# deduct_credits() consumes the reservation identified by its reservation_id.
# Returns a DeductionResult object with:
# - transaction_id: a unique reference for this completed deduction, used for audits and refunds
# - balance_after: the user's total balance after the deduction completes
ded = store.deduct_credits(user, res.reservation_id, 2_000)
print(f" Deduction: {ded.transaction_id}") # Unique reference for this spend
print(f" Balance aft: {ded.balance_after}") # Still 8,000 — the reservation already reduced it

# Step 3: Verify the final balance by querying independently.
bal = store.get_balance(user)
print(f" Final: {bal.balance}") # 8,000 credits remaining
assert bal.balance == 8_000 # Confirms the two-phase cycle correctly consumed 2,000 credits

Refund a deduction

Sometimes a completed deduction needs to be reversed. For example, if a credit purchase fails after the initial authorization, or if a customer requests a refund for a faulty model response.

refund_credits() restores the deducted amount to the user's balance. Critically, it requires the original deduction's transaction_id as a reference. This ensures a proper audit trail: the refund is linked to the original spend, and the same transaction cannot be refunded twice. The original deduction's transaction record remains in the database unchanged - it is not deleted or modified - preserving a complete history of the spend-and-refund cycle for auditing purposes.

# Refund the deduction we just completed, referencing its original transaction_id.
# Passing the deduction's transaction_id allows the system to:
# 1. Validate that the referenced transaction exists and has not already been refunded
# 2. Create an audit trail linking the refund to the original spend
# 3. Prevent double-reversal (deduplication) — the same transaction cannot be refunded twice
ref = store.refund_credits(ded.transaction_id, amount=2_000, reason="test")
print(f" Refund tx: {ref.refund_transaction_id}") # New unique ID for this refund operation
print(f" New balance: {ref.new_balance}") # Balance restored to 10,000 — the original deposit amount
assert ref.new_balance == 10_000 # Balance fully restored after refunding the full 2,000 credits
cleanup(pgdata)