From bec18b925b923a771f6bcb7b1317ff266458b583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sat, 15 Nov 2025 20:27:11 +0100 Subject: [PATCH] Add account balances --- .cursor/worktrees.json | 1 + .../Controllers/AccountBalanceController.php | 38 ++ .../Sync/AccountBalanceSyncController.php | 73 ++++ .../Requests/StoreAccountBalanceRequest.php | 35 ++ .../UpdateCurrentAccountBalanceRequest.php | 28 ++ app/Models/Account.php | 5 + app/Models/AccountBalance.php | 37 ++ bun.lock | 7 + database/factories/AccountBalanceFactory.php | 25 ++ ...5_172640_create_account_balances_table.php | 32 ++ package.json | 1 + .../transactions/import-step-mapping.tsx | 39 +- .../import-transactions-drawer.tsx | 360 +++++++++++++----- resources/js/components/ui/progress.tsx | 27 ++ resources/js/lib/dexie-db.ts | 6 +- resources/js/lib/file-parser.ts | 22 ++ resources/js/services/account-balance-sync.ts | 127 ++++++ resources/js/types/account.ts | 9 + resources/js/types/import.ts | 2 + routes/web.php | 6 + .../Feature/AccountBalanceControllerTest.php | 105 +++++ .../AccountBalanceSyncControllerTest.php | 187 +++++++++ 22 files changed, 1066 insertions(+), 106 deletions(-) create mode 100644 app/Http/Controllers/AccountBalanceController.php create mode 100644 app/Http/Controllers/Sync/AccountBalanceSyncController.php create mode 100644 app/Http/Requests/StoreAccountBalanceRequest.php create mode 100644 app/Http/Requests/UpdateCurrentAccountBalanceRequest.php create mode 100644 app/Models/AccountBalance.php create mode 100644 database/factories/AccountBalanceFactory.php create mode 100644 database/migrations/2025_11_15_172640_create_account_balances_table.php create mode 100644 resources/js/components/ui/progress.tsx create mode 100644 resources/js/services/account-balance-sync.ts create mode 100644 tests/Feature/AccountBalanceControllerTest.php create mode 100644 tests/Feature/AccountBalanceSyncControllerTest.php diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json index 155283a7..17102772 100644 --- a/.cursor/worktrees.json +++ b/.cursor/worktrees.json @@ -1,5 +1,6 @@ { "setup-worktree": [ + "cp $ROOT_WORKTREE_PATH/.env .env", "composer install", "bun install" ] diff --git a/app/Http/Controllers/AccountBalanceController.php b/app/Http/Controllers/AccountBalanceController.php new file mode 100644 index 00000000..319192c0 --- /dev/null +++ b/app/Http/Controllers/AccountBalanceController.php @@ -0,0 +1,38 @@ +authorize('update', $account); + + $today = now()->toDateString(); + + $balance = AccountBalance::updateOrCreate( + [ + 'account_id' => $account->id, + 'balance_date' => $today, + ], + [ + 'balance' => $request->validated()['balance'], + ] + ); + + return response()->json([ + 'data' => $balance, + ]); + } +} diff --git a/app/Http/Controllers/Sync/AccountBalanceSyncController.php b/app/Http/Controllers/Sync/AccountBalanceSyncController.php new file mode 100644 index 00000000..3c97c8bb --- /dev/null +++ b/app/Http/Controllers/Sync/AccountBalanceSyncController.php @@ -0,0 +1,73 @@ +whereHas('account', function ($q) use ($request) { + $q->where('user_id', $request->user()->id); + }); + + if ($request->has('since')) { + $query->where('updated_at', '>', $request->input('since')); + } + + $balances = $query + ->orderBy('balance_date', 'desc') + ->orderBy('updated_at', 'desc') + ->get(); + + return response()->json([ + 'data' => $balances, + ]); + } + + public function store(StoreAccountBalanceRequest $request): JsonResponse + { + $data = $request->validated(); + + $balance = new AccountBalance([ + 'account_id' => $data['account_id'], + 'balance_date' => $data['balance_date'], + 'balance' => $data['balance'], + ]); + + if (isset($data['id'])) { + $balance->id = $data['id']; + $balance->exists = false; + } + + $balance->save(); + + return response()->json([ + 'data' => $balance, + ], 201); + } + + public function update(StoreAccountBalanceRequest $request, AccountBalance $accountBalance): JsonResponse + { + $accountBalance->account->loadMissing('user'); + + if ($accountBalance->account->user_id !== $request->user()->id) { + abort(403, 'Unauthorized'); + } + + $data = $request->validated(); + unset($data['id']); + + $accountBalance->update($data); + + return response()->json([ + 'data' => $accountBalance->fresh(), + ]); + } +} diff --git a/app/Http/Requests/StoreAccountBalanceRequest.php b/app/Http/Requests/StoreAccountBalanceRequest.php new file mode 100644 index 00000000..736a3160 --- /dev/null +++ b/app/Http/Requests/StoreAccountBalanceRequest.php @@ -0,0 +1,35 @@ + ['sometimes', 'uuid'], + 'account_id' => ['required', 'exists:accounts,id'], + 'balance_date' => ['required', 'date'], + 'balance' => ['required', 'integer'], + ]; + } + + public function messages(): array + { + return [ + 'account_id.required' => 'The account is required.', + 'account_id.exists' => 'The selected account does not exist.', + 'balance_date.required' => 'The balance date is required.', + 'balance_date.date' => 'The balance date must be a valid date.', + 'balance.required' => 'The balance is required.', + 'balance.integer' => 'The balance must be an integer.', + ]; + } +} diff --git a/app/Http/Requests/UpdateCurrentAccountBalanceRequest.php b/app/Http/Requests/UpdateCurrentAccountBalanceRequest.php new file mode 100644 index 00000000..a8033a8e --- /dev/null +++ b/app/Http/Requests/UpdateCurrentAccountBalanceRequest.php @@ -0,0 +1,28 @@ + ['required', 'integer'], + ]; + } + + public function messages(): array + { + return [ + 'balance.required' => 'The balance is required.', + 'balance.integer' => 'The balance must be an integer.', + ]; + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index fb247581..b57d767f 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -44,4 +44,9 @@ class Account extends Model { return $this->hasMany(Transaction::class); } + + public function balances(): HasMany + { + return $this->hasMany(AccountBalance::class); + } } diff --git a/app/Models/AccountBalance.php b/app/Models/AccountBalance.php new file mode 100644 index 00000000..11bdb597 --- /dev/null +++ b/app/Models/AccountBalance.php @@ -0,0 +1,37 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'account_id', + 'balance_date', + 'balance', + ]; + + protected function casts(): array + { + return [ + 'balance_date' => 'date', + ]; + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + public function newUniqueId(): string + { + return (string) \Illuminate\Support\Str::uuid7(); + } +} diff --git a/bun.lock b/bun.lock index 4580c485..97597355 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", @@ -281,6 +282,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="], + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], @@ -1127,6 +1130,10 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], diff --git a/database/factories/AccountBalanceFactory.php b/database/factories/AccountBalanceFactory.php new file mode 100644 index 00000000..6ce8d962 --- /dev/null +++ b/database/factories/AccountBalanceFactory.php @@ -0,0 +1,25 @@ + + */ +class AccountBalanceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'account_id' => \App\Models\Account::factory(), + 'balance_date' => fake()->date(), + 'balance' => fake()->numberBetween(100000, 10000000), + ]; + } +} diff --git a/database/migrations/2025_11_15_172640_create_account_balances_table.php b/database/migrations/2025_11_15_172640_create_account_balances_table.php new file mode 100644 index 00000000..59727064 --- /dev/null +++ b/database/migrations/2025_11_15_172640_create_account_balances_table.php @@ -0,0 +1,32 @@ +char('id', 36)->primary(); + $table->foreignId('account_id')->constrained()->onDelete('cascade'); + $table->date('balance_date'); + $table->bigInteger('balance'); + $table->timestamps(); + + $table->unique(['account_id', 'balance_date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('account_balances'); + } +}; diff --git a/package.json b/package.json index e7022547..3cf1b501 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", diff --git a/resources/js/components/transactions/import-step-mapping.tsx b/resources/js/components/transactions/import-step-mapping.tsx index b292fd9f..7d6d0e30 100644 --- a/resources/js/components/transactions/import-step-mapping.tsx +++ b/resources/js/components/transactions/import-step-mapping.tsx @@ -97,9 +97,9 @@ export function ImportStepMapping({ - {columnOptions.map((option) => ( + {columnOptions.map((option, index) => (
@@ -125,9 +125,9 @@ export function ImportStepMapping({ - {columnOptions.map((option) => ( + {columnOptions.map((option, index) => (
@@ -153,9 +153,36 @@ export function ImportStepMapping({ - {columnOptions.map((option) => ( + {columnOptions.map((option, index) => ( + {option.label} + + ))} + + +
+ +
+ +