Update transaction amount to bigint

This commit is contained in:
Víctor Falcón 2025-11-15 20:53:47 +01:00
parent bec18b925b
commit dcb78d2252
16 changed files with 311 additions and 63 deletions

View File

@ -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.',

View File

@ -30,7 +30,7 @@ class Transaction extends Model
{
return [
'transaction_date' => 'date',
'amount' => 'decimal:2',
'amount' => 'integer',
];
}

View File

@ -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}'),

View File

@ -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();
});
}

View File

@ -0,0 +1,30 @@
<?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
{
DB::table('transactions')->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();
});
}
};

View File

@ -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<number>(0);
const [accountId, setAccountId] = useState<string>('');
const [categoryId, setCategoryId] = useState<string>('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
</Label>
{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>
)}
</>
<AmountInput
id="amount"
value={amount}
onChange={setAmount}
currencyCode={
selectedAccount?.currency_code || 'USD'
}
disabled={isSubmitting}
required
/>
) : (
<div className="text-sm font-medium">
{transaction &&
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: transaction.currency_code,
}).format(
parseFloat(transaction.amount),
)}
}).format(transaction.amount / 100)}
</div>
)}
</div>

View File

@ -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 => {

View File

@ -175,7 +175,8 @@ export function createTransactionColumns({
return <div className="w-full text-right">Amount</div>;
},
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 }
},

View File

@ -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<string, string> = {
USD: '$',
EUR: '€',
GBP: '£',
JPY: '¥',
};
return symbols[currencyCode] || currencyCode;
};
export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
(
{
value,
onChange,
currencyCode,
disabled = false,
required = false,
placeholder = '0.00',
id,
},
ref,
) => {
const [displayValue, setDisplayValue] = React.useState<string>('');
React.useEffect(() => {
if (value === 0) {
setDisplayValue('');
} else {
setDisplayValue((value / 100).toFixed(2));
}
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<InputGroup>
<InputGroupAddon>
<InputGroupText>
{getCurrencySymbol(currencyCode)}
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
ref={ref}
id={id}
type="text"
inputMode="decimal"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
required={required}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>{currencyCode}</InputGroupText>
</InputGroupAddon>
</InputGroup>
);
},
);
AmountInput.displayName = 'AmountInput';

View File

@ -0,0 +1,99 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const InputGroup = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'relative flex w-full items-stretch overflow-hidden rounded-lg border border-input bg-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
className,
)}
{...props}
/>
);
});
InputGroup.displayName = 'InputGroup';
const InputGroupAddon = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
align?: 'inline-start' | 'inline-end' | 'block-end';
}
>(({ className, align = 'inline-start', ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center bg-muted px-3 text-sm text-muted-foreground',
align === 'inline-end' && 'order-last',
align === 'block-end' && 'order-last w-full border-t',
className,
)}
{...props}
/>
);
});
InputGroupAddon.displayName = 'InputGroupAddon';
const InputGroupText = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(({ className, ...props }, ref) => {
return (
<span
ref={ref}
className={cn('whitespace-nowrap', className)}
{...props}
/>
);
});
InputGroupText.displayName = 'InputGroupText';
const InputGroupInput = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type = 'text', ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full flex-1 border-0 bg-transparent px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
});
InputGroupInput.displayName = 'InputGroupInput';
const InputGroupTextarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full flex-1 border-0 bg-transparent px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
});
InputGroupTextarea.displayName = 'InputGroupTextarea';
export {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
};

View File

