diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index c87e4819..81a5ac28 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -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; diff --git a/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php b/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php index 3b68bf7d..c20ce0bc 100644 --- a/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php +++ b/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php @@ -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( diff --git a/app/Http/Controllers/CashflowController.php b/app/Http/Controllers/CashflowController.php index 67dd0e28..e5b34779 100644 --- a/app/Http/Controllers/CashflowController.php +++ b/app/Http/Controllers/CashflowController.php @@ -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(), ]); } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index ca5cdd10..857b017a 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -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 diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index 520ecfbf..c0db2673 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -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; diff --git a/app/Services/PeriodComparator.php b/app/Services/PeriodComparator.php index 3b019eca..f377e7a2 100644 --- a/app/Services/PeriodComparator.php +++ b/app/Services/PeriodComparator.php @@ -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 diff --git a/app/Services/UserMonthPeriodService.php b/app/Services/UserMonthPeriodService.php index b7c6a5bb..d3351590 100644 --- a/app/Services/UserMonthPeriodService.php +++ b/app/Services/UserMonthPeriodService.php @@ -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; diff --git a/resources/js/components/cashflow/period-navigation.tsx b/resources/js/components/cashflow/period-navigation.tsx index 5c08e632..9565b4bf 100644 --- a/resources/js/components/cashflow/period-navigation.tsx +++ b/resources/js/components/cashflow/period-navigation.tsx @@ -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, diff --git a/resources/js/components/dashboard/top-categories-card.tsx b/resources/js/components/dashboard/top-categories-card.tsx index a063533c..5998a5fc 100644 --- a/resources/js/components/dashboard/top-categories-card.tsx +++ b/resources/js/components/dashboard/top-categories-card.tsx @@ -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().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 => { @@ -177,7 +181,7 @@ export function TopCategoriesCard({ } /> - {__('on the last 30 days')} + {__('this month')}
diff --git a/resources/js/pages/cashflow/index.tsx b/resources/js/pages/cashflow/index.tsx index d75bcd2b..259805fc 100644 --- a/resources/js/pages/cashflow/index.tsx +++ b/resources/js/pages/cashflow/index.tsx @@ -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(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(() => - 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} /> diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index eafa0981..1a17288c 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -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() { } > - + {props.features.cashflow && ( diff --git a/tests/Feature/CashflowAnalyticsTest.php b/tests/Feature/CashflowAnalyticsTest.php index 25753466..1be2d864 100644 --- a/tests/Feature/CashflowAnalyticsTest.php +++ b/tests/Feature/CashflowAnalyticsTest.php @@ -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, diff --git a/tests/Feature/CategoryMonthlyBreakdownTest.php b/tests/Feature/CategoryMonthlyBreakdownTest.php index 188718ab..033f0c16 100644 --- a/tests/Feature/CategoryMonthlyBreakdownTest.php +++ b/tests/Feature/CategoryMonthlyBreakdownTest.php @@ -1,6 +1,7 @@ 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', diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index be469c91..f473c02e 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -1,10 +1,12 @@ 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) + ) + ); +}); diff --git a/tests/Feature/PeriodComparatorTest.php b/tests/Feature/PeriodComparatorTest.php new file mode 100644 index 00000000..7d48aae8 --- /dev/null +++ b/tests/Feature/PeriodComparatorTest.php @@ -0,0 +1,67 @@ +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'); +}); diff --git a/tests/Feature/UserMonthPeriodServiceTest.php b/tests/Feature/UserMonthPeriodServiceTest.php index 5b377962..1a685983 100644 --- a/tests/Feature/UserMonthPeriodServiceTest.php +++ b/tests/Feature/UserMonthPeriodServiceTest.php @@ -1,11 +1,21 @@ 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'); +});