Transactions
This commit is contained in:
parent
85d19cf1c8
commit
52f8ae2fd1
5 changed files with 291 additions and 4 deletions
69
app/Http/Controllers/Transactions/PurchaseController.php
Normal file
69
app/Http/Controllers/Transactions/PurchaseController.php
Normal 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!');
|
||||
}
|
||||
}
|
||||
52
app/Models/Transactions/Purchase.php
Normal file
52
app/Models/Transactions/Purchase.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
126
resources/js/components/Transactions/AddPurchaseForm.tsx
Normal file
126
resources/js/components/Transactions/AddPurchaseForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Transactions\PurchaseController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
||||
|
|
@ -7,10 +8,16 @@
|
|||
return Inertia::render('welcome');
|
||||
})->name('home');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('dashboard', function () {
|
||||
Route::get('dashboard', function () {
|
||||
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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue