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:
parent
f140b5df7f
commit
7a056213cf
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class StoreAccountRequest extends FormRequest
|
|||
'string',
|
||||
Rule::in(array_map(fn ($type) => $type->value, AccountType::cases())),
|
||||
],
|
||||
'balance' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue