Past-due access, reliable checkout.completed, and complete morph map - #96
Merged
Conversation
A past_due subscription made subscribed() return false (Cashier default), so EnsureAccountReady redirected to /subscribe — which starts a brand-new checkout and creates a second subscription. Past-due users already have a subscription; they only need to update their payment method. Enable Cashier::keepPastDueSubscriptionsActive() so past_due counts as active and users keep navigating. Surface a past-due notice in the sidebar footer linking to the Stripe billing portal (not /subscribe). Both trial modes (subscription trial / generic trial) are unaffected.
A trial-with-card subscription is already subscribed() (status trialing) by the time the webhook lands, so /billing/processing usually mounts already-active and the false->true poll transition the event depended on never happened — only 3 of 66 real subscriptions emitted checkout.completed. Complete the purchase from whichever path runs first (onMounted when already active, or the poll transition), de-duplicated per checkout session via a one-time Cache::add gate on session_id so back-button/refresh can't re-fire.
Add the five automation models to Relation::enforceMorphMap(), document the all-models-must-be-mapped rule in CLAUDE.md, and add MorphMapTest to fail when any app/Models class is missing from the map.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch bundles three related billing/infra fixes.
1. Keep past_due subscribers in-app instead of forcing re-subscribe
Problem: when a subscription goes
past_due, Cashier's default (deactivatePastDue = true) makessubscribed()returnfalse.EnsureAccountReadythen redirects toapp.subscribe, which opens a fresh Stripe Checkout and creates a second subscription. A past-due customer already has one — they just need to update their payment method.Fix:
Cashier::keepPastDueSubscriptionsActive()—past_duecounts as active again, so the middleware lets the user navigate (no forced/subscribe).Account::isPastDue()(false when self-hosted, elsesubscription(...)?->pastDue()).auth.subscriptionPastDue+ TS type.app.billing.portal) — never/subscribe.en,es,pt-BR).Both trial modes verified, neither affected (past_due only exists on a real subscription): card-required → subscription trial (
subscribed()already true); no-card → generic trial (isOnTrial()/onGenericTrial()).2. Fire
checkout.completedreliably for trial-with-card checkoutsProblem (confirmed in PostHog): 66 real
subscription.createdvs only 3checkout.completed. With trial-with-card the subscription istrialing(alreadysubscribed()) by the time the webhook lands, so/billing/processingusually mounts already-active and thefalse→truepoll transition the event depended on never happens.Fix:
onMounted(already active) or the poll transition.Cache::addgate onsession_id(newfromCheckoutprop), so back-button/refresh to the success URL can't re-fire.3. Register all models in the morph map
Relation::enforceMorphMap().CLAUDE.md.tests/Unit/MorphMapTest.phpfails if anyapp/Modelsclass is missing from the map.Tests
BillingControllerTest,TrialMiddlewareAccessTest,AccountTest,MorphMapTestall green; broader billing/account/workspace/usage suites pass — no regressions. Pint clean.Processing.vuetiming logic is covered by reasoning + the backendfromCheckoutone-time test, not a component test.