OCTO Ops Help User guides and product documentation

2026 09 11 Billing 3B Seats And Modules

On this page 6

Billing 3b — seats and modules

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stop a team exceeding its contracted seat cap, and close the door behind a module its plan does not include.

Architecture: Two small, independent pieces, both of which call rules that already exist. Entitlement::hasSeatCapacity() decides seats; Entitlement::allowsModule() decides modules. 3b writes no new rule — it puts existing ones at the two invite paths and the two module route groups.

Tech Stack: Laravel 13, PHP 8.4, Inertia v3 + Vue 3, MySQL (SQLite in tests).

Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md"Increment 3 in detail — enforcement" → "Seat cap" and "Module entitlement", plus "Entitlement enforcement points" items 2 and 3. The spec is the binding authority.

A correction to the spec, made here

The spec's "Seat cap" section calls the rule Entitlement::canAddSeat(). There is no such method. The real one is:

php
public function hasSeatCapacity(int $currentSeats): bool
{
    return $this->seatCap === null || $currentSeats < $this->seatCap;
}

app/Support/Billing/Entitlement.php:47. Use the real name everywhere; I am correcting the spec text separately.

Global Constraints

  • PHP binary: plain php is MAMP's PHP 8.2 and too old. Artisan runs as herd php artisan ....
  • Tests: herd php -d memory_limit=2G artisan test --compact <path>. For the whole suite you must use herd php -d memory_limit=2G vendor/bin/phpunitartisan test spawns a child through PhpExecutableFinder which drops -d, and the run dies at 256 MB in TaskAttachmentTest. Never background or kill a whole-suite run: it corrupts this repo's shared MongoDB test state and produces hundreds of false failures indistinguishable from real ones.
  • No billing rule gets a second implementation. hasSeatCapacity() and allowsModule() are the only implementations of their rules. A seat comparison or a module in_array written at a call site is a defect.
  • Enforcement fails closed. A team that cannot be resolved is denied, not allowed. 3a shipped a bug in exactly this shape.
  • All billing tables are MySQL (default connection; sqlite in tests). MongoDB is used elsewhere and must never appear in billing code; whereHas cannot cross that boundary.
  • No Action performs authorization.
  • No new model factories. TeamFactory and UserFactory exist and are required — Team's #[Fillable] is ['name','slug','is_personal','modulesAllowed'], so Team::create() silently drops every billing column, and User's omits superAdmin.
  • Formatting: vendor/bin/pint --dirty --format agent; npm run format and npm run lint if any .vue/.ts changes, reverting repo-wide collateral.
  • Run nothing against any database but the test database.
  • Tests that pass for the wrong reason are this project's recurring failure. 3a produced four. For every test you write, ask what it does if the production change is reverted, and make sure the answer is "fail".
  • Pre-existing failures you did NOT cause and must NOT fix: 24 failures + 3 errors, nine ViteExceptions, with known intermittents in ReturnsRegisterTest, UtilizationTest, AtpTest, Sales\OrderConfirmAuditTest and a ~0.5% Faker flake in SuperAdmin\TeamCrudTest::test_index_lists_teams.

File Structure

Task 1 — the seat cap:

  • app/Support/Billing/Seats.php — where "how many seats does this team occupy" is answered, once
  • app/Actions/Users/InviteTeamUsers.php (modify) — refuse an invitation that would exceed the cap
  • app/Http/Controllers/Teams/TeamInvitationController.php (modify) — refuse an acceptance that would
  • tests/Feature/Billing/SeatCapTest.php

Task 2 — module route guards:

  • app/Http/Middleware/EnsureModuleEntitled.php
  • routes/web/logistics3p.php, routes/web/containers.php (modify — one line each)
  • tests/Feature/Billing/ModuleEntitlementTest.php

Task 1: The seat cap

Files:

  • Create: app/Support/Billing/Seats.php, tests/Feature/Billing/SeatCapTest.php
  • Modify: app/Actions/Users/InviteTeamUsers.php, app/Http/Controllers/Teams/TeamInvitationController.php

Interfaces:

  • Consumes: Entitlements::for(?Team): Entitlement, Entitlement::hasSeatCapacity(int $currentSeats): bool, Team::activeMembers(), Team::invitations().
  • Produces: Seats::occupiedBy(Team $team): int, Seats::committedBy(Team $team): int.

