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:
parent
9c3c4d573e
commit
2762acb240
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -23,6 +23,7 @@ describe('PeriodNavigation', () => {
|
|||
<PeriodNavigation
|
||||
currentDate={new Date(2026, 0, 1)}
|
||||
periodType="month"
|
||||
monthStartDay={1}
|
||||
onDateChange={() => undefined}
|
||||
onPeriodTypeChange={() => undefined}
|
||||
/>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue