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
|
<?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';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue