feat(periods): add custom month start day

Allow users to configure the day their monthly reporting period starts so dashboard and cashflow ranges match their budget cycle.
This commit is contained in:
Víctor Falcón 2026-05-28 09:43:07 +02:00
parent 9c3c4d573e
commit 2762acb240
17 changed files with 453 additions and 121 deletions

View File

@ -7,9 +7,11 @@ use App\Enums\CategoryType;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use App\Services\CategoryTree;
use App\Services\ExchangeRateService;
use App\Services\PeriodComparator;
use App\Services\UserMonthPeriodService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -20,6 +22,7 @@ class CashflowAnalyticsController extends Controller
public function __construct(
private ExchangeRateService $exchangeRateService,
private CategoryTree $tree,
private UserMonthPeriodService $userMonthPeriodService,
) {}
public function summary(Request $request): JsonResponse
@ -78,17 +81,19 @@ class CashflowAnalyticsController extends Controller
$user = $request->user();
if (isset($validated['from'], $validated['to'])) {
$start = Carbon::parse($validated['from'])->startOfMonth();
$end = Carbon::parse($validated['to'])->endOfMonth();
$start = Carbon::parse($validated['from'])->startOfDay();
$end = Carbon::parse($validated['to'])->startOfDay();
} else {
$months = $validated['months'] ?? 12;
$end = isset($validated['to'])
? Carbon::parse($validated['to'])->endOfMonth()
: Carbon::now()->endOfMonth();
$start = $end->copy()->subMonthsNoOverflow($months - 1)->startOfMonth();
$endPeriod = $this->userMonthPeriodService->monthContaining(
$user,
isset($validated['to']) ? Carbon::parse($validated['to']) : Carbon::now(),
);
$end = $endPeriod['end_inclusive'];
$start = $endPeriod['from']->copy()->subMonthsNoOverflow($months - 1);
}
$monthlyTotals = $this->getMonthlyTrendTotals($user->id, $user->currency_code, $start, $end);
$monthlyTotals = $this->getMonthlyTrendTotals($user->id, $user->currency_code, $start, $end, $user);
$data = [];
$current = $start->copy();
@ -326,7 +331,7 @@ class CashflowAnalyticsController extends Controller
return $categorized;
}
private function getMonthlyTrendTotals(string $userId, string $userCurrency, Carbon $from, Carbon $to): Collection
private function getMonthlyTrendTotals(string $userId, string $userCurrency, Carbon $from, Carbon $to, User $user): Collection
{
$transactions = Transaction::query()
->where('transactions.user_id', $userId)
@ -337,7 +342,9 @@ class CashflowAnalyticsController extends Controller
$this->preloadExchangeRates($transactions, $userCurrency);
return $transactions
->groupBy(fn (Transaction $transaction): string => $transaction->transaction_date->format('Y-m'))
->groupBy(fn (Transaction $transaction): string => $this->userMonthPeriodService
->monthContaining($user, $transaction->transaction_date)['from']
->format('Y-m'))
->map(function (Collection $transactions) use ($userCurrency): array {
$income = 0;
$expense = 0;

View File

@ -8,6 +8,7 @@ use App\Models\Transaction;
use App\Services\AccountMetricsService;
use App\Services\CategoryTree;
use App\Services\PeriodComparator;
use App\Services\UserMonthPeriodService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
@ -20,6 +21,7 @@ class DashboardController extends Controller
public function __construct(
private AccountMetricsService $accountMetricsService,
private CategoryTree $tree,
private UserMonthPeriodService $userMonthPeriodService,
) {}
public function __invoke(Request $request): Response
@ -50,11 +52,9 @@ class DashboardController extends Controller
private function getTopCategories(Request $request): array
{
$user = $request->user();
$now = Carbon::now();
$from = $now->copy()->subDays(30);
$to = $now->copy();
$periodDates = $this->userMonthPeriodService->current($user);
$period = new PeriodComparator($from, $to);
$period = new PeriodComparator($periodDates['from'], $periodDates['end_inclusive']);
$previousPeriod = $period->previous();
$currentSpending = $this->getCategorySpending($user->id, $period->from, $period->to);
@ -85,11 +85,9 @@ class DashboardController extends Controller
private function getCashflowSummary(Request $request): array
{
$user = $request->user();
$now = Carbon::now();
$from = $now->copy()->startOfMonth();
$to = $now->copy()->endOfMonth();
$periodDates = $this->userMonthPeriodService->current($user);
$period = new PeriodComparator($from, $to);
$period = new PeriodComparator($periodDates['from'], $periodDates['end_inclusive']);
$previousPeriod = $period->previous();
return [

View File

@ -4,6 +4,7 @@ namespace App\Http\Requests\Settings;
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;
@ -32,6 +33,7 @@ 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)],
];
}
}

View File

@ -47,6 +47,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
'currency_code',
'locale',
'timezone',
'month_start_day',
];
/**
@ -76,6 +77,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
'paywall_seen_at' => 'datetime',
'last_logged_in_at' => 'datetime',
'last_active_at' => 'datetime',
'month_start_day' => 'integer',
];
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Services;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonInterface;
class UserMonthPeriodService
{
/** @var list<int> */
public const ALLOWED_START_DAYS = [1, 25, 26, 27, 28];
public function startDay(User $user): int
{
$startDay = (int) ($user->month_start_day ?? 1);
return in_array($startDay, self::ALLOWED_START_DAYS, true) ? $startDay : 1;
}
/**
* @return array{from: Carbon, to: Carbon, end_inclusive: Carbon}
*/
public function current(User $user, ?CarbonInterface $date = null): array
{
return $this->monthContaining($user, $date ?? Carbon::now());
}
/**
* @return array{from: Carbon, to: Carbon, end_inclusive: Carbon}
*/
public function monthContaining(User $user, CarbonInterface $date): array
{
$start = $this->monthStartOnOrBefore(Carbon::instance($date->toDateTime()), $this->startDay($user));
$to = $start->copy()->addMonthNoOverflow();
return [
'from' => $start,
'to' => $to,
'end_inclusive' => $to->copy()->subDay(),
];
}
private function monthStartOnOrBefore(Carbon $date, int $startDay): Carbon
{
$start = $date->copy()->startOfDay()->day($startDay);
if ($start->gt($date)) {
$start->subMonthNoOverflow();
}
return $start;
}
}

View File

@ -27,6 +27,7 @@ class UserFactory extends Factory
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'currency_code' => 'USD',
'month_start_day' => 1,
'email_verified_at' => now(),
'password' => static::$password ??= 'password',
'remember_token' => Str::random(10),

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedTinyInteger('month_start_day')->default(1)->after('timezone');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('month_start_day');
});
}
};

View File

@ -23,6 +23,7 @@ describe('PeriodNavigation', () => {
<PeriodNavigation
currentDate={new Date(2026, 0, 1)}
periodType="month"
monthStartDay={1}
onDateChange={() => undefined}
onPeriodTypeChange={() => undefined}
/>,

View File

@ -2,29 +2,22 @@ import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import { CashflowPeriodType } from '@/hooks/use-cashflow-data';
import { useLocale } from '@/hooks/use-locale';
import {
getUserPeriodRange,
sameUserPeriod,
shiftUserPeriod,
UserMonthStartDay,
} from '@/lib/user-periods';
import { cn } from '@/lib/utils';
import { formatDate, formatMonthYear } from '@/utils/date';
import { __ } from '@/utils/i18n';
import {
addMonths,
addQuarters,
addYears,
getQuarter,
isSameMonth,
isSameQuarter,
isSameYear,
startOfMonth,
startOfQuarter,
startOfYear,
subMonths,
subQuarters,
subYears,
} from 'date-fns';
import { getQuarter } from 'date-fns';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PeriodNavigationProps {
currentDate: Date;
periodType: CashflowPeriodType;
monthStartDay: UserMonthStartDay;
onDateChange: (date: Date) => void;
onPeriodTypeChange: (periodType: CashflowPeriodType) => void;
}
@ -41,19 +34,34 @@ const periodTypeOptions: Array<{
export function PeriodNavigation({
currentDate,
periodType,
monthStartDay,
onDateChange,
onPeriodTypeChange,
}: PeriodNavigationProps) {
const locale = useLocale();
const now = new Date();
const isCurrentPeriod = samePeriod(currentDate, now, periodType);
const periodStart = getUserPeriodRange(
currentDate,
periodType,
monthStartDay,
).from;
const isCurrentPeriod = sameUserPeriod(
currentDate,
now,
periodType,
monthStartDay,
);
const handlePreviousPeriod = () => {
onDateChange(shiftPeriod(currentDate, periodType, -1));
onDateChange(
shiftUserPeriod(currentDate, periodType, monthStartDay, -1),
);
};
const handleNextPeriod = () => {
onDateChange(shiftPeriod(currentDate, periodType, 1));
onDateChange(
shiftUserPeriod(currentDate, periodType, monthStartDay, 1),
);
};
const handleCurrentPeriod = () => {
@ -97,7 +105,7 @@ export function PeriodNavigation({
variant="outline"
className="flex-1 sm:flex-none"
>
{formatPeriodLabel(currentDate, periodType, locale)}
{formatPeriodLabel(periodStart, periodType, locale)}
</Button>
<Button
@ -114,46 +122,6 @@ export function PeriodNavigation({
);
}
function shiftPeriod(
date: Date,
periodType: CashflowPeriodType,
amount: 1 | -1,
): Date {
if (periodType === 'quarter') {
const quarterStart = startOfQuarter(date);
return amount > 0
? addQuarters(quarterStart, 1)
: subQuarters(quarterStart, 1);
}
if (periodType === 'year') {
const yearStart = startOfYear(date);
return amount > 0 ? addYears(yearStart, 1) : subYears(yearStart, 1);
}
const monthStart = startOfMonth(date);
return amount > 0 ? addMonths(monthStart, 1) : subMonths(monthStart, 1);
}
function samePeriod(
left: Date,
right: Date,
periodType: CashflowPeriodType,
): boolean {
if (periodType === 'quarter') {
return isSameQuarter(left, right);
}
if (periodType === 'year') {
return isSameYear(left, right);
}
return isSameMonth(left, right);
}
function formatPeriodLabel(
date: Date,
periodType: CashflowPeriodType,

View File

@ -0,0 +1,104 @@
import {
addMonths,
addQuarters,
addYears,
endOfDay,
startOfDay,
subDays,
} from 'date-fns';
export type UserMonthStartDay = 1 | 25 | 26 | 27 | 28;
export type UserPeriodType = 'month' | 'quarter' | 'year';
export const USER_MONTH_START_DAYS: UserMonthStartDay[] = [1, 25, 26, 27, 28];
export function userMonthStartDay(value: unknown): UserMonthStartDay {
return USER_MONTH_START_DAYS.includes(value as UserMonthStartDay)
? (value as UserMonthStartDay)
: 1;
}
export function getUserPeriodRange(
date: Date,
periodType: UserPeriodType,
monthStartDay: UserMonthStartDay,
): { from: Date; to: Date; endInclusive: Date } {
if (periodType === 'quarter') {
return multiMonthPeriodContaining(date, monthStartDay, 3);
}
if (periodType === 'year') {
return multiMonthPeriodContaining(date, monthStartDay, 12);
}
const from = monthStartOnOrBefore(date, monthStartDay);
const to = addMonths(from, 1);
return { from, to, endInclusive: endOfDay(subDays(to, 1)) };
}
export function shiftUserPeriod(
date: Date,
periodType: UserPeriodType,
monthStartDay: UserMonthStartDay,
amount: 1 | -1,
): Date {
const { from } = getUserPeriodRange(date, periodType, monthStartDay);
if (periodType === 'quarter') {
return amount > 0 ? addQuarters(from, 1) : addQuarters(from, -1);
}
if (periodType === 'year') {
return amount > 0 ? addYears(from, 1) : addYears(from, -1);
}
return amount > 0 ? addMonths(from, 1) : addMonths(from, -1);
}
export function sameUserPeriod(
left: Date,
right: Date,
periodType: UserPeriodType,
monthStartDay: UserMonthStartDay,
): boolean {
return (
getUserPeriodRange(left, periodType, monthStartDay).from.getTime() ===
getUserPeriodRange(right, periodType, monthStartDay).from.getTime()
);
}
function multiMonthPeriodContaining(
date: Date,
monthStartDay: UserMonthStartDay,
months: 3 | 12,
): { from: Date; to: Date; endInclusive: Date } {
const anchorMonth =
months === 12 ? 0 : Math.floor(date.getMonth() / months) * months;
let from = startOfDay(
new Date(date.getFullYear(), anchorMonth, monthStartDay),
);
if (from > date) {
from = addMonths(from, -months);
}
const to = addMonths(from, months);
return { from, to, endInclusive: endOfDay(subDays(to, 1)) };
}
function monthStartOnOrBefore(
date: Date,
monthStartDay: UserMonthStartDay,
): Date {
let start = startOfDay(
new Date(date.getFullYear(), date.getMonth(), monthStartDay),
);
if (start > date) {
start = addMonths(start, -1);
}
return start;
}

View File

@ -7,21 +7,12 @@ import HeadingSmall from '@/components/heading-small';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CashflowPeriodType, useCashflowData } from '@/hooks/use-cashflow-data';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { getUserPeriodRange, userMonthStartDay } from '@/lib/user-periods';
import { cashflow } from '@/routes';
import { BreadcrumbItem } from '@/types';
import { __ } from '@/utils/i18n';
import { Head, router, usePage } from '@inertiajs/react';
import {
endOfMonth,
endOfQuarter,
endOfYear,
format,
getQuarter,
parse,
startOfMonth,
startOfQuarter,
startOfYear,
} from 'date-fns';
import { format, getQuarter, parse } from 'date-fns';
import { useEffect, useState } from 'react';
const breadcrumbs: BreadcrumbItem[] = [
@ -34,6 +25,7 @@ const breadcrumbs: BreadcrumbItem[] = [
function parsePeriodParam(
period: string | null,
periodType: CashflowPeriodType,
monthStartDay: number,
): Date {
if (!period) {
return new Date();
@ -44,7 +36,11 @@ function parsePeriodParam(
const match = /^(\d{4})-Q([1-4])$/.exec(period);
if (match) {
return new Date(Number(match[1]), (Number(match[2]) - 1) * 3);
return new Date(
Number(match[1]),
(Number(match[2]) - 1) * 3,
monthStartDay,
);
}
}
@ -52,14 +48,18 @@ function parsePeriodParam(
const match = /^(\d{4})$/.exec(period);
if (match) {
return new Date(Number(match[1]), 0);
return new Date(Number(match[1]), 0, monthStartDay);
}
}
const parsedDate = parse(period, 'yyyy-MM', new Date());
if (!isNaN(parsedDate.getTime())) {
return parsedDate;
return new Date(
parsedDate.getFullYear(),
parsedDate.getMonth(),
monthStartDay,
);
}
} catch {
return new Date();
@ -68,30 +68,6 @@ function parsePeriodParam(
return new Date();
}
function getPeriodRange(
currentDate: Date,
periodType: CashflowPeriodType,
): { from: Date; to: Date } {
if (periodType === 'quarter') {
return {
from: startOfQuarter(currentDate),
to: endOfQuarter(currentDate),
};
}
if (periodType === 'year') {
return {
from: startOfYear(currentDate),
to: endOfYear(currentDate),
};
}
return {
from: startOfMonth(currentDate),
to: endOfMonth(currentDate),
};
}
function formatPeriodParam(
currentDate: Date,
periodType: CashflowPeriodType,
@ -113,19 +89,28 @@ export default function CashflowPage() {
period: initialPeriod,
periodType: initialPeriodType,
} = usePage<{
auth: { user: { currency_code: string } };
auth: { user: { currency_code: string; month_start_day: number } };
period: string | null;
periodType: CashflowPeriodType;
}>().props;
const [periodType, setPeriodType] =
useState<CashflowPeriodType>(initialPeriodType);
const monthStartDay = userMonthStartDay(auth.user.month_start_day);
const [currentDate, setCurrentDate] = useState<Date>(() =>
parsePeriodParam(initialPeriod, initialPeriodType),
parsePeriodParam(initialPeriod, initialPeriodType, monthStartDay),
);
const period = getPeriodRange(currentDate, periodType);
const userPeriod = getUserPeriodRange(
currentDate,
periodType,
monthStartDay,
);
const period = {
from: userPeriod.from,
to: userPeriod.endInclusive,
};
const periodParam = formatPeriodParam(userPeriod.from, periodType);
const {
summary,
@ -140,8 +125,6 @@ export default function CashflowPage() {
});
useEffect(() => {
const periodParam = formatPeriodParam(currentDate, periodType);
if (initialPeriod !== periodParam || initialPeriodType !== periodType) {
router.visit(
cashflow({
@ -154,7 +137,7 @@ export default function CashflowPage() {
},
);
}
}, [currentDate, initialPeriod, initialPeriodType, periodType]);
}, [initialPeriod, initialPeriodType, periodParam, periodType]);
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
@ -172,6 +155,7 @@ export default function CashflowPage() {
<PeriodNavigation
currentDate={currentDate}
periodType={periodType}
monthStartDay={monthStartDay}
onDateChange={setCurrentDate}
onPeriodTypeChange={setPeriodType}
/>

View File

@ -218,6 +218,54 @@ export default function Account({
/>
</div>
<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),
})}
</SelectItem>
))}
</SelectContent>
</Select>
<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>
{mustVerifyEmail &&
auth.user.email_verified_at === null && (
<div>

View File

@ -101,6 +101,7 @@ export interface User {
currency_code: CurrencyCode;
locale: string | null;
timezone: string | null;
month_start_day: 1 | 25 | 26 | 27 | 28;
avatar?: string;
email_verified_at: string | null;
two_factor_enabled?: boolean;

View File

@ -259,6 +259,49 @@ test('cashflow summary includes actual saved and invested amounts', function ()
->assertJsonPath('current.investments', 15000);
});
test('cashflow summary respects custom salary month boundaries', function () {
$this->user->update(['month_start_day' => 25]);
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
foreach ([
['date' => '2026-01-24', 'amount' => 10000],
['date' => '2026-01-25', 'amount' => 20000],
['date' => '2026-02-24', 'amount' => 30000],
['date' => '2026-02-25', 'amount' => 40000],
] as $transaction) {
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => $transaction['amount'],
'transaction_date' => $transaction['date'],
]);
}
$summary = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => '2026-01-25',
'to' => '2026-02-24',
]));
$trend = $this->getJson('/api/cashflow/trend?'.http_build_query([
'from' => '2026-01-25',
'to' => '2026-02-24',
]));
$summary->assertOk()
->assertJsonPath('current.income', 50000)
->assertJsonPath('previous.income', 10000);
$trend->assertOk()
->assertJsonPath('data.0.month', '2026-01')
->assertJsonPath('data.0.income', 50000);
});
test('cashflow summary compares full quarter against previous full quarter', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,

View File

@ -82,3 +82,40 @@ test('dashboard top categories roll child spending up into the parent', function
->assertJsonPath('props.topCategories.0.category.id', $food->id)
->assertJsonPath('props.topCategories.0.amount', 3000);
});
test('dashboard cashflow summary uses user month start day', function () {
$this->travelTo('2026-02-10');
$user = User::factory()->onboarded()->create(['month_start_day' => 25]);
$this->actingAs($user);
$incomeCategory = Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Income,
]);
foreach ([
['date' => '2026-01-24', 'amount' => 10000],
['date' => '2026-01-25', 'amount' => 20000],
['date' => '2026-02-24', 'amount' => 30000],
] as $transaction) {
Transaction::factory()->create([
'user_id' => $user->id,
'category_id' => $incomeCategory->id,
'amount' => $transaction['amount'],
'transaction_date' => $transaction['date'],
]);
}
$response = $this->get(route('dashboard'));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('dashboard')
->missing('cashflowSummary')
->loadDeferredProps(fn ($reload) => $reload
->where('cashflowSummary.current.income', 50000)
->where('cashflowSummary.previous.income', 10000)
)
);
});

View File

@ -21,6 +21,7 @@ test('profile information can be updated', function () {
'name' => 'Test User',
'email' => 'test@example.com',
'currency_code' => 'EUR',
'month_start_day' => 25,
]);
$response
@ -33,6 +34,7 @@ test('profile information can be updated', function () {
expect($user->email)->toBe('test@example.com');
expect($user->email_verified_at)->toBeNull();
expect($user->currency_code)->toBe('EUR');
expect($user->month_start_day)->toBe(25);
});
test('profile accepts new latam primary currency', function () {
@ -44,6 +46,7 @@ test('profile accepts new latam primary currency', function () {
'name' => 'Test User',
'email' => 'test@example.com',
'currency_code' => 'ARS',
'month_start_day' => 1,
]);
$response
@ -119,11 +122,27 @@ test('profile rejects bitcoin as primary currency', function () {
'name' => 'Test User',
'email' => 'test@example.com',
'currency_code' => 'BTC',
'month_start_day' => 1,
]);
$response->assertSessionHasErrors(['currency_code']);
});
test('profile rejects unsupported month start day', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
'currency_code' => 'USD',
'month_start_day' => 24,
]);
$response->assertSessionHasErrors(['month_start_day']);
});
test('email verification status is unchanged when the email address is unchanged', function () {
$user = User::factory()->create();
@ -133,6 +152,7 @@ test('email verification status is unchanged when the email address is unchanged
'name' => 'Test User',
'email' => $user->email,
'currency_code' => $user->currency_code,
'month_start_day' => 1,
]);
$response

View File

@ -0,0 +1,40 @@
<?php
use App\Models\User;
use App\Services\UserMonthPeriodService;
use Carbon\Carbon;
it('returns natural month periods by default', function () {
$user = User::factory()->make(['month_start_day' => 1]);
$period = app(UserMonthPeriodService::class)->monthContaining($user, Carbon::parse('2026-02-14'));
expect($period['from']->toDateString())->toBe('2026-02-01')
->and($period['to']->toDateString())->toBe('2026-03-01')
->and($period['end_inclusive']->toDateString())->toBe('2026-02-28');
});
it('returns salary month periods for custom start days', function () {
$user = User::factory()->make(['month_start_day' => 25]);
$period = app(UserMonthPeriodService::class)->monthContaining($user, Carbon::parse('2026-02-14'));
$previousPeriod = app(UserMonthPeriodService::class)->monthContaining($user, $period['from']->copy()->subDay());
expect($period['from']->toDateString())->toBe('2026-01-25')
->and($period['to']->toDateString())->toBe('2026-02-25')
->and($period['end_inclusive']->toDateString())->toBe('2026-02-24')
->and($previousPeriod['from']->toDateString())->toBe('2025-12-25')
->and($previousPeriod['to']->toDateString())->toBe('2026-01-25');
});
it('starts a new salary month on the configured day', function () {
$user = User::factory()->make(['month_start_day' => 28]);
$period = app(UserMonthPeriodService::class)->monthContaining($user, Carbon::parse('2026-02-28'));
expect($period['from']->toDateString())->toBe('2026-02-28')
->and($period['to']->toDateString())->toBe('2026-03-28');
});
it('falls back to the first for invalid stored values', function () {
$user = User::factory()->make(['month_start_day' => 2]);
expect(app(UserMonthPeriodService::class)->startDay($user))->toBe(1);
});