Skip to main content

06 – Teams

Individual user balances work well for B2C products where each user pays for themselves. But B2B SaaS needs team accounts — one company with multiple users sharing a single credit pool. ducto's team feature lets you create shared balances, add members, enforce per-user spend caps, and track who spent what. Think of it like a shared bank account with individual debit card limits.

In a typical B2B scenario, a company purchases a block of credits and then distributes access to its employees or departments. Rather than managing individual balances for each employee, you create a single team pool. Every team member draws from that shared pool, and you can optionally cap how much each individual can spend. This mirrors the real-world pattern of a corporate card with per-employee spending limits.

Beyond simple sharing, teams also give you auditability: each deduction records which team member made the request, so you can bill back costs to specific departments or users. Combined with per-member caps, you prevent any single user from accidentally (or intentionally) exhausting the entire team's budget.

What we will do in this section: create a team with an initial balance, add three members, make deductions from the shared pool, observe what happens when the pool is empty, and enforce a per-member spend cap to demonstrate cost governance within a team.

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.")

Create team with initial balance

When you create a team, ducto establishes a separate credit balance that belongs to the team entity, not to any individual user. This is fundamentally different from the user-level add_credits calls we have seen in earlier notebooks: the team balance lives in its own ledger and is only accessible through team-specific API methods like deduct_team and get_team_balance.

Think of it as opening a joint bank account. The initial deposit of 100 000 credits is the team's working capital. Individual user balances still exist independently (they may have their own personal credits too), but team operations draw exclusively from the team pool. The two ledgers — personal and team — are separate and do not intermix.

What we will do in this section: call store.create_team with a name and initial balance, then inspect the returned team object to see its assigned identifier.

# Create a team entity with its own independent credit balance.
# The team "Engineering" gets 100 000 credits deposited into its
# team pool. This balance is separate from any individual user
# balance and can only be accessed through team-specific methods.
team = store.create_team(name="Engineering", initial_balance=100_000)
print(f" Team created: name='{team.name}', id={team.team_id}, initial_balance=100000")

Add members

Before a user can join a team, they must already exist in the user_credits table. This is an intentional design choice: ducto requires every team member to have a user record, even if that record has a zero balance. The team does not create user accounts — it only associates existing users with a shared pool. This ensures that all credit operations, including team deductions, are always attributed to a real user identity.

In practice, you will typically create user records during your application's signup flow and add them to teams later via an admin dashboard or an org-management workflow. The add_team_member call assigns a role ("member" by default) and optionally a per-user spend cap, which we will explore in the final section.

What we will do in this section: create three user records with zero balances, add each one as a team member, and verify the new member count on the team balance.

# Generate three unique user IDs that will serve as team members.
members = [str(uuid.uuid4()) for _ in range(3)]

# Each user must have a record in the user_credits table before
# they can join a team. Here we add them with a zero-balance
# adjustment: the user record exists but holds no personal credits.
for uid in members:
store.add_credits(uid, 0, type="adjustment")
# Now that the user exists, add them to the Engineering team.
store.add_team_member(team.team_id, uid, role="member")
print(f" Member added: {uid[:8]}… to team {team.team_id[:8]}…")

# Inspect the team balance to confirm the pool is intact and all
# three members are registered.
bal = store.get_team_balance(team.team_id)
print(f" Team balance: {bal.balance} credits across {bal.member_count} members")

Deduct from team pool

When a team member performs an action that costs credits, the deduction comes from the team balance, not from the member's personal balance. This is the core value of the team feature: a shared pool that all members draw from. The deduct_team method takes three arguments — the team ID, the member's user ID, and the amount — and records the transaction against both the team and the individual user.

The return value includes the team's remaining balance after the deduction, giving you immediate visibility into pool consumption. You can think of this as a corporate card transaction: the company pays, but the receipt shows which employee made the purchase. This attribution is critical for internal cost accounting and for detecting unusual spending patterns.