@ -516,7 +516,7 @@ export function convertRowsToTransactions(
results.push({
transaction_date: formattedDate,
description,
amount,
amount: Math.round(amount * 100),
balance,
validationErrors: [],
});

View File

@ -75,7 +75,7 @@ export function prepareTransactionData(
return {
description: normalizeWhitespace((transaction.decryptedDescription || '').toLowerCase()),
amount: parseFloat(transaction.amount),
amount: transaction.amount / 100,
transaction_date: transaction.transaction_date,
bank_name: bank?.name || '',
account_name: account?.name || '',

View File

@ -396,13 +396,13 @@ export default function Transactions({ categories, accounts, banks }: Props) {
if (
filters.amountMin !== null &&
parseFloat(transaction.amount) < filters.amountMin
transaction.amount / 100 < filters.amountMin
) {
return false;
}
if (
filters.amountMax !== null &&
parseFloat(transaction.amount) > filters.amountMax
transaction.amount / 100 > filters.amountMax
) {
return false;
}

View File

@ -9,7 +9,7 @@ export interface Transaction {
description: string;
description_iv: string;
transaction_date: string;
amount: string;
amount: number;
currency_code: string;
notes: string | null;
notes_iv: string | null;

View File

@ -79,7 +79,7 @@ it('can create a transaction', function () {
'description' => 'encrypted_description',
'description_iv' => '1234567890123456',
'transaction_date' => now()->toDateString(),
'amount' => '100.50',
'amount' => 10050,
'currency_code' => 'USD',
'notes' => null,
'notes_iv' => null,
@ -109,7 +109,7 @@ it('can create a transaction', function () {
'account_id' => $account->id,
'category_id' => $category->id,
'description' => 'encrypted_description',
'amount' => '100.50',
'amount' => 10050,
]);
});
@ -125,7 +125,7 @@ it('can create a transaction with a UUID', function () {
'description' => 'encrypted_description',
'description_iv' => '1234567890123456',
'transaction_date' => now()->toDateString(),
'amount' => '100.50',
'amount' => 10050,
'currency_code' => 'USD',
'notes' => null,
'notes_iv' => null,
@ -155,7 +155,7 @@ it('can update a transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$transaction = Transaction::factory()->for($user)->for($account)->create([
'amount' => '100.00',
'amount' => 10000,
'description' => 'old_description',
]);
@ -165,7 +165,7 @@ it('can update a transaction', function () {
'description' => 'new_description',
'description_iv' => $transaction->description_iv,
'transaction_date' => $transaction->transaction_date->toDateString(),
'amount' => '200.00',
'amount' => 20000,
'currency_code' => $transaction->currency_code,
'notes' => null,
'notes_iv' => null,
@ -174,12 +174,12 @@ it('can update a transaction', function () {
$response = $this->actingAs($user)->patchJson("/api/sync/transactions/{$transaction->id}", $updateData);
$response->assertSuccessful()
->assertJsonPath('data.amount', '200.00')
->assertJsonPath('data.amount', 20000)
->assertJsonPath('data.description', 'new_description');
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'amount' => '200.00',
'amount' => 20000,
'description' => 'new_description',
]);
});
@ -196,7 +196,7 @@ it('cannot update another user transaction', function () {
'description' => 'hacked',
'description_iv' => $transaction->description_iv,
'transaction_date' => $transaction->transaction_date->toDateString(),
'amount' => '999.99',
'amount' => 99999,
'currency_code' => $transaction->currency_code,
'notes' => null,
'notes_iv' => null,

View File

@ -226,7 +226,7 @@ test('users can create a new transaction', function () {
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '150.50',
'amount' => 15050,
'currency_code' => 'USD',
'notes' => 'encrypted_notes',
'notes_iv' => str_repeat('n', 16),
@ -258,7 +258,7 @@ test('users can create a new transaction', function () {
'account_id' => $account->id,
'category_id' => $category->id,
'description' => 'encrypted_description',
'amount' => '150.50',
'amount' => 15050,
'currency_code' => 'USD',
]);
});
@ -273,7 +273,7 @@ test('users can create a transaction without category', function () {
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '75.25',
'amount' => 7525,
'currency_code' => 'EUR',
];
@ -285,7 +285,7 @@ test('users can create a transaction without category', function () {
'account_id' => $account->id,
'category_id' => null,
'description' => 'encrypted_description',
'amount' => '75.25',
'amount' => 7525,
]);
});
@ -298,7 +298,7 @@ test('users can create a transaction without notes', function () {
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '100.00',
'amount' => 10000,
'currency_code' => 'USD',
];
@ -320,7 +320,7 @@ test('account_id is required when creating transaction', function () {
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '100.00',
'amount' => 10000,
'currency_code' => 'USD',
];
@ -338,7 +338,7 @@ test('description is required when creating transaction', function () {
'account_id' => $account->id,
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '100.00',
'amount' => 10000,
'currency_code' => 'USD',
];
@ -374,7 +374,7 @@ test('transaction_date is required when creating transaction', function () {
'account_id' => $account->id,
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'amount' => '100.00',
'amount' => 10000,
'currency_code' => 'USD',
];
@ -393,7 +393,7 @@ test('currency_code is required when creating transaction', function () {
'description' => 'encrypted_description',
'description_iv' => str_repeat('d', 16),
'transaction_date' => '2025-11-11',
'amount' => '100.00',
'amount' => 10000,
];
$response = actingAs($user)->postJson(route('transactions.store'), $transactionData);