OCTO Ops Help User guides and product documentation

2026 09 15 Billing 7 Account Management

On this page 4

Increment 7 — self-serve account management: Implementation Plan

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 a paying self-serve team end its subscription at period end (Owner only), undo that before the period ends, change its card while healthy, and set its own billing email — with the webhook still the only writer of access state.

Architecture: Two sub-increments, executed and merged one at a time, each its own branch off dev: 7a cancel + resume (the status map learns Stripe's cancel_at_period_end flag; two actions; two gateway methods; the plan card's buttons), 7b change card + billing email (an on-demand SetupIntent endpoint that reuses the recovery form; one action and one field). Every Stripe call goes through PaymentGateway, outside any transaction; no action writes billing_access_state; RecoveryOptions stays the one place that decides which card controls a page offers; a new AccountOptions is the one place that decides cancel/resume.

Tech Stack: Laravel 13, Cashier v16 (Subscription::cancel() / resume() / onGracePeriod()), Inertia v3 + Vue 3, MySQL (SQLite in-memory in tests).

Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md — "Increment 7 in detail — self-serve account management" (~line 2257), plus "Access state machine" (~276), "Stripe status is not our access state" (~1546), "The recovery path" (~1860). 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. Test the access-state and money behaviours the task names, one mutation check per new production rule, nothing else. Run tests/Feature/Billing, tests/Feature/Auth, tests/Feature/Settings, tests/Feature/SuperAdmin, plus tests/Unit when app/Support or app/Enums changes. Command: herd php -d memory_limit=2G artisan test --compact <paths>. Foreground only.
  • No background tasks. Nothing will notify you.
  • Never call Stripe inside a database transaction. FakePaymentGateway records DB::transactionLevel() on every call; tests assert it equals the level the test itself runs at (1 under RefreshDatabase).
  • billing_access_state is written by the webhook's applyAccessState() only. Cancel and resume write nothing to it; the page may show a pending note for the seconds until the webhook lands.
  • No billing rule gets a second implementation. SubscriptionStatusMap::toAccessState() owns Stripe-status meaning (now with the flag); AccountOptions::for() owns "may this user cancel / resume"; RecoveryOptions::for() owns the card controls; Subscriptions::forTeam() / liveFor() are the one subscription lookup (never Cashier's $team->subscription() relation property); UpdatePaymentMethod is the one way a card changes; DunningRecipients::for() is the one recipient rule.
  • Record after Stripe accepted (5c's rule): the gateway call first, outside any transaction, then the billing_events row. A false event is worse than a late one.
  • PaymentGateway is the only route to Stripe. Every new method on the interface, StripePaymentGateway and FakePaymentGateway in the same commit as its caller; amend the interface docblock's "no methods without callers" clause as 4c/5c/6b did. The fake mirrors what Cashier writes locally (ends_at on cancel, null on resume) and nothing Stripe does.
  • Owner-only for cancel and resume, enforced inside the action (TeamRole::Owner), not only by the route. Everything else on this increment is the existing EnsureTeamMembership::class.':admin' group.
  • No .vue decides a rule. Booleans and dates arrive as props. No new page. Confirmation is an inline panel, not window.confirm (browser automation and tests cannot dismiss a native dialog).
  • Integer cents; no floats; no factories; billing tables MySQL; no real Stripe secret committed.
  • Formatting: vendor/bin/pint --dirty --format agent; npm run lint / npm run format on touched .vue/.ts, reverting collateral; npm run build before any Inertia render test. PHPStan: parity with the branch baseline (126 pre-existing errors in ProjectManagement/Sales) — zero in any Billing path.
  • Pre-existing failures, do not fix: Settings\Modules\Hr\DepartmentTest::test_store_allows_duplicate_code_for_a_different_team; SuperAdmin\TeamCrudTest::test_index_lists_teams is an order-dependent flake that passes in isolation.
  • No deploy notes expected: no migration, no new webhook event (customer.subscription.updated already carries the flag), no config.

Task 1: 7a — cancel and resume

Spec: "Corrections to the sections above", "Cancelling", "Resuming", "What the customer sees" (plan card), "Routes". Read them first.

Files:

  • Create: app/Support/Billing/AccountOptions.php, app/Actions/Billing/CancelSubscription.php, app/Actions/Billing/ResumeSubscription.php, app/Http/Controllers/Billing/SubscriptionCancellationController.php (store = cancel, destroy = resume)
  • Modify: app/Support/Billing/SubscriptionStatusMap.php (second argument), app/Http/Controllers/Billing/StripeWebhookController.php (applyAccessState() ~395-430 computes the flag and passes it), app/Http/Controllers/Billing/RestrictionController.php (~151 passes $subscription->onGracePeriod()), app/Support/Billing/PaymentGateway.php + StripePaymentGateway.php + FakePaymentGateway.php (cancelAtPeriodEnd(), resumeSubscription()), routes/web/billing.php (two routes in the verified + :admin group at ~99), app/Http/Controllers/Billing/BillingPageController.php (spread AccountOptions::for($team, $user) into the props), resources/js/pages/billing/Index.vue (plan card buttons + inline confirmation + cancelled note), resources/js/types/billing.ts, docs/superpowers/specs/... no — already amended
  • Test: tests/Unit/Billing/SubscriptionStatusMapTest.php (extend), tests/Feature/Billing/StripeWebhookTest.php (extend: flag payloads), tests/Feature/Billing/SubscriptionCancellationTest.php (new), tests/Feature/Billing/TeamBillingPageTest.php (extend: props), tests/Feature/Billing/PaymentGatewayContractTest.php + StartSubscriptionTest.php (the widened interface)

Interfaces:

  • Consumes: Cashier Subscription::cancel() (sets cancel_at_period_end at Stripe, ends_at locally = period end or trial end), resume() (clears both; throws LogicException off grace), onGracePeriod() (ends_at in the future), ended(), valid(); Subscriptions::forTeam() / liveFor(); Team::owner(), User::teamRole(Team): ?TeamRole, TeamRole::Owner; BillingEvent::record(?Team, string $type, array $payload, ?int $actorId ...) (read its signature); TeamBillingSummary::for()'s plan.next_charge_at / next_charge_label (already "Access ends" + the date for Cancelled); Cashier's own customer.subscription.updated handler, which sets local ends_at when the payload's cancel_at_period_end is true (vendor/laravel/cashier/src/Http/Controllers/WebhookController.php:174).

  • Produces:

    • SubscriptionStatusMap::toAccessState(string $stripeStatus, bool $cancelAtPeriodEnd = false): ?BillingAccessStateactive or trialing with the flag → Cancelled; everything else as today. The default keeps every existing caller unchanged.
    • In applyAccessState(): $cancelAtPeriodEnd = for subscription events (bool) ($payload['data']['object']['cancel_at_period_end'] ?? false); for invoice events Subscriptions::forTeam($team)?->onGracePeriod() === true (so a mid-period proration invoice paid while cancelling does not flip the team back to active). One computation, one map call.
    • PaymentGateway::cancelAtPeriodEnd(Team $team): voidSubscriptions::liveFor($team)->cancel(); PaymentGateway::resumeSubscription(Team $team): voidSubscriptions::forTeam($team)->resume(). Both throw Stripe's ApiErrorException subclasses raw. Fake: $subscriptionsCancelled[] / $subscriptionsResumed[] ({team, transactionLevel, sequence}), $cancelAtPeriodEndThrows, $resumeSubscriptionThrows; the fake sets the local row's ends_at (= Carbon::now()->addDays(30) when not on trial, else trial_ends_at) on cancel and null on resume, mirroring Cashier's local write.
    • AccountOptions::for(Team $team, User $user): array{canCancel: bool, canResume: bool}canCancel = user is Owner ($user->teamRole($team) === TeamRole::Owner) ∧ self-serve ∧ state ∈ {Active, Trialing} ∧ liveFor() not null ∧ !onGracePeriod(); canResume = Owner ∧ self-serve ∧ state = Cancelled ∧ forTeam()?->onGracePeriod() === true. Suspended is excluded by the state checks.
    • CancelSubscription::handle(Team $team, User $actor): voidabort_unless(AccountOptions::for($team, $actor)['canCancel'], 422, 'Only the owner of an active subscription can cancel it.'); gateway; then BillingEvent::record($team, 'subscription.cancelled', ['ends_at' => <local ends_at ISO>], $actor->id). ResumeSubscription::handle(Team, User) mirrors it with canResume, resumeSubscription(), subscription.resumed.
    • Routes: POST billing/cancelbilling.cancel.store, DELETE billing/cancelbilling.cancel.destroy (resume), both in the verified + :admin group; flash status "Your subscription will end on {date}." / "Your subscription will continue."
    • Page props from the controller: canCancel, canResume. Index.vue: plan card footer — v-if="canCancel" a "Cancel subscription" button that opens an inline panel (data-test="cancel-panel") naming plan.next_charge_at ("Access continues until {date}. Nothing is refunded.") with Confirm/Keep; v-if="plan.state === 'cancelled'" a note "Your subscription ends on {plan.next_charge_at}." (data-test="cancelled-note") and v-if="canResume" a "Keep my subscription" button (router.delete). Processing state disables the buttons.
  • Step 1: Unit tests first — SubscriptionStatusMapTest: ('active', true)Cancelled; ('trialing', true)Cancelled; ('active', false)Active; ('past_due', true)PastDue (the flag only matters for the two live statuses — a past-due team that scheduled a cancellation is still past due); ('canceled', false)Cancelled; every existing row unchanged with the default argument.

  • Step 2: Failing webhook tests — StripeWebhookTest (extend subscriptionEventPayload()'s $objectOverrides with cancel_at_period_end and current_period_end): an active team receiving customer.subscription.updated with status: active, cancel_at_period_end: true, current_period_end: Tcancelled, billing_current_period_ends_at = T, billing_grace_ends_at null, the local subscription row's ends_at set (Cashier's handler did that); the same event with the flag false → back to active, billing_restricted_at null; a cancelled (on grace) team receiving invoice.payment_succeededstill cancelled (mutation check for the invoice-event flag source); a suspended team receiving the flag event → still suspended.

  • Step 3: Failing feature tests — SubscriptionCancellationTest (RefreshDatabase, fake gateway, a self-serve active team with a live subscription built as StripeWebhookTest::withLiveSubscription() does, Owner and Admin members via BillingTestCase::member()):

    • Owner POSTs billing.cancel.store → one cancelAtPeriodEnd() call at the test's transaction baseline, billing_access_state still active (the webhook is what flips it), a subscription.cancelled event with the actor and ends_at, redirect with the flash, and the local row now onGracePeriod().
    • Admin POSTs → 422, no call, no event. Owner of a past_due team → 422, no call. Owner of an enterprise team → 422. A second POST while already on grace → 422, one call total.
    • A trialing team's Owner may cancel; ends_at = the trial end.
    • Gateway throws ApiErrorException → 5xx propagates (or the controller's existing error shape — follow PaymentMethodController), no event recorded (record-after-accept).
    • Resume: after the fake cancel, the webhook (posted through PostsStripeWebhooks) has moved the team to cancelled; Owner DELETEs billing.cancel.destroy → one resumeSubscription() call at baseline, state still cancelled until the flag-false webhook (post it) → active, subscription.resumed event, ends_at null. Admin → 422. A cancelled team whose ends_at has passed (Carbon::setTestNow past it) → 422, no call.
    • billing:apply-access-expiry still restricts a cancelled team whose period end has passed (one assertion; the command exists).
    • AccountOptions::for() is what the page and the actions agree on: TeamBillingPageTest asserts canCancel true for the Owner and false for an Admin on the same team, canResume true for the Owner of a cancelled-on-grace team; the Inertia render (npm run build) shows data-test="cancel-panel" only after the button is clicked — assert the props, not the DOM.
  • Step 4: Run them to confirm they fail for the right reason.

  • Step 5: Implement the map argument (+ docblock: why active/trialing only), the webhook's flag computation (comment: two sources, one rule; why the invoice-event source is onGracePeriod()), RestrictionController passing $subscription->onGracePeriod(), the gateway trio, AccountOptions, the two actions, the controller, the routes, the props, the page and types.

  • Step 6: Run the scoped set + tests/Unit, Pint, lint/format/build, PHPStan, commit. Subject: Billing 7a: cancel at period end, and undo it.


Task 2: 7b — the card and the email

Spec: "Changing the card", "The billing email", "What the customer sees" (payment card, billing email), "Routes". Read them first.

Files:

  • Create: app/Actions/Billing/SetBillingEmail.php, app/Http/Requests/Billing/UpdateBillingEmailRequest.php, app/Http/Controllers/Billing/BillingEmailController.php (update)
  • Modify: app/Support/Billing/RecoveryOptions.php (canChangeCard), app/Http/Controllers/Billing/PaymentMethodController.php (intent()), routes/web/billing.php (POST billing/payment-method/intentbilling.payment-method.intent in the non-verified :admin group beside payment-method.store; PATCH billing/emailbilling.email.update in the verified :admin group), app/Queries/Billing/TeamBillingSummary.php (canEditBillingEmail), resources/js/pages/billing/Index.vue (Change card button + form reveal; inline billing-email editor), resources/js/components/billing/RecoveryControls.vue (only if the secret must be settable after mount — prefer passing a reactive prop), resources/js/types/billing.ts
  • Test: tests/Feature/Billing/RecoveryPathTest.php (extend: canChangeCard truth table alongside shouldOfferCard), tests/Feature/Billing/PaymentMethodIntentTest.php (new), tests/Feature/Billing/BillingEmailTest.php (new), tests/Feature/Billing/TeamBillingPageTest.php (extend)

Interfaces:

  • Consumes: RecoveryOptions::for() (canUpdateCard, canPayNow, shouldOfferCard); PaymentGateway::createSetupIntent(Team): string; UpdatePaymentMethod::handle(Team, string) and billing.payment-method.store; RecoveryControls.vue props (canUpdateCard, canPayNow, setupIntentClientSecret, stripeKey); DunningRecipients::for(Team): list<string>; BillingEvent::record().

  • Produces:

    • RecoveryOptions::for() gains canChangeCard: bool = canUpdateCard && ! shouldOfferCard. Docblock: the two are never both true on a rendered page by construction.
    • PaymentMethodController::intent(Request): JsonResponseabort_unless(RecoveryOptions::for($team)['canUpdateCard'], 403) (the same gate store() uses); returns ['clientSecret' => $gateway->createSetupIntent($team)]. No Inertia render creates a SetupIntent for a canChangeCard team (BillingPageController unchanged).
    • SetBillingEmail::handle(Team $team, ?string $email, User $actor): voidabort_unless($team->billing_type === BillingType::SelfServe && $team->billing_access_state !== BillingAccessState::Suspended, 422, 'Billing email is managed by your account manager.'); normalises to lowercase-trimmed or null; writes billing_email; records billing.email_changed ['from' => ?, 'to' => ?] with the actor. Validation in the request: email ['nullable', 'string', 'email:rfc', 'max:255'].
    • TeamBillingSummary::for() gains canEditBillingEmail = self-serve ∧ not suspended (the same predicate the action uses — put it on RecoveryOptions? No: on a private static in SetBillingEmail exposed as SetBillingEmail::allowedFor(Team): bool, called by both).
    • Page: Index.vue payment card — v-if="canChangeCard && !changingCard" a "Change card" button; on click router-independent fetch/useHttp POST to billing.payment-method.intent (Wayfinder route), store the secret in a ref, set changingCard = true, render RecoveryControls with canUpdateCard: true, canPayNow: false, the fetched secret and stripeKey; a "Keep current card" link hides it again. Plan card — the billing-email dd becomes an inline editor when canEditBillingEmail: value + "Edit" → <input type="email"> + Save/Cancel via useForm patch to billing.email.update; validation error rendered from form.errors.email.
    • Routes as listed; billing.payment-method.intent is NOT on EnsureTeamNotRestricted::ALLOWED_ROUTES (a restricted team uses the recovery page, whose intent the controller already creates on render).
  • Step 1: Failing tests — RecoveryPathTest (extend): a healthy self-serve team with a card and a live subscription → canChangeCard true, shouldOfferCard false; the same team past_duecanChangeCard false, shouldOfferCard true; no card on file → canChangeCard false; enterprise → false; suspended → false; ended subscription with no open invoice → false. One assertion that canChangeCard && shouldOfferCard is never true across those fixtures.

  • Step 2: Failing tests — PaymentMethodIntentTest: Owner/Admin of a healthy team POSTs the intent route → 200 JSON {clientSecret}, one createSetupIntent() call at baseline; a Member → 403 (middleware); an enterprise team → 403 with no call; rendering billing.index for the same healthy team creates no SetupIntent (setupIntentsCreatedFor empty — the 4e/5c rule stays); the existing billing.payment-method.store flow is unchanged (RecoveryPathTest still passes).

  • Step 3: Failing tests — BillingEmailTest: Admin PATCHes [email protected] → column set (lowercased), billing.email_changed with from: null, to: ... and the actor, redirect with flash; PATCH empty → cleared, event to: null; invalid address → 422 with an email error, nothing written; enterprise team → 422, nothing written; a Member → 403; after saving, DunningRecipients::for($team) includes the address exactly once even when it equals an Owner's address in different case (dedupe rule from 5b); TeamBillingPageTest: canEditBillingEmail true for self-serve, false for enterprise.

  • Step 4: Run them to confirm they fail for the right reason.

  • Step 5: Implement canChangeCard, intent(), the route, the page's button and form reveal (prefer a reactive setupIntentClientSecret prop on RecoveryControls over a second form), SetBillingEmail + allowedFor(), the request, the controller, the route, the inline editor, the props and types.

  • Step 6: Run the scoped set, Pint, lint/format/build, PHPStan, commit. Subject: Billing 7b: change card, and a billing email of your own.


Self-review notes

  • Spec coverage: corrections (flag in the map, cancelled → active, Owner-only) → Task 1; cancelling preconditions, record-after-accept, no access-state write → Task 1; resuming and its refusal after period end → Task 1; change card via on-demand intent reusing the recovery form and UpdatePaymentMethod → Task 2; canChangeCard never co-true with shouldOfferCard → Task 2 Step 1; billing email writer, no Stripe sync, enterprise read-only → Task 2; routes, none on the restricted allow-list → both; "does not do" → nothing here refunds, emails, switches cycles, syncs Stripe, or deletes data.
  • Type consistency: toAccessState(string, bool = false) defined in Task 1 and called with two sources in the webhook and one in RestrictionController; AccountOptions::for(Team, User) consumed by both actions and the controller; canChangeCard defined in Task 2 and consumed only there; SetBillingEmail::allowedFor(Team) consumed by the action and TeamBillingSummary.
  • The riskiest thing in this plan is the invoice-event flag source: a cancelled-on-grace team paying a proration invoice must stay cancelled, and the test for it is the mutation check. The second is the fake's local ends_at write: without it, a resume test cannot pass onGracePeriod() — the fake mirrors Cashier's local behaviour deliberately and says so.
  • Deliberately deferred: immediate cancel, refunds, cancellation email, cycle switching, Stripe customer email sync, data deletion — all in the spec's "What increment 7 does not do".