The design decision this task turns on. A cap checked only when an invitation is sent is advisory: a team at 25 of 25 with three outstanding invitations quietly becomes 28. A cap checked only on acceptance lets an admin send invitations that will fail for the recipient, which is a bad experience for the person least able to fix it. So both, counting different things:

  • At invite time the cap is measured against committed seats — active members plus pending invitations (not accepted, not expired). You cannot promise a seat you do not have.
  • At acceptance time it is measured against occupied seats — active members only, because the invitation being accepted is not yet one. This is the backstop for invitations that predate a cap being lowered.

Both counts live in one place so they cannot drift.

Reactivation counts. TeamInvitationController::accept() uses memberships()->updateOrCreate(..., ['status' => true]), so accepting can reactivate a dormant member as well as create one. Both consume a seat, and the check must sit before either.

  • Step 1: Write the failing test

Create tests/Feature/Billing/SeatCapTest.php. It must cover, at minimum:

php
public function test_a_team_under_its_cap_can_invite(): void
public function test_a_team_at_its_cap_cannot_invite(): void
public function test_pending_invitations_count_towards_the_cap(): void      // 25-cap, 24 members, 1 pending -> refused
public function test_an_expired_invitation_does_not_count(): void
public function test_an_accepted_invitation_does_not_count_twice(): void     // it is already an active member
public function test_a_team_with_no_cap_can_invite_freely(): void            // seatCap null -> unlimited
public function test_accepting_an_invitation_at_the_cap_is_refused(): void
public function test_accepting_is_refused_when_the_cap_was_lowered_after_the_invite(): void
public function test_reactivating_a_dormant_member_at_the_cap_is_refused(): void
public function test_a_deactivated_member_does_not_occupy_a_seat(): void

Seed teams with Team::factory()->create(['billing_seat_cap' => N])never Team::create(), which drops the column. Build memberships and invitations explicitly.

  • Step 2: Run it and watch it fail

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/SeatCapTest.php Expected: FAIL — Class "App\Support\Billing\Seats" not found.

  • Step 3: Write Seats

Create app/Support/Billing/Seats.php:

php
<?php

namespace App\Support\Billing;

use App\Models\Team;

/**
 * How many seats a team is using (design doc, "Increment 3 in detail" →
 * "Seat cap").
 *
 * Two counts, deliberately different, because the cap is checked at two
 * moments that mean different things:
 *
 *  - `occupiedBy()` — active members. What the team is using right now.
 *  - `committedBy()` — active members plus invitations that have neither been
 *    accepted nor expired. What the team has promised.
 *
 * An invitation is a promise of a seat, so sending one has to be measured
 * against the promise; accepting one has to be measured against reality,
 * because the invitation being accepted is not yet a member. Keeping both
 * here means the two can never drift into disagreeing about what a seat is.
 */
class Seats
{
    public static function occupiedBy(Team $team): int
    {
        return $team->activeMembers()->count();
    }

    public static function committedBy(Team $team): int
    {
        return self::occupiedBy($team) + $team->invitations()
            ->whereNull('accepted_at')
            ->where(fn ($query) => $query
                ->whereNull('expires_at')
                ->orWhere('expires_at', '>', now()))
            ->count();
    }
}

Check TeamInvitation's actual columns before finalising — if accepted_at or expires_at are named differently, match the model, not this snippet.

  • Step 4: Guard the invite

In App\Actions\Users\InviteTeamUsers, before the loop that creates invitations, refuse when the cap is already met. A resend to an existing invitation must not be refused — it promises no new seat.

Use abort_if(…, 422, …) with a message naming what to do: a seat cap is contractual, and a team admin cannot raise their own.

  • Step 5: Guard the acceptance

In TeamInvitationController::accept(), inside the existing DB::transaction, after locking the team row and before updateOrCreate:

php
$team = Team::query()->whereKey($invitation->team_id)->lockForUpdate()->firstOrFail();

abort_if(
    ! Entitlements::for($team)->hasSeatCapacity(Seats::occupiedBy($team)),
    422,
    'This organization has no seats available. Ask an administrator to contact us before accepting.',
);

Lock, then count, then write — otherwise two people accepting simultaneously both pass the check. This is the same pattern 2a established for invoices and 3a used for restriction.

Reactivation must be covered by this tooupdateOrCreate turning status back on is a seat.

  • Step 6: Run until green, then check the whole surface
bash
herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/SeatCapTest.php
herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing tests/Unit/Billing tests/Feature/SuperAdmin tests/Feature/Teams tests/Feature/Settings
herd php -d memory_limit=1G vendor/bin/phpstan analyse app/Support/Billing app/Actions/Users app/Http/Controllers/Teams --no-progress
vendor/bin/pint --dirty --format agent

