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:
- Account + organization — a public form, no card.
- 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_typedefaults toenterpriseandbilling_access_statetoactive(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.subscribeandbilling.subscribe.storeto 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
phpis MAMP's PHP 8.2 and too old. Useherd php artisan .... - Tests:
herd php -d memory_limit=2G artisan test --compact <path>. Whole suite only viaherd 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.
registerkeeps creating a personal team, and keeps honouring invitations — including 3b's seat-cap guard. Any change there is a defect, andtests/Feature/Authis the proof. - No second subscription-creation path.
StartSubscriptionis the only one. billing_access_stateis 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
BillingAccessStatecase. - Integer cents, never floats. No
.vuecomputes money. No new model factories —TeamFactory/UserFactoryexist and are required;Team::create()silently drops everybilling_*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, thennpm run formatandnpm run lint, reverting repo-wide collateral.npm run buildbefore 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-existingDepartmentTestfailure, and two pre-existingCashierInvoiceDischargeListTestViteExceptions.
File Structure
Task 1 — the funnel:
app/Actions/Billing/RegisterSelfServeTeam.phpapp/Http/Controllers/Billing/SignupController.php,app/Http/Requests/Billing/SignupRequest.phproutes/web/billing.php(modify),resources/js/pages/billing/Signup.vuetests/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; routesbilling.signup(GET) andbilling.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_serveandbilling_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;
-
registerstill 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.restrictedand sees copy that fits — not "settle your outstanding balance", because there is no balance and no invoice; -
they can still reach
billing.subscribeand 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_stateis 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.