diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php new file mode 100644 index 00000000..c61d2366 --- /dev/null +++ b/app/Http/Controllers/Settings/AccountController.php @@ -0,0 +1,78 @@ +user() + ->accounts() + ->with('bank:id,name,logo') + ->orderBy('name') + ->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']); + + $banks = Bank::query() + ->where(function ($query) { + $query->where('user_id', auth()->id()) + ->orWhereNull('user_id'); + }) + ->orderBy('name') + ->get(['id', 'name', 'logo']); + + return Inertia::render('settings/accounts', [ + 'accounts' => $accounts, + 'banks' => $banks, + ]); + } + + /** + * Store a newly created account. + */ + public function store(StoreAccountRequest $request): RedirectResponse + { + auth()->user()->accounts()->create($request->validated()); + + return to_route('accounts.index'); + } + + /** + * Update the specified account. + */ + public function update(UpdateAccountRequest $request, Account $account): RedirectResponse + { + $this->authorize('update', $account); + + $account->update($request->validated()); + + return to_route('accounts.index'); + } + + /** + * Hard delete the specified account and cascade delete all transactions. + */ + public function destroy(Account $account): RedirectResponse + { + $this->authorize('delete', $account); + + $account->transactions()->delete(); + $account->delete(); + + return to_route('accounts.index'); + } +} diff --git a/app/Http/Requests/Settings/StoreAccountRequest.php b/app/Http/Requests/Settings/StoreAccountRequest.php new file mode 100644 index 00000000..ce1f43d1 --- /dev/null +++ b/app/Http/Requests/Settings/StoreAccountRequest.php @@ -0,0 +1,43 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string'], + 'name_iv' => ['required', 'string', 'size:16'], + 'bank_id' => ['required', 'exists:banks,id'], + 'currency_code' => [ + 'required', + 'string', + Rule::in(['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'CNY', 'INR', 'MXN']), + ], + 'type' => [ + 'required', + 'string', + Rule::in(array_map(fn ($type) => $type->value, AccountType::cases())), + ], + ]; + } +} diff --git a/app/Http/Requests/Settings/UpdateAccountRequest.php b/app/Http/Requests/Settings/UpdateAccountRequest.php new file mode 100644 index 00000000..c4ce48b2 --- /dev/null +++ b/app/Http/Requests/Settings/UpdateAccountRequest.php @@ -0,0 +1,43 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string'], + 'name_iv' => ['required', 'string', 'size:16'], + 'bank_id' => ['required', 'exists:banks,id'], + 'currency_code' => [ + 'required', + 'string', + Rule::in(['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'CNY', 'INR', 'MXN']), + ], + 'type' => [ + 'required', + 'string', + Rule::in(array_map(fn ($type) => $type->value, AccountType::cases())), + ], + ]; + } +} diff --git a/app/Policies/AccountPolicy.php b/app/Policies/AccountPolicy.php new file mode 100644 index 00000000..dc80eade --- /dev/null +++ b/app/Policies/AccountPolicy.php @@ -0,0 +1,65 @@ +id === $account->user_id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Account $account): bool + { + return $user->id === $account->user_id; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Account $account): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Account $account): bool + { + return false; + } +} diff --git a/database/migrations/2025_11_07_184056_modify_banks_table_add_user_id_and_simplify_name.php b/database/migrations/2025_11_07_184056_modify_banks_table_add_user_id_and_simplify_name.php new file mode 100644 index 00000000..7788bd1f --- /dev/null +++ b/database/migrations/2025_11_07_184056_modify_banks_table_add_user_id_and_simplify_name.php @@ -0,0 +1,33 @@ +foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('name', 255)->change(); + $table->dropColumn('name_iv'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('banks', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + $table->text('name')->change(); + $table->string('name_iv', 16); + }); + } +}; diff --git a/database/migrations/2025_11_07_185018_change_banks_logo_to_text.php b/database/migrations/2025_11_07_185018_change_banks_logo_to_text.php new file mode 100644 index 00000000..06b878f5 --- /dev/null +++ b/database/migrations/2025_11_07_185018_change_banks_logo_to_text.php @@ -0,0 +1,28 @@ +text('logo')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('banks', function (Blueprint $table) { + $table->string('logo')->nullable()->change(); + }); + } +}; diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx new file mode 100644 index 00000000..6bb2e88d --- /dev/null +++ b/resources/js/components/accounts/create-account-dialog.tsx @@ -0,0 +1,263 @@ +import { useState, useEffect } from 'react'; +import { Form } from '@inertiajs/react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + ACCOUNT_TYPES, + CURRENCY_OPTIONS, + formatAccountType, + type Bank, +} from '@/types/account'; +import { store } from '@/actions/App/Http/Controllers/Settings/AccountController'; +import { getStoredKey } from '@/lib/key-storage'; +import { importKey, encrypt, bufferToBase64 } from '@/lib/crypto'; + +interface CreateAccountDialogProps { + banks: Bank[]; +} + +export function CreateAccountDialog({ banks }: CreateAccountDialogProps) { + const [open, setOpen] = useState(false); + const [isKeyAvailable, setIsKeyAvailable] = useState(false); + + useEffect(() => { + const checkKey = () => { + const key = getStoredKey(); + setIsKeyAvailable(!!key); + }; + + checkKey(); + const interval = setInterval(checkKey, 1000); + + return () => clearInterval(interval); + }, []); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + const name = formData.get('name') as string; + const bankId = formData.get('bank_id') as string; + const type = formData.get('type') as string; + const currencyCode = formData.get('currency_code') as string; + + const keyString = getStoredKey(); + if (!keyString) { + alert('Encryption key not available. Please unlock first.'); + return; + } + + try { + const key = await importKey(keyString); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const ivString = bufferToBase64(iv); + const { encrypted } = await encrypt(name, key); + + const form = event.currentTarget; + const hiddenNameInput = form.querySelector( + 'input[name="encrypted_name"]', + ) as HTMLInputElement; + const hiddenIvInput = form.querySelector( + 'input[name="name_iv"]', + ) as HTMLInputElement; + + if (hiddenNameInput && hiddenIvInput) { + hiddenNameInput.value = encrypted; + hiddenIvInput.value = ivString; + form.requestSubmit(); + } + } catch (err) { + console.error('Encryption failed:', err); + alert('Failed to encrypt account name. Please try again.'); + } + } + + const createButton = ( + + ); + + return ( + + + {isKeyAvailable ? ( + createButton + ) : ( + + + + {createButton} + + +

+ Encryption key required. Please unlock your + data first. +

+
+
+
+ )} +
+ + + Create Account + + Add a new bank account to track your transactions. + + +
setOpen(false)} + className="space-y-4" + > + {({ errors, processing }) => ( + <> + + + +
+ + + {errors.name && ( +

+ {errors.name} +

+ )} +
+ +
+ + + {errors.bank_id && ( +

+ {errors.bank_id} +

+ )} +
+ +
+ + + {errors.type && ( +

+ {errors.type} +

+ )} +
+ +
+ + + {errors.currency_code && ( +

+ {errors.currency_code} +

+ )} +
+ +
+ + +
+ + )} +
+
+
+ ); +} + diff --git a/resources/js/components/accounts/delete-account-dialog.tsx b/resources/js/components/accounts/delete-account-dialog.tsx new file mode 100644 index 00000000..25cefaa2 --- /dev/null +++ b/resources/js/components/accounts/delete-account-dialog.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { Form } from '@inertiajs/react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { type Account } from '@/types/account'; +import { destroy } from '@/actions/App/Http/Controllers/Settings/AccountController'; + +interface DeleteAccountDialogProps { + account: Account; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DeleteAccountDialog({ + account, + open, + onOpenChange, +}: DeleteAccountDialogProps) { + const [confirmText, setConfirmText] = useState(''); + + function handleOpenChange(newOpen: boolean) { + if (!newOpen) { + setConfirmText(''); + } + onOpenChange(newOpen); + } + + const isDeleteEnabled = confirmText === 'DELETE'; + + return ( + + + + Delete Account + +

+ This action is irreversible. All transactions in + this account will also be permanently deleted. +

+

+ Type DELETE to + confirm. +

+
+
+ +
+
+ + setConfirmText(e.target.value)} + placeholder="Type DELETE" + autoComplete="off" + /> +
+ +
handleOpenChange(false)} + > + {({ processing }) => ( + + + + + )} +
+
+
+
+ ); +} + diff --git a/resources/js/components/accounts/edit-account-dialog.tsx b/resources/js/components/accounts/edit-account-dialog.tsx new file mode 100644 index 00000000..9b54bb97 --- /dev/null +++ b/resources/js/components/accounts/edit-account-dialog.tsx @@ -0,0 +1,264 @@ +import { useState, useEffect } from 'react'; +import { Form, usePage } from '@inertiajs/react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + ACCOUNT_TYPES, + CURRENCY_OPTIONS, + formatAccountType, + type Account, + type Bank, +} from '@/types/account'; +import { update } from '@/actions/App/Http/Controllers/Settings/AccountController'; +import { getStoredKey } from '@/lib/key-storage'; +import { importKey, encrypt, decrypt, bufferToBase64 } from '@/lib/crypto'; + +interface EditAccountDialogProps { + account: Account; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditAccountDialog({ + account, + open, + onOpenChange, +}: EditAccountDialogProps) { + const { banks } = usePage<{ banks: Bank[] }>().props; + const [decryptedName, setDecryptedName] = useState(''); + + useEffect(() => { + async function decryptName() { + const keyString = getStoredKey(); + if (!keyString) { + setDecryptedName('[Encrypted]'); + return; + } + + try { + const key = await importKey(keyString); + const name = await decrypt( + account.name, + key, + account.name_iv, + ); + setDecryptedName(name); + } catch (err) { + console.error('Failed to decrypt account name:', err); + setDecryptedName('[Encrypted]'); + } + } + + if (open) { + decryptName(); + } + }, [open, account]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + const name = formData.get('display_name') as string; + const bankId = formData.get('bank_id') as string; + const type = formData.get('type') as string; + const currencyCode = formData.get('currency_code') as string; + + const keyString = getStoredKey(); + if (!keyString) { + alert('Encryption key not available. Please unlock first.'); + return; + } + + try { + const key = await importKey(keyString); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const ivString = bufferToBase64(iv); + const { encrypted } = await encrypt(name, key); + + const form = event.currentTarget; + const hiddenNameInput = form.querySelector( + 'input[name="name"]', + ) as HTMLInputElement; + const hiddenIvInput = form.querySelector( + 'input[name="name_iv"]', + ) as HTMLInputElement; + + if (hiddenNameInput && hiddenIvInput) { + hiddenNameInput.value = encrypted; + hiddenIvInput.value = ivString; + form.requestSubmit(); + } + } catch (err) { + console.error('Encryption failed:', err); + alert('Failed to encrypt account name. Please try again.'); + } + } + + return ( + + + + Edit Account + + Update the account information. + + +
onOpenChange(false)} + className="space-y-4" + > + {({ errors, processing }) => ( + <> + + + +
+ + + {errors.name && ( +

+ {errors.name} +

+ )} +
+ +
+ + + {errors.bank_id && ( +

+ {errors.bank_id} +

+ )} +
+ +
+ + + {errors.type && ( +

+ {errors.type} +

+ )} +
+ +
+ + + {errors.currency_code && ( +

+ {errors.currency_code} +

+ )} +
+ +
+ + +
+ + )} +
+
+
+ ); +} + diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index a8a7f478..32b3f373 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { cn, isSameUrl, resolveUrl } from '@/lib/utils'; import { edit as editAppearance } from '@/routes/appearance'; +import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController'; import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController'; import { edit as editAccount } from '@/routes/account'; import { edit as editDeleteAccount } from '@/routes/delete-account'; @@ -12,10 +13,15 @@ import { type PropsWithChildren } from 'react'; const sidebarNavItems: NavItem[] = [ { - title: 'Account', + title: 'User account', href: editAccount(), icon: null, }, + { + title: 'Bank accounts', + href: accountsIndex(), + icon: null, + }, { title: 'Categories', href: categoriesIndex(), diff --git a/resources/js/pages/settings/accounts.tsx b/resources/js/pages/settings/accounts.tsx new file mode 100644 index 00000000..1c2697ae --- /dev/null +++ b/resources/js/pages/settings/accounts.tsx @@ -0,0 +1,334 @@ +import { useState, useEffect } from 'react'; +import { Head } from '@inertiajs/react'; +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState, +} from '@tanstack/react-table'; +import { ArrowUpDown, MoreHorizontal } from 'lucide-react'; + +import AppLayout from '@/layouts/app-layout'; +import SettingsLayout from '@/layouts/settings/layout'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Badge } from '@/components/ui/badge'; +import HeadingSmall from '@/components/heading-small'; +import { CreateAccountDialog } from '@/components/accounts/create-account-dialog'; +import { EditAccountDialog } from '@/components/accounts/edit-account-dialog'; +import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog'; +import { + type Account, + type Bank, + formatAccountType, +} from '@/types/account'; +import { type BreadcrumbItem } from '@/types'; +import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController'; +import { getStoredKey } from '@/lib/key-storage'; +import { importKey, decrypt } from '@/lib/crypto'; + +interface AccountsProps { + accounts: Account[]; + banks: Bank[]; +} + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Accounts settings', + href: accountsIndex(), + }, +]; + +function AccountActions({ account }: { account: Account }) { + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + return ( + <> + + + + + + Actions + setEditOpen(true)}> + Edit + + setDeleteOpen(true)} + className="text-red-600" + > + Delete + + + + + + + + ); +} + +export default function Accounts({ accounts, banks }: AccountsProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState( + {}, + ); + const [decryptedNames, setDecryptedNames] = useState< + Record + >({}); + + useEffect(() => { + async function decryptAccountNames() { + const keyString = getStoredKey(); + if (!keyString) return; + + try { + const key = await importKey(keyString); + const decrypted: Record = {}; + + for (const account of accounts) { + try { + const name = await decrypt( + account.name, + key, + account.name_iv, + ); + decrypted[account.id] = name; + } catch (err) { + console.error( + `Failed to decrypt account ${account.id}:`, + err, + ); + decrypted[account.id] = '[Encrypted]'; + } + } + + setDecryptedNames(decrypted); + } catch (err) { + console.error('Failed to decrypt account names:', err); + } + } + + decryptAccountNames(); + }, [accounts]); + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const name = decryptedNames[row.original.id] || '[Encrypted]'; + return
{name}
; + }, + }, + { + accessorKey: 'bank', + header: 'Bank', + cell: ({ row }) => { + const bank = row.original.bank; + return ( +
+ {bank.logo ? ( + {bank.name} + ) : ( +
+ )} + {bank.name} +
+ ); + }, + }, + { + accessorKey: 'type', + header: 'Type', + cell: ({ row }) => { + return ( + + {formatAccountType(row.getValue('type'))} + + ); + }, + }, + { + accessorKey: 'currency_code', + header: 'Currency', + cell: ({ row }) => { + return ( +
+ {row.getValue('currency_code')} +
+ ); + }, + }, + { + id: 'actions', + enableHiding: false, + cell: ({ row }) => , + }, + ]; + + const table = useReactTable({ + data: accounts, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + state: { + sorting, + columnFilters, + columnVisibility, + }, + }); + + return ( + + Accounts + + } + > + + + +
+ + +
+
+ + table + .getColumn('name') + ?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> + +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef + .header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No accounts found. + + + )} + +
+
+
+
+
+
+ ); +} + diff --git a/resources/js/types/account.ts b/resources/js/types/account.ts new file mode 100644 index 00000000..6bff5130 --- /dev/null +++ b/resources/js/types/account.ts @@ -0,0 +1,51 @@ +export const ACCOUNT_TYPES = [ + 'checking', + 'credit_card', + 'loan', + 'savings', + 'others', +] as const; + +export type AccountType = (typeof ACCOUNT_TYPES)[number]; + +export const CURRENCY_OPTIONS = [ + 'USD', + 'EUR', + 'GBP', + 'JPY', + 'CHF', + 'CAD', + 'AUD', + 'CNY', + 'INR', + 'MXN', +] as const; + +export type CurrencyCode = (typeof CURRENCY_OPTIONS)[number]; + +export interface Bank { + id: number; + name: string; + logo: string | null; +} + +export interface Account { + id: number; + name: string; + name_iv: string; + bank: Bank; + type: AccountType; + currency_code: CurrencyCode; +} + +export function formatAccountType(type: AccountType): string { + const typeMap: Record = { + checking: 'Checking', + credit_card: 'Credit Card', + loan: 'Loan', + savings: 'Savings', + others: 'Others', + }; + return typeMap[type] || type; +} + diff --git a/routes/settings.php b/routes/settings.php index 11bfff7f..1fd25a37 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -1,5 +1,6 @@ group(function () { ->middleware('throttle:6,1') ->name('user-password.update'); + Route::get('settings/accounts', [AccountController::class, 'index'])->name('accounts.index'); + Route::post('settings/accounts', [AccountController::class, 'store'])->name('accounts.store'); + Route::patch('settings/accounts/{account}', [AccountController::class, 'update'])->name('accounts.update'); + Route::delete('settings/accounts/{account}', [AccountController::class, 'destroy'])->name('accounts.destroy'); + Route::get('settings/categories', [CategoryController::class, 'index'])->name('categories.index'); Route::post('settings/categories', [CategoryController::class, 'store'])->name('categories.store'); Route::patch('settings/categories/{category}', [CategoryController::class, 'update'])->name('categories.update'); diff --git a/tests/Feature/Settings/AccountTest.php b/tests/Feature/Settings/AccountTest.php new file mode 100644 index 00000000..3cbef57b --- /dev/null +++ b/tests/Feature/Settings/AccountTest.php @@ -0,0 +1,235 @@ +user = User::factory()->create(); + $this->bank = Bank::factory()->create(); +}); + +it('displays user accounts on index page', function () { + actingAs($this->user); + + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $response = $this->get(route('accounts.index')); + + $response->assertSuccessful(); + $response->assertInertia(fn ($page) => $page + ->component('settings/accounts') + ->has('accounts', 1) + ->has('banks')); +}); + +it('can create a new account', function () { + actingAs($this->user); + + $data = [ + 'name' => 'encrypted_name_value', + 'name_iv' => 'abcd1234efgh5678', + 'bank_id' => $this->bank->id, + 'currency_code' => 'USD', + 'type' => AccountType::Checking->value, + ]; + + $response = $this->post(route('accounts.store'), $data); + + $response->assertRedirect(route('accounts.index')); + assertDatabaseHas('accounts', [ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + 'name' => 'encrypted_name_value', + 'name_iv' => 'abcd1234efgh5678', + 'currency_code' => 'USD', + 'type' => AccountType::Checking->value, + ]); +}); + +it('validates required fields when creating account', function () { + actingAs($this->user); + + $response = $this->post(route('accounts.store'), []); + + $response->assertSessionHasErrors(['name', 'name_iv', 'bank_id', 'currency_code', 'type']); +}); + +it('validates name_iv must be exactly 16 characters', function () { + actingAs($this->user); + + $response = $this->post(route('accounts.store'), [ + 'name' => 'encrypted_name', + 'name_iv' => 'short', + 'bank_id' => $this->bank->id, + 'currency_code' => 'USD', + 'type' => AccountType::Checking->value, + ]); + + $response->assertSessionHasErrors(['name_iv']); +}); + +it('validates currency_code must be in allowed list', function () { + actingAs($this->user); + + $response = $this->post(route('accounts.store'), [ + 'name' => 'encrypted_name', + 'name_iv' => 'abcd1234efgh5678', + 'bank_id' => $this->bank->id, + 'currency_code' => 'INVALID', + 'type' => AccountType::Checking->value, + ]); + + $response->assertSessionHasErrors(['currency_code']); +}); + +it('validates type must be valid AccountType', function () { + actingAs($this->user); + + $response = $this->post(route('accounts.store'), [ + 'name' => 'encrypted_name', + 'name_iv' => 'abcd1234efgh5678', + 'bank_id' => $this->bank->id, + 'currency_code' => 'USD', + 'type' => 'invalid_type', + ]); + + $response->assertSessionHasErrors(['type']); +}); + +it('can update an account', function () { + actingAs($this->user); + + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $newBank = Bank::factory()->create(); + + $data = [ + 'name' => 'updated_encrypted_name', + 'name_iv' => 'newiv123456789ab', + 'bank_id' => $newBank->id, + 'currency_code' => 'EUR', + 'type' => AccountType::Savings->value, + ]; + + $response = $this->patch(route('accounts.update', $account), $data); + + $response->assertRedirect(route('accounts.index')); + assertDatabaseHas('accounts', [ + 'id' => $account->id, + 'name' => 'updated_encrypted_name', + 'name_iv' => 'newiv123456789ab', + 'bank_id' => $newBank->id, + 'currency_code' => 'EUR', + 'type' => AccountType::Savings->value, + ]); +}); + +it('prevents updating another users account', function () { + $otherUser = User::factory()->create(); + $account = Account::factory()->create([ + 'user_id' => $otherUser->id, + 'bank_id' => $this->bank->id, + ]); + + actingAs($this->user); + + $response = $this->patch(route('accounts.update', $account), [ + 'name' => 'hacked_name', + 'name_iv' => 'abcd1234efgh5678', + 'bank_id' => $this->bank->id, + 'currency_code' => 'USD', + 'type' => AccountType::Checking->value, + ]); + + $response->assertForbidden(); +}); + +it('can delete an account', function () { + actingAs($this->user); + + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $response = $this->delete(route('accounts.destroy', $account)); + + $response->assertRedirect(route('accounts.index')); + expect(Account::withTrashed()->find($account->id))->not->toBeNull(); + expect(Account::withTrashed()->find($account->id)->deleted_at)->not->toBeNull(); + expect(Account::find($account->id))->toBeNull(); +}); + +it('deletes all transactions when deleting account', function () { + actingAs($this->user); + + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $transaction1 = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + ]); + + $transaction2 = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + ]); + + $response = $this->delete(route('accounts.destroy', $account)); + + $response->assertRedirect(route('accounts.index')); + expect(Account::find($account->id))->toBeNull(); + expect(Account::withTrashed()->find($account->id))->not->toBeNull(); + expect(Transaction::find($transaction1->id))->toBeNull(); + expect(Transaction::find($transaction2->id))->toBeNull(); +}); + +it('prevents deleting another users account', function () { + $otherUser = User::factory()->create(); + $account = Account::factory()->create([ + 'user_id' => $otherUser->id, + 'bank_id' => $this->bank->id, + ]); + + actingAs($this->user); + + $response = $this->delete(route('accounts.destroy', $account)); + + $response->assertForbidden(); + assertDatabaseHas('accounts', ['id' => $account->id]); +}); + +it('only shows banks owned by user or global banks', function () { + Bank::query()->delete(); + + actingAs($this->user); + + $userBank = Bank::factory()->create(['user_id' => $this->user->id]); + $globalBank = Bank::factory()->create(['user_id' => null]); + $otherUserBank = Bank::factory()->create(['user_id' => User::factory()->create()->id]); + + $response = $this->get(route('accounts.index')); + + $response->assertSuccessful(); + $response->assertInertia(fn ($page) => $page + ->component('settings/accounts') + ->has('banks', 2) + ->where('banks.0.id', fn ($id) => in_array($id, [$userBank->id, $globalBank->id])) + ->where('banks.1.id', fn ($id) => in_array($id, [$userBank->id, $globalBank->id]))); +});