Compare commits

..

No commits in common. "41fad79af7770717a561dc88cc29b3582e8f4602" and "2e633d75da3c6b10622811cc78c55654ff95d5bc" have entirely different histories.

117 changed files with 555 additions and 4307 deletions

View file

@ -14,5 +14,5 @@ trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
[compose.yaml]
indent_size = 4

View file

@ -1,25 +0,0 @@
APP_NAME=Laravel
APP_ENV=testing
APP_KEY=base64:5VABFQKtzx6flRFn7rQUQYI/G8xLnkUSYPVaYz2s/4M=
APP_DEBUG=true
APP_URL=http://localhost
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=4
LOG_CHANNEL=stack
LOG_STACK=single
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
SESSION_DRIVER=array
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
CACHE_STORE=array
MAIL_MAILER=array

View file

@ -1,41 +0,0 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up Docker Buildx
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
- name: Login to Forgejo Registry
uses: https://data.forgejo.org/docker/login-action@v3
with:
registry: forge.lvl0.xyz
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Determine tags
id: meta
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
echo "tags=forge.lvl0.xyz/lvl0/buckets:${TAG},forge.lvl0.xyz/lvl0/buckets:latest" >> $GITHUB_OUTPUT
else
echo "tags=forge.lvl0.xyz/lvl0/buckets:latest" >> $GITHUB_OUTPUT
fi
- name: Build and push
uses: https://data.forgejo.org/docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}

View file

@ -1,113 +0,0 @@
name: CI
on:
push:
branches: ['release/*']
pull_request:
branches: [main]
jobs:
ci:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up PHP
uses: https://github.com/shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_sqlite, mbstring, xml, dom
coverage: pcov
- name: Cache Composer dependencies
uses: https://data.forgejo.org/actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Prepare environment
run: cp .env.testing .env
- name: Lint
run: vendor/bin/pint --test
- name: Static analysis
run: vendor/bin/phpstan analyse --memory-limit=512M
- name: Tests
run: php artisan test --coverage-clover coverage.xml --coverage-text
- name: Parse coverage
if: github.event_name == 'pull_request'
id: coverage
run: |
COVERAGE=$(php -r '
$xml = simplexml_load_file("coverage.xml");
if ($xml === false || !isset($xml->project->metrics)) {
echo "0";
exit;
}
$metrics = $xml->project->metrics;
$statements = (int) $metrics["statements"];
$covered = (int) $metrics["coveredstatements"];
echo $statements > 0 ? round(($covered / $statements) * 100, 2) : 0;
')
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COVERAGE: ${{ steps.coverage.outputs.percentage }}
REPO: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
COMMIT_SHA: ${{ github.sha }}
run: |
API_URL="${SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments"
MARKER="<!-- buckets-ci-coverage-report -->"
BODY="${MARKER}
## Code Coverage Report
| Metric | Value |
|--------|-------|
| **Line Coverage** | ${COVERAGE}% |
_Updated by CI — commit ${COMMIT_SHA}_"
# Find existing coverage comment
EXISTING=$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" \
"${API_URL}?limit=50" | \
php -r '
$comments = json_decode(file_get_contents("php://stdin"), true);
if (!is_array($comments)) exit;
foreach ($comments as $c) {
if (str_contains($c["body"], "<!-- buckets-ci-coverage-report -->")) {
echo $c["id"];
exit;
}
}
' || true)
if [ -n "$EXISTING" ]; then
# Update existing comment
curl -sf -X PATCH \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
"${SERVER_URL}/api/v1/repos/${REPO}/issues/comments/${EXISTING}" > /dev/null
else
# Create new comment
curl -sf -X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
"${API_URL}" > /dev/null
fi

8
.gitignore vendored
View file

@ -1,17 +1,16 @@
/.vite
/.phpunit.cache
/bootstrap/ssr
/node_modules
/public/build
/public/hot
/public/storage
/public/vendor
/resources/js/actions
/resources/js/routes
/resources/js/wayfinder
/storage/*.key
/storage/pail
/vendor
.DS_Store
.env
.env.backup
.env.production
@ -21,13 +20,10 @@ Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/package-lock.json
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.zed
/coverage-report*
/coverage.xml
/.claude
package-lock.json

View file

@ -2,7 +2,6 @@
namespace App\Actions;
use App\Enums\BucketAllocationTypeEnum;
use App\Models\Bucket;
use App\Models\Scenario;
use Illuminate\Support\Facades\DB;
@ -13,15 +12,21 @@ class CreateBucketAction
public function execute(
Scenario $scenario,
string $name,
BucketAllocationTypeEnum $allocationType,
string $allocationType,
?float $allocationValue = null,
?int $priority = null
): Bucket {
// Validate allocation type
$validTypes = [Bucket::TYPE_FIXED_LIMIT, Bucket::TYPE_PERCENTAGE, Bucket::TYPE_UNLIMITED];
if (!in_array($allocationType, $validTypes)) {
throw new InvalidArgumentException("Invalid allocation type: {$allocationType}");
}
// Validate allocation value based on type
$this->validateAllocationValue($allocationType, $allocationValue);
// Set allocation_value to null for unlimited buckets
if ($allocationType === BucketAllocationTypeEnum::UNLIMITED) {
if ($allocationType === Bucket::TYPE_UNLIMITED) {
$allocationValue = null;
}
@ -33,20 +38,16 @@ public function execute(
} else {
// Validate priority is positive
if ($priority < 1) {
throw new InvalidArgumentException('Priority must be at least 1');
throw new InvalidArgumentException("Priority must be at least 1");
}
// Check if priority already exists and shift others if needed
$existingBucket = $scenario->buckets()->where('priority', $priority)->first();
if ($existingBucket) {
// Shift priorities in reverse order to avoid unique constraint violations
// (SQLite checks constraints per-row during bulk updates)
// Shift priorities to make room
$scenario->buckets()
->where('priority', '>=', $priority)
->orderByDesc('priority')
->each(function ($bucket) {
$bucket->increment('priority');
});
->increment('priority');
}
}
@ -61,13 +62,23 @@ 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.
*/
private function validateAllocationValue(BucketAllocationTypeEnum $allocationType, ?float $allocationValue): void
private function validateAllocationValue(string $allocationType, ?float $allocationValue): void
{
switch ($allocationType) {
case BucketAllocationTypeEnum::FIXED_LIMIT:
case Bucket::TYPE_FIXED_LIMIT:
if ($allocationValue === null) {
throw new InvalidArgumentException('Fixed limit buckets require an allocation value');
}
@ -76,7 +87,7 @@ private function validateAllocationValue(BucketAllocationTypeEnum $allocationTyp
}
break;
case BucketAllocationTypeEnum::PERCENTAGE:
case Bucket::TYPE_PERCENTAGE:
if ($allocationValue === null) {
throw new InvalidArgumentException('Percentage buckets require an allocation value');
}
@ -85,47 +96,10 @@ private function validateAllocationValue(BucketAllocationTypeEnum $allocationTyp
}
break;
case BucketAllocationTypeEnum::UNLIMITED:
case Bucket::TYPE_UNLIMITED:
// Unlimited buckets should not have an allocation value
// We'll set it to null in the main method regardless
break;
}
}
/**
* Create default buckets for a scenario.
*/
public function createDefaultBuckets(Scenario $scenario): array
{
$buckets = [];
// Monthly Expenses - Fixed limit, priority 1
$buckets[] = $this->execute(
$scenario,
'Monthly Expenses',
BucketAllocationTypeEnum::FIXED_LIMIT,
0,
1
);
// Emergency Fund - Fixed limit, priority 2
$buckets[] = $this->execute(
$scenario,
'Emergency Fund',
BucketAllocationTypeEnum::FIXED_LIMIT,
0,
2
);
// Investments - Unlimited, priority 3
$buckets[] = $this->execute(
$scenario,
'Investments',
BucketAllocationTypeEnum::UNLIMITED,
null,
3
);
return $buckets;
}
}

View file

@ -1,31 +0,0 @@
<?php
namespace App\Actions;
use App\Enums\BucketAllocationTypeEnum;
use App\Models\Scenario;
use Illuminate\Support\Facades\DB;
readonly class CreateScenarioAction
{
public function __construct(
private CreateBucketAction $createBucketAction
) {}
public function execute(array $data): Scenario
{
return DB::transaction(function () use ($data) {
$scenario = Scenario::create($data);
$this->createDefaultBuckets($scenario);
return $scenario;
});
}
private function createDefaultBuckets(Scenario $scenario): void
{
$this->createBucketAction->execute($scenario, 'Monthly Expenses', BucketAllocationTypeEnum::FIXED_LIMIT, 0, 1);
$this->createBucketAction->execute($scenario, 'Emergency Fund', BucketAllocationTypeEnum::FIXED_LIMIT, 0, 2);
$this->createBucketAction->execute($scenario, 'Investments', BucketAllocationTypeEnum::UNLIMITED, null, 3);
}
}

View file

@ -1,13 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Scenario;
readonly class DeleteScenarioAction
{
public function execute(Scenario $scenario): void
{
$scenario->delete();
}
}

View file

@ -1,15 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Scenario;
readonly class UpdateScenarioAction
{
public function execute(Scenario $scenario, array $data): Scenario
{
$scenario->update($data);
return $scenario;
}
}

View file

