From 8e334cd6762f3e421febf75ba51cc5429b63318c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 14 Jun 2026 15:46:17 -0300 Subject: [PATCH 1/3] Keep past_due subscribers in-app instead of forcing re-subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Middleware/App/HandleInertiaRequests.php | 1 + app/Models/Account.php | 9 +++ app/Providers/AppServiceProvider.php | 1 + lang/en/billing.php | 6 ++ lang/es/billing.php | 6 ++ lang/pt-BR/billing.php | 6 ++ resources/js/components/AppSidebar.vue | 26 ++++++++ resources/js/types/index.d.ts | 1 + .../Middleware/TrialMiddlewareAccessTest.php | 28 +++++++++ tests/Unit/Models/AccountTest.php | 59 +++++++++++++++++++ 10 files changed, 143 insertions(+) diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index b266b584..5d46fa0c 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -44,6 +44,7 @@ public function share(Request $request): array 'account' => $account ? AuthAccountResource::make($account) : null, 'plan' => $account && $account->plan ? AuthPlanResource::make($account, $account->plan) : null, 'hasActiveSubscription' => $account ? $account->hasActiveSubscription() : false, + 'subscriptionPastDue' => $account ? $account->isPastDue() : false, ], 'usage' => $account && ! $isSelfHosted ? $account->usage() : null, 'features' => $account && ! $isSelfHosted ? $account->featureLimits() : null, diff --git a/app/Models/Account.php b/app/Models/Account.php index f4c04c99..eafefef1 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -91,6 +91,15 @@ public function hasActiveSubscription(): bool return $this->subscribed(self::SUBSCRIPTION_NAME); } + public function isPastDue(): bool + { + if (config('trypost.self_hosted')) { + return false; + } + + return (bool) $this->subscription(self::SUBSCRIPTION_NAME)?->pastDue(); + } + public function isOnTrial(): bool { if (! (bool) config('trypost.billing.require_card_for_trial', true) && $this->onGenericTrial()) { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3888f6ee..b7b2cdf0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -88,6 +88,7 @@ public function boot(): void Cashier::useCustomerModel(Account::class); Cashier::useSubscriptionModel(Subscription::class); Cashier::useSubscriptionItemModel(SubscriptionItem::class); + Cashier::keepPastDueSubscriptionsActive(); Feature::resolveScopeUsing(fn () => auth()->user()?->account); Feature::useMorphMap(); diff --git a/lang/en/billing.php b/lang/en/billing.php index eac2f116..915b0eca 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -3,6 +3,12 @@ return [ 'title' => 'Billing', + 'past_due_notice' => [ + 'title' => 'Payment past due', + 'description' => 'Update your payment method to keep your subscription active.', + 'cta' => 'Update payment', + ], + 'upgrade_dialog' => [ 'title' => 'Upgrade your plan', 'description' => 'Pick a plan that fits your needs.', diff --git a/lang/es/billing.php b/lang/es/billing.php index f077718d..28f6b422 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -3,6 +3,12 @@ return [ 'title' => 'Facturación', + 'past_due_notice' => [ + 'title' => 'Pago vencido', + 'description' => 'Actualiza tu método de pago para mantener tu suscripción activa.', + 'cta' => 'Actualizar pago', + ], + 'upgrade_dialog' => [ 'title' => 'Actualiza tu plan', 'description' => 'Elige un plan que se adapte a tus necesidades.', diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index 6a74b23d..618a9672 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -3,6 +3,12 @@ return [ 'title' => 'Faturamento', + 'past_due_notice' => [ + 'title' => 'Pagamento em atraso', + 'description' => 'Atualize sua forma de pagamento para manter sua assinatura ativa.', + 'cta' => 'Atualizar pagamento', + ], + 'upgrade_dialog' => [ 'title' => 'Faça upgrade do seu plano', 'description' => 'Escolha um plano que se encaixe nas suas necessidades.', diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 6c7ba6ee..e48ce749 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -2,6 +2,7 @@ import { Link, router, usePage } from '@inertiajs/vue3'; import { IconAffiliate, + IconAlertTriangle, IconBolt, IconCalendar, IconChartBar, @@ -45,6 +46,7 @@ import { useActiveUrl } from '@/composables/useActiveUrl'; import { useFeatureAccess } from '@/composables/useFeatureAccess'; import { useUpgradeDialog } from '@/composables/useUpgradeDialog'; import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app'; +import { portal } from '@/routes/app/billing'; import { index as assets } from '@/routes/app/assets'; import { index as automations } from '@/routes/app/automations'; import { index as labels } from '@/routes/app/labels'; @@ -61,6 +63,7 @@ interface Workspace { const page = usePage(); const currentWorkspace = computed(() => page.props.auth.currentWorkspace as Workspace | null); const workspaces = computed(() => page.props.auth.workspaces as Workspace[]); +const subscriptionPastDue = computed(() => Boolean(page.props.auth.subscriptionPastDue)); const mainNavItems = computed(() => [ { @@ -208,6 +211,29 @@ const handleCreateWorkspace = () => { +
+
+ + {{ $t('billing.past_due_notice.title') }} +
+

+ {{ $t('billing.past_due_notice.description') }} +

+ +
assertOk(); }); +test('user with past_due subscription can access the app instead of being forced to subscribe', function () { + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $user = User::factory()->create(['account_id' => $account->id]); + $account->update(['owner_id' => $user->id]); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'past_due', + 'stripe_price' => 'price_123', + ]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $response = $this->actingAs($user->fresh())->get(route('app.accounts')); + + $response->assertOk(); + $response->assertSessionMissing('errors'); +}); + test('user on generic trial can access the app when card is not required', function () { config(['trypost.billing.require_card_for_trial' => false]); diff --git a/tests/Unit/Models/AccountTest.php b/tests/Unit/Models/AccountTest.php index 4d4e416c..09288f69 100644 --- a/tests/Unit/Models/AccountTest.php +++ b/tests/Unit/Models/AccountTest.php @@ -14,6 +14,65 @@ Carbon::setTestNow('2026-05-14 12:00:00'); }); +test('isPastDue returns false without a subscription', function () { + config(['trypost.self_hosted' => false]); + + $account = Account::factory()->create(['trial_ends_at' => null]); + + expect($account->isPastDue())->toBeFalse(); +}); + +test('isPastDue returns true for a past_due subscription', function () { + config(['trypost.self_hosted' => false]); + + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'past_due', + 'stripe_price' => 'price_123', + ]); + + expect($account->isPastDue())->toBeTrue(); +}); + +test('isPastDue returns false for an active subscription', function () { + config(['trypost.self_hosted' => false]); + + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); + + expect($account->isPastDue())->toBeFalse(); +}); + +test('isPastDue returns false when self-hosted', function () { + config(['trypost.self_hosted' => true]); + + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'past_due', + 'stripe_price' => 'price_123', + ]); + + expect($account->isPastDue())->toBeFalse(); +}); + test('isOnTrial ignores generic trial when there is no subscription', function () { $account = Account::factory()->create(['trial_ends_at' => now()->addDays(7)]); From f223cf14cecc51d23343389cb88e1b796f8f362c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 14 Jun 2026 16:12:49 -0300 Subject: [PATCH 2/3] Fire checkout.completed reliably for trial-with-card checkouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Controllers/App/BillingController.php | 11 +++++- resources/js/pages/billing/Processing.vue | 39 ++++++++++++------- tests/Feature/BillingControllerTest.php | 18 +++++++++ 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 3edfbd03..3ea14e0d 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -8,6 +8,7 @@ use App\Models\Plan; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Gate; use Inertia\Inertia; use Inertia\Response; @@ -88,9 +89,17 @@ public function processing(Request $request): Response|RedirectResponse $account = $request->user()->account; $sessionId = $request->query('session_id'); + // Consume the checkout session once: `fromCheckout` is true only the first + // time this session_id is seen, so a back-button/refresh to the success URL + // can't re-fire `checkout.completed`. `Cache::add` is atomic — it returns + // true only when the key didn't exist yet. + $fromCheckout = is_string($sessionId) && $sessionId !== '' + && Cache::add("checkout_tracked:{$sessionId}", true, now()->addDay()); + return Inertia::render('billing/Processing', [ 'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME), - 'conversion' => is_string($sessionId) && $sessionId !== '' && $account?->stripe_id + 'fromCheckout' => $fromCheckout, + 'conversion' => $fromCheckout && $account?->stripe_id ? fn () => $this->buildConversionData($account, $sessionId) : null, ]); diff --git a/resources/js/pages/billing/Processing.vue b/resources/js/pages/billing/Processing.vue index 878bde10..5220708f 100644 --- a/resources/js/pages/billing/Processing.vue +++ b/resources/js/pages/billing/Processing.vue @@ -1,7 +1,7 @@ diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php index 55fafc56..ec6d7b0a 100644 --- a/tests/Feature/BillingControllerTest.php +++ b/tests/Feature/BillingControllerTest.php @@ -183,10 +183,28 @@ $response->assertInertia(fn ($page) => $page ->component('billing/Processing', false) ->has('subscriptionActive') + ->where('fromCheckout', false) ->where('conversion', null) ); }); +test('billing processing exposes fromCheckout=true only the first time a session_id is seen', function () { + config(['trypost.self_hosted' => false]); + + $sessionId = 'cs_test_'.fake()->uuid(); + + $first = $this->actingAs($this->user) + ->get(route('app.billing.processing', ['session_id' => $sessionId])); + $first->assertOk(); + $first->assertInertia(fn ($page) => $page->where('fromCheckout', true)); + + // A back-button / refresh to the same success URL must not re-fire the event. + $second = $this->actingAs($this->user) + ->get(route('app.billing.processing', ['session_id' => $sessionId])); + $second->assertOk(); + $second->assertInertia(fn ($page) => $page->where('fromCheckout', false)); +}); + test('billing processing exposes null conversion when session_id query param is missing', function () { config(['trypost.self_hosted' => false]); From 65116de8591b611e317b971ae0eb342d09381de6 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 14 Jun 2026 16:12:49 -0300 Subject: [PATCH 3/3] Register all models in the morph map + lock it with a test 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. --- CLAUDE.md | 6 ++++++ app/Providers/AppServiceProvider.php | 10 ++++++++++ tests/Unit/MorphMapTest.php | 21 +++++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 tests/Unit/MorphMapTest.php diff --git a/CLAUDE.md b/CLAUDE.md index ec414276..0c066141 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -297,6 +297,12 @@ ## Array Data Access - Example: `data_get($data, 'name')` instead of `$data['name']`. - Use the third parameter for fallback values: `data_get($data, 'username', $sender->username)` instead of `$data['username'] ?? $sender->username`. +## Eloquent Models & Morph Map + +- EVERY Eloquent model in `app/Models` MUST be registered in `Relation::enforceMorphMap([...])` inside `AppServiceProvider::configureMorphMap()`, keyed by a camelCase alias (e.g. `'postPlatform' => PostPlatform::class`). +- When you add a new model, add it to the morph map in the same change. `tests/Unit/MorphMapTest.php` fails if any model is missing. +- The alias is persisted in polymorphic columns, so never rename or remove an existing alias for a model that has stored rows. + ## Imports - NEVER use inline class references (e.g., `\DB::listen`, `\Str::uuid()`). ALWAYS import classes at the top of the file with a `use` statement. diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b7b2cdf0..8d5ed7d9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,11 @@ use App\Models\AccessToken; use App\Models\Account; use App\Models\AiUsageLog; +use App\Models\Automation; +use App\Models\AutomationNodeRun; +use App\Models\AutomationNodeState; +use App\Models\AutomationRun; +use App\Models\AutomationTriggerItem; use App\Models\Invite; use App\Models\Media; use App\Models\Notification; @@ -114,6 +119,11 @@ protected function configureMorphMap(): void 'accessToken' => AccessToken::class, 'account' => Account::class, 'aiUsageLog' => AiUsageLog::class, + 'automation' => Automation::class, + 'automationNodeRun' => AutomationNodeRun::class, + 'automationNodeState' => AutomationNodeState::class, + 'automationRun' => AutomationRun::class, + 'automationTriggerItem' => AutomationTriggerItem::class, 'invite' => Invite::class, 'media' => Media::class, 'notification' => Notification::class, diff --git a/tests/Unit/MorphMapTest.php b/tests/Unit/MorphMapTest.php new file mode 100644 index 00000000..859f5392 --- /dev/null +++ b/tests/Unit/MorphMapTest.php @@ -0,0 +1,21 @@ +map(fn (string $file): string => 'App\\Models\\'.pathinfo($file, PATHINFO_FILENAME)) + ->filter(fn (string $class): bool => class_exists($class) + && is_subclass_of($class, Model::class) + && ! (new ReflectionClass($class))->isAbstract()) + ->reject(fn (string $class): bool => in_array($class, $mapped, true)) + ->values() + ->all(); + + expect($missing)->toBe([]); +});