Transactions page

This commit is contained in:
Víctor Falcón 2025-11-08 16:58:05 +00:00
parent faf6d9146e
commit 509065e28d
22 changed files with 2144 additions and 104 deletions

View File

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateTransactionRequest;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Transaction;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class TransactionController extends Controller
{
use AuthorizesRequests;
public function index(Request $request): Response
{
$user = $request->user();
$categories = Category::query()
->where('user_id', $user->id)
->orderBy('name')
->get(['id', 'name', 'icon', 'color']);
$accounts = Account::query()
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
$banks = Bank::query()
->where(function ($q) use ($user) {
$q->whereNull('user_id')
->orWhere('user_id', $user->id);
})
->orderBy('name')
->get(['id', 'name', 'logo']);
return Inertia::render('transactions/index', [
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,
]);
}
public function update(UpdateTransactionRequest $request, Transaction $transaction): JsonResponse
{
$this->authorize('update', $transaction);
$transaction->update($request->validated());
return response()->json([
'data' => $transaction->fresh(),
]);
}
public function destroy(Request $request, Transaction $transaction): JsonResponse
{
$this->authorize('delete', $transaction);
$transaction->delete();
return response()->json([
'message' => 'Transaction deleted successfully',
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTransactionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'category_id' => ['nullable', 'exists:categories,id'],
'notes' => ['nullable', 'string'],
'notes_iv' => ['nullable', 'string', 'size:16'],
];
}
public function messages(): array
{
return [
'category_id.exists' => 'The selected category does not exist.',
'notes_iv.size' => 'The notes IV must be exactly 16 characters.',
];
}
}

View File

@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Transaction extends Model
{
/** @use HasFactory<\Database\Factories\TransactionFactory> */
use HasFactory, HasUuids;
use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',

View File

@ -0,0 +1,44 @@
<?php
namespace App\Policies;
use App\Models\Transaction;
use App\Models\User;
class TransactionPolicy
{
public function viewAny(User $user): bool
{
return false;
}
public function view(User $user, Transaction $transaction): bool
{
return false;
}
public function create(User $user): bool
{
return false;
}
public function update(User $user, Transaction $transaction): bool
{
return $user->id === $transaction->user_id;
}
public function delete(User $user, Transaction $transaction): bool
{
return $user->id === $transaction->user_id;
}
public function restore(User $user, Transaction $transaction): bool
{
return false;
}
public function forceDelete(User $user, Transaction $transaction): bool
{
return false;
}
}

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('transactions', function (Blueprint $table) {
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View File

@ -30,9 +30,10 @@ import { UserMenuContent } from '@/components/user-menu-content';
import { useInitials } from '@/hooks/use-initials';
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
import { dashboard } from '@/routes';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
import { BookOpen, Folder, LayoutGrid, Menu, Receipt, Search } from 'lucide-react';
import AppLogo from './app-logo';
import AppLogoIcon from './app-logo-icon';
@ -42,6 +43,11 @@ const mainNavItems: NavItem[] = [
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Transactions',
href: transactionsIndex(),
icon: Receipt,
},
];
const rightNavItems: NavItem[] = [

View File

@ -11,9 +11,10 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { type NavItem } from '@/types';
import { Link } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
import { BookOpen, Folder, LayoutGrid, Receipt } from 'lucide-react';
import AppLogo from './app-logo';
const mainNavItems: NavItem[] = [
@ -22,6 +23,11 @@ const mainNavItems: NavItem[] = [
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Transactions',
href: transactionsIndex(),
icon: Receipt,
},
];
const footerNavItems: NavItem[] = [

View File

@ -1,122 +1,138 @@
import { useState, useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { getStoredKey } from '@/lib/key-storage';
import { importKey, decrypt } from '@/lib/crypto';
type Length = number | { min: number; max: number } | null;
interface EncryptedTextProps {
encryptedText: string;
iv: string;
interval?: number;
className?: string;
fallback?: string;
length: Length;
}
const chars = "-_~`!@#$%^&*()+=[]{}|;:,.<>?";
function generateRandomChars(length: number): string {
return Array.from({ length }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('');
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function EncryptedText({
encryptedText,
iv,
interval = 30,
className = '',
fallback = '[Encrypted]',
}: EncryptedTextProps) {
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
export function EncryptedText(props: EncryptedTextProps) {
const { encryptedText, iv, className = '', length = null } = props;
const { isKeySet } = useEncryptionKey();
const [decryptedText, setDecryptedText] = useState<string | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const isFirstLoad = useRef(true);
const fallbackCharsRef = useRef<string>(generateRandomChars(fallback.length));
const [targetText, setTargetText] = useState(() => fallbackCharsRef.current);
const [outputText, setOutputText] = useState(() => fallbackCharsRef.current);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
async function decryptData() {
const keyString = getStoredKey();
if (!keyString || !isKeySet) {
setDecryptedText(null);
return;
}
try {
const key = await importKey(keyString);
const text = await decrypt(encryptedText, key, iv);
setDecryptedText(text);
} catch (err) {
console.error('Failed to decrypt text:', err);
setDecryptedText(null);
}
const maxLength = useMemo<number>(() => {
if (typeof length === 'number') {
return length;
}
decryptData();
}, [encryptedText, iv, isKeySet]);
useEffect(() => {
if (decryptedText !== null && isKeySet) {
setTargetText(decryptedText);
} else {
setTargetText(fallbackCharsRef.current);
if (length && typeof length === 'object' && 'max' in length) {
return randInt(length.min, length.max)
}
}, [decryptedText, isKeySet]);
return randInt(10, 20)
}, [length]);
const [cachedDecryption, setCachedDecryption] = useState<{ encryptedText: string; iv: string; value: string; } | null>(null);
const maskedText = useMemo(() => {
if (isKeySet && cachedDecryption?.value) {
return cachedDecryption.value;
}
return encryptedText.slice(0, maxLength);
}, [cachedDecryption?.value, encryptedText, isKeySet, maxLength]);
const [displayValue, setDisplayValue] = useState<string>(maskedText);
const intervalRef = useRef<number | null>(null);
const displayValueRef = useRef(displayValue);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isFirstLoad.current) {
setOutputText(targetText);
isFirstLoad.current = false;
if (!isKeySet) {
setCachedDecryption(null);
return;
}
if (outputText !== targetText) {
setIsAnimating(true);
let currentIndex = 0;
timer = setInterval(() => {
if (currentIndex < targetText.length) {
setOutputText(targetText.slice(0, currentIndex + 1));
currentIndex++;
} else {
clearInterval(timer);
setIsAnimating(false);
}
}, interval);
if (
cachedDecryption &&
cachedDecryption.encryptedText === encryptedText &&
cachedDecryption.iv === iv
) {
return;
}
const keyString = getStoredKey();
if (!keyString) {
setCachedDecryption(null);
return;
}
importKey(keyString)
.then((key) => decrypt(encryptedText, key, iv))
.then((value) => {
setCachedDecryption({ encryptedText, iv, value });
})
.catch(() => {
setCachedDecryption(null);
});
}, [encryptedText, iv, isKeySet]);
useEffect(() => {
displayValueRef.current = displayValue;
}, [displayValue]);
useEffect(() => {
if (intervalRef.current) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (maskedText === displayValueRef.current) {
return;
}
if (!maskedText.length) {
displayValueRef.current = '';
setDisplayValue('');
return;
}
let iteration = 0;
const target = maskedText;
const targetLength = target.length;
intervalRef.current = window.setInterval(() => {
iteration += 1;
if (iteration >= targetLength) {
if (intervalRef.current) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
displayValueRef.current = target;
setDisplayValue(target);
return;
}
const revealCount = Math.floor(iteration);
const randomValue = Array.from({ length: targetLength }, (_, index) => {
if (index < revealCount) {
return target[index];
}
return LETTERS[Math.floor(Math.random() * LETTERS.length)];
}).join('');
displayValueRef.current = randomValue;
setDisplayValue(randomValue);
}, 20);
return () => {
clearInterval(timer);
setIsAnimating(false);
if (intervalRef.current) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetText, interval]);
}, [maskedText]);
const remainder =
outputText.length < targetText.length
? targetText
.slice(outputText.length)
.split('')
.map(() => chars[Math.floor(Math.random() * chars.length)])
.join('')
: '';
if (!isMounted) {
return <span className={className}> </span>;
}
return (
<span className={`${className} ${isAnimating ? 'animate-pulse' : ''}`}>
{outputText}
{remainder}
</span>
);
return <span className={className}>{displayValue}</span>;
}

View File

@ -0,0 +1,136 @@
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import * as Icons from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { type Account, type Bank } from '@/types/account';
import { transactionSyncService } from '@/services/transaction-sync';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
interface CategoryCellProps {
transaction: DecryptedTransaction;
categories: Category[];
accounts: Account[];
banks: Bank[];
onUpdate: (transaction: DecryptedTransaction) => void;
}
export function CategoryCell({
transaction,
categories,
accounts,
banks,
onUpdate,
}: CategoryCellProps) {
const [isUpdating, setIsUpdating] = useState(false);
async function handleCategoryChange(value: string) {
const categoryId = value === 'null' ? null : parseInt(value);
setIsUpdating(true);
try {
const updateData: {
category_id: number | null;
notes?: string;
notes_iv?: string;
} = {
category_id: categoryId,
};
if (transaction.notes) {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not available');
}
const key = await importKey(keyString);
const encrypted = await encrypt(transaction.notes, key);
updateData.notes = encrypted.encrypted;
updateData.notes_iv = encrypted.iv;
}
await transactionSyncService.update(transaction.id, updateData);
const updatedCategory = categoryId
? categories.find((c) => c.id === categoryId) || null
: null;
const account = accounts.find((a) => a.id === transaction.account_id);
const bank = account?.bank?.id
? banks.find((b) => b.id === account.bank.id)
: undefined;
const updatedTransaction: DecryptedTransaction = {
...transaction,
category_id: categoryId,
category: updatedCategory,
account,
bank,
};
onUpdate(updatedTransaction);
} catch (error) {
console.error('Failed to update category:', error);
} finally {
setIsUpdating(false);
}
}
const currentCategory = transaction.category;
const colorClasses = currentCategory
? getCategoryColorClasses(currentCategory.color)
: null;
const CurrentCategoryIconComponent = (currentCategory ? Icons[currentCategory.icon as keyof typeof Icons] : "CircleQuestionMark") as Icons.LucideIcon;
return (
<Select
value={
transaction.category_id
? String(transaction.category_id)
: 'null'
}
onValueChange={handleCategoryChange}
disabled={isUpdating}
>
<SelectTrigger className="h-auto w-auto border-0 bg-transparent p-0 shadow-none focus:ring-0">
<SelectValue>
{currentCategory ? (
<Badge
className={`${colorClasses?.bg} ${colorClasses?.text} flex gap-2`}
>
<CurrentCategoryIconComponent className={`opacity-80 h-2 w-2 ${colorClasses?.text}`} />
{currentCategory.name}
</Badge>
) : (
<Badge className="text-zinc-500 bg-zinc-50 dark:bg-zinc-950 flex gap-2">
<Icons.CircleQuestionMark className={`opacity-80 h-2 w-2 text-zinc-500`} />
Uncategorized
</Badge>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{categories.map((category) => {
const IconComponent = Icons[category.icon as keyof typeof Icons] as Icons.LucideIcon;
const classes = getCategoryColorClasses(category.color);
return (
<SelectItem key={category.id} value={String(category.id)}>
<Badge className={`flex items-center gap-2 py-0.5 ${classes.bg} ${classes.text}`}>
<IconComponent className={`opacity-80 h-2 w-2 ${classes.text}`} />
<span>{category.name}</span>
</Badge>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}

View File

@ -0,0 +1,217 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { transactionSyncService } from '@/services/transaction-sync';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { format, parseISO } from 'date-fns';
interface EditTransactionDialogProps {
transaction: DecryptedTransaction | null;
categories: Category[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function EditTransactionDialog({
transaction,
categories,
open,
onOpenChange,
onSuccess,
}: EditTransactionDialogProps) {
const [categoryId, setCategoryId] = useState<string>('null');
const [notes, setNotes] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (transaction) {
setCategoryId(
transaction.category_id ? String(transaction.category_id) : 'null',
);
setNotes(transaction.decryptedNotes || '');
}
}, [transaction]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!transaction) {
return;
}
setIsSubmitting(true);
try {
const updateData: {
category_id: number | null;
notes?: string;
notes_iv?: string;
} = {
category_id: categoryId === 'null' ? null : parseInt(categoryId),
};
if (notes.trim()) {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not available');
}
const key = await importKey(keyString);
const encrypted = await encrypt(notes, key);
updateData.notes = encrypted.encrypted;
updateData.notes_iv = encrypted.iv;
} else {
updateData.notes = null;
updateData.notes_iv = null;
}
await transactionSyncService.update(transaction.id, updateData);
onSuccess();
onOpenChange(false);
} catch (error) {
console.error('Failed to update transaction:', error);
} finally {
setIsSubmitting(false);
}
}
if (!transaction) {
return null;
}
const selectedCategory = categories.find(
(c) => c.id === parseInt(categoryId),
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Edit Transaction</DialogTitle>
<DialogDescription>
Update the category and notes for this transaction.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-muted-foreground text-sm">
Date
</Label>
<div className="text-sm">
{format(parseISO(transaction.transaction_date), 'PPP')}
</div>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-sm">
Description
</Label>
<div className="text-sm">
{transaction.decryptedDescription}
</div>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-sm">
Amount
</Label>
<div className="text-sm font-medium">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: transaction.currency_code,
}).format(parseFloat(transaction.amount))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select
value={categoryId}
onValueChange={setCategoryId}
>
<SelectTrigger id="category">
<SelectValue>
{selectedCategory ? (
<div className="flex items-center gap-2">
<span>{selectedCategory.icon}</span>
<span>{selectedCategory.name}</span>
</div>
) : (
<span className="text-zinc-500">
Uncategorized
</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="null">
<span className="text-zinc-500">
Uncategorized
</span>
</SelectItem>
{categories.map((category) => (
<SelectItem
key={category.id}
value={String(category.id)}
>
<div className="flex items-center gap-2">
<span>{category.icon}</span>
<span>{category.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
placeholder="Add notes..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -89,7 +89,7 @@ export function ImportStepAccount({
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
fallback="[Encrypted Account]"
length={19}
/>
</span>
<span className="text-sm text-muted-foreground">

View File

@ -0,0 +1,178 @@
import { ColumnDef } from '@tanstack/react-table';
import { ArrowDown, ArrowUpDown, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CategoryCell } from '@/components/transactions/category-cell';
import { EncryptedText } from '@/components/encrypted-text';
import { type DecryptedTransaction } from '@/types/transaction';
import { type Category } from '@/types/category';
import { type Account, type Bank } from '@/types/account';
interface CreateColumnsOptions {
categories: Category[];
accounts: Account[];
banks: Bank[];
onEdit: (transaction: DecryptedTransaction) => void;
onDelete: (transaction: DecryptedTransaction) => void;
onUpdate: (transaction: DecryptedTransaction) => void;
}
export function createTransactionColumns({
categories,
accounts,
banks,
onEdit,
onDelete,
onUpdate,
}: CreateColumnsOptions): ColumnDef<DecryptedTransaction>[] {
return [
{
accessorKey: 'transaction_date',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
Date
<ArrowDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
);
},
cell: ({ row }) => {
return (
<div className="font-medium pl-3">
{format(parseISO(row.getValue('transaction_date')), 'PP')}
</div>
);
},
},
{
accessorKey: 'category_id',
header: 'Category',
cell: ({ row }) => {
return (
<CategoryCell
transaction={row.original}
categories={categories}
accounts={accounts}
banks={banks}
onUpdate={onUpdate}
/>
);
},
},
{
accessorKey: 'decryptedDescription',
header: 'Description',
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="max-w-[150px] md:max-w-[250px] lg:max-w-[500px] truncate">
<EncryptedText
encryptedText={transaction.description}
iv={transaction.description_iv}
length={{ min: 20, max: 80 }}
/>
</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"
/>
)}
<span>{bank?.name || 'N/A'}</span>
</div>
);
},
},
{
accessorKey: 'account',
header: 'Account',
cell: ({ row }) => {
const account = row.original.account;
return <div>{account?.name || 'N/A'}</div>;
},
enableHiding: true,
},
{
accessorKey: 'amount',
header: () => {
return (
<div className="w-full text-right">
Amount
</div>
);
},
cell: ({ row }) => {
const amount = parseFloat(row.getValue('amount'));
const currencyCode = row.original.currency_code;
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(amount);
return (
<div
className={`text-right`}
>
<span className={`${amount < 0 ? '' : 'bg-green-100/70 dark:bg-green-900'}`}>{formatted}</span>
</div>
);
},
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const transaction = row.original;
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={() => onEdit(transaction)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant='destructive'
onClick={() => onDelete(transaction)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}

View File

@ -0,0 +1,260 @@
import { useState } from 'react';
import { Calendar, X } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { type Category } from '@/types/category';
import { type Account } from '@/types/account';
import { type TransactionFilters } from '@/types/transaction';
interface TransactionFiltersProps {
filters: TransactionFilters;
onFiltersChange: (filters: TransactionFilters) => void;
categories: Category[];
accounts: Account[];
isKeySet: boolean;
}
export function TransactionFilters({
filters,
onFiltersChange,
categories,
accounts,
isKeySet,
}: TransactionFiltersProps) {
const [isOpen, setIsOpen] = useState(false);
function handleCategoryToggle(categoryId: number) {
const newCategoryIds = filters.categoryIds.includes(categoryId)
? filters.categoryIds.filter((id) => id !== categoryId)
: [...filters.categoryIds, categoryId];
onFiltersChange({ ...filters, categoryIds: newCategoryIds });
}
function handleAccountToggle(accountId: number) {
const newAccountIds = filters.accountIds.includes(accountId)
? filters.accountIds.filter((id) => id !== accountId)
: [...filters.accountIds, accountId];
onFiltersChange({ ...filters, accountIds: newAccountIds });
}
function clearFilters() {
onFiltersChange({
dateFrom: null,
dateTo: null,
amountMin: null,
amountMax: null,
categoryIds: [],
accountIds: [],
searchText: '',
});
}
const activeFilterCount =
(filters.dateFrom ? 1 : 0) +
(filters.dateTo ? 1 : 0) +
(filters.amountMin !== null ? 1 : 0) +
(filters.amountMax !== null ? 1 : 0) +
filters.categoryIds.length +
filters.accountIds.length;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder={
isKeySet
? 'Search description or notes...'
: 'Search disabled (encryption key not set)'
}
value={filters.searchText}
onChange={(e) =>
onFiltersChange({
...filters,
searchText: e.target.value,
})
}
disabled={!isKeySet}
className="max-w-sm"
/>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline">
Filters
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="ml-2 rounded-full px-1.5 py-0.5"
>
{activeFilterCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="start">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">Filters</h4>
{activeFilterCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
>
Clear all
</Button>
)}
</div>
<div className="space-y-2">
<Label>Date Range</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="date"
value={
filters.dateFrom
? format(filters.dateFrom, 'yyyy-MM-dd')
: ''
}
onChange={(e) =>
onFiltersChange({
...filters,
dateFrom: e.target.value
? new Date(e.target.value)
: null,
})
}
placeholder="From"
/>
<Input
type="date"
value={
filters.dateTo
? format(filters.dateTo, 'yyyy-MM-dd')
: ''
}
onChange={(e) =>
onFiltersChange({
...filters,
dateTo: e.target.value
? new Date(e.target.value)
: null,
})
}
placeholder="To"
/>
</div>
</div>
<div className="space-y-2">
<Label>Amount Range</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
step="0.01"
value={filters.amountMin ?? ''}
onChange={(e) =>
onFiltersChange({
...filters,
amountMin: e.target.value
? parseFloat(e.target.value)
: null,
})
}
placeholder="Min"
/>
<Input
type="number"
step="0.01"
value={filters.amountMax ?? ''}
onChange={(e) =>
onFiltersChange({
...filters,
amountMax: e.target.value
? parseFloat(e.target.value)
: null,
})
}
placeholder="Max"
/>
</div>
</div>
<div className="space-y-2">
<Label>Categories</Label>
<div className="flex flex-wrap gap-2">
{categories.map((category) => {
const isSelected =
filters.categoryIds.includes(category.id);
return (
<Badge
key={category.id}
variant={
isSelected ? 'default' : 'outline'
}
className="cursor-pointer"
onClick={() =>
handleCategoryToggle(category.id)
}
>
{category.icon} {category.name}
</Badge>
);
})}
</div>
</div>
<div className="space-y-2">
<Label>Accounts</Label>
<div className="flex flex-wrap gap-2">
{accounts.map((account) => {
const isSelected =
filters.accountIds.includes(account.id);
return (
<Badge
key={account.id}
variant={
isSelected ? 'default' : 'outline'
}
className="cursor-pointer"
onClick={() =>
handleAccountToggle(account.id)
}
>
{account.name}
</Badge>
);
})}
</div>
</div>
</div>
</PopoverContent>
</Popover>
{activeFilterCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-9"
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,154 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger {...props} />
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal {...props} />
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex flex-col gap-2 text-center sm:text-left",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(
buttonVariants({ variant: "outline" }),
className
)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,43 @@
import { Table as TableType } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
interface DataTablePaginationProps<TData> {
table: TableType<TData>;
rowCountLabel?: string;
displayedCount?: number;
total?: number;
}
export function DataTablePagination<TData>({
table,
displayedCount = undefined,
total = undefined,
rowCountLabel = 'row(s) total',
}: DataTablePaginationProps<TData>) {
return (
<div className="flex px-5 items-center justify-end space-x-2">
{displayedCount && total && (
<div className="text-muted-foreground flex-1 text-sm">
{displayedCount} of {total} {rowCountLabel}
</div>
)}
{displayedCount && total === undefined && (
<div className="text-muted-foreground flex-1 text-sm">
{displayedCount} {rowCountLabel}
</div>
)}
{((displayedCount ?? 0) < (total ?? 0)) && <div className="space-x-2">
<Button
variant="ghost"
size="sm"
disabled
>
Loading <RefreshCw className="ml-2 h-4 w-4 animate-spin" />
</Button>
</div>}
</div>
);
}

View File

@ -0,0 +1,81 @@
import {
ColumnDef,
flexRender,
Table as TableType,
} from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface DataTableProps<TData, TValue> {
table: TableType<TData>;
columns: ColumnDef<TData, TValue>[];
emptyMessage?: string;
}
export function DataTable<TData, TValue>({
table,
columns,
emptyMessage = 'No results found.',
}: DataTableProps<TData, TValue>) {
return (
<div className="overflow-hidden 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"
>
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<textarea
className={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border px-3 py-2 text-base focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@ -145,6 +145,7 @@ export default function Accounts() {
<div className="pl-3 font-medium">
<EncryptedText
encryptedText={row.original.name}
length={{ min: 10, max: 20 }}
iv={row.original.name_iv}
/>
</div>
@ -259,11 +260,11 @@ export default function Accounts() {
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef
.header,
header.getContext(),
)}
header.column
.columnDef
.header,
header.getContext(),
)}
</TableHead>
);
})}

View File

@ -0,0 +1,489 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { Head } from '@inertiajs/react';
import {
ColumnFiltersState,
SortingState,
VisibilityState,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { parseISO, isWithinInterval } from 'date-fns';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { DataTable } from '@/components/ui/data-table';
import HeadingSmall from '@/components/heading-small';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { TransactionFilters } from '@/components/transactions/transaction-filters';
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
import { createTransactionColumns } from '@/components/transactions/transaction-columns';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { type Category } from '@/types/category';
import { type Account, type Bank } from '@/types/account';
import {
type DecryptedTransaction,
type TransactionFilters as Filters,
} from '@/types/transaction';
import { type BreadcrumbItem } from '@/types';
import { transactionSyncService } from '@/services/transaction-sync';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Transactions',
href: transactionsIndex.url(),
},
];
interface Props {
categories: Category[];
accounts: Account[];
banks: Bank[];
}
export default function Transactions({ categories, accounts, banks }: Props) {
const { isKeySet } = useEncryptionKey();
const [transactions, setTransactions] = useState<DecryptedTransaction[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState<SortingState>([
{ id: 'transaction_date', desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
account: false,
});
const [filters, setFilters] = useState<Filters>({
dateFrom: null,
dateTo: null,
amountMin: null,
amountMax: null,
categoryIds: [],
accountIds: [],
searchText: '',
});
const [editTransaction, setEditTransaction] =
useState<DecryptedTransaction | null>(null);
const [deleteTransaction, setDeleteTransaction] =
useState<DecryptedTransaction | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [displayedCount, setDisplayedCount] = useState(25);
const observerTarget = useRef<HTMLDivElement>(null);
function updateTransaction(updatedTransaction: DecryptedTransaction) {
setTransactions((prev) =>
prev.map((t) =>
t.id === updatedTransaction.id ? updatedTransaction : t
)
);
}
async function loadTransactions() {
setIsLoading(true);
try {
const rawTransactions = await transactionSyncService.getAll();
const accountsMap = new Map(
accounts.map((account) => [account.id, account]),
);
const categoriesMap = new Map(
categories.map((category) => [category.id, category]),
);
const banksMap = new Map(banks.map((bank) => [bank.id, bank]));
const keyString = getStoredKey();
let key: CryptoKey | null = null;
if (keyString && isKeySet) {
try {
key = await importKey(keyString);
} catch (error) {
console.error('Failed to import encryption key:', error);
}
}
const decrypted = await Promise.all(
rawTransactions.map(async (transaction) => {
try {
let decryptedDescription = '';
let decryptedNotes: string | null = null;
if (key) {
try {
decryptedDescription = await decrypt(
transaction.description,
key,
transaction.description_iv,
);
if (transaction.notes && transaction.notes_iv) {
decryptedNotes = await decrypt(
transaction.notes,
key,
transaction.notes_iv,
);
}
} catch (error) {
console.error(
'Failed to decrypt transaction:',
transaction.id,
error,
);
}
}
const account = accountsMap.get(transaction.account_id);
const category = transaction.category_id
? categoriesMap.get(transaction.category_id)
: null;
const bank = account?.bank?.id
? banksMap.get(account.bank.id)
: undefined;
return {
...transaction,
decryptedDescription,
decryptedNotes,
account,
category: category || null,
bank,
} as DecryptedTransaction;
} catch (error) {
console.error(
'Failed to process transaction:',
transaction.id,
error,
);
return null;
}
}),
);
setTransactions(
decrypted.filter(
(t): t is DecryptedTransaction => t !== null,
),
);
} catch (error) {
console.error('Failed to load transactions:', error);
} finally {
setIsLoading(false);
}
}
useEffect(() => {
loadTransactions();
}, []);
useEffect(() => {
async function reDecryptTransactions() {
if (transactions.length === 0) {
return;
}
const keyString = getStoredKey();
let key: CryptoKey | null = null;
if (keyString && isKeySet) {
try {
key = await importKey(keyString);
} catch (error) {
console.error('Failed to import encryption key:', error);
}
}
const reDecrypted = await Promise.all(
transactions.map(async (transaction) => {
try {
let decryptedDescription = '';
let decryptedNotes: string | null = null;
if (key) {
try {
decryptedDescription = await decrypt(
transaction.description,
key,
transaction.description_iv,
);
if (transaction.notes && transaction.notes_iv) {
decryptedNotes = await decrypt(
transaction.notes,
key,
transaction.notes_iv,
);
}
} catch (error) {
console.error(
'Failed to decrypt transaction:',
transaction.id,
error,
);
}
}
return {
...transaction,
decryptedDescription,
decryptedNotes,
} as DecryptedTransaction;
} catch (error) {
console.error(
'Failed to process transaction:',
transaction.id,
error,
);
return transaction;
}
}),
);
setTransactions(reDecrypted);
}
reDecryptTransactions();
}, [isKeySet]);
const filteredTransactions = useMemo(() => {
return transactions.filter((transaction) => {
if (filters.dateFrom || filters.dateTo) {
const transactionDate = parseISO(transaction.transaction_date);
if (
filters.dateFrom &&
filters.dateTo &&
!isWithinInterval(transactionDate, {
start: filters.dateFrom,
end: filters.dateTo,
})
) {
return false;
}
if (filters.dateFrom && transactionDate < filters.dateFrom) {
return false;
}
if (filters.dateTo && transactionDate > filters.dateTo) {
return false;
}
}
if (
filters.amountMin !== null &&
parseFloat(transaction.amount) < filters.amountMin
) {
return false;
}
if (
filters.amountMax !== null &&
parseFloat(transaction.amount) > filters.amountMax
) {
return false;
}
if (
filters.categoryIds.length > 0 &&
!filters.categoryIds.includes(transaction.category_id || -1)
) {
return false;
}
if (
filters.accountIds.length > 0 &&
!filters.accountIds.includes(transaction.account_id)
) {
return false;
}
if (filters.searchText && isKeySet) {
const searchLower = filters.searchText.toLowerCase();
const matchesDescription =
transaction.decryptedDescription
.toLowerCase()
.includes(searchLower);
const matchesNotes =
transaction.decryptedNotes
?.toLowerCase()
.includes(searchLower) || false;
if (!matchesDescription && !matchesNotes) {
return false;
}
}
return true;
});
}, [transactions, filters, isKeySet]);
const displayedTransactions = useMemo(() => {
return filteredTransactions.slice(0, displayedCount);
}, [filteredTransactions, displayedCount]);
const columns = createTransactionColumns({
categories,
accounts,
banks,
onEdit: setEditTransaction,
onDelete: setDeleteTransaction,
onUpdate: updateTransaction,
});
const table = useReactTable({
data: displayedTransactions,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getRowId: (row) => row.id.toString(),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
state: {
sorting,
columnFilters,
columnVisibility,
},
});
const loadMore = useCallback(() => {
if (displayedCount < filteredTransactions.length) {
setDisplayedCount((prev) => Math.min(prev + 25, filteredTransactions.length));
}
}, [displayedCount, filteredTransactions.length]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.1 }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [loadMore]);
useEffect(() => {
setDisplayedCount(25);
}, [filters]);
async function handleDelete() {
if (!deleteTransaction) {
return;
}
setIsDeleting(true);
try {
await transactionSyncService.delete(deleteTransaction.id);
await loadTransactions();
setDeleteTransaction(null);
} catch (error) {
console.error('Failed to delete transaction:', error);
} finally {
setIsDeleting(false);
}
}
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title="Transactions" />
<div className="space-y-6 p-6">
<HeadingSmall
title="Transactions"
description="View and manage your transactions"
/>
<div className="space-y-4">
<TransactionFilters
filters={filters}
onFiltersChange={setFilters}
categories={categories}
accounts={accounts}
isKeySet={isKeySet}
/>
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<div className="text-muted-foreground">
Loading transactions...
</div>
</div>
) : (
<>
<DataTable
table={table}
columns={columns}
emptyMessage="No transactions found."
/>
<DataTablePagination
table={table}
displayedCount={displayedCount}
total={filteredTransactions.length}
rowCountLabel="transactions total"
/>
<div ref={observerTarget} className="h-0" />
</>
)}
</div>
</div>
<EditTransactionDialog
transaction={editTransaction}
categories={categories}
open={!!editTransaction}
onOpenChange={(open) => !open && setEditTransaction(null)}
onSuccess={loadTransactions}
/>
<AlertDialog
open={!!deleteTransaction}
onOpenChange={(open) => !open && setDeleteTransaction(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Transaction</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this transaction?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AppSidebarLayout>
);
}

View File

@ -0,0 +1,37 @@
import { type Account, type Bank } from './account';
import { type Category } from './category';
export interface Transaction {
id: string;
user_id: number;
account_id: number;
category_id: number | null;
description: string;
description_iv: string;
transaction_date: string;
amount: string;
currency_code: string;
notes: string | null;
notes_iv: string | null;
created_at: string;
updated_at: string;
}
export interface DecryptedTransaction extends Transaction {
decryptedDescription: string;
decryptedNotes: string | null;
account?: Account;
category?: Category | null;
bank?: Bank;
}
export interface TransactionFilters {
dateFrom: Date | null;
dateTo: Date | null;
amountMin: number | null;
amountMax: number | null;
categoryIds: number[];
accountIds: number[];
searchText: string;
}

View File

@ -4,6 +4,7 @@ use App\Http\Controllers\EncryptionController;
use App\Http\Controllers\Sync\AccountSyncController;
use App\Http\Controllers\Sync\BankSyncController;
use App\Http\Controllers\Sync\CategorySyncController;
use App\Http\Controllers\TransactionController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Fortify\Features;
@ -36,6 +37,10 @@ Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function (
Route::get('dashboard', function () {
return Inertia::render('dashboard');
})->name('dashboard');
Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index');
Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update');
Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy');
});
require __DIR__.'/settings.php';

View File

@ -0,0 +1,216 @@
<?php
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use function Pest\Laravel\actingAs;
test('guests cannot access transactions page', function () {
$response = $this->get(route('transactions.index'));
$response->assertRedirect(route('login'));
});
test('authenticated users can access transactions page', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$response = actingAs($user)->get(route('transactions.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('transactions/index')
->has('categories')
->has('accounts')
->has('banks')
);
});
test('users can update their own transaction category', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$category = Category::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => null,
]);
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
'category_id' => $category->id,
]);
$response->assertSuccessful();
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'category_id' => $category->id,
]);
});
test('users can update transaction notes', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
'notes' => 'encrypted_notes_content',
'notes_iv' => str_repeat('c', 16),
]);
$response->assertSuccessful();
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'notes' => 'encrypted_notes_content',
'notes_iv' => str_repeat('c', 16),
]);
});
test('users can clear transaction category', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$category = Category::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => $category->id,
]);
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
'category_id' => null,
]);
$response->assertSuccessful();
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'category_id' => null,
]);
});
test('users cannot update other users transactions', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$otherUser = User::factory()->create(['encryption_salt' => str_repeat('b', 24)]);
$account = Account::factory()->create(['user_id' => $otherUser->id]);
$category = Category::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
'user_id' => $otherUser->id,
'account_id' => $account->id,
]);
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
'category_id' => $category->id,
]);
$response->assertForbidden();
});
test('category_id must exist when updating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
'category_id' => 99999,
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['category_id']);
});
test('notes_iv must be exactly 16 characters', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
$response = actingAs($user)->patchJson(route('transactions.update', $transaction), [
'notes' => 'encrypted_notes',
'notes_iv' => 'invalid',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['notes_iv']);
});
test('users can soft delete their own transactions', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
$response = actingAs($user)->deleteJson(route('transactions.destroy', $transaction));
$response->assertSuccessful();
$this->assertSoftDeleted('transactions', [
'id' => $transaction->id,
]);
});
test('users cannot delete other users transactions', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$otherUser = User::factory()->create(['encryption_salt' => str_repeat('b', 24)]);
$account = Account::factory()->create(['user_id' => $otherUser->id]);
$transaction = Transaction::factory()->create([
'user_id' => $otherUser->id,
'account_id' => $account->id,
]);
$response = actingAs($user)->deleteJson(route('transactions.destroy', $transaction));
$response->assertForbidden();
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'deleted_at' => null,
]);
});
test('transactions index page passes user categories', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$otherUser = User::factory()->create();
$userCategory = Category::factory()->create(['user_id' => $user->id, 'name' => 'My Category']);
Category::factory()->create(['user_id' => $otherUser->id, 'name' => 'Other Category']);
$response = actingAs($user)->get(route('transactions.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('transactions/index')
->has('categories', 1)
->where('categories.0.name', 'My Category')
);
});
test('transactions index page passes user accounts', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$otherUser = User::factory()->create();
Account::factory()->create(['user_id' => $user->id, 'name' => 'encrypted_name_1', 'name_iv' => str_repeat('a', 16)]);
Account::factory()->create(['user_id' => $otherUser->id, 'name' => 'encrypted_name_2', 'name_iv' => str_repeat('b', 16)]);
$response = actingAs($user)->get(route('transactions.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('transactions/index')
->has('accounts', 1)
);
});