@ -1,42 +0,0 @@
<?php
namespace App\Enums;
enum BucketAllocationTypeEnum: string
{
case FIXED_LIMIT = 'fixed_limit';
case PERCENTAGE = 'percentage';
case UNLIMITED = 'unlimited';
public function getLabel(): string
{
return match ($this) {
self::FIXED_LIMIT => 'Fixed Limit',
self::PERCENTAGE => 'Percentage',
self::UNLIMITED => 'Unlimited',
};
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public function getAllocationValueRules(): array
{
return match ($this) {
self::FIXED_LIMIT => ['required', 'numeric', 'min:0'],
self::PERCENTAGE => ['required', 'numeric', 'min:0.01', 'max:100'],
self::UNLIMITED => ['nullable'],
};
}
public function formatValue(?float $value): string
{
return match ($this) {
self::FIXED_LIMIT => '$'.number_format($value ?? 0, 2),
self::PERCENTAGE => number_format($value ?? 0, 2).'%',
self::UNLIMITED => 'All remaining',
};
}
}

View file

@ -1,55 +0,0 @@
<?php
namespace App\Enums;
enum StreamFrequencyEnum: string
{
case ONCE = 'once';
case DAILY = 'daily';
case WEEKLY = 'weekly';
case BIWEEKLY = 'biweekly';
case MONTHLY = 'monthly';
case QUARTERLY = 'quarterly';
case YEARLY = 'yearly';
public function label(): string
{
return match ($this) {
self::ONCE => 'One-time',
self::DAILY => 'Daily',
self::WEEKLY => 'Weekly',
self::BIWEEKLY => 'Bi-weekly',
self::MONTHLY => 'Monthly',
self::QUARTERLY => 'Quarterly',
self::YEARLY => 'Yearly',
};
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public static function labels(): array
{
$labels = [];
foreach (self::cases() as $case) {
$labels[$case->value] = $case->label();
}
return $labels;
}
public function getMonthlyEquivalentMultiplier(): float
{
return match ($this) {
self::DAILY => 30.44, // Average days per month
self::WEEKLY => 4.33, // Average weeks per month
self::BIWEEKLY => 2.17,
self::MONTHLY => 1.0,
self::QUARTERLY => 1.0 / 3.0,
self::YEARLY => 1.0 / 12.0,
self::ONCE => 0.0,
};
}
}

View file

@ -1,33 +0,0 @@
<?php
namespace App\Enums;
enum StreamTypeEnum: string
{
case INCOME = 'income';
case EXPENSE = 'expense';
public function label(): string
{
return match ($this) {
self::INCOME => 'Income',
self::EXPENSE => 'Expense',
};
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public static function labels(): array
{
$labels = [];
foreach (self::cases() as $case) {
$labels[$case->value] = $case->label();
}
return $labels;
}
}

View file

@ -34,7 +34,7 @@ public function index(Scenario $scenario): JsonResponse
});
return response()->json([
'buckets' => $buckets,
'buckets' => $buckets
]);
}
@ -42,7 +42,7 @@ public function store(Request $request, Scenario $scenario): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'allocation_type' => 'required|in:'.implode(',', [
'allocation_type' => 'required|in:' . implode(',', [
Bucket::TYPE_FIXED_LIMIT,
Bucket::TYPE_PERCENTAGE,
Bucket::TYPE_UNLIMITED,
@ -52,7 +52,7 @@ public function store(Request $request, Scenario $scenario): JsonResponse
]);
try {
$createBucketAction = new CreateBucketAction;
$createBucketAction = new CreateBucketAction();
$bucket = $createBucketAction->execute(
$scenario,
$validated['name'],
@ -63,12 +63,12 @@ public function store(Request $request, Scenario $scenario): JsonResponse
return response()->json([
'bucket' => $this->formatBucketResponse($bucket),
'message' => 'Bucket created successfully.',
'message' => 'Bucket created successfully.'
], 201);
} catch (InvalidArgumentException $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['allocation_value' => [$e->getMessage()]],
'errors' => ['allocation_value' => [$e->getMessage()]]
], 422);
}
}
@ -77,7 +77,7 @@ public function update(Request $request, Bucket $bucket): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'allocation_type' => 'required|in:'.implode(',', [
'allocation_type' => 'required|in:' . implode(',', [
Bucket::TYPE_FIXED_LIMIT,
Bucket::TYPE_PERCENTAGE,
Bucket::TYPE_UNLIMITED,
@ -107,7 +107,7 @@ public function update(Request $request, Bucket $bucket): JsonResponse
return response()->json([
'bucket' => $this->formatBucketResponse($bucket),
'message' => 'Bucket updated successfully.',
'message' => 'Bucket updated successfully.'
]);
}
@ -125,7 +125,7 @@ public function destroy(Bucket $bucket): JsonResponse
$this->shiftPrioritiesDown($scenarioId, $deletedPriority);
return response()->json([
'message' => 'Bucket deleted successfully.',
'message' => 'Bucket deleted successfully.'
]);
}
@ -151,7 +151,7 @@ public function updatePriorities(Request $request, Scenario $scenario): JsonResp
}
return response()->json([
'message' => 'Bucket priorities updated successfully.',
'message' => 'Bucket priorities updated successfully.'
]);
}
@ -175,6 +175,7 @@ private function formatBucketResponse(Bucket $bucket): array
];
}
/**
* Shift priorities down to fill gap after deletion.
*/

View file

@ -1,30 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\CalculateProjectionRequest;
use App\Http\Resources\ProjectionResource;
use App\Models\Scenario;
use App\Services\Projection\ProjectionGeneratorService;
use Carbon\Carbon;
class ProjectionController extends Controller
{
public function __construct(
private readonly ProjectionGeneratorService $projectionGeneratorService
) {}
public function calculate(CalculateProjectionRequest $request, Scenario $scenario): ProjectionResource
{
$startDate = Carbon::parse($request->input('start_date'));
$endDate = Carbon::parse($request->input('end_date'));
$projections = $this->projectionGeneratorService->generateProjections(
$scenario,
$startDate,
$endDate
);
return new ProjectionResource($projections);
}
}

View file

@ -2,37 +2,19 @@
namespace App\Http\Controllers;
use App\Actions\CreateScenarioAction;
use App\Actions\DeleteScenarioAction;
use App\Actions\UpdateScenarioAction;
use App\Http\Requests\StoreScenarioRequest;
use App\Http\Requests\UpdateScenarioRequest;
use App\Http\Resources\BucketResource;
use App\Http\Resources\ScenarioResource;
use App\Http\Resources\StreamResource;
use App\Actions\CreateBucketAction;
use App\Models\Scenario;
use App\Repositories\ScenarioRepository;
use App\Repositories\StreamRepository;
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' => ScenarioResource::collection($this->scenarioRepository->getAll()),
'scenarios' => Scenario::orderBy('created_at', 'desc')->get()
]);
}
@ -43,10 +25,22 @@ public function show(Scenario $scenario): Response
}]);
return Inertia::render('Scenarios/Show', [
'scenario' => new ScenarioResource($scenario),
'buckets' => BucketResource::collection($scenario->buckets),
'streams' => StreamResource::collection($this->streamRepository->getForScenario($scenario)),
'streamStats' => $this->statsService->getSummaryStats($scenario),
'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(),
];
})
]);
}
@ -55,9 +49,19 @@ public function create(): Response
return Inertia::render('Scenarios/Create');
}
public function store(StoreScenarioRequest $request): RedirectResponse
public function store(Request $request): RedirectResponse
{
$scenario = $this->createScenarioAction->execute($request->validated());
$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);
return redirect()->route('scenarios.show', $scenario);
}
@ -65,25 +69,30 @@ public function store(StoreScenarioRequest $request): RedirectResponse
public function edit(Scenario $scenario): Response
{
return Inertia::render('Scenarios/Edit', [
'scenario' => new ScenarioResource($scenario),
'scenario' => $scenario
]);
}
public function update(UpdateScenarioRequest $request, Scenario $scenario): RedirectResponse
public function update(Request $request, Scenario $scenario): RedirectResponse
{
$this->updateScenarioAction->execute($scenario, $request->validated());
$request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
]);
return redirect()
->route('scenarios.show', $scenario)
$scenario->update($request->only(['name', 'description', 'start_date', 'end_date']));
return redirect()->route('scenarios.show', $scenario)
->with('success', 'Scenario updated successfully');
}
public function destroy(Scenario $scenario): RedirectResponse
{
$this->deleteScenarioAction->execute($scenario);
$scenario->delete();
return redirect()
->route('scenarios.index')
return redirect()->route('scenarios.index')
->with('success', 'Scenario deleted successfully');
}
}

View file

@ -1,69 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreStreamRequest;
use App\Http\Requests\UpdateStreamRequest;
use App\Http\Resources\StreamResource;
use App\Models\Scenario;
use App\Models\Stream;
use App\Repositories\StreamRepository;
use App\Services\Streams\StatsService;
use Illuminate\Http\JsonResponse;
class StreamController extends Controller
{
public function __construct(
private readonly StreamRepository $streamRepository,
private readonly StatsService $statsService
) {}
public function index(Scenario $scenario): JsonResponse
{
$streams = $this->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.',
]);
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CalculateProjectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'start_date' => ['required', 'date', 'before_or_equal:end_date'],
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
];
}
public function messages(): array
{
return [
'start_date.before_or_equal' => 'Start date must be before or equal to end date.',
'end_date.after_or_equal' => 'End date must be after or equal to start date.',
];
}
}

View file

@ -1,42 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreScenarioRequest extends FormRequest
{
public function authorize(): bool
{
// In production, check if user is authenticated
// For now, allow all requests
return true;
}
public function rules(): array
{
return [
'name' => ['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),
]);
}
}
}

View file

@ -1,107 +0,0 @@
<?php
namespace App\Http\Requests;
use App\Models\Scenario;
use App\Models\Stream;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreStreamRequest extends FormRequest
{
public function authorize(): bool
{
// In production, check if user owns the scenario
// For now, allow all requests
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array|string>
*/
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,
]);
}
}
}

View file

@ -1,50 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateScenarioRequest extends FormRequest
{
public function authorize(): bool
{
// In production, check if user owns the scenario
// For now, allow all requests
return true;
}
public function rules(): array
{
return [
'name' => ['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,
]);
}
}
}

View file

@ -1,110 +0,0 @@
<?php
namespace App\Http\Requests;
use App\Models\Stream;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateStreamRequest extends FormRequest
{
public function authorize(): bool
{
// In production, check if user owns the stream/scenario
// For now, allow all requests
return true;
}
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'],
'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,
]);
}
}
}

View file

@ -1,26 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BucketResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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(),
];
}
}

View file

@ -1,23 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class DrawResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'bucket_id' => $this->bucket_id,
'amount' => $this->amount_currency,
'formatted_amount' => $this->formatted_amount,
'date' => $this->date->format('Y-m-d'),
'description' => $this->description,
'is_projected' => $this->is_projected,
'priority_order' => $this->priority_order ?? null,
];
}
}

View file

@ -1,22 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class InflowResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'stream_id' => $this->stream_id,
'amount' => $this->amount_currency,
'formatted_amount' => $this->formatted_amount,
'date' => $this->date->format('Y-m-d'),
'description' => $this->description,
'is_projected' => $this->is_projected,
];
}
}

View file

@ -1,23 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OutflowResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'stream_id' => $this->stream_id,
'bucket_id' => $this->bucket_id,
'amount' => $this->amount_currency,
'formatted_amount' => $this->formatted_amount,
'date' => $this->date->format('Y-m-d'),
'description' => $this->description,
'is_projected' => $this->is_projected,
];
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'inflows' => InflowResource::collection($this->resource['inflows']),
'outflows' => OutflowResource::collection($this->resource['outflows']),
'draws' => DrawResource::collection($this->resource['draws']),
'summary' => [
'total_inflow' => $this->resource['inflows']->sum('amount_currency'),
'total_outflow' => $this->resource['outflows']->sum('amount_currency'),
'total_allocated' => $this->resource['draws']->sum('amount_currency'),
'net_cashflow' => $this->resource['inflows']->sum('amount_currency') - $this->resource['outflows']->sum('amount_currency'),
],
];
}
}

View file

@ -1,20 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ScenarioResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class StreamResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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(),
];
}
}

View file

@ -2,7 +2,6 @@
namespace App\Models;
use App\Enums\BucketAllocationTypeEnum;
use Database\Factories\BucketFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -10,16 +9,10 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $scenario_id
* @property Scenario $scenario
* @property string $name
* @property int $priority
* @property BucketAllocationTypeEnum $allocation_type
* @property float $starting_amount
* @property float $allocation_value
*
* @method static BucketFactory factory()
*/
class Bucket extends Model
{
@ -33,17 +26,19 @@ class Bucket extends Model
'sort_order',
'allocation_type',
'allocation_value',
'starting_amount',
];
protected $casts = [
'priority' => 'integer',
'sort_order' => 'integer',
'allocation_value' => 'decimal:2',
'starting_amount' => 'integer',
'allocation_type' => BucketAllocationTypeEnum::class,
];
// TODO Extract to Enum
const string TYPE_FIXED_LIMIT = 'fixed_limit';
const string TYPE_PERCENTAGE = 'percentage';
const string TYPE_UNLIMITED = 'unlimited';
public function scenario(): BelongsTo
{
return $this->belongsTo(Scenario::class);
@ -51,17 +46,21 @@ public function scenario(): BelongsTo
/**
* Get the draws for the bucket.
* (Will be implemented when Draw model is created)
*/
public function draws(): HasMany
{
// TODO: Implement when Draw model is created
return $this->hasMany(Draw::class);
}
/**
* Get the outflows for the bucket.
* (Will be implemented when Outflow model is created)
*/
public function outflows(): HasMany
{
// TODO: Implement when Outflow model is created
return $this->hasMany(Outflow::class);
}
@ -83,14 +82,12 @@ public function scopeOrderedBySortOrder($query)
/**
* Get the current balance of the bucket.
* Calculates starting amount plus total draws (money allocated to bucket) minus total outflows (money spent from bucket).
* For MVP, this will always return 0 as we don't have transactions yet.
*/
public function getCurrentBalance(): int
public function getCurrentBalance(): float
{
$totalDraws = $this->draws()->sum('amount');
$totalOutflows = $this->outflows()->sum('amount');
return $this->starting_amount + $totalDraws - $totalOutflows;
// TODO: Calculate from draws minus outflows when those features are implemented
return 0.0;
}
/**
@ -98,7 +95,7 @@ public function getCurrentBalance(): int
*/
public function hasAvailableSpace(): bool
{
if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) {
if ($this->allocation_type !== self::TYPE_FIXED_LIMIT) {
return true;
}
@ -110,7 +107,7 @@ public function hasAvailableSpace(): bool
*/
public function getAvailableSpace(): float
{
if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) {
if ($this->allocation_type !== self::TYPE_FIXED_LIMIT) {
return PHP_FLOAT_MAX;
}
@ -122,7 +119,12 @@ public function getAvailableSpace(): float
*/
public function getAllocationTypeLabel(): string
{
return $this->allocation_type->getLabel();
return match($this->allocation_type) {
self::TYPE_FIXED_LIMIT => 'Fixed Limit',
self::TYPE_PERCENTAGE => 'Percentage',
self::TYPE_UNLIMITED => 'Unlimited',
default => 'Unknown',
};
}
/**
@ -130,7 +132,12 @@ public function getAllocationTypeLabel(): string
*/
public function getFormattedAllocationValue(): string
{
return $this->allocation_type->formatValue($this->allocation_value);
return match($this->allocation_type) {
self::TYPE_FIXED_LIMIT => '$' . number_format($this->allocation_value, 2),
self::TYPE_PERCENTAGE => number_format($this->allocation_value, 2) . '%',
self::TYPE_UNLIMITED => 'All remaining',
default => '-',
};
}
/**
@ -140,13 +147,17 @@ public static function validationRules($scenarioId = null): array
{
$rules = [
'name' => 'required|string|max:255',
'allocation_type' => 'required|in:'.implode(',', BucketAllocationTypeEnum::values()),
'allocation_type' => 'required|in:' . implode(',', [
self::TYPE_FIXED_LIMIT,
self::TYPE_PERCENTAGE,
self::TYPE_UNLIMITED,
]),
'priority' => 'required|integer|min:1',
];
// Add scenario-specific priority uniqueness if scenario ID provided
if ($scenarioId) {
$rules['priority'] .= '|unique:buckets,priority,NULL,id,scenario_id,'.$scenarioId;
$rules['priority'] .= '|unique:buckets,priority,NULL,id,scenario_id,' . $scenarioId;
}
return $rules;
@ -155,8 +166,13 @@ public static function validationRules($scenarioId = null): array
/**
* Get allocation value validation rules based on type.
*/
public static function allocationValueRules(BucketAllocationTypeEnum $allocationType): array
public static function allocationValueRules($allocationType): array
{
return $allocationType->getAllocationValueRules();
return match($allocationType) {
self::TYPE_FIXED_LIMIT => ['required', 'numeric', 'min:0'],
self::TYPE_PERCENTAGE => ['required', 'numeric', 'min:0.01', 'max:100'],
self::TYPE_UNLIMITED => ['nullable'],
default => ['nullable'],
};
}
}

View file

@ -1,54 +0,0 @@
<?php
namespace App\Models;
use App\Models\Traits\HasAmount;
use App\Models\Traits\HasProjectionStatus;
use Carbon\Carbon;
use Database\Factories\DrawFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property Bucket $bucket
* @property int $priority_order
* @property float $amount
* @property Carbon $date
* @property string $description
* @property bool $is_projected
*
* @method static create(array $array)
*/
class Draw extends Model
{
use HasAmount;
/** @use HasFactory<DrawFactory> */
use HasFactory;
use HasProjectionStatus;
protected $fillable = [
'bucket_id',
'amount',
'date',
'description',
'is_projected',
];
protected $casts = [
'amount' => 'integer',
'date' => 'date',
'is_projected' => 'boolean',
];
public function bucket(): BelongsTo
{
return $this->belongsTo(Bucket::class);
}
public function scenario(): BelongsTo
{
return $this->bucket->scenario();
}
}

View file

@ -1,48 +0,0 @@
<?php
namespace App\Models;
use App\Models\Traits\HasAmount;
use App\Models\Traits\HasProjectionStatus;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $stream_id
* @property Stream $stream
* @property float $amount
* @property Carbon $date
* @property string $description
* @property bool $is_projected
*/
class Inflow extends Model
{
use HasAmount;
use HasProjectionStatus;
protected $fillable = [
'stream_id',
'amount',
'date',
'description',
'is_projected',
];
protected $casts = [
'amount' => 'integer',
'date' => 'date',
'is_projected' => 'boolean',
];
public function stream(): BelongsTo
{
return $this->belongsTo(Stream::class);
}
public function scenario(): BelongsTo
{
return $this->stream->scenario();
}
}

View file

@ -1,61 +0,0 @@
<?php
namespace App\Models;
use App\Models\Traits\HasAmount;
use App\Models\Traits\HasProjectionStatus;
use Carbon\Carbon;
use Database\Factories\OutflowFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $stream_id
* @property Stream $stream
* @property int $amount
* @property Carbon $date
* @property string $description
* @property bool $is_projected
*
* @method static create(array $array)
*/
class Outflow extends Model
{
use HasAmount;
/** @use HasFactory<OutflowFactory> */
use HasFactory;
use HasProjectionStatus;
protected $fillable = [
'stream_id',
'bucket_id',
'amount',
'date',
'description',
'is_projected',
];
protected $casts = [
'amount' => 'integer',
'date' => 'date',
'is_projected' => 'boolean',
];
public function stream(): BelongsTo
{
return $this->belongsTo(Stream::class);
}
public function bucket(): BelongsTo
{
return $this->belongsTo(Bucket::class);
}
public function scenario(): BelongsTo
{
return $this->stream->scenario();
}
}

View file

@ -3,17 +3,10 @@
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 int $id
* @property Collection<Bucket> $buckets
*
* @method static create(array $data)
*/
class Scenario extends Model
{
/** @use HasFactory<ScenarioFactory> */
@ -21,24 +14,34 @@ 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);

View file

@ -1,89 +0,0 @@
<?php
namespace App\Models;
use App\Enums\StreamFrequencyEnum;
use App\Enums\StreamTypeEnum;
use App\Models\Traits\HasAmount;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $amount
* @property StreamFrequencyEnum $frequency
* @property Carbon $start_date
*/
class Stream extends Model
{
use HasAmount, HasFactory;
protected $fillable = [
'scenario_id',
'name',
'type',
'amount',
'frequency',
'start_date',
'end_date',
'bucket_id',
'description',
'is_active',
];
protected $casts = [
'type' => StreamTypeEnum::class,
'frequency' => StreamFrequencyEnum::class,
'amount' => 'integer',
'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 StreamTypeEnum::labels();
}
public static function getFrequencies(): array
{
return StreamFrequencyEnum::labels();
}
public function getTypeLabel(): string
{
return $this->type?->label() ?? '';
}
public function getFrequencyLabel(): string
{
return $this->frequency?->label() ?? '';
}
public function getMonthlyEquivalent(): float
{
if (! $this->frequency) {
return 0;
}
return $this->amount * $this->frequency->getMonthlyEquivalentMultiplier();
}
/* SCOPES */
public function scopeByType($query, string $type)
{
return $query->where('type', $type);
}
}

View file

@ -1,31 +0,0 @@
<?php
namespace App\Models\Traits;
trait HasAmount
{
/**
* Get amount in currency units (stored as minor units/cents).
*/
public function getAmountCurrencyAttribute(): float
{
return $this->amount / 100;
}
/**
* Set amount from currency units (stores as minor units/cents).
*/
public function setAmountCurrencyAttribute($value): void
{
$this->attributes['amount'] = round($value * 100);
}
/**
* Format amount for display with proper currency formatting.
* This can be extended later to support different currencies.
*/
public function getFormattedAmountAttribute(): string
{
return number_format($this->amount / 100, 2);
}
}

View file

@ -1,69 +0,0 @@
<?php
namespace App\Models\Traits;
trait HasProjectionStatus
{
public function initializeHasProjectionStatus(): void
{
$this->fillable = array_merge($this->fillable ?? [], [
'is_projected',
]);
$this->casts = array_merge($this->casts ?? [], [
'is_projected' => 'boolean',
]);
}
/**
* Scope to filter projected transactions
*/
public function scopeProjected($query)
{
return $query->where('is_projected', true);
}
/**
* Scope to filter actual transactions
*/
public function scopeActual($query)
{
return $query->where('is_projected', false);
}
/**
* Check if this transaction is projected
*/
public function isProjected(): bool
{
return $this->is_projected;
}
/**
* Check if this transaction is actual
*/
public function isActual(): bool
{
return ! $this->is_projected;
}
/**
* Mark transaction as projected
*/
public function markAsProjected(): self
{
$this->update(['is_projected' => true]);
return $this;
}
/**
* Mark transaction as actual
*/
public function markAsActual(): self
{
$this->update(['is_projected' => false]);
return $this;
}
}

View file

@ -1,16 +0,0 @@
<?php
namespace App\Repositories;
use App\Models\Scenario;
use Illuminate\Support\Collection;
class ScenarioRepository
{
public function getAll(): Collection
{
return Scenario::query()
->orderBy('created_at', 'desc')
->get();
}
}

View file

@ -1,88 +0,0 @@
<?php
namespace App\Repositories;
use App\Models\Scenario;
use App\Models\Stream;
use Illuminate\Support\Collection;
class StreamRepository
{
public function getForScenario(Scenario $scenario): Collection
{
return $scenario->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();
}
}

View file

@ -1,100 +0,0 @@
<?php
namespace App\Services\Projection;
use App\Enums\BucketAllocationTypeEnum;
use App\Models\Bucket;
use App\Models\Draw;
use App\Models\Scenario;
use Carbon\Carbon;
use Illuminate\Support\Collection;
readonly class PipelineAllocationService
{
/**
* Allocate an inflow amount across scenario buckets according to priority rules.
*
* @return Collection<Draw> Collection of Draw models
*/
public function allocateInflow(Scenario $scenario, int $amount, ?Carbon $date = null, ?string $description = null): Collection
{
$draws = collect();
// Guard clauses
if ($amount <= 0) {
return $draws;
}
// Get buckets ordered by priority
$buckets = $scenario->buckets()
->orderBy('priority')
->get();
if ($buckets->isEmpty()) {
return $draws;
}
$priorityOrder = 1;
$remainingAmount = $amount;
$allocationDate = $date ?? now();
foreach ($buckets as $bucket) {
if ($remainingAmount <= 0) {
break;
}
$allocation = $this->calculateBucketAllocation($bucket, $remainingAmount);
if ($allocation > 0) {
$draw = new Draw([
'bucket_id' => $bucket->id,
'amount' => $allocation,
'date' => $allocationDate,
'description' => $description ?? 'Allocation from inflow',
'is_projected' => true,
]);
$draws->push($draw);
$remainingAmount -= $allocation;
$priorityOrder++;
}
}
return $draws;
}
/**
* Calculate how much should be allocated to a specific bucket.
*/
private function calculateBucketAllocation(Bucket $bucket, int $remainingAmount): int
{
return match ($bucket->allocation_type) {
BucketAllocationTypeEnum::FIXED_LIMIT => $this->calculateFixedAllocation($bucket, $remainingAmount),
BucketAllocationTypeEnum::PERCENTAGE => $this->calculatePercentageAllocation($bucket, $remainingAmount),
BucketAllocationTypeEnum::UNLIMITED => $remainingAmount, // Takes all remaining
default => 0,
};
}
/**
* Calculate allocation for fixed limit buckets.
*/
private function calculateFixedAllocation(Bucket $bucket, int $remainingAmount): int
{
$bucketCapacity = (int) ($bucket->allocation_value ?? 0);
$currentBalance = $bucket->getCurrentBalance();
$availableSpace = max(0, $bucketCapacity - $currentBalance);
return min($availableSpace, $remainingAmount);
}
/**
* Calculate allocation for percentage buckets.
*/
private function calculatePercentageAllocation(Bucket $bucket, int $remainingAmount): int
{
$percentage = $bucket->allocation_value ?? 0;
return (int) round($remainingAmount * ($percentage / 100));
}
}

View file

@ -1,145 +0,0 @@
<?php
namespace App\Services\Projection;
use App\Enums\StreamFrequencyEnum;
use App\Enums\StreamTypeEnum;
use App\Models\Inflow;
use App\Models\Outflow;
use App\Models\Scenario;
use App\Models\Stream;
use Carbon\Carbon;
class ProjectionGeneratorService
{
public function __construct(
private readonly PipelineAllocationService $pipelineAllocationService
) {}
public function generateProjections(Scenario $scenario, Carbon $startDate, Carbon $endDate): array
{
$inflows = collect();
$outflows = collect();
$draws = collect();
// Get active streams
$activeStreams = $scenario->streams()
->where('is_active', true)
->get();
// Process each day in the range
$currentDate = $startDate->copy();
while ($currentDate <= $endDate) {
// Process all streams that fire on this date
foreach ($activeStreams as $stream) {
if ($this->streamFiresOnDate($stream, $currentDate)) {
if ($stream->type === StreamTypeEnum::INCOME) {
// Create and save inflow
$inflow = Inflow::create([
'stream_id' => $stream->id,
'amount' => $stream->amount,
'date' => $currentDate->copy(),
'description' => "Projected income from {$stream->name}",
'is_projected' => true,
]);
$inflows->push($inflow);
// Immediately allocate this income to buckets
$dailyDraws = $this->pipelineAllocationService->allocateInflow(
$scenario,
$inflow->amount
);
// Set date and description for each draw and save
foreach ($dailyDraws as $draw) {
$draw->date = $currentDate->copy();
$draw->description = "Allocation from {$stream->name}";
$draw->is_projected = true;
$draw->save();
}
$draws = $draws->merge($dailyDraws);
} else {
// Create and save outflow
$outflow = Outflow::create([
'stream_id' => $stream->id,
'bucket_id' => $stream->bucket_id,
'amount' => $stream->amount,
'date' => $currentDate->copy(),
'description' => "Projected expense from {$stream->name}",
'is_projected' => true,
]);
$outflows->push($outflow);
}
}
}
// Move to next day
$currentDate->addDay();
}
// Calculate summary statistics
$summary = [
'total_inflow' => $inflows->sum('amount'),
'total_outflow' => $outflows->sum('amount'),
'total_allocated' => $draws->sum('amount'),
'net_cashflow' => $inflows->sum('amount') - $outflows->sum('amount'),
];
return [
'inflows' => $inflows->sortBy('date')->values(),
'outflows' => $outflows->sortBy('date')->values(),
'draws' => $draws->sortBy('date')->values(),
'summary' => $summary,
];
}
private function streamFiresOnDate(Stream $stream, Carbon $date): bool
{
// Check if date is within stream's active period
if ($date->lt($stream->start_date)) {
return false;
}
if ($stream->end_date && $date->gt($stream->end_date)) {
return false;
}
// Check frequency-specific rules
return match ($stream->frequency) {
StreamFrequencyEnum::DAILY => true,
StreamFrequencyEnum::WEEKLY => $this->isWeeklyOccurrence($stream, $date),
StreamFrequencyEnum::MONTHLY => $this->isMonthlyOccurrence($stream, $date),
StreamFrequencyEnum::YEARLY => $this->isYearlyOccurrence($stream, $date),
default => false,
};
}
private function isWeeklyOccurrence(Stream $stream, Carbon $date): bool
{
// Check if it's the same day of week as the start date
return $date->dayOfWeek === $stream->start_date->dayOfWeek;
}
private function isMonthlyOccurrence(Stream $stream, Carbon $date): bool
{
// Check if it's the same day of month as the start date
// Handle end-of-month cases (e.g., 31st in months with fewer days)
$targetDay = $stream->start_date->day;
$lastDayOfMonth = $date->copy()->endOfMonth()->day;
if ($targetDay > $lastDayOfMonth) {
// If the target day doesn't exist in this month, use the last day
return $date->day === $lastDayOfMonth;
}
return $date->day === $targetDay;
}
private function isYearlyOccurrence(Stream $stream, Carbon $date): bool
{
// Check if it's the same month and day as the start date
return $date->month === $stream->start_date->month
&& $date->day === $stream->start_date->day;
}
}

View file

@ -1,34 +0,0 @@
<?php
namespace App\Services\Streams;
use App\Enums\StreamTypeEnum;
use App\Models\Scenario;
readonly class StatsService
{
public function getSummaryStats(Scenario $scenario): array
{
$streams = $scenario->streams()
->where('is_active', true)
->get();
$totalMonthlyIncome = $streams
->where('type', StreamTypeEnum::INCOME)
->sum(fn ($stream) => $stream->getMonthlyEquivalent());
$totalMonthlyExpenses = $streams
->where('type', StreamTypeEnum::EXPENSE)
->sum(fn ($stream) => $stream->getMonthlyEquivalent());
return [
'total_streams' => $streams->count(),
'active_streams' => $streams->where('is_active', true)->count(),
'income_streams' => $streams->where('type', StreamTypeEnum::INCOME)->count(),
'expense_streams' => $streams->where('type', StreamTypeEnum::EXPENSE)->count(),
'monthly_income' => $totalMonthlyIncome,
'monthly_expenses' => $totalMonthlyExpenses,
'monthly_net' => $totalMonthlyIncome - $totalMonthlyExpenses,
];
}
}

View file

@ -9,7 +9,7 @@
],
"license": "MIT",
"require": {
"php": "^8.3",
"php": "^8.2",
"inertiajs/inertia-laravel": "^2.0",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
@ -18,13 +18,11 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
"larastan/larastan": "^3.9",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpstan/phpstan-mockery": "^2.0",
"phpunit/phpunit": "^11.5.3"
},
"autoload": {

237
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1fb421bd376d40ce4b1e241d0805c5ed",
"content-hash": "3c79e040a3570288a4004f205416801a",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -6660,137 +6660,6 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "iamcal/sql-parser",
"version": "v0.7",
"source": {
"type": "git",
"url": "https://github.com/iamcal/SQLParser.git",
"reference": "610392f38de49a44dab08dc1659960a29874c4b8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
"reference": "610392f38de49a44dab08dc1659960a29874c4b8",
"shasum": ""
},
"require-dev": {
"php-coveralls/php-coveralls": "^1.0",
"phpunit/phpunit": "^5|^6|^7|^8|^9"
},
"type": "library",
"autoload": {
"psr-4": {
"iamcal\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Cal Henderson",
"email": "cal@iamcal.com"
}
],
"description": "MySQL schema parser",
"support": {
"issues": "https://github.com/iamcal/SQLParser/issues",
"source": "https://github.com/iamcal/SQLParser/tree/v0.7"
},
"time": "2026-01-28T22:20:33+00:00"
},
{
"name": "larastan/larastan",
"version": "v3.9.3",
"source": {
"type": "git",
"url": "https://github.com/larastan/larastan.git",
"reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/larastan/larastan/zipball/64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65",
"reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65",
"shasum": ""
},
"require": {
"ext-json": "*",
"iamcal/sql-parser": "^0.7.0",
"illuminate/console": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/container": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/database": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/http": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
"php": "^8.2",
"phpstan/phpstan": "^2.1.32"
},
"require-dev": {
"doctrine/coding-standard": "^13",
"laravel/framework": "^11.44.2 || ^12.7.2 || ^13",
"mockery/mockery": "^1.6.12",
"nikic/php-parser": "^5.4",
"orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11",
"orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11",
"phpstan/phpstan-deprecation-rules": "^2.0.1",
"phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8"
},
"suggest": {
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
"phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Larastan\\Larastan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Can Vural",
"email": "can9119@gmail.com"
}
],
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
"keywords": [
"PHPStan",
"code analyse",
"code analysis",
"larastan",
"laravel",
"package",
"php",
"static analysis"
],
"support": {
"issues": "https://github.com/larastan/larastan/issues",
"source": "https://github.com/larastan/larastan/tree/v3.9.3"
},
"funding": [
{
"url": "https://github.com/canvural",
"type": "github"
}
],
"time": "2026-02-20T12:07:12+00:00"
},
{
"name": "laravel/pail",
"version": "v1.2.4",
@ -7360,108 +7229,6 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
{
"name": "phpstan/phpstan",
"version": "2.1.42",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0",
"reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2026-03-17T14:58:32+00:00"
},
{
"name": "phpstan/phpstan-mockery",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-mockery.git",
"reference": "89a949d0ac64298e88b7c7fa00caee565c198394"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-mockery/zipball/89a949d0ac64298e88b7c7fa00caee565c198394",
"reference": "89a949d0ac64298e88b7c7fa00caee565c198394",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.0"
},
"require-dev": {
"mockery/mockery": "^1.6.11",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
}
},
"autoload": {
"psr-4": {
"PHPStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan Mockery extension",
"support": {
"issues": "https://github.com/phpstan/phpstan-mockery/issues",
"source": "https://github.com/phpstan/phpstan-mockery/tree/2.0.0"
},
"time": "2024-10-14T03:18:12+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "11.0.12",
@ -9077,7 +8844,7 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.3"
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"

View file

@ -2,7 +2,6 @@
namespace Database\Factories;
use App\Enums\BucketAllocationTypeEnum;
use App\Models\Bucket;
use App\Models\Scenario;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -15,9 +14,9 @@ class BucketFactory extends Factory
public function definition(): array
{
$allocationType = $this->faker->randomElement([
BucketAllocationTypeEnum::FIXED_LIMIT,
BucketAllocationTypeEnum::PERCENTAGE,
BucketAllocationTypeEnum::UNLIMITED,
Bucket::TYPE_FIXED_LIMIT,
Bucket::TYPE_PERCENTAGE,
Bucket::TYPE_UNLIMITED,
]);
return [
@ -40,7 +39,6 @@ public function definition(): array
'sort_order' => $this->faker->numberBetween(0, 10),
'allocation_type' => $allocationType,
'allocation_value' => $this->getAllocationValueForType($allocationType),
'starting_amount' => $this->faker->numberBetween(0, 100000), // $0 to $1000 in cents
];
}
@ -52,7 +50,7 @@ public function fixedLimit($amount = null): Factory
$amount = $amount ?? $this->faker->numberBetween(500, 5000);
return $this->state([
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
'allocation_value' => $amount,
]);
}
@ -65,7 +63,7 @@ public function percentage($percentage = null): Factory
$percentage = $percentage ?? $this->faker->numberBetween(10, 50);
return $this->state([
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
'allocation_type' => Bucket::TYPE_PERCENTAGE,
'allocation_value' => $percentage,
]);
}
@ -76,7 +74,7 @@ public function percentage($percentage = null): Factory
public function unlimited(): Factory
{
return $this->state([
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
'allocation_type' => Bucket::TYPE_UNLIMITED,
'allocation_value' => null,
]);
}
@ -91,25 +89,22 @@ public function defaultSet(): array
'name' => 'Monthly Expenses',
'priority' => 1,
'sort_order' => 1,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
'allocation_value' => 0,
'starting_amount' => 0,
]),
$this->state([
'name' => 'Emergency Fund',
'priority' => 2,
'sort_order' => 2,
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
'allocation_value' => 0,
'starting_amount' => 0,
]),
$this->state([
'name' => 'Investments',
'priority' => 3,
'sort_order' => 3,
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
'allocation_type' => Bucket::TYPE_UNLIMITED,
'allocation_value' => null,
'starting_amount' => 0,
]),
];
}
@ -117,12 +112,12 @@ public function defaultSet(): array
/**
* Get allocation value based on type.
*/
private function getAllocationValueForType(BucketAllocationTypeEnum $type): ?float
private function getAllocationValueForType(string $type): ?float
{
return match ($type) {
BucketAllocationTypeEnum::FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
BucketAllocationTypeEnum::PERCENTAGE => $this->faker->numberBetween(5, 50),
BucketAllocationTypeEnum::UNLIMITED => null,
return match($type) {
Bucket::TYPE_FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
Bucket::TYPE_PERCENTAGE => $this->faker->numberBetween(5, 50),
Bucket::TYPE_UNLIMITED => null,
};
}
}

