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:
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
phpis MAMP's PHP 8.2 and too old. Artisan runs asherd php artisan .... - Tests:
herd php -d memory_limit=2G artisan test --compact <path>. For the whole suite you must useherd php -d memory_limit=2G vendor/bin/phpunit—artisan testspawns a child throughPhpExecutableFinderwhich drops-d, and the run dies at 256 MB inTaskAttachmentTest. 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()andallowsModule()are the only implementations of their rules. A seat comparison or a modulein_arraywritten 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;
sqlitein tests). MongoDB is used elsewhere and must never appear in billing code;whereHascannot cross that boundary. - No Action performs authorization.
- No new model factories.
TeamFactoryandUserFactoryexist and are required —Team's#[Fillable]is['name','slug','is_personal','modulesAllowed'], soTeam::create()silently drops every billing column, andUser's omitssuperAdmin. - Formatting:
vendor/bin/pint --dirty --format agent;npm run formatandnpm run lintif any.vue/.tschanges, 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\OrderConfirmAuditTestand a ~0.5% Faker flake inSuperAdmin\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, onceapp/Actions/Users/InviteTeamUsers.php(modify) — refuse an invitation that would exceed the capapp/Http/Controllers/Teams/TeamInvitationController.php(modify) — refuse an acceptance that wouldtests/Feature/Billing/SeatCapTest.php
Task 2 — module route guards:
app/Http/Middleware/EnsureModuleEntitled.phproutes/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:
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
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:
$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 too — updateOrCreate turning status back on is a seat.
- Step 6: Run until green, then check the whole surface
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
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:
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.
public function handle(Request $request, Closure $next, string $moduleKey): Response
- Step 4: Guard the two route groups
Both files already read:
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
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 ishasSeatCapacity(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.