refactor: remove hardcoded plan prices from models and database in favor of localized translation files

This commit is contained in:
Paulo Castellano 2026-05-03 14:03:31 -03:00
parent d39124e90b
commit 01a220a95a
14 changed files with 32 additions and 73 deletions

View file

@ -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);
}
}

View file

@ -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]),

View file

@ -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');

View file

@ -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,

View file

@ -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',

View file

@ -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

View file

@ -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',

View file

@ -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') }}

View file

@ -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>

View file

@ -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)

View file

@ -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();