Skip to content

Past-due access, reliable checkout.completed, and complete morph map - #96

Merged
paulocastellano merged 3 commits into
mainfrom
fix/past-due-no-forced-subscribe
Jun 14, 2026
Merged

Past-due access, reliable checkout.completed, and complete morph map#96
paulocastellano merged 3 commits into
mainfrom
fix/past-due-no-forced-subscribe

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

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) makes subscribed() return false. EnsureAccountReady then redirects to app.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_due counts as active again, so the middleware lets the user navigate (no forced /subscribe).
  • Account::isPastDue() (false when self-hosted, else subscription(...)?->pastDue()).
  • Shared Inertia prop auth.subscriptionPastDue + TS type.
  • Sidebar-footer notice (red) with a CTA to the Stripe billing portal (app.billing.portal) — never /subscribe.
  • i18n (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.completed reliably for trial-with-card checkouts

Problem (confirmed in PostHog): 66 real subscription.created vs only 3 checkout.completed. With trial-with-card the subscription is trialing (already subscribed()) by the time the webhook lands, so /billing/processing usually mounts already-active and the false→true poll transition the event depended on never happens.

Fix:

  • Complete the purchase from whichever path runs first — onMounted (already active) or the poll transition.
  • De-duplicated per checkout session via a one-time Cache::add gate on session_id (new fromCheckout prop), so back-button/refresh to the success URL can't re-fire.

3. Register all models in the morph map

  • Added the five automation models to Relation::enforceMorphMap().
  • Documented the "every model must be mapped" rule in CLAUDE.md.
  • tests/Unit/MorphMapTest.php fails if any app/Models class is missing from the map.

Tests

  • BillingControllerTest, TrialMiddlewareAccessTest, AccountTest, MorphMapTest all green; broader billing/account/workspace/usage suites pass — no regressions. Pint clean.
  • Note: no JS test runner in the project, so the Processing.vue timing logic is covered by reasoning + the backend fromCheckout one-time test, not a component test.

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.
@paulocastellano paulocastellano changed the title Keep past_due subscribers in-app instead of forcing re-subscribe Past-due access, reliable checkout.completed, and complete morph map Jun 14, 2026
@paulocastellano
paulocastellano merged commit c712343 into main Jun 14, 2026
2 checks passed
@paulocastellano
paulocastellano deleted the fix/past-due-no-forced-subscribe branch June 14, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant