feat(accounts): allow setting initial balance when creating balance-tracking accounts (#239)

## Summary

- Adds an optional balance input field to the account creation modal for
**investment**, **loan**, **retirement**, and **savings** account types.
- When a balance is provided, an `AccountBalance` record is created for
today's date upon account creation.
- The balance label adapts to the account type (e.g., "Owed Amount" for
loans, "Balance" for others).

## Changes

- **`account-form.tsx`** — Shows `AmountInput` when a balance-tracking
type is selected and currency is set.
- **`create-account-dialog.tsx`** — Passes `balance` in the POST payload
when present.
- **`StoreAccountRequest.php`** — Adds optional `balance` (nullable
integer) validation.
- **`AccountController::store`** — Creates an `AccountBalance` record
when balance is provided.
- **`AccountTest.php`** — 3 new tests covering creation with balance,
without balance, and invalid balance validation.

## Screenshots
<img width="1017" height="649" alt="image"
src="https://github.com/user-attachments/assets/4bf1530e-0faf-45a4-a3d1-d0ec49d5b412"
/>
This commit is contained in:
Víctor Falcón 2026-03-20 10:51:42 +00:00 committed by GitHub
parent f140b5df7f
commit 7a056213cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 126 additions and 3 deletions

View File

@ -38,12 +38,23 @@ class AccountController extends Controller
public function store(StoreAccountRequest $request): RedirectResponse|JsonResponse
{
$user = auth()->user();
$validated = $request->validated();
$balance = $validated['balance'] ?? null;
unset($validated['balance']);
$account = $user->accounts()->create([
...$request->validated(),
...$validated,
'encrypted' => false,
'name_iv' => null,
]);
if ($balance !== null) {
$account->balances()->create([
'balance_date' => now()->toDateString(),
'balance' => $balance,
]);
}
// Set user's currency_code from first account
if ($user->accounts()->count() === 1) {
$user->update(['currency_code' => $account->currency_code]);
@ -53,7 +64,7 @@ class AccountController extends Controller
return response()->json($account, 201);
}
return to_route('accounts.index');
return back();
}
/**

View File

@ -37,6 +37,7 @@ class StoreAccountRequest extends FormRequest
'string',
Rule::in(array_map(fn ($type) => $type->value, AccountType::cases())),
],
'balance' => ['nullable', 'integer'],
];
}
}

View File

@ -838,6 +838,7 @@
"Only you know this password. It never leaves your device.": "Solo t\u00fa conoces esta contrase\u00f1a. Nunca sale de tu dispositivo.",
"Open menu": "Abrir men\u00fa",
"Open source": "C\u00f3digo abierto",
"Optional. Set the current balance for this account.": "Opcional. Establece el balance actual de esta cuenta.",
"Or, return to": "O, regresar a",
"Organize financial data with categories and custom labels": "Organiza datos financieros con categor\u00edas y etiquetas personalizadas",
"Organize financial data with categories and\\n custom labels": "Organiza los datos financieros con categor\u00edas y etiquetas personalizadas",

View File

@ -1,3 +1,4 @@
import { AmountInput } from '@/components/ui/amount-input';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
@ -10,6 +11,7 @@ import {
import {
ACCOUNT_TYPES,
CURRENCY_OPTIONS,
balanceTermCapitalized,
formatAccountType,
type AccountType,
type Bank,
@ -20,12 +22,20 @@ import { useCallback, useEffect, useState } from 'react';
import { BankCombobox } from './bank-combobox';
import { CustomBankData, CustomBankForm } from './custom-bank-form';
const BALANCE_ACCOUNT_TYPES: AccountType[] = [
'investment',
'loan',
'retirement',
'savings',
];
export interface AccountFormData {
displayName: string;
bankId: number | null;
type: AccountType | null;
currencyCode: CurrencyCode | null;
customBank: CustomBankData | null;
balance: number | null;
}
interface AccountFormProps {
@ -65,6 +75,10 @@ export function AccountForm({
const [customBankData, setCustomBankData] = useState<CustomBankData>(
initialCustomBankData,
);
const [balance, setBalance] = useState<number | null>(null);
const showBalanceField =
selectedType !== null && BALANCE_ACCOUNT_TYPES.includes(selectedType);
useEffect(() => {
onChange({
@ -73,6 +87,7 @@ export function AccountForm({
type: selectedType,
currencyCode: selectedCurrency,
customBank: isCreatingCustomBank ? customBankData : null,
balance: showBalanceField ? balance : null,
});
}, [
displayName,
@ -81,6 +96,8 @@ export function AccountForm({
selectedCurrency,
isCreatingCustomBank,
customBankData,
balance,
showBalanceField,
onChange,
]);
@ -215,6 +232,27 @@ export function AccountForm({
</Select>
</div>
</div>
{showBalanceField && selectedCurrency && (
<div className="space-y-2">
<Label htmlFor="balance">
{balanceTermCapitalized(selectedType!)}
</Label>
<div className="mt-1">
<AmountInput
id="balance"
value={balance ?? 0}
onChange={setBalance}
currencyCode={selectedCurrency}
/>
</div>
<p className="pl-1 text-xs text-muted-foreground">
{__(
'Optional. Set the current balance for this account.',
)}
</p>
</div>
)}
</>
);
}

View File

@ -46,6 +46,7 @@ export function CreateAccountDialog({
type: null,
currencyCode: null,
customBank: null,
balance: null,
});
const handleFormChange = useCallback((data: AccountFormData) => {
@ -140,6 +141,9 @@ export function CreateAccountDialog({
bank_id: finalBankId,
type: type,
currency_code: currencyCode,
...(formDataRef.current.balance
? { balance: formDataRef.current.balance }
: {}),
},
{
onSuccess: () => {

View File

@ -8,6 +8,7 @@ use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\assertDatabaseMissing;
beforeEach(function () {
$this->user = User::factory()->create();
@ -42,7 +43,7 @@ it('can create a new account with plaintext name', function () {
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect(route('accounts.index'));
$response->assertRedirect();
assertDatabaseHas('accounts', [
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
@ -195,3 +196,70 @@ it('prevents deleting another users account', function () {
$response->assertForbidden();
assertDatabaseHas('accounts', ['id' => $account->id]);
});
it('can create an account with an initial balance', function () {
actingAs($this->user);
$data = [
'name' => 'My Savings Account',
'bank_id' => $this->bank->id,
'currency_code' => 'USD',
'type' => AccountType::Savings->value,
'balance' => 150000,
];
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect();
$account = Account::where('user_id', $this->user->id)
->where('name', 'My Savings Account')
->first();
expect($account)->not->toBeNull();
assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => now()->toDateString(),
'balance' => 150000,
]);
});
it('creates account without balance record when balance is not provided', function () {
actingAs($this->user);
$data = [
'name' => 'My Investment Account',
'bank_id' => $this->bank->id,
'currency_code' => 'USD',
'type' => AccountType::Investment->value,
];
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect();
$account = Account::where('user_id', $this->user->id)
->where('name', 'My Investment Account')
->first();
expect($account)->not->toBeNull();
assertDatabaseMissing('account_balances', [
'account_id' => $account->id,
]);
});
it('validates balance must be an integer when provided', function () {
actingAs($this->user);
$response = $this->post(route('accounts.store'), [
'name' => 'My Account',
'bank_id' => $this->bank->id,
'currency_code' => 'USD',
'type' => AccountType::Savings->value,
'balance' => 'not-a-number',
]);
$response->assertSessionHasErrors(['balance']);
});