From 22e3394cb109c6cf7f7e986431166d09ab84c341 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 2 May 2026 17:10:00 +0200 Subject: [PATCH] 32 - Backend: Tracker/Entry models, TrackerController, EntryController, update routes --- app/Http/Controllers/AssetController.php | 37 +------- .../Milestones/MilestoneController.php | 20 +++- .../Controllers/Pricing/PricingController.php | 21 +++-- app/Http/Controllers/TrackerController.php | 93 +++++++++++++++++++ .../Transactions/EntryController.php | 91 ++++++++++++++++++ .../Transactions/PurchaseController.php | 71 -------------- app/Models/Asset.php | 5 - app/Models/Milestone.php | 6 ++ app/Models/Tracker.php | 23 +++++ app/Models/Transactions/Entry.php | 53 +++++++++++ app/Models/Transactions/Purchase.php | 52 ----------- app/Models/User.php | 32 ++----- routes/web.php | 22 +++-- tests/Feature/MilestoneTest.php | 4 +- 14 files changed, 319 insertions(+), 211 deletions(-) create mode 100644 app/Http/Controllers/TrackerController.php create mode 100644 app/Http/Controllers/Transactions/EntryController.php delete mode 100644 app/Http/Controllers/Transactions/PurchaseController.php create mode 100644 app/Models/Transactions/Entry.php delete mode 100644 app/Models/Transactions/Purchase.php diff --git a/app/Http/Controllers/AssetController.php b/app/Http/Controllers/AssetController.php index aec988c..b8c87cf 100644 --- a/app/Http/Controllers/AssetController.php +++ b/app/Http/Controllers/AssetController.php @@ -1,9 +1,10 @@ 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(), ]); } diff --git a/app/Http/Controllers/Milestones/MilestoneController.php b/app/Http/Controllers/Milestones/MilestoneController.php index 580cb4a..dfe79fd 100644 --- a/app/Http/Controllers/Milestones/MilestoneController.php +++ b/app/Http/Controllers/Milestones/MilestoneController.php @@ -1,9 +1,11 @@ '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()); } } diff --git a/app/Http/Controllers/Pricing/PricingController.php b/app/Http/Controllers/Pricing/PricingController.php index f7089cc..e7a2ce3 100644 --- a/app/Http/Controllers/Pricing/PricingController.php +++ b/app/Http/Controllers/Pricing/PricingController.php @@ -1,26 +1,29 @@ 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), ]); } } diff --git a/app/Http/Controllers/TrackerController.php b/app/Http/Controllers/TrackerController.php new file mode 100644 index 0000000..32d0d68 --- /dev/null +++ b/app/Http/Controllers/TrackerController.php @@ -0,0 +1,93 @@ +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')); + } +} diff --git a/app/Http/Controllers/Transactions/EntryController.php b/app/Http/Controllers/Transactions/EntryController.php new file mode 100644 index 0000000..ccc8031 --- /dev/null +++ b/app/Http/Controllers/Transactions/EntryController.php @@ -0,0 +1,91 @@ +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!', + ]); + } +} diff --git a/app/Http/Controllers/Transactions/PurchaseController.php b/app/Http/Controllers/Transactions/PurchaseController.php deleted file mode 100644 index 19c3ae9..0000000 --- a/app/Http/Controllers/Transactions/PurchaseController.php +++ /dev/null @@ -1,71 +0,0 @@ -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!', - ]); - } -} diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 3710a79..719a8b5 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -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(); diff --git a/app/Models/Milestone.php b/app/Models/Milestone.php index 08c7caf..fb7a9c5 100644 --- a/app/Models/Milestone.php +++ b/app/Models/Milestone.php @@ -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); + } } diff --git a/app/Models/Tracker.php b/app/Models/Tracker.php index 8b024cb..b7170d6 100644 --- a/app/Models/Tracker.php +++ b/app/Models/Tracker.php @@ -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); + } } diff --git a/app/Models/Transactions/Entry.php b/app/Models/Transactions/Entry.php new file mode 100644 index 0000000..f5ca706 --- /dev/null +++ b/app/Models/Transactions/Entry.php @@ -0,0 +1,53 @@ + '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; + } +} diff --git a/app/Models/Transactions/Purchase.php b/app/Models/Transactions/Purchase.php deleted file mode 100644 index 7ff6ef7..0000000 --- a/app/Models/Transactions/Purchase.php +++ /dev/null @@ -1,52 +0,0 @@ - '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; - } -} diff --git a/app/Models/User.php b/app/Models/User.php index e0fd5f6..031123f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ use HasFactory, Notifiable; - /** - * The attributes that are mass assignable. - * - * @var list - */ protected $fillable = [ 'name', 'email', - 'asset_id', - 'price_tracking_enabled', ]; - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ 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(); } } diff --git a/routes/web.php b/routes/web.php index 0704ec7..dbbe194 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 diff --git a/tests/Feature/MilestoneTest.php b/tests/Feature/MilestoneTest.php index 1218dda..62d7fd1 100644 --- a/tests/Feature/MilestoneTest.php +++ b/tests/Feature/MilestoneTest.php @@ -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', ]);