Increment 4 completion — 4f, 4c, 4d, 4e
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: Finish increment 4 — close the enterprise-by-default door, keep the seat quantity honest, let a team change its modules, and show the team what it is paying for.
Architecture: Four sub-increments, executed and merged one at a time — each is independently shippable, so each gets its own branch off dev and merges when clean rather than accumulating on one long-lived branch. 4f ships first because it closes a hole. 4c and 4d share machinery (mutating an existing Stripe subscription's items) so 4d builds on the gateway method 4c adds. 4e is read-only and last, because it displays what the three before it produce.
Testing philosophy (user instruction, 2026-09-12): test the crucial parts, not everything. For each task that means the money-and-access behaviours, one mutation check per new production rule, and nothing else. Skip exhaustive validation matrices, redundant permutations, and re-pinning what an existing test already covers. Scoped test runs only — never the whole suite.
Tech Stack: Laravel 13, Cashier v16, Inertia v3 + Vue 3, MySQL.
Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md — "Increment 4 in detail" (~1476) and "Increment 4f in detail" (~1631). The spec is binding; where this plan and the spec disagree, the spec wins.
Global Constraints
- PHP binary: plain
phpis MAMP's 8.2 and too old. Useherd php artisan .... - Tests are scoped and minimal — the user has a standing instruction against whole-suite runs and against exhaustive coverage. Run
tests/Feature/Billing,tests/Feature/Auth,tests/Feature/Settings,tests/Feature/SuperAdmin, plus the directory of any module you touch; addtests/Unitonly when you changeapp/Supportorapp/Enums. Command:herd php -d memory_limit=2G artisan test --compact <paths>. Foreground only — an interrupted run corrupts shared MongoDB test state. - No background tasks. Nothing will notify you.
- Never call Stripe inside a database transaction. 4a's worst bug was a Stripe call inside
transaction()+lockForUpdate(): the rollback-and-retry path created a second live subscription and charged twice. Every gateway call in this plan happens after the transaction commits. - No billing rule gets a second implementation.
Seats::occupiedBy()is the seat count.SetTeamModulesalready sets modules for the superadmin.SubscriptionStatusMapowns Stripe-status meaning.Money::format()owns money display. Call them. billing_access_stateis written by the webhook. The one existing exception is the initial value at team creation (4b). Task 1 extends that exception to two more creation paths and adds no others.- No new
BillingAccessStatecase. PaymentGatewayis the only route to Stripe. BothStripePaymentGatewayandFakePaymentGatewayimplement every method; tests use$this->swap().- Integer cents, never floats. No
.vuecomputes money. No new model factories. Billing tables MySQL, never MongoDB. - No real Stripe key, secret or
whsec_in any committed file. - Formatting:
vendor/bin/pint --dirty --format agent, thennpm run format/npm run lint, reverting collateral outside your files.npm run buildbefore any Inertia render test. - A new
.vuepage underresources/js/pages/needs a case inresources/js/lib/resolveLayout.tsand the matching assertion inresolveLayout.test.ts. 4b's only Critical was that omission. - Pre-existing failure, do not fix:
Settings\Modules\Hr\DepartmentTest::test_store_allows_duplicate_code_for_a_different_team.
Task 1: 4f — team creation is self-serve by default
Spec: "Increment 4f in detail". Read it first; it carries the reasoning and the two product decisions (restrict registration's personal team; grandfather existing teams).
Files:
- Modify:
app/Http/Controllers/Teams/TeamController.php(store),app/Actions/Fortify/CreateNewUser.php(the personal-team fallback),app/Http/Controllers/SuperAdmin/TeamController.php(store) - Likely extract: a shared way to create a self-serve-restricted team, since
RegisterSelfServeTeam(4b) already does exactly this for public signup - Test:
tests/Feature/Billing/TeamCreationDefaultsTest.php, plus updates to existing team/auth tests
Interfaces: consumes CreateTeam::handle(User, string, bool $isPersonal = false), BillingType, BillingAccessState, RegisterSelfServeTeam (4b).
-
Step 1: Read what 4b already built.
app/Actions/Billing/RegisterSelfServeTeam.phpcreates a user and a self-serve-restricted team. Task 1 needs only the team half, from three different callers. Decide whether to extract the team half into something both can call, or to have the three doors call a new action. Do not duplicate the billing-column assignment in three places — that is three implementations of one rule. -
Step 2: Write the failing tests.
- Settings → Teams creates a
self_serve/restrictedteam and redirects the creator tobilling.subscribe. - Registration without an invitation creates a
self_serve/restrictedpersonal team and lands the new user onbilling.subscribe. - Registration with a valid invitation is unchanged: no team created, the invitee joins the existing one, and 3b's
SeatGuardcap still refuses when full. - A bad or stale invitation code still falls back to a personal team (4b established this) — and that team is now restricted like any other.
- The superadmin console still creates
enterprise/active. - Grandfathering: no migration alters any existing team. Assert an existing team on the old default keeps
enterprise/active. - Each of the three doors dies independently under mutation — deleting one door's billing assignment must fail a test naming that door.
- Settings → Teams creates a
-
Step 3: Implement the three doors. The superadmin path should state
enterprise/activeexplicitly rather than relying on a column default to carry the meaning — a default that two other callers now deliberately override is no longer self-documenting. -
Step 4: Check what a restricted personal team breaks. A user whose only team is restricted now hits
billing.restrictedimmediately after registering. Verify they can still reachbilling.subscribe(they are Owner, soEnsureTeamMembership:adminpasses), and thatRestrictionController's copy — which 4b split six ways — says something true for a brand-new personal team. Fix the copy if it does not. -
Step 5: Run the scoped set, Pint, PHPStan, commit.
Task 2: 4c — seat sync
Spec: "Increment 4 in detail" → "The shape of a subscription" and sub-increment 3. A seat's two halves: increment 3's cap refuses, this bills.
Files:
- Create:
app/Actions/Billing/SyncSeats.php,app/Console/Commands/ReconcileSeats.php - Modify:
app/Support/Billing/PaymentGateway.php,StripePaymentGateway.php,FakePaymentGateway.php; the five seat doors listed below;routes/console.phpor the scheduler - Test:
tests/Feature/Billing/SeatSyncTest.php,tests/Feature/Billing/ReconcileSeatsTest.php
Interfaces:
-
Produces:
PaymentGateway::syncSeatQuantity(Team $team, int $quantity): void;SyncSeats::handle(Team $team): void -
Consumes:
Seats::occupiedBy(Team $team): int— the seat count, already used byStartSubscriptionwhen it sets the initial quantity. -
Step 1: The five doors. Membership becomes active through exactly these, all found by 3b and all already calling
SeatGuard:Settings\User\UserStatusController:46,Settings\User\OwnershipTransferController:62,Teams\TeamInvitationController:43,Actions\Fortify\CreateNewUser:62-64,Actions\Users\CreateTeamUser:45. Deactivation and removal also change the count — find those paths too; a team that loses a member and keeps paying for the seat is the complaint this task exists to prevent. -
Step 2: Write the failing tests first. At minimum: adding a member raises the Stripe quantity; removing or deactivating one lowers it; a team with no subscription is a no-op (not an error); the gateway is called once, with the count
Seats::occupiedBy()reports; and — the one that matters — a door whose transaction rolls back does not call Stripe. -
Step 3: Implement
SyncSeats, and place the call after commit. There is noapp/Jobs/and no queue-worker convention in this repo; do not introduce one. The gateway call happens synchronously, after the door's transaction has committed — never inside it. Re-read the Global Constraint about 4a's double-charge before writing this. -
Step 4: Idempotency and drift. Sync must be safe to run twice (Stripe's quantity is absolute, not a delta — setting it again is harmless; verify that is true of the API you call). Then add
billing:reconcile-seats, modelled on 3c's storage reconciliation command: it walks subscribed teams, compares Stripe quantity againstSeats::occupiedBy(), and corrects drift. This is the backstop for a sixth door added later that forgets to sync — an eager call at five doors plus a nightly sweep, the same belt-and-braces 3c uses. -
Step 5: Proration. The spec says seat additions are prorated and charged immediately. Confirm what Cashier v16 does by default for a quantity change and make the intent explicit rather than inherited — read the vendor source, and say in a comment which behaviour you selected and why.
-
Step 6: Run the scoped set, commit.
Task 3: 4d — module add and drop
Spec: sub-increment 4. Adding adds an item with immediate proration; dropping removes it at period end, which is why billing_team_modules records an ends_at rather than deleting the row.
Files:
- Create:
app/Actions/Billing/ChangeTeamModules.php, a controller and request for the team-facing change - Modify:
PaymentGateway+ both implementations;resources/js/pages/billing/(the module picker);routes/web/billing.php - Test:
tests/Feature/Billing/ModuleChangeTest.php
Interfaces:
-
Produces:
PaymentGateway::addSubscriptionItem(...)/removeSubscriptionItem(...)— name them for what they do to Stripe, and keep both implementations in step -
Consumes:
TeamModule::activeModuleKeysFor(Team, ?CarbonInterface),TeamModule::scopeActiveAt(),Product::specializedModuleKeys(),billing_team_modules.stripe_subscription_item_id(nullable, exists, currently unfilled) -
Step 1: Do not reimplement
SetTeamModules.app/Actions/Billing/SetTeamModules.phpalready changes a team's modules for the superadmin console. Read it first. Either the self-serve path calls it and adds the Stripe half, or the shared rule is extracted — but there must not be two implementations of "what modules does this team hold". -
Step 2: Write the failing tests. Adding a module: a
billing_team_modulesrow withstarts_atnow andends_atnull, a Stripe item created, and its id stored instripe_subscription_item_id. Dropping:ends_atset to period end, the row not deleted, entitlement retained until that date, and the Stripe item removed at drop time withproration_behavior=none— no credit for the paid period, nothing billed next period (the spec originally said "when the renewal webhook is handled"; corrected during execution because that timing bills one extra period). Re-adding a dropped module before itsends_atshould not create a duplicate row or a second Stripe item. Adding a module a team already holds is a no-op. And the transaction-rollback test again: no Stripe call if the local write fails. -
Step 3: Implement, with the gateway calls after commit — same rule as Task 2, same reason.
-
Step 4: Entitlement agreement.
Entitlements::allowedModuleKeys()already unions standard modules withTeamModule::activeModuleKeysFor(). Verify a dropped-but-not-yet-ended module still grants access and that an ended one does not, on the entitlement path, not only in the table. -
Step 5: Run the scoped set, commit.
Task 4: 4e — the team-facing billing page
Spec: sub-increment 5. What a customer sees: plan, card, invoices, next charge. Everything before this is reachable only by a superadmin or a webhook.
Files:
- Create:
app/Http/Controllers/Billing/BillingPageController.php,app/Queries/Billing/TeamBillingSummary.php,resources/js/pages/billing/Index.vue - Modify:
routes/web/billing.php,resources/js/lib/resolveLayout.ts+.test.ts, the settings/billing navigation entry - Test:
tests/Feature/Billing/TeamBillingPageTest.php
Interfaces: consumes Entitlements, Seats::occupiedBy(), TeamModule::activeModuleKeysFor(), Money::format(), BillingInvoiceStatus::outstandingValues(), Cashier's subscription('default').
-
Step 1: Mirror 2c, do not reinvent it.
app/Queries/Billing/TeamBillingList.phpand the superadmin team-detail screen already assemble almost exactly this data. Read them and reuse the shape; a second, subtly different way of computing a team's outstanding balance is how 2c's Critical happened (the list and the detail disagreed once a credit note existed). -
Step 2: Write the failing tests. Owner and Admin can see the page; a Manager or member cannot (
EnsureTeamMembership:admin, the same gatebilling.subscribeuses). Plan, cycle, seat count, modules held, card last-four and next charge all render. An enterprise team sees its negotiated terms and its manual invoices, not a self-serve plan summary. A restricted team is not shown this page instead of the recovery page. Money is formatted server-side — assert no arithmetic in the.vue. -
Step 3: Implement the query object, then the controller, then the page. Server formats money; the page renders strings.
-
Step 4: Wire navigation and the layout case. Add the
resolveLayoutcase and its test assertion. -
Step 5: Run the scoped set,
npm run build, commit.
Self-review notes
- Spec coverage: 4f, 4c, 4d and 4e each map to one task. Increment 5 (dunning) and 6 (usage) are untouched.
- The riskiest thing in this plan is the after-commit rule, and it appears in two tasks. 4a's double-charge is the precedent: a Stripe call inside a transaction that later rolls back bills a customer for something that does not exist. Both Task 2 and Task 3 carry an explicit rollback test for that reason.
- The second riskiest is duplication. Three tasks touch a rule that already has an implementation —
RegisterSelfServeTeam(Task 1),SetTeamModules(Task 3),TeamBillingList(Task 4). Each task's first step is to read the existing one. A reviewer should treat a second implementation as a Critical, not a style note. - Deliberately deferred: cancelling a subscription outright, cycle switching mid-term, and dunning. All named in "What increment 4 does not do".
- Known gap carried forward: nothing expires an abandoned signup, and 4f will now create more of them (every registration without an invitation). Worth revisiting after 4f ships with real numbers.