Merge pull request #96 from trypostit/fix/past-due-no-forced-subscribe
Past-due access, reliable checkout.completed, and complete morph map
This commit is contained in:
commit
c71234337d
15 changed files with 233 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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<Workspace | null>(() => page.props.auth.currentWorkspace as Workspace | null);
|
||||
const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Workspace[]);
|
||||
const subscriptionPastDue = computed<boolean>(() => Boolean(page.props.auth.subscriptionPastDue));
|
||||
|
||||
const mainNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
|
|
@ -208,6 +211,29 @@ const handleCreateWorkspace = () => {
|
|||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<div
|
||||
v-if="subscriptionPastDue"
|
||||
dusk="past-due-notice"
|
||||
class="mx-1 mb-1 rounded-md border-2 border-destructive bg-destructive/10 p-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-destructive">
|
||||
<IconAlertTriangle class="size-4 shrink-0" />
|
||||
<span class="text-sm font-semibold">{{ $t('billing.past_due_notice.title') }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ $t('billing.past_due_notice.description') }}
|
||||
</p>
|
||||
<Button
|
||||
as="a"
|
||||
:href="portal.url()"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="mt-2 w-full"
|
||||
dusk="past-due-cta"
|
||||
>
|
||||
{{ $t('billing.past_due_notice.cta') }}
|
||||
</Button>
|
||||
</div>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
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();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
1
resources/js/types/index.d.ts
vendored
1
resources/js/types/index.d.ts
vendored
|
|
@ -32,6 +32,7 @@ export interface Auth {
|
|||
account: AuthAccount | null;
|
||||
plan: AuthPlan | null;
|
||||
hasActiveSubscription: boolean;
|
||||
subscriptionPastDue: boolean;
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
||||
|
|
|
|||
21
tests/Unit/MorphMapTest.php
Normal file
21
tests/Unit/MorphMapTest.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
test('every Eloquent model is registered in the morph map', function () {
|
||||
$mapped = array_values(Relation::morphMap());
|
||||
|
||||
$missing = collect(glob(app_path('Models/*.php')))
|
||||
->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([]);
|
||||
});
|
||||
Loading…
Reference in a new issue