32 - Backend: Tracker/Entry models, TrackerController, EntryController, update routes

This commit is contained in:
myrmidex 2026-05-02 17:10:00 +02:00
parent b66e018b3a
commit 22e3394cb1
14 changed files with 319 additions and 211 deletions

View file

@ -1,9 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Asset;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -11,36 +12,7 @@ class AssetController extends Controller
{
public function index(): JsonResponse
{
$assets = Asset::orderBy('symbol')->get();
return response()->json($assets);
}
public function current(): JsonResponse
{
$user = User::default();
return response()->json([
'asset' => $user->asset,
'price_tracking_enabled' => $user->price_tracking_enabled,
]);
}
public function setCurrent(Request $request)
{
$validated = $request->validate([
'symbol' => 'required|string|max:10',
'full_name' => 'nullable|string|max:255',
]);
$asset = Asset::findOrCreateBySymbol(
$validated['symbol'],
$validated['full_name'] ?? null
);
User::default()->update(['asset_id' => $asset->id]);
return back()->with('success', 'Asset set successfully!');
return response()->json(Asset::orderBy('symbol')->get());
}
public function store(Request $request): JsonResponse
@ -65,11 +37,10 @@ public function store(Request $request): JsonResponse
public function show(Asset $asset): JsonResponse
{
$asset->load('assetPrices');
$currentPrice = $asset->currentPrice();
return response()->json([
'asset' => $asset,
'current_price' => $currentPrice,
'current_price' => $asset->currentPrice(),
]);
}

View file

@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Milestones;
use App\Http\Controllers\Controller;
use App\Models\Milestone;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -17,15 +19,25 @@ public function store(Request $request): RedirectResponse
'description' => 'required|string|max:255',
]);
Milestone::create($validated);
$tracker = User::default()->tracker;
if (! $tracker) {
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
}
$tracker->milestones()->create($validated);
return back()->with('success', 'Milestone created successfully');
}
public function index(): JsonResponse
{
$milestones = Milestone::orderBy('target')->get();
$tracker = User::default()->tracker;
return response()->json($milestones);
if (! $tracker) {
return response()->json([]);
}
return response()->json($tracker->milestones()->orderBy('target')->get());
}
}

View file