View file

@ -1,36 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\Bucket;
use App\Models\Draw;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Draw>
*/
class DrawFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'bucket_id' => null,
'amount' => $this->faker->numberBetween(5000, 200000), // $50 to $2000 in cents
'date' => $this->faker->dateTimeBetween('-6 months', 'now'),
'description' => $this->faker->sentence(),
'is_projected' => $this->faker->boolean(),
];
}
public function bucket(Bucket $bucket): self
{
return $this->state(fn () => [
'bucket_id' => $bucket->id,
]);
}
}

View file

@ -1,40 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\Bucket;
use App\Models\Outflow;
use App\Models\Stream;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Outflow>
*/
class OutflowFactory extends Factory
{
public function definition(): array
{
return [
'stream_id' => null,
'bucket_id' => null,
'amount' => $this->faker->numberBetween(2500, 100000), // $25 to $1000 in cents
'date' => $this->faker->dateTimeBetween('-6 months', 'now'),
'description' => $this->faker->sentence(),
'is_projected' => $this->faker->boolean(),
];
}
public function stream(Stream $stream): self
{
return $this->state(fn () => [
'stream_id' => $stream->id,
]);
}
public function bucket(Bucket $bucket): self
{
return $this->state(fn () => [
'bucket_id' => $bucket->id,
]);
}
}

View file

@ -13,8 +13,7 @@ class ScenarioFactory extends Factory
public function definition(): array
{
return [
'name' => $this->faker->words(2, true).' Budget',
'description' => $this->faker->text,
'name' => $this->faker->words(2, true) . ' Budget',
];
}
}

View file

@ -1,97 +0,0 @@
<?php
namespace Database\Factories;
use App\Enums\StreamFrequencyEnum;
use App\Enums\StreamTypeEnum;
use App\Models\Scenario;
use App\Models\Stream;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Stream>
*/
class StreamFactory extends Factory
{
protected $model = Stream::class;
public function definition(): array
{
$type = $this->faker->randomElement(StreamTypeEnum::cases());
return [
'scenario_id' => null, // Set in test
'name' => $this->faker->words(3, true),
'type' => $type,
'amount' => $this->faker->numberBetween(5000, 200000), // $50 to $2000
'frequency' => $this->faker->randomElement([
StreamFrequencyEnum::DAILY,
StreamFrequencyEnum::WEEKLY,
StreamFrequencyEnum::MONTHLY,
StreamFrequencyEnum::YEARLY,
]),
'start_date' => $this->faker->dateTimeBetween('-1 year', 'now'),
'end_date' => $this->faker->optional(0.2)->dateTimeBetween('now', '+1 year'),
'bucket_id' => null, // Only for expenses
'description' => $this->faker->optional()->sentence(),
'is_active' => true,
];
}
public function scenario(Scenario $scenario): self
{
return $this->state(fn () => [
'scenario_id' => $scenario->id,
]);
}
public function income(): self
{
return $this->state(fn (array $attributes) => [
'type' => StreamTypeEnum::INCOME,
'bucket_id' => null,
]);
}
public function expense(): self
{
return $this->state(fn (array $attributes) => [
'type' => StreamTypeEnum::EXPENSE,
]);
}
public function inactive(): self
{
return $this->state(fn (array $attributes) => [
'is_active' => false,
]);
}
public function daily(): self
{
return $this->state(fn (array $attributes) => [
'frequency' => StreamFrequencyEnum::DAILY,
]);
}
public function weekly(): self
{
return $this->state(fn (array $attributes) => [
'frequency' => StreamFrequencyEnum::WEEKLY,
]);
}
public function monthly(): self
{
return $this->state(fn (array $attributes) => [
'frequency' => StreamFrequencyEnum::MONTHLY,
]);
}
public function yearly(): self
{
return $this->state(fn (array $attributes) => [
'frequency' => StreamFrequencyEnum::YEARLY,
]);
}
}

View file

@ -11,7 +11,6 @@ public function up(): void
Schema::create('scenarios', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
});
}

View file

@ -17,8 +17,6 @@ public function up(): void
$table->enum('allocation_type', ['fixed_limit', 'percentage', 'unlimited']);
$table->decimal('allocation_value', 10, 2)->nullable()
->comment('Limit amount for fixed_limit, percentage for percentage type, NULL for unlimited');
$table->unsignedBigInteger('starting_amount')->default(0)
->comment('Initial amount in bucket in cents before any draws or outflows');
$table->timestamps();
// Indexes for performance

View file

@ -1,36 +0,0 @@
<?php
use App\Enums\StreamFrequencyEnum;
use App\Enums\StreamTypeEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('streams', function (Blueprint $table) {
$table->id();
$table->foreignId('scenario_id')->constrained()->cascadeOnDelete();
$table->foreignId('bucket_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->boolean('is_active')->default(true);
$table->unsignedBigInteger('amount');
$table->enum('type', StreamTypeEnum::values());
$table->enum('frequency', StreamFrequencyEnum::values());
$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');
}
};

View file

@ -1,29 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('inflows', function (Blueprint $table) {
$table->id();
$table->foreignId('stream_id')->nullable()->constrained()->onDelete('set null');
$table->unsignedBigInteger('amount');
$table->date('date');
$table->text('description')->nullable();
$table->boolean('is_projected')->default(true);
$table->timestamps();
$table->index(['stream_id', 'date']);
$table->index('is_projected');
});
}
public function down(): void
{
Schema::dropIfExists('inflows');
}
};

View file

@ -1,30 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('outflows', function (Blueprint $table) {
$table->id();
$table->foreignId('stream_id')->nullable()->constrained()->onDelete('set null');
$table->foreignId('bucket_id')->nullable()->constrained()->onDelete('set null');
$table->unsignedBigInteger('amount');
$table->date('date');
$table->text('description')->nullable();
$table->boolean('is_projected')->default(true);
$table->timestamps();
$table->index(['bucket_id', 'date']);
$table->index('is_projected');
});
}
public function down(): void
{
Schema::dropIfExists('outflows');
}
};

View file

@ -1,29 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('draws', function (Blueprint $table) {
$table->id();
$table->foreignId('bucket_id')->constrained()->onDelete('cascade');
$table->unsignedBigInteger('amount');
$table->date('date');
$table->text('description')->nullable();
$table->boolean('is_projected')->default(true);
$table->timestamps();
$table->index(['bucket_id', 'date']);
$table->index('is_projected');
});
}
public function down(): void
{
Schema::dropIfExists('draws');
}
};

View file

@ -1,7 +1,7 @@
# Production Docker Compose
services:
app:
image: forge.lvl0.xyz/lvl0/buckets:latest
image: codeberg.org/lvl0/buckets:latest
container_name: buckets_app
restart: always
ports:

View file

@ -1,52 +1,59 @@
# ===================
# Buckets Development Services
# ===================
# Port allocation:
# App: 8100 (frankenphp), 5174 (vite)
# DB: 3307 (mysql)
# Mailhog: 8026 (web), 1026 (smtp)
# Local Development Docker Compose
version: '3.8'
services:
app:
build:
context: ../..
context: .
dockerfile: Dockerfile.dev
container_name: buckets_dev_app
container_name: buckets_app
restart: unless-stopped
# Remove user directive to run as root in container
# The container will handle permissions internally
ports:
- "8100:8000"
- "5174:5173"
volumes:
- ../..:/app
- app_vendor:/app/vendor
- app_node_modules:/app/node_modules
- "8100:8000" # Laravel app
- "5174:5173" # Vite dev server
environment:
# Laravel
APP_NAME: "${APP_NAME:-buckets}"
APP_ENV: "${APP_ENV:-local}"
APP_KEY: "${APP_KEY:-base64:YOUR_KEY_HERE}"
APP_DEBUG: "${APP_DEBUG:-true}"
APP_URL: "${APP_URL:-http://localhost:8100}"
# Database
DB_CONNECTION: mysql
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: "${DB_DATABASE:-buckets}"
DB_USERNAME: "${DB_USERNAME:-buckets}"
DB_PASSWORD: "${DB_PASSWORD:-buckets}"
# Session & Cache
SESSION_DRIVER: "${SESSION_DRIVER:-file}"
CACHE_DRIVER: "${CACHE_DRIVER:-file}"
QUEUE_CONNECTION: "${QUEUE_CONNECTION:-sync}"
# Mail (for development)
MAIL_MAILER: "${MAIL_MAILER:-log}"
# Vite
VITE_HOST: "0.0.0.0"
VITE_PORT: "5174"
volumes:
# Mount entire project for hot reload with SELinux context
- .:/app:Z
# Named volumes for performance and permission isolation
- app_vendor:/app/vendor
- app_node_modules:/app/node_modules
depends_on:
db:
condition: service_healthy
- db
networks:
- buckets-network
- buckets
db:
image: mariadb:11
container_name: buckets_dev_db
container_name: buckets_db
hostname: db
restart: unless-stopped
ports:
@ -58,28 +65,39 @@ services:
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
volumes:
- db_data:/var/lib/mysql
- ../../docker/mysql-init:/docker-entrypoint-initdb.d
# Initialize with SQL scripts
- ./docker/mysql-init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
retries: 3
networks:
- buckets-network
- buckets
# Optional: Mailhog for email testing
mailhog:
image: mailhog/mailhog
container_name: buckets_dev_mailhog
container_name: buckets_mailhog
restart: unless-stopped
ports:
- "1026:1025"
- "8026:8025"
- "1026:1025" # SMTP server
- "8026:8025" # Web UI
networks:
- buckets-network
- buckets
# Optional: Redis for caching/sessions
# redis:
# image: redis:alpine
# container_name: buckets_redis
# restart: unless-stopped
# ports:
# - "6379:6379"
# networks:
# - buckets
networks:
buckets-network:
buckets:
driver: bridge
volumes:

View file

@ -1,631 +0,0 @@
parameters:
ignoreErrors:
-
message: '#^Method App\\Actions\\CreateBucketAction\:\:execute\(\) should return App\\Models\\Bucket but returns Illuminate\\Database\\Eloquent\\Model\.$#'
identifier: return.type
count: 1
path: app/Actions/CreateBucketAction.php
-
message: '#^Access to undefined constant App\\Models\\Bucket\:\:TYPE_FIXED_LIMIT\.$#'
identifier: classConstant.notFound
count: 2
path: app/Http/Controllers/BucketController.php
-
message: '#^Access to undefined constant App\\Models\\Bucket\:\:TYPE_PERCENTAGE\.$#'
identifier: classConstant.notFound
count: 2
path: app/Http/Controllers/BucketController.php
-
message: '#^Access to undefined constant App\\Models\\Bucket\:\:TYPE_UNLIMITED\.$#'
identifier: classConstant.notFound
count: 3
path: app/Http/Controllers/BucketController.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\HasMany\:\:orderedBySortOrder\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Controllers/BucketController.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_BIWEEKLY\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/StoreStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_MONTHLY\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/StoreStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_ONCE\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/StoreStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_QUARTERLY\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/StoreStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_WEEKLY\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/StoreStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_YEARLY\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/StoreStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:TYPE_EXPENSE\.$#'
identifier: classConstant.notFound
count: 2
path: app/Http/Requests/StoreStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:TYPE_INCOME\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/StoreStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_BIWEEKLY\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/UpdateStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_MONTHLY\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/UpdateStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_ONCE\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/UpdateStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_QUARTERLY\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/UpdateStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_WEEKLY\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/UpdateStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:FREQUENCY_YEARLY\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/UpdateStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:TYPE_EXPENSE\.$#'
identifier: classConstant.notFound
count: 2
path: app/Http/Requests/UpdateStreamRequest.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:TYPE_INCOME\.$#'
identifier: classConstant.notFound
count: 1
path: app/Http/Requests/UpdateStreamRequest.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:buckets\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Requests/UpdateStreamRequest.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$allocation_type\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$allocation_value\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$name\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$priority\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$sort_order\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Call to an undefined method App\\Http\\Resources\\BucketResource\:\:getAllocationTypeLabel\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Call to an undefined method App\\Http\\Resources\\BucketResource\:\:getAvailableSpace\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Call to an undefined method App\\Http\\Resources\\BucketResource\:\:getCurrentBalance\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Call to an undefined method App\\Http\\Resources\\BucketResource\:\:getFormattedAllocationValue\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Call to an undefined method App\\Http\\Resources\\BucketResource\:\:hasAvailableSpace\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Resources/BucketResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$amount_currency\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/DrawResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$bucket_id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/DrawResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$date\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/DrawResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$description\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/DrawResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$formatted_amount\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/DrawResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/DrawResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$is_projected\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/DrawResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$amount_currency\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/InflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$date\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/InflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$description\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/InflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$formatted_amount\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/InflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/InflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$is_projected\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/InflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$stream_id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/InflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$amount_currency\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/OutflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$bucket_id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/OutflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$date\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/OutflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$description\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/OutflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$formatted_amount\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/OutflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/OutflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$is_projected\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/OutflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$stream_id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/OutflowResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\ScenarioResource\:\:\$created_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/ScenarioResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\ScenarioResource\:\:\$description\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/ScenarioResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\ScenarioResource\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/ScenarioResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\ScenarioResource\:\:\$name\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/ScenarioResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\ScenarioResource\:\:\$updated_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/ScenarioResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$amount\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$bucket\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$bucket_id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$description\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$end_date\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$frequency\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$is_active\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$name\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$start_date\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$type\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Call to an undefined method App\\Http\\Resources\\StreamResource\:\:getFrequencyLabel\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Call to an undefined method App\\Http\\Resources\\StreamResource\:\:getMonthlyEquivalent\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Call to an undefined method App\\Http\\Resources\\StreamResource\:\:getTypeLabel\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Resources/StreamResource.php
-
message: '#^Method App\\Models\\Bucket\:\:getCurrentBalance\(\) should return int but returns float\.$#'
identifier: return.type
count: 1
path: app/Models/Bucket.php
-
message: '#^Property App\\Models\\Draw\:\:\$casts \(array\<string, string\>\) on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.property
count: 1
path: app/Models/Draw.php
-
message: '#^Property App\\Models\\Draw\:\:\$fillable \(list\<string\>\) on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.property
count: 1
path: app/Models/Draw.php
-
message: '#^Property App\\Models\\Inflow\:\:\$casts \(array\<string, string\>\) on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.property
count: 1
path: app/Models/Inflow.php
-
message: '#^Property App\\Models\\Inflow\:\:\$fillable \(list\<string\>\) on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.property
count: 1
path: app/Models/Inflow.php
-
message: '#^Property App\\Models\\Outflow\:\:\$casts \(array\<string, string\>\) on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.property
count: 1
path: app/Models/Outflow.php
-
message: '#^Property App\\Models\\Outflow\:\:\$fillable \(list\<string\>\) on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.property
count: 1
path: app/Models/Outflow.php
-
message: '#^Expression on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.expr
count: 2
path: app/Models/Stream.php
-
message: '#^Negated boolean expression is always false\.$#'
identifier: booleanNot.alwaysFalse
count: 1
path: app/Models/Stream.php
-
message: '#^Using nullsafe method call on non\-nullable type App\\Enums\\StreamFrequencyEnum\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Models/Stream.php
-
message: '#^Using nullsafe method call on non\-nullable type App\\Enums\\StreamTypeEnum\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Models/Stream.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:TYPE_EXPENSE\.$#'
identifier: classConstant.notFound
count: 1
path: app/Repositories/StreamRepository.php
-
message: '#^Access to undefined constant App\\Models\\Stream\:\:TYPE_INCOME\.$#'
identifier: classConstant.notFound
count: 1
path: app/Repositories/StreamRepository.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\HasMany\:\:byType\(\)\.$#'
identifier: method.notFound
count: 2
path: app/Repositories/StreamRepository.php
-
message: '#^Method App\\Repositories\\StreamRepository\:\:create\(\) should return App\\Models\\Stream but returns Illuminate\\Database\\Eloquent\\Model\.$#'
identifier: return.type
count: 1
path: app/Repositories/StreamRepository.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Services/Projection/PipelineAllocationService.php
-
message: '#^Match arm comparison between App\\Enums\\BucketAllocationTypeEnum\:\:UNLIMITED and App\\Enums\\BucketAllocationTypeEnum\:\:UNLIMITED is always true\.$#'
identifier: match.alwaysTrue
count: 1
path: app/Services/Projection/PipelineAllocationService.php
-
message: '#^Parameter \#1 \$bucket of method App\\Services\\Projection\\PipelineAllocationService\:\:calculateBucketAllocation\(\) expects App\\Models\\Bucket, Illuminate\\Database\\Eloquent\\Model given\.$#'
identifier: argument.type
count: 1
path: app/Services/Projection/PipelineAllocationService.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$amount\.$#'
identifier: property.notFound
count: 2
path: app/Services/Projection/ProjectionGeneratorService.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$bucket_id\.$#'
identifier: property.notFound
count: 1
path: app/Services/Projection/ProjectionGeneratorService.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
identifier: property.notFound
count: 2
path: app/Services/Projection/ProjectionGeneratorService.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$name\.$#'
identifier: property.notFound
count: 3
path: app/Services/Projection/ProjectionGeneratorService.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$type\.$#'
identifier: property.notFound
count: 1
path: app/Services/Projection/ProjectionGeneratorService.php
-
message: '#^Parameter \#1 \$stream of method App\\Services\\Projection\\ProjectionGeneratorService\:\:streamFiresOnDate\(\) expects App\\Models\\Stream, Illuminate\\Database\\Eloquent\\Model given\.$#'
identifier: argument.type
count: 1
path: app/Services/Projection/ProjectionGeneratorService.php
-
message: '#^Parameter \#2 \$amount of method App\\Services\\Projection\\PipelineAllocationService\:\:allocateInflow\(\) expects int, float given\.$#'
identifier: argument.type
count: 1
path: app/Services/Projection/ProjectionGeneratorService.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:getMonthlyEquivalent\(\)\.$#'
identifier: method.notFound
count: 2
path: app/Services/Streams/StatsService.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$allocation_type\.$#'
identifier: property.notFound
count: 3
path: tests/Unit/Actions/CreateBucketActionTest.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$allocation_value\.$#'
identifier: property.notFound
count: 3
path: tests/Unit/Actions/CreateBucketActionTest.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$name\.$#'
identifier: property.notFound
count: 5
path: tests/Unit/Actions/CreateBucketActionTest.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$priority\.$#'
identifier: property.notFound
count: 3
path: tests/Unit/Actions/CreateBucketActionTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with float will always evaluate to false\.$#'
identifier: method.impossibleType
count: 2
path: tests/Unit/Actions/CreateBucketActionTest.php

View file

