feat(periods): gate custom month start day behind feature flag

- add CustomMonthStartDay Pennant feature (off by default)
- hide the Start of month setting in account settings unless enabled
- only validate month_start_day when the feature is active so other
  profile updates keep working while the option is hidden
- share the flag to the frontend and cover both enabled/disabled paths
This commit is contained in:
Víctor Falcón 2026-05-29 15:35:27 +02:00
parent 4557ef5065
commit fc2ae4fa3e
6 changed files with 102 additions and 46 deletions

View File

@ -0,0 +1,19 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* @api
*/
class CustomMonthStartDay
{
/**
* Resolve the feature's initial value.
*/
public function resolve(?User $user): bool
{
return false;
}
}

View File

@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Enums\BankingConnectionStatus;
use App\Features\CalculateBalancesOnImport;
use App\Features\CustomMonthStartDay;
use App\Features\TransactionAnalysis;
use App\Models\BankingConnection;
use App\Services\CurrencyOptions;
@ -168,18 +169,21 @@ class HandleInertiaRequests extends Middleware
return [
'cashflow' => true,
'calculateBalancesOnImport' => false,
'customMonthStartDay' => false,
'transactionAnalysis' => false,
];
}
$features = Feature::for($user)->values([
CalculateBalancesOnImport::class,
CustomMonthStartDay::class,
TransactionAnalysis::class,
]);
return [
'cashflow' => true,
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
'customMonthStartDay' => $features[CustomMonthStartDay::class] !== false,
'transactionAnalysis' => $features[TransactionAnalysis::class] !== false,
];
}

View File

@ -2,12 +2,14 @@
namespace App\Http\Requests\Settings;
use App\Features\CustomMonthStartDay;
use App\Models\User;
use App\Services\CurrencyOptions;
use App\Services\UserMonthPeriodService;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Laravel\Pennant\Feature;
class ProfileUpdateRequest extends FormRequest
{
@ -20,7 +22,7 @@ class ProfileUpdateRequest extends FormRequest
{
$currencyOptions = app(CurrencyOptions::class);
return [
$rules = [
'name' => ['required', 'string', 'max:255'],
'email' => [
@ -33,7 +35,12 @@ class ProfileUpdateRequest extends FormRequest
],
'currency_code' => ['required', 'string', 'max:3', Rule::in($currencyOptions->primaryCodes())],
'locale' => ['nullable', 'string', Rule::in(['en', 'es'])],
'month_start_day' => ['required', 'integer', Rule::in(UserMonthPeriodService::ALLOWED_START_DAYS)],
];
if (Feature::for($this->user())->active(CustomMonthStartDay::class)) {
$rules['month_start_day'] = ['required', 'integer', Rule::in(UserMonthPeriodService::ALLOWED_START_DAYS)];
}
return $rules;
}
}

View File

@ -52,7 +52,7 @@ export default function Account({
twoFactorEnabled?: boolean;
notifyOnBankTransactionsSynced?: boolean;
}) {
const { auth, currencies } = usePage<SharedData>().props;
const { auth, currencies, features } = usePage<SharedData>().props;
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
const [notifyBankTransactions, setNotifyBankTransactions] = useState(
@ -218,53 +218,55 @@ export default function Account({
/>
</div>
<div className="grid gap-2">
<Label htmlFor="month_start_day">
{__('Start of month')}
</Label>
{features.customMonthStartDay && (
<div className="grid gap-2">
<Label htmlFor="month_start_day">
{__('Start of month')}
</Label>
<Select
name="month_start_day"
defaultValue={String(
auth.user.month_start_day ?? 1,
)}
required
>
<SelectTrigger className="mt-1 w-full">
<SelectValue
placeholder={__(
'Select start of month',
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{__('Calendar month (1st)')}
</SelectItem>
{[25, 26, 27, 28].map((day) => (
<SelectItem
key={day}
value={String(day)}
>
{__('Day :day', {
day: String(day),
})}
<Select
name="month_start_day"
defaultValue={String(
auth.user.month_start_day ?? 1,
)}
required
>
<SelectTrigger className="mt-1 w-full">
<SelectValue
placeholder={__(
'Select start of month',
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{__('Calendar month (1st)')}
</SelectItem>
))}
</SelectContent>
</Select>
{[25, 26, 27, 28].map((day) => (
<SelectItem
key={day}
value={String(day)}
>
{__('Day :day', {
day: String(day),
})}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{__(
'Cashflow periods and analysis will start on this day.',
)}
</p>
<p className="text-sm text-muted-foreground">
{__(
'Cashflow periods and analysis will start on this day.',
)}
</p>
<InputError
className="mt-2"
message={errors.month_start_day}
/>
</div>
<InputError
className="mt-2"
message={errors.month_start_day}
/>
</div>
)}
{mustVerifyEmail &&
auth.user.email_verified_at === null && (

View File

@ -42,6 +42,7 @@ export interface NavDivider {
export interface Features {
cashflow: boolean;
calculateBalancesOnImport: boolean;
customMonthStartDay: boolean;
transactionAnalysis: boolean;
}

View File

@ -1,6 +1,8 @@
<?php
use App\Features\CustomMonthStartDay;
use App\Models\User;
use Laravel\Pennant\Feature;
test('profile page is displayed', function () {
$user = User::factory()->create();
@ -14,6 +16,7 @@ test('profile page is displayed', function () {
test('profile information can be updated', function () {
$user = User::factory()->create();
Feature::for($user)->activate(CustomMonthStartDay::class);
$response = $this
->actingAs($user)
@ -130,6 +133,7 @@ test('profile rejects bitcoin as primary currency', function () {
test('profile rejects unsupported month start day', function () {
$user = User::factory()->create();
Feature::for($user)->activate(CustomMonthStartDay::class);
$response = $this
->actingAs($user)
@ -143,6 +147,25 @@ test('profile rejects unsupported month start day', function () {
$response->assertSessionHasErrors(['month_start_day']);
});
test('profile ignores month start day when feature is disabled', function () {
$user = User::factory()->create(['month_start_day' => 1]);
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
'currency_code' => 'USD',
'month_start_day' => 25,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('account.edit'));
expect($user->refresh()->month_start_day)->toBe(1);
});
test('email verification status is unchanged when the email address is unchanged', function () {
$user = User::factory()->create();