@ -1,26 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Pricing;
use App\Http\Controllers\Controller;
use App\Models\Pricing\AssetPrice;
use App\Models\Tracker;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PricingController extends Controller
{
private User $user;
private ?Tracker $tracker;
public function __construct()
{
$this->user = User::default();
$this->tracker = User::default()->tracker;
}
public function current(): JsonResponse
{
return response()->json([
'current_price' => AssetPrice::current($this->user->asset_id),
'current_price' => AssetPrice::current($this->tracker?->asset_id),
]);
}
@ -31,14 +34,14 @@ public function update(Request $request)
'price' => 'required|numeric|min:0.0001',
]);
if (! $this->user->asset_id) {
if (! $this->tracker?->asset_id) {
return back()->withErrors(['asset' => 'Please set an asset first.']);
}
AssetPrice::updatePrice($this->user->asset_id, $validated['date'], $validated['price']);
AssetPrice::updatePrice($this->tracker->asset_id, $validated['date'], $validated['price']);
if (! $this->user->price_tracking_enabled) {
$this->user->update(['price_tracking_enabled' => true]);
if (! $this->tracker->price_tracking_enabled) {
$this->tracker->update(['price_tracking_enabled' => true]);
}
return back()->with('success', 'Asset price updated successfully!');
@ -48,7 +51,7 @@ public function history(Request $request): JsonResponse
{
$limit = min(max(1, $request->integer('limit', 30)), 365);
return response()->json(AssetPrice::history($this->user->asset_id, $limit));
return response()->json(AssetPrice::history($this->tracker?->asset_id, $limit));
}
public function forDate(Request $request, string $date): JsonResponse
@ -57,7 +60,7 @@ public function forDate(Request $request, string $date): JsonResponse
return response()->json([
'date' => $date,
'price' => AssetPrice::forDate($date, $this->user->asset_id),
'price' => AssetPrice::forDate($date, $this->tracker?->asset_id),
]);
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Asset;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TrackerController extends Controller
{
public function show(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json(null);
}
return response()->json($tracker->load('asset'));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'label' => 'required|string|max:255',
'unit' => 'required|string|max:50',
'price_tracking_enabled' => 'boolean',
'symbol' => 'nullable|string|max:10',
'full_name' => 'nullable|string|max:255',
]);
$user = User::default();
$assetId = null;
if (! empty($validated['symbol'])) {
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
$assetId = $asset->id;
}
if ($user->tracker) {
return response()->json(['error' => 'Tracker already exists.'], 409);
}
$tracker = $user->tracker()->create([
'label' => $validated['label'],
'unit' => $validated['unit'],
'price_tracking_enabled' => $validated['price_tracking_enabled'] ?? false,
'asset_id' => $assetId,
]);
return response()->json($tracker->load('asset'), 201);
}
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'label' => 'sometimes|string|max:255',
'unit' => 'sometimes|string|max:50',
'price_tracking_enabled' => 'sometimes|boolean',
'symbol' => 'nullable|string|max:10',
'full_name' => 'nullable|string|max:255',
]);
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json(['error' => 'No tracker found.'], 404);
}
if (array_key_exists('symbol', $validated)) {
if ($validated['symbol']) {
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
$tracker->asset_id = $asset->id;
} else {
$tracker->asset_id = null;
}
}
$update = array_filter([
'label' => $validated['label'] ?? null,
'unit' => $validated['unit'] ?? null,
'price_tracking_enabled' => $validated['price_tracking_enabled'] ?? null,
'asset_id' => $tracker->asset_id,
], fn ($v) => $v !== null);
$tracker->update($update);
return response()->json($tracker->load('asset'));
}
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use App\Models\Transactions\Entry;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EntryController extends Controller
{
public function index(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json([]);
}
return response()->json($tracker->entries()->orderBy('date', 'desc')->get());
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'date' => 'required|date|before_or_equal:today',
'quantity' => 'required|numeric|min:0.000001',
'unit_price' => 'nullable|numeric|min:0.01',
'total_cost' => 'nullable|numeric|min:0.01',
]);
$tracker = User::default()->tracker;
if (! $tracker) {
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
}
// If unit_price and total_cost provided, verify the calculation
if (isset($validated['unit_price'], $validated['total_cost'])) {
$calculatedTotal = $validated['quantity'] * $validated['unit_price'];
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
return back()->withErrors([
'total_cost' => 'Total cost does not match quantity × unit price.',
]);
}
}
$tracker->entries()->create($validated);
return back()->with('success', 'Entry added successfully!');
}
public function summary(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json([
'total_quantity' => 0,
'total_cost' => 0,
'average_cost_per_unit' => 0,
]);
}
return response()->json([
'total_quantity' => Entry::totalQuantity($tracker->id),
'total_cost' => Entry::totalCost($tracker->id),
'average_cost_per_unit' => Entry::averageCostPerUnit($tracker->id),
]);
}
public function destroy(Entry $entry): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker || $entry->tracker_id !== $tracker->id) {
return response()->json(['error' => 'Entry not found.'], 404);
}
$entry->delete();
return response()->json([
'success' => true,
'message' => 'Entry deleted successfully!',
]);
}
}

View file

@ -1,71 +0,0 @@
<?php
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use App\Models\Transactions\Purchase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PurchaseController extends Controller
{
public function index(): JsonResponse
{
$purchases = Purchase::orderBy('date', 'desc')->get();
return response()->json($purchases);
}
public function store(Request $request)
{
$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 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 response()->json([
'success' => true,
'message' => 'Purchase deleted successfully!',
]);
}
}

View file

@ -35,11 +35,6 @@ public function assetPrices(): HasMany
return $this->hasMany(Pricing\AssetPrice::class);
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function currentPrice(): ?float
{
$latestPrice = $this->assetPrices()->latest('date')->first();

View file

@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @method static create(array $array)
@ -19,4 +20,9 @@ class Milestone extends Model
protected $casts = [
'target' => 'integer',
];
public function tracker(): BelongsTo
{
return $this->belongsTo(Tracker::class);
}
}

View file

@ -4,7 +4,10 @@
namespace App\Models;
use App\Models\Transactions\Entry;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tracker extends Model
{
@ -22,4 +25,24 @@ protected function casts(): array
'price_tracking_enabled' => 'boolean',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function asset(): BelongsTo
{
return $this->belongsTo(Asset::class);
}
public function entries(): HasMany
{
return $this->hasMany(Entry::class);
}
public function milestones(): HasMany
{
return $this->hasMany(Milestone::class);
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Models\Transactions;
use App\Models\Tracker;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Entry extends Model
{
protected $fillable = [
'tracker_id',
'date',
'quantity',
'unit_price',
'total_cost',
];
protected function casts(): array
{
return [
'date' => 'date',
'quantity' => 'decimal:6',
'unit_price' => 'decimal:4',
'total_cost' => 'decimal:2',
];
}
public function tracker(): BelongsTo
{
return $this->belongsTo(Tracker::class);
}
public static function totalQuantity(int $trackerId): float
{
return (float) static::where('tracker_id', $trackerId)->sum('quantity');
}
public static function totalCost(int $trackerId): float
{
return (float) static::where('tracker_id', $trackerId)->sum('total_cost');
}
public static function averageCostPerUnit(int $trackerId): float
{
$totalQuantity = static::totalQuantity($trackerId);
$totalCost = static::totalCost($trackerId);
return $totalQuantity > 0 ? $totalCost / $totalQuantity : 0;
}
}

View file

@ -1,52 +0,0 @@
<?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

@ -2,40 +2,23 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Models\Transactions\Purchase;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
/**
* @property int|null $asset_id
*/
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'asset_id',
'price_tracking_enabled',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
@ -46,13 +29,12 @@ protected function casts(): array
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'price_tracking_enabled' => 'boolean',
];
}
public function asset(): BelongsTo
public function tracker(): HasOne
{
return $this->belongsTo(Asset::class);
return $this->hasOne(Tracker::class);
}
public static function default(): self
@ -67,16 +49,16 @@ public static function default(): self
public function hasCompletedOnboarding(): bool
{
return $this->hasPurchases() && $this->hasMilestones();
return $this->hasEntries() && $this->hasMilestones();
}
public function hasPurchases(): bool
public function hasEntries(): bool
{
return Purchase::totalShares() > 0;
return (bool) $this->tracker?->entries()->exists();
}
public function hasMilestones(): bool
{
return Milestone::count() > 0;
return (bool) $this->tracker?->milestones()->exists();
}
}

View file

@ -3,7 +3,8 @@
use App\Http\Controllers\AssetController;
use App\Http\Controllers\Milestones\MilestoneController;
use App\Http\Controllers\Pricing\PricingController;
use App\Http\Controllers\Transactions\PurchaseController;
use App\Http\Controllers\TrackerController;
use App\Http\Controllers\Transactions\EntryController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@ -15,22 +16,25 @@
return Inertia::render('dashboard');
})->name('dashboard');
// Tracker routes
Route::get('/tracker', [TrackerController::class, 'show'])->name('tracker.show');
Route::post('/tracker', [TrackerController::class, 'store'])->name('tracker.store');
Route::patch('/tracker', [TrackerController::class, 'update'])->name('tracker.update');
// Asset routes
Route::prefix('assets')->name('assets.')->group(function () {
Route::get('/', [AssetController::class, 'index'])->name('index');
Route::post('/', [AssetController::class, 'store'])->name('store');
Route::get('/current', [AssetController::class, 'current'])->name('current');
Route::post('/set-current', [AssetController::class, 'setCurrent'])->name('set-current');
Route::get('/search', [AssetController::class, 'search'])->name('search');
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
});
// 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');
// Entry routes (replaces purchases)
Route::prefix('entries')->name('entries.')->group(function () {
Route::get('/', [EntryController::class, 'index'])->name('index');
Route::post('/', [EntryController::class, 'store'])->name('store');
Route::get('/summary', [EntryController::class, 'summary'])->name('summary');
Route::delete('/{entry}', [EntryController::class, 'destroy'])->name('destroy');
});
// Pricing routes

View file

@ -14,10 +14,8 @@ class MilestoneTest extends TestCase
private function tracker(): Tracker
{
$user = User::factory()->create();
return Tracker::create([
'user_id' => $user->id,
'user_id' => User::default()->id,
'label' => 'Test',
'unit' => 'units',
]);