Billing 3a — access enforcement
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 restricted team from using the application, and let a date be what restricts it.
Architecture: Three pieces. A derived state list and a query scope so "does this team have access" has exactly one implementation. A daily command that moves past_due → restricted and cancelled → restricted when their governing date passes, through an Action like every other state change. And a globally-registered middleware that turns a restricted team away — to an explanation for members, to a recovery screen for Owner and Admin, and to a 402 for API callers.
Tech Stack: Laravel 13, PHP 8.4, Inertia v3 + Vue 3, Wayfinder, Bootstrap/Metronic, MySQL (SQLite in tests).
Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md — read "Increment 3 in detail — enforcement" (line ~1202), plus "Access state machine" and "Restriction". It is the binding authority; where this plan and the spec disagree, the spec wins.
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>; whole suiteherd php -d memory_limit=2G vendor/bin/phpunit(artisan testspawns children that do NOT inherit-d memory_limit). - No billing rule gets a second implementation. This is the constraint for increment 3: every rule it needs already exists.
BillingAccessState::grantsAppAccess()decides who has access;Entitlements::for()resolves a team;Modules::allowedForTeam()decides modules. Re-spelling any of them as a state list, anin_array, or awhereInliteral is a defect. - Amounts are integer cents — never floats. Formatting goes through
Money::format(). No.vuefile performs arithmetic on money or bytes. - All billing tables are MySQL (default connection;
sqlitein tests). MongoDB is used elsewhere in this app and must never appear in billing code. - 3a takes no payment and calls no Stripe.
Recover.vueships with its payment controls inert. - No new model factories. Build rows with explicit
Model::create([...])— exceptTeamandUser, whose#[Fillable]attributes silently drop the columns these tests depend on.TeamFactoryandUserFactoryalready exist; use them. Creating a NEW factory is still forbidden. - Actions: one public
handle(),abort_if(…, 422, …)guards,DB::transaction(), aBillingEvent::record()trail, a class docblock citing the spec section. CopySuspendTeam/RestoreTeam. - Actions that write
billing_*columns onteamsmust callEntitlements::flush(). - No Action performs authorization.
- Wayfinder:
herd php artisan wayfinder:generate --with-form. The--with-formflag is mandatory. npm run buildbefore any Inertia render test. A stale Vite manifest throws aViteExceptionthat looks like a code error and is not.- Formatting:
vendor/bin/pint --dirty --format agent, thennpm run formatandnpm run lint. Both rewrite unrelated files repo-wide — revert the collateral. ESLint's pre-existing errors live inresources/js/pages/maintenance/**; leave them. - Run nothing against any database but the test database.
- Pre-existing failures you did NOT cause and must NOT fix: 24 failures + 3 errors across the suite, nine ViteExceptions, plus intermittent Mongo flakiness in
ReturnsRegisterTest/UtilizationTest/AtpTestand a ~0.5% Faker-ordering flake inSuperAdmin\TeamCrudTest::test_index_lists_teams.tests/Feature/Billing,tests/Unit/Billingandtests/Feature/SuperAdminare green — any failure there is yours.
A correction to the spec, made here
The spec says EnsureTeamNotRestricted goes "on the web module route groups and the API group". Do not do that. Thirty-six route files apply EnsureTeamMembership; adding a second middleware to each is thirty-six chances to miss one, and a missed one is a module that stays reachable while restricted — silently, forever, and only discovered by a customer using it.
Register it globally, appended to the web and api stacks in bootstrap/app.php, with the middleware itself allowlisting what must stay reachable. This file already does exactly this for EnforceForcedLogout and EnforcePasswordChange, with the reasoning written in a comment: "both have to apply to every authenticated page — an admin-forced logout or password change that only took effect on some routes would be no enforcement at all." Restriction is the same kind of rule. A route file added next year is then covered by default rather than by memory.
EnforcePasswordChange is the template for the allowlist. Read it before writing the middleware.
File Structure
Task 1 — the rule and the trigger (no UI):
app/Enums/BillingAccessState.php—grantingAppAccess()derived fromgrantsAppAccess()(modify)app/Models/Team.php—scopeWithAppAccess()(modify)app/Actions/Billing/RestrictTeam.phpapp/Console/Commands/ApplyAccessExpiry.php,routes/console.php(modify)
Task 2 — turning a restricted team away:
app/Http/Middleware/EnsureTeamNotRestricted.php,bootstrap/app.php(modify)app/Http/Controllers/Billing/RestrictionController.phproutes/web/billing.php(new, required inroutes/web.php)resources/js/pages/billing/Restricted.vue,resources/js/pages/billing/Recover.vueresources/js/types/billing.ts(modify)
Task 3 — what the scheduler still does for a restricted team:
app/Console/Commands/GenerateRecurringTasks.php(modify)- the opportunity-snapshot command (modify)
Task 1: The rule and the trigger
Files:
- Modify:
app/Enums/BillingAccessState.php,app/Models/Team.php,routes/console.php - Create:
app/Actions/Billing/RestrictTeam.php,app/Console/Commands/ApplyAccessExpiry.php - Test:
tests/Unit/Billing/BillingAccessStateTest.php,tests/Feature/Billing/ApplyAccessExpiryTest.php
Interfaces:
-
Consumes:
BillingAccessState::grantsAppAccess(): bool,BillingEvent::record(?Team, string, array, ?int, ?string),Entitlements::flush(). -
Produces:
BillingAccessState::grantingAppAccess(): list<string>Team::scopeWithAppAccess(Builder $query): void— usable asTeam::query()->withAppAccess()RestrictTeam::handle(Team $team, string $reason, ?User $actor = null): Team
-
Step 1: Write the failing enum test
Create tests/Unit/Billing/BillingAccessStateTest.php:
<?php
namespace Tests\Unit\Billing;
use App\Enums\BillingAccessState;
use PHPUnit\Framework\TestCase;
class BillingAccessStateTest extends TestCase
{
public function test_it_lists_exactly_the_states_that_grant_access(): void
{
$this->assertSame(
['trialing', 'active', 'past_due', 'cancelled'],
BillingAccessState::grantingAppAccess(),
);
}
/**
* The list must be DERIVED from grantsAppAccess(), not typed out beside
* it. This asserts the two agree for every case, so a state added later
* cannot appear in one and not the other.
*/
public function test_the_list_agrees_with_the_rule_for_every_case(): void
{
$granting = BillingAccessState::grantingAppAccess();
foreach (BillingAccessState::cases() as $case) {
$this->assertSame(
$case->grantsAppAccess(),
in_array($case->value, $granting, true),
"{$case->value} disagrees between grantsAppAccess() and grantingAppAccess().",
);
}
}
public function test_restricted_and_suspended_do_not_grant_access(): void
{
$this->assertFalse(BillingAccessState::Restricted->grantsAppAccess());
$this->assertFalse(BillingAccessState::Suspended->grantsAppAccess());
}
}
- Step 2: Run it and watch it fail
Run: herd php -d memory_limit=2G artisan test --compact tests/Unit/Billing/BillingAccessStateTest.php
Expected: FAIL — Call to undefined method App\Enums\BillingAccessState::grantingAppAccess().
- Step 3: Add
grantingAppAccess()
In app/Enums/BillingAccessState.php:
/**
* The values of every state that grants access, for narrowing a query.
*
* Derived from `grantsAppAccess()` rather than typed out beside it: a
* second list is how a state added later ends up granting access in SQL
* and denying it in PHP, or the reverse.
*
* @return list<string>
*/
public static function grantingAppAccess(): array
{
return array_values(array_map(
fn (self $case) => $case->value,
array_filter(self::cases(), fn (self $case) => $case->grantsAppAccess()),
));
}
- Step 4: Add the query scope
In app/Models/Team.php, beside the other scopes:
/**
* Teams whose billing state still lets their members use the app.
*
* The scheduled jobs that create records for a team narrow with this
* (design doc, "Increment 3 in detail" → "Scheduled work for a restricted
* team"), so that "who has access" is answered in one place for both the
* request path and the scheduler.
*/
public function scopeWithAppAccess(Builder $query): void
{
$query->whereIn('billing_access_state', BillingAccessState::grantingAppAccess());
}
Add the Builder and BillingAccessState imports if absent.
- Step 5: Run the enum test — it should pass
Run: herd php -d memory_limit=2G artisan test --compact tests/Unit/Billing/BillingAccessStateTest.php
Expected: PASS, 3 tests.
- Step 6: Write the failing command test
Create tests/Feature/Billing/ApplyAccessExpiryTest.php:
<?php
namespace Tests\Feature\Billing;
use App\Enums\BillingAccessState;
use App\Models\Billing\BillingEvent;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class ApplyAccessExpiryTest extends TestCase
{
use RefreshDatabase;
private function team(string $name, BillingAccessState $state, array $dates = []): Team
{
return Team::factory()->create([
'name' => $name,
'billing_access_state' => $state->value,
...$dates,
]);
}
public function test_it_restricts_a_past_due_team_whose_grace_has_ended(): void
{
$team = $this->team('Lapsed', BillingAccessState::PastDue, [
'billing_grace_ends_at' => Carbon::now()->subDay(),
]);
$this->artisan('billing:apply-access-expiry')->assertSuccessful();
$this->assertSame(BillingAccessState::Restricted, $team->refresh()->billing_access_state);
$this->assertNotNull($team->billing_restricted_at);
}
public function test_it_leaves_a_past_due_team_still_inside_its_grace_window(): void
{
$team = $this->team('Still In Grace', BillingAccessState::PastDue, [
'billing_grace_ends_at' => Carbon::now()->addDays(5),
]);
$this->artisan('billing:apply-access-expiry')->assertSuccessful();
$this->assertSame(BillingAccessState::PastDue, $team->refresh()->billing_access_state);
}
public function test_it_restricts_a_cancelled_team_whose_paid_period_has_ended(): void
{
$team = $this->team('Cancelled', BillingAccessState::Cancelled, [
'billing_current_period_ends_at' => Carbon::now()->subHour(),
]);
$this->artisan('billing:apply-access-expiry')->assertSuccessful();
$this->assertSame(BillingAccessState::Restricted, $team->refresh()->billing_access_state);
}
public function test_it_leaves_a_cancelled_team_still_inside_its_paid_period(): void
{
$team = $this->team('Cancelled Later', BillingAccessState::Cancelled, [
'billing_current_period_ends_at' => Carbon::now()->addWeek(),
]);
$this->artisan('billing:apply-access-expiry')->assertSuccessful();
$this->assertSame(BillingAccessState::Cancelled, $team->refresh()->billing_access_state);
}
public function test_a_past_due_team_with_no_grace_date_is_left_alone(): void
{
$team = $this->team('No Date', BillingAccessState::PastDue, [
'billing_grace_ends_at' => null,
]);
$this->artisan('billing:apply-access-expiry')->assertSuccessful();
$this->assertSame(BillingAccessState::PastDue, $team->refresh()->billing_access_state);
}
/**
* An enterprise team sits in `active` and only a superadmin moves it.
* A stale date on such a team must never restrict it.
*/
public function test_it_never_touches_an_active_team(): void
{
$team = $this->team('Enterprise', BillingAccessState::Active, [
'billing_grace_ends_at' => Carbon::now()->subYear(),
'billing_current_period_ends_at' => Carbon::now()->subYear(),
]);
$this->artisan('billing:apply-access-expiry')->assertSuccessful();
$this->assertSame(BillingAccessState::Active, $team->refresh()->billing_access_state);
}
public function test_it_never_reopens_a_suspended_team(): void
{
$team = $this->team('Suspended', BillingAccessState::Suspended, [
'billing_grace_ends_at' => Carbon::now()->subDay(),
]);
$this->artisan('billing:apply-access-expiry')->assertSuccessful();
$this->assertSame(BillingAccessState::Suspended, $team->refresh()->billing_access_state);
}
public function test_it_records_a_billing_event_naming_the_reason(): void
{
$this->team('Lapsed', BillingAccessState::PastDue, [
'billing_grace_ends_at' => Carbon::now()->subDay(),
]);
$this->artisan('billing:apply-access-expiry')->assertSuccessful();
$event = BillingEvent::query()->where('type', 'access.restricted')->sole();
$this->assertSame('grace_expired', $event->payload['reason']);
$this->assertNull($event->actor_user_id);
}
public function test_running_it_twice_restricts_once(): void
{
$this->team('Lapsed', BillingAccessState::PastDue, [
'billing_grace_ends_at' => Carbon::now()->subDay(),
]);
$this->artisan('billing:apply-access-expiry')->assertSuccessful();
$this->artisan('billing:apply-access-expiry')->assertSuccessful();
$this->assertSame(1, BillingEvent::query()->where('type', 'access.restricted')->count());
}
public function test_dry_run_writes_nothing(): void
{
$team = $this->team('Lapsed', BillingAccessState::PastDue, [
'billing_grace_ends_at' => Carbon::now()->subDay(),
]);
$this->artisan('billing:apply-access-expiry --dry-run')->assertSuccessful();
$this->assertSame(BillingAccessState::PastDue, $team->refresh()->billing_access_state);
$this->assertSame(0, BillingEvent::query()->count());
}
public function test_a_run_with_nothing_to_do_says_so(): void
{
$this->artisan('billing:apply-access-expiry')
->expectsOutputToContain('No team has reached')
->assertSuccessful();
}
}
- Step 7: Run it and watch it fail
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/ApplyAccessExpiryTest.php
Expected: FAIL — the command does not exist.
- Step 8: Write
RestrictTeam
Create app/Actions/Billing/RestrictTeam.php:
<?php
namespace App\Actions\Billing;
use App\Enums\BillingAccessState;
use App\Models\Billing\BillingEvent;
use App\Models\Team;
use App\Models\User;
use App\Support\Billing\Entitlements;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Removes a team's access because a date it was living on has passed
* (design doc, "Increment 3 in detail" → "The trigger").
*
* Distinct from `SuspendTeam`, which is a superadmin's deliberate act: this
* is the lifecycle catching up with itself, so the actor is normally null and
* the reason says which date ran out.
*
* Refuses to restrict a team that is not in a state the lifecycle restricts
* FROM — notably `suspended`, which must never be quietly downgraded, and
* `active`, where a stale date left over from an earlier cycle would
* otherwise take a working enterprise team offline.
*/
class RestrictTeam
{
/**
* States the lifecycle may restrict from.
*/
private const RESTRICTABLE = [
BillingAccessState::PastDue,
BillingAccessState::Cancelled,
];
public function handle(Team $team, string $reason, ?User $actor = null): Team
{
return DB::transaction(function () use ($team, $reason, $actor) {
// Lock, then re-check: a superadmin may have suspended or restored
// this team between the command's query and this write.
$locked = Team::query()->whereKey($team->getKey())->lockForUpdate()->firstOrFail();
abort_unless(
in_array($locked->billing_access_state, self::RESTRICTABLE, true),
422,
'Only a past-due or cancelled team can be restricted by the lifecycle.',
);
$locked->forceFill([
'billing_access_state' => BillingAccessState::Restricted,
'billing_restricted_at' => Carbon::now(),
])->save();
BillingEvent::record(
$locked,
'access.restricted',
[
'reason' => $reason,
'from' => $team->billing_access_state->value,
],
$actor?->id,
);
Entitlements::flush();
return $locked;
});
}
}
- Step 9: Write the command
Create app/Console/Commands/ApplyAccessExpiry.php:
<?php
namespace App\Console\Commands;
use App\Actions\Billing\RestrictTeam;
use App\Enums\BillingAccessState;
use App\Models\Team;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Applies the two lifecycle transitions that are simply a date passing
* (design doc, "Increment 3 in detail" → "The trigger"):
*
* past_due --billing_grace_ends_at passed--------> restricted
* cancelled --billing_current_period_ends_at passed-> restricted
*
* Deliberately not part of increment 5's dunning: this sends no mail, calls
* no Stripe and retries nothing. It reads two dates and writes one column, so
* increment 3's restriction flow has a real trigger rather than only a
* superadmin doing it by hand.
*
* Only actually runs once `schedule:run` is invoked by a real cron/systemd
* timer in the deployment.
*/
class ApplyAccessExpiry extends Command
{
protected $signature = 'billing:apply-access-expiry
{--dry-run : Show what would be restricted without writing anything}';
protected $description = 'Restrict teams whose grace window or paid period has ended';
public function handle(RestrictTeam $restrictTeam): int
{
$dryRun = (bool) $this->option('dry-run');
$now = Carbon::now();
$rows = [];
$due = Team::query()
->where(fn ($query) => $query
->where('billing_access_state', BillingAccessState::PastDue->value)
->whereNotNull('billing_grace_ends_at')
->where('billing_grace_ends_at', '<=', $now))
->orWhere(fn ($query) => $query
->where('billing_access_state', BillingAccessState::Cancelled->value)
->whereNotNull('billing_current_period_ends_at')
->where('billing_current_period_ends_at', '<=', $now));
$due->chunkById(200, function ($teams) use ($restrictTeam, $dryRun, &$rows) {
foreach ($teams as $team) {
$reason = $team->billing_access_state === BillingAccessState::PastDue
? 'grace_expired'
: 'cancellation_period_ended';
if ($dryRun) {
$rows[] = [$team->name, $team->billing_access_state->value, $reason, 'would restrict'];
continue;
}
$restrictTeam->handle($team, $reason);
$rows[] = [$team->name, $team->billing_access_state->value, $reason, 'restricted'];
}
});
if ($rows === []) {
$this->info('No team has reached the end of a grace window or paid period.');
return self::SUCCESS;
}
$this->table(['Organization', 'From', 'Reason', 'Outcome'], $rows);
if ($dryRun) {
$this->comment('Dry run — nothing was written.');
}
return self::SUCCESS;
}
}
Note the where(...)->orWhere(...) grouping: each closure is a grouped condition, so the two branches cannot leak into each other.
Corrected after Task 1's review. This step originally claimed test_it_never_touches_an_active_team catches a flattened orWhere. It does not. With two pure-conjunction branches the flattened form is logically equivalent on the first page — an active team fails both branches either way — and the grouping only becomes observable from chunkById's second page, where forPageAfterId() appends its cursor condition ungrouped at the top level. Covering it needs a --chunk option and a test run at --chunk=1 with an active team ordered by id between two genuinely due teams, so the cursor page is actually reached.
- Step 10: Run the command tests until green
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/ApplyAccessExpiryTest.php
Expected: PASS, 11 tests.
- Step 11: Schedule it
In routes/console.php, following the commenting convention of its neighbours:
// design doc, "Increment 3 in detail" → "The trigger": past_due -> restricted
// and cancelled -> restricted are dates passing, not payment events, so they
// run here rather than waiting for increment 5's dunning. Daily because both
// governing values are dates; the Action re-checks state under a lock, so a
// superadmin acting between the query and the write wins.
Schedule::command('billing:apply-access-expiry')
->daily()
->withoutOverlapping()
->description('Restrict teams whose grace window or paid period has ended');
Verify: herd php artisan schedule:list | grep apply-access-expiry
- Step 12: Full 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=1G vendor/bin/phpstan analyse app/Actions/Billing app/Console/Commands app/Enums app/Models app/Support/Billing --no-progress
vendor/bin/pint --dirty --format agent
git add app/Enums/BillingAccessState.php app/Models/Team.php app/Actions/Billing/RestrictTeam.php \
app/Console/Commands/ApplyAccessExpiry.php routes/console.php \
tests/Unit/Billing/BillingAccessStateTest.php tests/Feature/Billing/ApplyAccessExpiryTest.php
git commit -m "Billing: let a date be what restricts a team"
Task 2: Turning a restricted team away
Files:
- Create:
app/Http/Middleware/EnsureTeamNotRestricted.php,app/Http/Controllers/Billing/RestrictionController.php,routes/web/billing.php,resources/js/pages/billing/Restricted.vue,resources/js/pages/billing/Recover.vue - Modify:
bootstrap/app.php,routes/web.php,resources/js/types/billing.ts - Test:
tests/Feature/Billing/RestrictedAccessTest.php
Interfaces:
-
Consumes:
Entitlements::for(?Team): Entitlement,Entitlement::grantsAppAccess(): bool,TeamRole::Owner/Admin,User::teamRole(Team): ?TeamRole,Invoice/BillingInvoiceStatusfor the outstanding list,Money::format(). -
Produces: routes
billing.restrictedandbilling.recover; middlewareEnsureTeamNotRestricted. -
Step 1: Write the failing test
Create tests/Feature/Billing/RestrictedAccessTest.php:
<?php
namespace Tests\Feature\Billing;
use App\Enums\BillingAccessState;
use App\Enums\TeamRole;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class RestrictedAccessTest extends TestCase
{
use RefreshDatabase;
private function member(BillingAccessState $state, TeamRole $role = TeamRole::Member): User
{
$team = Team::factory()->create([
'name' => 'Acme Logistics',
'billing_access_state' => $state->value,
]);
$user = User::factory()->create();
$team->members()->attach($user, ['role' => $role->value, 'status' => 1]);
$user->forceFill(['current_team_id' => $team->id])->save();
return $user->refresh();
}
public function test_a_member_of_an_active_team_reaches_the_dashboard(): void
{
$this->actingAs($this->member(BillingAccessState::Active))
->get('/dashboard')
->assertOk();
}
public function test_a_member_of_a_restricted_team_is_sent_to_the_restricted_page(): void
{
$this->actingAs($this->member(BillingAccessState::Restricted))
->get('/dashboard')
->assertRedirect(route('billing.restricted'));
}
public function test_a_member_of_a_suspended_team_is_also_turned_away(): void
{
$this->actingAs($this->member(BillingAccessState::Suspended))
->get('/dashboard')
->assertRedirect(route('billing.restricted'));
}
/**
* `past_due` is the 21-day grace window: the team works normally while
* the platform retries. Restricting here would take a paying customer
* offline on their first failed charge.
*/
public function test_a_past_due_team_still_works_normally(): void
{
$this->actingAs($this->member(BillingAccessState::PastDue))
->get('/dashboard')
->assertOk();
}
public function test_a_cancelled_team_still_works_until_its_period_ends(): void
{
$this->actingAs($this->member(BillingAccessState::Cancelled))
->get('/dashboard')
->assertOk();
}
public function test_a_member_sees_the_explanation_page(): void
{
$this->actingAs($this->member(BillingAccessState::Restricted))
->get('/billing/restricted')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('billing/Restricted')
->where('teamName', 'Acme Logistics')
);
}
public function test_an_owner_sees_the_recovery_page_instead(): void
{
$this->actingAs($this->member(BillingAccessState::Restricted, TeamRole::Owner))
->get('/billing/restricted')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->component('billing/Recover'));
}
public function test_an_admin_sees_the_recovery_page(): void
{
$this->actingAs($this->member(BillingAccessState::Restricted, TeamRole::Admin))
->get('/billing/restricted')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->component('billing/Recover'));
}
/**
* A manager is not a billing role. The spec names Owner and Admin as the
* roles that may manage billing, and they are the same two that recover.
*/
public function test_a_manager_sees_the_member_page_not_the_recovery_page(): void
{
$this->actingAs($this->member(BillingAccessState::Restricted, TeamRole::Manager))
->get('/billing/restricted')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->component('billing/Restricted'));
}
public function test_a_restricted_team_can_still_log_out(): void
{
$this->actingAs($this->member(BillingAccessState::Restricted))
->post('/logout')
->assertRedirect();
}
public function test_an_api_request_from_a_restricted_team_gets_402_not_a_redirect(): void
{
$this->actingAs($this->member(BillingAccessState::Restricted))
->getJson('/api/v1/files/uploads')
->assertStatus(402)
->assertJsonStructure(['message']);
}
public function test_a_user_with_no_team_is_not_caught_by_the_middleware(): void
{
$user = User::factory()->create(['current_team_id' => null]);
$this->actingAs($user)->get('/dashboard')->assertRedirect();
$this->assertNotSame(route('billing.restricted'), url()->previous());
}
/**
* A superadmin must be able to run the console while their own team is
* restricted — otherwise restriction locks out the only person who can
* lift it.
*/
public function test_a_superadmin_still_reaches_the_console(): void
{
$user = $this->member(BillingAccessState::Restricted);
$user->forceFill(['superAdmin' => true])->save();
$this->actingAs($user->refresh())
->get('/superadmin/billing')
->assertOk();
}
}
If /api/v1/files/uploads is not a registered route, substitute any registered api/v1 GET route — confirm with herd php artisan route:list --path=api/v1 | head.
- Step 2: Run it and watch it fail
Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/RestrictedAccessTest.php
Expected: FAIL — Route [billing.restricted] not defined.
- Step 3: Write the middleware
Read app/Http/Middleware/EnforcePasswordChange.php first — it is the template for shape and allowlist.
Create app/Http/Middleware/EnsureTeamNotRestricted.php:
<?php
namespace App\Http\Middleware;
use App\Support\Billing\Entitlements;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Holds a restricted team out of the application (design doc, "Restriction").
*
* Registered globally on the `web` and `api` stacks rather than on each
* module's route group. Thirty-six route files apply `EnsureTeamMembership`;
* a second middleware on each is thirty-six chances to miss one, and a missed
* one is a module that stays reachable while restricted, silently and
* forever. `EnforceForcedLogout` and `EnforcePasswordChange` are registered
* the same way for the same reason.
*
* The decision is `Entitlements::for($team)->grantsAppAccess()` — never a
* list of states re-spelled here. `BillingAccessState` owns that question,
* and a second copy is how `suspended` ends up remembered in one place and
* forgotten in the other.
*/
class EnsureTeamNotRestricted
{
/**
* Routes a restricted team must still reach, or it could never leave,
* pay, or find out why.
*
* @var array<int, string>
*/
private const ALLOWED_ROUTES = [
'billing.restricted',
'billing.recover',
'logout',
'login',
'password.confirm',
'password.confirm.store',
'password.confirmation',
'verification.notice',
'verification.verify',
'verification.send',
'superadmin.impersonate.stop',
];
/**
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
// A superadmin must be able to run the console while their own team is
// restricted — otherwise restriction locks out the only person who can
// lift it. While impersonating, the acting user is the impersonated
// one, who is not a superadmin, so the restriction is seen exactly as
// that user sees it.
if ($user->superAdmin === true) {
return $next($request);
}
$team = $user->currentTeam;
if (! $team || Entitlements::for($team)->grantsAppAccess()) {
return $next($request);
}
$routeName = $request->route()?->getName();
if ($routeName !== null && in_array($routeName, self::ALLOWED_ROUTES, true)) {
return $next($request);
}
if ($request->expectsJson()) {
abort(402, __('This organization\'s account is restricted.'));
}
return redirect()->route('billing.restricted');
}
}
- Step 4: Register it globally
In bootstrap/app.php, append to the web stack after EnforcePasswordChange::class:
// Restriction has to apply to every authenticated page for the
// same reason the two above do: 36 route files apply
// `EnsureTeamMembership`, and a restriction middleware added to
// 35 of them is not enforcement, it is a gap nobody finds until a
// customer uses it.
EnsureTeamNotRestricted::class,
and append to the api stack — note the existing call uses prepend:, so add a second call:
$middleware->api(append: [
EnsureTeamNotRestricted::class,
]);
Add the import.
- Step 5: Write the controller
Create app/Http/Controllers/Billing/RestrictionController.php:
<?php
namespace App\Http\Controllers\Billing;
use App\Enums\BillingInvoiceStatus;
use App\Enums\TeamRole;
use App\Http\Controllers\Controller;
use App\Models\Billing\Invoice;
use App\Support\Billing\Money;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* What a restricted team sees instead of the application (design doc,
* "Restriction").
*
* One route, two pages, chosen by role: a member gets an explanation they can
* act on by talking to someone, an Owner or Admin gets the outstanding
* invoices and what to do about them. Splitting them into two routes would
* mean a member could read their organization's balance by typing the other
* URL.
*/
class RestrictionController extends Controller
{
public function show(Request $request): Response
{
$user = $request->user();
$team = $user->currentTeam;
$role = $user->teamRole($team);
$canRecover = $role !== null
&& in_array($role, [TeamRole::Owner, TeamRole::Admin], true);
if (! $canRecover) {
return Inertia::render('billing/Restricted', [
'teamName' => $team->name,
])->rootView('auth');
}
$invoices = Invoice::query()
->where('team_id', $team->id)
->whereIn('status', BillingInvoiceStatus::outstandingValues())
->with('payments')
->orderBy('period_start')
->get()
->map(fn (Invoice $invoice) => [
'id' => $invoice->id,
'number' => $invoice->number,
'period_start' => $invoice->period_start?->toDateString(),
'period_end' => $invoice->period_end?->toDateString(),
'total' => Money::format($invoice->total_cents, $invoice->currency),
'amount_due' => Money::format($invoice->amountDueCents(), $invoice->currency),
])
->values()
->all();
return Inertia::render('billing/Recover', [
'teamName' => $team->name,
'billingEmail' => $team->billing_email,
'invoices' => $invoices,
])->rootView('auth');
}
}
BillingInvoiceStatus::outstandingValues() and Invoice::amountDueCents() both exist — do not reimplement either.
- Step 6: Register the routes
Create routes/web/billing.php:
<?php
use App\Http\Controllers\Billing\RestrictionController;
use Illuminate\Support\Facades\Route;
/**
* The only part of the application a restricted team can reach (design doc,
* "Restriction"). Deliberately behind `auth` alone — NOT
* `EnsureTeamMembership`, and reachable while `EnsureTeamNotRestricted` is
* turning every other route away, because a restricted team that could not
* reach this page would see nothing but a redirect loop.
*/
Route::middleware(['auth'])->prefix('billing')->name('billing.')->group(function () {
Route::get('restricted', [RestrictionController::class, 'show'])->name('restricted');
});
Require it from routes/modules.php, which is where every routes/web/*.php file is required (alphabetically, so billing.php goes after barcode.php and before billboards.php). routes/web.php requires modules.php, not the individual files.
The billing.recover name in the middleware allowlist is reserved for increment 4's payment routes; it is listed now so the allowlist does not need editing then. If your linter objects to an allowlisted route that does not exist, keep it — in_array on a name is not a route lookup.
- Step 7: Write the two pages
Create resources/js/pages/billing/Restricted.vue, modelled on resources/js/pages/AccessRemoved.vue (read it first — same layout contract, same Head, same sign-out link):
<script setup lang="ts">
/**
* Shown to a member whose organization's billing account is restricted.
*
* Deliberately carries no figures: a member cannot settle an invoice, and
* their organization's balance is not their business. They get the one fact
* they can act on — who to talk to.
*/
import { Head, Link } from '@inertiajs/vue3';
import { logout } from '@/routes';
defineProps<{
teamName: string;
}>();
defineOptions({
layout: {
title: 'Account restricted',
description: 'This organization’s account needs attention',
},
});
</script>
<template>
<Head title="Account restricted" />
<div class="text-center w-100">
<p class="text-gray-700 mb-8">
<span class="fw-semibold">{{ teamName }}</span> is temporarily restricted while its account is brought up to date. Your work and
everything you created are safe — an owner or administrator of this organization can restore access.
</p>
<Link :href="logout()" method="post" as="button" class="btn btn-light-primary">Sign out</Link>
</div>
</template>
Create resources/js/pages/billing/Recover.vue — same layout contract, plus the outstanding invoices. It performs no arithmetic: total and amount_due arrive formatted.
<script setup lang="ts">
/**
* Shown to an Owner or Admin whose organization is restricted.
*
* In increment 3 this page explains and lists; it does not take a card.
* Self-serve payment is increment 4, and a button that appears to charge and
* does not would be worse than none.
*/
import { Head, Link } from '@inertiajs/vue3';
import { logout } from '@/routes';
import type { BillingRecoverInvoice } from '@/types';
defineProps<{
teamName: string;
billingEmail: string | null;
invoices: BillingRecoverInvoice[];
}>();
defineOptions({
layout: {
title: 'Account restricted',
description: 'Settle the outstanding balance to restore access',
},
});
</script>
<template>
<Head title="Account restricted" />
<div class="w-100">
<p class="text-gray-700 mb-6">
<span class="fw-semibold">{{ teamName }}</span> is restricted until its outstanding balance is settled. Everything your team created is
safe and will be available again as soon as the account is up to date.
</p>
<div v-if="invoices.length" class="card mb-6">
<div class="card-header">
<h3 class="card-title">Outstanding invoices</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr class="fw-semibold text-muted">
<th>Invoice</th>
<th>Period</th>
<th class="text-end">Total</th>
<th class="text-end">Due</th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices" :key="invoice.id">
<td>{{ invoice.number ?? 'Draft' }}</td>
<td>{{ invoice.period_start }} – {{ invoice.period_end }}</td>
<td class="text-end">{{ invoice.total }}</td>
<td class="text-end fw-semibold">{{ invoice.amount_due }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<p class="text-muted mb-8">
To settle the balance, contact us<span v-if="billingEmail"> — we’ll reply to {{ billingEmail }}</span>. Online payment is coming soon.
</p>
<Link :href="logout()" method="post" as="button" class="btn btn-light-primary">Sign out</Link>
</div>
</template>
Append to resources/js/types/billing.ts:
export type BillingRecoverInvoice = {
id: number;
number: string | null;
period_start: string | null;
period_end: string | null;
total: string;
amount_due: string;
};
- Step 8: Generate Wayfinder, build, and run
herd php artisan wayfinder:generate --with-form
npm run build
herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/RestrictedAccessTest.php
Expected: PASS, 13 tests. A ViteException means the build did not run.
Expect fallout here. This middleware now runs on every authenticated request in the suite, so any existing test whose team is restricted or suspended will start redirecting. Run the whole suite once (herd php -d memory_limit=2G vendor/bin/phpunit) and compare against the documented baseline of 24 failures + 3 errors. Anything new is yours — and it is far more likely to be a test that needs a non-restricted team than a bug in the middleware. Fix the tests, not the rule.
- Step 9: Full 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=1G vendor/bin/phpstan analyse app/Http/Middleware app/Http/Controllers/Billing app/Support/Billing --no-progress
vendor/bin/pint --dirty --format agent
npm run format && npm run lint
git add <the paths above> && git commit -m "Billing: hold a restricted team out of the application"
Revert format/lint collateral before committing.
Task 3: What the scheduler still does for a restricted team
Files:
- Modify:
app/Console/Commands/GenerateRecurringTasks.php,app/Console/Commands/Sales/CaptureOpportunitySnapshotsCommand.php - Test:
tests/Feature/Billing/RestrictedScheduledWorkTest.php
Interfaces:
-
Consumes:
Team::scopeWithAppAccess()from Task 1. -
Step 1: Write the failing test
Create tests/Feature/Billing/RestrictedScheduledWorkTest.php. It must prove two things per command: a team with access still gets its records, and a restricted team does not. Assert on rows created, never on log output.
Seed with Team::factory()->create(['billing_access_state' => …]). Build the domain rows each command needs with explicit Model::create([...]) — read each command first to learn what it actually queries, and do not guess the shape.
public function test_recurring_tasks_generate_for_a_team_with_access(): void
{
// ... seed an active team + a due schedule-mode recurrence
$this->artisan('tasks:generate-recurring-occurrences')->assertSuccessful();
// assert the occurrence row exists
}
public function test_recurring_tasks_do_not_generate_for_a_restricted_team(): void
{
// ... same seed, billing_access_state = restricted
$this->artisan('tasks:generate-recurring-occurrences')->assertSuccessful();
// assert no occurrence row was created
}
- Step 2: Run it and watch it fail
The restricted case will fail: today both commands generate for everyone.
- Step 3: Narrow both commands
Each command narrows through the one shared scope, never its own state list. Where the command starts from a team:
Team::query()->withAppAccess()
Where it starts from a domain record (as GenerateRecurringTasks does, querying TaskRecurrence), join or constrain through the team relationship — e.g.
->whereHas('team', fn (Builder $query) => $query->withAppAccess())
— reading the command first to find how it reaches a team. If a command has no team relation at all, resolve the team per row and ask Entitlements::for($team)->grantsAppAccess(); do not invent a state list.
Add a comment at each site citing the spec section, saying that a job which creates records skips restricted teams while one that releases or expires does not. Someone will otherwise "fix" the asymmetry.
- Step 4: Confirm the commands that must NOT change
Leave these alone, and say so in your report: octo:sales-expire-quotations (a state change), octo:sales-release-expired-reservations (frees stock the team is not using), notifications:send-digests (silencing it would silence the mail that prompts someone to pay), billing:draft-enterprise-invoices and billing:apply-access-expiry (billing), and the TeamInvitation expiry closure (cleanup).
- 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=1G vendor/bin/phpstan analyse app/Console/Commands app/Models --no-progress
vendor/bin/pint --dirty --format agent
git add <paths> && git commit -m "Billing: stop generating work a restricted team cannot act on"
Self-review notes
Checked while writing:
- Spec coverage. "The trigger" → Task 1. "Restriction" (middleware, both pages, API 402, allowlist) → Task 2. "Scheduled work for a restricted team" → Tasks 1 and 3 together, the scope in 1 and its use in 3. Seat cap, module guards and storage are 3b and 3c and deliberately absent here.
- One deliberate divergence from the spec, argued above: the middleware is registered globally rather than on 36 route groups. The spec's intent — every module route guarded — is better served this way, and the file already does it for two comparable rules.
- Type consistency.
grantingAppAccess(): list<string>feedswhereIn, andgrantsAppAccess(): boolstays the per-state question; the enum test asserts they agree for every case.RestrictTeam::handle()'s?User $actor = nullmatches the nullable actorCreateInvoicetook in 2d. - Corrected after Task 1's review: the command needs a
--chunkoption, purely so the query grouping can be tested at a chunk boundary. See Task 1 Step 9. - Corrected after Task 1's review:
RestrictTeam's lock-recheck must re-evaluate the governing date, not only the state.ExtendGracemovesbilling_grace_ends_atand leaves the team inpast_due, so a state-only re-check lets the sweep overrule a superadmin who just extended grace — and recordgrace_expiredas the reason for doing it. - The riskiest step is Task 2 Step 8. A globally-registered middleware runs on every authenticated request in the suite, so existing tests with restricted or suspended teams will start redirecting. The plan says to expect that, compare against the documented baseline, and fix the tests rather than weaken the rule — which is the temptation at that moment.
- A known gap, deliberate: nothing in 3a lets a restricted team recover.
Recover.vuelists invoices and says to make contact; paying is increment 4. A team leavesrestrictedonly by a superadmin acting, which is exactly the state of the system until self-serve billing exists.