48 - Remove asset and price-tracking subsystem

This commit is contained in:
myrmidex 2026-08-15 21:34:19 +02:00
parent 3a28988ae7
commit 671cdb66ba
12 changed files with 154 additions and 470 deletions

View file

@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Asset;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AssetController extends Controller
{
public function index(): JsonResponse
{
return response()->json(Asset::orderBy('symbol')->get());
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'symbol' => 'required|string|max:10|unique:assets,symbol',
'full_name' => 'nullable|string|max:255',
]);
$asset = Asset::create([
'symbol' => strtoupper($validated['symbol']),
'full_name' => $validated['full_name'],
]);
return response()->json([
'success' => true,
'message' => 'Asset created successfully!',
'asset' => $asset,
], 201);
}
public function show(Asset $asset): JsonResponse
{
$asset->load('assetPrices');
return response()->json([
'asset' => $asset,
'current_price' => $asset->currentPrice(),
]);
}
public function search(Request $request): JsonResponse
{
$query = $request->get('q');
if (! $query) {
return response()->json([]);
}
$assets = Asset::where('symbol', 'like', "%{$query}%")
->orWhere('full_name', 'like', "%{$query}%")
->orderBy('symbol')
->limit(10)
->get();
return response()->json($assets);
}
}

View file

@ -1,66 +0,0 @@
<?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 ?Tracker $tracker;
public function __construct()
{
$this->tracker = User::default()->tracker;
}
public function current(): JsonResponse
{
return response()->json([
'current_price' => AssetPrice::current($this->tracker?->asset_id),
]);
}
public function update(Request $request)
{
$validated = $request->validate([
'date' => 'required|date|before_or_equal:today',
'price' => 'required|numeric|min:0.0001',
]);
if (! $this->tracker?->asset_id) {
return back()->withErrors(['asset' => 'Please set an asset first.']);
}
AssetPrice::updatePrice($this->tracker->asset_id, $validated['date'], $validated['price']);
if (! $this->tracker->price_tracking_enabled) {
$this->tracker->update(['price_tracking_enabled' => true]);
}
return back()->with('success', 'Asset price updated successfully!');
}
public function history(Request $request): JsonResponse
{
$limit = min(max(1, $request->integer('limit', 30)), 365);
return response()->json(AssetPrice::history($this->tracker?->asset_id, $limit));
}
public function forDate(Request $request, string $date): JsonResponse
{
validator(['date' => $date], ['date' => 'required|date_format:Y-m-d'])->validate();
return response()->json([
'date' => $date,
'price' => AssetPrice::forDate($date, $this->tracker?->asset_id),
]);
}
}

View file

@ -4,10 +4,8 @@
namespace App\Http\Controllers;
use App\Models\Asset;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TrackerController extends Controller
@ -20,7 +18,7 @@ public function show(): JsonResponse
return response()->json(['exists' => false]);
}
return response()->json(['exists' => true, 'tracker' => $tracker->load('asset')]);
return response()->json(['exists' => true, 'tracker' => $tracker]);
}
public function store(Request $request): JsonResponse
@ -28,9 +26,6 @@ public function store(Request $request): JsonResponse
$validated = $request->validate([
'label' => 'sometimes|string|max:255',
'unit' => 'sometimes|string|max:50',
'price_tracking_enabled' => 'boolean',
'symbol' => 'nullable|string|max:10',
'full_name' => 'nullable|string|max:255',
]);
$user = User::default();
@ -39,63 +34,29 @@ public function store(Request $request): JsonResponse
return response()->json(['error' => 'Tracker already exists.'], 409);
}
$assetId = null;
if (! empty($validated['symbol'])) {
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
$assetId = $asset->id;
}
$tracker = $user->tracker()->create([
'label' => $validated['label'] ?? 'Counter',
'unit' => $validated['unit'] ?? 'units',
'price_tracking_enabled' => $validated['price_tracking_enabled'] ?? false,
'asset_id' => $assetId,
]);
return response()->json($tracker->load('asset'), 201);
return response()->json($tracker, 201);
}
public function update(Request $request): RedirectResponse|JsonResponse
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 back()->withErrors(['tracker' => 'No tracker found.']);
return response()->json(['error' => 'No counter 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;
}
}
$tracker->update($validated);
$update = [];
if (isset($validated['label'])) {
$update['label'] = $validated['label'];
}
if (isset($validated['unit'])) {
$update['unit'] = $validated['unit'];
}
if (array_key_exists('price_tracking_enabled', $validated)) {
$update['price_tracking_enabled'] = $validated['price_tracking_enabled'];
}
if (array_key_exists('symbol', $validated)) {
$update['asset_id'] = $tracker->asset_id;
}
$tracker->update($update);
return back();
return response()->json($tracker);
}
}

View file

