Skip to content
SaaS4Builders
Teams

Seat-Based Billing Integration

How team size connects to subscription billing: seat quotas, entitlement checks, no-subscription modes, automatic seat syncing, and frontend awareness.

The team system integrates directly with billing to enforce seat limits and automatically sync member counts with Stripe. When a tenant subscribes to a seat-based plan, the team_members feature entitlement defines how many users can join the team. Adding or removing members triggers an asynchronous pipeline that updates the Stripe subscription quantity.


How Seats Connect to Billing

Seat limits are determined by the tenant's active subscription plan. Plans define features with entitlements that control what tenants can do. The team_members feature code is the key that links billing to team management.

The chain works as follows:

  1. A Plan has Entitlements linking to Features
  2. One of those features has code = 'team_members'
  3. The entitlement's type determines whether seats are limited or unlimited
  4. The CheckSeatQuota action reads this entitlement to enforce limits

For details on how plans, features, and entitlements are structured, see Pricing Models.


Seat Quota Check

The CheckSeatQuota action is the central point for all seat-related decisions. It provides three methods:

backend/app/Application/Team/Actions/CheckSeatQuota.php
final class CheckSeatQuota
{
    /**
     * Check if the tenant can add more members.
     */
    public function canAddMembers(Tenant $tenant, int $additionalMembers = 1): bool
    {
        $limit = $this->getSeatLimit($tenant);

        if ($limit === null) {
            return true;
        }

        $usage = $this->getCurrentUsage($tenant);
        $totalAfterAdd = $usage['members'] + $usage['pending_invitations'] + $additionalMembers;

        return $totalAfterAdd <= $limit;
    }

    /**
     * Get the seat limit for a tenant.
     *
     * @return int|null The seat limit, or null for unlimited
     */
    public function getSeatLimit(Tenant $tenant): ?int
    {
        $subscription = $this->getSubscription->execute($tenant->id);

        if ($subscription === null) {
            return $this->getNoSubscriptionLimit();
        }

        $plan = $subscription->plan;
        $plan->load('entitlements.feature');

        foreach ($plan->entitlements as $entitlement) {
            if ($entitlement->feature->code === BillingReadinessChecker::SEAT_FEATURE_CODE) {
                return match ($entitlement->type) {
                    EntitlementType::Boolean => null,
                    EntitlementType::Quota => $entitlement->value,
                };
            }
        }

        return null;
    }

    /**
     * Get usage statistics for seat quota.
     *
     * @return array{members: int, pending_invitations: int, total: int, limit: int|null, available: int|null}
     */
    public function getUsageStats(Tenant $tenant, ?int $precomputedLimit = null): array
    {
        $usage = $this->getCurrentUsage($tenant);
        $limit = $precomputedLimit ?? $this->getSeatLimit($tenant);

        $total = $usage['members'] + $usage['pending_invitations'];
        $available = $limit !== null ? max(0, $limit - $total) : null;

        return [
            'members' => $usage['members'],
            'pending_invitations' => $usage['pending_invitations'],
            'total' => $total,
            'limit' => $limit,
            'available' => $available,
        ];
    }
}

The usage count includes both current members and pending invitations. This prevents over-inviting — if a plan allows 5 seats, you can have 3 members and 2 pending invitations, but not 3 members and 3 pending invitations.


Entitlement Types

How the seat limit is derived depends on the entitlement type:

Entitlement TypeSeat BehaviorExample
BooleanUnlimited seats — the feature is enabled with no capEnterprise plan: "team_members: true"
QuotaNumeric limit — the entitlement value is the max seat countPro plan: "team_members: 10"
No entitlementUnlimited — if the plan has no team_members entitlement, seats are uncappedLegacy plan without team feature

The BillingReadinessChecker::SEAT_FEATURE_CODE constant ('team_members') is the hardcoded feature code used to look up the seat entitlement. This code must match the feature's code field in your plan configuration.


No-Subscription Modes

When a tenant has no active subscription (e.g., during a free trial period or after cancellation), seat behavior is controlled by a configuration value:

backend/config/team.php
return [
    // ...
    'no_subscription_seat_mode' => env('TEAM_NO_SUBSCRIPTION_SEAT_MODE', 'owner_only'),
];
ModeLimitBehavior
owner_only1 seatDefault. Only the owner exists — no invitations possible until a subscription is active.
strict0 seatsNo seats at all. Even the most restrictive option.
unlimitedNo limitNo seat restrictions. Use this if you want a free tier with full team support.

The resolution logic:

backend/app/Application/Team/Actions/CheckSeatQuota.php
private function getNoSubscriptionLimit(): ?int
{
    $mode = config('team.no_subscription_seat_mode', 'owner_only');

    return match ($mode) {
        'strict' => 0,
        'owner_only' => 1,
        'unlimited' => null,
        default => 1,
    };
}
Set the TEAM_NO_SUBSCRIPTION_SEAT_MODE environment variable to unlimited if you want to offer a free tier where users can invite team members without subscribing.

Enforcement Point

Seat quotas are checked at invitation creation time, not at acceptance time. This design choice has important implications:

  • Pending invitations count toward the quotatotal = members + pending_invitations
  • If seats are reduced after invitations are sent, existing pending invitations can still be accepted
  • Existing members are never removed when a plan's seat count is reduced
  • New invitations are blocked when the quota is full

The InviteTeamMember action calls CheckSeatQuota::canAddMembers() before creating the invitation. If the check fails, a SEAT_LIMIT_REACHED error is returned.

Reducing your plan's seat count does not remove existing members. It only prevents new invitations once the limit is reached. To reduce the team size below the new limit, an admin must manually remove members.

