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/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/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..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; @@ -88,6 +93,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(); @@ -113,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/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') }} +

+ +
import { Head, router, usePage, usePoll } from '@inertiajs/vue3'; import { IconLoader2 } from '@tabler/icons-vue'; -import { onMounted, watch } from 'vue'; +import { onMounted, ref, watch } from 'vue'; import { useTracking } from '@/composables/useTracking'; import { home } from '@/routes/app'; @@ -9,6 +9,7 @@ import type { Auth } from '@/types'; const props = defineProps<{ subscriptionActive: boolean; + fromCheckout: boolean; conversion?: { value: number; currency: string; transaction_id: string } | null; }>(); @@ -24,20 +25,21 @@ const { stop } = usePoll(2000, { const { trackPurchase } = useTracking(); +const tracked = ref(false); + const goHome = () => router.visit(home.url()); -// `watch` (without `immediate`) only fires on transition false → true, which -// is exactly the purchase moment. The `onMounted` fallback covers the case -// where the user lands here with an already-active subscription (back button, -// refresh after the redirect) — we just bounce them home, no extra event. -watch( - () => props.subscriptionActive, - (active) => { - if (! active) { - return; - } +// Fires `checkout.completed` exactly once for a real checkout. A trial-with-card +// subscription is already `subscribed()` (status `trialing`) by the time the +// webhook lands, so the user frequently reaches this page already active — the +// false → true poll transition never happens. We therefore complete the purchase +// from whichever path runs first (immediate active state or poll transition), +// gated on `fromCheckout` so back-button/refresh visits don't over-count. +const completePurchase = () => { + stop(); - stop(); + if (! tracked.value && props.fromCheckout) { + tracked.value = true; const plan = (page.props.auth as Auth | undefined)?.plan; if (plan) { @@ -49,14 +51,23 @@ watch( props.conversion ?? null, ); } + } - goHome(); + goHome(); +}; + +watch( + () => props.subscriptionActive, + (active) => { + if (active) { + completePurchase(); + } }, ); onMounted(() => { if (props.subscriptionActive) { - goHome(); + completePurchase(); } }); diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 0e23d6ac..b1bf5001 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -32,6 +32,7 @@ export interface Auth { account: AuthAccount | null; plan: AuthPlan | null; hasActiveSubscription: boolean; + subscriptionPastDue: boolean; } export interface Usage { 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]); diff --git a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php index 3bc6cf7b..4a552a0b 100644 --- a/tests/Feature/Middleware/TrialMiddlewareAccessTest.php +++ b/tests/Feature/Middleware/TrialMiddlewareAccessTest.php @@ -92,6 +92,34 @@ $response->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)]); 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([]); +});