@ -1,17 +0,0 @@
includes:
- vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan-mockery/extension.neon
- phpstan-baseline.neon
parameters:
level: 5
paths:
- app/
- tests/
excludePaths:
- bootstrap/*.php
- storage/*
ignoreErrors:
- identifier: method.alreadyNarrowedType

View file

@ -18,19 +18,18 @@
</include>
</source>
<php>
<env name="APP_ENV" value="testing" force="true"/>
<server name="APP_ENV" value="testing" force="true"/>
<server name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
<server name="BCRYPT_ROUNDS" value="4" force="true"/>
<server name="BROADCAST_CONNECTION" value="log" force="true"/>
<server name="CACHE_STORE" value="array" force="true"/>
<server name="DB_CONNECTION" value="sqlite" force="true"/>
<server name="DB_DATABASE" value=":memory:" force="true"/>
<server name="MAIL_MAILER" value="array" force="true"/>
<server name="PULSE_ENABLED" value="false" force="true"/>
<server name="QUEUE_CONNECTION" value="sync" force="true"/>
<server name="SESSION_DRIVER" value="array" force="true"/>
<server name="TELESCOPE_ENABLED" value="false" force="true"/>
<server name="NIGHTWATCH_ENABLED" value="false" force="true"/>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

View file

@ -1,5 +1,5 @@
import { Breadcrumbs } from '@/components/Breadcrumbs';
import { Icon } from '@/components/Icon';
import { Breadcrumbs } from '@/components/breadcrumbs';
import { Icon } from '@/components/icon';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {

View file

@ -1,4 +1,4 @@
import { Breadcrumbs } from '@/components/Breadcrumbs';
import { Breadcrumbs } from '@/components/breadcrumbs';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';

View file

@ -1,6 +1,6 @@
import { NavFooter } from '@/components/NavFooter';
import { NavMain } from '@/components/NavMain';
import { NavUser } from '@/components/NavUser';
import { NavFooter } from '@/components/nav-footer';
import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user';
import {
Sidebar,
SidebarContent,

View file

@ -1,6 +1,6 @@
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import HeadingSmall from '@/components/HeadingSmall';
import InputError from '@/components/InputError';
import HeadingSmall from '@/components/heading-small';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import {
Dialog,

View file

@ -1,4 +1,4 @@
import { Icon } from '@/components/Icon';
import { Icon } from '@/components/icon';
import {
SidebarGroup,
SidebarGroupContent,

View file

@ -1,4 +1,4 @@
import InputError from '@/components/InputError';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import {
Dialog,

View file

@ -1,4 +1,4 @@
import AppLayoutTemplate from '@/layouts/app/AppSidebarLayout';
import AppLayoutTemplate from '@/layouts/app/app-sidebar-layout';
import { type BreadcrumbItem } from '@/types';
import { type ReactNode } from 'react';

View file

@ -1,6 +1,6 @@
import { AppContent } from '@/components/AppContent';
import { AppHeader } from '@/components/AppHeader';
import { AppShell } from '@/components/AppShell';
import { AppContent } from '@/components/app-content';
import { AppHeader } from '@/components/app-header';
import { AppShell } from '@/components/app-shell';
import { type BreadcrumbItem } from '@/types';
import type { PropsWithChildren } from 'react';

View file

@ -1,7 +1,7 @@
import { AppContent } from '@/components/AppContent';
import { AppShell } from '@/components/AppShell';
import { AppSidebar } from '@/components/AppSidebar';
import { AppSidebarHeader } from '@/components/AppSidebarHeader';
import { AppContent } from '@/components/app-content';
import { AppShell } from '@/components/app-shell';
import { AppSidebar } from '@/components/app-sidebar';
import { AppSidebarHeader } from '@/components/app-sidebar-header';
import { type BreadcrumbItem } from '@/types';
import { type PropsWithChildren } from 'react';

View file

@ -1,4 +1,4 @@
import AuthLayoutTemplate from '@/layouts/auth/AuthSimpleLayout';
import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout';
export default function AuthLayout({
children,

View file

@ -1,4 +1,4 @@
import AppLogoIcon from '@/components/AppLogoIcon';
import AppLogoIcon from '@/components/app-logo-icon';
import {
Card,
CardContent,

View file

@ -1,5 +1,5 @@
import AppLogoIcon from '@/components/AppLogoIcon';
import { dashboard } from '@/routes';
import AppLogoIcon from '@/components/app-logo-icon';
import { home } from '@/routes';
import { Link } from '@inertiajs/react';
import { type PropsWithChildren } from 'react';
@ -20,7 +20,7 @@ export default function AuthSimpleLayout({
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4">
<Link
href={dashboard.url()}
href={home()}
className="flex flex-col items-center gap-2 font-medium"
>
<div className="mb-1 flex h-9 w-9 items-center justify-center rounded-md">

View file

@ -1,4 +1,4 @@
import AppLogoIcon from '@/components/AppLogoIcon';
import AppLogoIcon from '@/components/app-logo-icon';
import { home } from '@/routes';
import { type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';

View file

@ -1,4 +1,4 @@
import Heading from '@/components/Heading';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';

View file

@ -9,9 +9,7 @@ interface Scenario {
}
interface Props {
scenarios: {
data: Scenario[];
};
scenarios: Scenario[];
}
export default function Index({ scenarios }: Props) {
@ -110,7 +108,7 @@ export default function Index({ scenarios }: Props) {
)}
{/* Scenarios List */}
{scenarios.data.length === 0 ? (
{scenarios.length === 0 ? (
<div className="rounded-lg bg-white p-12 text-center shadow">
<div className="mx-auto h-12 w-12 text-gray-400">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -124,7 +122,7 @@ export default function Index({ scenarios }: Props) {
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{scenarios.data.map((scenario) => (
{scenarios.map((scenario) => (
<div
key={scenario.id}
onClick={() => router.visit(`/scenarios/${scenario.id}`)}

View file

@ -22,96 +22,27 @@ 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: { data: Bucket[] };
streams: { data: Stream[] };
streamStats?: StreamStats;
buckets: Bucket[];
}
export default function Show({ scenario, buckets, streams = { data: [] }, streamStats }: Props) {
export default function Show({ scenario, buckets }: Props) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editingBucket, setEditingBucket] = useState<Bucket | null>(null);
const [formData, setFormData] = useState({
name: '',
allocation_type: 'fixed_limit',
allocation_value: ''
});
const handleEdit = (bucket: Bucket) => {
setEditingBucket(bucket);
setFormData({
name: bucket.name,
allocation_type: bucket.allocation_type,
allocation_value: bucket.allocation_value ? bucket.allocation_value.toString() : ''
});
setIsModalOpen(true);
};
const handleDelete = async (bucket: Bucket) => {
if (!confirm(`Are you sure you want to delete "${bucket.name}"?`)) {
return;
}
try {
const response = await fetch(`/buckets/${bucket.id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
});
if (response.ok) {
router.reload({ only: ['buckets'] });
} else {
console.error('Failed to delete bucket');
}
} catch (error) {
console.error('Error deleting bucket:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const url = editingBucket
? `/buckets/${editingBucket.id}`
: `/scenarios/${scenario.id}/buckets`;
const method = editingBucket ? 'PATCH' : 'POST';
try {
const response = await fetch(url, {
method: method,
const response = await fetch(`/scenarios/${scenario.id}/buckets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
@ -119,35 +50,33 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
body: JSON.stringify({
name: formData.name,
allocation_type: formData.allocation_type,
allocation_value: formData.allocation_value ? parseFloat(formData.allocation_value) : null,
priority: editingBucket ? editingBucket.priority : undefined
allocation_value: formData.allocation_value ? parseFloat(formData.allocation_value) : null
}),
});
if (response.ok) {
setIsModalOpen(false);
setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' });
setEditingBucket(null);
router.reload({ only: ['buckets'] });
} else {
const errorData = await response.json();
console.error(`Failed to ${editingBucket ? 'update' : 'create'} bucket:`, errorData);
console.error('Failed to create bucket:', errorData);
}
} catch (error) {
console.error(`Error ${editingBucket ? 'updating' : 'creating'} bucket:`, error);
console.error('Error creating bucket:', error);
} finally {
setIsSubmitting(false);
}
};
const handlePriorityChange = async (bucketId: number, direction: 'up' | 'down') => {
const bucket = buckets.data.find(b => b.id === bucketId);
const bucket = buckets.find(b => b.id === bucketId);
if (!bucket) return;
const newPriority = direction === 'up' ? bucket.priority - 1 : bucket.priority + 1;
// Don't allow moving beyond bounds
if (newPriority < 1 || newPriority > buckets.data.length) return;
if (newPriority < 1 || newPriority > buckets.length) return;
try {
const response = await fetch(`/buckets/${bucketId}`, {
@ -206,11 +135,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Buckets</h2>
<button
onClick={() => {
setEditingBucket(null);
setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' });
setIsModalOpen(true);
}}
onClick={() => setIsModalOpen(true)}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
+ Add Bucket
@ -218,7 +143,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{buckets.data.map((bucket) => (
{buckets.map((bucket) => (
<div
key={bucket.id}
className="rounded-lg bg-white p-6 shadow transition-shadow hover:shadow-lg border-l-4 border-blue-500"
@ -245,7 +170,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</button>
<button
onClick={() => handlePriorityChange(bucket.id, 'down')}
disabled={bucket.priority === buckets.data.length}
disabled={bucket.priority === buckets.length}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
title="Move down in priority"
>
@ -294,16 +219,10 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</div>
<div className="mt-4 flex gap-2">
<button
onClick={() => handleEdit(bucket)}
className="flex-1 text-sm text-blue-600 hover:text-blue-500"
>
<button className="flex-1 text-sm text-blue-600 hover:text-blue-500">
Edit
</button>
<button
onClick={() => handleDelete(bucket)}
className="flex-1 text-sm text-red-600 hover:text-red-500"
>
<button className="flex-1 text-sm text-red-600 hover:text-red-500">
Delete
</button>
</div>
@ -331,159 +250,13 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</div>
</div>
{/* Streams Section */}
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Income & Expense Streams</h2>
<button
className="rounded-md bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-500"
>
+ Add Stream
</button>
</div>
{/* Stream Statistics */}
{streamStats && (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-6">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Monthly Income
</dt>
<dd className="mt-1 text-3xl font-semibold text-green-600">
${streamStats.monthly_income.toFixed(2)}
</dd>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Monthly Expenses
</dt>
<dd className="mt-1 text-3xl font-semibold text-red-600">
${streamStats.monthly_expenses.toFixed(2)}
</dd>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Net Cash Flow
</dt>
<dd className={`mt-1 text-3xl font-semibold ${streamStats.monthly_net >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${streamStats.monthly_net.toFixed(2)}
</dd>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Active Streams
</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">
{streamStats.active_streams} / {streamStats.total_streams}
</dd>
</div>
</div>
</div>
)}
{streams.data.length === 0 ? (
<div className="rounded-lg bg-white p-8 text-center shadow">
<p className="text-gray-600">No streams yet. Add income or expense streams to start tracking cash flow.</p>
</div>
) : (
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Frequency
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Bucket
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Start Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Status
</th>
<th className="relative px-6 py-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{streams.data.map((stream) => (
<tr key={stream.id}>
<td className="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">
{stream.name}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<span className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
stream.type === 'income'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{stream.type_label}
</span>
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
${stream.amount.toFixed(2)}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{stream.frequency_label}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{stream.bucket_name || '-'}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{new Date(stream.start_date).toLocaleDateString()}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<button
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
stream.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{stream.is_active ? 'Active' : 'Inactive'}
</button>
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button className="text-indigo-600 hover:text-indigo-900 mr-3">
Edit
</button>
<button className="text-red-600 hover:text-red-900">
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Placeholder for future features */}
<div className="rounded-lg bg-blue-50 p-6 text-center">
<h3 className="text-lg font-medium text-blue-900">
Coming Next: Timeline & Projections
Coming Next: Streams & Timeline
</h3>
<p className="text-sm text-blue-700 mt-2">
Calculate projections to see your money flow through these buckets over time.
Add income and expense streams, then calculate projections to see your money flow through these buckets over time.
</p>
</div>
</div>
@ -495,7 +268,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3">
<h3 className="text-lg font-medium text-gray-900 mb-4">{editingBucket ? 'Edit Bucket' : 'Add New Bucket'}</h3>
<h3 className="text-lg font-medium text-gray-900 mb-4">Add New Bucket</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
@ -551,11 +324,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setIsModalOpen(false);
setEditingBucket(null);
setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' });
}}
onClick={() => setIsModalOpen(false)}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
disabled={isSubmitting}
>
@ -566,7 +335,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
disabled={isSubmitting}
>
{isSubmitting ? (editingBucket ? 'Updating...' : 'Creating...') : (editingBucket ? 'Update Bucket' : 'Create Bucket')}
{isSubmitting ? 'Creating...' : 'Create Bucket'}
</button>
</div>
</form>

View file

@ -1,9 +1,9 @@
import InputError from '@/components/InputError';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout';
import AuthLayout from '@/layouts/auth-layout';
import { store } from '@/routes/password/confirm';
import { Form, Head } from '@inertiajs/react';

View file

@ -4,12 +4,12 @@ import { email } from '@/routes/password';
import { Form, Head } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import InputError from '@/components/InputError';
import TextLink from '@/components/TextLink';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/AuthLayout';
import AuthLayout from '@/layouts/auth-layout';
export default function ForgotPassword({ status }: { status?: string }) {
return (

View file

@ -1,11 +1,11 @@
import InputError from '@/components/InputError';
import TextLink from '@/components/TextLink';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout';
import AuthLayout from '@/layouts/auth-layout';
import { register } from '@/routes';
import { store } from '@/routes/login';
import { request } from '@/routes/password';

View file

@ -2,13 +2,13 @@ import { login } from '@/routes';
import { store } from '@/routes/register';
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/InputError';
import TextLink from '@/components/TextLink';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout';
import AuthLayout from '@/layouts/auth-layout';
export default function Register() {
return (

View file

@ -1,12 +1,12 @@
import { update } from '@/routes/password';
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/InputError';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout';
import AuthLayout from '@/layouts/auth-layout';
interface ResetPasswordProps {
token: string;

View file

@ -1,4 +1,4 @@
import InputError from '@/components/InputError';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@ -7,7 +7,7 @@ import {
InputOTPSlot,
} from '@/components/ui/input-otp';
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
import AuthLayout from '@/layouts/AuthLayout';
import AuthLayout from '@/layouts/auth-layout';
import { store } from '@/routes/two-factor/login';
import { Form, Head } from '@inertiajs/react';
import { REGEXP_ONLY_DIGITS } from 'input-otp';

View file

@ -1,8 +1,8 @@
// Components
import TextLink from '@/components/TextLink';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout';
import AuthLayout from '@/layouts/auth-layout';
import { logout } from '@/routes';
import { send } from '@/routes/verification';
import { Form, Head } from '@inertiajs/react';

View file

@ -1,5 +1,5 @@
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import AppLayout from '@/layouts/AppLayout';
import AppLayout from '@/layouts/app-layout';
import { dashboard } from '@/routes';
import { type BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/react';

View file

@ -1,10 +1,10 @@
import { Head } from '@inertiajs/react';
import AppearanceTabs from '@/components/AppearanceTabs';
import HeadingSmall from '@/components/HeadingSmall';
import AppearanceTabs from '@/components/appearance-tabs';
import HeadingSmall from '@/components/heading-small';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/AppLayout';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { edit as editAppearance } from '@/routes/appearance';

Some files were not shown because too many files have changed in this diff Show more