refactor: remove hardcoded plan prices from models and database in favor of localized translation files
This commit is contained in:
parent
d39124e90b
commit
01a220a95a
14 changed files with 32 additions and 73 deletions
|
|
@ -22,8 +22,6 @@ class Plan extends Model
|
|||
'name',
|
||||
'stripe_monthly_price_id',
|
||||
'stripe_yearly_price_id',
|
||||
'monthly_price',
|
||||
'yearly_price',
|
||||
'social_account_limit',
|
||||
'member_limit',
|
||||
'workspace_limit',
|
||||
|
|
@ -38,8 +36,6 @@ protected function casts(): array
|
|||
return [
|
||||
'slug' => Slug::class,
|
||||
'is_archived' => 'boolean',
|
||||
'monthly_price' => 'integer',
|
||||
'yearly_price' => 'integer',
|
||||
'social_account_limit' => 'integer',
|
||||
'member_limit' => 'integer',
|
||||
'workspace_limit' => 'integer',
|
||||
|
|
@ -58,14 +54,4 @@ public function scopeActive(Builder $query): Builder
|
|||
{
|
||||
return $query->where('is_archived', false);
|
||||
}
|
||||
|
||||
public function formattedMonthlyPrice(): string
|
||||
{
|
||||
return '$'.number_format($this->monthly_price / 100, 0);
|
||||
}
|
||||
|
||||
public function formattedYearlyPrice(): string
|
||||
{
|
||||
return '$'.number_format($this->yearly_price / 100, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ public function definition(): array
|
|||
'name' => fake()->word(),
|
||||
'stripe_monthly_price_id' => null,
|
||||
'stripe_yearly_price_id' => null,
|
||||
'monthly_price' => fake()->randomElement([900, 1900, 3900, 7900]),
|
||||
'yearly_price' => fake()->randomElement([9000, 19000, 39000, 79000]),
|
||||
'social_account_limit' => fake()->randomElement([3, 10, 25, 100]),
|
||||
'member_limit' => fake()->randomElement([1, 3, 10, 50]),
|
||||
'workspace_limit' => fake()->randomElement([1, 3, 10, 50]),
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ public function up(): void
|
|||
$table->string('name');
|
||||
$table->string('stripe_monthly_price_id')->nullable();
|
||||
$table->string('stripe_yearly_price_id')->nullable();
|
||||
$table->integer('monthly_price');
|
||||
$table->integer('yearly_price');
|
||||
$table->integer('social_account_limit');
|
||||
$table->integer('member_limit');
|
||||
$table->integer('workspace_limit');
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ public function run(): void
|
|||
'name' => 'Starter',
|
||||
'stripe_monthly_price_id' => env('STRIPE_STARTER_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_STARTER_YEARLY'),
|
||||
'monthly_price' => 1900,
|
||||
'yearly_price' => 19000,
|
||||
'social_account_limit' => 5,
|
||||
'member_limit' => 1,
|
||||
'workspace_limit' => 1,
|
||||
|
|
@ -35,8 +33,6 @@ public function run(): void
|
|||
'name' => 'Plus',
|
||||
'stripe_monthly_price_id' => env('STRIPE_PLUS_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_PLUS_YEARLY'),
|
||||
'monthly_price' => 2900,
|
||||
'yearly_price' => 29000,
|
||||
'social_account_limit' => 10,
|
||||
'member_limit' => 5,
|
||||
'workspace_limit' => 5,
|
||||
|
|
@ -49,8 +45,6 @@ public function run(): void
|
|||
'name' => 'Pro',
|
||||
'stripe_monthly_price_id' => env('STRIPE_PRO_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_PRO_YEARLY'),
|
||||
'monthly_price' => 4900,
|
||||
'yearly_price' => 49000,
|
||||
'social_account_limit' => 30,
|
||||
'member_limit' => 15,
|
||||
'workspace_limit' => 15,
|
||||
|
|
@ -63,8 +57,6 @@ public function run(): void
|
|||
'name' => 'Max',
|
||||
'stripe_monthly_price_id' => env('STRIPE_MAX_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_MAX_YEARLY'),
|
||||
'monthly_price' => 9900,
|
||||
'yearly_price' => 99000,
|
||||
'social_account_limit' => 100,
|
||||
'member_limit' => 20,
|
||||
'workspace_limit' => 50,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
'monthly' => 'Monthly',
|
||||
'yearly' => 'Yearly',
|
||||
'per_month' => 'mo',
|
||||
'per_year' => 'yr',
|
||||
'billed_monthly' => 'billed monthly',
|
||||
'billed_yearly' => 'billed yearly',
|
||||
'save_months' => '2 months free',
|
||||
|
|
@ -18,6 +19,12 @@
|
|||
'start_trial' => 'Start :days-day free trial',
|
||||
'card_required' => 'Credit card required to start your trial.',
|
||||
'cancel_anytime' => 'Cancel anytime before your trial ends — no charge.',
|
||||
'prices' => [
|
||||
'starter' => ['monthly' => '$19', 'yearly' => '$190'],
|
||||
'plus' => ['monthly' => '$29', 'yearly' => '$290'],
|
||||
'pro' => ['monthly' => '$49', 'yearly' => '$490'],
|
||||
'max' => ['monthly' => '$99', 'yearly' => '$990'],
|
||||
],
|
||||
'features' => [
|
||||
'social_accounts' => ':count social accounts',
|
||||
'workspaces' => ':count workspaces',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
'monthly' => 'Mensual',
|
||||
'yearly' => 'Anual',
|
||||
'per_month' => 'mes',
|
||||
'per_year' => 'año',
|
||||
'billed_monthly' => 'facturado mensualmente',
|
||||
'billed_yearly' => 'facturado anualmente',
|
||||
'save_months' => '2 meses gratis',
|
||||
|
|
@ -19,6 +20,12 @@
|
|||
'start_trial' => 'Comenzar prueba de :days días',
|
||||
'card_required' => 'Tarjeta de crédito requerida para iniciar tu prueba.',
|
||||
'cancel_anytime' => 'Cancela antes que termine tu prueba — sin cargo.',
|
||||
'prices' => [
|
||||
'starter' => ['monthly' => '$19', 'yearly' => '$190'],
|
||||
'plus' => ['monthly' => '$29', 'yearly' => '$290'],
|
||||
'pro' => ['monthly' => '$49', 'yearly' => '$490'],
|
||||
'max' => ['monthly' => '$99', 'yearly' => '$990'],
|
||||
],
|
||||
'features' => [
|
||||
'social_accounts' => ':count cuentas sociales',
|
||||
'workspaces' => ':count workspaces',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -12,6 +12,7 @@
|
|||
'monthly' => 'Mensal',
|
||||
'yearly' => 'Anual',
|
||||
'per_month' => 'mês',
|
||||
'per_year' => 'ano',
|
||||
'billed_monthly' => 'cobrança mensal',
|
||||
'billed_yearly' => 'cobrança anual',
|
||||
'save_months' => '2 meses grátis',
|
||||
|
|
@ -19,6 +20,12 @@
|
|||
'start_trial' => 'Iniciar teste de :days dias',
|
||||
'card_required' => 'Cartão de crédito necessário para iniciar o teste.',
|
||||
'cancel_anytime' => 'Cancele antes do fim do teste — sem cobrança.',
|
||||
'prices' => [
|
||||
'starter' => ['monthly' => 'R$ 95', 'yearly' => 'R$ 950'],
|
||||
'plus' => ['monthly' => 'R$ 145', 'yearly' => 'R$ 1450'],
|
||||
'pro' => ['monthly' => 'R$ 245', 'yearly' => 'R$ 2450'],
|
||||
'max' => ['monthly' => 'R$ 495', 'yearly' => 'R$ 4950'],
|
||||
],
|
||||
'features' => [
|
||||
'social_accounts' => ':count contas sociais',
|
||||
'workspaces' => ':count workspaces',
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ interface Plan {
|
|||
name: string;
|
||||
stripe_monthly_price_id: string | null;
|
||||
stripe_yearly_price_id: string | null;
|
||||
monthly_price: number;
|
||||
yearly_price: number;
|
||||
social_account_limit: number;
|
||||
member_limit: number;
|
||||
workspace_limit: number;
|
||||
|
|
@ -32,22 +30,9 @@ const props = defineProps<{
|
|||
const isYearly = ref(false);
|
||||
const processing = ref<string | null>(null);
|
||||
|
||||
const formatPrice = (cents: number): string => {
|
||||
return '$' + (cents / 100).toFixed(0);
|
||||
};
|
||||
|
||||
const getPrice = (plan: Plan): string => {
|
||||
return formatPrice(isYearly.value ? plan.yearly_price / 12 : plan.monthly_price);
|
||||
};
|
||||
|
||||
const getTotalPrice = (plan: Plan): string => {
|
||||
return formatPrice(isYearly.value ? plan.yearly_price : plan.monthly_price);
|
||||
};
|
||||
|
||||
const getSavings = (plan: Plan): number => {
|
||||
const monthlyTotal = plan.monthly_price * 12;
|
||||
const yearlyTotal = plan.yearly_price;
|
||||
return Math.round(((monthlyTotal - yearlyTotal) / monthlyTotal) * 100);
|
||||
const key = `billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly' : 'monthly'}`;
|
||||
return trans(key);
|
||||
};
|
||||
|
||||
const formatRetention = (days: number): string => {
|
||||
|
|
@ -138,7 +123,7 @@ const isPopular = (plan: Plan): boolean => {
|
|||
<h3 class="text-xl font-semibold">{{ plan.name }}</h3>
|
||||
<div class="mt-3 flex items-baseline gap-1">
|
||||
<span class="text-4xl font-bold tracking-tight">{{ getPrice(plan) }}</span>
|
||||
<span class="text-muted-foreground">/{{ $t('billing.subscribe.per_month') }}</span>
|
||||
<span class="text-muted-foreground">/{{ isYearly ? $t('billing.subscribe.per_year') : $t('billing.subscribe.per_month') }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { IconDownload, IconFileText } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -9,8 +10,6 @@ import { portal } from '@/routes/app/billing';
|
|||
interface Plan {
|
||||
name: string;
|
||||
slug: string;
|
||||
monthly_price: number;
|
||||
yearly_price: number;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
|
|
@ -44,9 +43,9 @@ const props = defineProps<{
|
|||
defaultPaymentMethod: PaymentMethod | null;
|
||||
}>();
|
||||
|
||||
const formatPrice = (cents: number): string => {
|
||||
if (cents === 0) return 'Free';
|
||||
return '$' + (cents / 100).toFixed(0);
|
||||
const monthlyPrice = (slug: string | undefined): string => {
|
||||
if (! slug) return 'Free';
|
||||
return trans(`billing.subscribe.prices.${slug}.monthly`);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -86,7 +85,7 @@ const formatPrice = (cents: number): string => {
|
|||
<div class="flex items-center gap-3 py-3">
|
||||
<span class="text-sm">{{ $t('billing.plan.price') }}</span>
|
||||
<span class="ml-auto text-sm font-medium tabular-nums">
|
||||
{{ formatPrice(plan?.monthly_price ?? 0) }}/{{ $t('billing.plan.month') }}
|
||||
{{ monthlyPrice(plan?.slug) }}/{{ $t('billing.plan.month') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@
|
|||
$starter = Plan::where('slug', Slug::Starter)->first();
|
||||
|
||||
expect($starter->name)->toBe('Starter')
|
||||
->and($starter->monthly_price)->toBe(1900)
|
||||
->and($starter->yearly_price)->toBe(19000)
|
||||
->and($starter->social_account_limit)->toBe(5)
|
||||
->and($starter->member_limit)->toBe(1)
|
||||
->and($starter->workspace_limit)->toBe(1)
|
||||
|
|
@ -26,8 +24,6 @@
|
|||
$max = Plan::where('slug', Slug::Max)->first();
|
||||
|
||||
expect($max->name)->toBe('Max')
|
||||
->and($max->monthly_price)->toBe(9900)
|
||||
->and($max->yearly_price)->toBe(99000)
|
||||
->and($max->social_account_limit)->toBe(100)
|
||||
->and($max->member_limit)->toBe(20)
|
||||
->and($max->workspace_limit)->toBe(50)
|
||||
|
|
|
|||
|
|
@ -11,15 +11,11 @@
|
|||
$plan = Plan::factory()->create([
|
||||
'slug' => Slug::Pro,
|
||||
'name' => 'Pro',
|
||||
'monthly_price' => 3900,
|
||||
'yearly_price' => 39000,
|
||||
]);
|
||||
|
||||
expect($plan)->toBeInstanceOf(Plan::class)
|
||||
->and($plan->slug)->toBe(Slug::Pro)
|
||||
->and($plan->name)->toBe('Pro')
|
||||
->and($plan->monthly_price)->toBe(3900)
|
||||
->and($plan->yearly_price)->toBe(39000)
|
||||
->and($plan->is_archived)->toBeFalse();
|
||||
});
|
||||
|
||||
|
|
@ -40,18 +36,6 @@
|
|||
expect(Plan::active()->count())->toBe($activeBefore - 1);
|
||||
});
|
||||
|
||||
test('formatted monthly price returns dollar format', function () {
|
||||
$plan = Plan::where('slug', Slug::Starter)->first();
|
||||
|
||||
expect($plan->formattedMonthlyPrice())->toBe('$19');
|
||||
});
|
||||
|
||||
test('formatted yearly price returns dollar format', function () {
|
||||
$plan = Plan::where('slug', Slug::Starter)->first();
|
||||
|
||||
expect($plan->formattedYearlyPrice())->toBe('$190');
|
||||
});
|
||||
|
||||
test('integer fields are cast correctly', function () {
|
||||
$plan = Plan::where('slug', Slug::Starter)->first();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue