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:
parent
89e31bb54e
commit
26c83c710f
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue