feat(TransactionController): Add store method for creating transactions
This commit is contained in:
parent
9ac9e4cd16
commit
c1fbd4d09f
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue