fix(periods): correct previous-period comparisons and align period consumers

- make PeriodComparator::previous() period-aware: shift by whole months
  derived from the period length so variable-length salary months,
  quarters, and years compute the correct previous period (and an
  end-inclusive end-of-day bound no longer leaks an extra day)
- snap trend() from/to to the user's period boundaries so emitted buckets
  match how transactions are grouped, and resolve the start day once
- share the dashboard top-categories period so the drill-down, links, and
  description use the same window as the parent rows
- gate the custom start day read path behind CustomMonthStartDay so rolling
  the flag back reverts to calendar months instead of stranding users
- relax month_start_day validation to sometimes|required
- resolve "today" in the user timezone for the cashflow page so the client
  and server agree on the current period near boundaries
- bucket the category monthly breakdown drawer by the user's period
- cover previous-period math across short months, quarters, years, trend
  alignment, and flag rollback
This commit is contained in:
Víctor Falcón 2026-06-12 10:34:59 +01:00
parent 89e31bb54e
commit 26c83c710f
16 changed files with 335 additions and 63 deletions

View File

@ -81,8 +81,11 @@ class CashflowAnalyticsController extends Controller
$user = $request->user();
if (isset($validated['from'], $validated['to'])) {
$start = Carbon::parse($validated['from'])->startOfDay();
$end = Carbon::parse($validated['to'])->startOfDay();
// Snap the bounds to the user's period start so the emitted month
// buckets line up with how transactions are grouped below;
// otherwise transactions in a partial leading period are dropped.
$start = $this->userMonthPeriodService->monthContaining($user, Carbon::parse($validated['from']))['from'];
$end = $this->userMonthPeriodService->monthContaining($user, Carbon::parse($validated['to']))['end_inclusive'];
} else {
$months = $validated['months'] ?? 12;
$endPeriod = $this->userMonthPeriodService->monthContaining(
@ -111,7 +114,7 @@ class CashflowAnalyticsController extends Controller
'net' => $income - $expense,
];
$current->addMonth();
$current->addMonthNoOverflow();
}
return $this->cashflowJson([
@ -341,10 +344,18 @@ class CashflowAnalyticsController extends Controller
$this->preloadExchangeRates($transactions, $userCurrency);
$startDay = $this->userMonthPeriodService->startDay($user);
return $transactions
->groupBy(fn (Transaction $transaction): string => $this->userMonthPeriodService
->monthContaining($user, $transaction->transaction_date)['from']
->format('Y-m'))
->groupBy(function (Transaction $transaction) use ($startDay): string {
$date = $transaction->transaction_date;
// Bucket by the period a transaction falls in: dates before the
// start day belong to the period that opened the prior month.
return $date->day >= $startDay
? $date->format('Y-m')
: $date->copy()->subMonthNoOverflow()->format('Y-m');
})
->map(function (Collection $transactions) use ($userCurrency): array {
$income = 0;
$expense = 0;

View File

@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Transaction;
use App\Services\ExchangeRateService;
use App\Services\UserMonthPeriodService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -27,6 +28,7 @@ class CategoryMonthlyBreakdownController extends Controller
public function __construct(
private ExchangeRateService $exchangeRateService,
private UserMonthPeriodService $userMonthPeriodService,
) {}
public function __invoke(Request $request, Category $category): JsonResponse
@ -36,7 +38,9 @@ class CategoryMonthlyBreakdownController extends Controller
abort_unless($category->user_id === $user->id, 403);
$currency = $user->currency_code;
$start = Carbon::now()->startOfMonth()->subMonths(self::MONTHS - 1);
$startDay = $this->userMonthPeriodService->startDay($user);
$start = $this->userMonthPeriodService->current($user)['from']
->subMonthsNoOverflow(self::MONTHS - 1);
$parentMap = Category::query()
->where('user_id', $user->id)
@ -77,7 +81,7 @@ class CategoryMonthlyBreakdownController extends Controller
foreach ($transactions as $transaction) {
$amount = $this->convertTransactionAmount($transaction, $currency) * $orientation;
$monthKey = $transaction->transaction_date->format('Y-m');
$monthKey = $this->periodKey($transaction->transaction_date, $startDay);
$segment = $this->segmentFor($transaction->category_id, $category->id, $parentMap, $hasChildren);
$buckets[$monthKey][$segment] = ($buckets[$monthKey][$segment] ?? 0) + $amount;
@ -271,12 +275,23 @@ class CategoryMonthlyBreakdownController extends Controller
}
$months[] = $point;
$cursor->addMonth();
$cursor->addMonthNoOverflow();
}
return $months;
}
/**
* The Y-m label of the month period a date belongs to: dates before the
* user's start day roll into the period that opened the previous month.
*/
private function periodKey(Carbon $date, int $startDay): string
{
return $date->day >= $startDay
? $date->format('Y-m')
: $date->copy()->subMonthNoOverflow()->format('Y-m');
}
private function convertTransactionAmount(Transaction $transaction, string $currency): int
{
return $this->exchangeRateService->convert(

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@ -45,6 +46,9 @@ class CashflowController extends Controller
'banks' => $banks,
'period' => $validPeriod,
'periodType' => $validPeriodType,
// Resolve "today" in the user's timezone so the client agrees with
// the server on which period is current near month boundaries.
'today' => Carbon::now($user->timezone)->toDateString(),
]);
}

View File

@ -62,7 +62,7 @@ class DashboardController extends Controller
$totalAmount = $currentSpending->sum('amount');
return $currentSpending
$categories = $currentSpending
->sortByDesc('amount')
->take(10)
->map(function ($item) use ($previousSpending, $totalAmount) {
@ -80,6 +80,14 @@ class DashboardController extends Controller
})
->values()
->all();
return [
'categories' => $categories,
// Share the resolved period so the drill-down and links query the
// same window the parent rows were computed for.
'from' => $periodDates['from']->toDateString(),
'to' => $periodDates['end_inclusive']->toDateString(),
];
}
private function getCashflowSummary(Request $request): array

