OCTO Ops Help User guides and product documentation

2026 09 12 Billing 4B Public Signup

On this page 7

Billing 4b — public signup

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: Let someone sign up from the public site and end up on a paid plan, without touching the registration flow that already exists.

Architecture: 4b is deliberately thin, because 4a did the hard part. Signup creates an account and a self-serve organization that starts without access, then hands off to 4a's already-tested subscribe page. The subscription, the card, the trial and the webhook are all 4a's; 4b only gets someone to the door.

Tech Stack: Laravel 13, Fortify, Cashier v16, Inertia v3 + Vue 3, MySQL.

Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md"Increment 4 in detail — self-serve subscription" (line ~1476), especially "Sub-increments" item 2 and "The trial". The spec is the binding authority.

The ordering constraint that shapes this

A card cannot be captured without a Stripe SetupIntent; a SetupIntent needs a customer; a customer is the team. So the team must exist before any card field can render. That rules out a single form that creates everything at once, and it means the funnel is necessarily two steps:

  1. Account + organization — a public form, no card.
  2. Plan + card — 4a's existing billing/subscribe, reached by redirect.

Do not rebuild step 2. It is tested, its Critical bugs are closed, and duplicating it would give this subsystem a second subscription-creation path — the thing four increments have been spent eliminating.

The decision this plan turns on

A team created by public signup starts with no access, billing_type = self_serve and billing_access_state = restricted, and becomes trialing only when Stripe's webhook says the subscription exists.

That sounds harsh; it is the only safe default, and it falls out of decisions already made:

  • teams.billing_type defaults to enterprise and billing_access_state to active (increment 1, so existing teams migrated correctly). A signup that abandons before paying would otherwise leave a team with permanent free enterprise access — reachable by anyone who can load the signup page.
  • 3a already built exactly the state a restricted team needs: an explanation page, and an allowlist that lets Owner and Admin reach billing routes.
  • 4a already added billing.subscribe and billing.subscribe.store to that allowlist, precisely so a team without access can pay.

So the fail-closed path already exists and is tested. Use it rather than inventing a "pending" state — a new BillingAccessState case would need every enforcement point in increment 3 to learn about it.

Global Constraints

  • PHP binary: plain php is MAMP's PHP 8.2 and too old. Use herd php artisan ....
  • Tests: herd php -d memory_limit=2G artisan test --compact <path>. Whole suite only via herd php -d memory_limit=2G vendor/bin/phpunit, foreground, never backgrounded or killed — an interrupted run corrupts this repo's shared MongoDB test state and produces hundreds of false failures.
  • No background tasks. Nothing will notify you. Run everything in the foreground. Seven agents in this project have parked on runs that did not exist.
  • The existing registration flow must not change behaviour. register keeps creating a personal team, and keeps honouring invitations — including 3b's seat-cap guard. Any change there is a defect, and tests/Feature/Auth is the proof.
  • No second subscription-creation path. StartSubscription is the only one.
  • billing_access_state is written by the webhook — with exactly one exception introduced here, at team creation, which sets the initial fail-closed value and must never be a second implementation of the status mapping.
  • No new BillingAccessState case.
  • Integer cents, never floats. No .vue computes money. No new model factoriesTeamFactory/UserFactory exist and are required; Team::create() silently drops every billing_* column. Billing tables MySQL, never MongoDB.
  • No real Stripe key, secret or whsec_ in any test, fixture or committed file.
  • Formatting: vendor/bin/pint --dirty --format agent, then npm run format and npm run lint, reverting repo-wide collateral. npm run build before any Inertia render test.
  • Pre-existing failures you must NOT fix: 24 failures + 3 errors, nine ViteExceptions, known intermittents in ReturnsRegisterTest, UtilizationTest, AtpTest, Sales\OrderConfirmAuditTest, a pre-existing DepartmentTest failure, and two pre-existing CashierInvoiceDischargeListTest ViteExceptions.

File Structure

Task 1 — the funnel:

  • app/Actions/Billing/RegisterSelfServeTeam.php
  • app/Http/Controllers/Billing/SignupController.php, app/Http/Requests/Billing/SignupRequest.php
  • routes/web/billing.php (modify), resources/js/pages/billing/Signup.vue
  • tests/Feature/Billing/PublicSignupTest.php

Task 2 — the loose ends the funnel creates:

  • app/Http/Controllers/Billing/RestrictionController.php (modify — what a never-subscribed team sees)
  • app/Queries/Billing/TeamBillingList.php / BillingOverview.php (modify if needed — a never-subscribed team must be visible to a superadmin)
  • tests/Feature/Billing/AbandonedSignupTest.php

