From e23ee84ce8a3d26c908323e5a3d4525bed28d43c Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 31 Dec 2025 00:02:54 +0100 Subject: [PATCH] Add streams --- app/Actions/CreateBucketAction.php | 9 - app/Actions/CreateScenarioAction.php | 31 +++ app/Actions/DeleteScenarioAction.php | 13 ++ app/Actions/UpdateScenarioAction.php | 15 ++ app/Http/Controllers/ScenarioController.php | 79 ++++---- app/Http/Controllers/StreamController.php | 69 +++++++ app/Http/Requests/StoreScenarioRequest.php | 42 ++++ app/Http/Requests/StoreStreamRequest.php | 107 +++++++++++ app/Http/Requests/UpdateScenarioRequest.php | 50 +++++ app/Http/Requests/UpdateStreamRequest.php | 111 +++++++++++ app/Http/Resources/BucketResource.php | 26 +++ app/Http/Resources/ScenarioResource.php | 20 ++ app/Http/Resources/StreamResource.php | 29 +++ app/Models/Scenario.php | 17 +- app/Models/Stream.php | 105 ++++++++++ app/Repositories/ScenarioRepository.php | 16 ++ app/Repositories/StreamRepository.php | 90 +++++++++ app/Services/Streams/StatsService.php | 34 ++++ database/factories/ScenarioFactory.php | 1 + ...25_12_29_192203_create_scenarios_table.php | 1 + ...2025_12_30_202750_create_streams_table.php | 34 ++++ resources/js/pages/Scenarios/Show.tsx | 181 +++++++++++++++++- routes/web.php | 8 + 23 files changed, 1021 insertions(+), 67 deletions(-) create mode 100644 app/Actions/CreateScenarioAction.php create mode 100644 app/Actions/DeleteScenarioAction.php create mode 100644 app/Actions/UpdateScenarioAction.php create mode 100644 app/Http/Controllers/StreamController.php create mode 100644 app/Http/Requests/StoreScenarioRequest.php create mode 100644 app/Http/Requests/StoreStreamRequest.php create mode 100644 app/Http/Requests/UpdateScenarioRequest.php create mode 100644 app/Http/Requests/UpdateStreamRequest.php create mode 100644 app/Http/Resources/BucketResource.php create mode 100644 app/Http/Resources/ScenarioResource.php create mode 100644 app/Http/Resources/StreamResource.php create mode 100644 app/Models/Stream.php create mode 100644 app/Repositories/ScenarioRepository.php create mode 100644 app/Repositories/StreamRepository.php create mode 100644 app/Services/Streams/StatsService.php create mode 100644 database/migrations/2025_12_30_202750_create_streams_table.php diff --git a/app/Actions/CreateBucketAction.php b/app/Actions/CreateBucketAction.php index d37be7f..8aab24d 100644 --- a/app/Actions/CreateBucketAction.php +++ b/app/Actions/CreateBucketAction.php @@ -62,15 +62,6 @@ public function execute( }); } - /** - * Create default buckets for a new scenario. - */ - public function createDefaultBuckets(Scenario $scenario): void - { - $this->execute($scenario, 'Monthly Expenses', Bucket::TYPE_FIXED_LIMIT, 0, 1); - $this->execute($scenario, 'Emergency Fund', Bucket::TYPE_FIXED_LIMIT, 0, 2); - $this->execute($scenario, 'Investments', Bucket::TYPE_UNLIMITED, null, 3); - } /** * Validate allocation value based on allocation type. diff --git a/app/Actions/CreateScenarioAction.php b/app/Actions/CreateScenarioAction.php new file mode 100644 index 0000000..4702689 --- /dev/null +++ b/app/Actions/CreateScenarioAction.php @@ -0,0 +1,31 @@ +createDefaultBuckets($scenario); + + return $scenario; + }); + } + + private function createDefaultBuckets(Scenario $scenario): void + { + $this->createBucketAction->execute($scenario, 'Monthly Expenses', Bucket::TYPE_FIXED_LIMIT, 0, 1); + $this->createBucketAction->execute($scenario, 'Emergency Fund', Bucket::TYPE_FIXED_LIMIT, 0, 2); + $this->createBucketAction->execute($scenario, 'Investments', Bucket::TYPE_UNLIMITED, null, 3); + } +} diff --git a/app/Actions/DeleteScenarioAction.php b/app/Actions/DeleteScenarioAction.php new file mode 100644 index 0000000..4931ecf --- /dev/null +++ b/app/Actions/DeleteScenarioAction.php @@ -0,0 +1,13 @@ +delete(); + } +} \ No newline at end of file diff --git a/app/Actions/UpdateScenarioAction.php b/app/Actions/UpdateScenarioAction.php new file mode 100644 index 0000000..e334c81 --- /dev/null +++ b/app/Actions/UpdateScenarioAction.php @@ -0,0 +1,15 @@ +update($data); + + return $scenario; + } +} diff --git a/app/Http/Controllers/ScenarioController.php b/app/Http/Controllers/ScenarioController.php index fa8172d..ea20ca1 100644 --- a/app/Http/Controllers/ScenarioController.php +++ b/app/Http/Controllers/ScenarioController.php @@ -2,19 +2,37 @@ namespace App\Http\Controllers; -use App\Actions\CreateBucketAction; +use App\Actions\CreateScenarioAction; +use App\Actions\UpdateScenarioAction; +use App\Actions\DeleteScenarioAction; +use App\Http\Resources\BucketResource; +use App\Http\Resources\StreamResource; +use App\Http\Resources\ScenarioResource; +use App\Http\Requests\StoreScenarioRequest; +use App\Http\Requests\UpdateScenarioRequest; use App\Models\Scenario; +use App\Repositories\StreamRepository; +use App\Repositories\ScenarioRepository; +use App\Services\Streams\StatsService; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; class ScenarioController extends Controller { + public function __construct( + private readonly ScenarioRepository $scenarioRepository, + private readonly StreamRepository $streamRepository, + private readonly CreateScenarioAction $createScenarioAction, + private readonly UpdateScenarioAction $updateScenarioAction, + private readonly DeleteScenarioAction $deleteScenarioAction, + private readonly StatsService $statsService + ) {} + public function index(): Response { return Inertia::render('Scenarios/Index', [ - 'scenarios' => Scenario::orderBy('created_at', 'desc')->get() + 'scenarios' => ScenarioResource::collection($this->scenarioRepository->getAll()) ]); } @@ -25,22 +43,10 @@ public function show(Scenario $scenario): Response }]); return Inertia::render('Scenarios/Show', [ - 'scenario' => $scenario, - 'buckets' => $scenario->buckets->map(function ($bucket) { - return [ - 'id' => $bucket->id, - 'name' => $bucket->name, - 'priority' => $bucket->priority, - 'sort_order' => $bucket->sort_order, - 'allocation_type' => $bucket->allocation_type, - 'allocation_value' => $bucket->allocation_value, - 'allocation_type_label' => $bucket->getAllocationTypeLabel(), - 'formatted_allocation_value' => $bucket->getFormattedAllocationValue(), - 'current_balance' => $bucket->getCurrentBalance(), - 'has_available_space' => $bucket->hasAvailableSpace(), - 'available_space' => $bucket->getAvailableSpace(), - ]; - }) + 'scenario' => new ScenarioResource($scenario), + 'buckets' => BucketResource::collection($scenario->buckets), + 'streams' => StreamResource::collection($this->streamRepository->getForScenario($scenario)), + 'streamStats' => $this->statsService->getSummaryStats($scenario) ]); } @@ -49,19 +55,9 @@ public function create(): Response return Inertia::render('Scenarios/Create'); } - public function store(Request $request): RedirectResponse + public function store(StoreScenarioRequest $request): RedirectResponse { - $request->validate([ - 'name' => 'required|string|max:255', - ]); - - $scenario = Scenario::create([ - 'name' => $request->name, - ]); - - // Create default buckets using the action - $createBucketAction = new CreateBucketAction(); - $createBucketAction->createDefaultBuckets($scenario); + $scenario = $this->createScenarioAction->execute($request->validated()); return redirect()->route('scenarios.show', $scenario); } @@ -69,30 +65,25 @@ public function store(Request $request): RedirectResponse public function edit(Scenario $scenario): Response { return Inertia::render('Scenarios/Edit', [ - 'scenario' => $scenario + 'scenario' => new ScenarioResource($scenario) ]); } - public function update(Request $request, Scenario $scenario): RedirectResponse + public function update(UpdateScenarioRequest $request, Scenario $scenario): RedirectResponse { - $request->validate([ - 'name' => 'required|string|max:255', - 'description' => 'nullable|string', - 'start_date' => 'nullable|date', - 'end_date' => 'nullable|date|after_or_equal:start_date', - ]); + $this->updateScenarioAction->execute($scenario, $request->validated()); - $scenario->update($request->only(['name', 'description', 'start_date', 'end_date'])); - - return redirect()->route('scenarios.show', $scenario) + return redirect() + ->route('scenarios.show', $scenario) ->with('success', 'Scenario updated successfully'); } public function destroy(Scenario $scenario): RedirectResponse { - $scenario->delete(); + $this->deleteScenarioAction->execute($scenario); - return redirect()->route('scenarios.index') + return redirect() + ->route('scenarios.index') ->with('success', 'Scenario deleted successfully'); } } diff --git a/app/Http/Controllers/StreamController.php b/app/Http/Controllers/StreamController.php new file mode 100644 index 0000000..6f03a32 --- /dev/null +++ b/app/Http/Controllers/StreamController.php @@ -0,0 +1,69 @@ +streamRepository->getForScenario($scenario); + + return response()->json([ + 'streams' => StreamResource::collection($streams), + 'stats' => $this->statsService->getSummaryStats($scenario) + ]); + } + + public function store(StoreStreamRequest $request, Scenario $scenario): JsonResponse + { + $stream = $this->streamRepository->create($scenario, $request->validated()); + + return response()->json([ + 'stream' => new StreamResource($stream), + 'message' => 'Stream created successfully.' + ], 201); + } + + public function update(UpdateStreamRequest $request, Stream $stream): JsonResponse + { + $stream = $this->streamRepository->update($stream, $request->validated()); + + return response()->json([ + 'stream' => new StreamResource($stream), + 'message' => 'Stream updated successfully.' + ]); + } + + public function destroy(Stream $stream): JsonResponse + { + $this->streamRepository->delete($stream); + + return response()->json([ + 'message' => 'Stream deleted successfully.' + ]); + } + + public function toggle(Stream $stream): JsonResponse + { + $stream = $this->streamRepository->toggleActive($stream); + + return response()->json([ + 'stream' => new StreamResource($stream), + 'message' => $stream->is_active ? 'Stream activated.' : 'Stream deactivated.' + ]); + } +} diff --git a/app/Http/Requests/StoreScenarioRequest.php b/app/Http/Requests/StoreScenarioRequest.php new file mode 100644 index 0000000..e1eed7f --- /dev/null +++ b/app/Http/Requests/StoreScenarioRequest.php @@ -0,0 +1,42 @@ + ['required', 'string', 'max:255', 'min:1'], + 'description' => ['nullable', 'string', 'max:1000'], + ]; + } + + public function messages(): array + { + return [ + 'name.required' => 'A scenario name is required.', + 'name.min' => 'The scenario name must be at least 1 character.', + 'name.max' => 'The scenario name cannot exceed 255 characters.', + ]; + } + + protected function prepareForValidation(): void + { + // Trim the name + if ($this->has('name')) { + $this->merge([ + 'name' => trim($this->name), + ]); + } + } +} diff --git a/app/Http/Requests/StoreStreamRequest.php b/app/Http/Requests/StoreStreamRequest.php new file mode 100644 index 0000000..2cd3b9e --- /dev/null +++ b/app/Http/Requests/StoreStreamRequest.php @@ -0,0 +1,107 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::in([Stream::TYPE_INCOME, Stream::TYPE_EXPENSE])], + 'amount' => ['required', 'numeric', 'min:0.01', 'max:999999999.99'], + 'frequency' => [ + 'required', + Rule::in([ + Stream::FREQUENCY_ONCE, + Stream::FREQUENCY_WEEKLY, + Stream::FREQUENCY_BIWEEKLY, + Stream::FREQUENCY_MONTHLY, + Stream::FREQUENCY_QUARTERLY, + Stream::FREQUENCY_YEARLY, + ]) + ], + 'start_date' => ['required', 'date', 'date_format:Y-m-d'], + 'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'], + 'bucket_id' => ['nullable', 'exists:buckets,id'], + 'description' => ['nullable', 'string', 'max:1000'], + ]; + } + + public function messages(): array + { + return [ + 'type.in' => 'The type must be either income or expense.', + 'frequency.in' => 'Invalid frequency selected.', + 'amount.min' => 'The amount must be greater than zero.', + 'amount.max' => 'The amount is too large.', + 'end_date.after_or_equal' => 'The end date must be after or equal to the start date.', + 'bucket_id.exists' => 'The selected bucket does not exist.', + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + // Validate that the bucket belongs to the scenario + if ($this->bucket_id) { + /** @var Scenario $scenario */ + $scenario = $this->route('scenario'); + + $bucketBelongsToScenario = $scenario->buckets() + ->where('id', $this->bucket_id) + ->exists(); + + if (!$bucketBelongsToScenario) { + $validator->errors()->add('bucket_id', 'The selected bucket does not belong to this scenario.'); + } + } + + // For expense streams, bucket is required + if ($this->type === Stream::TYPE_EXPENSE && !$this->bucket_id) { + $validator->errors()->add('bucket_id', 'A bucket must be selected for expense streams.'); + } + }); + } + + protected function prepareForValidation(): void + { + // Ensure dates are in the correct format + if ($this->has('start_date') && !empty($this->start_date)) { + $this->merge([ + 'start_date' => date('Y-m-d', strtotime($this->start_date)), + ]); + } + + if ($this->has('end_date') && !empty($this->end_date)) { + $this->merge([ + 'end_date' => date('Y-m-d', strtotime($this->end_date)), + ]); + } + + // Convert amount to float + if ($this->has('amount')) { + $this->merge([ + 'amount' => (float) $this->amount, + ]); + } + } +} diff --git a/app/Http/Requests/UpdateScenarioRequest.php b/app/Http/Requests/UpdateScenarioRequest.php new file mode 100644 index 0000000..81e3108 --- /dev/null +++ b/app/Http/Requests/UpdateScenarioRequest.php @@ -0,0 +1,50 @@ + ['required', 'string', 'max:255', 'min:1'], + 'description' => ['nullable', 'string', 'max:1000'], + ]; + } + + public function messages(): array + { + return [ + 'name.required' => 'A scenario name is required.', + 'name.min' => 'The scenario name must be at least 1 character.', + 'name.max' => 'The scenario name cannot exceed 255 characters.', + 'description.max' => 'The description cannot exceed 1000 characters.', + ]; + } + + protected function prepareForValidation(): void + { + // Trim the name + if ($this->has('name')) { + $this->merge([ + 'name' => trim($this->name), + ]); + } + + // Trim the description + if ($this->has('description')) { + $this->merge([ + 'description' => $this->description ? trim($this->description) : null, + ]); + } + } +} diff --git a/app/Http/Requests/UpdateStreamRequest.php b/app/Http/Requests/UpdateStreamRequest.php new file mode 100644 index 0000000..1dcf099 --- /dev/null +++ b/app/Http/Requests/UpdateStreamRequest.php @@ -0,0 +1,111 @@ + ['required', 'string', 'max:255'], + 'type' => ['required', Rule::in([Stream::TYPE_INCOME, Stream::TYPE_EXPENSE])], + 'amount' => ['required', 'numeric', 'min:0.01', 'max:999999999.99'], + 'frequency' => [ + 'required', + Rule::in([ + Stream::FREQUENCY_ONCE, + Stream::FREQUENCY_WEEKLY, + Stream::FREQUENCY_BIWEEKLY, + Stream::FREQUENCY_MONTHLY, + Stream::FREQUENCY_QUARTERLY, + Stream::FREQUENCY_YEARLY, + ]) + ], + 'start_date' => ['required', 'date', 'date_format:Y-m-d'], + 'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'], + 'bucket_id' => ['nullable', 'exists:buckets,id'], + 'description' => ['nullable', 'string', 'max:1000'], + 'is_active' => ['boolean'], + ]; + } + + public function messages(): array + { + return [ + 'type.in' => 'The type must be either income or expense.', + 'frequency.in' => 'Invalid frequency selected.', + 'amount.min' => 'The amount must be greater than zero.', + 'amount.max' => 'The amount is too large.', + 'end_date.after_or_equal' => 'The end date must be after or equal to the start date.', + 'bucket_id.exists' => 'The selected bucket does not exist.', + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + /** @var Stream $stream */ + $stream = $this->route('stream'); + + // Validate that the bucket belongs to the stream's scenario + if ($this->bucket_id) { + $bucketBelongsToScenario = $stream->scenario->buckets() + ->where('id', $this->bucket_id) + ->exists(); + + if (!$bucketBelongsToScenario) { + $validator->errors()->add('bucket_id', 'The selected bucket does not belong to this scenario.'); + } + } + + // For expense streams, bucket is required + if ($this->type === Stream::TYPE_EXPENSE && !$this->bucket_id) { + $validator->errors()->add('bucket_id', 'A bucket must be selected for expense streams.'); + } + }); + } + + protected function prepareForValidation(): void + { + // Ensure dates are in the correct format + if ($this->has('start_date') && !empty($this->start_date)) { + $this->merge([ + 'start_date' => date('Y-m-d', strtotime($this->start_date)), + ]); + } + + if ($this->has('end_date') && !empty($this->end_date)) { + $this->merge([ + 'end_date' => date('Y-m-d', strtotime($this->end_date)), + ]); + } + + // Convert amount to float + if ($this->has('amount')) { + $this->merge([ + 'amount' => (float) $this->amount, + ]); + } + + // Default is_active to current value if not provided + if (!$this->has('is_active')) { + /** @var Stream $stream */ + $stream = $this->route('stream'); + $this->merge([ + 'is_active' => $stream->is_active, + ]); + } + } +} diff --git a/app/Http/Resources/BucketResource.php b/app/Http/Resources/BucketResource.php new file mode 100644 index 0000000..b9c79b3 --- /dev/null +++ b/app/Http/Resources/BucketResource.php @@ -0,0 +1,26 @@ + $this->id, + 'name' => $this->name, + 'priority' => $this->priority, + 'sort_order' => $this->sort_order, + 'allocation_type' => $this->allocation_type, + 'allocation_value' => $this->allocation_value, + 'allocation_type_label' => $this->getAllocationTypeLabel(), + 'formatted_allocation_value' => $this->getFormattedAllocationValue(), + 'current_balance' => $this->getCurrentBalance(), + 'has_available_space' => $this->hasAvailableSpace(), + 'available_space' => $this->getAvailableSpace(), + ]; + } +} diff --git a/app/Http/Resources/ScenarioResource.php b/app/Http/Resources/ScenarioResource.php new file mode 100644 index 0000000..563dba6 --- /dev/null +++ b/app/Http/Resources/ScenarioResource.php @@ -0,0 +1,20 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/StreamResource.php b/app/Http/Resources/StreamResource.php new file mode 100644 index 0000000..664a5e5 --- /dev/null +++ b/app/Http/Resources/StreamResource.php @@ -0,0 +1,29 @@ + $this->id, + 'name' => $this->name, + 'type' => $this->type, + 'type_label' => $this->getTypeLabel(), + 'amount' => $this->amount, + 'frequency' => $this->frequency, + 'frequency_label' => $this->getFrequencyLabel(), + 'start_date' => $this->start_date->format('Y-m-d'), + 'end_date' => $this->end_date?->format('Y-m-d'), + 'bucket_id' => $this->bucket_id, + 'bucket_name' => $this->bucket?->name, + 'description' => $this->description, + 'is_active' => $this->is_active, + 'monthly_equivalent' => $this->getMonthlyEquivalent(), + ]; + } +} diff --git a/app/Models/Scenario.php b/app/Models/Scenario.php index 354b091..9a8ca20 100644 --- a/app/Models/Scenario.php +++ b/app/Models/Scenario.php @@ -3,10 +3,15 @@ namespace App\Models; use Database\Factories\ScenarioFactory; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * @property Collection $buckets + * @method static create(array $data) + */ class Scenario extends Model { /** @use HasFactory */ @@ -14,34 +19,24 @@ class Scenario extends Model protected $fillable = [ 'name', + 'description', ]; - - public function buckets(): HasMany { return $this->hasMany(Bucket::class); } - /** - * Get the streams for this scenario. - */ public function streams(): HasMany { return $this->hasMany(Stream::class); } - /** - * Get the inflows for this scenario. - */ public function inflows(): HasMany { return $this->hasMany(Inflow::class); } - /** - * Get the outflows for this scenario. - */ public function outflows(): HasMany { return $this->hasMany(Outflow::class); diff --git a/app/Models/Stream.php b/app/Models/Stream.php new file mode 100644 index 0000000..66860c9 --- /dev/null +++ b/app/Models/Stream.php @@ -0,0 +1,105 @@ + 'decimal:2', + 'start_date' => 'date', + 'end_date' => 'date', + 'is_active' => 'boolean', + ]; + + public function scenario(): BelongsTo + { + return $this->belongsTo(Scenario::class); + } + + public function bucket(): BelongsTo + { + return $this->belongsTo(Bucket::class); + } + + public static function getTypes(): array + { + return [ + self::TYPE_INCOME => 'Income', + self::TYPE_EXPENSE => 'Expense', + ]; + } + + public static function getFrequencies(): array + { + return [ + self::FREQUENCY_ONCE => 'One-time', + self::FREQUENCY_WEEKLY => 'Weekly', + self::FREQUENCY_BIWEEKLY => 'Bi-weekly', + self::FREQUENCY_MONTHLY => 'Monthly', + self::FREQUENCY_QUARTERLY => 'Quarterly', + self::FREQUENCY_YEARLY => 'Yearly', + ]; + } + + public function getTypeLabel(): string + { + return self::getTypes()[$this->type] ?? $this->type; + } + + public function getFrequencyLabel(): string + { + return self::getFrequencies()[$this->frequency] ?? $this->frequency; + } + + public function getMonthlyEquivalent(): float + { + switch ($this->frequency) { + case self::FREQUENCY_WEEKLY: + return $this->amount * 4.33; // Average weeks per month + case self::FREQUENCY_BIWEEKLY: + return $this->amount * 2.17; + case self::FREQUENCY_MONTHLY: + return $this->amount; + case self::FREQUENCY_QUARTERLY: + return $this->amount / 3; + case self::FREQUENCY_YEARLY: + return $this->amount / 12; + case self::FREQUENCY_ONCE: + default: + return 0; + } + } + + /* SCOPES */ + + public function scopeByType($query, string $type) + { + return $query->where('type', $type); + } +} diff --git a/app/Repositories/ScenarioRepository.php b/app/Repositories/ScenarioRepository.php new file mode 100644 index 0000000..9323b2d --- /dev/null +++ b/app/Repositories/ScenarioRepository.php @@ -0,0 +1,16 @@ +orderBy('created_at', 'desc') + ->get(); + } +} \ No newline at end of file diff --git a/app/Repositories/StreamRepository.php b/app/Repositories/StreamRepository.php new file mode 100644 index 0000000..66e49ca --- /dev/null +++ b/app/Repositories/StreamRepository.php @@ -0,0 +1,90 @@ +streams() + ->with('bucket:id,name') + ->orderBy('type') + ->orderBy('name') + ->get(); + } + + public function create(Scenario $scenario, array $data): Stream + { + return $scenario->streams()->create($data); + } + + public function update(Stream $stream, array $data): Stream + { + $stream->update($data); + + return $stream->fresh('bucket'); + } + + public function delete(Stream $stream): bool + { + return $stream->delete(); + } + + public function toggleActive(Stream $stream): Stream + { + $stream->update([ + 'is_active' => !$stream->is_active + ]); + + return $stream->fresh('bucket'); + } + + /** + * Check if a bucket belongs to the scenario + */ + public function bucketBelongsToScenario(Scenario $scenario, ?int $bucketId): bool + { + if (! $bucketId) { + return true; + } + + return $scenario->buckets() + ->where('id', $bucketId) + ->exists(); + } + + + + /** + * Get streams grouped by type + */ + public function getGroupedByType(Scenario $scenario): array + { + return [ + 'income' => $scenario->streams() + ->with('bucket:id,name') + ->byType(Stream::TYPE_INCOME) + ->orderBy('name') + ->get(), + 'expense' => $scenario->streams() + ->with('bucket:id,name') + ->byType(Stream::TYPE_EXPENSE) + ->orderBy('name') + ->get(), + ]; + } + + /** + * Get streams for a specific bucket + */ + public function getForBucket(int $bucketId): Collection + { + return Stream::where('bucket_id', $bucketId) + ->where('is_active', true) + ->get(); + } +} diff --git a/app/Services/Streams/StatsService.php b/app/Services/Streams/StatsService.php new file mode 100644 index 0000000..3970b97 --- /dev/null +++ b/app/Services/Streams/StatsService.php @@ -0,0 +1,34 @@ +streams() + ->where('is_active', true) + ->get(); + + $totalMonthlyIncome = $streams + ->where('type', Stream::TYPE_INCOME) + ->sum(fn($stream) => $stream->getMonthlyEquivalent()); + + $totalMonthlyExpenses = $streams + ->where('type', Stream::TYPE_EXPENSE) + ->sum(fn($stream) => $stream->getMonthlyEquivalent()); + + return [ + 'total_streams' => $streams->count(), + 'active_streams' => $streams->where('is_active', true)->count(), + 'income_streams' => $streams->where('type', Stream::TYPE_INCOME)->count(), + 'expense_streams' => $streams->where('type', Stream::TYPE_EXPENSE)->count(), + 'monthly_income' => $totalMonthlyIncome, + 'monthly_expenses' => $totalMonthlyExpenses, + 'monthly_net' => $totalMonthlyIncome - $totalMonthlyExpenses, + ]; + } +} \ No newline at end of file diff --git a/database/factories/ScenarioFactory.php b/database/factories/ScenarioFactory.php index f95f816..6595c1b 100644 --- a/database/factories/ScenarioFactory.php +++ b/database/factories/ScenarioFactory.php @@ -14,6 +14,7 @@ public function definition(): array { return [ 'name' => $this->faker->words(2, true) . ' Budget', + 'description' => $this->faker->text, ]; } } diff --git a/database/migrations/2025_12_29_192203_create_scenarios_table.php b/database/migrations/2025_12_29_192203_create_scenarios_table.php index 3226989..2f1fdbe 100644 --- a/database/migrations/2025_12_29_192203_create_scenarios_table.php +++ b/database/migrations/2025_12_29_192203_create_scenarios_table.php @@ -11,6 +11,7 @@ public function up(): void Schema::create('scenarios', function (Blueprint $table) { $table->id(); $table->string('name'); + $table->text('description')->nullable(); $table->timestamps(); }); } diff --git a/database/migrations/2025_12_30_202750_create_streams_table.php b/database/migrations/2025_12_30_202750_create_streams_table.php new file mode 100644 index 0000000..2dddef2 --- /dev/null +++ b/database/migrations/2025_12_30_202750_create_streams_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('scenario_id')->constrained()->cascadeOnDelete(); + $table->foreignId('bucket_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->boolean('is_active')->default(true); + $table->decimal('amount', 12, 2); + $table->enum('type', ['income', 'expense']); + $table->enum('frequency', ['once', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']); + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->index(['scenario_id', 'is_active']); + $table->index('start_date'); + }); + } + + public function down(): void + { + Schema::dropIfExists('streams'); + } +}; diff --git a/resources/js/pages/Scenarios/Show.tsx b/resources/js/pages/Scenarios/Show.tsx index 6eef5d3..aa2bbef 100644 --- a/resources/js/pages/Scenarios/Show.tsx +++ b/resources/js/pages/Scenarios/Show.tsx @@ -22,12 +22,41 @@ interface Bucket { available_space: number; } +interface Stream { + id: number; + name: string; + type: 'income' | 'expense'; + type_label: string; + amount: number; + frequency: string; + frequency_label: string; + start_date: string; + end_date: string | null; + bucket_id: number | null; + bucket_name: string | null; + description: string | null; + is_active: boolean; + monthly_equivalent: number; +} + +interface StreamStats { + total_streams: number; + active_streams: number; + income_streams: number; + expense_streams: number; + monthly_income: number; + monthly_expenses: number; + monthly_net: number; +} + interface Props { scenario: Scenario; buckets: Bucket[]; + streams: Stream[]; + streamStats?: StreamStats; } -export default function Show({ scenario, buckets }: Props) { +export default function Show({ scenario, buckets, streams = [], streamStats }: Props) { const [isModalOpen, setIsModalOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [editingBucket, setEditingBucket] = useState(null); @@ -302,13 +331,159 @@ export default function Show({ scenario, buckets }: Props) { + {/* Streams Section */} +
+
+

