Merge branch 'main' into feat/ai-image-regenerate
This commit is contained in:
commit
e91e0e4c29
15 changed files with 176 additions and 119 deletions
|
|
@ -22,13 +22,18 @@ public static function execute(array $data, array $utmParameters = []): User
|
|||
{
|
||||
$user = DB::transaction(function () use ($data, $utmParameters): User {
|
||||
$isInviteRegistration = data_get($data, 'is_invite', false);
|
||||
|
||||
$account = Account::create([
|
||||
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
|
||||
$accountAttributes = [
|
||||
'name' => data_get($data, 'name')."'s Account",
|
||||
'billing_email' => data_get($data, 'email'),
|
||||
'plan_id' => Plan::where('slug', Slug::Starter)->value('id'),
|
||||
'trial_ends_at' => now()->addDays(config('cashier.trial_days')),
|
||||
]);
|
||||
];
|
||||
|
||||
if (! $requiresCardForTrial) {
|
||||
$accountAttributes['plan_id'] = Plan::where('slug', Slug::Starter)->value('id');
|
||||
$accountAttributes['trial_ends_at'] = now()->addDays(config('cashier.trial_days', 7));
|
||||
}
|
||||
|
||||
$account = Account::create($accountAttributes);
|
||||
|
||||
$user = User::create(array_merge([
|
||||
'name' => data_get($data, 'name'),
|
||||
|
|
|
|||
|
|
@ -28,8 +28,11 @@ public function subscribe(Request $request): Response|RedirectResponse
|
|||
return redirect()->route('app.billing.index');
|
||||
}
|
||||
|
||||
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
|
||||
|
||||
return Inertia::render('billing/Subscribe', [
|
||||
'plans' => Plan::active()->orderBy('sort')->get(),
|
||||
'trialDays' => $requiresCardForTrial ? config('cashier.trial_days') : null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +67,10 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect
|
|||
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
|
||||
->allowPromotionCodes();
|
||||
|
||||
if ((bool) config('trypost.billing.require_card_for_trial', true)) {
|
||||
$subscription->trialDays(config('cashier.trial_days'));
|
||||
}
|
||||
|
||||
$checkoutSession = $subscription->checkout([
|
||||
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
|
||||
'cancel_url' => route('app.subscribe'),
|
||||
|
|
|
|||
|
|
@ -25,9 +25,10 @@ public function handle(Request $request, Closure $next): Response
|
|||
$account = $user->account;
|
||||
|
||||
if (! config('trypost.self_hosted')) {
|
||||
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
|
||||
$hasAccess = $account && (
|
||||
$account->subscribed(Account::SUBSCRIPTION_NAME)
|
||||
|| $account->isOnTrial()
|
||||
|| (! $requiresCardForTrial && $account->isOnTrial())
|
||||
);
|
||||
|
||||
if (! $hasAccess) {
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ public function hasActiveSubscription(): bool
|
|||
|
||||
public function isOnTrial(): bool
|
||||
{
|
||||
if ($this->onGenericTrial()) {
|
||||
if (! (bool) config('trypost.billing.require_card_for_trial', true) && $this->onGenericTrial()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -104,11 +104,15 @@ public function activeTrialEndsAt(): ?CarbonInterface
|
|||
{
|
||||
$subscription = $this->subscription(self::SUBSCRIPTION_NAME);
|
||||
|
||||
return match (true) {
|
||||
(bool) $subscription?->onTrial() => $subscription->trial_ends_at,
|
||||
$this->onGenericTrial() => $this->trial_ends_at,
|
||||
default => null,
|
||||
};
|
||||
if (! $subscription?->onTrial()) {
|
||||
if (! (bool) config('trypost.billing.require_card_for_trial', true) && $this->onGenericTrial()) {
|
||||
return $this->trial_ends_at;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $subscription->trial_ends_at;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -135,6 +135,6 @@
|
|||
|
|
||||
*/
|
||||
|
||||
'trial_days' => env('CASHIER_TRIAL_DAYS', 7),
|
||||
'trial_days' => env('CASHIER_TRIAL_DAYS', 8),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -16,6 +16,21 @@
|
|||
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Billing
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Control trial behavior for SaaS billing:
|
||||
| - true: require card at checkout to start trial (Stripe trialing)
|
||||
| - false: grant generic trial at signup without card
|
||||
|
|
||||
*/
|
||||
|
||||
'billing' => [
|
||||
'require_card_for_trial' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Media Size Limits
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
'eyebrow' => 'Pricing',
|
||||
'title' => 'Choose the right plan for you',
|
||||
'description' => 'Pick the plan that fits you. Billed monthly or annually.',
|
||||
'trial_info' => ':days-day free trial, then billed automatically',
|
||||
'monthly' => 'Monthly',
|
||||
'yearly' => 'Yearly',
|
||||
'per_month' => 'monthly',
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
'everything_in' => 'Everything in :plan, plus:',
|
||||
'save_months' => '2 months free',
|
||||
'popular' => 'Most popular',
|
||||
'start_trial' => 'Start :days-day free trial',
|
||||
'subscribe_cta' => 'Subscribe',
|
||||
'prices' => [
|
||||
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
'eyebrow' => 'Precios',
|
||||
'title' => 'Elige el plan ideal para ti',
|
||||
'description' => 'Elige el plan que te queda. Facturación mensual o anual.',
|
||||
'trial_info' => 'Prueba gratuita de :days días, luego se cobra automáticamente',
|
||||
'monthly' => 'Mensual',
|
||||
'yearly' => 'Anual',
|
||||
'per_month' => 'mensual',
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
'everything_in' => 'Todo lo de :plan, más:',
|
||||
'save_months' => '2 meses gratis',
|
||||
'popular' => 'Más popular',
|
||||
'start_trial' => 'Comenzar prueba de :days días',
|
||||
'subscribe_cta' => 'Suscribirse',
|
||||
'prices' => [
|
||||
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
'eyebrow' => 'Preços',
|
||||
'title' => 'Escolha o plano ideal pra você',
|
||||
'description' => 'Escolha o plano que combina com você. Cobrança mensal ou anual.',
|
||||
'trial_info' => ':days dias grátis, depois cobrança automática',
|
||||
'monthly' => 'Mensal',
|
||||
'yearly' => 'Anual',
|
||||
'per_month' => 'mensal',
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
'everything_in' => 'Tudo do :plan, mais:',
|
||||
'save_months' => '2 meses grátis',
|
||||
'popular' => 'Mais popular',
|
||||
'start_trial' => 'Iniciar teste de :days dias',
|
||||
'subscribe_cta' => 'Assinar',
|
||||
'prices' => [
|
||||
'starter' => ['monthly' => 'R$ 95', 'yearly_per_month' => 'R$ 79', 'yearly' => 'R$ 950'],
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ interface Highlight {
|
|||
tooltip?: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const { plans, trialDays } = defineProps<{
|
||||
plans: Plan[];
|
||||
trialDays: number | null;
|
||||
}>();
|
||||
|
||||
const isYearly = ref(true);
|
||||
|
|
@ -121,6 +122,9 @@ const planTones: Record<PlanSlug, string> = {
|
|||
<p class="mx-auto max-w-2xl text-balance text-base text-muted-foreground sm:text-lg">
|
||||
{{ $t('billing.subscribe.description') }}
|
||||
</p>
|
||||
<p v-if="trialDays !== null" class="text-sm font-semibold text-foreground/70">
|
||||
{{ trans('billing.subscribe.trial_info', { days: String(trialDays) }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -227,7 +231,12 @@ const planTones: Record<PlanSlug, string> = {
|
|||
]"
|
||||
@click="selectPlan(plan)"
|
||||
>
|
||||
{{ $t('billing.subscribe.subscribe_cta') }}
|
||||
<template v-if="trialDays !== null">
|
||||
{{ trans('billing.subscribe.start_trial', { days: String(trialDays) }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('billing.subscribe.subscribe_cta') }}
|
||||
</template>
|
||||
<IconArrowRight class="size-4" />
|
||||
</button>
|
||||
|
||||
|
|
|
|||
41
tests/Feature/Auth/SignupRequiresCheckoutTest.php
Normal file
41
tests/Feature/Auth/SignupRequiresCheckoutTest.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\User\CreateUser;
|
||||
use Database\Seeders\PlanSeeder;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
config(['trypost.billing.require_card_for_trial' => true]);
|
||||
$this->seed(PlanSeeder::class);
|
||||
});
|
||||
|
||||
test('new signup does not create a trial before checkout', function () {
|
||||
$user = CreateUser::execute([
|
||||
'name' => 'Alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password' => 'password123',
|
||||
'timezone' => 'UTC',
|
||||
'registration_ip' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
expect($user->account->plan_id)->toBeNull();
|
||||
expect($user->account->trial_ends_at)->toBeNull();
|
||||
expect($user->account->stripe_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('new signup creates generic trial when card is not required', function () {
|
||||
config(['trypost.billing.require_card_for_trial' => false]);
|
||||
|
||||
$user = CreateUser::execute([
|
||||
'name' => 'Alice',
|
||||
'email' => 'alice+nocard@example.com',
|
||||
'password' => 'password123',
|
||||
'timezone' => 'UTC',
|
||||
'registration_ip' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
expect($user->account->plan_id)->not->toBeNull();
|
||||
expect($user->account->trial_ends_at)->not->toBeNull();
|
||||
});
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\User\CreateUser;
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Models\Plan;
|
||||
use Carbon\Carbon;
|
||||
use Database\Seeders\PlanSeeder;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
$this->seed(PlanSeeder::class);
|
||||
});
|
||||
|
||||
test('new signup gets a 7-day trial without card', function () {
|
||||
Carbon::setTestNow('2026-05-14 12:00:00');
|
||||
|
||||
$user = CreateUser::execute([
|
||||
'name' => 'Alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password' => 'password123',
|
||||
'timezone' => 'UTC',
|
||||
'registration_ip' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
$starterPlan = Plan::where('slug', Slug::Starter)->firstOrFail();
|
||||
|
||||
expect($user->account->plan_id)->toBe($starterPlan->id);
|
||||
expect($user->account->trial_ends_at?->toDateTimeString())->toBe('2026-05-21 12:00:00');
|
||||
expect($user->account->stripe_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('account during generic trial is recognized as on trial', function () {
|
||||
Carbon::setTestNow('2026-05-14 12:00:00');
|
||||
|
||||
$user = CreateUser::execute([
|
||||
'name' => 'Alice',
|
||||
'email' => 'alice2@example.com',
|
||||
'password' => 'password123',
|
||||
'timezone' => 'UTC',
|
||||
'registration_ip' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
expect($user->account->isOnTrial())->toBeTrue();
|
||||
expect($user->account->onGenericTrial())->toBeTrue();
|
||||
});
|
||||
|
||||
test('account whose generic trial expired is not on trial', function () {
|
||||
Carbon::setTestNow('2026-05-14 12:00:00');
|
||||
|
||||
$user = CreateUser::execute([
|
||||
'name' => 'Alice',
|
||||
'email' => 'alice3@example.com',
|
||||
'password' => 'password123',
|
||||
'timezone' => 'UTC',
|
||||
'registration_ip' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
Carbon::setTestNow('2026-05-21 12:01:00');
|
||||
|
||||
expect($user->account->fresh()->isOnTrial())->toBeFalse();
|
||||
});
|
||||
|
|
@ -9,6 +9,8 @@
|
|||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.billing.require_card_for_trial' => true]);
|
||||
|
||||
$this->account = Account::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
|
|
@ -92,22 +94,6 @@
|
|||
);
|
||||
});
|
||||
|
||||
test('billing index exposes onTrial=true and trialEndsAt for generic-trial-only account', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$endsAt = now()->addDays(7)->startOfSecond();
|
||||
$this->account->update(['trial_ends_at' => $endsAt]);
|
||||
|
||||
$response = $this->actingAs($this->user->fresh())->get(route('app.billing.index'));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('settings/account/Billing', false)
|
||||
->where('hasSubscription', false)
|
||||
->where('onTrial', true)
|
||||
->where('trialEndsAt', $endsAt->toIso8601ZuluString('microsecond'))
|
||||
);
|
||||
});
|
||||
|
||||
test('billing index exposes onTrial=true and trialEndsAt for subscription-trial account', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
|
|
@ -148,14 +134,28 @@
|
|||
);
|
||||
});
|
||||
|
||||
test('subscribe page does not expose trialDays prop anymore', function () {
|
||||
test('subscribe page exposes trialDays prop', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('billing/Subscribe', false)
|
||||
->missing('trialDays')
|
||||
->where('trialDays', config('cashier.trial_days'))
|
||||
);
|
||||
});
|
||||
|
||||
test('subscribe page exposes null trialDays when card is not required', function () {
|
||||
config([
|
||||
'trypost.self_hosted' => false,
|
||||
'trypost.billing.require_card_for_trial' => false,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('billing/Subscribe', false)
|
||||
->where('trialDays', null)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,17 +7,15 @@
|
|||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Carbon\Carbon;
|
||||
use Database\Seeders\PlanSeeder;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
config(['trypost.billing.require_card_for_trial' => true]);
|
||||
$this->seed(PlanSeeder::class);
|
||||
});
|
||||
|
||||
test('user on generic trial can access the app', function () {
|
||||
Carbon::setTestNow('2026-05-14 12:00:00');
|
||||
|
||||
test('user without subscription is redirected to subscribe', function () {
|
||||
$user = CreateUser::execute([
|
||||
'name' => 'Alice',
|
||||
'email' => 'alice@example.com',
|
||||
|
|
@ -35,12 +33,10 @@
|
|||
|
||||
$response = $this->actingAs($user->fresh())->get(route('app.accounts'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertRedirect(route('app.subscribe'));
|
||||
});
|
||||
|
||||
test('user whose trial expired is redirected to subscribe', function () {
|
||||
Carbon::setTestNow('2026-05-14 12:00:00');
|
||||
|
||||
test('user with active subscription can access the app', function () {
|
||||
$user = CreateUser::execute([
|
||||
'name' => 'Alice',
|
||||
'email' => 'alice2@example.com',
|
||||
|
|
@ -56,11 +52,16 @@
|
|||
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
Carbon::setTestNow('2026-05-21 12:01:00');
|
||||
$user->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user->fresh())->get(route('app.accounts'));
|
||||
|
||||
$response->assertRedirect(route('app.subscribe'));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('user on trialing subscription (legacy trial-with-card) can access the app', function () {
|
||||
|
|
@ -90,3 +91,26 @@
|
|||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('user on generic trial can access the app when card is not required', function () {
|
||||
config(['trypost.billing.require_card_for_trial' => false]);
|
||||
|
||||
$user = CreateUser::execute([
|
||||
'name' => 'Alice',
|
||||
'email' => 'alice-generic@example.com',
|
||||
'password' => 'password123',
|
||||
'timezone' => 'UTC',
|
||||
'registration_ip' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
$workspace = Workspace::factory()->create([
|
||||
'account_id' => $user->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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,26 +9,25 @@
|
|||
use Database\Seeders\PlanSeeder;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.billing.require_card_for_trial' => true]);
|
||||
$this->seed(PlanSeeder::class);
|
||||
Carbon::setTestNow('2026-05-14 12:00:00');
|
||||
});
|
||||
|
||||
test('isOnTrial returns true for account on generic trial', function () {
|
||||
$account = Account::factory()->create([
|
||||
'trial_ends_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
expect($account->isOnTrial())->toBeTrue();
|
||||
});
|
||||
|
||||
test('isOnTrial returns false when generic trial has expired and there is no subscription', function () {
|
||||
$account = Account::factory()->create([
|
||||
'trial_ends_at' => now()->subDay(),
|
||||
]);
|
||||
test('isOnTrial ignores generic trial when there is no subscription', function () {
|
||||
$account = Account::factory()->create(['trial_ends_at' => now()->addDays(7)]);
|
||||
|
||||
expect($account->isOnTrial())->toBeFalse();
|
||||
});
|
||||
|
||||
test('isOnTrial includes generic trial when card is not required', function () {
|
||||
config(['trypost.billing.require_card_for_trial' => false]);
|
||||
|
||||
$account = Account::factory()->create(['trial_ends_at' => now()->addDays(7)]);
|
||||
|
||||
expect($account->isOnTrial())->toBeTrue();
|
||||
});
|
||||
|
||||
test('isOnTrial returns false for account without trial or subscription', function () {
|
||||
$account = Account::factory()->create(['trial_ends_at' => null]);
|
||||
|
||||
|
|
@ -61,6 +60,15 @@
|
|||
$endsAt = now()->addDays(7);
|
||||
$account = Account::factory()->create(['trial_ends_at' => $endsAt]);
|
||||
|
||||
expect($account->activeTrialEndsAt())->toBeNull();
|
||||
});
|
||||
|
||||
test('activeTrialEndsAt returns generic trial date when card is not required', function () {
|
||||
config(['trypost.billing.require_card_for_trial' => false]);
|
||||
|
||||
$endsAt = now()->addDays(7);
|
||||
$account = Account::factory()->create(['trial_ends_at' => $endsAt]);
|
||||
|
||||
expect($account->activeTrialEndsAt()?->toDateTimeString())
|
||||
->toBe($endsAt->toDateTimeString());
|
||||
});
|
||||
|
|
@ -83,7 +91,7 @@
|
|||
->toBe($subscriptionEndsAt->toDateTimeString());
|
||||
});
|
||||
|
||||
test('activeTrialEndsAt prefers subscription date over generic when both active', function () {
|
||||
test('activeTrialEndsAt returns subscription date when both generic and subscription trials are present', function () {
|
||||
$genericEndsAt = now()->addDays(7);
|
||||
$subscriptionEndsAt = now()->addDays(14);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue