Banks accounts

This commit is contained in:
Víctor Falcón 2025-11-07 19:19:29 +00:00
parent c6d8d7b464
commit 59bc62f976
14 changed files with 1547 additions and 1 deletions

View File

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreAccountRequest;
use App\Http\Requests\Settings\UpdateAccountRequest;
use App\Models\Account;
use App\Models\Bank;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class AccountController extends Controller
{
use AuthorizesRequests;
/**
* Show the user's accounts settings page.
*/
public function index(): Response
{
$accounts = auth()->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');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Settings;
use App\Enums\AccountType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreAccountRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|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())),
],
];
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Settings;
use App\Enums\AccountType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateAccountRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|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())),
],
];
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Models\Account;
use App\Models\User;
class AccountPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Account $account): bool
{
return false;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Account $account): bool
{
return $user->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;
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('banks', function (Blueprint $table) {
$table->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);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('banks', function (Blueprint $table) {
$table->text('logo')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('banks', function (Blueprint $table) {
$table->string('logo')->nullable()->change();
});
}
};

View File

@ -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<HTMLFormElement>) {
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 = (
<Button disabled={!isKeyAvailable}>Create Account</Button>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{isKeyAvailable ? (
createButton
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{createButton}
</TooltipTrigger>
<TooltipContent>
<p>
Encryption key required. Please unlock your
data first.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Account</DialogTitle>
<DialogDescription>
Add a new bank account to track your transactions.
</DialogDescription>
</DialogHeader>
<Form
{...store.form()}
onSubmit={handleSubmit}
onSuccess={() => setOpen(false)}
className="space-y-4"
>
{({ errors, processing }) => (
<>
<input
type="hidden"
name="name"
id="encrypted_name"
/>
<input
type="hidden"
name="name_iv"
id="name_iv"
/>
<div className="space-y-2">
<Label htmlFor="display_name">Name</Label>
<Input
id="display_name"
name="display_name"
placeholder="Account name"
required
/>
{errors.name && (
<p className="text-sm text-red-500">
{errors.name}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="bank_id">Bank</Label>
<Select name="bank_id" required>
<SelectTrigger>
<SelectValue placeholder="Select a bank" />
</SelectTrigger>
<SelectContent>
{banks.map((bank) => (
<SelectItem
key={bank.id}
value={bank.id.toString()}
>
<div className="flex items-center gap-2">
{bank.logo ? (
<img
src={bank.logo}
alt={bank.name}
className="h-4 w-4 rounded object-contain"
/>
) : (
<div className="h-4 w-4 rounded bg-muted" />
)}
<span>{bank.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.bank_id && (
<p className="text-sm text-red-500">
{errors.bank_id}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="type">Account Type</Label>
<Select name="type" required>
<SelectTrigger>
<SelectValue placeholder="Select account type" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{formatAccountType(type)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.type && (
<p className="text-sm text-red-500">
{errors.type}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency_code">Currency</Label>
<Select name="currency_code" required>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
{CURRENCY_OPTIONS.map((currency) => (
<SelectItem
key={currency}
value={currency}
>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.currency_code && (
<p className="text-sm text-red-500">
{errors.currency_code}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={processing}
>
Cancel
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create'}
</Button>
</div>
</>
)}
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Delete Account</DialogTitle>
<DialogDescription className="space-y-2">
<p>
This action is irreversible. All transactions in
this account will also be permanently deleted.
</p>
<p className="font-semibold">
Type <span className="text-red-600">DELETE</span> to
confirm.
</p>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="confirm">Confirmation</Label>
<Input
id="confirm"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="Type DELETE"
autoComplete="off"
/>
</div>
<Form
{...destroy.form.delete(account.id)}
onSuccess={() => handleOpenChange(false)}
>
{({ processing }) => (
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={processing}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
disabled={processing || !isDeleteEnabled}
>
{processing ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
)}
</Form>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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<HTMLFormElement>) {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Account</DialogTitle>
<DialogDescription>
Update the account information.
</DialogDescription>
</DialogHeader>
<Form
{...update.form.patch(account.id)}
onSubmit={handleSubmit}
onSuccess={() => onOpenChange(false)}
className="space-y-4"
>
{({ errors, processing }) => (
<>
<input type="hidden" name="name" id="name" />
<input
type="hidden"
name="name_iv"
id="name_iv"
/>
<div className="space-y-2">
<Label htmlFor="display_name">Name</Label>
<Input
id="display_name"
name="display_name"
defaultValue={decryptedName}
placeholder="Account name"
required
/>
{errors.name && (
<p className="text-sm text-red-500">
{errors.name}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="bank_id">Bank</Label>
<Select
name="bank_id"
defaultValue={account.bank.id.toString()}
required
>
<SelectTrigger>
<SelectValue placeholder="Select a bank" />
</SelectTrigger>
<SelectContent>
{banks.map((bank) => (
<SelectItem
key={bank.id}
value={bank.id.toString()}
>
<div className="flex items-center gap-2">
{bank.logo ? (
<img
src={bank.logo}
alt={bank.name}
className="h-4 w-4 rounded object-contain"
/>
) : (
<div className="h-4 w-4 rounded bg-muted" />
)}
<span>{bank.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.bank_id && (
<p className="text-sm text-red-500">
{errors.bank_id}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="type">Account Type</Label>
<Select
name="type"
defaultValue={account.type}
required
>
<SelectTrigger>
<SelectValue placeholder="Select account type" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{formatAccountType(type)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.type && (
<p className="text-sm text-red-500">
{errors.type}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency_code">Currency</Label>
<Select
name="currency_code"
defaultValue={account.currency_code}
required
>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
{CURRENCY_OPTIONS.map((currency) => (
<SelectItem
key={currency}
value={currency}
>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.currency_code && (
<p className="text-sm text-red-500">
{errors.currency_code}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
>
Cancel
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Updating...' : 'Update'}
</Button>
</div>
</>
)}
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -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(),

View File

@ -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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setEditOpen(true)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
className="text-red-600"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditAccountDialog
account={account}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteAccountDialog
account={account}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</>
);
}
export default function Accounts({ accounts, banks }: AccountsProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
const [decryptedNames, setDecryptedNames] = useState<
Record<number, string>
>({});
useEffect(() => {
async function decryptAccountNames() {
const keyString = getStoredKey();
if (!keyString) return;
try {
const key = await importKey(keyString);
const decrypted: Record<number, string> = {};
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<Account>[] = [
{
accessorKey: 'name',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const name = decryptedNames[row.original.id] || '[Encrypted]';
return <div className="pl-3 font-medium">{name}</div>;
},
},
{
accessorKey: 'bank',
header: 'Bank',
cell: ({ row }) => {
const bank = row.original.bank;
return (
<div className="flex items-center gap-2">
{bank.logo ? (
<img
src={bank.logo}
alt={bank.name}
className="h-6 w-6 rounded object-contain"
/>
) : (
<div className="h-6 w-6 rounded bg-muted" />
)}
<span>{bank.name}</span>
</div>
);
},
},
{
accessorKey: 'type',
header: 'Type',
cell: ({ row }) => {
return (
<Badge variant="outline">
{formatAccountType(row.getValue('type'))}
</Badge>
);
},
},
{
accessorKey: 'currency_code',
header: 'Currency',
cell: ({ row }) => {
return (
<div className="font-medium">
{row.getValue('currency_code')}
</div>
);
},
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => <AccountActions account={row.original} />,
},
];
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 (
<AppLayout
breadcrumbs={breadcrumbs}
header={
<h2 className="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Accounts
</h2>
}
>
<Head title="Accounts settings" />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall
title="Accounts settings"
description="Manage your bank accounts"
/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Input
placeholder="Filter accounts..."
value={
(table
.getColumn('name')
?.getFilterValue() as string) ?? ''
}
onChange={(event) =>
table
.getColumn('name')
?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<CreateAccountDialog banks={banks} />
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef
.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() && 'selected'
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No accounts found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
</SettingsLayout>
</AppLayout>
);
}

View File

@ -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<AccountType, string> = {
checking: 'Checking',
credit_card: 'Credit Card',
loan: 'Loan',
savings: 'Savings',
others: 'Others',
};
return typeMap[type] || type;
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Settings\AccountController;
use App\Http\Controllers\Settings\CategoryController;
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
@ -21,6 +22,11 @@ Route::middleware('auth')->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');

View File

@ -0,0 +1,235 @@
<?php
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Transaction;
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();
$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])));
});