Income & Expense Streams

+ +
+ + {/* Stream Statistics */} + {streamStats && ( +
+
+
+
+ Monthly Income +
+
+ ${streamStats.monthly_income.toFixed(2)} +
+
+
+
+
+
+ Monthly Expenses +
+
+ ${streamStats.monthly_expenses.toFixed(2)} +
+
+
+
+
+
+ Net Cash Flow +
+
= 0 ? 'text-green-600' : 'text-red-600'}`}> + ${streamStats.monthly_net.toFixed(2)} +
+
+
+
+
+
+ Active Streams +
+
+ {streamStats.active_streams} / {streamStats.total_streams} +
+
+
+
+ )} + + {streams.length === 0 ? ( +
+

No streams yet. Add income or expense streams to start tracking cash flow.

+
+ ) : ( +
+ + + + + + + + + + + + + + + {streams.map((stream) => ( + + + + + + + + + + + ))} + +
+ Name + + Type + + Amount + + Frequency + + Bucket + + Start Date + + Status + + Actions +
+ {stream.name} + + + {stream.type_label} + + + ${stream.amount.toFixed(2)} + + {stream.frequency_label} + + {stream.bucket_name || '-'} + + {new Date(stream.start_date).toLocaleDateString()} + + + + + +
+
+ )} +
+ {/* Placeholder for future features */}

- Coming Next: Streams & Timeline + Coming Next: Timeline & Projections

- Add income and expense streams, then calculate projections to see your money flow through these buckets over time. + Calculate projections to see your money flow through these buckets over time.

diff --git a/routes/web.php b/routes/web.php index c3bccdd..6b6d9d4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\BucketController; use App\Http\Controllers\ScenarioController; +use App\Http\Controllers\StreamController; use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Laravel\Fortify\Features; @@ -22,6 +23,13 @@ Route::delete('/buckets/{bucket}', [BucketController::class, 'destroy'])->name('buckets.destroy'); Route::patch('/scenarios/{scenario}/buckets/priorities', [BucketController::class, 'updatePriorities'])->name('buckets.update-priorities'); +// Stream routes (no auth required for MVP) +Route::get('/scenarios/{scenario}/streams', [StreamController::class, 'index'])->name('streams.index'); +Route::post('/scenarios/{scenario}/streams', [StreamController::class, 'store'])->name('streams.store'); +Route::patch('/streams/{stream}', [StreamController::class, 'update'])->name('streams.update'); +Route::delete('/streams/{stream}', [StreamController::class, 'destroy'])->name('streams.destroy'); +Route::patch('/streams/{stream}/toggle', [StreamController::class, 'toggle'])->name('streams.toggle'); + Route::middleware(['auth', 'verified'])->group(function () { Route::get('dashboard', function () { return Inertia::render('dashboard');