Task 1: The funnel

Interfaces:

  • Consumes: CreateTeam::handle(User, string, bool $isPersonal = false), Fortify's password rules, BillingType, BillingAccessState.

  • Produces: RegisterSelfServeTeam::handle(string $name, string $email, string $password, string $organization): User; routes billing.signup (GET) and billing.signup.store (POST).

  • Step 1: Write the failing tests

tests/Feature/Billing/PublicSignupTest.php must cover, at minimum:

  • the page is reachable unauthenticated, and an authenticated user is sent somewhere sensible rather than signing up twice;

  • a successful signup creates a user, a non-personal team, and a membership with TeamRole::Owner;

  • the team is billing_type = self_serve and billing_access_state = restricted;

  • the user is logged in and redirected to billing.subscribe;

  • the redirect target is actually reachable in that state — follow it and assert a 200, not a bounce to billing.restricted. This is the one that matters: 4a's review found precisely this bounce on a different route.

  • validation: duplicate email, weak password, missing organization name;

  • register still behaves exactly as before — a personal team, and an invitation still honoured.

  • Step 2: Write the Action

RegisterSelfServeTeam follows 2a's shape — one public handle(), DB::transaction(), a BillingEvent trail, Entitlements::flush().

It creates the user, calls CreateTeam::handle($user, $organization, isPersonal: false), then sets billing_type and the initial billing_access_state. Write a comment saying why restricted is the initial value and that the webhook owns every subsequent transition — otherwise the next reader sees a local write of billing_access_state and reasonably concludes the rule was abandoned.

Do not create a Stripe customer here. 4a's subscribe page does that when it needs a SetupIntent; doing it twice creates two customers.

  • Step 3: Controller, request, routes

GET billing/signup and POST billing/signup, both outside auth and outside EnsureTeamMembership. Verify live with herd php artisan route:list --path=billing.

Password rules come from Fortify's existing rule object — do not restate them, or the two flows will drift on password policy.

  • Step 4: The page

resources/js/pages/billing/Signup.vue: name, email, password, organization. No card, no plan picker — those are 4a's page, one redirect away. Render server-supplied errors; no money, no arithmetic.

  • Step 5: Run, check, commit

Scoped run plus tests/Feature/Auth, PHPStan, Pint, format, lint.


Task 2: The loose ends

A funnel that can be abandoned creates a state nothing has handled before: a self-serve team that is restricted and has never had a subscription. It is not delinquent — it simply never paid.

Interfaces: consumes Entitlements, BillingInvoiceStatus::outstandingValues().

  • Step 1: Write the failing tests

tests/Feature/Billing/AbandonedSignupTest.php:

  • the owner of a never-subscribed team reaches billing.restricted and sees copy that fits — not "settle your outstanding balance", because there is no balance and no invoice;

  • they can still reach billing.subscribe and complete the subscription later;

  • a member of such a team sees the member page, not the recovery page;

  • the team appears in the superadmin Teams list and is distinguishable from a delinquent one;

  • BillingOverview's figures are not distorted by it — in particular it must not be counted as a paying team.

  • Step 2: Make the recovery page honest

RestrictionController currently renders outstanding invoices. For a team with none, that section is empty and the copy is wrong. Branch on whether the team has ever had a subscription, and say the true thing in each case. Do not add a new state to do this — derive it from what already exists.

  • Step 3: Confirm the superadmin console tells the truth

Check TeamBillingList and BillingOverview against a never-subscribed team. If either misreports it, fix it here; if both are already correct, say so in the report with the evidence rather than changing code to look busy.

  • Step 4: Run the whole suite, check, commit

A new public route affects more than billing. Foreground, once, against the baseline.


Self-review notes

  • Spec coverage. "4b — public signup" is delivered as the funnel plus the state it creates. Seat sync (4c), module changes (4d) and the team billing page (4e) are deliberately absent.
  • The one deliberate exception to a standing rule, argued above: billing_access_state is written at team creation. It sets the initial fail-closed value only; every transition after that stays the webhook's.
  • The riskiest step is Task 1 Step 1's redirect assertion. 4a shipped a restricted team being redirected to a page that bounced it straight back, and the test at the time asserted only the redirect target rather than following it. Follow it.
  • A known gap, deliberate: nothing here expires an abandoned signup. A team that never subscribes sits restricted indefinitely, which is safe but accumulates rows. Expiry is a retention-policy decision the spec already defers.