Compare commits
No commits in common. "41fad79af7770717a561dc88cc29b3582e8f4602" and "2e633d75da3c6b10622811cc78c55654ff95d5bc" have entirely different histories.
41fad79af7
...
2e633d75da
117 changed files with 555 additions and 4307 deletions
|
|
@ -14,5 +14,5 @@ trim_trailing_whitespace = false
|
||||||
[*.{yml,yaml}]
|
[*.{yml,yaml}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
[docker-compose.yml]
|
[compose.yaml]
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|
|
||||||
25
.env.testing
25
.env.testing
|
|
@ -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
|
|
||||||
|
|
@ -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 }}
|
|
||||||
|
|
@ -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
8
.gitignore
vendored
|
|
@ -1,17 +1,16 @@
|
||||||
/.vite
|
|
||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
/bootstrap/ssr
|
/bootstrap/ssr
|
||||||
/node_modules
|
/node_modules
|
||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/public/vendor
|
|
||||||
/resources/js/actions
|
/resources/js/actions
|
||||||
/resources/js/routes
|
/resources/js/routes
|
||||||
/resources/js/wayfinder
|
/resources/js/wayfinder
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
|
|
@ -21,13 +20,10 @@ Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/package-lock.json
|
|
||||||
/auth.json
|
/auth.json
|
||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.nova
|
/.nova
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/coverage-report*
|
package-lock.json
|
||||||
/coverage.xml
|
|
||||||
/.claude
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace App\Actions;
|
namespace App\Actions;
|
||||||
|
|
||||||
use App\Enums\BucketAllocationTypeEnum;
|
|
||||||
use App\Models\Bucket;
|
use App\Models\Bucket;
|
||||||
use App\Models\Scenario;
|
use App\Models\Scenario;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -13,15 +12,21 @@ class CreateBucketAction
|
||||||
public function execute(
|
public function execute(
|
||||||
Scenario $scenario,
|
Scenario $scenario,
|
||||||
string $name,
|
string $name,
|
||||||
BucketAllocationTypeEnum $allocationType,
|
string $allocationType,
|
||||||
?float $allocationValue = null,
|
?float $allocationValue = null,
|
||||||
?int $priority = null
|
?int $priority = null
|
||||||
): Bucket {
|
): 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
|
// Validate allocation value based on type
|
||||||
$this->validateAllocationValue($allocationType, $allocationValue);
|
$this->validateAllocationValue($allocationType, $allocationValue);
|
||||||
|
|
||||||
// Set allocation_value to null for unlimited buckets
|
// Set allocation_value to null for unlimited buckets
|
||||||
if ($allocationType === BucketAllocationTypeEnum::UNLIMITED) {
|
if ($allocationType === Bucket::TYPE_UNLIMITED) {
|
||||||
$allocationValue = null;
|
$allocationValue = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,20 +38,16 @@ public function execute(
|
||||||
} else {
|
} else {
|
||||||
// Validate priority is positive
|
// Validate priority is positive
|
||||||
if ($priority < 1) {
|
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
|
// Check if priority already exists and shift others if needed
|
||||||
$existingBucket = $scenario->buckets()->where('priority', $priority)->first();
|
$existingBucket = $scenario->buckets()->where('priority', $priority)->first();
|
||||||
if ($existingBucket) {
|
if ($existingBucket) {
|
||||||
// Shift priorities in reverse order to avoid unique constraint violations
|
// Shift priorities to make room
|
||||||
// (SQLite checks constraints per-row during bulk updates)
|
|
||||||
$scenario->buckets()
|
$scenario->buckets()
|
||||||
->where('priority', '>=', $priority)
|
->where('priority', '>=', $priority)
|
||||||
->orderByDesc('priority')
|
->increment('priority');
|
||||||
->each(function ($bucket) {
|
|
||||||
$bucket->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.
|
* 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) {
|
switch ($allocationType) {
|
||||||
case BucketAllocationTypeEnum::FIXED_LIMIT:
|
case Bucket::TYPE_FIXED_LIMIT:
|
||||||
if ($allocationValue === null) {
|
if ($allocationValue === null) {
|
||||||
throw new InvalidArgumentException('Fixed limit buckets require an allocation value');
|
throw new InvalidArgumentException('Fixed limit buckets require an allocation value');
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +87,7 @@ private function validateAllocationValue(BucketAllocationTypeEnum $allocationTyp
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case BucketAllocationTypeEnum::PERCENTAGE:
|
case Bucket::TYPE_PERCENTAGE:
|
||||||
if ($allocationValue === null) {
|
if ($allocationValue === null) {
|
||||||
throw new InvalidArgumentException('Percentage buckets require an allocation value');
|
throw new InvalidArgumentException('Percentage buckets require an allocation value');
|
||||||
}
|
}
|
||||||
|
|
@ -85,47 +96,10 @@ private function validateAllocationValue(BucketAllocationTypeEnum $allocationTyp
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case BucketAllocationTypeEnum::UNLIMITED:
|
case Bucket::TYPE_UNLIMITED:
|
||||||
// Unlimited buckets should not have an allocation value
|
// Unlimited buckets should not have an allocation value
|
||||||
// We'll set it to null in the main method regardless
|
// We'll set it to null in the main method regardless
|
||||||
break;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Actions;
|
|
||||||
|
|
||||||
use App\Models\Scenario;
|
|
||||||
|
|
||||||
readonly class DeleteScenarioAction
|
|
||||||
{
|
|
||||||
public function execute(Scenario $scenario): void
|
|
||||||
{
|
|
||||||
$scenario->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -34,7 +34,7 @@ public function index(Scenario $scenario): JsonResponse
|
||||||
});
|
});
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'buckets' => $buckets,
|
'buckets' => $buckets
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +42,7 @@ public function store(Request $request, Scenario $scenario): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'allocation_type' => 'required|in:'.implode(',', [
|
'allocation_type' => 'required|in:' . implode(',', [
|
||||||
Bucket::TYPE_FIXED_LIMIT,
|
Bucket::TYPE_FIXED_LIMIT,
|
||||||
Bucket::TYPE_PERCENTAGE,
|
Bucket::TYPE_PERCENTAGE,
|
||||||
Bucket::TYPE_UNLIMITED,
|
Bucket::TYPE_UNLIMITED,
|
||||||
|
|
@ -52,7 +52,7 @@ public function store(Request $request, Scenario $scenario): JsonResponse
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$createBucketAction = new CreateBucketAction;
|
$createBucketAction = new CreateBucketAction();
|
||||||
$bucket = $createBucketAction->execute(
|
$bucket = $createBucketAction->execute(
|
||||||
$scenario,
|
$scenario,
|
||||||
$validated['name'],
|
$validated['name'],
|
||||||
|
|
@ -63,12 +63,12 @@ public function store(Request $request, Scenario $scenario): JsonResponse
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'bucket' => $this->formatBucketResponse($bucket),
|
'bucket' => $this->formatBucketResponse($bucket),
|
||||||
'message' => 'Bucket created successfully.',
|
'message' => 'Bucket created successfully.'
|
||||||
], 201);
|
], 201);
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed.',
|
'message' => 'Validation failed.',
|
||||||
'errors' => ['allocation_value' => [$e->getMessage()]],
|
'errors' => ['allocation_value' => [$e->getMessage()]]
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +77,7 @@ public function update(Request $request, Bucket $bucket): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'allocation_type' => 'required|in:'.implode(',', [
|
'allocation_type' => 'required|in:' . implode(',', [
|
||||||
Bucket::TYPE_FIXED_LIMIT,
|
Bucket::TYPE_FIXED_LIMIT,
|
||||||
Bucket::TYPE_PERCENTAGE,
|
Bucket::TYPE_PERCENTAGE,
|
||||||
Bucket::TYPE_UNLIMITED,
|
Bucket::TYPE_UNLIMITED,
|
||||||
|
|
@ -107,7 +107,7 @@ public function update(Request $request, Bucket $bucket): JsonResponse
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'bucket' => $this->formatBucketResponse($bucket),
|
'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);
|
$this->shiftPrioritiesDown($scenarioId, $deletedPriority);
|
||||||
|
|
||||||
return response()->json([
|
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([
|
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.
|
* Shift priorities down to fill gap after deletion.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,37 +2,19 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\CreateScenarioAction;
|
use App\Actions\CreateBucketAction;
|
||||||
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\Models\Scenario;
|
use App\Models\Scenario;
|
||||||
use App\Repositories\ScenarioRepository;
|
|
||||||
use App\Repositories\StreamRepository;
|
|
||||||
use App\Services\Streams\StatsService;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class ScenarioController extends Controller
|
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
|
public function index(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('Scenarios/Index', [
|
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', [
|
return Inertia::render('Scenarios/Show', [
|
||||||
'scenario' => new ScenarioResource($scenario),
|
'scenario' => $scenario,
|
||||||
'buckets' => BucketResource::collection($scenario->buckets),
|
'buckets' => $scenario->buckets->map(function ($bucket) {
|
||||||
'streams' => StreamResource::collection($this->streamRepository->getForScenario($scenario)),
|
return [
|
||||||
'streamStats' => $this->statsService->getSummaryStats($scenario),
|
'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');
|
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);
|
return redirect()->route('scenarios.show', $scenario);
|
||||||
}
|
}
|
||||||
|
|
@ -65,25 +69,30 @@ public function store(StoreScenarioRequest $request): RedirectResponse
|
||||||
public function edit(Scenario $scenario): Response
|
public function edit(Scenario $scenario): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('Scenarios/Edit', [
|
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()
|
$scenario->update($request->only(['name', 'description', 'start_date', 'end_date']));
|
||||||
->route('scenarios.show', $scenario)
|
|
||||||
|
return redirect()->route('scenarios.show', $scenario)
|
||||||
->with('success', 'Scenario updated successfully');
|
->with('success', 'Scenario updated successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Scenario $scenario): RedirectResponse
|
public function destroy(Scenario $scenario): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->deleteScenarioAction->execute($scenario);
|
$scenario->delete();
|
||||||
|
|
||||||
return redirect()
|
return redirect()->route('scenarios.index')
|
||||||
->route('scenarios.index')
|
|
||||||
->with('success', 'Scenario deleted successfully');
|
->with('success', 'Scenario deleted successfully');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Enums\BucketAllocationTypeEnum;
|
|
||||||
use Database\Factories\BucketFactory;
|
use Database\Factories\BucketFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -10,16 +9,10 @@
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
|
||||||
* @property int $scenario_id
|
* @property int $scenario_id
|
||||||
* @property Scenario $scenario
|
* @property Scenario $scenario
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property int $priority
|
* @property int $priority
|
||||||
* @property BucketAllocationTypeEnum $allocation_type
|
|
||||||
* @property float $starting_amount
|
|
||||||
* @property float $allocation_value
|
|
||||||
*
|
|
||||||
* @method static BucketFactory factory()
|
|
||||||
*/
|
*/
|
||||||
class Bucket extends Model
|
class Bucket extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -33,17 +26,19 @@ class Bucket extends Model
|
||||||
'sort_order',
|
'sort_order',
|
||||||
'allocation_type',
|
'allocation_type',
|
||||||
'allocation_value',
|
'allocation_value',
|
||||||
'starting_amount',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'priority' => 'integer',
|
'priority' => 'integer',
|
||||||
'sort_order' => 'integer',
|
'sort_order' => 'integer',
|
||||||
'allocation_value' => 'decimal:2',
|
'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
|
public function scenario(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Scenario::class);
|
return $this->belongsTo(Scenario::class);
|
||||||
|
|
@ -51,17 +46,21 @@ public function scenario(): BelongsTo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the draws for the bucket.
|
* Get the draws for the bucket.
|
||||||
|
* (Will be implemented when Draw model is created)
|
||||||
*/
|
*/
|
||||||
public function draws(): HasMany
|
public function draws(): HasMany
|
||||||
{
|
{
|
||||||
|
// TODO: Implement when Draw model is created
|
||||||
return $this->hasMany(Draw::class);
|
return $this->hasMany(Draw::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the outflows for the bucket.
|
* Get the outflows for the bucket.
|
||||||
|
* (Will be implemented when Outflow model is created)
|
||||||
*/
|
*/
|
||||||
public function outflows(): HasMany
|
public function outflows(): HasMany
|
||||||
{
|
{
|
||||||
|
// TODO: Implement when Outflow model is created
|
||||||
return $this->hasMany(Outflow::class);
|
return $this->hasMany(Outflow::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,14 +82,12 @@ public function scopeOrderedBySortOrder($query)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current balance of the bucket.
|
* 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');
|
// TODO: Calculate from draws minus outflows when those features are implemented
|
||||||
$totalOutflows = $this->outflows()->sum('amount');
|
return 0.0;
|
||||||
|
|
||||||
return $this->starting_amount + $totalDraws - $totalOutflows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -98,7 +95,7 @@ public function getCurrentBalance(): int
|
||||||
*/
|
*/
|
||||||
public function hasAvailableSpace(): bool
|
public function hasAvailableSpace(): bool
|
||||||
{
|
{
|
||||||
if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
if ($this->allocation_type !== self::TYPE_FIXED_LIMIT) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +107,7 @@ public function hasAvailableSpace(): bool
|
||||||
*/
|
*/
|
||||||
public function getAvailableSpace(): float
|
public function getAvailableSpace(): float
|
||||||
{
|
{
|
||||||
if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
if ($this->allocation_type !== self::TYPE_FIXED_LIMIT) {
|
||||||
return PHP_FLOAT_MAX;
|
return PHP_FLOAT_MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +119,12 @@ public function getAvailableSpace(): float
|
||||||
*/
|
*/
|
||||||
public function getAllocationTypeLabel(): string
|
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
|
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 = [
|
$rules = [
|
||||||
'name' => 'required|string|max:255',
|
'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',
|
'priority' => 'required|integer|min:1',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add scenario-specific priority uniqueness if scenario ID provided
|
// Add scenario-specific priority uniqueness if scenario ID provided
|
||||||
if ($scenarioId) {
|
if ($scenarioId) {
|
||||||
$rules['priority'] .= '|unique:buckets,priority,NULL,id,scenario_id,'.$scenarioId;
|
$rules['priority'] .= '|unique:buckets,priority,NULL,id,scenario_id,' . $scenarioId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rules;
|
return $rules;
|
||||||
|
|
@ -155,8 +166,13 @@ public static function validationRules($scenarioId = null): array
|
||||||
/**
|
/**
|
||||||
* Get allocation value validation rules based on type.
|
* 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'],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,17 +3,10 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Database\Factories\ScenarioFactory;
|
use Database\Factories\ScenarioFactory;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property Collection<Bucket> $buckets
|
|
||||||
*
|
|
||||||
* @method static create(array $data)
|
|
||||||
*/
|
|
||||||
class Scenario extends Model
|
class Scenario extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<ScenarioFactory> */
|
/** @use HasFactory<ScenarioFactory> */
|
||||||
|
|
@ -21,24 +14,34 @@ class Scenario extends Model
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'description',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function buckets(): HasMany
|
public function buckets(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Bucket::class);
|
return $this->hasMany(Bucket::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the streams for this scenario.
|
||||||
|
*/
|
||||||
public function streams(): HasMany
|
public function streams(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Stream::class);
|
return $this->hasMany(Stream::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the inflows for this scenario.
|
||||||
|
*/
|
||||||
public function inflows(): HasMany
|
public function inflows(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Inflow::class);
|
return $this->hasMany(Inflow::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the outflows for this scenario.
|
||||||
|
*/
|
||||||
public function outflows(): HasMany
|
public function outflows(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Outflow::class);
|
return $this->hasMany(Outflow::class);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.2",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/fortify": "^1.30",
|
"laravel/fortify": "^1.30",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
|
@ -18,13 +18,11 @@
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"larastan/larastan": "^3.9",
|
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.24",
|
"laravel/pint": "^1.24",
|
||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"phpstan/phpstan-mockery": "^2.0",
|
|
||||||
"phpunit/phpunit": "^11.5.3"
|
"phpunit/phpunit": "^11.5.3"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|
|
||||||
237
composer.lock
generated
237
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "1fb421bd376d40ce4b1e241d0805c5ed",
|
"content-hash": "3c79e040a3570288a4004f205416801a",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
|
|
@ -6660,137 +6660,6 @@
|
||||||
},
|
},
|
||||||
"time": "2025-04-30T06:54:44+00:00"
|
"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",
|
"name": "laravel/pail",
|
||||||
"version": "v1.2.4",
|
"version": "v1.2.4",
|
||||||
|
|
@ -7360,108 +7229,6 @@
|
||||||
},
|
},
|
||||||
"time": "2022-02-21T01:04:05+00:00"
|
"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",
|
"name": "phpunit/php-code-coverage",
|
||||||
"version": "11.0.12",
|
"version": "11.0.12",
|
||||||
|
|
@ -9077,7 +8844,7 @@
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.3"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.9.0"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use App\Enums\BucketAllocationTypeEnum;
|
|
||||||
use App\Models\Bucket;
|
use App\Models\Bucket;
|
||||||
use App\Models\Scenario;
|
use App\Models\Scenario;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
@ -15,9 +14,9 @@ class BucketFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
$allocationType = $this->faker->randomElement([
|
$allocationType = $this->faker->randomElement([
|
||||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
Bucket::TYPE_FIXED_LIMIT,
|
||||||
BucketAllocationTypeEnum::PERCENTAGE,
|
Bucket::TYPE_PERCENTAGE,
|
||||||
BucketAllocationTypeEnum::UNLIMITED,
|
Bucket::TYPE_UNLIMITED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
@ -40,7 +39,6 @@ public function definition(): array
|
||||||
'sort_order' => $this->faker->numberBetween(0, 10),
|
'sort_order' => $this->faker->numberBetween(0, 10),
|
||||||
'allocation_type' => $allocationType,
|
'allocation_type' => $allocationType,
|
||||||
'allocation_value' => $this->getAllocationValueForType($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);
|
$amount = $amount ?? $this->faker->numberBetween(500, 5000);
|
||||||
|
|
||||||
return $this->state([
|
return $this->state([
|
||||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
|
||||||
'allocation_value' => $amount,
|
'allocation_value' => $amount,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +63,7 @@ public function percentage($percentage = null): Factory
|
||||||
$percentage = $percentage ?? $this->faker->numberBetween(10, 50);
|
$percentage = $percentage ?? $this->faker->numberBetween(10, 50);
|
||||||
|
|
||||||
return $this->state([
|
return $this->state([
|
||||||
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
'allocation_type' => Bucket::TYPE_PERCENTAGE,
|
||||||
'allocation_value' => $percentage,
|
'allocation_value' => $percentage,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +74,7 @@ public function percentage($percentage = null): Factory
|
||||||
public function unlimited(): Factory
|
public function unlimited(): Factory
|
||||||
{
|
{
|
||||||
return $this->state([
|
return $this->state([
|
||||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
'allocation_type' => Bucket::TYPE_UNLIMITED,
|
||||||
'allocation_value' => null,
|
'allocation_value' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -91,25 +89,22 @@ public function defaultSet(): array
|
||||||
'name' => 'Monthly Expenses',
|
'name' => 'Monthly Expenses',
|
||||||
'priority' => 1,
|
'priority' => 1,
|
||||||
'sort_order' => 1,
|
'sort_order' => 1,
|
||||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
|
||||||
'allocation_value' => 0,
|
'allocation_value' => 0,
|
||||||
'starting_amount' => 0,
|
|
||||||
]),
|
]),
|
||||||
$this->state([
|
$this->state([
|
||||||
'name' => 'Emergency Fund',
|
'name' => 'Emergency Fund',
|
||||||
'priority' => 2,
|
'priority' => 2,
|
||||||
'sort_order' => 2,
|
'sort_order' => 2,
|
||||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
|
||||||
'allocation_value' => 0,
|
'allocation_value' => 0,
|
||||||
'starting_amount' => 0,
|
|
||||||
]),
|
]),
|
||||||
$this->state([
|
$this->state([
|
||||||
'name' => 'Investments',
|
'name' => 'Investments',
|
||||||
'priority' => 3,
|
'priority' => 3,
|
||||||
'sort_order' => 3,
|
'sort_order' => 3,
|
||||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
'allocation_type' => Bucket::TYPE_UNLIMITED,
|
||||||
'allocation_value' => null,
|
'allocation_value' => null,
|
||||||
'starting_amount' => 0,
|
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -117,12 +112,12 @@ public function defaultSet(): array
|
||||||
/**
|
/**
|
||||||
* Get allocation value based on type.
|
* Get allocation value based on type.
|
||||||
*/
|
*/
|
||||||
private function getAllocationValueForType(BucketAllocationTypeEnum $type): ?float
|
private function getAllocationValueForType(string $type): ?float
|
||||||
{
|
{
|
||||||
return match ($type) {
|
return match($type) {
|
||||||
BucketAllocationTypeEnum::FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
|
Bucket::TYPE_FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
|
||||||
BucketAllocationTypeEnum::PERCENTAGE => $this->faker->numberBetween(5, 50),
|
Bucket::TYPE_PERCENTAGE => $this->faker->numberBetween(5, 50),
|
||||||
BucketAllocationTypeEnum::UNLIMITED => null,
|
Bucket::TYPE_UNLIMITED => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,8 +13,7 @@ class ScenarioFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => $this->faker->words(2, true).' Budget',
|
'name' => $this->faker->words(2, true) . ' Budget',
|
||||||
'description' => $this->faker->text,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,6 @@ public function up(): void
|
||||||
Schema::create('scenarios', function (Blueprint $table) {
|
Schema::create('scenarios', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('name');
|
$table->string('name');
|
||||||
$table->text('description')->nullable();
|
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ public function up(): void
|
||||||
$table->enum('allocation_type', ['fixed_limit', 'percentage', 'unlimited']);
|
$table->enum('allocation_type', ['fixed_limit', 'percentage', 'unlimited']);
|
||||||
$table->decimal('allocation_value', 10, 2)->nullable()
|
$table->decimal('allocation_value', 10, 2)->nullable()
|
||||||
->comment('Limit amount for fixed_limit, percentage for percentage type, NULL for unlimited');
|
->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();
|
$table->timestamps();
|
||||||
|
|
||||||
// Indexes for performance
|
// Indexes for performance
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Production Docker Compose
|
# Production Docker Compose
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: forge.lvl0.xyz/lvl0/buckets:latest
|
image: codeberg.org/lvl0/buckets:latest
|
||||||
container_name: buckets_app
|
container_name: buckets_app
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,59 @@
|
||||||
# ===================
|
# Local Development Docker Compose
|
||||||
# Buckets Development Services
|
version: '3.8'
|
||||||
# ===================
|
|
||||||
# Port allocation:
|
|
||||||
# App: 8100 (frankenphp), 5174 (vite)
|
|
||||||
# DB: 3307 (mysql)
|
|
||||||
# Mailhog: 8026 (web), 1026 (smtp)
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: .
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
container_name: buckets_dev_app
|
container_name: buckets_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Remove user directive to run as root in container
|
||||||
|
# The container will handle permissions internally
|
||||||
ports:
|
ports:
|
||||||
- "8100:8000"
|
- "8100:8000" # Laravel app
|
||||||
- "5174:5173"
|
- "5174:5173" # Vite dev server
|
||||||
volumes:
|
|
||||||
- ../..:/app
|
|
||||||
- app_vendor:/app/vendor
|
|
||||||
- app_node_modules:/app/node_modules
|
|
||||||
environment:
|
environment:
|
||||||
|
# Laravel
|
||||||
APP_NAME: "${APP_NAME:-buckets}"
|
APP_NAME: "${APP_NAME:-buckets}"
|
||||||
APP_ENV: "${APP_ENV:-local}"
|
APP_ENV: "${APP_ENV:-local}"
|
||||||
APP_KEY: "${APP_KEY:-base64:YOUR_KEY_HERE}"
|
APP_KEY: "${APP_KEY:-base64:YOUR_KEY_HERE}"
|
||||||
APP_DEBUG: "${APP_DEBUG:-true}"
|
APP_DEBUG: "${APP_DEBUG:-true}"
|
||||||
APP_URL: "${APP_URL:-http://localhost:8100}"
|
APP_URL: "${APP_URL:-http://localhost:8100}"
|
||||||
|
|
||||||
|
# Database
|
||||||
DB_CONNECTION: mysql
|
DB_CONNECTION: mysql
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
DB_PORT: 3306
|
DB_PORT: 3306
|
||||||
DB_DATABASE: "${DB_DATABASE:-buckets}"
|
DB_DATABASE: "${DB_DATABASE:-buckets}"
|
||||||
DB_USERNAME: "${DB_USERNAME:-buckets}"
|
DB_USERNAME: "${DB_USERNAME:-buckets}"
|
||||||
DB_PASSWORD: "${DB_PASSWORD:-buckets}"
|
DB_PASSWORD: "${DB_PASSWORD:-buckets}"
|
||||||
|
|
||||||
|
# Session & Cache
|
||||||
SESSION_DRIVER: "${SESSION_DRIVER:-file}"
|
SESSION_DRIVER: "${SESSION_DRIVER:-file}"
|
||||||
CACHE_DRIVER: "${CACHE_DRIVER:-file}"
|
CACHE_DRIVER: "${CACHE_DRIVER:-file}"
|
||||||
QUEUE_CONNECTION: "${QUEUE_CONNECTION:-sync}"
|
QUEUE_CONNECTION: "${QUEUE_CONNECTION:-sync}"
|
||||||
|
|
||||||
|
# Mail (for development)
|
||||||
MAIL_MAILER: "${MAIL_MAILER:-log}"
|
MAIL_MAILER: "${MAIL_MAILER:-log}"
|
||||||
|
|
||||||
|
# Vite
|
||||||
VITE_HOST: "0.0.0.0"
|
VITE_HOST: "0.0.0.0"
|
||||||
VITE_PORT: "5174"
|
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:
|
depends_on:
|
||||||
db:
|
- db
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
networks:
|
||||||
- buckets-network
|
- buckets
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: mariadb:11
|
image: mariadb:11
|
||||||
container_name: buckets_dev_db
|
container_name: buckets_db
|
||||||
hostname: db
|
hostname: db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -58,28 +65,39 @@ services:
|
||||||
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
|
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/mysql
|
- db_data:/var/lib/mysql
|
||||||
- ../../docker/mysql-init:/docker-entrypoint-initdb.d
|
# Initialize with SQL scripts
|
||||||
|
- ./docker/mysql-init:/docker-entrypoint-initdb.d
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 3
|
||||||
start_period: 30s
|
|
||||||
networks:
|
networks:
|
||||||
- buckets-network
|
- buckets
|
||||||
|
|
||||||
|
# Optional: Mailhog for email testing
|
||||||
mailhog:
|
mailhog:
|
||||||
image: mailhog/mailhog
|
image: mailhog/mailhog
|
||||||
container_name: buckets_dev_mailhog
|
container_name: buckets_mailhog
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "1026:1025"
|
- "1026:1025" # SMTP server
|
||||||
- "8026:8025"
|
- "8026:8025" # Web UI
|
||||||
networks:
|
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:
|
networks:
|
||||||
buckets-network:
|
buckets:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -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
|
|
||||||
17
phpstan.neon
17
phpstan.neon
|
|
@ -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
|
|
||||||
27
phpunit.xml
27
phpunit.xml
|
|
@ -18,19 +18,18 @@
|
||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing" force="true"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<server name="APP_ENV" value="testing" force="true"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<server name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<server name="BCRYPT_ROUNDS" value="4" force="true"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<server name="BROADCAST_CONNECTION" value="log" force="true"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<server name="CACHE_STORE" value="array" force="true"/>
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
<server name="DB_CONNECTION" value="sqlite" force="true"/>
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
<server name="DB_DATABASE" value=":memory:" force="true"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<server name="MAIL_MAILER" value="array" force="true"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<server name="PULSE_ENABLED" value="false" force="true"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
<server name="QUEUE_CONNECTION" value="sync" force="true"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<server name="SESSION_DRIVER" value="array" force="true"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<server name="TELESCOPE_ENABLED" value="false" force="true"/>
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
<server name="NIGHTWATCH_ENABLED" value="false" force="true"/>
|
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs';
|
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||||
import { Icon } from '@/components/Icon';
|
import { Icon } from '@/components/icon';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs';
|
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { NavFooter } from '@/components/NavFooter';
|
import { NavFooter } from '@/components/nav-footer';
|
||||||
import { NavMain } from '@/components/NavMain';
|
import { NavMain } from '@/components/nav-main';
|
||||||
import { NavUser } from '@/components/NavUser';
|
import { NavUser } from '@/components/nav-user';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||||
import HeadingSmall from '@/components/HeadingSmall';
|
import HeadingSmall from '@/components/heading-small';
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/input-error';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Icon } from '@/components/Icon';
|
import { Icon } from '@/components/icon';
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/input-error';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -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 BreadcrumbItem } from '@/types';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { AppContent } from '@/components/AppContent';
|
import { AppContent } from '@/components/app-content';
|
||||||
import { AppHeader } from '@/components/AppHeader';
|
import { AppHeader } from '@/components/app-header';
|
||||||
import { AppShell } from '@/components/AppShell';
|
import { AppShell } from '@/components/app-shell';
|
||||||
import { type BreadcrumbItem } from '@/types';
|
import { type BreadcrumbItem } from '@/types';
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { AppContent } from '@/components/AppContent';
|
import { AppContent } from '@/components/app-content';
|
||||||
import { AppShell } from '@/components/AppShell';
|
import { AppShell } from '@/components/app-shell';
|
||||||
import { AppSidebar } from '@/components/AppSidebar';
|
import { AppSidebar } from '@/components/app-sidebar';
|
||||||
import { AppSidebarHeader } from '@/components/AppSidebarHeader';
|
import { AppSidebarHeader } from '@/components/app-sidebar-header';
|
||||||
import { type BreadcrumbItem } from '@/types';
|
import { type BreadcrumbItem } from '@/types';
|
||||||
import { type PropsWithChildren } from 'react';
|
import { type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import AuthLayoutTemplate from '@/layouts/auth/AuthSimpleLayout';
|
import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout';
|
||||||
|
|
||||||
export default function AuthLayout({
|
export default function AuthLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import AppLogoIcon from '@/components/AppLogoIcon';
|
import AppLogoIcon from '@/components/app-logo-icon';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import AppLogoIcon from '@/components/AppLogoIcon';
|
import AppLogoIcon from '@/components/app-logo-icon';
|
||||||
import { dashboard } from '@/routes';
|
import { home } from '@/routes';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { type PropsWithChildren } from '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 gap-8">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href={dashboard.url()}
|
href={home()}
|
||||||
className="flex flex-col items-center gap-2 font-medium"
|
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">
|
<div className="mb-1 flex h-9 w-9 items-center justify-center rounded-md">
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import AppLogoIcon from '@/components/AppLogoIcon';
|
import AppLogoIcon from '@/components/app-logo-icon';
|
||||||
import { home } from '@/routes';
|
import { home } from '@/routes';
|
||||||
import { type SharedData } from '@/types';
|
import { type SharedData } from '@/types';
|
||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import Heading from '@/components/Heading';
|
import Heading from '@/components/heading';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
|
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,7 @@ interface Scenario {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
scenarios: {
|
scenarios: Scenario[];
|
||||||
data: Scenario[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Index({ scenarios }: Props) {
|
export default function Index({ scenarios }: Props) {
|
||||||
|
|
@ -110,7 +108,7 @@ export default function Index({ scenarios }: Props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scenarios List */}
|
{/* Scenarios List */}
|
||||||
{scenarios.data.length === 0 ? (
|
{scenarios.length === 0 ? (
|
||||||
<div className="rounded-lg bg-white p-12 text-center shadow">
|
<div className="rounded-lg bg-white p-12 text-center shadow">
|
||||||
<div className="mx-auto h-12 w-12 text-gray-400">
|
<div className="mx-auto h-12 w-12 text-gray-400">
|
||||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|
@ -124,7 +122,7 @@ export default function Index({ scenarios }: Props) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{scenarios.data.map((scenario) => (
|
{scenarios.map((scenario) => (
|
||||||
<div
|
<div
|
||||||
key={scenario.id}
|
key={scenario.id}
|
||||||
onClick={() => router.visit(`/scenarios/${scenario.id}`)}
|
onClick={() => router.visit(`/scenarios/${scenario.id}`)}
|
||||||
|
|
|
||||||
|
|
@ -22,96 +22,27 @@ interface Bucket {
|
||||||
available_space: number;
|
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 {
|
interface Props {
|
||||||
scenario: Scenario;
|
scenario: Scenario;
|
||||||
buckets: { data: Bucket[] };
|
buckets: Bucket[];
|
||||||
streams: { data: Stream[] };
|
|
||||||
streamStats?: StreamStats;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Show({ scenario, buckets, streams = { data: [] }, streamStats }: Props) {
|
export default function Show({ scenario, buckets }: Props) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [editingBucket, setEditingBucket] = useState<Bucket | null>(null);
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
allocation_type: 'fixed_limit',
|
allocation_type: 'fixed_limit',
|
||||||
allocation_value: ''
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
const url = editingBucket
|
|
||||||
? `/buckets/${editingBucket.id}`
|
|
||||||
: `/scenarios/${scenario.id}/buckets`;
|
|
||||||
|
|
||||||
const method = editingBucket ? 'PATCH' : 'POST';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(`/scenarios/${scenario.id}/buckets`, {
|
||||||
method: method,
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
'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({
|
body: JSON.stringify({
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
allocation_type: formData.allocation_type,
|
allocation_type: formData.allocation_type,
|
||||||
allocation_value: formData.allocation_value ? parseFloat(formData.allocation_value) : null,
|
allocation_value: formData.allocation_value ? parseFloat(formData.allocation_value) : null
|
||||||
priority: editingBucket ? editingBucket.priority : undefined
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' });
|
setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' });
|
||||||
setEditingBucket(null);
|
|
||||||
router.reload({ only: ['buckets'] });
|
router.reload({ only: ['buckets'] });
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
console.error(`Failed to ${editingBucket ? 'update' : 'create'} bucket:`, errorData);
|
console.error('Failed to create bucket:', errorData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error ${editingBucket ? 'updating' : 'creating'} bucket:`, error);
|
console.error('Error creating bucket:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePriorityChange = async (bucketId: number, direction: 'up' | 'down') => {
|
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;
|
if (!bucket) return;
|
||||||
|
|
||||||
const newPriority = direction === 'up' ? bucket.priority - 1 : bucket.priority + 1;
|
const newPriority = direction === 'up' ? bucket.priority - 1 : bucket.priority + 1;
|
||||||
|
|
||||||
// Don't allow moving beyond bounds
|
// Don't allow moving beyond bounds
|
||||||
if (newPriority < 1 || newPriority > buckets.data.length) return;
|
if (newPriority < 1 || newPriority > buckets.length) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/buckets/${bucketId}`, {
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Buckets</h2>
|
<h2 className="text-xl font-semibold text-gray-900">Buckets</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => setIsModalOpen(true)}
|
||||||
setEditingBucket(null);
|
|
||||||
setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' });
|
|
||||||
setIsModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||||
>
|
>
|
||||||
+ Add Bucket
|
+ Add Bucket
|
||||||
|
|
@ -218,7 +143,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{buckets.data.map((bucket) => (
|
{buckets.map((bucket) => (
|
||||||
<div
|
<div
|
||||||
key={bucket.id}
|
key={bucket.id}
|
||||||
className="rounded-lg bg-white p-6 shadow transition-shadow hover:shadow-lg border-l-4 border-blue-500"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePriorityChange(bucket.id, 'down')}
|
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"
|
className="p-1 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
title="Move down in priority"
|
title="Move down in priority"
|
||||||
>
|
>
|
||||||
|
|
@ -294,16 +219,10 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
<button
|
<button className="flex-1 text-sm text-blue-600 hover:text-blue-500">
|
||||||
onClick={() => handleEdit(bucket)}
|
|
||||||
className="flex-1 text-sm text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="flex-1 text-sm text-red-600 hover:text-red-500">
|
||||||
onClick={() => handleDelete(bucket)}
|
|
||||||
className="flex-1 text-sm text-red-600 hover:text-red-500"
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -331,159 +250,13 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Placeholder for future features */}
|
||||||
<div className="rounded-lg bg-blue-50 p-6 text-center">
|
<div className="rounded-lg bg-blue-50 p-6 text-center">
|
||||||
<h3 className="text-lg font-medium text-blue-900">
|
<h3 className="text-lg font-medium text-blue-900">
|
||||||
Coming Next: Timeline & Projections
|
Coming Next: Streams & Timeline
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-blue-700 mt-2">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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="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="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||||
<div className="mt-3">
|
<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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
<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">
|
<div className="flex gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setIsModalOpen(false)}
|
||||||
setIsModalOpen(false);
|
|
||||||
setEditingBucket(null);
|
|
||||||
setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' });
|
|
||||||
}}
|
|
||||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
|
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}
|
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"
|
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}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (editingBucket ? 'Updating...' : 'Creating...') : (editingBucket ? 'Update Bucket' : 'Create Bucket')}
|
{isSubmitting ? 'Creating...' : 'Create Bucket'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/input-error';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import AuthLayout from '@/layouts/AuthLayout';
|
import AuthLayout from '@/layouts/auth-layout';
|
||||||
import { store } from '@/routes/password/confirm';
|
import { store } from '@/routes/password/confirm';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
import { Form, Head } from '@inertiajs/react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ import { email } from '@/routes/password';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
import { Form, Head } from '@inertiajs/react';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/input-error';
|
||||||
import TextLink from '@/components/TextLink';
|
import TextLink from '@/components/text-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import AuthLayout from '@/layouts/AuthLayout';
|
import AuthLayout from '@/layouts/auth-layout';
|
||||||
|
|
||||||
export default function ForgotPassword({ status }: { status?: string }) {
|
export default function ForgotPassword({ status }: { status?: string }) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/input-error';
|
||||||
import TextLink from '@/components/TextLink';
|
import TextLink from '@/components/text-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import AuthLayout from '@/layouts/AuthLayout';
|
import AuthLayout from '@/layouts/auth-layout';
|
||||||
import { register } from '@/routes';
|
import { register } from '@/routes';
|
||||||
import { store } from '@/routes/login';
|
import { store } from '@/routes/login';
|
||||||
import { request } from '@/routes/password';
|
import { request } from '@/routes/password';
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ import { login } from '@/routes';
|
||||||
import { store } from '@/routes/register';
|
import { store } from '@/routes/register';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
import { Form, Head } from '@inertiajs/react';
|
||||||
|
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/input-error';
|
||||||
import TextLink from '@/components/TextLink';
|
import TextLink from '@/components/text-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import AuthLayout from '@/layouts/AuthLayout';
|
import AuthLayout from '@/layouts/auth-layout';
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { update } from '@/routes/password';
|
import { update } from '@/routes/password';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
import { Form, Head } from '@inertiajs/react';
|
||||||
|
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/input-error';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import AuthLayout from '@/layouts/AuthLayout';
|
import AuthLayout from '@/layouts/auth-layout';
|
||||||
|
|
||||||
interface ResetPasswordProps {
|
interface ResetPasswordProps {
|
||||||
token: string;
|
token: string;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/input-error';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
InputOTPSlot,
|
InputOTPSlot,
|
||||||
} from '@/components/ui/input-otp';
|
} from '@/components/ui/input-otp';
|
||||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
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 { store } from '@/routes/two-factor/login';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
import { Form, Head } from '@inertiajs/react';
|
||||||
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// Components
|
// Components
|
||||||
import TextLink from '@/components/TextLink';
|
import TextLink from '@/components/text-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import AuthLayout from '@/layouts/AuthLayout';
|
import AuthLayout from '@/layouts/auth-layout';
|
||||||
import { logout } from '@/routes';
|
import { logout } from '@/routes';
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
import { Form, Head } from '@inertiajs/react';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
||||||
import AppLayout from '@/layouts/AppLayout';
|
import AppLayout from '@/layouts/app-layout';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import { type BreadcrumbItem } from '@/types';
|
import { type BreadcrumbItem } from '@/types';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
|
|
||||||
import AppearanceTabs from '@/components/AppearanceTabs';
|
import AppearanceTabs from '@/components/appearance-tabs';
|
||||||
import HeadingSmall from '@/components/HeadingSmall';
|
import HeadingSmall from '@/components/heading-small';
|
||||||
import { type BreadcrumbItem } from '@/types';
|
import { type BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
import AppLayout from '@/layouts/AppLayout';
|
import AppLayout from '@/layouts/app-layout';
|
||||||
import SettingsLayout from '@/layouts/settings/layout';
|
import SettingsLayout from '@/layouts/settings/layout';
|
||||||
import { edit as editAppearance } from '@/routes/appearance';
|
import { edit as editAppearance } from '@/routes/appearance';
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue