From dcb78d225202a575c64be949af37de38c742b297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sat, 15 Nov 2025 20:53:47 +0100 Subject: [PATCH] Update transaction amount to bigint --- app/Http/Requests/StoreTransactionRequest.php | 4 +- app/Models/Transaction.php | 2 +- database/factories/TransactionFactory.php | 2 +- ..._140230_change_transactions_id_to_uuid.php | 22 ++-- ...6_change_transactions_amount_to_bigint.php | 30 +++++ .../transactions/edit-transaction-dialog.tsx | 41 +++--- .../transactions/import-step-preview.tsx | 2 +- .../transactions/transaction-columns.tsx | 5 +- resources/js/components/ui/amount-input.tsx | 123 ++++++++++++++++++ resources/js/components/ui/input-group.tsx | 99 ++++++++++++++ resources/js/lib/file-parser.ts | 2 +- resources/js/lib/rule-engine.ts | 2 +- resources/js/pages/transactions/index.tsx | 4 +- resources/js/types/transaction.ts | 2 +- tests/Feature/Sync/TransactionSyncTest.php | 16 +-- tests/Feature/TransactionTest.php | 18 +-- 16 files changed, 311 insertions(+), 63 deletions(-) create mode 100644 database/migrations/2025_11_15_175636_change_transactions_amount_to_bigint.php create mode 100644 resources/js/components/ui/amount-input.tsx create mode 100644 resources/js/components/ui/input-group.tsx diff --git a/app/Http/Requests/StoreTransactionRequest.php b/app/Http/Requests/StoreTransactionRequest.php index e937b673..da017dc7 100644 --- a/app/Http/Requests/StoreTransactionRequest.php +++ b/app/Http/Requests/StoreTransactionRequest.php @@ -20,7 +20,7 @@ class StoreTransactionRequest extends FormRequest 'description' => ['required', 'string'], 'description_iv' => ['required', 'string', 'size:16'], 'transaction_date' => ['required', 'date'], - 'amount' => ['required', 'numeric'], + 'amount' => ['required', 'integer'], 'currency_code' => ['required', 'string', 'size:3'], 'notes' => ['nullable', 'string'], 'notes_iv' => ['nullable', 'string', 'size:16'], @@ -39,7 +39,7 @@ class StoreTransactionRequest extends FormRequest 'transaction_date.required' => 'The transaction date is required.', 'transaction_date.date' => 'The transaction date must be a valid date.', 'amount.required' => 'The amount is required.', - 'amount.numeric' => 'The amount must be a number.', + 'amount.integer' => 'The amount must be an integer.', 'currency_code.required' => 'The currency code is required.', 'currency_code.size' => 'The currency code must be exactly 3 characters.', 'notes_iv.size' => 'The notes IV must be exactly 16 characters.', diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index a2e8a366..370b5e46 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -30,7 +30,7 @@ class Transaction extends Model { return [ 'transaction_date' => 'date', - 'amount' => 'decimal:2', + 'amount' => 'integer', ]; } diff --git a/database/factories/TransactionFactory.php b/database/factories/TransactionFactory.php index 935d20c8..18b2199f 100644 --- a/database/factories/TransactionFactory.php +++ b/database/factories/TransactionFactory.php @@ -26,7 +26,7 @@ class TransactionFactory extends Factory 'description' => fake()->sentence(), 'description_iv' => fake()->regexify('[A-Za-z0-9]{16}'), 'transaction_date' => fake()->dateTimeBetween('-1 year', 'now'), - 'amount' => fake()->randomFloat(2, -1000, 1000), + 'amount' => fake()->numberBetween(-100000, 100000), 'currency_code' => fake()->randomElement(['USD', 'EUR', 'GBP', 'JPY']), 'notes' => fake()->optional()->paragraph(), 'notes_iv' => fake()->optional()->regexify('[A-Za-z0-9]{16}'), diff --git a/database/migrations/2025_11_08_140230_change_transactions_id_to_uuid.php b/database/migrations/2025_11_08_140230_change_transactions_id_to_uuid.php index 6728b4e9..5d2985ff 100644 --- a/database/migrations/2025_11_08_140230_change_transactions_id_to_uuid.php +++ b/database/migrations/2025_11_08_140230_change_transactions_id_to_uuid.php @@ -11,15 +11,21 @@ return new class extends Migration */ public function up(): void { - // Drop the existing id column and recreate it as UUID - Schema::table('transactions', function (Blueprint $table) { - // Drop the existing auto-increment id - $table->dropColumn('id'); - }); + Schema::dropIfExists('transactions'); - Schema::table('transactions', function (Blueprint $table) { - // Add new UUID id as primary key - $table->uuid('id')->primary()->first(); + Schema::create('transactions', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('account_id')->constrained()->onDelete('cascade'); + $table->foreignId('category_id')->nullable()->constrained()->onDelete('cascade'); + $table->text('description'); + $table->string('description_iv', 16); + $table->date('transaction_date'); + $table->decimal('amount', 15, 2); + $table->string('currency_code', 3); + $table->text('notes')->nullable(); + $table->string('notes_iv', 16)->nullable(); + $table->timestamps(); }); } diff --git a/database/migrations/2025_11_15_175636_change_transactions_amount_to_bigint.php b/database/migrations/2025_11_15_175636_change_transactions_amount_to_bigint.php new file mode 100644 index 00000000..6600108d --- /dev/null +++ b/database/migrations/2025_11_15_175636_change_transactions_amount_to_bigint.php @@ -0,0 +1,30 @@ +truncate(); + + Schema::table('transactions', function (Blueprint $table) { + $table->bigInteger('amount')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->decimal('amount', 15, 2)->change(); + }); + } +}; diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index fc5f856b..7902851a 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -1,4 +1,5 @@ import { CategorySelect } from '@/components/transactions/category-select'; +import { AmountInput } from '@/components/ui/amount-input'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -53,7 +54,7 @@ export function EditTransactionDialog({ const { isKeySet } = useEncryptionKey(); const [transactionDate, setTransactionDate] = useState(''); const [description, setDescription] = useState(''); - const [amount, setAmount] = useState(''); + const [amount, setAmount] = useState(0); const [accountId, setAccountId] = useState(''); const [categoryId, setCategoryId] = useState('null'); const [notes, setNotes] = useState(''); @@ -78,7 +79,7 @@ export function EditTransactionDialog({ const today = new Date().toISOString().split('T')[0]; setTransactionDate(today); setDescription(''); - setAmount(''); + setAmount(0); setAccountId(accounts.length > 0 ? String(accounts[0].id) : ''); setCategoryId('null'); setNotes(''); @@ -142,7 +143,7 @@ export function EditTransactionDialog({ toast.error('Description is required'); return; } - if (!amount || parseFloat(amount) === 0) { + if (amount === 0) { toast.error('Amount is required'); return; } @@ -377,35 +378,23 @@ export function EditTransactionDialog({ Amount {mode === 'create' ? ( - <> - - setAmount(e.target.value) - } - placeholder="0.00" - disabled={isSubmitting} - required - /> - {selectedAccount && ( -

- Currency:{' '} - {selectedAccount.currency_code} -

- )} - + ) : (
{transaction && new Intl.NumberFormat('en-US', { style: 'currency', currency: transaction.currency_code, - }).format( - parseFloat(transaction.amount), - )} + }).format(transaction.amount / 100)}
)} diff --git a/resources/js/components/transactions/import-step-preview.tsx b/resources/js/components/transactions/import-step-preview.tsx index d1eaf1e7..af8a8aa9 100644 --- a/resources/js/components/transactions/import-step-preview.tsx +++ b/resources/js/components/transactions/import-step-preview.tsx @@ -36,7 +36,7 @@ export function ImportStepPreview({ return new Intl.NumberFormat('en-US', { style: 'currency', currency: currencyCode, - }).format(amount); + }).format(amount / 100); }; const formatDate = (dateStr: string): string => { diff --git a/resources/js/components/transactions/transaction-columns.tsx b/resources/js/components/transactions/transaction-columns.tsx index a03ed833..0ad4817e 100644 --- a/resources/js/components/transactions/transaction-columns.tsx +++ b/resources/js/components/transactions/transaction-columns.tsx @@ -175,7 +175,8 @@ export function createTransactionColumns({ return
Amount
; }, cell: ({ row }) => { - const amount = parseFloat(row.getValue('amount')); + const amountInCents = row.getValue('amount') as number; + const amount = amountInCents / 100; const currencyCode = row.original.currency_code; const formatted = new Intl.NumberFormat('en-US', { @@ -200,7 +201,7 @@ export function createTransactionColumns({ size: 35, maxSize: 35, minSize: 35, - meta: { + meta: { cellClassName: '!w-[35px] !max-w-[35px] !min-w-[35px] !p-0 whitespace-normal', cellStyle: { width: '35px', maxWidth: '35px', minWidth: '35px', padding: 0 } }, diff --git a/resources/js/components/ui/amount-input.tsx b/resources/js/components/ui/amount-input.tsx new file mode 100644 index 00000000..48ec4614 --- /dev/null +++ b/resources/js/components/ui/amount-input.tsx @@ -0,0 +1,123 @@ +import * as React from 'react'; + +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from '@/components/ui/input-group'; + +interface AmountInputProps { + value: number; + onChange: (valueInCents: number) => void; + currencyCode: string; + disabled?: boolean; + required?: boolean; + placeholder?: string; + id?: string; +} + +const getCurrencySymbol = (currencyCode: string): string => { + const symbols: Record = { + USD: '$', + EUR: '€', + GBP: '£', + JPY: '¥', + }; + return symbols[currencyCode] || currencyCode; +}; + +export const AmountInput = React.forwardRef( + ( + { + value, + onChange, + currencyCode, + disabled = false, + required = false, + placeholder = '0.00', + id, + }, + ref, + ) => { + const [displayValue, setDisplayValue] = React.useState(''); + + React.useEffect(() => { + if (value === 0) { + setDisplayValue(''); + } else { + setDisplayValue((value / 100).toFixed(2)); + } + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + + if (inputValue === '' || inputValue === '-') { + setDisplayValue(inputValue); + onChange(0); + return; + } + + const numericValue = inputValue.replace(/[^\d.-]/g, ''); + + if (numericValue === '' || numericValue === '-') { + setDisplayValue(numericValue); + onChange(0); + return; + } + + const parsedValue = parseFloat(numericValue); + + if (isNaN(parsedValue)) { + return; + } + + setDisplayValue(numericValue); + + const valueInCents = Math.round(parsedValue * 100); + onChange(valueInCents); + }; + + const handleBlur = () => { + if (displayValue === '' || displayValue === '-') { + setDisplayValue(''); + onChange(0); + return; + } + + const parsedValue = parseFloat(displayValue); + if (!isNaN(parsedValue)) { + setDisplayValue(parsedValue.toFixed(2)); + } + }; + + return ( + + + + {getCurrencySymbol(currencyCode)} + + + + + {currencyCode} + + + ); + }, +); + +AmountInput.displayName = 'AmountInput'; + diff --git a/resources/js/components/ui/input-group.tsx b/resources/js/components/ui/input-group.tsx new file mode 100644 index 00000000..2dab395c --- /dev/null +++ b/resources/js/components/ui/input-group.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const InputGroup = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( +
+ ); +}); +InputGroup.displayName = 'InputGroup'; + +const InputGroupAddon = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { + align?: 'inline-start' | 'inline-end' | 'block-end'; + } +>(({ className, align = 'inline-start', ...props }, ref) => { + return ( +
+ ); +}); +InputGroupAddon.displayName = 'InputGroupAddon'; + +const InputGroupText = React.forwardRef< + HTMLSpanElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( + + ); +}); +InputGroupText.displayName = 'InputGroupText'; + +const InputGroupInput = React.forwardRef< + HTMLInputElement, + React.InputHTMLAttributes +>(({ className, type = 'text', ...props }, ref) => { + return ( + + ); +}); +InputGroupInput.displayName = 'InputGroupInput'; + +const InputGroupTextarea = React.forwardRef< + HTMLTextAreaElement, + React.TextareaHTMLAttributes +>(({ className, ...props }, ref) => { + return ( +