Transactions

This commit is contained in:
myrmidex 2025-07-10 17:20:48 +02:00
parent 85d19cf1c8
commit 52f8ae2fd1
5 changed files with 291 additions and 4 deletions

View file

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use App\Models\Transactions\Purchase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
class PurchaseController extends Controller
{
public function index(): JsonResponse
{
$purchases = Purchase::orderBy('date', 'desc')->get();
return response()->json($purchases);
}
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'date' => 'required|date|before_or_equal:today',
'shares' => 'required|numeric|min:0.000001',
'price_per_share' => 'required|numeric|min:0.01',
'total_cost' => 'required|numeric|min:0.01',
]);
// Verify calculation is correct
$calculatedTotal = $validated['shares'] * $validated['price_per_share'];
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
return back()->withErrors([
'total_cost' => 'Total cost does not match shares × price per share.'
]);
}
Purchase::create([
'date' => $validated['date'],
'shares' => $validated['shares'],
'price_per_share' => $validated['price_per_share'],
'total_cost' => $validated['total_cost'],
]);
return Redirect::back()->with('success', 'Purchase added successfully!');
}
public function summary()
{
$totalShares = Purchase::totalShares();
$totalInvestment = Purchase::totalInvestment();
$averageCost = Purchase::averageCostPerShare();
return response()->json([
'total_shares' => $totalShares,
'total_investment' => $totalInvestment,
'average_cost_per_share' => $averageCost,
]);
}
/**
* Remove the specified purchase.
*/
public function destroy(Purchase $purchase)
{
$purchase->delete();
return Redirect::back()->with('success', 'Purchase deleted successfully!');
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Models\Transactions;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Purchase extends Model
{
use HasFactory;
protected $fillable = [
'date',
'shares',
'price_per_share',
'total_cost',
];
protected $casts = [
'date' => 'date',
'shares' => 'decimal:6',
'price_per_share' => 'decimal:4',
'total_cost' => 'decimal:2',
];
/**
* Calculate total shares
*/
public static function totalShares(): float
{
return static::sum('shares');
}
/**
* Calculate total investment
*/
public static function totalInvestment(): float
{
return static::sum('total_cost');
}
/**
* Get average cost per share
*/
public static function averageCostPerShare(): float
{
$totalShares = static::totalShares();
$totalCost = static::totalInvestment();
return $totalShares > 0 ? $totalCost / $totalShares : 0;
}
}

View file

@ -0,0 +1,33 @@
<?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::create('purchases', function (Blueprint $table) {
$table->id();
$table->date('date');
$table->decimal('shares', 12, 6); // Supports fractional shares
$table->decimal('price_per_share', 8, 4); // Price in euros
$table->decimal('total_cost', 12, 2); // Total cost in euros
$table->timestamps();
$table->index('date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('purchases');
}
};

View file

@ -0,0 +1,126 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler, useEffect } from 'react';
interface PurchaseFormData {
date: string;
shares: string;
price_per_share: string;
total_cost: string;
}
export default function AddPurchaseForm() {
const { data, setData, post, processing, errors, reset } = useForm<PurchaseFormData>({
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
shares: '',
price_per_share: '',
total_cost: '',
});
// Auto-calculate total cost when shares or price changes
useEffect(() => {
if (data.shares && data.price_per_share) {
const shares = parseFloat(data.shares);
const pricePerShare = parseFloat(data.price_per_share);
if (!isNaN(shares) && !isNaN(pricePerShare)) {
const totalCost = (shares * pricePerShare).toFixed(2);
setData('total_cost', totalCost);
}
}
}, [data.shares, data.price_per_share, setData]);
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('purchases.store'), {
onSuccess: () => {
reset();
setData('date', new Date().toISOString().split('T')[0]);
},
});
};
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Add VWCE Purchase</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div>
<Label htmlFor="date">Purchase Date</Label>
<Input
id="date"
type="date"
value={data.date}
onChange={(e) => setData('date', e.target.value)}
max={new Date().toISOString().split('T')[0]}
/>
<InputError message={errors.date} />
</div>
<div>
<Label htmlFor="shares">Number of Shares</Label>
<Input
id="shares"
type="number"
step="0.000001"
min="0"
placeholder="1.234567"
value={data.shares}
onChange={(e) => setData('shares', e.target.value)}
/>
<InputError message={errors.shares} />
</div>
<div>
<Label htmlFor="price_per_share">Price per Share ()</Label>
<Input
id="price_per_share"
type="number"
step="0.01"
min="0"
placeholder="123.45"
value={data.price_per_share}
onChange={(e) => setData('price_per_share', e.target.value)}
/>
<InputError message={errors.price_per_share} />
</div>
<div>
<Label htmlFor="total_cost">Total Cost ()</Label>
<Input
id="total_cost"
type="number"
step="0.01"
min="0"
placeholder="1234.56"
value={data.total_cost}
onChange={(e) => setData('total_cost', e.target.value)}
className="bg-neutral-50 dark:bg-neutral-800"
/>
<p className="text-xs text-neutral-500 mt-1">
Auto-calculated from shares × price
</p>
<InputError message={errors.total_cost} />
</div>
<Button
type="submit"
disabled={processing}
className="w-full"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
Add Purchase
</Button>
</form>
</CardContent>
</Card>
);
}

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\Transactions\PurchaseController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
@ -7,10 +8,16 @@
return Inertia::render('welcome'); return Inertia::render('welcome');
})->name('home'); })->name('home');
Route::middleware(['auth', 'verified'])->group(function () { Route::get('dashboard', function () {
Route::get('dashboard', function () {
return Inertia::render('dashboard'); return Inertia::render('dashboard');
})->name('dashboard'); })->name('dashboard');
// Purchase routes
Route::prefix('purchases')->name('purchases.')->group(function () {
Route::get('/', [PurchaseController::class, 'index'])->name('index');
Route::post('/', [PurchaseController::class, 'store'])->name('store');
Route::get('/summary', [PurchaseController::class, 'summary'])->name('summary');
Route::delete('/{purchase}', [PurchaseController::class, 'destroy'])->name('destroy');
}); });
require __DIR__.'/settings.php'; require __DIR__.'/settings.php';