@ -1,63 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @method static create(array $array)
* @method static where(string $string, string $value)
* @method static find(int $id)
* @method static orderBy(string $string)
*
* @property int $id
* @property string $symbol
* @property string|null $full_name
*/
class Asset extends Model
{
use HasFactory;
protected $fillable = [
'symbol',
'full_name',
];
protected $casts = [
'symbol' => 'string',
'full_name' => 'string',
];
public function assetPrices(): HasMany
{
return $this->hasMany(Pricing\AssetPrice::class);
}
public function currentPrice(): ?float
{
$latestPrice = $this->assetPrices()->latest('date')->first();
return $latestPrice ? $latestPrice->price : null;
}
public static function findBySymbol(string $symbol): ?self
{
return static::where('symbol', strtoupper($symbol))->first();
}
public static function findOrCreateBySymbol(string $symbol, ?string $fullName = null): self
{
$asset = static::findBySymbol($symbol);
if (! $asset) {
$asset = static::create([
'symbol' => strtoupper($symbol),
'full_name' => $fullName,
]);
}
return $asset;
}
}

View file

@ -1,86 +0,0 @@
<?php
namespace App\Models\Pricing;
use App\Models\Asset;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @method static latest(string $string)
* @method static where(string $string, string $string1, string $date)
* @method static updateOrCreate(string[] $array, float[] $array1)
* @method static orderBy(string $string, string $string1)
*
* @property Carbon $date
* @property float $price
*/
class AssetPrice extends Model
{
use HasFactory;
protected $fillable = [
'asset_id',
'date',
'price',
];
protected $casts = [
'date' => 'date',
'price' => 'decimal:4',
];
public function asset(): BelongsTo
{
return $this->belongsTo(Asset::class);
}
public static function current(?int $assetId = null): ?float
{
$query = static::latest('date');
if ($assetId) {
$query->where('asset_id', $assetId);
}
$latestPrice = $query->first();
return $latestPrice ? $latestPrice->price : null;
}
public static function forDate(string $date, ?int $assetId = null): ?float
{
$query = static::where('date', '<=', $date)
->orderBy('date', 'desc');
if ($assetId) {
$query->where('asset_id', $assetId);
}
$price = $query->first();
return $price ? $price->price : null;
}
public static function updatePrice(int $assetId, string $date, float $price): self
{
return static::updateOrCreate(
['asset_id' => $assetId, 'date' => $date],
['price' => $price]
);
}
public static function history(?int $assetId = null, int $limit = 30): Collection
{
$query = static::orderBy('date', 'desc')->limit($limit);
if ($assetId) {
$query->where('asset_id', $assetId);
}
return $query->get();
}
}

View file

@ -16,18 +16,15 @@ class Tracker extends Model
protected $fillable = [
'user_id',
'asset_id',
'label',
'unit',
'count',
'price_tracking_enabled',
];
protected function casts(): array
{
return [
'count' => 'integer',
'price_tracking_enabled' => 'boolean',
];
}
@ -35,9 +32,4 @@ public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function asset(): BelongsTo
{
return $this->belongsTo(Asset::class);
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('trackers', function (Blueprint $table): void {
$table->dropForeign(['asset_id']);
$table->dropColumn(['asset_id', 'price_tracking_enabled']);
});
Schema::dropIfExists('asset_prices');
Schema::dropIfExists('assets');
}
// Lossy by design: recorded symbols and prices cannot be recovered.
public function down(): void
{
Schema::create('assets', function (Blueprint $table): void {
$table->id();
$table->string('symbol')->unique();
$table->string('full_name')->nullable();
$table->timestamps();
$table->index('symbol');
});
Schema::create('asset_prices', function (Blueprint $table): void {
$table->id();
$table->foreignId('asset_id')->constrained()->onDelete('cascade');
$table->date('date');
$table->decimal('price', 10, 4);
$table->timestamps();
$table->unique(['asset_id', 'date']);
$table->index('asset_id');
$table->index('date');
});
Schema::table('trackers', function (Blueprint $table): void {
$table->foreignId('asset_id')->nullable()->after('user_id')->constrained()->nullOnDelete();
$table->boolean('price_tracking_enabled')->default(false);
});
}
};

View file

@ -1,113 +0,0 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler, useState } from 'react';
import ComponentTitle from '@/components/ui/ComponentTitle';
interface CreateTrackerStepProps {
onSuccess: () => void;
}
export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps) {
const [label, setLabel] = useState('');
const [unit, setUnit] = useState('');
const [processing, setProcessing] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const submit: FormEventHandler = async (e) => {
e.preventDefault();
setProcessing(true);
setErrors({});
try {
const response = await fetch(route('tracker.store'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
'Accept': 'application/json',
},
body: JSON.stringify({
label,
unit,
price_tracking_enabled: 0,
}),
});
if (response.ok || response.status === 201 || response.status === 409) {
onSuccess();
} else {
const data = await response.json();
if (data.errors) {
setErrors(data.errors);
} else if (data.message) {
setErrors({ label: data.message });
}
}
} catch {
setErrors({ label: 'Something went wrong. Please try again.' });
} finally {
setProcessing(false);
}
};
return (
<div className="w-full">
<div className="space-y-4">
<ComponentTitle>SET UP YOUR TRACKER</ComponentTitle>
<p className="text-sm text-red-400/60 font-mono">
[SYSTEM] What are you tracking?
</p>
<form onSubmit={submit} className="space-y-4">
<div>
<Label htmlFor="label" className="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Tracker Name
</Label>
<Input
id="label"
type="text"
placeholder="My Portfolio"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
/>
<p className="text-xs text-red-400/60 mt-1 font-mono">
[REQUIRED] e.g. "My Portfolio", "Books Read", "KM Run"
</p>
<InputError message={errors.label} />
</div>
<div>
<Label htmlFor="unit" className="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Unit
</Label>
<Input
id="unit"
type="text"
placeholder="shares"
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
/>
<p className="text-xs text-red-400/60 mt-1 font-mono">
[REQUIRED] e.g. "shares", "books", "km"
</p>
<InputError message={errors.unit} />
</div>
<Button
type="submit"
disabled={processing || !label || !unit}
className="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
[INITIALIZE]
</Button>
</form>
</div>
</div>
);
}