The invite and acceptance paths have existing coverage — find it and confirm none of it was weakened.

  • Step 7: Commit
bash
git add app/Support/Billing/Seats.php app/Actions/Users/InviteTeamUsers.php \
        app/Http/Controllers/Teams/TeamInvitationController.php tests/Feature/Billing/SeatCapTest.php
git commit -m "Billing: hold a team to the seats it contracted for"

Task 2: Module route guards

Files:

  • Create: app/Http/Middleware/EnsureModuleEntitled.php, tests/Feature/Billing/ModuleEntitlementTest.php
  • Modify: routes/web/logistics3p.php, routes/web/containers.php

Interfaces:

  • Consumes: Entitlements::for(?Team): Entitlement, Entitlement::allowsModule(string $moduleKey): bool.
  • Produces: middleware alias usable as EnsureModuleEntitled::class.':logistics3p'.

What this is and is not. Modules::allowedForTeam() already intersects a team's configuration with its billing entitlement, so the sidebar has hidden unentitled modules since increment 1. This task closes the door behind the hidden menu item, so a bookmarked URL is refused the way the menu refuses it. It is defence in depth, not a new rule — it asks allowsModule() the same question the menu asks, and must not grow its own notion of entitlement.

  • Step 1: Write the failing test

Create tests/Feature/Billing/ModuleEntitlementTest.php:

php
public function test_an_entitled_team_reaches_the_module(): void
public function test_an_unentitled_team_is_refused(): void
public function test_a_team_with_no_module_restriction_reaches_it(): void   // allowedModuleKeys null -> all
public function test_an_api_request_is_refused_as_json_not_a_redirect(): void
public function test_a_user_with_no_current_team_is_refused(): void          // fails CLOSED

Set entitlement through the team's real columns, seeded with Team::factory().

  • Step 2: Run it and watch it fail — the routes are currently unguarded, so the unentitled case passes through.

  • Step 3: Write the middleware

Create app/Http/Middleware/EnsureModuleEntitled.php. Model it on EnsureTeamNotRestricted (3a) for team resolution and the JSON/redirect split — including resolving a {team} route parameter, not only currentTeam, which was a Major finding against that middleware.

It must fail closed: no user, no resolvable team, or an unrecognised module key means refuse.

php
public function handle(Request $request, Closure $next, string $moduleKey): Response
  • Step 4: Guard the two route groups

Both files already read:

php
Route::middleware(['auth', 'verified', EnsureTeamMembership::class])

Append the guard with its key — EnsureModuleEntitled::class.':logistics3p' and …':containers' respectively. Confirm the module keys against database/seeders/BillingProductsSeeder.php, which seeds module.logistics3p / module.containers with module_key values logistics3p and containers.

Verify live: herd php artisan route:list --path=logistics3p and --path=containers.

  • Step 5: Run, check and commit
bash
herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing tests/Unit/Billing tests/Feature/SuperAdmin
herd php -d memory_limit=2G vendor/bin/phpunit   # foreground, once, against baseline
herd php -d memory_limit=1G vendor/bin/phpstan analyse app/Http/Middleware app/Support/Billing --no-progress
vendor/bin/pint --dirty --format agent
git add <paths> && git commit -m "Billing: close the door behind a module a plan does not include"

The whole-suite run matters here: these two route files carry real module tests, and a guard that refuses a team those tests use will show up there rather than in the scoped run.


Self-review notes

  • Spec coverage. "Seat cap" → Task 1 (both call sites, as the spec requires). "Module entitlement" → Task 2. Storage is 3c and deliberately absent.
  • One spec correction made here, argued above: the spec names canAddSeat(); the method is hasSeatCapacity(int $currentSeats).
  • One design decision that is mine, not the spec's: measuring the invite-time cap against committed seats (members + pending invitations) rather than occupied ones. The spec says only that both call sites are checked. Without counting pending invitations the send-time check is nearly useless, since a team can simply send more invitations than it has seats.
  • The riskiest step is Task 2 Step 4. Adding a guard to two live module route groups can refuse teams that existing tests rely on. Expect the whole-suite run to surface that, and fix the tests' team setup rather than weakening the guard.
  • A known gap, deliberate: nothing in 3b tells a team why a module is missing — the menu simply does not show it, and a bookmarked URL is refused. A "your plan does not include this" page is a better experience and belongs with the self-serve upgrade flow in increment 4, where there is something to upgrade to.