feat(TransactionController): Add store method for creating transactions

This commit is contained in:
Víctor Falcón 2025-11-11 12:20:39 +00:00
parent 9ac9e4cd16
commit c1fbd4d09f
6 changed files with 658 additions and 63 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Http\Requests\BulkUpdateTransactionsRequest;
use App\Http\Requests\StoreTransactionRequest;
use App\Http\Requests\UpdateTransactionRequest;
use App\Models\Account;
use App\Models\Bank;
@ -48,6 +49,27 @@ class TransactionController extends Controller
]);
}
public function store(StoreTransactionRequest $request): JsonResponse
{
$data = $request->validated();
$transaction = new Transaction([
...$data,
'user_id' => $request->user()->id,
]);
if (isset($data['id'])) {
$transaction->id = $data['id'];
$transaction->exists = false;
}
$transaction->save();
return response()->json([
'data' => $transaction,
], 201);
}
public function update(UpdateTransactionRequest $request, Transaction $transaction): JsonResponse
{
$this->authorize('update', $transaction);

View File

@ -8,12 +8,21 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { encrypt, importKey } from '@/lib/crypto';
import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account } from '@/types/account';
import { type Category } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { format, parseISO } from 'date-fns';
@ -23,149 +32,380 @@ import { toast } from 'sonner';
interface EditTransactionDialogProps {
transaction: DecryptedTransaction | null;
categories: Category[];
accounts: Account[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (transaction: DecryptedTransaction) => void;
mode: 'create' | 'edit';
}
export function EditTransactionDialog({
transaction,
categories,
accounts,
open,
onOpenChange,
onSuccess,
mode,
}: EditTransactionDialogProps) {
const { isKeySet } = useEncryptionKey();
const [transactionDate, setTransactionDate] = useState('');
const [description, setDescription] = useState('');
const [amount, setAmount] = useState('');
const [accountId, setAccountId] = useState<string>('');
const [categoryId, setCategoryId] = useState<string>('null');
const [notes, setNotes] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [decryptedAccountNames, setDecryptedAccountNames] = useState<
Map<number, string>
>(new Map());
useEffect(() => {
if (transaction) {
if (mode === 'edit' && transaction) {
setTransactionDate(transaction.transaction_date);
setDescription(transaction.decryptedDescription);
setAmount(transaction.amount);
setAccountId(String(transaction.account_id));
setCategoryId(
transaction.category_id
? String(transaction.category_id)
: 'null',
);
setNotes(transaction.decryptedNotes || '');
} else if (mode === 'create' && open) {
const today = new Date().toISOString().split('T')[0];
setTransactionDate(today);
setDescription('');
setAmount('');
setAccountId(accounts.length > 0 ? String(accounts[0].id) : '');
setCategoryId('null');
setNotes('');
}
}, [transaction]);
}, [mode, transaction, open, accounts]);
useEffect(() => {
if (!open || mode !== 'create') return;
async function decryptAccountNames() {
const keyString = getStoredKey();
if (!keyString) {
return;
}
try {
const key = await importKey(keyString);
const decryptedNames = new Map<number, string>();
await Promise.all(
accounts.map(async (account) => {
try {
const decryptedName = await decrypt(
account.name,
key,
account.name_iv,
);
decryptedNames.set(account.id, decryptedName);
} catch (error) {
console.error(
'Failed to decrypt account name:',
account.id,
error,
);
decryptedNames.set(account.id, '[Encrypted]');
}
}),
);
setDecryptedAccountNames(decryptedNames);
} catch (error) {
console.error('Failed to decrypt account names:', error);
}
}
decryptAccountNames();
}, [open, mode, accounts]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!transaction) {
return;
}
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to update transactions',
'Please unlock your encryption key to save transactions',
);
return;
}
if (mode === 'create') {
if (!description.trim()) {
toast.error('Description is required');
return;
}
if (!amount || parseFloat(amount) === 0) {
toast.error('Amount is required');
return;
}
if (!accountId) {
toast.error('Account is required');
return;
}
if (!transactionDate) {
toast.error('Date is required');
return;
}
}
setIsSubmitting(true);
try {
const selectedCategoryId =
categoryId === 'null' ? null : parseInt(categoryId, 10);
const trimmedNotes = notes.trim();
const trimmedDescription = description.trim();
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not available');
}
const key = await importKey(keyString);
let encryptedNotes: string | null = null;
let notesIv: string | null = null;
if (trimmedNotes) {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not available');
}
const key = await importKey(keyString);
const encrypted = await encrypt(trimmedNotes, key);
encryptedNotes = encrypted.encrypted;
notesIv = encrypted.iv;
}
await transactionSyncService.update(transaction.id, {
category_id: selectedCategoryId,
notes: encryptedNotes,
notes_iv: notesIv,
});
if (mode === 'create') {
const encryptedDescription = await encrypt(
trimmedDescription,
key,
);
const updatedRecord = await transactionSyncService.getById(
transaction.id,
);
const updatedCategory = selectedCategoryId
? categories.find(
(category) => category.id === selectedCategoryId,
) || null
: null;
const selectedAccount = accounts.find(
(acc) => acc.id === parseInt(accountId, 10),
);
if (!selectedAccount) {
throw new Error('Selected account not found');
}
const updatedTransaction: DecryptedTransaction = {
...transaction,
category_id: selectedCategoryId,
category: updatedCategory,
decryptedNotes: trimmedNotes || null,
notes: encryptedNotes,
notes_iv: notesIv,
updated_at: updatedRecord?.updated_at ?? transaction.updated_at,
};
await transactionSyncService.create({
user_id: 0,
account_id: parseInt(accountId, 10),
category_id: selectedCategoryId,
description: encryptedDescription.encrypted,
description_iv: encryptedDescription.iv,
transaction_date: transactionDate,
amount: amount,
currency_code: selectedAccount.currency_code,
notes: encryptedNotes,
notes_iv: notesIv,
});
onSuccess(updatedTransaction);
onOpenChange(false);
toast.success('Transaction created successfully');
onOpenChange(false);
} else {
if (!transaction) {
return;
}
await transactionSyncService.update(transaction.id, {
category_id: selectedCategoryId,
notes: encryptedNotes,
notes_iv: notesIv,
});
const updatedRecord = await transactionSyncService.getById(
transaction.id,
);
const updatedCategory = selectedCategoryId
? categories.find(
(category) => category.id === selectedCategoryId,
) || null
: null;
const updatedTransaction: DecryptedTransaction = {
...transaction,
category_id: selectedCategoryId,
category: updatedCategory,
decryptedNotes: trimmedNotes || null,
notes: encryptedNotes,
notes_iv: notesIv,
updated_at:
updatedRecord?.updated_at ?? transaction.updated_at,
};
toast.success('Transaction updated successfully');
onSuccess(updatedTransaction);
onOpenChange(false);
}
} catch (error) {
console.error('Failed to update transaction:', error);
console.error('Failed to save transaction:', error);
toast.error(
`Failed to ${mode === 'create' ? 'create' : 'update'} transaction`,
);
} finally {
setIsSubmitting(false);
}
}
if (!transaction) {
return null;
}
const selectedAccount = accounts.find(
(acc) => acc.id === parseInt(accountId, 10),
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Edit Transaction</DialogTitle>
<DialogTitle>
{mode === 'create'
? 'Add Transaction'
: 'Edit Transaction'}
</DialogTitle>
<DialogDescription>
Update the category and notes for this transaction.
{mode === 'create'
? 'Create a new transaction.'
: '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-sm text-muted-foreground">
<Label
htmlFor="date"
className={
mode === 'edit'
? 'text-sm text-muted-foreground'
: ''
}
>
Date
</Label>
<div className="text-sm">
{format(
parseISO(transaction.transaction_date),
'PPP',
)}
</div>
{mode === 'create' ? (
<Input
id="date"
type="date"
value={transactionDate}
onChange={(e) =>
setTransactionDate(e.target.value)
}
disabled={isSubmitting}
required
/>
) : (
<div className="text-sm">
{transaction &&
format(
parseISO(
transaction.transaction_date,
),
'PPP',
)}
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
<Label
htmlFor="description"
className={
mode === 'edit'
? 'text-sm text-muted-foreground'
: ''
}
>
Description
</Label>
<div className="text-sm">
{transaction.decryptedDescription}
</div>
{mode === 'create' ? (
<Input
id="description"
type="text"
value={description}
onChange={(e) =>
setDescription(e.target.value)
}
placeholder="Transaction description"
disabled={isSubmitting}
required
/>
) : (
<div className="text-sm">
{transaction?.decryptedDescription}
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
<Label
htmlFor="amount"
className={
mode === 'edit'
? 'text-sm text-muted-foreground'
: ''
}
>
Amount
</Label>
<div className="text-sm font-medium">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: transaction.currency_code,
}).format(parseFloat(transaction.amount))}
</div>
{mode === 'create' ? (
<>
<Input
id="amount"
type="number"
step="0.01"
value={amount}
onChange={(e) =>
setAmount(e.target.value)
}
placeholder="0.00"
disabled={isSubmitting}
required
/>
{selectedAccount && (
<p className="text-xs text-muted-foreground">
Currency:{' '}
{selectedAccount.currency_code}
</p>
)}
</>
) : (
<div className="text-sm font-medium">
{transaction &&
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: transaction.currency_code,
}).format(parseFloat(transaction.amount))}
</div>
)}
</div>
{mode === 'create' && (
<div className="space-y-2">
<Label htmlFor="account">Account</Label>
<Select
value={accountId}
onValueChange={setAccountId}
disabled={isSubmitting}
>
<SelectTrigger id="account">
<SelectValue placeholder="Select account" />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem
key={account.id}
value={String(account.id)}
>
{decryptedAccountNames.get(
account.id,
) || '[Loading...]'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<CategorySelect
@ -187,6 +427,7 @@ export function EditTransactionDialog({
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
disabled={isSubmitting}
/>
</div>
</div>
@ -201,7 +442,11 @@ export function EditTransactionDialog({
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
{isSubmitting
? 'Saving...'
: mode === 'create'
? 'Create Transaction'
: 'Save Changes'}
</Button>
</DialogFooter>
</form>

View File

@ -0,0 +1,124 @@
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import { ChevronDown, Plus, Upload } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { ImportTransactionsDrawer } from './import-transactions-drawer';
interface TransactionActionsMenuProps {
categories: Category[];
accounts: Account[];
banks: Bank[];
onAddTransaction: () => void;
}
export function TransactionActionsMenu({
categories,
accounts,
banks,
onAddTransaction,
}: TransactionActionsMenuProps) {
const { isKeySet } = useEncryptionKey();
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
const handleAddTransaction = () => {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to add transactions',
);
return;
}
onAddTransaction();
};
const handleOpenImportDrawer = () => {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to import transactions',
);
return;
}
setImportDrawerOpen(true);
};
return (
<>
<ButtonGroup>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size={"sm"}
className={!isKeySet ? 'cursor-not-allowed opacity-50' : ''}
onClick={handleAddTransaction}
aria-label="Add transaction"
>
<Plus className="h-5 w-5" />
Add Transaction
</Button>
</TooltipTrigger>
<TooltipContent>
{!isKeySet
? 'Unlock encryption to add transactions'
: 'Create a new transaction'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon-sm"
aria-label="More actions"
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>More actions</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={handleOpenImportDrawer}
disabled={!isKeySet}
>
<Upload className="mr-2 h-4 w-4" />
Import Transactions
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
<ImportTransactionsDrawer
open={importDrawerOpen}
onOpenChange={setImportDrawerOpen}
categories={categories}
accounts={accounts}
banks={banks}
/>
</>
);
}

View File

@ -17,7 +17,7 @@ import { index as transactionsIndex } from '@/actions/App/Http/Controllers/Trans
import HeadingSmall from '@/components/heading-small';
import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
import { ImportTransactionsButton } from '@/components/transactions/import-transactions-button';
import { TransactionActionsMenu } from '@/components/transactions/transaction-actions-menu';
import { createTransactionColumns } from '@/components/transactions/transaction-columns';
import { TransactionFilters } from '@/components/transactions/transaction-filters';
import {
@ -109,6 +109,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
});
const [editTransaction, setEditTransaction] =
useState<DecryptedTransaction | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteTransaction, setDeleteTransaction] =
useState<DecryptedTransaction | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
@ -907,10 +908,13 @@ export default function Transactions({ categories, accounts, banks }: Props) {
isKeySet={isKeySet}
actions={
<>
<ImportTransactionsButton
<TransactionActionsMenu
categories={categories}
accounts={accounts}
banks={banks}
onAddTransaction={() =>
setCreateDialogOpen(true)
}
/>
<DataTableViewOptions table={table} />
</>
@ -974,9 +978,21 @@ export default function Transactions({ categories, accounts, banks }: Props) {
<EditTransactionDialog
transaction={editTransaction}
categories={categories}
accounts={accounts}
open={!!editTransaction}
onOpenChange={(open) => !open && setEditTransaction(null)}
onSuccess={updateTransaction}
mode="edit"
/>
<EditTransactionDialog
transaction={null}
categories={categories}
accounts={accounts}
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={() => {}}
mode="create"
/>
<AlertDialog

View File

@ -40,6 +40,7 @@ Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function (
})->name('dashboard');
Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index');
Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store');
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update');
Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update');
Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy');

View File

@ -214,3 +214,190 @@ test('transactions index page passes user accounts', function () {
->has('accounts', 1)
);
});
test('users can create a new transaction', 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]);
$transactionData = [
'account_id' => $account->id,
'category_id' => $category->id,
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '150.50',
'currency_code' => 'USD',
'notes' => 'encrypted_notes',
'notes_iv' => str_repeat('n', 16),
];
$response = actingAs($user)->postJson(route('transactions.store'), $transactionData);
$response->assertCreated();
$response->assertJsonStructure([
'data' => [
'id',
'user_id',
'account_id',
'category_id',
'description',
'description_iv',
'transaction_date',
'amount',
'currency_code',
'notes',
'notes_iv',
'created_at',
'updated_at',
],
]);
$this->assertDatabaseHas('transactions', [
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => $category->id,
'description' => 'encrypted_description',
'amount' => '150.50',
'currency_code' => 'USD',
]);
});
test('users can create a transaction without category', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
'account_id' => $account->id,
'category_id' => null,
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '75.25',
'currency_code' => 'EUR',
];
$response = actingAs($user)->postJson(route('transactions.store'), $transactionData);
$response->assertCreated();
$this->assertDatabaseHas('transactions', [
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => null,
'description' => 'encrypted_description',
'amount' => '75.25',
]);
});
test('users can create a transaction without notes', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
'account_id' => $account->id,
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '100.00',
'currency_code' => 'USD',
];
$response = actingAs($user)->postJson(route('transactions.store'), $transactionData);
$response->assertCreated();
$this->assertDatabaseHas('transactions', [
'user_id' => $user->id,
'account_id' => $account->id,
'notes' => null,
'notes_iv' => null,
]);
});
test('account_id is required when creating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$transactionData = [
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '100.00',
'currency_code' => 'USD',
];
$response = actingAs($user)->postJson(route('transactions.store'), $transactionData);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['account_id']);
});
test('description is required when creating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
'account_id' => $account->id,
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '100.00',
'currency_code' => 'USD',
];
$response = actingAs($user)->postJson(route('transactions.store'), $transactionData);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['description']);
});
test('amount is required when creating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
'account_id' => $account->id,
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'currency_code' => 'USD',
];
$response = actingAs($user)->postJson(route('transactions.store'), $transactionData);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['amount']);
});
test('transaction_date is required when creating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
'account_id' => $account->id,
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'amount' => '100.00',
'currency_code' => 'USD',
];
$response = actingAs($user)->postJson(route('transactions.store'), $transactionData);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['transaction_date']);
});
test('currency_code is required when creating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
'account_id' => $account->id,
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '100.00',
];
$response = actingAs($user)->postJson(route('transactions.store'), $transactionData);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['currency_code']);
});