Automatic Seat Syncing

When team membership changes, the system automatically updates the Stripe subscription quantity through an event-driven pipeline:

Pipeline

  1. TeamMemberAdded or TeamMemberRemoved event — dispatched synchronously when a member joins or is removed
  2. SyncSeatQuantityOnMemberChange listener — catches the event and dispatches a delayed job
  3. SyncSeatQuantityJob — runs asynchronously after a 30-second delay
  4. SyncSeatQuantity action — updates the Stripe subscription quantity and dispatches SeatQuantityChanged

Listener

The listener dispatches the job with a 30-second delay to allow batch operations (e.g., inviting multiple members) to settle before making a Stripe API call:

backend/app/Listeners/Billing/SyncSeatQuantityOnMemberChange.php
final class SyncSeatQuantityOnMemberChange
{
    private const int DELAY_SECONDS = 30;

    public function handleMemberAdded(TeamMemberAdded $event): void
    {
        SyncSeatQuantityJob::dispatch($event->tenant)
            ->delay(now()->addSeconds(self::DELAY_SECONDS));
    }

    public function handleMemberRemoved(TeamMemberRemoved $event): void
    {
        SyncSeatQuantityJob::dispatch($event->tenant)
            ->delay(now()->addSeconds(self::DELAY_SECONDS));
    }

    public function subscribe(Dispatcher $events): array
    {
        return [
            TeamMemberAdded::class => 'handleMemberAdded',
            TeamMemberRemoved::class => 'handleMemberRemoved',
        ];
    }
}

Job Deduplication

The SyncSeatQuantityJob uses Laravel's ShouldBeUnique to prevent duplicate Stripe API calls for the same tenant:

backend/app/Jobs/Billing/SyncSeatQuantityJob.php
final class SyncSeatQuantityJob implements ShouldBeUnique, ShouldQueue
{
    public int $tries = 3;

    public function __construct(
        public readonly Tenant $tenant,
    ) {}

    public function uniqueId(): string
    {
        return $this->tenant->id;
    }

    public function uniqueFor(): int
    {
        return 60; // 60-second uniqueness window
    }

    public function backoff(): array
    {
        return [10, 30, 60]; // Progressive retry backoff
    }

    public function handle(SyncSeatQuantity $action): void
    {
        $action->execute($this->tenant);
    }
}
The ShouldBeUnique interface with a 60-second uniqueness window means that if multiple members are added in rapid succession, only one Stripe API call is made. The job deduplicates by tenant ID, so concurrent dispatches for the same tenant collapse into a single execution.

Sync Action

The SyncSeatQuantity action performs the actual Stripe update:

  1. Loads the tenant's active subscription
  2. Checks that the plan uses seat-based pricing (PricingType::Seat)
  3. Counts current members (minimum 1 — the owner always counts)
  4. Skips the API call if the count hasn't changed
  5. Updates the Stripe subscription quantity via the payment gateway
  6. Updates the local subscription record
  7. Dispatches SeatQuantityChanged event with old and new quantities

The proration behavior is configurable per plan via the seat_proration_behavior field (defaults to create_prorations).


TeamStats API Response

The GET /api/v1/tenant/{tenantId}/team/stats endpoint returns seat quota information alongside member counts:

{
  "data": {
    "members": 3,
    "pending_invitations": 2,
    "total": 5,
    "limit": 10,
    "available": 5
  }
}
FieldTypeDescription
membersintCurrent number of team members
pending_invitationsintValid pending invitations
totalintmembers + pending_invitations
limitint or nullSeat limit from the plan (null = unlimited)
availableint or nullRemaining seats: limit - total (null = unlimited)

This response is produced by the TeamStatsResource, which delegates to CheckSeatQuota::getUsageStats().


Frontend Awareness

The frontend uses the stats response to provide real-time feedback about seat availability.

Store Logic

The Pinia team store computes canAddMembers from the stats:

frontend/features/core/team/stores/useTeamStore.ts
const canAddMembers = computed(() => {
  if (!stats.value) return false
  const limit = stats.value.limit
  if (limit === null) return true
  if (limit === 0) return false
  return (stats.value.available ?? 0) > 0
})

This computed property drives the "Invite Member" button state — it's disabled when no seats are available.

TeamStats Component

The TeamStats component (frontend/features/core/team/components/TeamStats.vue) displays a three-card overview:

  • Members count — current team size
  • Pending invitations — valid pending invitations
  • Seats{used} of {limit} or "Unlimited" when limit is null (displayed as ∞)

Billing Notice in Invite Modal

When the tenant is on a seat-based plan, the TeamInviteModal component shows an informational alert before the invitation form:

frontend/features/core/team/components/TeamInviteModal.vue
<UAlert
  v-if="isSeatBasedPlan"
  color="info"
  variant="subtle"
  icon="i-lucide-info"
  :title="t('billing.seats.addMemberBillingNotice')"
  class="mb-4"
/>

This informs the user that adding a member may affect their billing — particularly important for seat-based plans where each member incurs a per-seat charge.

Seat Billing Composable

The useSeatBilling() composable (in frontend/features/core/docs/billing/composables/useSeatBilling.ts) provides detailed billing-side seat information:

  • isSeatBased — whether the current plan uses seat pricing
  • currentSeats / seatLimit / availableSeats — numeric seat values
  • isAtLimit / isNearLimit — computed flags for UI warnings
  • formattedPricePerSeat / formattedTotalCost — formatted money values for display

This composable bridges the billing and team domains, giving components access to pricing information without crossing feature boundaries.


Cross-References