OCTO Ops Help User guides and product documentation

2026 09 12 Billing 4 Completion

On this page 6

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 php is MAMP's 8.2 and too old. Use herd 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; add tests/Unit only when you change app/Support or app/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. SetTeamModules already sets modules for the superadmin. SubscriptionStatusMap owns Stripe-status meaning. Money::format() owns money display. Call them.
  • billing_access_state is 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 BillingAccessState case.
  • PaymentGateway is the only route to Stripe. Both StripePaymentGateway and FakePaymentGateway implement every method; tests use $this->swap().
  • Integer cents, never floats. No .vue computes 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, then npm run format / npm run lint, reverting collateral outside your files. npm run build before any Inertia render test.
  • A new .vue page under resources/js/pages/ needs a case in resources/js/lib/resolveLayout.ts and the matching assertion in resolveLayout.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.php creates 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 / restricted team and redirects the creator to billing.subscribe.
    • Registration without an invitation creates a self_serve / restricted personal team and lands the new user on billing.subscribe.
    • Registration with a valid invitation is unchanged: no team created, the invitee joins the existing one, and 3b's SeatGuard cap 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.
  • Step 3: Implement the three doors. The superadmin path should state enterprise/active explicitly 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.restricted immediately after registering. Verify they can still reach billing.subscribe (they are Owner, so EnsureTeamMembership:admin passes), and that RestrictionController'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.php or 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): intthe seat count, already used by StartSubscription when 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 no app/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 against Seats::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.php already 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_modules row with starts_at now and ends_at null, a Stripe item created, and its id stored in stripe_subscription_item_id. Dropping: ends_at set to period end, the row not deleted, entitlement retained until that date, and the Stripe item removed at drop time with proration_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 its ends_at should 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 with TeamModule::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.php and 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 gate billing.subscribe uses). 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 resolveLayout case 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.