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
phpis MAMP's 8.2 and too old. Useherd 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, plustests/Unitwhenapp/Supportorapp/Enumschanges. 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.
FakePaymentGatewayrecordsDB::transactionLevel()on every call; tests assert it equals the level the test itself runs at (1 underRefreshDatabase). billing_access_stateis written by the webhook'sapplyAccessState()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);UpdatePaymentMethodis 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_eventsrow. A false event is worse than a late one. PaymentGatewayis the only route to Stripe. Every new method on the interface,StripePaymentGatewayandFakePaymentGatewayin 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_aton 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 existingEnsureTeamMembership::class.':admin'group. - No
.vuedecides a rule. Booleans and dates arrive as props. No new page. Confirmation is an inline panel, notwindow.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 formaton touched.vue/.ts, reverting collateral;npm run buildbefore 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_teamsis an order-dependent flake that passes in isolation. - No deploy notes expected: no migration, no new webhook event (
customer.subscription.updatedalready 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 theverified+:admingroup at ~99),app/Http/Controllers/Billing/BillingPageController.php(spreadAccountOptions::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()(setscancel_at_period_endat Stripe,ends_atlocally = period end or trial end),resume()(clears both; throwsLogicExceptionoff grace),onGracePeriod()(ends_atin 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()'splan.next_charge_at/next_charge_label(already "Access ends" + the date forCancelled); Cashier's owncustomer.subscription.updatedhandler, which sets localends_atwhen the payload'scancel_at_period_endis true (vendor/laravel/cashier/src/Http/Controllers/WebhookController.php:174). -
Produces:
SubscriptionStatusMap::toAccessState(string $stripeStatus, bool $cancelAtPeriodEnd = false): ?BillingAccessState—activeortrialingwith 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 eventsSubscriptions::forTeam($team)?->onGracePeriod() === true(so a mid-period proration invoice paid while cancelling does not flip the team back toactive). One computation, one map call. PaymentGateway::cancelAtPeriodEnd(Team $team): void—Subscriptions::liveFor($team)->cancel();PaymentGateway::resumeSubscription(Team $team): void—Subscriptions::forTeam($team)->resume(). Both throw Stripe'sApiErrorExceptionsubclasses raw. Fake:$subscriptionsCancelled[]/$subscriptionsResumed[]({team, transactionLevel, sequence}),$cancelAtPeriodEndThrows,$resumeSubscriptionThrows; the fake sets the local row'sends_at(=Carbon::now()->addDays(30)when not on trial, elsetrial_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): void—abort_unless(AccountOptions::for($team, $actor)['canCancel'], 422, 'Only the owner of an active subscription can cancel it.'); gateway; thenBillingEvent::record($team, 'subscription.cancelled', ['ends_at' => <local ends_at ISO>], $actor->id).ResumeSubscription::handle(Team, User)mirrors it withcanResume,resumeSubscription(),subscription.resumed.- Routes:
POST billing/cancel→billing.cancel.store,DELETE billing/cancel→billing.cancel.destroy(resume), both in theverified+:admingroup; flashstatus"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") namingplan.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") andv-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(extendsubscriptionEventPayload()'s$objectOverrideswithcancel_at_period_endandcurrent_period_end): anactiveteam receivingcustomer.subscription.updatedwithstatus: active, cancel_at_period_end: true, current_period_end: T→cancelled,billing_current_period_ends_at = T,billing_grace_ends_atnull, the local subscription row'sends_atset (Cashier's handler did that); the same event with the flag false → back toactive,billing_restricted_atnull; acancelled(on grace) team receivinginvoice.payment_succeeded→ stillcancelled(mutation check for the invoice-event flag source); asuspendedteam receiving the flag event → stillsuspended. -
Step 3: Failing feature tests —
SubscriptionCancellationTest(RefreshDatabase, fake gateway, a self-serveactiveteam with a live subscription built asStripeWebhookTest::withLiveSubscription()does, Owner and Admin members viaBillingTestCase::member()):- Owner POSTs
billing.cancel.store→ onecancelAtPeriodEnd()call at the test's transaction baseline,billing_access_statestillactive(the webhook is what flips it), asubscription.cancelledevent with the actor andends_at, redirect with the flash, and the local row nowonGracePeriod(). - Admin POSTs → 422, no call, no event. Owner of a
past_dueteam → 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 — followPaymentMethodController), no event recorded (record-after-accept). - Resume: after the fake cancel, the webhook (posted through
PostsStripeWebhooks) has moved the team tocancelled; Owner DELETEsbilling.cancel.destroy→ oneresumeSubscription()call at baseline, state stillcancelleduntil the flag-false webhook (post it) →active,subscription.resumedevent,ends_atnull. Admin → 422. Acancelledteam whoseends_athas passed (Carbon::setTestNowpast it) → 422, no call. billing:apply-access-expirystill 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:TeamBillingPageTestassertscanCanceltrue for the Owner and false for an Admin on the same team,canResumetrue for the Owner of a cancelled-on-grace team; the Inertia render (npm run build) showsdata-test="cancel-panel"only after the button is clicked — assert the props, not the DOM.
- Owner POSTs
-
Step 4: Run them to confirm they fail for the right reason.
-
Step 5: Implement the map argument (+ docblock: why
active/trialingonly), the webhook's flag computation (comment: two sources, one rule; why the invoice-event source isonGracePeriod()),RestrictionControllerpassing$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/intent→billing.payment-method.intentin the non-verified:admingroup besidepayment-method.store;PATCH billing/email→billing.email.updatein theverified:admingroup),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:canChangeCardtruth table alongsideshouldOfferCard),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)andbilling.payment-method.store;RecoveryControls.vueprops (canUpdateCard,canPayNow,setupIntentClientSecret,stripeKey);DunningRecipients::for(Team): list<string>;BillingEvent::record(). -
Produces:
RecoveryOptions::for()gainscanChangeCard: bool=canUpdateCard && ! shouldOfferCard. Docblock: the two are never both true on a rendered page by construction.PaymentMethodController::intent(Request): JsonResponse—abort_unless(RecoveryOptions::for($team)['canUpdateCard'], 403)(the same gatestore()uses); returns['clientSecret' => $gateway->createSetupIntent($team)]. No Inertia render creates a SetupIntent for acanChangeCardteam (BillingPageControllerunchanged).SetBillingEmail::handle(Team $team, ?string $email, User $actor): void—abort_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; writesbilling_email; recordsbilling.email_changed['from' => ?, 'to' => ?]with the actor. Validation in the request:email['nullable', 'string', 'email:rfc', 'max:255'].TeamBillingSummary::for()gainscanEditBillingEmail= self-serve ∧ not suspended (the same predicate the action uses — put it onRecoveryOptions? No: on a private static inSetBillingEmailexposed asSetBillingEmail::allowedFor(Team): bool, called by both).- Page:
Index.vuepayment card —v-if="canChangeCard && !changingCard"a "Change card" button; on clickrouter-independentfetch/useHttpPOST tobilling.payment-method.intent(Wayfinder route), store the secret in aref, setchangingCard = true, renderRecoveryControlswithcanUpdateCard: true,canPayNow: false, the fetched secret andstripeKey; a "Keep current card" link hides it again. Plan card — the billing-emailddbecomes an inline editor whencanEditBillingEmail: value + "Edit" →<input type="email">+ Save/Cancel viauseFormpatchtobilling.email.update; validation error rendered fromform.errors.email. - Routes as listed;
billing.payment-method.intentis NOT onEnsureTeamNotRestricted::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 →canChangeCardtrue,shouldOfferCardfalse; the same teampast_due→canChangeCardfalse,shouldOfferCardtrue; no card on file →canChangeCardfalse; enterprise → false; suspended → false; ended subscription with no open invoice → false. One assertion thatcanChangeCard && shouldOfferCardis never true across those fixtures. -
Step 2: Failing tests —
PaymentMethodIntentTest: Owner/Admin of a healthy team POSTs the intent route → 200 JSON{clientSecret}, onecreateSetupIntent()call at baseline; a Member → 403 (middleware); an enterprise team → 403 with no call; renderingbilling.indexfor the same healthy team creates no SetupIntent (setupIntentsCreatedForempty — the 4e/5c rule stays); the existingbilling.payment-method.storeflow is unchanged (RecoveryPathTeststill passes). -
Step 3: Failing tests —
BillingEmailTest: Admin PATCHes[email protected]→ column set (lowercased),billing.email_changedwithfrom: null, to: ...and the actor, redirect with flash; PATCH empty → cleared, eventto: null; invalid address → 422 with anemailerror, 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:canEditBillingEmailtrue 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 reactivesetupIntentClientSecretprop onRecoveryControlsover 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 andUpdatePaymentMethod→ Task 2;canChangeCardnever co-true withshouldOfferCard→ 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 inRestrictionController;AccountOptions::for(Team, User)consumed by both actions and the controller;canChangeCarddefined in Task 2 and consumed only there;SetBillingEmail::allowedFor(Team)consumed by the action andTeamBillingSummary. - 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 localends_atwrite: without it, a resume test cannot passonGracePeriod()— 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".