Billing 4a — card capture and subscription creation
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 an existing team's owner add a card and start a subscription, and learn from Stripe what happened to it.
Architecture: Every Stripe call goes through a narrow PaymentGateway interface with a fake, so the state machine is provable offline. The card is tokenised client-side by Stripe.js and never reaches our server. The subscription's effect on access state arrives by webhook, not from the request that created it.
Tech Stack: Laravel 13, PHP 8.4, Cashier v16, Inertia v3 + Vue 3, Stripe.js (Elements), MySQL.
Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md — read "Increment 4 in detail — self-serve subscription" (line ~1476) in full, plus "Access state machine", "Signup and trial" and "Testing". The spec is the binding authority.
This is the first increment that can move money
Everything through increment 3 was provable against a local database. From here, correctness depends on Stripe. Two consequences shape this plan:
- Feature tests run against
FakePaymentGatewayand prove our logic, not Stripe's. A test that asserts a proration figure is testing Stripe's arithmetic and is worthless; a test that asserts we asked for the right proration behaviour is testing us. The spec says this explicitly — follow it. - A separate contract-test group exercises Stripe test mode and stays out of the default run. It is small on purpose: it exists to catch our assumptions about Stripe's API being wrong, not to re-test the flow.
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>. For the whole suite you must useherd php -d memory_limit=2G vendor/bin/phpunit;artisan testdrops-dand dies at 256 MB. Never background or kill a whole-suite run — it corrupts this repo's shared MongoDB test state and produces hundreds of false failures indistinguishable from real ones. - No background tasks. Nothing will notify you. Run everything in the foreground. Five agents in increment 3 parked on phantom background runs.
- No billing rule gets a second implementation.
BillingAccessStateowns the access vocabulary; the Stripe-status mapping is written once, in one class.Money::format()is the only money formatter.Entitlements::flush()after any Action writingbilling_*onteams. - Amounts are integer cents, never floats. Stripe speaks cents too; do not convert on the way past.
- Enforcement fails closed. Increments 3a, 3b and 3c each shipped a fail-open bug; assume this one will too unless proven.
- All billing tables are MySQL (sqlite in tests). MongoDB must never appear in billing code.
- No Action performs authorization. Route middleware and policies do that.
- No new model factories.
TeamFactory/UserFactoryexist and are required —Team::create()silently drops everybilling_*column, so a subscription test seeding with it would test an unconfigured team and pass for the wrong reason. - Never put a real Stripe key, secret or
whsec_in a test, a fixture or a committed file. Tests use the fake; contract tests read env. - Run nothing against any database but the test database.
- Formatting:
vendor/bin/pint --dirty --format agent, thennpm run formatandnpm run lint, reverting repo-wide collateral. - Pre-existing failures you must NOT fix: 24 failures + 3 errors, nine ViteExceptions, known intermittents in
ReturnsRegisterTest,UtilizationTest,AtpTest,Sales\OrderConfirmAuditTest, a pre-existingDepartmentTestfailure, two pre-existingCashierInvoiceDischargeListTestViteExceptions, and a ~0.5% Faker flake inSuperAdmin\TeamCrudTest::test_index_lists_teams.
What already exists — do not rebuild it
- Cashier v16,
Teamas the Billable customer,subscriptions/subscription_itemstables, customer columns onteams. Verified present:createSetupIntent(),updateDefaultPaymentMethod(),newSubscription(),createOrGetStripeCustomer(),hasDefaultPaymentMethod(), andSubscriptionBuilder::price()/trialDays()/create(). config/billing.php— six Price ids, env-driven, all resolving.billing_events.stripe_event_id, unique, already documented as the webhook idempotency key.routes/web/billing.phpfrom 3a, holding the restricted page.BillingAccessState,Entitlements,Money, and the whole superadmin console.
File Structure
Task 1 — the seam and the catalog (no UI, no Stripe calls):
app/Contracts/Billing/PaymentGateway.php,app/Support/Billing/Gateways/StripePaymentGateway.php,app/Support/Billing/Gateways/FakePaymentGateway.phpapp/Providers/AppServiceProvider.php(bind),database/seeders/BillingProductsSeeder.php(read config)tests/Feature/Billing/PaymentGatewayContractTest.php
Task 2 — card and subscription:
app/Actions/Billing/StartSubscription.php,app/Http/Controllers/Billing/SubscriptionController.phproutes/web/billing.php(modify),resources/js/pages/billing/Subscribe.vue,package.json(+@stripe/stripe-js)tests/Feature/Billing/StartSubscriptionTest.php
Task 3 — the webhook:
app/Http/Controllers/Billing/StripeWebhookController.php,app/Support/Billing/SubscriptionStatusMap.phpapp/Providers/AppServiceProvider.php(removeCashier::ignoreRoutes()),routes/web/billing.php(modify)tests/Feature/Billing/StripeWebhookTest.php
Task 1: The seam and the catalog
Files: as listed above.
Interfaces:
-
Produces:
PaymentGatewaywith exactly the calls 4a makes —createSetupIntent(Team): string,setDefaultPaymentMethod(Team, string $paymentMethodId): void,createSubscription(Team, array $prices, int $trialDays, string $paymentMethodId): void. Do not add methods 4c–4e will need. An interface written for work that does not exist yet is guesswork, and every unused method is a lie about what is covered. -
Step 1: Write the failing contract test
tests/Feature/Billing/PaymentGatewayContractTest.php runs the same assertions against both implementations where that is possible offline, and against the fake only where it is not. At minimum: the fake records what it was asked for; createSubscription passes every price given; the trial days reach the call; and resolving PaymentGateway from the container yields the fake in tests and StripePaymentGateway otherwise.
- Step 2: Write the interface and the two implementations
StripePaymentGateway wraps Cashier and contains no business rules — it translates arguments into Cashier calls. FakePaymentGateway records calls and returns plausible values; it must never reach the network.
- Step 3: Bind it
In AppServiceProvider, bind PaymentGateway to StripePaymentGateway, and to FakePaymentGateway when the app is running unit/feature tests. Binding by environment is a rule — write it once, in one place, and assert it in the contract test.
- Step 4: The seeder reads the catalog
BillingProductsSeeder fills stripe_price_monthly and stripe_price_yearly from config('billing.prices'), keyed by the product key it already uses (seat, module.logistics3p, module.containers). Its docblock currently says the ids are "filled in by an operator" — update it. A missing config value leaves the column null rather than writing an empty string, because a null is visibly unconfigured and '' is a Price id that will fail at charge time.
- Step 5: Run, check, commit
Scoped run, PHPStan on app/Contracts app/Support/Billing app/Providers database/seeders, Pint, then commit.
Task 2: Card capture and subscription creation
Interfaces:
-
Consumes:
PaymentGatewayfrom Task 1;Entitlements::flush();TeamRole::Owner/Admin. -
Produces:
StartSubscription::handle(Team $team, BillingCycle $cycle, array $moduleKeys, string $paymentMethodId, User $actor): void; routesbilling.subscribe(GET) andbilling.subscribe.store(POST). -
Step 1: Write the failing tests
tests/Feature/Billing/StartSubscriptionTest.php must cover: only Owner and Admin may reach the page and the endpoint (a Manager may not — increment 3b established those are the billing roles); the chosen cycle selects the matching prices for every item; a team with two modules produces three prices (seat + two modules); the trial is 30 days; billing_type becomes self_serve and billing_cycle is recorded; billing_access_state is NOT changed here — the webhook owns that; a team that already has an active subscription is refused; and a gateway failure leaves no partial local state.
The most important test: the prices passed to the gateway are the ones from billing_products for the chosen cycle — assert the actual Price ids, because passing a monthly price for a yearly subscription is the failure this task can most plausibly ship.
- Step 2: Write the Action
StartSubscription follows 2a's shape — one public handle(), abort_if guards, DB::transaction(), a BillingEvent trail, Entitlements::flush().
It resolves prices from billing_products by cycle, calls PaymentGateway::createSubscription(), and writes billing_type, billing_cycle and the module holdings. It does not set billing_access_state — Stripe tells us what the subscription became, and Task 3 applies it. Writing it here would be a second implementation of the status mapping.
Guard: a team with an existing active subscription is refused (422). Check under lockForUpdate() — two concurrent submissions must not create two Stripe subscriptions, which is unrecoverable without manual intervention in the dashboard.
- Step 3: The controller and routes
GET billing/subscribe renders the page with a SetupIntent client secret, the available modules and both cycles' prices, formatted server-side via Money::format(). POST billing/subscribe takes the payment-method id Stripe.js returns.
Both routes sit inside auth and are restricted to Owner and Admin. Add them to EnsureTeamNotRestricted's allowlist — a restricted team paying is exactly the case that must get through, and 3a's allowlist already reserves billing.recover for this.
- Step 4: The page
resources/js/pages/billing/Subscribe.vue. Add @stripe/stripe-js to package.json — approved by the product owner as part of choosing Elements.
The card element mounts from the SetupIntent client secret; on submit, confirmCardSetup returns a payment method id which is posted to our endpoint. Card details never touch our server. Handle the 3-D Secure path Stripe returns, and render Stripe's error messages rather than inventing our own.
No .vue file computes money. Prices arrive formatted.
- Step 5: Run, check, commit —
npm run buildfirst; scoped run; PHPStan; Pint, format, lint with collateral reverted.
Task 3: The webhook
Interfaces:
-
Produces:
SubscriptionStatusMap::toAccessState(string $stripeStatus): ?BillingAccessState, andPOST /stripe/webhook. -
Step 1: Write the failing tests
tests/Feature/Billing/StripeWebhookTest.php, driven by constructed payloads, never live calls. Cover every row of the spec's status table, plus the three rules that matter more than the rows:
- a suspended team is never changed by any webhook — assert for several statuses, not one;
- grace is set once:
past_duearriving twice leavesbilling_grace_ends_atat its first value; - idempotency: the same
stripe_event_iddelivered twice produces oneBillingEventand one state change.
Also: an unrecognised status changes nothing (fails closed); an unsigned or wrongly-signed request is rejected; and the endpoint is reachable while the team is restricted.
- Step 2: Write the status map
SubscriptionStatusMap is a pure function from Stripe's vocabulary to ours, returning null for "no opinion" (incomplete). It contains no writes and no team lookup — it maps, and the caller applies. That separation is what lets every row be unit-tested without a database.
- Step 3: Write the controller
Extend Cashier's WebhookController so its own handling (subscription sync, customer updates, payment-method updates) keeps working, and add ours on top.
Idempotency is enforced by the unique index, not by a check-then-act: attempt the BillingEvent insert keyed on stripe_event_id and treat a duplicate-key violation as "already handled, return 200". Increment 2's number sequence uses insertOrIgnore for the same reason, and a check-then-act here would let two concurrent deliveries both act.
Return 2xx for anything handled or deliberately ignored. A non-2xx makes Stripe retry, and retrying an event we have decided to ignore is a loop.
- Step 4: Turn the endpoint on
Remove Cashier::ignoreRoutes() from AppServiceProvider::register() — this is the moment the endpoint goes live, and increment 1 added that line specifically so this removal would be explicit and reviewable. Register the route, confirm with herd php artisan route:list --path=stripe, and verify it is outside auth, outside EnsureTeamMembership and outside EnsureTeamNotRestricted.
- Step 5: Run the whole suite
A new unauthenticated route plus a provider change affects more than billing. Foreground, once, against the baseline.
- Step 6: Commit
Self-review notes
- Spec coverage. "What is already in place" → Task 1; the trial, the subscription shape and the interval constraint → Task 2; the status table, the two governing rules and idempotency → Task 3. 4b–4e are deliberately absent.
- One decision worth restating:
StartSubscriptiondoes not setbilling_access_state. It is tempting — the request knows a trial started — but the webhook is the single source of that truth, and writing it in both places is exactly the duplication this subsystem has spent three increments removing. - The riskiest step is Task 3 Step 4. Removing
ignoreRoutes()exposes a public unauthenticated endpoint. Verify its middleware stack explicitly rather than assuming, and confirm signature verification actually rejects an unsigned request — a webhook that accepts anything is a way to set any team's billing state from the internet. - A known gap, deliberate: nothing here syncs seat quantity when members change, or handles a module being added later. 4c and 4d own those. A team that subscribes and then adds ten members is billed for the original count until 4c ships — acceptable only because no self-serve team exists yet.