OCTO Ops Help User guides and product documentation

2026 09 11 Billing 4A Subscriptions

On this page 8

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 FakePaymentGateway and 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 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>. For the whole suite you must use herd php -d memory_limit=2G vendor/bin/phpunit; artisan test drops -d and 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. BillingAccessState owns 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 writing billing_* on teams.
  • 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/UserFactory exist and are required — Team::create() silently drops every billing_* 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, then npm run format and npm 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-existing DepartmentTest failure, two pre-existing CashierInvoiceDischargeListTest ViteExceptions, and a ~0.5% Faker flake in SuperAdmin\TeamCrudTest::test_index_lists_teams.

What already exists — do not rebuild it

  • Cashier v16, Team as the Billable customer, subscriptions/subscription_items tables, customer columns on teams. Verified present: createSetupIntent(), updateDefaultPaymentMethod(), newSubscription(), createOrGetStripeCustomer(), hasDefaultPaymentMethod(), and SubscriptionBuilder::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.php from 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.php
  • app/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.php
  • routes/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.php
  • app/Providers/AppServiceProvider.php (remove Cashier::ignoreRoutes()), routes/web/billing.php (modify)
  • tests/Feature/Billing/StripeWebhookTest.php

Task 1: The seam and the catalog

Files: as listed above.

Interfaces:

  • Produces: PaymentGateway with 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: PaymentGateway from Task 1; Entitlements::flush(); TeamRole::Owner/Admin.

  • Produces: StartSubscription::handle(Team $team, BillingCycle $cycle, array $moduleKeys, string $paymentMethodId, User $actor): void; routes billing.subscribe (GET) and billing.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.jsonapproved 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, commitnpm run build first; scoped run; PHPStan; Pint, format, lint with collateral reverted.

Task 3: The webhook

Interfaces:

  • Produces: SubscriptionStatusMap::toAccessState(string $stripeStatus): ?BillingAccessState, and POST /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_due arriving twice leaves billing_grace_ends_at at its first value;
  • idempotency: the same stripe_event_id delivered twice produces one BillingEvent and 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: StartSubscription does not set billing_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.