What we will do in this section: deduct 5 000 credits from the team pool as the first member, verify the team balance decreased to 95 000, and confirm that the member attribution was recorded.

# Deduct 5 000 credits from the team pool on behalf of members[0].
# The deduction is charged against the team balance, not against
# the individual user's personal balance (which is 0).
res = store.deduct_team(team.team_id, members[0], 5_000)
print(f" Deducted 5 000 for {members[0][:8]}…: team_balance_after={res.team_balance_after}, error={res.error}")

# Verify the team balance decreased from 100 000 to 95 000.
bal2 = store.get_team_balance(team.team_id)
assert bal2.balance == 95_000

Exceed team balance (rejected)

What happens when a team tries to spend more credits than the pool contains? Just like overdraft protection on a bank account, ducto rejects the transaction. The deduct_team call returns an error with the code "insufficient_team_balance" rather than allowing the balance to go negative. This is a safety mechanism: it prevents the team from accruing debt and ensures that credits are consumed only when they are available.

This behavior is by design. In a production SaaS application, an overdrawn team pool could mean a user receives service they cannot pay for, creating a billing gap. By rejecting insufficient-balance transactions upfront, ducto lets you surface the error to the team admin, who can then top up the pool before the user experiences a service disruption.

What we will do in this section: attempt to deduct 999 999 credits from the team pool (far exceeding the remaining 95 000), observe the error response, and verify the assertion that enforces the rejection.

# Attempt to deduct 999 999 credits, far more than the 95 000
# remaining in the team pool. The store should reject this.
res2 = store.deduct_team(team.team_id, members[1], 999_999)
print(f" Attempt to deduct 999 999: error='{res2.error}', team_balance_after={res2.team_balance_after}")
print(f" (team balance is 95 000 — insufficient to cover the request)")
assert res2.error == "insufficient_team_balance"

Per-member spend cap

A shared pool solves the basic sharing problem, but it introduces a new one: any single member could drain the entire team's credits. A rogue script, an aggressive user, or a bug in your application could consume the whole pool in minutes. Per-member spend caps prevent this by limiting how much each individual can draw from the team pool within a period.

The cap is set when you add the member via add_team_member(..., spend_cap=3_000). Once the member's cumulative team spending reaches that limit, subsequent deduct_team calls return "spend_cap_exceeded". The team balance may still have plenty of credits — the cap only restricts that specific user. You can raise, lower, or remove the cap dynamically without affecting other members.

What we will do in this section: create a new user with a 3 000 credit spend cap, deduct 3 000 (within the cap, succeeds), then attempt to deduct 1 more credit (exceeds the cap, fails), and verify that the error is correctly returned.

# Create a new user and add them to the team with a per-member
# spend cap of 3 000 credits. This limits how much this specific
# user can draw from the shared pool, regardless of how many
# credits remain in the team balance.
capped_user = str(uuid.uuid4())
store.add_credits(capped_user, 0, type="adjustment")
store.add_team_member(team.team_id, capped_user, role="member", spend_cap=3_000)
print(f" Member added: {capped_user[:8]}… with spend_cap=3 000")

# Deduct exactly 3 000 — this is within the cap, so it succeeds.
# The team pool covers the cost, and the user's personal cap
# tracker records this spend.
res3 = store.deduct_team(team.team_id, capped_user, 3_000)
print(f" Deducted 3 000 (within cap): team_balance_after={res3.team_balance_after}")

# Attempt to deduct 1 more credit. Even though the team pool has
# plenty remaining, this user has exhausted their personal cap
# of 3 000. The transaction is rejected.
res4 = store.deduct_team(team.team_id, capped_user, 1)
print(f" Attempt to deduct 1 (exceeds cap): error='{res4.error}'")
assert res4.error == "spend_cap_exceeded"
print(" Verification passed: per-member spend cap correctly enforced")
cleanup(pgdata)