View File

@ -38,7 +38,7 @@ class ProfileUpdateRequest extends FormRequest
];
if (Feature::for($this->user())->active(CustomMonthStartDay::class)) {
$rules['month_start_day'] = ['required', 'integer', Rule::in(UserMonthPeriodService::ALLOWED_START_DAYS)];
$rules['month_start_day'] = ['sometimes', 'required', 'integer', Rule::in(UserMonthPeriodService::ALLOWED_START_DAYS)];
}
return $rules;

View File

@ -13,28 +13,28 @@ class PeriodComparator
public function previous(): self
{
$days = $this->from->diffInDays($this->to) + 1;
$from = $this->from->copy()->startOfDay();
$exclusiveEnd = $this->to->copy()->startOfDay()->addDay();
// If it's a full month range (e.g. 1st to end of month), shift by months
if ($this->isFullMonthRange()) {
$months = $this->from->diffInMonths($this->to->copy()->addDay());
$months = (int) $from->diffInMonths($exclusiveEnd);
$previousFrom = $this->from->copy()->subMonthsNoOverflow($months);
$previousTo = $previousFrom->copy()->addMonthsNoOverflow($months - 1)->endOfMonth();
return new self($previousFrom, $previousTo);
// Period boundaries are anchored to a month start day (calendar or
// custom), so whole-month ranges shift by months rather than by day
// count. This keeps the previous period correctly aligned even when
// adjacent months differ in length (e.g. a 25th-to-24th salary month).
if ($months >= 1) {
return new self(
$from->copy()->subMonthsNoOverflow($months),
$from->copy()->subDay()->endOfDay(),
);
}
return new self(
$this->from->copy()->subDays($days),
$this->from->copy()->subDay()
);
}
$days = (int) $from->diffInDays($exclusiveEnd);
private function isFullMonthRange(): bool
{
return $this->from->day === 1 &&
$this->to->isLastOfMonth();
return new self(
$from->copy()->subDays($days),
$from->copy()->subDay()->endOfDay(),
);
}
public static function fromRequest(array $validated): self

View File

@ -2,9 +2,11 @@
namespace App\Services;
use App\Features\CustomMonthStartDay;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Laravel\Pennant\Feature;
class UserMonthPeriodService
{
@ -13,6 +15,13 @@ class UserMonthPeriodService
public function startDay(User $user): int
{
// The custom start day only takes effect while the feature is active,
// so rolling the flag back cleanly reverts everyone to calendar months
// instead of stranding them on a salary month they can no longer edit.
if (! Feature::for($user)->active(CustomMonthStartDay::class)) {
return 1;
}
$startDay = (int) ($user->month_start_day ?? 1);
return in_array($startDay, self::ALLOWED_START_DAYS, true) ? $startDay : 1;

View File

@ -18,6 +18,7 @@ interface PeriodNavigationProps {
currentDate: Date;
periodType: CashflowPeriodType;
monthStartDay: UserMonthStartDay;
referenceDate?: Date;
onDateChange: (date: Date) => void;
onPeriodTypeChange: (periodType: CashflowPeriodType) => void;
}
@ -35,11 +36,12 @@ export function PeriodNavigation({
currentDate,
periodType,
monthStartDay,
referenceDate,
onDateChange,
onPeriodTypeChange,
}: PeriodNavigationProps) {
const locale = useLocale();
const now = new Date();
const now = referenceDate ?? new Date();
const periodStart = getUserPeriodRange(
currentDate,
periodType,

View File

@ -39,6 +39,8 @@ interface CategoryData {
interface TopCategoriesCardProps {
categories: CategoryData[];
from?: string;
to?: string;
loading?: boolean;
}
@ -48,6 +50,8 @@ function rowKey(item: CategoryData): string {
export function TopCategoriesCard({
categories,
from,
to,
loading,
}: TopCategoriesCardProps) {
const { auth } = usePage<SharedData>().props;
@ -56,10 +60,10 @@ export function TopCategoriesCard({
const { dateFrom, dateTo } = useMemo(() => {
const now = new Date();
return {
dateFrom: format(subDays(now, 30), 'yyyy-MM-dd'),
dateTo: format(now, 'yyyy-MM-dd'),
dateFrom: from ?? format(subDays(now, 30), 'yyyy-MM-dd'),
dateTo: to ?? format(now, 'yyyy-MM-dd'),
};
}, []);
}, [from, to]);
const fetchChildren = useCallback(
async (categoryId: string): Promise<CategoryData[]> => {
@ -177,7 +181,7 @@ export function TopCategoriesCard({
}
/>
</div>
<CardDescription>{__('on the last 30 days')}</CardDescription>
<CardDescription>{__('this month')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">

View File

@ -9,7 +9,7 @@ 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 { BreadcrumbItem, SharedData } from '@/types';
import { __ } from '@/utils/i18n';
import { Head, router, usePage } from '@inertiajs/react';
import { format, getQuarter, parse } from 'date-fns';
@ -26,9 +26,10 @@ function parsePeriodParam(
period: string | null,
periodType: CashflowPeriodType,
monthStartDay: number,
fallback: Date,
): Date {
if (!period) {
return new Date();
return fallback;
}
try {
@ -52,7 +53,7 @@ function parsePeriodParam(
}
}
const parsedDate = parse(period, 'yyyy-MM', new Date());
const parsedDate = parse(period, 'yyyy-MM', fallback);
if (!isNaN(parsedDate.getTime())) {
return new Date(
@ -62,10 +63,10 @@ function parsePeriodParam(
);
}
} catch {
return new Date();
return fallback;
}
return new Date();
return fallback;
}
function formatPeriodParam(
@ -86,20 +87,36 @@ function formatPeriodParam(
export default function CashflowPage() {
const {
auth,
features,
period: initialPeriod,
periodType: initialPeriodType,
} = usePage<{
auth: { user: { currency_code: string; month_start_day: number } };
period: string | null;
periodType: CashflowPeriodType;
}>().props;
today,
} = usePage<
SharedData & {
period: string | null;
periodType: CashflowPeriodType;
today: string;
}
>().props;
const [periodType, setPeriodType] =
useState<CashflowPeriodType>(initialPeriodType);
const monthStartDay = userMonthStartDay(auth.user.month_start_day);
// Only honour the custom start day while the feature is active so the
// client matches the server, which reverts to calendar months otherwise.
const monthStartDay = features.customMonthStartDay
? userMonthStartDay(auth.user.month_start_day)
: 1;
const referenceDate = today
? parse(today, 'yyyy-MM-dd', new Date())
: new Date();
const [currentDate, setCurrentDate] = useState<Date>(() =>
parsePeriodParam(initialPeriod, initialPeriodType, monthStartDay),
parsePeriodParam(
initialPeriod,
initialPeriodType,
monthStartDay,
referenceDate,
),
);
const userPeriod = getUserPeriodRange(
currentDate,
@ -156,6 +173,7 @@ export default function CashflowPage() {
currentDate={currentDate}
periodType={periodType}
monthStartDay={monthStartDay}
referenceDate={referenceDate}
onDateChange={setCurrentDate}
onPeriodTypeChange={setPeriodType}
/>

View File

@ -28,15 +28,19 @@ interface CashflowSummary {
interface DashboardProps extends SharedData {
showEncryptionPrompt: boolean;
netWorthEvolution?: NetWorthEvolutionData;
topCategories?: Array<{
category: Category | null;
category_id?: string | null;
amount: number;
previous_amount: number;
total_amount: number;
has_children?: boolean;
is_direct?: boolean;
}>;
topCategories?: {
categories: Array<{
category: Category | null;
category_id?: string | null;
amount: number;
previous_amount: number;
total_amount: number;
has_children?: boolean;
is_direct?: boolean;
}>;
from: string;
to: string;
};
cashflowSummary?: {
current: CashflowSummary;
previous: CashflowSummary;
@ -121,7 +125,7 @@ export default function Dashboard() {
return map;
}, [accountMetrics]);
const topCategories = props.topCategories ?? [];
const topCategories = props.topCategories?.categories ?? [];
const refetch = useCallback(() => {
router.reload({
@ -232,7 +236,11 @@ export default function Dashboard() {
<TopCategoriesCard categories={[]} loading={true} />
}
>
<TopCategoriesCard categories={topCategories} />
<TopCategoriesCard
categories={topCategories}
from={props.topCategories?.from}
to={props.topCategories?.to}
/>
</Deferred>
{props.features.cashflow && (

View File

@ -2,12 +2,14 @@
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Features\CustomMonthStartDay;
use App\Models\Account;
use App\Models\Category;
use App\Models\ExchangeRate;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Laravel\Pennant\Feature;
beforeEach(function () {
Http::fake();
@ -261,6 +263,7 @@ test('cashflow summary includes actual saved and invested amounts', function ()
test('cashflow summary respects custom salary month boundaries', function () {
$this->user->update(['month_start_day' => 25]);
Feature::for($this->user)->activate(CustomMonthStartDay::class);
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
@ -302,6 +305,46 @@ test('cashflow summary respects custom salary month boundaries', function () {
->assertJsonPath('data.0.income', 50000);
});
test('trend keeps transactions when the requested range is not period-aligned', function () {
$this->user->update(['month_start_day' => 25]);
Feature::for($this->user)->activate(CustomMonthStartDay::class);
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Jan 10 belongs to the Dec 25 - Jan 24 salary period ('2025-12'). A
// calendar-aligned request must still surface it rather than drop it.
foreach ([
['date' => '2026-01-10', 'amount' => 10000],
['date' => '2026-02-10', 'amount' => 20000],
] 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'],
]);
}
$trend = $this->getJson('/api/cashflow/trend?'.http_build_query([
'from' => '2026-01-01',
'to' => '2026-02-28',
]));
$trend->assertOk();
$income = collect($trend->json('data'))->pluck('income', 'month');
expect($income->get('2025-12'))->toBe(10000)
->and($income->get('2026-01'))->toBe(20000)
->and($income->sum())->toBe(30000);
});
test('cashflow summary compares full quarter against previous full quarter', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,

View File

@ -1,6 +1,7 @@
<?php
use App\Enums\CategoryType;
use App\Features\CustomMonthStartDay;
use App\Models\Account;
use App\Models\Category;
use App\Models\ExchangeRate;
@ -9,6 +10,7 @@ use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Testing\TestResponse;
use Laravel\Pennant\Feature;
beforeEach(function () {
Http::fake();
@ -182,6 +184,23 @@ test('summary trend is null when the earlier half has no spending', function ()
expect($response->json('summary.trend_percentage'))->toBeNull();
});
test('it buckets by the user salary month when the custom start day is active', function () {
$this->user->update(['month_start_day' => 25]);
Feature::for($this->user)->activate(CustomMonthStartDay::class);
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
// Now is 2026-06-15, so the current salary period is May 25 - Jun 24,
// keyed '2026-05'. A Jun 10 spend falls in that period, not calendar June.
makeBreakdownTransaction(['amount' => -4000, 'category_id' => $category->id, 'transaction_date' => '2026-06-10']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('months'))->toHaveCount(12);
expect($response->json('months.11.key'))->toBe('2026-05');
expect($response->json('months.11.'.$category->id))->toBe(4000);
});
test('foreign-currency transactions are converted to the user currency', function () {
ExchangeRate::factory()->create([
'base_currency' => 'usd',

View File

@ -1,10 +1,12 @@
<?php
use App\Enums\CategoryType;
use App\Features\CustomMonthStartDay;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Laravel\Fortify\Features;
use Laravel\Pennant\Feature;
beforeEach(function () {
config(['landing.hide_auth_buttons' => false]);
@ -78,15 +80,16 @@ test('dashboard top categories roll child spending up into the parent', function
]);
$response->assertOk()
->assertJsonCount(1, 'props.topCategories')
->assertJsonPath('props.topCategories.0.category.id', $food->id)
->assertJsonPath('props.topCategories.0.amount', 3000);
->assertJsonCount(1, 'props.topCategories.categories')
->assertJsonPath('props.topCategories.categories.0.category.id', $food->id)
->assertJsonPath('props.topCategories.categories.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]);
Feature::for($user)->activate(CustomMonthStartDay::class);
$this->actingAs($user);
$incomeCategory = Category::factory()->create([
@ -119,3 +122,43 @@ test('dashboard cashflow summary uses user month start day', function () {
)
);
});
test('dashboard previous period stays aligned across a short salary month', function () {
// Current period Feb 25 - Mar 24 is only 28 days; the previous period must
// still be the full Jan 25 - Feb 24 salary month, not a 28-day count back.
$this->travelTo('2026-03-10');
$user = User::factory()->onboarded()->create(['month_start_day' => 25]);
Feature::for($user)->activate(CustomMonthStartDay::class);
$this->actingAs($user);
$incomeCategory = Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Income,
]);
foreach ([
['date' => '2026-01-25', 'amount' => 10000], // previous period opening day
['date' => '2026-02-24', 'amount' => 20000], // previous period closing day
['date' => '2026-02-25', 'amount' => 40000], // current period opening day
['date' => '2026-03-10', 'amount' => 30000], // current period
] 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')
->loadDeferredProps(fn ($reload) => $reload
->where('cashflowSummary.current.income', 70000)
->where('cashflowSummary.previous.income', 30000)
)
);
});

View File

@ -0,0 +1,67 @@
<?php
use App\Services\PeriodComparator;
use Carbon\Carbon;
function comparator(string $from, string $to): PeriodComparator
{
return new PeriodComparator(Carbon::parse($from), Carbon::parse($to));
}
it('shifts a natural calendar month back a whole month', function () {
$previous = comparator('2026-02-01', '2026-02-28')->previous();
expect($previous->from->toDateString())->toBe('2026-01-01')
->and($previous->to->toDateString())->toBe('2026-01-31');
});
it('shifts a 31-day salary month back to the previous salary month', function () {
$previous = comparator('2026-01-25', '2026-02-24')->previous();
expect($previous->from->toDateString())->toBe('2025-12-25')
->and($previous->to->toDateString())->toBe('2026-01-24');
});
it('shifts a short salary month back without losing the payday days', function () {
// Feb 25 - Mar 24 is only 28 days; a day-count shift would land on Jan 27
// and drop Jan 25-26. The previous salary month must still start on the 25th.
$previous = comparator('2026-02-25', '2026-03-24')->previous();
expect($previous->from->toDateString())->toBe('2026-01-25')
->and($previous->to->toDateString())->toBe('2026-02-24');
});
it('treats an end-inclusive end-of-day bound as the same period', function () {
$period = new PeriodComparator(
Carbon::parse('2026-01-25')->startOfDay(),
Carbon::parse('2026-02-24')->endOfDay(),
);
$previous = $period->previous();
// The 23:59:59.999999 upper bound must not bleed an extra day into the
// previous window (which would start on Dec 24 instead of Dec 25).
expect($previous->from->toDateString())->toBe('2025-12-25')
->and($previous->to->toDateString())->toBe('2026-01-24');
});
it('shifts a quarter back a full quarter', function () {
$previous = comparator('2026-01-25', '2026-04-24')->previous();
expect($previous->from->toDateString())->toBe('2025-10-25')
->and($previous->to->toDateString())->toBe('2026-01-24');
});
it('shifts a year back a full year', function () {
$previous = comparator('2026-01-25', '2027-01-24')->previous();
expect($previous->from->toDateString())->toBe('2025-01-25')
->and($previous->to->toDateString())->toBe('2026-01-24');
});
it('falls back to a day-count shift for sub-month ranges', function () {
$previous = comparator('2026-03-10', '2026-03-16')->previous();
expect($previous->from->toDateString())->toBe('2026-03-03')
->and($previous->to->toDateString())->toBe('2026-03-09');
});

View File

@ -1,11 +1,21 @@
<?php
use App\Features\CustomMonthStartDay;
use App\Models\User;
use App\Services\UserMonthPeriodService;
use Carbon\Carbon;
use Laravel\Pennant\Feature;
function customStartDayUser(int $startDay): User
{
$user = User::factory()->create(['month_start_day' => $startDay]);
Feature::for($user)->activate(CustomMonthStartDay::class);
return $user;
}
it('returns natural month periods by default', function () {
$user = User::factory()->make(['month_start_day' => 1]);
$user = customStartDayUser(1);
$period = app(UserMonthPeriodService::class)->monthContaining($user, Carbon::parse('2026-02-14'));
expect($period['from']->toDateString())->toBe('2026-02-01')
@ -14,7 +24,7 @@ it('returns natural month periods by default', function () {
});
it('returns salary month periods for custom start days', function () {
$user = User::factory()->make(['month_start_day' => 25]);
$user = customStartDayUser(25);
$period = app(UserMonthPeriodService::class)->monthContaining($user, Carbon::parse('2026-02-14'));
$previousPeriod = app(UserMonthPeriodService::class)->monthContaining($user, $period['from']->copy()->subDay());
@ -26,7 +36,7 @@ it('returns salary month periods for custom start days', function () {
});
it('starts a new salary month on the configured day', function () {
$user = User::factory()->make(['month_start_day' => 28]);
$user = customStartDayUser(28);
$period = app(UserMonthPeriodService::class)->monthContaining($user, Carbon::parse('2026-02-28'));
expect($period['from']->toDateString())->toBe('2026-02-28')
@ -34,7 +44,18 @@ it('starts a new salary month on the configured day', function () {
});
it('falls back to the first for invalid stored values', function () {
$user = User::factory()->make(['month_start_day' => 2]);
$user = customStartDayUser(2);
expect(app(UserMonthPeriodService::class)->startDay($user))->toBe(1);
});
it('ignores the stored start day while the feature is disabled', function () {
$user = User::factory()->create(['month_start_day' => 25]);
$service = app(UserMonthPeriodService::class);
$period = $service->monthContaining($user, Carbon::parse('2026-02-14'));
expect($service->startDay($user))->toBe(1)
->and($period['from']->toDateString())->toBe('2026-02-01')
->and($period['end_inclusive']->toDateString())->toBe('2026-02-28');
});