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}]
|
||||
indent_size = 2
|
||||
|
||||
[docker-compose.yml]
|
||||
[compose.yaml]
|
||||
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
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/vendor
|
||||
/resources/js/actions
|
||||
/resources/js/routes
|
||||
/resources/js/wayfinder
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
|
|
@ -21,13 +20,10 @@ Homestead.json
|
|||
Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/package-lock.json
|
||||
/auth.json
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
/coverage-report*
|
||||
/coverage.xml
|
||||
/.claude
|
||||
package-lock.json
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -13,15 +12,21 @@ class CreateBucketAction
|
|||
public function execute(
|
||||
Scenario $scenario,
|
||||
string $name,
|
||||
BucketAllocationTypeEnum $allocationType,
|
||||
string $allocationType,
|
||||
?float $allocationValue = null,
|
||||
?int $priority = null
|
||||
): Bucket {
|
||||
// Validate allocation type
|
||||
$validTypes = [Bucket::TYPE_FIXED_LIMIT, Bucket::TYPE_PERCENTAGE, Bucket::TYPE_UNLIMITED];
|
||||
if (!in_array($allocationType, $validTypes)) {
|
||||
throw new InvalidArgumentException("Invalid allocation type: {$allocationType}");
|
||||
}
|
||||
|
||||
// Validate allocation value based on type
|
||||
$this->validateAllocationValue($allocationType, $allocationValue);
|
||||
|
||||
// Set allocation_value to null for unlimited buckets
|
||||
if ($allocationType === BucketAllocationTypeEnum::UNLIMITED) {
|
||||
if ($allocationType === Bucket::TYPE_UNLIMITED) {
|
||||
$allocationValue = null;
|
||||
}
|
||||
|
||||
|
|
@ -33,20 +38,16 @@ public function execute(
|
|||
} else {
|
||||
// Validate priority is positive
|
||||
if ($priority < 1) {
|
||||
throw new InvalidArgumentException('Priority must be at least 1');
|
||||
throw new InvalidArgumentException("Priority must be at least 1");
|
||||
}
|
||||
|
||||
// Check if priority already exists and shift others if needed
|
||||
$existingBucket = $scenario->buckets()->where('priority', $priority)->first();
|
||||
if ($existingBucket) {
|
||||
// Shift priorities in reverse order to avoid unique constraint violations
|
||||
// (SQLite checks constraints per-row during bulk updates)
|
||||
// Shift priorities to make room
|
||||
$scenario->buckets()
|
||||
->where('priority', '>=', $priority)
|
||||
->orderByDesc('priority')
|
||||
->each(function ($bucket) {
|
||||
$bucket->increment('priority');
|
||||
});
|
||||
->increment('priority');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,13 +62,23 @@ public function execute(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default buckets for a new scenario.
|
||||
*/
|
||||
public function createDefaultBuckets(Scenario $scenario): void
|
||||
{
|
||||
$this->execute($scenario, 'Monthly Expenses', Bucket::TYPE_FIXED_LIMIT, 0, 1);
|
||||
$this->execute($scenario, 'Emergency Fund', Bucket::TYPE_FIXED_LIMIT, 0, 2);
|
||||
$this->execute($scenario, 'Investments', Bucket::TYPE_UNLIMITED, null, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate allocation value based on allocation type.
|
||||
*/
|
||||
private function validateAllocationValue(BucketAllocationTypeEnum $allocationType, ?float $allocationValue): void
|
||||
private function validateAllocationValue(string $allocationType, ?float $allocationValue): void
|
||||
{
|
||||
switch ($allocationType) {
|
||||
case BucketAllocationTypeEnum::FIXED_LIMIT:
|
||||
case Bucket::TYPE_FIXED_LIMIT:
|
||||
if ($allocationValue === null) {
|
||||
throw new InvalidArgumentException('Fixed limit buckets require an allocation value');
|
||||
}
|
||||
|
|
@ -76,7 +87,7 @@ private function validateAllocationValue(BucketAllocationTypeEnum $allocationTyp
|
|||
}
|
||||
break;
|
||||
|
||||
case BucketAllocationTypeEnum::PERCENTAGE:
|
||||
case Bucket::TYPE_PERCENTAGE:
|
||||
if ($allocationValue === null) {
|
||||
throw new InvalidArgumentException('Percentage buckets require an allocation value');
|
||||
}
|
||||
|
|
@ -85,47 +96,10 @@ private function validateAllocationValue(BucketAllocationTypeEnum $allocationTyp
|
|||
}
|
||||
break;
|
||||
|
||||
case BucketAllocationTypeEnum::UNLIMITED:
|
||||
case Bucket::TYPE_UNLIMITED:
|
||||
// Unlimited buckets should not have an allocation value
|
||||
// We'll set it to null in the main method regardless
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default buckets for a scenario.
|
||||
*/
|
||||
public function createDefaultBuckets(Scenario $scenario): array
|
||||
{
|
||||
$buckets = [];
|
||||
|
||||
// Monthly Expenses - Fixed limit, priority 1
|
||||
$buckets[] = $this->execute(
|
||||
$scenario,
|
||||
'Monthly Expenses',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
0,
|
||||
1
|
||||
);
|
||||
|
||||
// Emergency Fund - Fixed limit, priority 2
|
||||
$buckets[] = $this->execute(
|
||||
$scenario,
|
||||
'Emergency Fund',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
0,
|
||||
2
|
||||
);
|
||||
|
||||
// Investments - Unlimited, priority 3
|
||||
$buckets[] = $this->execute(
|
||||
$scenario,
|
||||
'Investments',
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
null,
|
||||
3
|
||||
);
|
||||
|
||||
return $buckets;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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([
|
||||
'buckets' => $buckets,
|
||||
'buckets' => $buckets
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ public function store(Request $request, Scenario $scenario): JsonResponse
|
|||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'allocation_type' => 'required|in:'.implode(',', [
|
||||
'allocation_type' => 'required|in:' . implode(',', [
|
||||
Bucket::TYPE_FIXED_LIMIT,
|
||||
Bucket::TYPE_PERCENTAGE,
|
||||
Bucket::TYPE_UNLIMITED,
|
||||
|
|
@ -52,7 +52,7 @@ public function store(Request $request, Scenario $scenario): JsonResponse
|
|||
]);
|
||||
|
||||
try {
|
||||
$createBucketAction = new CreateBucketAction;
|
||||
$createBucketAction = new CreateBucketAction();
|
||||
$bucket = $createBucketAction->execute(
|
||||
$scenario,
|
||||
$validated['name'],
|
||||
|
|
@ -63,12 +63,12 @@ public function store(Request $request, Scenario $scenario): JsonResponse
|
|||
|
||||
return response()->json([
|
||||
'bucket' => $this->formatBucketResponse($bucket),
|
||||
'message' => 'Bucket created successfully.',
|
||||
'message' => 'Bucket created successfully.'
|
||||
], 201);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['allocation_value' => [$e->getMessage()]],
|
||||
'errors' => ['allocation_value' => [$e->getMessage()]]
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ public function update(Request $request, Bucket $bucket): JsonResponse
|
|||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'allocation_type' => 'required|in:'.implode(',', [
|
||||
'allocation_type' => 'required|in:' . implode(',', [
|
||||
Bucket::TYPE_FIXED_LIMIT,
|
||||
Bucket::TYPE_PERCENTAGE,
|
||||
Bucket::TYPE_UNLIMITED,
|
||||
|
|
@ -107,7 +107,7 @@ public function update(Request $request, Bucket $bucket): JsonResponse
|
|||
|
||||
return response()->json([
|
||||
'bucket' => $this->formatBucketResponse($bucket),
|
||||
'message' => 'Bucket updated successfully.',
|
||||
'message' => 'Bucket updated successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ public function destroy(Bucket $bucket): JsonResponse
|
|||
$this->shiftPrioritiesDown($scenarioId, $deletedPriority);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Bucket deleted successfully.',
|
||||
'message' => 'Bucket deleted successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +151,7 @@ public function updatePriorities(Request $request, Scenario $scenario): JsonResp
|
|||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Bucket priorities updated successfully.',
|
||||
'message' => 'Bucket priorities updated successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +175,7 @@ private function formatBucketResponse(Bucket $bucket): array
|
|||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shift priorities down to fill gap after deletion.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
use App\Actions\CreateScenarioAction;
|
||||
use App\Actions\DeleteScenarioAction;
|
||||
use App\Actions\UpdateScenarioAction;
|
||||
use App\Http\Requests\StoreScenarioRequest;
|
||||
use App\Http\Requests\UpdateScenarioRequest;
|
||||
use App\Http\Resources\BucketResource;
|
||||
use App\Http\Resources\ScenarioResource;
|
||||
use App\Http\Resources\StreamResource;
|
||||
use App\Actions\CreateBucketAction;
|
||||
use App\Models\Scenario;
|
||||
use App\Repositories\ScenarioRepository;
|
||||
use App\Repositories\StreamRepository;
|
||||
use App\Services\Streams\StatsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ScenarioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ScenarioRepository $scenarioRepository,
|
||||
private readonly StreamRepository $streamRepository,
|
||||
private readonly CreateScenarioAction $createScenarioAction,
|
||||
private readonly UpdateScenarioAction $updateScenarioAction,
|
||||
private readonly DeleteScenarioAction $deleteScenarioAction,
|
||||
private readonly StatsService $statsService
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('Scenarios/Index', [
|
||||
'scenarios' => ScenarioResource::collection($this->scenarioRepository->getAll()),
|
||||
'scenarios' => Scenario::orderBy('created_at', 'desc')->get()
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -43,10 +25,22 @@ public function show(Scenario $scenario): Response
|
|||
}]);
|
||||
|
||||
return Inertia::render('Scenarios/Show', [
|
||||
'scenario' => new ScenarioResource($scenario),
|
||||
'buckets' => BucketResource::collection($scenario->buckets),
|
||||
'streams' => StreamResource::collection($this->streamRepository->getForScenario($scenario)),
|
||||
'streamStats' => $this->statsService->getSummaryStats($scenario),
|
||||
'scenario' => $scenario,
|
||||
'buckets' => $scenario->buckets->map(function ($bucket) {
|
||||
return [
|
||||
'id' => $bucket->id,
|
||||
'name' => $bucket->name,
|
||||
'priority' => $bucket->priority,
|
||||
'sort_order' => $bucket->sort_order,
|
||||
'allocation_type' => $bucket->allocation_type,
|
||||
'allocation_value' => $bucket->allocation_value,
|
||||
'allocation_type_label' => $bucket->getAllocationTypeLabel(),
|
||||
'formatted_allocation_value' => $bucket->getFormattedAllocationValue(),
|
||||
'current_balance' => $bucket->getCurrentBalance(),
|
||||
'has_available_space' => $bucket->hasAvailableSpace(),
|
||||
'available_space' => $bucket->getAvailableSpace(),
|
||||
];
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -55,9 +49,19 @@ public function create(): Response
|
|||
return Inertia::render('Scenarios/Create');
|
||||
}
|
||||
|
||||
public function store(StoreScenarioRequest $request): RedirectResponse
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$scenario = $this->createScenarioAction->execute($request->validated());
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$scenario = Scenario::create([
|
||||
'name' => $request->name,
|
||||
]);
|
||||
|
||||
// Create default buckets using the action
|
||||
$createBucketAction = new CreateBucketAction();
|
||||
$createBucketAction->createDefaultBuckets($scenario);
|
||||
|
||||
return redirect()->route('scenarios.show', $scenario);
|
||||
}
|
||||
|
|
@ -65,25 +69,30 @@ public function store(StoreScenarioRequest $request): RedirectResponse
|
|||
public function edit(Scenario $scenario): Response
|
||||
{
|
||||
return Inertia::render('Scenarios/Edit', [
|
||||
'scenario' => new ScenarioResource($scenario),
|
||||
'scenario' => $scenario
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateScenarioRequest $request, Scenario $scenario): RedirectResponse
|
||||
public function update(Request $request, Scenario $scenario): RedirectResponse
|
||||
{
|
||||
$this->updateScenarioAction->execute($scenario, $request->validated());
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('scenarios.show', $scenario)
|
||||
$scenario->update($request->only(['name', 'description', 'start_date', 'end_date']));
|
||||
|
||||
return redirect()->route('scenarios.show', $scenario)
|
||||
->with('success', 'Scenario updated successfully');
|
||||
}
|
||||
|
||||
public function destroy(Scenario $scenario): RedirectResponse
|
||||
{
|
||||
$this->deleteScenarioAction->execute($scenario);
|
||||
$scenario->delete();
|
||||
|
||||
return redirect()
|
||||
->route('scenarios.index')
|
||||
return redirect()->route('scenarios.index')
|
||||
->with('success', 'Scenario deleted successfully');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use Database\Factories\BucketFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -10,16 +9,10 @@
|
|||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $scenario_id
|
||||
* @property Scenario $scenario
|
||||
* @property string $name
|
||||
* @property int $priority
|
||||
* @property BucketAllocationTypeEnum $allocation_type
|
||||
* @property float $starting_amount
|
||||
* @property float $allocation_value
|
||||
*
|
||||
* @method static BucketFactory factory()
|
||||
*/
|
||||
class Bucket extends Model
|
||||
{
|
||||
|
|
@ -33,17 +26,19 @@ class Bucket extends Model
|
|||
'sort_order',
|
||||
'allocation_type',
|
||||
'allocation_value',
|
||||
'starting_amount',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'priority' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'allocation_value' => 'decimal:2',
|
||||
'starting_amount' => 'integer',
|
||||
'allocation_type' => BucketAllocationTypeEnum::class,
|
||||
];
|
||||
|
||||
// TODO Extract to Enum
|
||||
const string TYPE_FIXED_LIMIT = 'fixed_limit';
|
||||
const string TYPE_PERCENTAGE = 'percentage';
|
||||
const string TYPE_UNLIMITED = 'unlimited';
|
||||
|
||||
public function scenario(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Scenario::class);
|
||||
|
|
@ -51,17 +46,21 @@ public function scenario(): BelongsTo
|
|||
|
||||
/**
|
||||
* Get the draws for the bucket.
|
||||
* (Will be implemented when Draw model is created)
|
||||
*/
|
||||
public function draws(): HasMany
|
||||
{
|
||||
// TODO: Implement when Draw model is created
|
||||
return $this->hasMany(Draw::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the outflows for the bucket.
|
||||
* (Will be implemented when Outflow model is created)
|
||||
*/
|
||||
public function outflows(): HasMany
|
||||
{
|
||||
// TODO: Implement when Outflow model is created
|
||||
return $this->hasMany(Outflow::class);
|
||||
}
|
||||
|
||||
|
|
@ -83,14 +82,12 @@ public function scopeOrderedBySortOrder($query)
|
|||
|
||||
/**
|
||||
* Get the current balance of the bucket.
|
||||
* Calculates starting amount plus total draws (money allocated to bucket) minus total outflows (money spent from bucket).
|
||||
* For MVP, this will always return 0 as we don't have transactions yet.
|
||||
*/
|
||||
public function getCurrentBalance(): int
|
||||
public function getCurrentBalance(): float
|
||||
{
|
||||
$totalDraws = $this->draws()->sum('amount');
|
||||
$totalOutflows = $this->outflows()->sum('amount');
|
||||
|
||||
return $this->starting_amount + $totalDraws - $totalOutflows;
|
||||
// TODO: Calculate from draws minus outflows when those features are implemented
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -98,7 +95,7 @@ public function getCurrentBalance(): int
|
|||
*/
|
||||
public function hasAvailableSpace(): bool
|
||||
{
|
||||
if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
||||
if ($this->allocation_type !== self::TYPE_FIXED_LIMIT) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +107,7 @@ public function hasAvailableSpace(): bool
|
|||
*/
|
||||
public function getAvailableSpace(): float
|
||||
{
|
||||
if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
||||
if ($this->allocation_type !== self::TYPE_FIXED_LIMIT) {
|
||||
return PHP_FLOAT_MAX;
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +119,12 @@ public function getAvailableSpace(): float
|
|||
*/
|
||||
public function getAllocationTypeLabel(): string
|
||||
{
|
||||
return $this->allocation_type->getLabel();
|
||||
return match($this->allocation_type) {
|
||||
self::TYPE_FIXED_LIMIT => 'Fixed Limit',
|
||||
self::TYPE_PERCENTAGE => 'Percentage',
|
||||
self::TYPE_UNLIMITED => 'Unlimited',
|
||||
default => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -130,7 +132,12 @@ public function getAllocationTypeLabel(): string
|
|||
*/
|
||||
public function getFormattedAllocationValue(): string
|
||||
{
|
||||
return $this->allocation_type->formatValue($this->allocation_value);
|
||||
return match($this->allocation_type) {
|
||||
self::TYPE_FIXED_LIMIT => '$' . number_format($this->allocation_value, 2),
|
||||
self::TYPE_PERCENTAGE => number_format($this->allocation_value, 2) . '%',
|
||||
self::TYPE_UNLIMITED => 'All remaining',
|
||||
default => '-',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -140,13 +147,17 @@ public static function validationRules($scenarioId = null): array
|
|||
{
|
||||
$rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'allocation_type' => 'required|in:'.implode(',', BucketAllocationTypeEnum::values()),
|
||||
'allocation_type' => 'required|in:' . implode(',', [
|
||||
self::TYPE_FIXED_LIMIT,
|
||||
self::TYPE_PERCENTAGE,
|
||||
self::TYPE_UNLIMITED,
|
||||
]),
|
||||
'priority' => 'required|integer|min:1',
|
||||
];
|
||||
|
||||
// Add scenario-specific priority uniqueness if scenario ID provided
|
||||
if ($scenarioId) {
|
||||
$rules['priority'] .= '|unique:buckets,priority,NULL,id,scenario_id,'.$scenarioId;
|
||||
$rules['priority'] .= '|unique:buckets,priority,NULL,id,scenario_id,' . $scenarioId;
|
||||
}
|
||||
|
||||
return $rules;
|
||||
|
|
@ -155,8 +166,13 @@ public static function validationRules($scenarioId = null): array
|
|||
/**
|
||||
* Get allocation value validation rules based on type.
|
||||
*/
|
||||
public static function allocationValueRules(BucketAllocationTypeEnum $allocationType): array
|
||||
public static function allocationValueRules($allocationType): array
|
||||
{
|
||||
return $allocationType->getAllocationValueRules();
|
||||
return match($allocationType) {
|
||||
self::TYPE_FIXED_LIMIT => ['required', 'numeric', 'min:0'],
|
||||
self::TYPE_PERCENTAGE => ['required', 'numeric', 'min:0.01', 'max:100'],
|
||||
self::TYPE_UNLIMITED => ['nullable'],
|
||||
default => ['nullable'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
use Database\Factories\ScenarioFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property Collection<Bucket> $buckets
|
||||
*
|
||||
* @method static create(array $data)
|
||||
*/
|
||||
class Scenario extends Model
|
||||
{
|
||||
/** @use HasFactory<ScenarioFactory> */
|
||||
|
|
@ -21,24 +14,34 @@ class Scenario extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
|
||||
|
||||
public function buckets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Bucket::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the streams for this scenario.
|
||||
*/
|
||||
public function streams(): HasMany
|
||||
{
|
||||
return $this->hasMany(Stream::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inflows for this scenario.
|
||||
*/
|
||||
public function inflows(): HasMany
|
||||
{
|
||||
return $this->hasMany(Inflow::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the outflows for this scenario.
|
||||
*/
|
||||
public function outflows(): HasMany
|
||||
{
|
||||
return $this->hasMany(Outflow::class);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"php": "^8.2",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/fortify": "^1.30",
|
||||
"laravel/framework": "^12.0",
|
||||
|
|
@ -18,13 +18,11 @@
|
|||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"larastan/larastan": "^3.9",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpstan/phpstan-mockery": "^2.0",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
|
|
|
|||
237
composer.lock
generated
237
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "1fb421bd376d40ce4b1e241d0805c5ed",
|
||||
"content-hash": "3c79e040a3570288a4004f205416801a",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
|
|
@ -6660,137 +6660,6 @@
|
|||
},
|
||||
"time": "2025-04-30T06:54:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "iamcal/sql-parser",
|
||||
"version": "v0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/iamcal/SQLParser.git",
|
||||
"reference": "610392f38de49a44dab08dc1659960a29874c4b8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
|
||||
"reference": "610392f38de49a44dab08dc1659960a29874c4b8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require-dev": {
|
||||
"php-coveralls/php-coveralls": "^1.0",
|
||||
"phpunit/phpunit": "^5|^6|^7|^8|^9"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"iamcal\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Cal Henderson",
|
||||
"email": "cal@iamcal.com"
|
||||
}
|
||||
],
|
||||
"description": "MySQL schema parser",
|
||||
"support": {
|
||||
"issues": "https://github.com/iamcal/SQLParser/issues",
|
||||
"source": "https://github.com/iamcal/SQLParser/tree/v0.7"
|
||||
},
|
||||
"time": "2026-01-28T22:20:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "larastan/larastan",
|
||||
"version": "v3.9.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/larastan/larastan.git",
|
||||
"reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/larastan/larastan/zipball/64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65",
|
||||
"reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"iamcal/sql-parser": "^0.7.0",
|
||||
"illuminate/console": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/container": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/database": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/http": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"php": "^8.2",
|
||||
"phpstan/phpstan": "^2.1.32"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^13",
|
||||
"laravel/framework": "^11.44.2 || ^12.7.2 || ^13",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"nikic/php-parser": "^5.4",
|
||||
"orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11",
|
||||
"orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.1",
|
||||
"phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8"
|
||||
},
|
||||
"suggest": {
|
||||
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
|
||||
"phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
|
||||
},
|
||||
"type": "phpstan-extension",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Larastan\\Larastan\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Can Vural",
|
||||
"email": "can9119@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
|
||||
"keywords": [
|
||||
"PHPStan",
|
||||
"code analyse",
|
||||
"code analysis",
|
||||
"larastan",
|
||||
"laravel",
|
||||
"package",
|
||||
"php",
|
||||
"static analysis"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/larastan/larastan/issues",
|
||||
"source": "https://github.com/larastan/larastan/tree/v3.9.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/canvural",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-20T12:07:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/pail",
|
||||
"version": "v1.2.4",
|
||||
|
|
@ -7360,108 +7229,6 @@
|
|||
},
|
||||
"time": "2022-02-21T01:04:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.1.42",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0",
|
||||
"reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan-shim": "*"
|
||||
},
|
||||
"bin": [
|
||||
"phpstan",
|
||||
"phpstan.phar"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHPStan - PHP Static Analysis Tool",
|
||||
"keywords": [
|
||||
"dev",
|
||||
"static analysis"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://phpstan.org/user-guide/getting-started",
|
||||
"forum": "https://github.com/phpstan/phpstan/discussions",
|
||||
"issues": "https://github.com/phpstan/phpstan/issues",
|
||||
"security": "https://github.com/phpstan/phpstan/security/policy",
|
||||
"source": "https://github.com/phpstan/phpstan-src"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/ondrejmirtes",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/phpstan",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-17T14:58:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan-mockery",
|
||||
"version": "2.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpstan-mockery.git",
|
||||
"reference": "89a949d0ac64298e88b7c7fa00caee565c198394"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan-mockery/zipball/89a949d0ac64298e88b7c7fa00caee565c198394",
|
||||
"reference": "89a949d0ac64298e88b7c7fa00caee565c198394",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0",
|
||||
"phpstan/phpstan": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6.11",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.2",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^9.6"
|
||||
},
|
||||
"type": "phpstan-extension",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PHPStan\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHPStan Mockery extension",
|
||||
"support": {
|
||||
"issues": "https://github.com/phpstan/phpstan-mockery/issues",
|
||||
"source": "https://github.com/phpstan/phpstan-mockery/tree/2.0.0"
|
||||
},
|
||||
"time": "2024-10-14T03:18:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
"version": "11.0.12",
|
||||
|
|
@ -9077,7 +8844,7 @@
|
|||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.3"
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
|
@ -15,9 +14,9 @@ class BucketFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
$allocationType = $this->faker->randomElement([
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
BucketAllocationTypeEnum::PERCENTAGE,
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
Bucket::TYPE_FIXED_LIMIT,
|
||||
Bucket::TYPE_PERCENTAGE,
|
||||
Bucket::TYPE_UNLIMITED,
|
||||
]);
|
||||
|
||||
return [
|
||||
|
|
@ -40,7 +39,6 @@ public function definition(): array
|
|||
'sort_order' => $this->faker->numberBetween(0, 10),
|
||||
'allocation_type' => $allocationType,
|
||||
'allocation_value' => $this->getAllocationValueForType($allocationType),
|
||||
'starting_amount' => $this->faker->numberBetween(0, 100000), // $0 to $1000 in cents
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -50,9 +48,9 @@ public function definition(): array
|
|||
public function fixedLimit($amount = null): Factory
|
||||
{
|
||||
$amount = $amount ?? $this->faker->numberBetween(500, 5000);
|
||||
|
||||
|
||||
return $this->state([
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
|
||||
'allocation_value' => $amount,
|
||||
]);
|
||||
}
|
||||
|
|
@ -63,9 +61,9 @@ public function fixedLimit($amount = null): Factory
|
|||
public function percentage($percentage = null): Factory
|
||||
{
|
||||
$percentage = $percentage ?? $this->faker->numberBetween(10, 50);
|
||||
|
||||
|
||||
return $this->state([
|
||||
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||
'allocation_type' => Bucket::TYPE_PERCENTAGE,
|
||||
'allocation_value' => $percentage,
|
||||
]);
|
||||
}
|
||||
|
|
@ -76,7 +74,7 @@ public function percentage($percentage = null): Factory
|
|||
public function unlimited(): Factory
|
||||
{
|
||||
return $this->state([
|
||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||
'allocation_type' => Bucket::TYPE_UNLIMITED,
|
||||
'allocation_value' => null,
|
||||
]);
|
||||
}
|
||||
|
|
@ -91,25 +89,22 @@ public function defaultSet(): array
|
|||
'name' => 'Monthly Expenses',
|
||||
'priority' => 1,
|
||||
'sort_order' => 1,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
|
||||
'allocation_value' => 0,
|
||||
'starting_amount' => 0,
|
||||
]),
|
||||
$this->state([
|
||||
'name' => 'Emergency Fund',
|
||||
'priority' => 2,
|
||||
'sort_order' => 2,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
|
||||
'allocation_value' => 0,
|
||||
'starting_amount' => 0,
|
||||
]),
|
||||
$this->state([
|
||||
'name' => 'Investments',
|
||||
'priority' => 3,
|
||||
'sort_order' => 3,
|
||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||
'allocation_type' => Bucket::TYPE_UNLIMITED,
|
||||
'allocation_value' => null,
|
||||
'starting_amount' => 0,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
|
@ -117,12 +112,12 @@ public function defaultSet(): array
|
|||
/**
|
||||
* Get allocation value based on type.
|
||||
*/
|
||||
private function getAllocationValueForType(BucketAllocationTypeEnum $type): ?float
|
||||
private function getAllocationValueForType(string $type): ?float
|
||||
{
|
||||
return match ($type) {
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
|
||||
BucketAllocationTypeEnum::PERCENTAGE => $this->faker->numberBetween(5, 50),
|
||||
BucketAllocationTypeEnum::UNLIMITED => null,
|
||||
return match($type) {
|
||||
Bucket::TYPE_FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
|
||||
Bucket::TYPE_PERCENTAGE => $this->faker->numberBetween(5, 50),
|
||||
Bucket::TYPE_UNLIMITED => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->words(2, true).' Budget',
|
||||
'description' => $this->faker->text,
|
||||
'name' => $this->faker->words(2, true) . ' Budget',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ public function up(): void
|
|||
$table->enum('allocation_type', ['fixed_limit', 'percentage', 'unlimited']);
|
||||
$table->decimal('allocation_value', 10, 2)->nullable()
|
||||
->comment('Limit amount for fixed_limit, percentage for percentage type, NULL for unlimited');
|
||||
$table->unsignedBigInteger('starting_amount')->default(0)
|
||||
->comment('Initial amount in bucket in cents before any draws or outflows');
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes for performance
|
||||
|
|
|
|||
|
|
@ -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
|
||||
services:
|
||||
app:
|
||||
image: forge.lvl0.xyz/lvl0/buckets:latest
|
||||
image: codeberg.org/lvl0/buckets:latest
|
||||
container_name: buckets_app
|
||||
restart: always
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -1,52 +1,59 @@
|
|||
# ===================
|
||||
# Buckets Development Services
|
||||
# ===================
|
||||
# Port allocation:
|
||||
# App: 8100 (frankenphp), 5174 (vite)
|
||||
# DB: 3307 (mysql)
|
||||
# Mailhog: 8026 (web), 1026 (smtp)
|
||||
# Local Development Docker Compose
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ../..
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: buckets_dev_app
|
||||
container_name: buckets_app
|
||||
restart: unless-stopped
|
||||
# Remove user directive to run as root in container
|
||||
# The container will handle permissions internally
|
||||
ports:
|
||||
- "8100:8000"
|
||||
- "5174:5173"
|
||||
volumes:
|
||||
- ../..:/app
|
||||
- app_vendor:/app/vendor
|
||||
- app_node_modules:/app/node_modules
|
||||
- "8100:8000" # Laravel app
|
||||
- "5174:5173" # Vite dev server
|
||||
environment:
|
||||
# Laravel
|
||||
APP_NAME: "${APP_NAME:-buckets}"
|
||||
APP_ENV: "${APP_ENV:-local}"
|
||||
APP_KEY: "${APP_KEY:-base64:YOUR_KEY_HERE}"
|
||||
APP_DEBUG: "${APP_DEBUG:-true}"
|
||||
APP_URL: "${APP_URL:-http://localhost:8100}"
|
||||
|
||||
# Database
|
||||
DB_CONNECTION: mysql
|
||||
DB_HOST: db
|
||||
DB_PORT: 3306
|
||||
DB_DATABASE: "${DB_DATABASE:-buckets}"
|
||||
DB_USERNAME: "${DB_USERNAME:-buckets}"
|
||||
DB_PASSWORD: "${DB_PASSWORD:-buckets}"
|
||||
|
||||
# Session & Cache
|
||||
SESSION_DRIVER: "${SESSION_DRIVER:-file}"
|
||||
CACHE_DRIVER: "${CACHE_DRIVER:-file}"
|
||||
QUEUE_CONNECTION: "${QUEUE_CONNECTION:-sync}"
|
||||
|
||||
# Mail (for development)
|
||||
MAIL_MAILER: "${MAIL_MAILER:-log}"
|
||||
|
||||
# Vite
|
||||
VITE_HOST: "0.0.0.0"
|
||||
VITE_PORT: "5174"
|
||||
volumes:
|
||||
# Mount entire project for hot reload with SELinux context
|
||||
- .:/app:Z
|
||||
# Named volumes for performance and permission isolation
|
||||
- app_vendor:/app/vendor
|
||||
- app_node_modules:/app/node_modules
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
- db
|
||||
networks:
|
||||
- buckets-network
|
||||
- buckets
|
||||
|
||||
db:
|
||||
image: mariadb:11
|
||||
container_name: buckets_dev_db
|
||||
container_name: buckets_db
|
||||
hostname: db
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
|
@ -58,28 +65,39 @@ services:
|
|||
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
- ../../docker/mysql-init:/docker-entrypoint-initdb.d
|
||||
# Initialize with SQL scripts
|
||||
- ./docker/mysql-init:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
retries: 3
|
||||
networks:
|
||||
- buckets-network
|
||||
- buckets
|
||||
|
||||
# Optional: Mailhog for email testing
|
||||
mailhog:
|
||||
image: mailhog/mailhog
|
||||
container_name: buckets_dev_mailhog
|
||||
container_name: buckets_mailhog
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1026:1025"
|
||||
- "8026:8025"
|
||||
- "1026:1025" # SMTP server
|
||||
- "8026:8025" # Web UI
|
||||
networks:
|
||||
- buckets-network
|
||||
- buckets
|
||||
|
||||
# Optional: Redis for caching/sessions
|
||||
# redis:
|
||||
# image: redis:alpine
|
||||
# container_name: buckets_redis
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "6379:6379"
|
||||
# networks:
|
||||
# - buckets
|
||||
|
||||
networks:
|
||||
buckets-network:
|
||||
buckets:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
|
|
@ -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>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<server name="APP_ENV" value="testing" force="true"/>
|
||||
<server name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||
<server name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||
<server name="BROADCAST_CONNECTION" value="log" force="true"/>
|
||||
<server name="CACHE_STORE" value="array" force="true"/>
|
||||
<server name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<server name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<server name="MAIL_MAILER" value="array" force="true"/>
|
||||
<server name="PULSE_ENABLED" value="false" force="true"/>
|
||||
<server name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||
<server name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<server name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||
<server name="NIGHTWATCH_ENABLED" value="false" force="true"/>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Breadcrumbs } from '@/components/Breadcrumbs';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { Icon } from '@/components/icon';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Breadcrumbs } from '@/components/Breadcrumbs';
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { NavFooter } from '@/components/NavFooter';
|
||||
import { NavMain } from '@/components/NavMain';
|
||||
import { NavUser } from '@/components/NavUser';
|
||||
import { NavFooter } from '@/components/nav-footer';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import HeadingSmall from '@/components/HeadingSmall';
|
||||
import InputError from '@/components/InputError';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Icon } from '@/components/Icon';
|
||||
import { Icon } from '@/components/icon';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import InputError from '@/components/InputError';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
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 ReactNode } from 'react';
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { AppContent } from '@/components/AppContent';
|
||||
import { AppHeader } from '@/components/AppHeader';
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
import { AppContent } from '@/components/app-content';
|
||||
import { AppHeader } from '@/components/app-header';
|
||||
import { AppShell } from '@/components/app-shell';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { AppContent } from '@/components/AppContent';
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
import { AppSidebar } from '@/components/AppSidebar';
|
||||
import { AppSidebarHeader } from '@/components/AppSidebarHeader';
|
||||
import { AppContent } from '@/components/app-content';
|
||||
import { AppShell } from '@/components/app-shell';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { AppSidebarHeader } from '@/components/app-sidebar-header';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import AuthLayoutTemplate from '@/layouts/auth/AuthSimpleLayout';
|
||||
import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout';
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import AppLogoIcon from '@/components/AppLogoIcon';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import AppLogoIcon from '@/components/AppLogoIcon';
|
||||
import { dashboard } from '@/routes';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { home } from '@/routes';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ export default function AuthSimpleLayout({
|
|||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Link
|
||||
href={dashboard.url()}
|
||||
href={home()}
|
||||
className="flex flex-col items-center gap-2 font-medium"
|
||||
>
|
||||
<div className="mb-1 flex h-9 w-9 items-center justify-center rounded-md">
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import AppLogoIcon from '@/components/AppLogoIcon';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { home } from '@/routes';
|
||||
import { type SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import Heading from '@/components/Heading';
|
||||
import Heading from '@/components/heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ interface Scenario {
|
|||
}
|
||||
|
||||
interface Props {
|
||||
scenarios: {
|
||||
data: Scenario[];
|
||||
};
|
||||
scenarios: Scenario[];
|
||||
}
|
||||
|
||||
export default function Index({ scenarios }: Props) {
|
||||
|
|
@ -110,7 +108,7 @@ export default function Index({ scenarios }: Props) {
|
|||
)}
|
||||
|
||||
{/* Scenarios List */}
|
||||
{scenarios.data.length === 0 ? (
|
||||
{scenarios.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center shadow">
|
||||
<div className="mx-auto h-12 w-12 text-gray-400">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
|
@ -124,7 +122,7 @@ export default function Index({ scenarios }: Props) {
|
|||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{scenarios.data.map((scenario) => (
|
||||
{scenarios.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
onClick={() => router.visit(`/scenarios/${scenario.id}`)}
|
||||
|
|
|
|||
|
|
@ -22,96 +22,27 @@ interface Bucket {
|
|||
available_space: number;
|
||||
}
|
||||
|
||||
interface Stream {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'income' | 'expense';
|
||||
type_label: string;
|
||||
amount: number;
|
||||
frequency: string;
|
||||
frequency_label: string;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
bucket_id: number | null;
|
||||
bucket_name: string | null;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
monthly_equivalent: number;
|
||||
}
|
||||
|
||||
interface StreamStats {
|
||||
total_streams: number;
|
||||
active_streams: number;
|
||||
income_streams: number;
|
||||
expense_streams: number;
|
||||
monthly_income: number;
|
||||
monthly_expenses: number;
|
||||
monthly_net: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scenario: Scenario;
|
||||
buckets: { data: Bucket[] };
|
||||
streams: { data: Stream[] };
|
||||
streamStats?: StreamStats;
|
||||
buckets: Bucket[];
|
||||
}
|
||||
|
||||
export default function Show({ scenario, buckets, streams = { data: [] }, streamStats }: Props) {
|
||||
export default function Show({ scenario, buckets }: Props) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [editingBucket, setEditingBucket] = useState<Bucket | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
allocation_type: 'fixed_limit',
|
||||
allocation_value: ''
|
||||
});
|
||||
|
||||
const handleEdit = (bucket: Bucket) => {
|
||||
setEditingBucket(bucket);
|
||||
setFormData({
|
||||
name: bucket.name,
|
||||
allocation_type: bucket.allocation_type,
|
||||
allocation_value: bucket.allocation_value ? bucket.allocation_value.toString() : ''
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (bucket: Bucket) => {
|
||||
if (!confirm(`Are you sure you want to delete "${bucket.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/buckets/${bucket.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.reload({ only: ['buckets'] });
|
||||
} else {
|
||||
console.error('Failed to delete bucket');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting bucket:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const url = editingBucket
|
||||
? `/buckets/${editingBucket.id}`
|
||||
: `/scenarios/${scenario.id}/buckets`;
|
||||
|
||||
const method = editingBucket ? 'PATCH' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
const response = await fetch(`/scenarios/${scenario.id}/buckets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
|
|
@ -119,35 +50,33 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
|||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
allocation_type: formData.allocation_type,
|
||||
allocation_value: formData.allocation_value ? parseFloat(formData.allocation_value) : null,
|
||||
priority: editingBucket ? editingBucket.priority : undefined
|
||||
allocation_value: formData.allocation_value ? parseFloat(formData.allocation_value) : null
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setIsModalOpen(false);
|
||||
setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' });
|
||||
setEditingBucket(null);
|
||||
router.reload({ only: ['buckets'] });
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error(`Failed to ${editingBucket ? 'update' : 'create'} bucket:`, errorData);
|
||||
console.error('Failed to create bucket:', errorData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error ${editingBucket ? 'updating' : 'creating'} bucket:`, error);
|
||||
console.error('Error creating bucket:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePriorityChange = async (bucketId: number, direction: 'up' | 'down') => {
|
||||
const bucket = buckets.data.find(b => b.id === bucketId);
|
||||
const bucket = buckets.find(b => b.id === bucketId);
|
||||
if (!bucket) return;
|
||||
|
||||
const newPriority = direction === 'up' ? bucket.priority - 1 : bucket.priority + 1;
|
||||
|
||||
// Don't allow moving beyond bounds
|
||||
if (newPriority < 1 || newPriority > buckets.data.length) return;
|
||||
if (newPriority < 1 || newPriority > buckets.length) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/buckets/${bucketId}`, {
|
||||
|
|
@ -206,11 +135,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
|||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Buckets</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingBucket(null);
|
||||
setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' });
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
+ Add Bucket
|
||||
|
|
@ -218,7 +143,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
|||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{buckets.data.map((bucket) => (
|
||||
{buckets.map((bucket) => (
|
||||
<div
|
||||
key={bucket.id}
|
||||
className="rounded-lg bg-white p-6 shadow transition-shadow hover:shadow-lg border-l-4 border-blue-500"
|
||||
|
|
@ -245,7 +170,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
|||
</button>
|
||||
<button
|
||||
onClick={() => handlePriorityChange(bucket.id, 'down')}
|
||||
disabled={bucket.priority === buckets.data.length}
|
||||
disabled={bucket.priority === buckets.length}
|
||||
className="p-1 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Move down in priority"
|
||||
>
|
||||
|
|
@ -294,16 +219,10 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
|||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(bucket)}
|
||||
className="flex-1 text-sm text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
<button className="flex-1 text-sm text-blue-600 hover:text-blue-500">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(bucket)}
|
||||
className="flex-1 text-sm text-red-600 hover:text-red-500"
|
||||
>
|
||||
<button className="flex-1 text-sm text-red-600 hover:text-red-500">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -331,159 +250,13 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streams Section */}
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Income & Expense Streams</h2>
|
||||
<button
|
||||
className="rounded-md bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-500"
|
||||
>
|
||||
+ Add Stream
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stream Statistics */}
|
||||
{streamStats && (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Monthly Income
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-green-600">
|
||||
${streamStats.monthly_income.toFixed(2)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Monthly Expenses
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-red-600">
|
||||
${streamStats.monthly_expenses.toFixed(2)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Net Cash Flow
|
||||
</dt>
|
||||
<dd className={`mt-1 text-3xl font-semibold ${streamStats.monthly_net >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
${streamStats.monthly_net.toFixed(2)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Active Streams
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-gray-900">
|
||||
{streamStats.active_streams} / {streamStats.total_streams}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streams.data.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-8 text-center shadow">
|
||||
<p className="text-gray-600">No streams yet. Add income or expense streams to start tracking cash flow.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Frequency
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Bucket
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Start Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Status
|
||||
</th>
|
||||
<th className="relative px-6 py-3">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{streams.data.map((stream) => (
|
||||
<tr key={stream.id}>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">
|
||||
{stream.name}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<span className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
|
||||
stream.type === 'income'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{stream.type_label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
${stream.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{stream.frequency_label}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{stream.bucket_name || '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(stream.start_date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<button
|
||||
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
|
||||
stream.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{stream.is_active ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button className="text-indigo-600 hover:text-indigo-900 mr-3">
|
||||
Edit
|
||||
</button>
|
||||
<button className="text-red-600 hover:text-red-900">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Placeholder for future features */}
|
||||
<div className="rounded-lg bg-blue-50 p-6 text-center">
|
||||
<h3 className="text-lg font-medium text-blue-900">
|
||||
Coming Next: Timeline & Projections
|
||||
Coming Next: Streams & Timeline
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 mt-2">
|
||||
Calculate projections to see your money flow through these buckets over time.
|
||||
Add income and expense streams, then calculate projections to see your money flow through these buckets over time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -495,7 +268,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
|||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">{editingBucket ? 'Edit Bucket' : 'Add New Bucket'}</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Add New Bucket</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
|
|
@ -551,11 +324,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
|||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsModalOpen(false);
|
||||
setEditingBucket(null);
|
||||
setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' });
|
||||
}}
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
|
|
@ -566,7 +335,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
|
|||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (editingBucket ? 'Updating...' : 'Creating...') : (editingBucket ? 'Update Bucket' : 'Create Bucket')}
|
||||
{isSubmitting ? 'Creating...' : 'Create Bucket'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import InputError from '@/components/InputError';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import { store } from '@/routes/password/confirm';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { email } from '@/routes/password';
|
|||
import { Form, Head } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
|
||||
import InputError from '@/components/InputError';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
export default function ForgotPassword({ status }: { status?: string }) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import InputError from '@/components/InputError';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import { register } from '@/routes';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import { login } from '@/routes';
|
|||
import { store } from '@/routes/register';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
|
||||
import InputError from '@/components/InputError';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
export default function Register() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { update } from '@/routes/password';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
|
||||
import InputError from '@/components/InputError';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
|
||||
interface ResetPasswordProps {
|
||||
token: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import InputError from '@/components/InputError';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import { store } from '@/routes/two-factor/login';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// Components
|
||||
import TextLink from '@/components/TextLink';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import { logout } from '@/routes';
|
||||
import { send } from '@/routes/verification';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
||||
import AppLayout from '@/layouts/AppLayout';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { dashboard } from '@/routes';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Head } from '@inertiajs/react';
|
||||
|
||||
import AppearanceTabs from '@/components/AppearanceTabs';
|
||||
import HeadingSmall from '@/components/HeadingSmall';
|
||||
import AppearanceTabs from '@/components/appearance-tabs';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
|
||||
import AppLayout from '@/layouts/AppLayout';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue