diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index c4edd40a..906f25ed 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -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; diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 546e3603..ca5cdd10 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -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 [ diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index 962e9803..4d30e587 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -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)], ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 5ccb5784..33e26274 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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', ]; } diff --git a/app/Services/UserMonthPeriodService.php b/app/Services/UserMonthPeriodService.php new file mode 100644 index 00000000..90581a50 --- /dev/null +++ b/app/Services/UserMonthPeriodService.php @@ -0,0 +1,54 @@ + */ + 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; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 3a6a4e3f..bb598145 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -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), diff --git a/database/migrations/2026_05_27_120000_add_month_start_day_to_users_table.php b/database/migrations/2026_05_27_120000_add_month_start_day_to_users_table.php new file mode 100644 index 00000000..ea8aa6a1 --- /dev/null +++ b/database/migrations/2026_05_27_120000_add_month_start_day_to_users_table.php @@ -0,0 +1,22 @@ +unsignedTinyInteger('month_start_day')->default(1)->after('timezone'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('month_start_day'); + }); + } +}; diff --git a/resources/js/components/cashflow/period-navigation.test.tsx b/resources/js/components/cashflow/period-navigation.test.tsx index 65d5092a..84ad8b42 100644 --- a/resources/js/components/cashflow/period-navigation.test.tsx +++ b/resources/js/components/cashflow/period-navigation.test.tsx @@ -23,6 +23,7 @@ describe('PeriodNavigation', () => { undefined} onPeriodTypeChange={() => undefined} />, diff --git a/resources/js/components/cashflow/period-navigation.tsx b/resources/js/components/cashflow/period-navigation.tsx index 1567dc0d..5c08e632 100644 --- a/resources/js/components/cashflow/period-navigation.tsx +++ b/resources/js/components/cashflow/period-navigation.tsx @@ -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)}