Add Budgeting Feature to Track and Manage Spending (#36)
## Overview We're excited to introduce budgeting capabilities to Whisper Money! This feature helps you take control of your finances by setting spending limits and tracking your progress over time. ## Screenshots <img width="1316" height="793" alt="image" src="https://github.com/user-attachments/assets/ac394d36-cded-4ea4-9883-120785e260f1" /> <img width="1315" height="907" alt="image" src="https://github.com/user-attachments/assets/7c682474-5aa7-4388-b626-29b56f5ebbef" /> <img width="1315" height="992" alt="image" src="https://github.com/user-attachments/assets/21eace45-23c6-472d-9aa0-0feb6db3fba4" /> ## What's New ### Create Flexible Budgets - Set budgets for specific categories or labels - Choose from monthly, weekly, bi-weekly, or custom periods - Set your own budget start date for better alignment with your pay schedule ### Track Your Spending - Visual spending charts show how much you've spent vs. your budget - See at a glance which budgets are on track and which need attention - View all transactions that count toward each budget ### Smart Budget Management - **Carry Over**: Unused budget amounts automatically roll into the next period - **Reset**: Unused amounts return to your available money pool - Edit or delete budgets anytime as your needs change ### Easy Access - New Budgets section in the main navigation - Quick overview cards showing budget status - Detailed budget pages with spending history and transaction lists ## How It Works 1. Create a budget by selecting a category or label and setting your spending limit 2. Your transactions are automatically matched to relevant budgets 3. Track your progress with visual charts and spending summaries 4. Adjust your budgets as needed to stay on track with your financial goals This feature is now available behind a feature flag and can be enabled for users who want to start budgeting their expenses.
This commit is contained in:
parent
f1a2d787e5
commit
9b6c30775f
|
|
@ -8,4 +8,4 @@
|
|||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class BackfillUserCurrencyCode extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'app:backfill-user-currency-code';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Backfill currency_code for users based on their first account';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Starting currency code backfill...');
|
||||
|
||||
// Get all users without a currency_code
|
||||
$users = User::whereNull('currency_code')
|
||||
->has('accounts')
|
||||
->with(['accounts' => function ($query) {
|
||||
$query->orderBy('created_at', 'asc')->limit(1);
|
||||
}])
|
||||
->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->info('No users found that need currency code backfill.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$users->count()} users to update.");
|
||||
|
||||
$bar = $this->output->createProgressBar($users->count());
|
||||
$bar->start();
|
||||
|
||||
$updated = 0;
|
||||
|
||||
foreach ($users as $user) {
|
||||
$firstAccount = $user->accounts->first();
|
||||
|
||||
if ($firstAccount && $firstAccount->currency_code) {
|
||||
$user->update(['currency_code' => $firstAccount->currency_code]);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
|
||||
$this->info("Successfully updated {$updated} users with currency codes.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,24 +14,41 @@ trait ResolvesFeatures
|
|||
return $featureClass;
|
||||
}
|
||||
|
||||
if ($this->isStringBasedFeature($name)) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getAvailableFeatures(): string
|
||||
{
|
||||
$featuresPath = app_path('Features');
|
||||
|
||||
if (! File::isDirectory($featuresPath)) {
|
||||
return 'None';
|
||||
}
|
||||
|
||||
$files = File::files($featuresPath);
|
||||
$features = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$features[] = $file->getFilenameWithoutExtension();
|
||||
$featuresPath = app_path('Features');
|
||||
|
||||
if (File::isDirectory($featuresPath)) {
|
||||
$files = File::files($featuresPath);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$features[] = $file->getFilenameWithoutExtension();
|
||||
}
|
||||
}
|
||||
|
||||
return implode(', ', $features) ?: 'None';
|
||||
$stringFeatures = $this->getStringBasedFeatures();
|
||||
|
||||
$allFeatures = array_merge($features, $stringFeatures);
|
||||
|
||||
return implode(', ', array_unique($allFeatures)) ?: 'None';
|
||||
}
|
||||
|
||||
private function isStringBasedFeature(string $name): bool
|
||||
{
|
||||
return in_array($name, $this->getStringBasedFeatures(), true);
|
||||
}
|
||||
|
||||
private function getStringBasedFeatures(): array
|
||||
{
|
||||
return ['budgets'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class FeatureDisableCommand extends Command
|
|||
{
|
||||
use ResolvesFeatures;
|
||||
|
||||
protected $signature = 'feature:disable {feature : The feature class name} {target : User email or "all" for everyone}';
|
||||
protected $signature = 'feature:disable {feature : The feature name (class name or string-based feature)} {target : User email or "all" for everyone}';
|
||||
|
||||
protected $description = 'Disable a feature for a specific user or all users';
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class FeatureEnableCommand extends Command
|
|||
{
|
||||
use ResolvesFeatures;
|
||||
|
||||
protected $signature = 'feature:enable {feature : The feature class name} {target : User email or "all" for everyone}';
|
||||
protected $signature = 'feature:enable {feature : The feature name (class name or string-based feature)} {target : User email or "all" for everyone}';
|
||||
|
||||
protected $description = 'Enable a feature for a specific user or all users';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetPeriod;
|
||||
use App\Services\BudgetPeriodService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GenerateBudgetPeriods extends Command
|
||||
{
|
||||
protected $signature = 'budgets:generate-periods';
|
||||
|
||||
protected $description = 'Generate upcoming budget periods and close completed ones';
|
||||
|
||||
public function __construct(protected BudgetPeriodService $budgetPeriodService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Generating budget periods...');
|
||||
|
||||
$budgets = Budget::with('periods')->get();
|
||||
$generatedCount = 0;
|
||||
$closedCount = 0;
|
||||
|
||||
foreach ($budgets as $budget) {
|
||||
$currentPeriod = $budget->getCurrentPeriod();
|
||||
|
||||
if (! $currentPeriod) {
|
||||
$this->budgetPeriodService->generatePeriod($budget);
|
||||
$generatedCount++;
|
||||
$this->info("Generated initial period for budget: {$budget->name}");
|
||||
}
|
||||
|
||||
$completedPeriods = BudgetPeriod::where('budget_id', $budget->id)
|
||||
->where('end_date', '<', now()->subDay())
|
||||
->get();
|
||||
|
||||
foreach ($completedPeriods as $period) {
|
||||
if ($period->end_date < now()->subDay()) {
|
||||
$this->budgetPeriodService->closePeriod($period);
|
||||
$closedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$futurePeriods = $budget->periods()
|
||||
->where('start_date', '>', now())
|
||||
->count();
|
||||
|
||||
if ($futurePeriods < 2) {
|
||||
$this->budgetPeriodService->generatePeriod($budget);
|
||||
$generatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Generated {$generatedCount} new periods");
|
||||
$this->info("Closed {$closedCount} completed periods");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,18 +4,24 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Actions\CreateDefaultCategories;
|
||||
use App\Enums\AccountType;
|
||||
use App\Enums\BudgetPeriodType;
|
||||
use App\Enums\RolloverType;
|
||||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Budget;
|
||||
use App\Models\Category;
|
||||
use App\Models\EncryptedMessage;
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
use App\Services\BudgetPeriodService;
|
||||
use App\Services\BudgetTransactionService;
|
||||
use App\Services\Demo\DemoAutomationRulesProvider;
|
||||
use App\Services\Demo\DemoEncryptionService;
|
||||
use App\Services\Demo\DemoLabelsProvider;
|
||||
use App\Services\Demo\DemoTransactionsProvider;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class ResetDemoAccountCommand extends Command
|
||||
{
|
||||
|
|
@ -32,6 +38,8 @@ class ResetDemoAccountCommand extends Command
|
|||
private DemoLabelsProvider $labelsProvider,
|
||||
private DemoAutomationRulesProvider $rulesProvider,
|
||||
private DemoEncryptionService $encryptionService,
|
||||
private BudgetPeriodService $budgetPeriodService,
|
||||
private BudgetTransactionService $budgetTransactionService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
|
@ -67,6 +75,12 @@ class ResetDemoAccountCommand extends Command
|
|||
|
||||
$this->createAutomationRules($user, $labels);
|
||||
|
||||
$this->createBudgets($user);
|
||||
|
||||
$this->enableBudgetsFeature($user);
|
||||
|
||||
$this->assignTransactionsToBudgets($user);
|
||||
|
||||
$this->createSubscription($user);
|
||||
|
||||
$this->info('✓ Demo account reset successfully!');
|
||||
|
|
@ -102,6 +116,7 @@ class ResetDemoAccountCommand extends Command
|
|||
$user->labels()->forceDelete();
|
||||
$user->automationRules()->forceDelete();
|
||||
$user->categories()->forceDelete();
|
||||
$user->budgets()->forceDelete();
|
||||
$user->encryptedMessage()?->delete();
|
||||
|
||||
$this->info(' Deleted existing data');
|
||||
|
|
@ -214,7 +229,8 @@ class ResetDemoAccountCommand extends Command
|
|||
],
|
||||
];
|
||||
|
||||
$totalTransactions = 0;
|
||||
$createdAccounts = [];
|
||||
$transactionAccounts = [];
|
||||
|
||||
foreach ($accounts as $index => $accountData) {
|
||||
$encrypted = $this->encryptionService->encrypt(
|
||||
|
|
@ -233,13 +249,16 @@ class ResetDemoAccountCommand extends Command
|
|||
|
||||
$this->createBalanceHistory($account, $accountData['current_balance'], $accountData['monthly_variance']);
|
||||
|
||||
$createdAccounts[] = $account;
|
||||
|
||||
if ($this->accountTypeHasTransactions($accountData['type'])) {
|
||||
$transactionCount = $this->createTransactionsForAccount($account, $categories, $labels);
|
||||
$totalTransactions += $transactionCount;
|
||||
$transactionAccounts[] = $account;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(" Created 5 accounts with {$totalTransactions} transactions and 12 months of balances");
|
||||
$totalTransactions = $this->createMixedTransactions($transactionAccounts, $categories, $labels);
|
||||
|
||||
$this->info(' Created '.count($createdAccounts)." accounts with {$totalTransactions} transactions and 12 months of balances");
|
||||
}
|
||||
|
||||
private function generateRealisticBalance(int $min, int $max): int
|
||||
|
|
@ -297,15 +316,21 @@ class ResetDemoAccountCommand extends Command
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Account> $accounts
|
||||
* @param Collection<string, Category> $categories
|
||||
* @param array<int, array{label: Label, assignment_percentage: int}> $labels
|
||||
*/
|
||||
private function createTransactionsForAccount(Account $account, Collection $categories, array $labels): int
|
||||
private function createMixedTransactions(array $accounts, Collection $categories, array $labels): int
|
||||
{
|
||||
$transactions = $this->transactionsProvider->getTransactions();
|
||||
if (empty($accounts)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$allTransactions = $this->transactionsProvider->getTransactions();
|
||||
$transactionIndex = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($transactions as $index => $transactionData) {
|
||||
foreach ($allTransactions as $transactionData) {
|
||||
$categoryName = $transactionData['category_name'];
|
||||
unset($transactionData['category_name']);
|
||||
|
||||
|
|
@ -315,10 +340,12 @@ class ResetDemoAccountCommand extends Command
|
|||
continue;
|
||||
}
|
||||
|
||||
$account = $accounts[array_rand($accounts)];
|
||||
|
||||
$encrypted = $this->encryptionService->encrypt(
|
||||
$transactionData['description'],
|
||||
$this->encryptionKey,
|
||||
"demo_tx_{$account->id}_{$index}"
|
||||
"demo_tx_{$account->id}_{$transactionIndex}"
|
||||
);
|
||||
|
||||
$transactionData['description'] = $encrypted['encrypted'];
|
||||
|
|
@ -336,6 +363,7 @@ class ResetDemoAccountCommand extends Command
|
|||
}
|
||||
}
|
||||
|
||||
$transactionIndex++;
|
||||
$count++;
|
||||
}
|
||||
|
||||
|
|
@ -378,6 +406,141 @@ class ResetDemoAccountCommand extends Command
|
|||
return $type !== AccountType::Investment && $type !== AccountType::Retirement;
|
||||
}
|
||||
|
||||
private function createBudgets(User $user): void
|
||||
{
|
||||
$categories = $user->categories()->get()->keyBy('name');
|
||||
|
||||
$groceriesCategory = $categories->get('Groceries');
|
||||
$diningCategory = $categories->get('Cafes, restaurants, bars');
|
||||
|
||||
if (! $groceriesCategory || ! $diningCategory) {
|
||||
$this->warn(' Skipping budget creation - required categories not found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$budget1 = Budget::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Monthly Groceries',
|
||||
'period_type' => BudgetPeriodType::Monthly,
|
||||
'period_start_day' => 1,
|
||||
'category_id' => $groceriesCategory->id,
|
||||
'rollover_type' => RolloverType::CarryOver,
|
||||
]);
|
||||
|
||||
$this->generateHistoricalPeriods($budget1, 320000);
|
||||
|
||||
$budget2 = Budget::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Weekly Dining Out',
|
||||
'period_type' => BudgetPeriodType::Weekly,
|
||||
'period_start_day' => 0,
|
||||
'category_id' => $diningCategory->id,
|
||||
'rollover_type' => RolloverType::Reset,
|
||||
]);
|
||||
|
||||
$this->generateHistoricalPeriods($budget2, 20000);
|
||||
|
||||
$groceriesPeriodCount = $budget1->periods()->count();
|
||||
$diningPeriodCount = $budget2->periods()->count();
|
||||
$groceriesCurrentPeriod = $budget1->getCurrentPeriod();
|
||||
$diningCurrentPeriod = $budget2->getCurrentPeriod();
|
||||
|
||||
$this->info(' Created 2 budgets with historical periods');
|
||||
$this->info(" - Monthly Groceries: {$groceriesPeriodCount} periods".($groceriesCurrentPeriod ? ' (has current period)' : ' (no current period)'));
|
||||
$this->info(" - Weekly Dining Out: {$diningPeriodCount} periods".($diningCurrentPeriod ? ' (has current period)' : ' (no current period)'));
|
||||
}
|
||||
|
||||
private function generateHistoricalPeriods(Budget $budget, int $allocatedAmount): void
|
||||
{
|
||||
$transactionStartDate = now()->subMonths(12);
|
||||
$endDate = now()->addWeek();
|
||||
|
||||
$currentDate = $transactionStartDate->copy();
|
||||
|
||||
if ($budget->period_type === BudgetPeriodType::Weekly) {
|
||||
$dayOfWeek = $budget->period_start_day ?? 0;
|
||||
while ($currentDate->dayOfWeek !== $dayOfWeek) {
|
||||
$currentDate->subDay();
|
||||
}
|
||||
} elseif ($budget->period_type === BudgetPeriodType::Monthly) {
|
||||
$currentDate->startOfMonth();
|
||||
if ($budget->period_start_day) {
|
||||
$currentDate->day($budget->period_start_day);
|
||||
}
|
||||
}
|
||||
|
||||
$maxIterations = 1000;
|
||||
$iteration = 0;
|
||||
|
||||
while ($currentDate->lte($endDate) && $iteration < $maxIterations) {
|
||||
$period = $this->budgetPeriodService->generatePeriod($budget, $allocatedAmount, $currentDate);
|
||||
|
||||
if ($period->end_date->gte($endDate)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$currentDate = $period->end_date->copy()->addDay();
|
||||
|
||||
switch ($budget->period_type) {
|
||||
case BudgetPeriodType::Monthly:
|
||||
if ($budget->period_start_day) {
|
||||
$currentDate->day($budget->period_start_day);
|
||||
} else {
|
||||
$currentDate->startOfMonth();
|
||||
}
|
||||
break;
|
||||
case BudgetPeriodType::Weekly:
|
||||
$dayOfWeek = $budget->period_start_day ?? 0;
|
||||
while ($currentDate->dayOfWeek !== $dayOfWeek && $currentDate->lte($endDate)) {
|
||||
$currentDate->addDay();
|
||||
}
|
||||
break;
|
||||
case BudgetPeriodType::Biweekly:
|
||||
$dayOfWeek = $budget->period_start_day ?? 0;
|
||||
while ($currentDate->dayOfWeek !== $dayOfWeek && $currentDate->lte($endDate)) {
|
||||
$currentDate->addDay();
|
||||
}
|
||||
break;
|
||||
case BudgetPeriodType::Custom:
|
||||
break;
|
||||
}
|
||||
|
||||
$iteration++;
|
||||
}
|
||||
}
|
||||
|
||||
private function enableBudgetsFeature(User $user): void
|
||||
{
|
||||
Feature::for($user)->activate('budgets');
|
||||
}
|
||||
|
||||
private function assignTransactionsToBudgets(User $user): void
|
||||
{
|
||||
$transactions = $user->transactions()->get();
|
||||
$assignedCount = 0;
|
||||
$budgetAssignments = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$this->budgetTransactionService->assignTransaction($transaction);
|
||||
$transaction->refresh();
|
||||
$budgetTransactions = $transaction->budgetTransactions()->with('budgetPeriod.budget')->get();
|
||||
|
||||
if ($budgetTransactions->isNotEmpty()) {
|
||||
$assignedCount++;
|
||||
foreach ($budgetTransactions as $budgetTransaction) {
|
||||
$budgetName = $budgetTransaction->budgetPeriod->budget->name;
|
||||
$budgetAssignments[$budgetName] = ($budgetAssignments[$budgetName] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(" Assigned {$assignedCount} transactions to budgets");
|
||||
foreach ($budgetAssignments as $budgetName => $count) {
|
||||
$this->info(" - {$budgetName}: {$count} transactions");
|
||||
}
|
||||
}
|
||||
|
||||
private function createSubscription(User $user): void
|
||||
{
|
||||
$user->subscriptions()->delete();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BudgetPeriodType: string
|
||||
{
|
||||
case Monthly = 'monthly';
|
||||
case Weekly = 'weekly';
|
||||
case Biweekly = 'biweekly';
|
||||
case Custom = 'custom';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Monthly => 'Monthly',
|
||||
self::Weekly => 'Weekly',
|
||||
self::Biweekly => 'Bi-weekly',
|
||||
self::Custom => 'Custom',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum RolloverType: string
|
||||
{
|
||||
case CarryOver = 'carry_over';
|
||||
case Reset = 'reset';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CarryOver => 'Carry Over',
|
||||
self::Reset => 'Reset/Pool',
|
||||
};
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CarryOver => 'Remaining balance carries over to next period',
|
||||
self::Reset => 'Remaining balance returns to available money pool',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TransactionCreated
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(public Transaction $transaction) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TransactionDeleted
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(public Transaction $transaction) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TransactionUpdated
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(public Transaction $transaction) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreBudgetRequest;
|
||||
use App\Http\Requests\UpdateBudgetRequest;
|
||||
use App\Models\Budget;
|
||||
use App\Services\BudgetPeriodService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class BudgetController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __construct(protected BudgetPeriodService $budgetPeriodService) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$budgets = $user
|
||||
->budgets()
|
||||
->with(['category', 'label', 'periods' => function ($query) {
|
||||
$query->where('start_date', '<=', now())
|
||||
->where('end_date', '>=', now())
|
||||
->with(['budgetTransactions']);
|
||||
}])
|
||||
->get();
|
||||
|
||||
return Inertia::render('budgets/index', [
|
||||
'budgets' => $budgets,
|
||||
'currencyCode' => $user->currency_code ?? 'USD',
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Budget $budget): Response
|
||||
{
|
||||
$this->authorize('view', $budget);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$currentPeriod = $budget->getCurrentPeriod();
|
||||
|
||||
if (! $currentPeriod) {
|
||||
$currentPeriod = $this->budgetPeriodService->generatePeriod($budget);
|
||||
}
|
||||
|
||||
$currentPeriod->load([
|
||||
'budgetTransactions.transaction.account.bank',
|
||||
'budgetTransactions.transaction.category',
|
||||
'budgetTransactions.transaction.labels',
|
||||
]);
|
||||
|
||||
$budget->load(['category', 'label']);
|
||||
|
||||
$categories = \App\Models\Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color']);
|
||||
|
||||
$accounts = \App\Models\Account::query()
|
||||
->where('user_id', $user->id)
|
||||
->with('bank:id,name,logo')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
|
||||
|
||||
$banks = \App\Models\Bank::query()
|
||||
->where(function ($q) use ($user) {
|
||||
$q->whereNull('user_id')
|
||||
->orWhere('user_id', $user->id);
|
||||
})
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'logo']);
|
||||
|
||||
return Inertia::render('budgets/show', [
|
||||
'budget' => $budget,
|
||||
'currentPeriod' => $currentPeriod,
|
||||
'categories' => $categories,
|
||||
'accounts' => $accounts,
|
||||
'banks' => $banks,
|
||||
'currencyCode' => $user->currency_code ?? 'USD',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreBudgetRequest $request): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$budget = DB::transaction(function () use ($request) {
|
||||
$budget = $request->user()->budgets()->create([
|
||||
'name' => $request->name,
|
||||
'period_type' => $request->period_type,
|
||||
'period_duration' => $request->period_duration,
|
||||
'period_start_day' => $request->period_start_day,
|
||||
'category_id' => $request->category_id,
|
||||
'label_id' => $request->label_id,
|
||||
'rollover_type' => $request->rollover_type,
|
||||
]);
|
||||
|
||||
$period = $this->budgetPeriodService->generatePeriod($budget, $request->allocated_amount);
|
||||
|
||||
return $budget;
|
||||
});
|
||||
|
||||
return redirect()->route('budgets.show', $budget);
|
||||
}
|
||||
|
||||
public function update(UpdateBudgetRequest $request, Budget $budget): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $budget);
|
||||
|
||||
DB::transaction(function () use ($request, $budget) {
|
||||
$budget->update($request->only([
|
||||
'name',
|
||||
'period_type',
|
||||
'period_duration',
|
||||
'period_start_day',
|
||||
'category_id',
|
||||
'label_id',
|
||||
'rollover_type',
|
||||
]));
|
||||
|
||||
// If allocated_amount is provided, update current and future periods
|
||||
if ($request->has('allocated_amount')) {
|
||||
$budget->periods()
|
||||
->where('start_date', '>=', now()->startOfDay())
|
||||
->update(['allocated_amount' => $request->allocated_amount]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('budgets.show', $budget);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Budget $budget): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $budget);
|
||||
|
||||
$budget->delete();
|
||||
|
||||
return redirect()->route('budgets.index');
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,13 @@ class AccountController extends Controller
|
|||
*/
|
||||
public function store(StoreAccountRequest $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$account = auth()->user()->accounts()->create($request->validated());
|
||||
$user = auth()->user();
|
||||
$account = $user->accounts()->create($request->validated());
|
||||
|
||||
// Set user's currency_code from first account if not already set
|
||||
if (is_null($user->currency_code)) {
|
||||
$user->update(['currency_code' => $account->currency_code]);
|
||||
}
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json($account, 201);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureBudgetsFeature
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* Returns 404 if user doesn't have budgets feature enabled.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user || ! Feature::for($user)->active('budgets')) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Middleware;
|
|||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
|
|
@ -68,6 +69,7 @@ class HandleInertiaRequests extends Middleware
|
|||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'features' => [
|
||||
'cashflow' => true,
|
||||
'budgets' => $user ? Feature::for($user)->active('budgets') : false,
|
||||
],
|
||||
'accounts' => fn () => $user ? $user->accounts()
|
||||
->with('bank:id,name,logo')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\BudgetPeriodType;
|
||||
use App\Enums\RolloverType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreBudgetRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$userId = $this->user()->id;
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'period_type' => ['required', Rule::enum(BudgetPeriodType::class)],
|
||||
'period_duration' => ['nullable', 'integer', 'min:1', 'max:365'],
|
||||
'period_start_day' => ['nullable', 'integer', 'min:0', 'max:31'],
|
||||
'category_id' => ['nullable', Rule::exists('categories', 'id')->where('user_id', $userId)],
|
||||
'label_id' => ['nullable', Rule::exists('labels', 'id')->where('user_id', $userId)],
|
||||
'rollover_type' => ['required', Rule::enum(RolloverType::class)],
|
||||
'allocated_amount' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$hasCategoryId = ! empty($this->category_id);
|
||||
$hasLabelId = ! empty($this->label_id);
|
||||
|
||||
if (! $hasCategoryId && ! $hasLabelId) {
|
||||
$validator->errors()->add(
|
||||
'selection',
|
||||
'You must select either a category or a label.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\BudgetPeriodType;
|
||||
use App\Enums\RolloverType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateBudgetRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$userId = $this->user()->id;
|
||||
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'period_type' => ['sometimes', Rule::enum(BudgetPeriodType::class)],
|
||||
'period_duration' => ['nullable', 'integer', 'min:1', 'max:365'],
|
||||
'period_start_day' => ['nullable', 'integer', 'min:0', 'max:31'],
|
||||
'category_id' => ['nullable', Rule::exists('categories', 'id')->where('user_id', $userId)],
|
||||
'label_id' => ['nullable', Rule::exists('labels', 'id')->where('user_id', $userId)],
|
||||
'rollover_type' => ['sometimes', Rule::enum(RolloverType::class)],
|
||||
'allocated_amount' => ['sometimes', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\TransactionCreated;
|
||||
use App\Events\TransactionUpdated;
|
||||
use App\Services\BudgetTransactionService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class AssignTransactionToBudget implements ShouldQueue
|
||||
{
|
||||
public function __construct(protected BudgetTransactionService $budgetTransactionService) {}
|
||||
|
||||
public function handle(TransactionCreated|TransactionUpdated $event): void
|
||||
{
|
||||
$transaction = $event->transaction;
|
||||
$user = $transaction->user;
|
||||
|
||||
if (! $user || ! Feature::for($user)->active('budgets')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->budgetTransactionService->assignTransaction($transaction);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\TransactionDeleted;
|
||||
use App\Services\BudgetTransactionService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class UnassignTransactionFromBudget implements ShouldQueue
|
||||
{
|
||||
public function __construct(protected BudgetTransactionService $budgetTransactionService) {}
|
||||
|
||||
public function handle(TransactionDeleted $event): void
|
||||
{
|
||||
$transaction = $event->transaction;
|
||||
$user = $transaction->user;
|
||||
|
||||
if (! $user || ! Feature::for($user)->active('budgets')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->budgetTransactionService->unassignTransaction($transaction);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\BudgetPeriodType;
|
||||
use App\Enums\RolloverType;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Budget extends Model
|
||||
{
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'period_type',
|
||||
'period_duration',
|
||||
'period_start_day',
|
||||
'category_id',
|
||||
'label_id',
|
||||
'rollover_type',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'period_type' => BudgetPeriodType::class,
|
||||
'rollover_type' => RolloverType::class,
|
||||
'period_duration' => 'integer',
|
||||
'period_start_day' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function label(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Label::class);
|
||||
}
|
||||
|
||||
public function periods(): HasMany
|
||||
{
|
||||
return $this->hasMany(BudgetPeriod::class);
|
||||
}
|
||||
|
||||
public function getCurrentPeriod(): ?BudgetPeriod
|
||||
{
|
||||
return $this->periods()
|
||||
->where('start_date', '<=', now())
|
||||
->where('end_date', '>=', now())
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class BudgetPeriod extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'budget_id',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'allocated_amount',
|
||||
'carried_over_amount',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'allocated_amount' => 'integer',
|
||||
'carried_over_amount' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function budget(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Budget::class);
|
||||
}
|
||||
|
||||
public function budgetTransactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(BudgetTransaction::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BudgetTransaction extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'transaction_id',
|
||||
'budget_period_id',
|
||||
'amount',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function transaction(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Transaction::class);
|
||||
}
|
||||
|
||||
public function budgetPeriod(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BudgetPeriod::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,15 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Events\TransactionCreated;
|
||||
use App\Events\TransactionDeleted;
|
||||
use App\Events\TransactionUpdated;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Transaction extends Model
|
||||
|
|
@ -15,6 +19,13 @@ class Transaction extends Model
|
|||
/** @use HasFactory<\Database\Factories\TransactionFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
/** @var array<string, class-string> */
|
||||
protected $dispatchesEvents = [
|
||||
'created' => TransactionCreated::class,
|
||||
'updated' => TransactionUpdated::class,
|
||||
'deleted' => TransactionDeleted::class,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'account_id',
|
||||
|
|
@ -59,4 +70,9 @@ class Transaction extends Model
|
|||
->using(LabelTransaction::class)
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function budgetTransactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(BudgetTransaction::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
|||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Pennant\Concerns\HasFeatures;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use Billable, HasFactory, HasUuids, Notifiable, TwoFactorAuthenticatable;
|
||||
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
@ -109,6 +110,11 @@ class User extends Authenticatable
|
|||
return $this->hasMany(UserMailLog::class);
|
||||
}
|
||||
|
||||
public function budgets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Budget::class);
|
||||
}
|
||||
|
||||
public function hasReceivedEmail(DripEmailType $type): bool
|
||||
{
|
||||
return $this->mailLogs()->where('email_type', $type)->exists();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\User;
|
||||
|
||||
class BudgetPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, Budget $budget): bool
|
||||
{
|
||||
return $user->id === $budget->user_id;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function update(User $user, Budget $budget): bool
|
||||
{
|
||||
return $user->id === $budget->user_id;
|
||||
}
|
||||
|
||||
public function delete(User $user, Budget $budget): bool
|
||||
{
|
||||
return $user->id === $budget->user_id;
|
||||
}
|
||||
|
||||
public function restore(User $user, Budget $budget): bool
|
||||
{
|
||||
return $user->id === $budget->user_id;
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Budget $budget): bool
|
||||
{
|
||||
return $user->id === $budget->user_id;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,19 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Events\TransactionCreated;
|
||||
use App\Events\TransactionDeleted;
|
||||
use App\Events\TransactionUpdated;
|
||||
use App\Http\Responses\RegisterResponse;
|
||||
use App\Listeners\AssignTransactionToBudget;
|
||||
use App\Listeners\UnassignTransactionFromBudget;
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -23,8 +31,14 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Event::listen(TransactionCreated::class, AssignTransactionToBudget::class);
|
||||
Event::listen(TransactionUpdated::class, AssignTransactionToBudget::class);
|
||||
Event::listen(TransactionDeleted::class, UnassignTransactionFromBudget::class);
|
||||
|
||||
RateLimiter::for('emails', function (object $job): Limit {
|
||||
return Limit::perSecond(30);
|
||||
});
|
||||
|
||||
Feature::define('budgets', fn (User $user) => false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\BudgetPeriodType;
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetPeriod;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class BudgetPeriodService
|
||||
{
|
||||
public function generatePeriod(Budget $budget, ?int $allocatedAmount = null, ?Carbon $startDate = null): BudgetPeriod
|
||||
{
|
||||
if ($startDate === null) {
|
||||
$startDate = $this->calculateNextPeriodStartDate($budget);
|
||||
}
|
||||
|
||||
[$periodStart, $periodEnd] = $this->calculatePeriodDates($budget, $startDate);
|
||||
|
||||
// If no allocated amount provided, use the last period's amount or 0
|
||||
if ($allocatedAmount === null) {
|
||||
$lastPeriod = $budget->periods()->orderBy('end_date', 'desc')->first();
|
||||
$allocatedAmount = $lastPeriod?->allocated_amount ?? 0;
|
||||
}
|
||||
|
||||
return BudgetPeriod::create([
|
||||
'budget_id' => $budget->id,
|
||||
'start_date' => $periodStart,
|
||||
'end_date' => $periodEnd,
|
||||
'allocated_amount' => $allocatedAmount,
|
||||
'carried_over_amount' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function closePeriod(BudgetPeriod $period): void
|
||||
{
|
||||
$budget = $period->budget;
|
||||
$carriedOverAmount = 0;
|
||||
|
||||
if ($budget->rollover_type->value === 'carry_over') {
|
||||
$totalSpent = $period->budgetTransactions()->sum('amount');
|
||||
$remaining = $period->allocated_amount - abs($totalSpent);
|
||||
|
||||
if ($remaining > 0) {
|
||||
$carriedOverAmount = $remaining;
|
||||
}
|
||||
}
|
||||
|
||||
$nextPeriod = $this->generatePeriod($budget, $period->allocated_amount);
|
||||
$nextPeriod->update(['carried_over_amount' => $carriedOverAmount]);
|
||||
}
|
||||
|
||||
public function calculatePeriodDates(Budget $budget, Carbon $referenceDate): array
|
||||
{
|
||||
$startDate = $referenceDate->copy();
|
||||
|
||||
switch ($budget->period_type) {
|
||||
case BudgetPeriodType::Monthly:
|
||||
$startDate->day($budget->period_start_day ?? 1);
|
||||
if ($startDate > $referenceDate) {
|
||||
$startDate->subMonth();
|
||||
}
|
||||
$endDate = $startDate->copy()->addMonth()->subDay();
|
||||
break;
|
||||
|
||||
case BudgetPeriodType::Weekly:
|
||||
$dayOfWeek = $budget->period_start_day ?? 0;
|
||||
while ($startDate->dayOfWeek !== $dayOfWeek) {
|
||||
$startDate->subDay();
|
||||
}
|
||||
$endDate = $startDate->copy()->addWeek()->subDay();
|
||||
break;
|
||||
|
||||
case BudgetPeriodType::Biweekly:
|
||||
$dayOfWeek = $budget->period_start_day ?? 0;
|
||||
while ($startDate->dayOfWeek !== $dayOfWeek) {
|
||||
$startDate->subDay();
|
||||
}
|
||||
$endDate = $startDate->copy()->addWeeks(2)->subDay();
|
||||
break;
|
||||
|
||||
case BudgetPeriodType::Custom:
|
||||
$duration = $budget->period_duration ?? 30;
|
||||
$startDate->day($budget->period_start_day ?? 1);
|
||||
if ($startDate > $referenceDate) {
|
||||
$startDate->subDays($duration);
|
||||
}
|
||||
$endDate = $startDate->copy()->addDays($duration)->subDay();
|
||||
break;
|
||||
|
||||
default:
|
||||
$endDate = $startDate->copy()->addMonth()->subDay();
|
||||
}
|
||||
|
||||
return [$startDate, $endDate];
|
||||
}
|
||||
|
||||
protected function calculateNextPeriodStartDate(Budget $budget): Carbon
|
||||
{
|
||||
$lastPeriod = $budget->periods()->orderBy('end_date', 'desc')->first();
|
||||
|
||||
if ($lastPeriod) {
|
||||
return $lastPeriod->end_date->copy()->addDay();
|
||||
}
|
||||
|
||||
return now();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\BudgetPeriod;
|
||||
use App\Models\BudgetTransaction;
|
||||
use App\Models\Transaction;
|
||||
|
||||
class BudgetTransactionService
|
||||
{
|
||||
public function assignTransaction(Transaction $transaction): void
|
||||
{
|
||||
// Remove any existing assignments first
|
||||
if ($transaction->budgetTransactions()->exists()) {
|
||||
$this->unassignTransaction($transaction);
|
||||
}
|
||||
|
||||
// Get the user who owns this transaction
|
||||
$userId = $transaction->user_id;
|
||||
|
||||
if (! $userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching budget periods for this user only
|
||||
$budgetPeriods = BudgetPeriod::query()
|
||||
->whereHas('budget', function ($query) use ($transaction, $userId) {
|
||||
// Scope to user's budgets only
|
||||
$query->where('user_id', $userId)
|
||||
->where(function ($q) use ($transaction) {
|
||||
// Match by category
|
||||
$q->where('category_id', $transaction->category_id)
|
||||
->orWhere(function ($labelQuery) use ($transaction) {
|
||||
// Match by label
|
||||
$labelQuery->whereHas('label', function ($lq) use ($transaction) {
|
||||
$lq->whereIn('id', $transaction->labels->pluck('id'));
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
->where('start_date', '<=', $transaction->transaction_date)
|
||||
->where('end_date', '>=', $transaction->transaction_date)
|
||||
->with('budget')
|
||||
->get();
|
||||
|
||||
foreach ($budgetPeriods as $period) {
|
||||
$budget = $period->budget;
|
||||
|
||||
// Double-check transaction matches budget criteria
|
||||
$matches = false;
|
||||
|
||||
if ($budget->category_id && $budget->category_id === $transaction->category_id) {
|
||||
$matches = true;
|
||||
}
|
||||
|
||||
if ($budget->label_id && $transaction->labels->contains('id', $budget->label_id)) {
|
||||
$matches = true;
|
||||
}
|
||||
|
||||
if ($matches) {
|
||||
BudgetTransaction::create([
|
||||
'transaction_id' => $transaction->id,
|
||||
'budget_period_id' => $period->id,
|
||||
'amount' => abs($transaction->amount),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function unassignTransaction(Transaction $transaction): void
|
||||
{
|
||||
BudgetTransaction::where('transaction_id', $transaction->id)->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -14,46 +14,145 @@ class DemoTransactionsProvider
|
|||
['description' => 'Whole Foods Market', 'amount_min' => -15000, 'amount_max' => -8000, 'category_name' => 'Groceries', 'frequency' => 'weekly'],
|
||||
['description' => 'Trader Joe\'s', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Groceries', 'frequency' => 'weekly'],
|
||||
['description' => 'Costco Wholesale', 'amount_min' => -25000, 'amount_max' => -12000, 'category_name' => 'Groceries', 'frequency' => 'monthly'],
|
||||
['description' => 'Safeway Supermarket', 'amount_min' => -12000, 'amount_max' => -6500, 'category_name' => 'Groceries', 'frequency' => 'weekly'],
|
||||
['description' => 'Publix Grocery Store', 'amount_min' => -11000, 'amount_max' => -6000, 'category_name' => 'Groceries', 'frequency' => 'weekly'],
|
||||
['description' => 'Kroger Marketplace', 'amount_min' => -9500, 'amount_max' => -5000, 'category_name' => 'Groceries', 'frequency' => 'weekly'],
|
||||
['description' => 'Aldi Grocery', 'amount_min' => -7500, 'amount_max' => -4000, 'category_name' => 'Groceries', 'frequency' => 'weekly'],
|
||||
['description' => 'Sprouts Farmers Market', 'amount_min' => -10000, 'amount_max' => -5500, 'category_name' => 'Groceries', 'frequency' => 'biweekly'],
|
||||
['description' => 'Starbucks Coffee', 'amount_min' => -850, 'amount_max' => -450, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'frequent'],
|
||||
['description' => 'Dunkin Donuts', 'amount_min' => -650, 'amount_max' => -350, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'frequent'],
|
||||
['description' => 'Peet\'s Coffee', 'amount_min' => -750, 'amount_max' => -400, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'frequent'],
|
||||
['description' => 'Local Coffee Shop', 'amount_min' => -900, 'amount_max' => -500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'frequent'],
|
||||
['description' => 'Chipotle Mexican Grill', 'amount_min' => -1800, 'amount_max' => -1200, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'],
|
||||
['description' => 'Olive Garden Restaurant', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Thai Palace Dinner', 'amount_min' => -6500, 'amount_max' => -3500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Sushi House', 'amount_min' => -7500, 'amount_max' => -4000, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Pizza Hut', 'amount_min' => -3500, 'amount_max' => -1800, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'biweekly'],
|
||||
['description' => 'Domino\'s Pizza', 'amount_min' => -3200, 'amount_max' => -1500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'biweekly'],
|
||||
['description' => 'Papa John\'s Pizza', 'amount_min' => -3000, 'amount_max' => -1400, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'biweekly'],
|
||||
['description' => 'Red Lobster', 'amount_min' => -9500, 'amount_max' => -5000, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'quarterly'],
|
||||
['description' => 'Outback Steakhouse', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'quarterly'],
|
||||
['description' => 'Applebees', 'amount_min' => -6500, 'amount_max' => -3500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'TGI Fridays', 'amount_min' => -7000, 'amount_max' => -3800, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Buffalo Wild Wings', 'amount_min' => -5500, 'amount_max' => -2800, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Panera Bread', 'amount_min' => -2500, 'amount_max' => -1200, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'],
|
||||
['description' => 'Subway', 'amount_min' => -1200, 'amount_max' => -700, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'],
|
||||
['description' => 'McDonald\'s', 'amount_min' => -1000, 'amount_max' => -600, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'],
|
||||
['description' => 'Burger King', 'amount_min' => -950, 'amount_max' => -550, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'],
|
||||
['description' => 'Taco Bell', 'amount_min' => -800, 'amount_max' => -450, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'],
|
||||
['description' => 'Indian Curry House', 'amount_min' => -6000, 'amount_max' => -3200, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Mediterranean Grill', 'amount_min' => -5500, 'amount_max' => -2800, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Chinese Restaurant', 'amount_min' => -5000, 'amount_max' => -2500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Italian Bistro', 'amount_min' => -7500, 'amount_max' => -4000, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Shell Gas Station', 'amount_min' => -6500, 'amount_max' => -3500, 'category_name' => 'Fuel', 'frequency' => 'biweekly'],
|
||||
['description' => 'Chevron Gas', 'amount_min' => -5800, 'amount_max' => -3200, 'category_name' => 'Fuel', 'frequency' => 'biweekly'],
|
||||
['description' => 'BP Gas Station', 'amount_min' => -6200, 'amount_max' => -3400, 'category_name' => 'Fuel', 'frequency' => 'biweekly'],
|
||||
['description' => 'Exxon Mobil', 'amount_min' => -6000, 'amount_max' => -3300, 'category_name' => 'Fuel', 'frequency' => 'biweekly'],
|
||||
['description' => 'Speedway Gas', 'amount_min' => -5500, 'amount_max' => -3000, 'category_name' => 'Fuel', 'frequency' => 'biweekly'],
|
||||
['description' => 'Salary Deposit - ACME Corp', 'amount_min' => 485000, 'amount_max' => 485000, 'category_name' => 'Salary', 'frequency' => 'monthly'],
|
||||
['description' => 'Freelance Payment - Web Design', 'amount_min' => 25000, 'amount_max' => 75000, 'category_name' => 'Salary', 'frequency' => 'monthly'],
|
||||
['description' => 'Quarterly Bonus', 'amount_min' => 50000, 'amount_max' => 150000, 'category_name' => 'Salary', 'frequency' => 'quarterly'],
|
||||
['description' => 'Electric Company - Monthly Bill', 'amount_min' => -18500, 'amount_max' => -9500, 'category_name' => 'Electricity', 'frequency' => 'monthly'],
|
||||
['description' => 'Water & Sewer Utility', 'amount_min' => -7500, 'amount_max' => -4500, 'category_name' => 'Water', 'frequency' => 'monthly'],
|
||||
['description' => 'Natural Gas Bill', 'amount_min' => -12000, 'amount_max' => -4500, 'category_name' => 'Natural gas', 'frequency' => 'monthly'],
|
||||
['description' => 'Trash Collection Service', 'amount_min' => -3500, 'amount_max' => -2000, 'category_name' => 'Water', 'frequency' => 'monthly'],
|
||||
['description' => 'Comcast Internet & Cable', 'amount_min' => -15999, 'amount_max' => -12999, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'],
|
||||
['description' => 'T-Mobile Wireless', 'amount_min' => -8500, 'amount_max' => -7500, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'],
|
||||
['description' => 'Verizon Wireless', 'amount_min' => -9000, 'amount_max' => -8000, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'],
|
||||
['description' => 'AT&T Mobile', 'amount_min' => -8800, 'amount_max' => -7800, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'],
|
||||
['description' => 'Spectrum Internet', 'amount_min' => -7999, 'amount_max' => -6999, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'],
|
||||
['description' => 'Netflix Subscription', 'amount_min' => -1599, 'amount_max' => -1599, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'Spotify Premium', 'amount_min' => -1099, 'amount_max' => -1099, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'Amazon Prime', 'amount_min' => -1499, 'amount_max' => -1499, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'Disney+ Subscription', 'amount_min' => -1099, 'amount_max' => -1099, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'Chipotle Mexican Grill', 'amount_min' => -1800, 'amount_max' => -1200, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'],
|
||||
['description' => 'Olive Garden Restaurant', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Thai Palace Dinner', 'amount_min' => -6500, 'amount_max' => -3500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'],
|
||||
['description' => 'Hulu Subscription', 'amount_min' => -799, 'amount_max' => -799, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'HBO Max', 'amount_min' => -1599, 'amount_max' => -1599, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'Apple Music', 'amount_min' => -1099, 'amount_max' => -1099, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'YouTube Premium', 'amount_min' => -1399, 'amount_max' => -1399, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'Adobe Creative Cloud', 'amount_min' => -5499, 'amount_max' => -5499, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'Microsoft 365', 'amount_min' => -699, 'amount_max' => -699, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'Dropbox Plus', 'amount_min' => -999, 'amount_max' => -999, 'category_name' => 'Online services', 'frequency' => 'monthly'],
|
||||
['description' => 'DoorDash Delivery', 'amount_min' => -4500, 'amount_max' => -2500, 'category_name' => 'Food delivery', 'frequency' => 'weekly'],
|
||||
['description' => 'Uber Eats Order', 'amount_min' => -3800, 'amount_max' => -2200, 'category_name' => 'Food delivery', 'frequency' => 'weekly'],
|
||||
['description' => 'Grubhub Delivery', 'amount_min' => -4200, 'amount_max' => -2400, 'category_name' => 'Food delivery', 'frequency' => 'weekly'],
|
||||
['description' => 'Postmates', 'amount_min' => -4000, 'amount_max' => -2300, 'category_name' => 'Food delivery', 'frequency' => 'weekly'],
|
||||
['description' => 'Amazon.com Purchase', 'amount_min' => -15000, 'amount_max' => -2500, 'category_name' => 'Online transactions', 'frequency' => 'weekly'],
|
||||
['description' => 'eBay Purchase', 'amount_min' => -12000, 'amount_max' => -3000, 'category_name' => 'Online transactions', 'frequency' => 'biweekly'],
|
||||
['description' => 'Etsy Order', 'amount_min' => -8000, 'amount_max' => -2000, 'category_name' => 'Online transactions', 'frequency' => 'monthly'],
|
||||
['description' => 'Target Store', 'amount_min' => -12000, 'amount_max' => -3500, 'category_name' => 'Household goods', 'frequency' => 'biweekly'],
|
||||
['description' => 'Walmart Supercenter', 'amount_min' => -8500, 'amount_max' => -2500, 'category_name' => 'Other groceries', 'frequency' => 'biweekly'],
|
||||
['description' => 'Home Depot', 'amount_min' => -15000, 'amount_max' => -4000, 'category_name' => 'Household goods', 'frequency' => 'monthly'],
|
||||
['description' => 'Lowe\'s Home Improvement', 'amount_min' => -14000, 'amount_max' => -3500, 'category_name' => 'Household goods', 'frequency' => 'monthly'],
|
||||
['description' => 'Bed Bath & Beyond', 'amount_min' => -10000, 'amount_max' => -3000, 'category_name' => 'Household goods', 'frequency' => 'quarterly'],
|
||||
['description' => 'IKEA', 'amount_min' => -20000, 'amount_max' => -5000, 'category_name' => 'Household goods', 'frequency' => 'quarterly'],
|
||||
['description' => 'CVS Pharmacy', 'amount_min' => -4500, 'amount_max' => -1500, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'monthly'],
|
||||
['description' => 'Walgreens', 'amount_min' => -3500, 'amount_max' => -1200, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'monthly'],
|
||||
['description' => 'Rite Aid Pharmacy', 'amount_min' => -4000, 'amount_max' => -1300, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'monthly'],
|
||||
['description' => 'Doctor\'s Office Visit', 'amount_min' => -15000, 'amount_max' => -8000, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'quarterly'],
|
||||
['description' => 'Dentist Appointment', 'amount_min' => -12000, 'amount_max' => -6000, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'quarterly'],
|
||||
['description' => 'Pharmacy Prescription', 'amount_min' => -5000, 'amount_max' => -2000, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'monthly'],
|
||||
['description' => 'Planet Fitness Monthly', 'amount_min' => -2499, 'amount_max' => -2499, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'],
|
||||
['description' => '24 Hour Fitness', 'amount_min' => -3999, 'amount_max' => -3999, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'],
|
||||
['description' => 'LA Fitness Membership', 'amount_min' => -3499, 'amount_max' => -3499, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'],
|
||||
['description' => 'Golf Course Green Fees', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'],
|
||||
['description' => 'Tennis Court Rental', 'amount_min' => -3500, 'amount_max' => -1500, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'],
|
||||
['description' => 'ATM Cash Withdrawal', 'amount_min' => -30000, 'amount_max' => -10000, 'category_name' => 'Cash withdrawal', 'frequency' => 'biweekly'],
|
||||
['description' => 'State Farm Insurance', 'amount_min' => -15800, 'amount_max' => -12500, 'category_name' => 'Insurance', 'frequency' => 'monthly'],
|
||||
['description' => 'Geico Auto Insurance', 'amount_min' => -14500, 'amount_max' => -11000, 'category_name' => 'Insurance', 'frequency' => 'monthly'],
|
||||
['description' => 'Progressive Insurance', 'amount_min' => -15000, 'amount_max' => -12000, 'category_name' => 'Insurance', 'frequency' => 'monthly'],
|
||||
['description' => 'Health Insurance Premium', 'amount_min' => -35000, 'amount_max' => -25000, 'category_name' => 'Insurance', 'frequency' => 'monthly'],
|
||||
['description' => 'Rent Payment', 'amount_min' => -195000, 'amount_max' => -195000, 'category_name' => 'Rent and maintanence', 'frequency' => 'monthly'],
|
||||
['description' => 'Homeowners Association Fee', 'amount_min' => -25000, 'amount_max' => -15000, 'category_name' => 'Rent and maintanence', 'frequency' => 'monthly'],
|
||||
['description' => 'Property Management Fee', 'amount_min' => -12000, 'amount_max' => -8000, 'category_name' => 'Rent and maintanence', 'frequency' => 'monthly'],
|
||||
['description' => 'Uber Ride', 'amount_min' => -3500, 'amount_max' => -1200, 'category_name' => 'Transportation expenses', 'frequency' => 'weekly'],
|
||||
['description' => 'Lyft Ride', 'amount_min' => -2800, 'amount_max' => -1000, 'category_name' => 'Transportation expenses', 'frequency' => 'weekly'],
|
||||
['description' => 'Parking Garage', 'amount_min' => -2500, 'amount_max' => -800, 'category_name' => 'Parking', 'frequency' => 'weekly'],
|
||||
['description' => 'Street Parking Meter', 'amount_min' => -500, 'amount_max' => -200, 'category_name' => 'Parking', 'frequency' => 'weekly'],
|
||||
['description' => 'Toll Road Payment', 'amount_min' => -800, 'amount_max' => -300, 'category_name' => 'Transportation expenses', 'frequency' => 'weekly'],
|
||||
['description' => 'Car Wash', 'amount_min' => -1500, 'amount_max' => -800, 'category_name' => 'Transportation expenses', 'frequency' => 'monthly'],
|
||||
['description' => 'Oil Change Service', 'amount_min' => -4500, 'amount_max' => -2500, 'category_name' => 'Transportation expenses', 'frequency' => 'quarterly'],
|
||||
['description' => 'Auto Repair Shop', 'amount_min' => -25000, 'amount_max' => -8000, 'category_name' => 'Transportation expenses', 'frequency' => 'quarterly'],
|
||||
['description' => 'H&M Clothing', 'amount_min' => -8500, 'amount_max' => -3500, 'category_name' => 'Clothing and shoes', 'frequency' => 'monthly'],
|
||||
['description' => 'Nike Store', 'amount_min' => -15000, 'amount_max' => -6500, 'category_name' => 'Clothing and shoes', 'frequency' => 'quarterly'],
|
||||
['description' => 'Zara Fashion', 'amount_min' => -12000, 'amount_max' => -5000, 'category_name' => 'Clothing and shoes', 'frequency' => 'monthly'],
|
||||
['description' => 'Macy\'s Department Store', 'amount_min' => -18000, 'amount_max' => -8000, 'category_name' => 'Clothing and shoes', 'frequency' => 'quarterly'],
|
||||
['description' => 'Nordstrom', 'amount_min' => -25000, 'amount_max' => -12000, 'category_name' => 'Clothing and shoes', 'frequency' => 'quarterly'],
|
||||
['description' => 'Adidas Store', 'amount_min' => -13000, 'amount_max' => -6000, 'category_name' => 'Clothing and shoes', 'frequency' => 'quarterly'],
|
||||
['description' => 'AMC Movie Theater', 'amount_min' => -3500, 'amount_max' => -1500, 'category_name' => 'Theatre, music, cinema', 'frequency' => 'monthly'],
|
||||
['description' => 'Regal Cinemas', 'amount_min' => -3200, 'amount_max' => -1400, 'category_name' => 'Theatre, music, cinema', 'frequency' => 'monthly'],
|
||||
['description' => 'Concert Tickets', 'amount_min' => -15000, 'amount_max' => -6000, 'category_name' => 'Theatre, music, cinema', 'frequency' => 'quarterly'],
|
||||
['description' => 'Theater Show Tickets', 'amount_min' => -12000, 'amount_max' => -5000, 'category_name' => 'Theatre, music, cinema', 'frequency' => 'quarterly'],
|
||||
['description' => 'Barnes & Noble Books', 'amount_min' => -4500, 'amount_max' => -1500, 'category_name' => 'Books, newspapers, magazines', 'frequency' => 'monthly'],
|
||||
['description' => 'Kindle Book Purchase', 'amount_min' => -1299, 'amount_max' => -599, 'category_name' => 'Books, newspapers, magazines', 'frequency' => 'monthly'],
|
||||
['description' => 'New York Times Subscription', 'amount_min' => -1799, 'amount_max' => -1799, 'category_name' => 'Books, newspapers, magazines', 'frequency' => 'monthly'],
|
||||
['description' => 'Magazine Subscription', 'amount_min' => -999, 'amount_max' => -499, 'category_name' => 'Books, newspapers, magazines', 'frequency' => 'monthly'],
|
||||
['description' => 'Haircut & Styling', 'amount_min' => -3500, 'amount_max' => -1500, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'Barbershop', 'amount_min' => -2500, 'amount_max' => -1000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'Nail Salon', 'amount_min' => -4500, 'amount_max' => -2000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'Dry Cleaning', 'amount_min' => -2500, 'amount_max' => -1200, 'category_name' => 'Other personal transfers', 'frequency' => 'biweekly'],
|
||||
['description' => 'Laundromat', 'amount_min' => -1200, 'amount_max' => -600, 'category_name' => 'Other personal transfers', 'frequency' => 'weekly'],
|
||||
['description' => 'Pet Store - Petco', 'amount_min' => -8500, 'amount_max' => -3500, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'Veterinary Visit', 'amount_min' => -12000, 'amount_max' => -6000, 'category_name' => 'Other personal transfers', 'frequency' => 'quarterly'],
|
||||
['description' => 'Pet Grooming', 'amount_min' => -5500, 'amount_max' => -2500, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'Starbucks Gift Card', 'amount_min' => -5000, 'amount_max' => -2500, 'category_name' => 'Other personal transfers', 'frequency' => 'quarterly'],
|
||||
['description' => 'Amazon Gift Card', 'amount_min' => -10000, 'amount_max' => -5000, 'category_name' => 'Other personal transfers', 'frequency' => 'quarterly'],
|
||||
['description' => 'Charity Donation', 'amount_min' => -5000, 'amount_max' => -2000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'Interest Payment', 'amount_min' => 250, 'amount_max' => 850, 'category_name' => 'Other incoming payments', 'frequency' => 'monthly'],
|
||||
['description' => 'Dividend - VTI ETF', 'amount_min' => 15000, 'amount_max' => 25000, 'category_name' => 'Other incoming payments', 'frequency' => 'quarterly'],
|
||||
['description' => 'Dividend - Apple Stock', 'amount_min' => 5000, 'amount_max' => 12000, 'category_name' => 'Other incoming payments', 'frequency' => 'quarterly'],
|
||||
['description' => 'Dividend - Microsoft Stock', 'amount_min' => 4500, 'amount_max' => 10000, 'category_name' => 'Other incoming payments', 'frequency' => 'quarterly'],
|
||||
['description' => 'Tax Refund', 'amount_min' => 50000, 'amount_max' => 200000, 'category_name' => 'Other incoming payments', 'frequency' => 'yearly'],
|
||||
['description' => 'Transfer to Savings', 'amount_min' => -50000, 'amount_max' => -25000, 'category_name' => 'Own account', 'frequency' => 'monthly'],
|
||||
['description' => 'Transfer from Savings', 'amount_min' => 30000, 'amount_max' => 80000, 'category_name' => 'Own account', 'frequency' => 'quarterly'],
|
||||
['description' => 'Birthday Gift from Mom', 'amount_min' => 10000, 'amount_max' => 25000, 'category_name' => 'From account of relatives', 'frequency' => 'yearly'],
|
||||
['description' => 'Christmas Gift from Parents', 'amount_min' => 15000, 'amount_max' => 30000, 'category_name' => 'From account of relatives', 'frequency' => 'yearly'],
|
||||
['description' => 'Venmo from Friend', 'amount_min' => 2000, 'amount_max' => 8000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'PayPal Payment Received', 'amount_min' => 5000, 'amount_max' => 15000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'Zelle Transfer Received', 'amount_min' => 3000, 'amount_max' => 10000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'Cash App Payment', 'amount_min' => 1500, 'amount_max' => 6000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'Venmo Payment Sent', 'amount_min' => -5000, 'amount_max' => -2000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'PayPal Payment Sent', 'amount_min' => -8000, 'amount_max' => -3000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
['description' => 'Zelle Transfer Sent', 'amount_min' => -6000, 'amount_max' => -2500, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'],
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureBudgetsFeature;
|
||||
use App\Http\Middleware\EnsureUserIsSubscribed;
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
|
|
@ -38,6 +39,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
'subscribed' => EnsureUserIsSubscribed::class,
|
||||
'onboarded' => \App\Http\Middleware\EnsureOnboardingComplete::class,
|
||||
'block-demo' => \App\Http\Middleware\BlockDemoAccountActions::class,
|
||||
'budgets' => EnsureBudgetsFeature::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/boost": "^1.7",
|
||||
"laravel/boost": "1.8.7",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\BudgetPeriodType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Budget>
|
||||
*/
|
||||
class BudgetFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'name' => fake()->words(2, true).' Budget',
|
||||
'period_type' => fake()->randomElement(BudgetPeriodType::cases()),
|
||||
'period_duration' => null,
|
||||
'period_start_day' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
public function monthly(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'period_type' => BudgetPeriodType::Monthly,
|
||||
'period_start_day' => fake()->numberBetween(1, 28),
|
||||
]);
|
||||
}
|
||||
|
||||
public function weekly(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'period_type' => BudgetPeriodType::Weekly,
|
||||
'period_start_day' => fake()->numberBetween(0, 6),
|
||||
]);
|
||||
}
|
||||
|
||||
public function custom(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'period_type' => BudgetPeriodType::Custom,
|
||||
'period_duration' => fake()->numberBetween(7, 90),
|
||||
'period_start_day' => fake()->numberBetween(1, 28),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Budget;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BudgetPeriod>
|
||||
*/
|
||||
class BudgetPeriodFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$startDate = fake()->dateTimeBetween('-1 month', '+1 month');
|
||||
$endDate = (clone $startDate)->modify('+1 month');
|
||||
|
||||
return [
|
||||
'budget_id' => Budget::factory(),
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'carried_over_amount' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\BudgetPeriodAllocation;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BudgetTransaction>
|
||||
*/
|
||||
class BudgetTransactionFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'transaction_id' => Transaction::factory(),
|
||||
'budget_period_allocation_id' => BudgetPeriodAllocation::factory(),
|
||||
'amount' => fake()->numberBetween(1000, 50000),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('budgets', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('period_type');
|
||||
$table->integer('period_duration')->nullable();
|
||||
$table->integer('period_start_day')->nullable();
|
||||
$table->foreignUuid('category_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignUuid('label_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('rollover_type')->default('carry_over');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('budgets');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('budget_periods', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('budget_id')->constrained()->cascadeOnDelete();
|
||||
$table->date('start_date');
|
||||
$table->date('end_date');
|
||||
$table->bigInteger('allocated_amount')->default(0);
|
||||
$table->bigInteger('carried_over_amount')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['budget_id', 'start_date']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('budget_periods');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('budget_transactions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('transaction_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUuid('budget_period_id')->constrained()->cascadeOnDelete();
|
||||
$table->bigInteger('amount');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('budget_transactions');
|
||||
}
|
||||
};
|
||||
|
|
@ -12,7 +12,9 @@ return new class extends Migration
|
|||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('currency_code', 3)->default('USD')->after('email');
|
||||
if (! Schema::hasColumn('users', 'currency_code')) {
|
||||
$table->string('currency_code', 3)->default('USD')->after('email');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@
|
|||
--chart-8: var(--color-zinc-200);
|
||||
--chart-9: var(--color-zinc-100);
|
||||
--chart-10: var(--color-zinc-50);
|
||||
--spent: var(--color-zinc-800);
|
||||
--allocated: var(--color-zinc-200);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
|
|
@ -139,6 +141,8 @@
|
|||
--chart-8: var(--color-zinc-900);
|
||||
--chart-9: var(--color-zinc-50);
|
||||
--chart-10: var(--color-zinc-100);
|
||||
--spent: var(--color-zinc-200);
|
||||
--allocated: var(--color-zinc-700);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.985 0 0);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import { show } from '@/actions/App/Http/Controllers/BudgetController';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Budget, getBudgetPeriodTypeLabel } from '@/types/budget';
|
||||
import { formatCurrency } from '@/utils/currency';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { ArrowRight, Calendar } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
budget: Budget;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
export function BudgetListCard({ budget, currencyCode }: Props) {
|
||||
const currentPeriod = budget.periods?.[0];
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!currentPeriod) {
|
||||
return {
|
||||
totalAllocated: 0,
|
||||
totalSpent: 0,
|
||||
remaining: 0,
|
||||
percentageUsed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const totalAllocated = currentPeriod.allocated_amount;
|
||||
const totalSpent =
|
||||
currentPeriod.budget_transactions?.reduce(
|
||||
(sum, t) => sum + t.amount,
|
||||
0,
|
||||
) ?? 0;
|
||||
|
||||
const remaining = totalAllocated - totalSpent;
|
||||
const percentageUsed =
|
||||
totalAllocated > 0 ? (totalSpent / totalAllocated) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalAllocated,
|
||||
totalSpent,
|
||||
remaining,
|
||||
percentageUsed,
|
||||
};
|
||||
}, [currentPeriod]);
|
||||
|
||||
const periodLabel = useMemo(() => {
|
||||
if (!currentPeriod) return 'No active period';
|
||||
|
||||
const start = new Date(currentPeriod.start_date).toLocaleDateString(
|
||||
'en-US',
|
||||
{ month: 'short', day: 'numeric' },
|
||||
);
|
||||
const end = new Date(currentPeriod.end_date).toLocaleDateString(
|
||||
'en-US',
|
||||
{ month: 'short', day: 'numeric' },
|
||||
);
|
||||
|
||||
return `${start} - ${end}`;
|
||||
}, [currentPeriod]);
|
||||
|
||||
const statusColor = useMemo(() => {
|
||||
if (stats.percentageUsed >= 100)
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
if (stats.percentageUsed >= 80)
|
||||
return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
}, [stats.percentageUsed]);
|
||||
|
||||
const trackingLabel = useMemo(() => {
|
||||
if (budget.category) return budget.category.name;
|
||||
if (budget.label) return budget.label.name;
|
||||
return 'No tracking';
|
||||
}, [budget]);
|
||||
|
||||
return (
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl">{budget.name}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{periodLabel}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{getBudgetPeriodTypeLabel(budget.period_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Spent</span>
|
||||
<span className={statusColor}>
|
||||
{formatCurrency(stats.totalSpent, currencyCode)} of{' '}
|
||||
{formatCurrency(stats.totalAllocated, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(stats.percentageUsed, 100)}
|
||||
className="h-2"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Remaining</span>
|
||||
<span className={statusColor}>
|
||||
{formatCurrency(stats.remaining, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Tracking: {trackingLabel}
|
||||
</span>
|
||||
<Link href={show({ budget: budget.id }).url}>
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
View Details
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import { BudgetPeriod } from '@/types/budget';
|
||||
import { formatCurrency } from '@/utils/currency';
|
||||
import { useMemo } from 'react';
|
||||
import { Area, AreaChart, XAxis } from 'recharts';
|
||||
|
||||
interface Props {
|
||||
currentPeriod: BudgetPeriod;
|
||||
budgetName: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: {
|
||||
date: string;
|
||||
spent: number;
|
||||
allocated: number;
|
||||
remaining: number;
|
||||
};
|
||||
}>;
|
||||
label?: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
currencyCode,
|
||||
}: CustomTooltipProps) {
|
||||
if (!active || !payload || !payload.length || !label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = payload[0].payload;
|
||||
const allocated = data.allocated;
|
||||
const spent = data.spent;
|
||||
const available = data.remaining;
|
||||
const percentage =
|
||||
allocated > 0 ? Math.round((available / allocated) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{new Date(label).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<span className="text-muted-foreground">Allocated:</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(allocated, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<span className="text-muted-foreground">Spent:</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(spent, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t pt-1">
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<span className="font-medium">Available:</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{percentage}% /
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{formatCurrency(available, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BudgetSpendingChart({
|
||||
currentPeriod,
|
||||
budgetName,
|
||||
currencyCode,
|
||||
}: Props) {
|
||||
const chartData = useMemo(() => {
|
||||
const transactions = currentPeriod.budget_transactions || [];
|
||||
const startDate = new Date(currentPeriod.start_date);
|
||||
const endDate = new Date(currentPeriod.end_date);
|
||||
|
||||
// Group transactions by date (using the actual transaction date, not when it was assigned)
|
||||
const transactionsByDate = new Map<string, number>();
|
||||
transactions.forEach((t) => {
|
||||
if (!t.transaction) return;
|
||||
const date = new Date(t.transaction.transaction_date)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
transactionsByDate.set(
|
||||
date,
|
||||
(transactionsByDate.get(date) || 0) + t.amount,
|
||||
);
|
||||
});
|
||||
|
||||
// Generate daily data points
|
||||
const data = [];
|
||||
let cumulativeSpent = 0;
|
||||
const currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= endDate && currentDate <= new Date()) {
|
||||
const dateStr = currentDate.toISOString().split('T')[0];
|
||||
const dailySpent = transactionsByDate.get(dateStr) || 0;
|
||||
cumulativeSpent += dailySpent;
|
||||
|
||||
data.push({
|
||||
date: dateStr,
|
||||
spent: cumulativeSpent,
|
||||
allocated: currentPeriod.allocated_amount,
|
||||
remaining: currentPeriod.allocated_amount - cumulativeSpent,
|
||||
});
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [currentPeriod]);
|
||||
|
||||
const chartConfig = {
|
||||
spent: {
|
||||
label: 'Spent',
|
||||
color: 'var(--spent)',
|
||||
},
|
||||
allocated: {
|
||||
label: 'Budget',
|
||||
color: 'var(--allocated)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const periodLabel = useMemo(() => {
|
||||
const start = new Date(currentPeriod.start_date).toLocaleDateString(
|
||||
'en-US',
|
||||
{ month: 'short', day: 'numeric' },
|
||||
);
|
||||
const end = new Date(currentPeriod.end_date).toLocaleDateString(
|
||||
'en-US',
|
||||
{ month: 'short', day: 'numeric', year: 'numeric' },
|
||||
);
|
||||
return `${start} - ${end}`;
|
||||
}, [currentPeriod]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Budget Spending</CardTitle>
|
||||
<CardDescription>
|
||||
Tracking spending for {budgetName} · {periodLabel}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6 pt-0">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[300px] w-full"
|
||||
>
|
||||
<AreaChart
|
||||
className="overflow-hidden rounded"
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="fillSpent"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--color-spent)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="50%"
|
||||
stopColor="var(--color-spent)"
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--color-spent)"
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="fillAllocated"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--color-allocated)"
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="50%"
|
||||
stopColor="var(--color-allocated)"
|
||||
stopOpacity={0.2}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--color-allocated)"
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<CustomTooltip currencyCode={currencyCode} />
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="allocated"
|
||||
type="basis"
|
||||
fill="url(#fillAllocated)"
|
||||
stroke="var(--color-allocated)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
<Area
|
||||
dataKey="spent"
|
||||
type="basis"
|
||||
fill="url(#fillSpent)"
|
||||
stroke="var(--color-spent)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/BudgetController';
|
||||
import { AmountInput } from '@/components/ui/amount-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label as UILabel } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedData } from '@/types';
|
||||
import {
|
||||
BUDGET_PERIOD_TYPES,
|
||||
BudgetPeriodType,
|
||||
getBudgetPeriodTypeLabel,
|
||||
getRolloverTypeLabel,
|
||||
ROLLOVER_TYPES,
|
||||
RolloverType,
|
||||
} from '@/types/budget';
|
||||
import { Category } from '@/types/category';
|
||||
import { Label } from '@/types/label';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
currencyCode?: string;
|
||||
}
|
||||
|
||||
export function CreateBudgetDialog({
|
||||
className = '',
|
||||
currencyCode = 'USD',
|
||||
}: Props) {
|
||||
const page = usePage<SharedData>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [periodType, setPeriodType] = useState<BudgetPeriodType>('monthly');
|
||||
const [periodDuration, setPeriodDuration] = useState<number | null>(null);
|
||||
const [periodStartDay, setPeriodStartDay] = useState<number>(1);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
|
||||
const [selectedLabelId, setSelectedLabelId] = useState<string>('');
|
||||
const [allocatedAmount, setAllocatedAmount] = useState<number>(0);
|
||||
const [rolloverType, setRolloverType] =
|
||||
useState<RolloverType>('carry_over');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const allCategories = (page.props.categories as Category[]) || [];
|
||||
const allLabels = (page.props.labels as Label[]) || [];
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!selectedCategoryId && !selectedLabelId) {
|
||||
newErrors.selection =
|
||||
'You must select either a category or a label.';
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
router.post(
|
||||
store().url,
|
||||
{
|
||||
name,
|
||||
period_type: periodType,
|
||||
period_duration: periodDuration,
|
||||
period_start_day: periodStartDay,
|
||||
category_id: selectedCategoryId || null,
|
||||
label_id: selectedLabelId || null,
|
||||
rollover_type: rolloverType,
|
||||
allocated_amount: allocatedAmount,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setPeriodType('monthly');
|
||||
setPeriodDuration(null);
|
||||
setPeriodStartDay(1);
|
||||
setSelectedCategoryId('');
|
||||
setSelectedLabelId('');
|
||||
setAllocatedAmount(0);
|
||||
setRolloverType('carry_over');
|
||||
setErrors({});
|
||||
},
|
||||
onError: (errors) => {
|
||||
setErrors(errors as Record<string, string>);
|
||||
},
|
||||
onFinish: () => setIsSubmitting(false),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer opacity-50 transition-opacity duration-200 hover:opacity-100',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-row items-center justify-center gap-1">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Budget
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[600px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Budget</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set up a spending limit for a category or label.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<UILabel htmlFor="name">Budget Name</UILabel>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Padel Budget"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<UILabel htmlFor="period-type">Period Type</UILabel>
|
||||
<Select
|
||||
value={periodType}
|
||||
onValueChange={(value) =>
|
||||
setPeriodType(value as BudgetPeriodType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="period-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BUDGET_PERIOD_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getBudgetPeriodTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{periodType === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<UILabel htmlFor="period-duration">
|
||||
Period Duration (days)
|
||||
</UILabel>
|
||||
<Input
|
||||
id="period-duration"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={periodDuration ?? ''}
|
||||
onChange={(e) =>
|
||||
setPeriodDuration(
|
||||
e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: null,
|
||||
)
|
||||
}
|
||||
required={periodType === 'custom'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<UILabel htmlFor="period-start-day">
|
||||
{periodType === 'monthly'
|
||||
? 'Start Day of Month'
|
||||
: 'Start Day'}
|
||||
</UILabel>
|
||||
<Input
|
||||
id="period-start-day"
|
||||
type="number"
|
||||
min="0"
|
||||
max={periodType === 'monthly' ? '31' : '6'}
|
||||
value={periodStartDay}
|
||||
onChange={(e) =>
|
||||
setPeriodStartDay(parseInt(e.target.value))
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{periodType === 'monthly'
|
||||
? 'Day of the month when the period starts (1-31)'
|
||||
: periodType === 'weekly' ||
|
||||
periodType === 'biweekly'
|
||||
? 'Day of week (0=Sunday, 6=Saturday)'
|
||||
: 'Starting day'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{errors.selection && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{errors.selection}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<UILabel htmlFor="category">
|
||||
Category (Optional)
|
||||
</UILabel>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={selectedCategoryId || undefined}
|
||||
onValueChange={setSelectedCategoryId}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="category"
|
||||
className="flex-1"
|
||||
>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allCategories.map((category) => (
|
||||
<SelectItem
|
||||
key={category.id}
|
||||
value={category.id}
|
||||
>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedCategoryId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setSelectedCategoryId('')
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<UILabel htmlFor="label">
|
||||
Label (Optional)
|
||||
</UILabel>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={selectedLabelId || undefined}
|
||||
onValueChange={(value) =>
|
||||
setSelectedLabelId(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="label"
|
||||
className="flex-1"
|
||||
>
|
||||
<SelectValue placeholder="Select a label" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allLabels.map((label) => (
|
||||
<SelectItem
|
||||
key={label.id}
|
||||
value={label.id}
|
||||
>
|
||||
{label.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedLabelId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setSelectedLabelId('')
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select at least a category or a label to
|
||||
track.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<UILabel htmlFor="allocated-amount">
|
||||
Allocated Amount
|
||||
</UILabel>
|
||||
<AmountInput
|
||||
id="allocated-amount"
|
||||
value={allocatedAmount}
|
||||
onChange={setAllocatedAmount}
|
||||
currencyCode={currencyCode}
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How much do you want to budget per period?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<UILabel htmlFor="rollover">
|
||||
Rollover Type
|
||||
</UILabel>
|
||||
<Select
|
||||
value={rolloverType}
|
||||
onValueChange={(value) =>
|
||||
setRolloverType(value as RolloverType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="rollover">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLLOVER_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getRolloverTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{rolloverType === 'carry_over'
|
||||
? 'Unused budget will carry over to the next period.'
|
||||
: 'Budget resets to zero at the start of each period.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!name ||
|
||||
(!selectedCategoryId && !selectedLabelId) ||
|
||||
allocatedAmount <= 0
|
||||
}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Budget'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { destroy } from '@/actions/App/Http/Controllers/BudgetController';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Budget } from '@/types/budget';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
budget: Budget;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export function DeleteBudgetDialog({
|
||||
budget,
|
||||
open,
|
||||
onOpenChange,
|
||||
redirectTo = '/budgets',
|
||||
}: Props) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
setIsDeleting(true);
|
||||
|
||||
router.delete(destroy({ budget: budget.id }).url, {
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
if (redirectTo) {
|
||||
router.visit(redirectTo);
|
||||
}
|
||||
},
|
||||
onFinish: () => setIsDeleting(false),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Budget</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{budget.name}"? This
|
||||
action cannot be undone. All budget periods,
|
||||
allocations, and transaction assignments will be
|
||||
permanently removed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
variant="destructive"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
import { update } from '@/actions/App/Http/Controllers/BudgetController';
|
||||
import { AmountInput } from '@/components/ui/amount-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Budget,
|
||||
BUDGET_PERIOD_TYPES,
|
||||
BudgetPeriodType,
|
||||
getBudgetPeriodTypeLabel,
|
||||
getRolloverTypeLabel,
|
||||
ROLLOVER_TYPES,
|
||||
RolloverType,
|
||||
} from '@/types/budget';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
budget: Budget;
|
||||
currentPeriod: { allocated_amount: number };
|
||||
currencyCode?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditBudgetDialog({
|
||||
budget,
|
||||
currentPeriod,
|
||||
currencyCode = 'USD',
|
||||
open,
|
||||
onOpenChange,
|
||||
}: Props) {
|
||||
const [name, setName] = useState(budget.name);
|
||||
const [periodType, setPeriodType] = useState<BudgetPeriodType>(
|
||||
budget.period_type as BudgetPeriodType,
|
||||
);
|
||||
const [periodDuration, setPeriodDuration] = useState<number | null>(
|
||||
budget.period_duration,
|
||||
);
|
||||
const [periodStartDay, setPeriodStartDay] = useState<number>(
|
||||
budget.period_start_day || 1,
|
||||
);
|
||||
const [allocatedAmount, setAllocatedAmount] = useState<number>(
|
||||
currentPeriod.allocated_amount,
|
||||
);
|
||||
const [rolloverType, setRolloverType] = useState<RolloverType>(
|
||||
budget.rollover_type as RolloverType,
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && budget) {
|
||||
setName(budget.name);
|
||||
setPeriodType(budget.period_type as BudgetPeriodType);
|
||||
setPeriodDuration(budget.period_duration);
|
||||
setPeriodStartDay(budget.period_start_day || 1);
|
||||
setAllocatedAmount(currentPeriod.allocated_amount);
|
||||
setRolloverType(budget.rollover_type as RolloverType);
|
||||
}
|
||||
}, [open, budget, currentPeriod]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
router.patch(
|
||||
update({ budget: budget.id }).url,
|
||||
{
|
||||
name,
|
||||
period_type: periodType,
|
||||
period_duration: periodDuration,
|
||||
period_start_day: periodStartDay,
|
||||
allocated_amount: allocatedAmount,
|
||||
rollover_type: rolloverType,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
},
|
||||
onFinish: () => setIsSubmitting(false),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[600px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Budget</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update your budget settings. To change the allocated
|
||||
amount or tracking, use the budget page directly.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Budget Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
className="mt-1"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Monthly Budget"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="period-type">Period Type</Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={periodType}
|
||||
disabled
|
||||
onValueChange={(value) =>
|
||||
setPeriodType(value as BudgetPeriodType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="period-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BUDGET_PERIOD_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getBudgetPeriodTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{periodType === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="period-duration">
|
||||
Period Duration (days)
|
||||
</Label>
|
||||
<Input
|
||||
disabled
|
||||
id="period-duration"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
className="mt-1"
|
||||
value={periodDuration ?? ''}
|
||||
onChange={(e) =>
|
||||
setPeriodDuration(
|
||||
e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: null,
|
||||
)
|
||||
}
|
||||
required={periodType === 'custom'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="period-start-day">
|
||||
{periodType === 'monthly'
|
||||
? 'Start Day of Month'
|
||||
: 'Start Day'}
|
||||
</Label>
|
||||
<Input
|
||||
id="period-start-day"
|
||||
disabled
|
||||
type="number"
|
||||
className="mt-1"
|
||||
min="0"
|
||||
max={periodType === 'monthly' ? '31' : '6'}
|
||||
value={periodStartDay}
|
||||
onChange={(e) =>
|
||||
setPeriodStartDay(parseInt(e.target.value))
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{periodType === 'monthly'
|
||||
? 'Day of the month when the period starts (1-31)'
|
||||
: periodType === 'weekly' ||
|
||||
periodType === 'biweekly'
|
||||
? 'Day of week (0=Sunday, 6=Saturday)'
|
||||
: 'Starting day'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="allocated-amount">
|
||||
Allocated Amount
|
||||
</Label>
|
||||
<AmountInput
|
||||
id="allocated-amount"
|
||||
value={allocatedAmount}
|
||||
onChange={setAllocatedAmount}
|
||||
currencyCode={currencyCode}
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will update the allocated amount for the
|
||||
current and future periods.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rollover">Rollover Type</Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
disabled
|
||||
value={rolloverType}
|
||||
onValueChange={(value) =>
|
||||
setRolloverType(value as RolloverType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="rollover">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLLOVER_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getRolloverTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{rolloverType === 'carry_over'
|
||||
? 'Unused budget will carry over to the next period.'
|
||||
: 'Budget resets to zero at the start of each period.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !name}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,19 @@
|
|||
import { isValidElement, ReactNode } from 'react';
|
||||
|
||||
export default function HeadingSmall({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
description?: string | ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header>
|
||||
<h3 className="mb-0.5 text-base font-medium">{title}</h3>
|
||||
{description && (
|
||||
{typeof description === 'string' && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{isValidElement(description) && description}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ export interface TransactionListProps {
|
|||
labels?: Label[];
|
||||
automationRules?: AutomationRule[];
|
||||
accountId?: UUID;
|
||||
transactions?: Transaction[]; // Optional: if provided, use these instead of fetching from Dexie
|
||||
pageSize?: number;
|
||||
hideAccountFilter?: boolean;
|
||||
showActionsMenu?: boolean;
|
||||
|
|
@ -234,6 +235,7 @@ export function TransactionList({
|
|||
labels: initialLabels = [],
|
||||
automationRules = [],
|
||||
accountId,
|
||||
transactions: providedTransactions,
|
||||
pageSize = 25,
|
||||
hideAccountFilter = false,
|
||||
showActionsMenu = true,
|
||||
|
|
@ -312,7 +314,84 @@ export function TransactionList({
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchTransactions() {
|
||||
async function processTransactions() {
|
||||
// If transactions are provided directly, use them as-is (already decrypted from backend)
|
||||
if (providedTransactions) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const keyString = getStoredKey();
|
||||
let key: CryptoKey | null = null;
|
||||
|
||||
if (keyString && isKeySet) {
|
||||
try {
|
||||
key = await importKey(keyString);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to import encryption key:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
providedTransactions.map(async (transaction) => {
|
||||
try {
|
||||
let decryptedDescription = '';
|
||||
let decryptedNotes: string | null = null;
|
||||
|
||||
if (key) {
|
||||
try {
|
||||
decryptedDescription = await decrypt(
|
||||
transaction.description,
|
||||
key,
|
||||
transaction.description_iv,
|
||||
);
|
||||
if (
|
||||
transaction.notes &&
|
||||
transaction.notes_iv
|
||||
) {
|
||||
decryptedNotes = await decrypt(
|
||||
transaction.notes,
|
||||
key,
|
||||
transaction.notes_iv,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to decrypt transaction:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...transaction,
|
||||
decryptedDescription,
|
||||
decryptedNotes,
|
||||
} as DecryptedTransaction;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error processing transaction:',
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const validTransactions = decrypted.filter(
|
||||
(t): t is DecryptedTransaction => t !== null,
|
||||
);
|
||||
|
||||
setTransactions(validTransactions);
|
||||
} catch (error) {
|
||||
console.error('Error processing transactions:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await axios.get('/api/sync/transactions');
|
||||
|
|
@ -451,8 +530,16 @@ export function TransactionList({
|
|||
}
|
||||
}
|
||||
|
||||
fetchTransactions();
|
||||
}, [refreshKey, accounts, banks, categories, isKeySet, accountId]);
|
||||
processTransactions();
|
||||
}, [
|
||||
refreshKey,
|
||||
accounts,
|
||||
banks,
|
||||
categories,
|
||||
isKeySet,
|
||||
accountId,
|
||||
providedTransactions,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Separator } from '@/components/ui/separator';
|
|||
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
|
||||
import { edit as editAccount } from '@/routes/account';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { settings as budgetsSettings } from '@/routes/budgets';
|
||||
import { edit as editDeleteAccount } from '@/routes/delete-account';
|
||||
import { billing } from '@/routes/settings';
|
||||
import {
|
||||
|
|
@ -47,6 +48,12 @@ const getNavItems = (
|
|||
href: labelsIndex(),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
type: 'nav-item',
|
||||
title: 'Budgets',
|
||||
href: budgetsSettings(),
|
||||
icon: null,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'section-header',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import type {
|
||||
Budget,
|
||||
BudgetCategory,
|
||||
BudgetPeriod,
|
||||
BudgetPeriodAllocation,
|
||||
} from '@/types/budget';
|
||||
import type { Transaction } from '@/types/transaction';
|
||||
import Dexie, { type EntityTable } from 'dexie';
|
||||
|
||||
|
|
@ -8,6 +14,10 @@ export interface SyncMetadata {
|
|||
|
||||
type WhisperMoneyDB = Dexie & {
|
||||
transactions: EntityTable<Transaction, 'id'>;
|
||||
budgets: EntityTable<Budget, 'id'>;
|
||||
budget_categories: EntityTable<BudgetCategory, 'id'>;
|
||||
budget_periods: EntityTable<BudgetPeriod, 'id'>;
|
||||
budget_period_allocations: EntityTable<BudgetPeriodAllocation, 'id'>;
|
||||
sync_metadata: EntityTable<SyncMetadata, 'key'>;
|
||||
};
|
||||
|
||||
|
|
@ -59,6 +69,17 @@ function initializeDatabase(): WhisperMoneyDB {
|
|||
sync_metadata: 'key',
|
||||
});
|
||||
|
||||
// Version 9: Add budget tables
|
||||
database.version(9).stores({
|
||||
transactions: 'id, user_id, account_id, updated_at',
|
||||
budgets: 'id, user_id, updated_at',
|
||||
budget_categories: 'id, budget_id, updated_at',
|
||||
budget_periods: 'id, budget_id, start_date, updated_at',
|
||||
budget_period_allocations:
|
||||
'id, budget_period_id, budget_category_id, updated_at',
|
||||
sync_metadata: 'key',
|
||||
});
|
||||
|
||||
return database;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { index } from '@/actions/App/Http/Controllers/BudgetController';
|
||||
import { BudgetListCard } from '@/components/budgets/budget-list-card';
|
||||
import { CreateBudgetDialog } from '@/components/budgets/create-budget-dialog';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||
import { BreadcrumbItem } from '@/types';
|
||||
import { Budget } from '@/types/budget';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Budgets',
|
||||
href: index().url,
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
budgets: Budget[];
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
export default function BudgetsIndex({ budgets, currencyCode }: Props) {
|
||||
return (
|
||||
<AppSidebarLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Budgets" />
|
||||
|
||||
<div className="space-y-8 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<HeadingSmall
|
||||
title="Budgets"
|
||||
description="Track your spending with flexible budgets"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{budgets.length > 0 ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{budgets.map((budget) => (
|
||||
<BudgetListCard
|
||||
key={budget.id}
|
||||
budget={budget}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
))}
|
||||
<CreateBudgetDialog currencyCode={currencyCode} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CreateBudgetDialog
|
||||
className="min-h-[260px]"
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppSidebarLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { index, show } from '@/actions/App/Http/Controllers/BudgetController';
|
||||
import { BudgetSpendingChart } from '@/components/budgets/budget-spending-chart';
|
||||
import { DeleteBudgetDialog } from '@/components/budgets/delete-budget-dialog';
|
||||
import { EditBudgetDialog } from '@/components/budgets/edit-budget-dialog';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { TransactionList } from '@/components/transactions/transaction-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonGroup } from '@/components/ui/button-group';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||
import { BreadcrumbItem } from '@/types';
|
||||
import { Account, Bank } from '@/types/account';
|
||||
import { Budget, BudgetPeriod, getBudgetPeriodTypeLabel } from '@/types/budget';
|
||||
import { Category } from '@/types/category';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
budget: Budget;
|
||||
currentPeriod: BudgetPeriod;
|
||||
categories: Category[];
|
||||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
export default function BudgetShow({
|
||||
budget,
|
||||
currentPeriod,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
currencyCode,
|
||||
}: Props) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Budgets',
|
||||
href: index().url,
|
||||
},
|
||||
{
|
||||
title: budget.name,
|
||||
href: show({ budget: budget.id }).url,
|
||||
},
|
||||
];
|
||||
|
||||
const periodLabel = useMemo(() => {
|
||||
const start = new Date(currentPeriod.start_date).toLocaleDateString(
|
||||
'en-US',
|
||||
{ month: 'short', day: 'numeric', year: '2-digit' },
|
||||
);
|
||||
const end = new Date(currentPeriod.end_date).toLocaleDateString(
|
||||
'en-US',
|
||||
{ month: 'short', day: 'numeric', year: '2-digit' },
|
||||
);
|
||||
return `${start} - ${end}`;
|
||||
}, [currentPeriod]);
|
||||
|
||||
const trackingLabel = useMemo((): string | null => {
|
||||
if (budget.category) return budget.category.name;
|
||||
if (budget.label) return budget.label.name;
|
||||
return null;
|
||||
}, [budget]);
|
||||
|
||||
const periodTransactions = useMemo(() => {
|
||||
return (
|
||||
currentPeriod.budget_transactions
|
||||
?.map((bt) => bt.transaction)
|
||||
.filter((t) => t !== undefined && t !== null) || []
|
||||
);
|
||||
}, [currentPeriod]);
|
||||
|
||||
return (
|
||||
<AppSidebarLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title={budget.name} />
|
||||
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<HeadingSmall
|
||||
title={budget.name}
|
||||
description={
|
||||
<div className="flex flex-row items-center gap-1 text-sm">
|
||||
<div className="inline">
|
||||
{trackingLabel !== null ? (
|
||||
<>
|
||||
<span className="opacity-50">
|
||||
Tracking{' '}
|
||||
</span>
|
||||
<span>{trackingLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="opacity-50">
|
||||
No tracking
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="opacity-25">/</span>
|
||||
<div className="inline">
|
||||
<span>{periodLabel} </span>
|
||||
<span className="opacity-50">
|
||||
(
|
||||
{getBudgetPeriodTypeLabel(
|
||||
budget.period_type,
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
Edit budget
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="More options"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<BudgetSpendingChart
|
||||
currentPeriod={currentPeriod}
|
||||
budgetName={budget.name}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
|
||||
<TransactionList
|
||||
categories={categories}
|
||||
accounts={accounts}
|
||||
banks={banks}
|
||||
transactions={periodTransactions}
|
||||
pageSize={10}
|
||||
showActionsMenu={false}
|
||||
maxHeight={600}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditBudgetDialog
|
||||
budget={budget}
|
||||
currentPeriod={currentPeriod}
|
||||
currencyCode={currencyCode}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
||||
<DeleteBudgetDialog
|
||||
budget={budget}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
redirectTo={index().url}
|
||||
/>
|
||||
</AppSidebarLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1719,7 +1719,9 @@ export default function Transactions({
|
|||
labels={labels}
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={() => {}}
|
||||
onSuccess={(transaction) => {
|
||||
setTransactions((prev) => [transaction, ...prev]);
|
||||
}}
|
||||
mode="create"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { index as accountsIndex } from '@/actions/App/Http/Controllers/AccountController';
|
||||
import { index as budgetsIndex } from '@/actions/App/Http/Controllers/BudgetController';
|
||||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import DiscordIcon from '@/components/icons/DiscordIcon';
|
||||
import { cashflow, dashboard } from '@/routes';
|
||||
|
|
@ -7,6 +8,7 @@ import {
|
|||
CreditCard,
|
||||
Github,
|
||||
LayoutGrid,
|
||||
PiggyBank,
|
||||
Receipt,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
|
@ -45,6 +47,15 @@ export function getMainNavItems(features: Features): NavItem[] {
|
|||
},
|
||||
);
|
||||
|
||||
if (features.budgets) {
|
||||
items.push({
|
||||
type: 'nav-item',
|
||||
title: 'Budgets',
|
||||
href: budgetsIndex(),
|
||||
icon: PiggyBank,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { Category } from './category';
|
||||
import { Label } from './label';
|
||||
import { Transaction } from './transaction';
|
||||
import { UUID } from './uuid';
|
||||
|
||||
export const BUDGET_PERIOD_TYPES = [
|
||||
'monthly',
|
||||
'weekly',
|
||||
'biweekly',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
export type BudgetPeriodType = (typeof BUDGET_PERIOD_TYPES)[number];
|
||||
|
||||
export const ROLLOVER_TYPES = ['carry_over', 'reset'] as const;
|
||||
|
||||
export type RolloverType = (typeof ROLLOVER_TYPES)[number];
|
||||
|
||||
export interface Budget {
|
||||
id: UUID;
|
||||
user_id: UUID;
|
||||
name: string;
|
||||
period_type: BudgetPeriodType;
|
||||
period_duration: number | null;
|
||||
period_start_day: number | null;
|
||||
category_id: UUID | null;
|
||||
label_id: UUID | null;
|
||||
rollover_type: RolloverType;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
category?: Category;
|
||||
label?: Label;
|
||||
periods?: BudgetPeriod[];
|
||||
}
|
||||
|
||||
export interface BudgetPeriod {
|
||||
id: UUID;
|
||||
budget_id: UUID;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
allocated_amount: number;
|
||||
carried_over_amount: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
budget_transactions?: BudgetTransaction[];
|
||||
}
|
||||
|
||||
export interface BudgetTransaction {
|
||||
id: UUID;
|
||||
transaction_id: UUID;
|
||||
budget_period_id: UUID;
|
||||
amount: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
transaction?: Transaction;
|
||||
}
|
||||
|
||||
export interface BudgetHistoryData {
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
budgeted: number;
|
||||
spent: number;
|
||||
}
|
||||
|
||||
export function getBudgetPeriodTypeLabel(type: BudgetPeriodType): string {
|
||||
const labels: Record<BudgetPeriodType, string> = {
|
||||
monthly: 'Monthly',
|
||||
weekly: 'Weekly',
|
||||
biweekly: 'Bi-weekly',
|
||||
custom: 'Custom',
|
||||
};
|
||||
return labels[type];
|
||||
}
|
||||
|
||||
export function getRolloverTypeLabel(type: RolloverType): string {
|
||||
const labels: Record<RolloverType, string> = {
|
||||
carry_over: 'Carry Over',
|
||||
reset: 'Reset/Pool',
|
||||
};
|
||||
return labels[type];
|
||||
}
|
||||
|
||||
export function getRolloverTypeDescription(type: RolloverType): string {
|
||||
const descriptions: Record<RolloverType, string> = {
|
||||
carry_over: 'Remaining balance carries over to next period',
|
||||
reset: 'Remaining balance returns to available money pool',
|
||||
};
|
||||
return descriptions[type];
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ export interface NavDivider {
|
|||
|
||||
export interface Features {
|
||||
cashflow: boolean;
|
||||
budgets: boolean;
|
||||
}
|
||||
|
||||
export interface SharedData {
|
||||
|
|
@ -63,6 +64,7 @@ export interface User {
|
|||
avatar?: string;
|
||||
email_verified_at: string | null;
|
||||
two_factor_enabled?: boolean;
|
||||
currency_code?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
[key: string]: unknown;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
export function formatCurrency(
|
||||
valueInCents: number,
|
||||
currencyCode = 'USD',
|
||||
): string {
|
||||
const amount = valueInCents / 100;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function getCurrencySymbol(currencyCode: string): string {
|
||||
const symbols: Record<string, string> = {
|
||||
USD: '$',
|
||||
EUR: '€',
|
||||
GBP: '£',
|
||||
JPY: '¥',
|
||||
};
|
||||
return symbols[currencyCode] || currencyCode;
|
||||
}
|
||||
|
|
@ -3,3 +3,5 @@
|
|||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('demo:reset')->twiceDaily();
|
||||
Schedule::command('horizon:snapshot')->everyFiveMinutes();
|
||||
Schedule::command('budgets:generate-periods')->daily();
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ Route::middleware('auth')->group(function () {
|
|||
Route::patch('settings/labels/{label}', [LabelController::class, 'update'])->name('labels.update');
|
||||
Route::delete('settings/labels/{label}', [LabelController::class, 'destroy'])->name('labels.destroy');
|
||||
|
||||
Route::get('settings/budgets', function () {
|
||||
return Inertia::render('settings/budgets');
|
||||
})->name('budgets.settings');
|
||||
|
||||
Route::get('settings/automation-rules', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'index'])->name('automation-rules.index');
|
||||
Route::post('settings/automation-rules', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'store'])->name('automation-rules.store');
|
||||
Route::patch('settings/automation-rules/{automationRule}', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'update'])->name('automation-rules.update');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AccountController;
|
||||
use App\Http\Controllers\BudgetController;
|
||||
use App\Http\Controllers\CashflowController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\OnboardingController;
|
||||
|
|
@ -60,4 +61,12 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi
|
|||
Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed', 'budgets'])->group(function () {
|
||||
Route::get('budgets', [BudgetController::class, 'index'])->name('budgets.index');
|
||||
Route::post('budgets', [BudgetController::class, 'store'])->name('budgets.store');
|
||||
Route::get('budgets/{budget}', [BudgetController::class, 'show'])->name('budgets.show');
|
||||
Route::patch('budgets/{budget}', [BudgetController::class, 'update'])->name('budgets.update');
|
||||
Route::delete('budgets/{budget}', [BudgetController::class, 'destroy'])->name('budgets.destroy');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ it('can create a transaction with amount input', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = $this->visitWithEncryptionKey('/transactions');
|
||||
$page->wait(3); // Extra wait for IndexedDB to sync
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('Add Transaction')
|
||||
|
|
@ -71,8 +72,10 @@ it('can create a transaction with amount input', function () {
|
|||
->click($category->name)
|
||||
->wait(1)
|
||||
->click('[data-testid="submit-transaction"]')
|
||||
->wait(3)
|
||||
->waitForText('Test Transaction')
|
||||
->wait(4) // Wait for form submission and navigation
|
||||
->assertPathIs('/transactions')
|
||||
->wait(2) // Extra wait for IndexedDB sync after creation
|
||||
->waitForText('Test Transaction', 15)
|
||||
->assertSee('$123.45')
|
||||
->wait(1)
|
||||
->assertNoJavascriptErrors();
|
||||
|
|
@ -96,6 +99,7 @@ it('formats amount when pressing enter', function () {
|
|||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
$page->wait(3); // Extra wait for IndexedDB to sync
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('Add Transaction')
|
||||
|
|
@ -108,8 +112,10 @@ it('formats amount when pressing enter', function () {
|
|||
->click($category->name)
|
||||
->wait(0.5)
|
||||
->click('[data-testid="submit-transaction"]')
|
||||
->wait(2)
|
||||
->waitForText('Test Transaction Enter')
|
||||
->wait(4) // Wait for form submission and navigation
|
||||
->assertPathIs('/transactions')
|
||||
->wait(2) // Extra wait for IndexedDB sync after creation
|
||||
->waitForText('Test Transaction Enter', 15)
|
||||
->wait(1)
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
|
|
@ -132,6 +138,7 @@ it('accepts negative amounts', function () {
|
|||
|
||||
$page = visit('/transactions');
|
||||
$this->setupEncryptionKey($page);
|
||||
$page->wait(3); // Extra wait for IndexedDB to sync
|
||||
|
||||
$page->assertSee('Transactions')
|
||||
->click('Add Transaction')
|
||||
|
|
@ -143,8 +150,10 @@ it('accepts negative amounts', function () {
|
|||
->click($category->name)
|
||||
->wait(0.5)
|
||||
->click('[data-testid="submit-transaction"]')
|
||||
->wait(3)
|
||||
->waitForText('Test Negative Amount')
|
||||
->wait(4) // Wait for form submission and navigation
|
||||
->assertPathIs('/transactions')
|
||||
->wait(2) // Extra wait for IndexedDB sync after creation
|
||||
->waitForText('Test Negative Amount', 15)
|
||||
->wait(1)
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
test('user can create a budget with category', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Groceries',
|
||||
]);
|
||||
|
||||
$page = $this->actingAs($user)->visit('/budgets');
|
||||
$page->wait(2); // Wait for page to fully load
|
||||
|
||||
$page->assertSee('Budgets')
|
||||
->waitForText('Create Budget', 10)
|
||||
->wait(1) // Extra wait before clicking
|
||||
->click('Create Budget')
|
||||
->wait(3) // Wait for dialog to open
|
||||
->assertSee('Create Budget')
|
||||
->wait(1) // Wait for form to be ready
|
||||
->fill('name', 'Monthly Groceries')
|
||||
->wait(1)
|
||||
->click('#period-type')
|
||||
->wait(0.5)
|
||||
->click('[role="option"]:has-text("Monthly")')
|
||||
->wait(1)
|
||||
->click('button:has-text("Select a category")')
|
||||
->wait(0.5)
|
||||
->click('[role="option"]:has-text("'.$category->name.'")')
|
||||
->wait(1)
|
||||
->click('button:has-text("Carry Over")')
|
||||
->wait(0.5)
|
||||
->click('[role="option"]:has-text("Reset/Pool")')
|
||||
->wait(1)
|
||||
->fill('#allocated-amount', '500')
|
||||
->click('label:has-text("Rollover Type")') // Click elsewhere to blur the amount input
|
||||
->wait(2) // Wait for state to update
|
||||
->click('[role="dialog"] button[type="submit"]')
|
||||
->wait(4) // Wait for form submission
|
||||
->assertPathBeginsWith('/budgets/')
|
||||
->wait(2) // Wait for page to update
|
||||
->waitForText('Monthly Groceries', 15)
|
||||
->assertSee('Budget Spending')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseHas('budgets', [
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Monthly Groceries',
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('user can update budget name', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$budget = Budget::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $category->id,
|
||||
'name' => 'Old Name',
|
||||
]);
|
||||
|
||||
$page = $this->actingAs($user)->visit("/budgets/{$budget->id}");
|
||||
$page->wait(2); // Wait for page to fully load
|
||||
|
||||
$page->assertSee('Old Name')
|
||||
->wait(2)
|
||||
->waitForText('Edit budget', 10)
|
||||
->wait(1) // Extra wait before clicking
|
||||
->click('Edit budget')
|
||||
->wait(3) // Wait for dialog to open
|
||||
->assertSee('Edit Budget')
|
||||
->wait(1) // Wait for form to be ready
|
||||
->fill('name', 'New Budget Name')
|
||||
->wait(1)
|
||||
->fill('#allocated-amount', '500')
|
||||
->wait(2)
|
||||
->click('button:has-text("Save Changes")')
|
||||
->wait(4) // Wait for form submission
|
||||
->waitForText('New Budget Name', 15)
|
||||
->assertDontSee('Old Name')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseHas('budgets', [
|
||||
'id' => $budget->id,
|
||||
'name' => 'New Budget Name',
|
||||
]);
|
||||
});
|
||||
|
||||
test('user can delete a budget', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$budget = Budget::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $category->id,
|
||||
'name' => 'Budget to Delete',
|
||||
]);
|
||||
|
||||
$page = $this->actingAs($user)->visit("/budgets/{$budget->id}");
|
||||
$page->wait(2); // Wait for page to fully load
|
||||
|
||||
$page->assertSee('Budget to Delete')
|
||||
->wait(2)
|
||||
->waitForText('Edit budget', 10)
|
||||
->wait(1) // Extra wait before clicking
|
||||
->click('[aria-label="More options"]')
|
||||
->wait(1) // Wait for dropdown to open
|
||||
->click('Delete')
|
||||
->wait(3) // Wait for dialog to open
|
||||
->assertSee('Delete Budget')
|
||||
->assertSee('Are you sure')
|
||||
->wait(2)
|
||||
->click('button:has-text("Delete")')
|
||||
->wait(4) // Wait for deletion
|
||||
->assertPathIs('/budgets')
|
||||
->wait(2) // Wait for page to update
|
||||
->assertDontSee('Budget to Delete')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertSoftDeleted('budgets', [
|
||||
'id' => $budget->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('budget creation validates required fields', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$page = $this->actingAs($user)->visit('/budgets');
|
||||
$page->wait(2); // Wait for page to fully load
|
||||
|
||||
$page->assertSee('Budgets')
|
||||
->waitForText('Create Budget', 10)
|
||||
->wait(1) // Extra wait before clicking
|
||||
->click('Create Budget')
|
||||
->wait(3) // Wait for dialog to open
|
||||
->assertSee('Create Budget')
|
||||
->wait(2) // Wait for form to be ready
|
||||
->assertAttribute('button[type="submit"]:has-text("Create Budget")', 'disabled', '')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('budget shows current period information', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$budget = Budget::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$page = $this->actingAs($user)->visit("/budgets/{$budget->id}");
|
||||
|
||||
$page->assertSee($budget->name)
|
||||
->assertSee('Tracking')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('user can navigate back to budgets list from budget detail', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$budget = Budget::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$page = $this->actingAs($user)->visit("/budgets/{$budget->id}");
|
||||
$page->wait(2); // Wait for page to fully load
|
||||
|
||||
$page->assertSee($budget->name)
|
||||
->wait(2)
|
||||
->waitForText('Budgets', 10)
|
||||
->wait(1) // Extra wait before clicking
|
||||
->click('nav[aria-label="breadcrumb"] a:has-text("Budgets")')
|
||||
->wait(4) // Wait for navigation
|
||||
->assertPathIs('/budgets')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
test('budgets menu item hidden when feature disabled', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->deactivate('budgets');
|
||||
|
||||
$page = $this->actingAs($user)->visit('/dashboard');
|
||||
|
||||
$page->assertDontSee('Budgets')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('budgets menu item visible when feature enabled', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$page = $this->actingAs($user)->visit('/dashboard');
|
||||
|
||||
$page->assertSee('Budgets')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('user cannot access budgets page when feature disabled', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->deactivate('budgets');
|
||||
|
||||
$response = $this->actingAs($user)->get('/budgets');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('user can navigate to budgets index page', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$page = $this->actingAs($user)->visit('/budgets');
|
||||
|
||||
$page->assertPathIs('/budgets')
|
||||
->assertSee('Budgets')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('user can view empty budgets list', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$page = $this->actingAs($user)->visit('/budgets');
|
||||
|
||||
$page->assertSee('Budgets')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('user can view budgets list with existing budgets', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$budget = Budget::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $category->id,
|
||||
'name' => 'Test Budget',
|
||||
]);
|
||||
|
||||
$page = $this->actingAs($user)->visit('/budgets');
|
||||
|
||||
$page->assertSee('Budgets')
|
||||
->assertSee('Test Budget')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('user can open create budget dialog', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$page = $this->actingAs($user)->visit('/budgets');
|
||||
|
||||
$page->assertSee('Budgets')
|
||||
->click('Create Budget')
|
||||
->wait(1)
|
||||
->assertSee('Create Budget')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('user can view a specific budget', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$budget = Budget::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $category->id,
|
||||
'name' => 'My Monthly Budget',
|
||||
]);
|
||||
|
||||
$page = $this->actingAs($user)->visit('/budgets');
|
||||
|
||||
$page->assertSee('My Monthly Budget')
|
||||
->click('View Details')
|
||||
->wait(2)
|
||||
->assertPathIs("/budgets/{$budget->id}")
|
||||
->assertSee('My Monthly Budget')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('user can open edit budget dialog', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$budget = Budget::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $category->id,
|
||||
'name' => 'Original Name',
|
||||
]);
|
||||
|
||||
$page = $this->actingAs($user)->visit("/budgets/{$budget->id}");
|
||||
|
||||
$page->assertSee('Original Name')
|
||||
->click('Edit budget')
|
||||
->wait(1)
|
||||
->assertSee('Edit Budget')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('user can open delete budget dialog', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$budget = Budget::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $category->id,
|
||||
'name' => 'Budget to Delete',
|
||||
]);
|
||||
|
||||
$page = $this->actingAs($user)->visit("/budgets/{$budget->id}");
|
||||
|
||||
$page->assertSee('Budget to Delete')
|
||||
->click('//button[@aria-label="More options"]')
|
||||
->wait(0.5)
|
||||
->click('Delete')
|
||||
->wait(1)
|
||||
->assertSee('Delete Budget')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('user cannot access another users budget', function () {
|
||||
$user1 = User::factory()->create(['onboarded_at' => now()]);
|
||||
$user2 = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user1)->activate('budgets');
|
||||
Feature::for($user2)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user1->id]);
|
||||
$budget = Budget::factory()->create([
|
||||
'user_id' => $user1->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user2)->get("/budgets/{$budget->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('budgets navigation works from sidebar', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$page = $this->actingAs($user)->visit('/dashboard');
|
||||
|
||||
$page->assertSee('Budgets')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
test('budgets page shows correct feature flag state', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$page = $this->actingAs($user)->visit('/budgets');
|
||||
|
||||
$page->assertSee('Budgets')
|
||||
->assertNoJavascriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
test('users without budgets feature get 404 on budgets index', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
|
||||
Feature::for($user)->deactivate('budgets');
|
||||
|
||||
$response = $this->actingAs($user)->get('/budgets');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('users with budgets feature can access budgets index', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$response = $this->actingAs($user)->get('/budgets');
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('users without budgets feature get 404 on budgets show', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
$budget = Budget::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
Feature::for($user)->deactivate('budgets');
|
||||
|
||||
$response = $this->actingAs($user)->get("/budgets/{$budget->id}");
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('users without budgets feature get 404 on budgets store', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
Feature::for($user)->deactivate('budgets');
|
||||
|
||||
$response = $this->actingAs($user)->post('/budgets', [
|
||||
'name' => 'Test Budget',
|
||||
'period_type' => 'monthly',
|
||||
'period_start_day' => 1,
|
||||
'category_id' => $category->id,
|
||||
'rollover_type' => 'reset',
|
||||
'allocated_amount' => 100000,
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('users without budgets feature get 404 on budgets update', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
$budget = Budget::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
Feature::for($user)->deactivate('budgets');
|
||||
|
||||
$response = $this->actingAs($user)->patch("/budgets/{$budget->id}", [
|
||||
'name' => 'Updated Budget',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('users without budgets feature get 404 on budgets destroy', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
$budget = Budget::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
Feature::for($user)->deactivate('budgets');
|
||||
|
||||
$response = $this->actingAs($user)->delete("/budgets/{$budget->id}");
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('budgets feature flag is shared with frontend when enabled', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$response = $this->actingAs($user)->get('/dashboard');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->where('features.budgets', true)
|
||||
);
|
||||
});
|
||||
|
||||
test('budgets feature flag is shared with frontend when disabled', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
|
||||
Feature::for($user)->deactivate('budgets');
|
||||
|
||||
$response = $this->actingAs($user)->get('/dashboard');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->where('features.budgets', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('guests see budgets feature as false', function () {
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->where('features.budgets', false)
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
test('user can create a budget', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)->post('/budgets', [
|
||||
'name' => 'Monthly Budget',
|
||||
'period_type' => 'monthly',
|
||||
'period_start_day' => 1,
|
||||
'category_id' => $category->id,
|
||||
'rollover_type' => 'reset',
|
||||
'allocated_amount' => 100000,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('budgets', [
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Monthly Budget',
|
||||
'period_type' => 'monthly',
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$budget = Budget::where('user_id', $user->id)->first();
|
||||
$this->assertNotNull($budget);
|
||||
$this->assertCount(1, $budget->periods);
|
||||
});
|
||||
|
||||
test('user can view their budgets', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$budget = Budget::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)->get('/budgets');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('budgets/index')
|
||||
->has('budgets', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('user can view a specific budget', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
$budget = Budget::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get("/budgets/{$budget->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('budgets/show')
|
||||
->has('budget')
|
||||
->has('currentPeriod')
|
||||
);
|
||||
});
|
||||
|
||||
test('user cannot view another users budget', function () {
|
||||
$user1 = User::factory()->create(['onboarded_at' => now()]);
|
||||
$user2 = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user1)->activate('budgets');
|
||||
Feature::for($user2)->activate('budgets');
|
||||
|
||||
$budget = Budget::factory()->create(['user_id' => $user1->id]);
|
||||
|
||||
$response = $this->actingAs($user2)->get("/budgets/{$budget->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('user can update their budget', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$budget = Budget::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)->patch("/budgets/{$budget->id}", [
|
||||
'name' => 'Updated Budget Name',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('budgets', [
|
||||
'id' => $budget->id,
|
||||
'name' => 'Updated Budget Name',
|
||||
]);
|
||||
});
|
||||
|
||||
test('user can delete their budget', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$budget = Budget::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)->delete("/budgets/{$budget->id}");
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$this->assertSoftDeleted('budgets', [
|
||||
'id' => $budget->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('budget period is automatically generated', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => now()]);
|
||||
Feature::for($user)->activate('budgets');
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)->post('/budgets', [
|
||||
'name' => 'Test Budget',
|
||||
'period_type' => 'monthly',
|
||||
'period_start_day' => 1,
|
||||
'category_id' => $category->id,
|
||||
'rollover_type' => 'reset',
|
||||
'allocated_amount' => 50000,
|
||||
]);
|
||||
|
||||
$budget = Budget::where('user_id', $user->id)->first();
|
||||
$this->assertNotNull($budget);
|
||||
$this->assertCount(1, $budget->periods);
|
||||
|
||||
$period = $budget->periods->first();
|
||||
$this->assertNotNull($period->start_date);
|
||||
$this->assertNotNull($period->end_date);
|
||||
});
|
||||
Loading…
Reference in New Issue