View file

@ -1,13 +1,6 @@
export interface TrackerAsset {
id: number;
symbol: string;
full_name: string | null;
}
export interface Tracker {
id: number;
label: string;
unit: string;
price_tracking_enabled: boolean;
asset: TrackerAsset | null;
count: number;
}

View file

@ -1,8 +1,6 @@
<?php
use App\Http\Controllers\AssetController;
use App\Http\Controllers\CounterController;
use App\Http\Controllers\Pricing\PricingController;
use App\Http\Controllers\TrackerController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@ -20,24 +18,8 @@
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('/search', [AssetController::class, 'search'])->name('search');
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
});
// Counter routes
Route::post('/increment', [CounterController::class, 'increment'])->name('counter.increment');
Route::patch('/count', [CounterController::class, 'update'])->name('counter.update');
// Pricing routes
Route::prefix('pricing')->name('pricing.')->group(function () {
Route::get('/current', [PricingController::class, 'current'])->name('current');
Route::post('/update', [PricingController::class, 'update'])->name('update');
Route::get('/history', [PricingController::class, 'history'])->name('history');
Route::get('/date/{date}', [PricingController::class, 'forDate'])->name('for-date');
});
require __DIR__.'/auth.php';

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DropAssetsMigrationTest extends TestCase
{
use RefreshDatabase;
// Each require returns a fresh anonymous-class instance; there is no name to collide.
private function migration(): object
{
return require __DIR__.'/../../database/migrations/2026_08_15_000003_drop_assets_and_pricing.php';
}
public function test_the_migrated_schema_has_no_asset_or_price_columns(): void
{
$this->assertFalse(Schema::hasTable('assets'));
$this->assertFalse(Schema::hasTable('asset_prices'));
$this->assertFalse(Schema::hasColumn('trackers', 'asset_id'));
$this->assertFalse(Schema::hasColumn('trackers', 'price_tracking_enabled'));
}
public function test_down_restores_the_tables_and_columns(): void
{
$this->migration()->down();
$this->assertTrue(Schema::hasTable('assets'));
$this->assertTrue(Schema::hasTable('asset_prices'));
$this->assertTrue(Schema::hasColumn('trackers', 'asset_id'));
$this->assertTrue(Schema::hasColumn('trackers', 'price_tracking_enabled'));
}
public function test_up_drops_them_again_respecting_foreign_keys(): void
{
$this->migration()->down();
$this->migration()->up();
$this->assertFalse(Schema::hasTable('assets'));
$this->assertFalse(Schema::hasTable('asset_prices'));
$this->assertFalse(Schema::hasColumn('trackers', 'asset_id'));
$this->assertFalse(Schema::hasColumn('trackers', 'price_tracking_enabled'));
}
public function test_dropping_assets_does_not_disturb_the_counter(): void
{
$this->migration()->down();
$userId = DB::table('users')->insertGetId([
'name' => 'Test',
'email' => 'assets@example.test',
'password' => 'x',
'created_at' => now(),
'updated_at' => now(),
]);
$trackerId = DB::table('trackers')->insertGetId([
'user_id' => $userId,
'label' => 'Counter',
'unit' => 'units',
'count' => 31,
'created_at' => now(),
'updated_at' => now(),
]);
$this->migration()->up();
$this->assertSame(31, (int) DB::table('trackers')->where('id', $trackerId)->value('count'));
}
}

View file

@ -58,4 +58,24 @@ public function test_creating_a_second_tracker_conflicts(): void
$this->assertSame(1, Tracker::count());
}
public function test_label_and_unit_can_be_updated(): void
{
$tracker = Tracker::factory()->create(['count' => 4]);
$this->patchJson('/tracker', ['label' => 'Books', 'unit' => 'books'])
->assertOk()
->assertJson(['label' => 'Books', 'unit' => 'books']);
$tracker->refresh();
$this->assertSame('Books', $tracker->label);
$this->assertSame('books', $tracker->unit);
$this->assertSame(4, $tracker->count, 'updating the label must not disturb the count');
}
public function test_update_returns_404_without_a_tracker(): void
{
$this->patchJson('/tracker', ['label' => 'Books'])->assertNotFound();
}
}