From b84bc94da600eda1e67ce1f8450d712a3c70f935 Mon Sep 17 00:00:00 2001 From: hackerESQ Date: Sat, 17 Aug 2024 21:33:09 -0500 Subject: [PATCH] wip --- app/Models/Holding.php | 12 +- app/Models/Portfolio.php | 32 +--- app/Models/Transaction.php | 160 +++++++++--------- .../components/ib-skeleton-loader.blade.php | 9 + .../views/livewire/holdings-table.blade.php | 68 ++++++++ .../manage-transaction-form.blade.php | 8 +- .../livewire/transactions-list.blade.php | 64 +++---- resources/views/portfolio/show.blade.php | 26 ++- 8 files changed, 215 insertions(+), 164 deletions(-) create mode 100644 resources/views/components/ib-skeleton-loader.blade.php create mode 100644 resources/views/livewire/holdings-table.blade.php diff --git a/app/Models/Holding.php b/app/Models/Holding.php index ed7e350..90fcbb9 100644 --- a/app/Models/Holding.php +++ b/app/Models/Holding.php @@ -12,7 +12,7 @@ class Holding extends Model use HasFactory; use HasUuids; - protected $with = []; + protected $with = ['market_data']; /** * The attributes that are mass assignable. @@ -42,7 +42,7 @@ class Holding extends Model ]; /** - * get market data for holding + * Market data for holding * * @return void */ @@ -52,7 +52,7 @@ class Holding extends Model } /** - * get related transactions for holding + * Related transactions for holding * * @return void */ @@ -62,7 +62,7 @@ class Holding extends Model } /** - * get related dividends for holding + * Related dividends for holding * * @return void */ @@ -72,7 +72,7 @@ class Holding extends Model } /** - * get related portfolio for holding + * Related portfolio for holding * * @return void */ @@ -82,7 +82,7 @@ class Holding extends Model } /** - * get related splits for holding + * Related splits for holding * * @return void */ diff --git a/app/Models/Portfolio.php b/app/Models/Portfolio.php index cff312d..b05158a 100644 --- a/app/Models/Portfolio.php +++ b/app/Models/Portfolio.php @@ -11,58 +11,30 @@ class Portfolio extends Model use HasFactory; use HasUuids; - /** - * The attributes that are mass assignable. - * - * @var array - */ protected $fillable = [ 'title', 'notes', 'wishlist', ]; - /** - * - * @return void - */ protected static function boot() { parent::boot(); static::saved(function ($model) { + self::syncUsers($model); }); } - /** - * The attributes that should be hidden for arrays. - * - * @var array - */ protected $hidden = []; - /** - * The attributes that should be cast to native types. - * - * @var array - */ protected $casts = [ 'wishlist' => 'boolean' ]; - /** - * The relationships that should always be eagerly loaded. - * - * @var array - */ protected $with = ['users', 'transactions']; - /** - * The attributes that should be appended. - * - * @var array - */ protected $appends = ['owner_id']; public function users() @@ -77,7 +49,7 @@ class Portfolio extends Model public function transactions() { - return $this->hasMany(Transaction::class); + return $this->hasMany(Transaction::class)->orderBy('created_at', 'DESC'); } public function daily_change() diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 272303c..fcd8b52 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Models\MarketData; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -12,121 +13,57 @@ class Transaction extends Model use HasFactory; use HasUuids; - /** - * The attributes that are mass assignable. - * - * @var array - */ protected $fillable = [ 'symbol', 'date', - 'portfolio_id', 'transaction_type', 'quantity', 'cost_basis', - 'sale_price', - 'split' + 'sale_price' ]; - /** - * The attributes that should be hidden for arrays. - * - * @var array - */ protected $hidden = []; - /** - * The attributes that should be cast to native types. - * - * @var array - */ protected $casts = [ 'date' => 'datetime', - 'first_date' => 'datetime', - 'last_date' => 'datetime', 'split' => 'boolean', ]; - /** - * - * @return void - */ protected static function boot() { parent::boot(); static::saving(function ($transaction) { - // if sale, move cost basis to sale price if ($transaction->transaction_type == 'SELL') { - $transaction->cost_basis = $transaction->holding->average_cost_basis ?? $transaction->cost_basis; + $transaction->ensureCostBasisIsAddedToSale(); } }); static::saved(function ($transaction) { - // static::syncHolding($transaction); + $transaction->syncHolding(); }); static::deleted(function ($transaction) { - // static::syncHolding($transaction); + $transaction->syncHolding(); }); } - public static function syncHolding($transaction) { - // get the holding for a symbol and portfolio (or create one) - $holding = Holding::firstOrNew([ - 'portfolio_id' => $transaction->portfolio_id, - 'symbol' => $transaction->symbol - ], [ - 'portfolio_id' => $transaction->portfolio_id, - 'symbol' => $transaction->symbol, - 'quantity' => $transaction->quantity, - 'average_cost_basis' => $transaction->cost_basis, - 'total_cost_basis' => $transaction->quantity * $transaction->cost_basis, - ]); - - // pull existing transaction data - $query = self::where([ - 'portfolio_id' => $transaction->portfolio_id, - 'symbol' => $transaction->symbol, - ])->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) AS `qty_purchases`') - ->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS `qty_sales`') - ->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN (quantity * cost_basis) ELSE 0 END) AS `cost_basis`') - ->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN ((sale_price - cost_basis) * quantity) ELSE 0 END) AS `realized_gains`') - ->first(); - - $total_quantity = $query->qty_purchases - $query->qty_sales; - $average_cost_basis = $query->qty_purchases > 0 - ? $query->cost_basis / $query->qty_purchases - : 0; - - // update holding - $holding->fill([ - 'quantity' => $total_quantity, - 'average_cost_basis' => $average_cost_basis, - 'total_cost_basis' => $total_quantity * $average_cost_basis, - 'realized_gain_loss_dollars' => $query->realized_gains, - ]); - - $holding->save(); - - // load market data while we're here - $transaction->refreshMarketData(); - - // sync dividends to holding - $transaction->syncDividendsToHolding(); - } - - public function setSymbolAttribute($value) + /** + * Ensure transaction symbol is always upper case + */ + protected function symbol(): Attribute { - $this->attributes['symbol'] = strtoupper($value); + return Attribute::make( + set: fn (string $value) => strtoupper($value) + ); } /** - * get market data for transaction + * Related market data for transaction * * @return void */ @@ -136,7 +73,7 @@ class Transaction extends Model } /** - * get portfolio for transaction + * Related portfolio * * @return void */ @@ -176,4 +113,73 @@ class Transaction extends Model { return Dividend::getDividendData($this->attributes['symbol']); } + + /** + * Writes average cost basis to a sale transaction + * + * @return Transaction + */ + public function ensureCostBasisIsAddedToSale() + { + $holding = Holding::firstOrNew([ + 'portfolio_id' => $this->portfolio_id, + 'symbol' => $this->symbol + ],[ + 'average_cost_basis' => null + ]); + + $this->cost_basis = $holding->average_cost_basis ?? 0; + + return $this; + } + + /** + * Syncs the holding related to this transaction + * + * @return void + */ + public function syncHolding() { + // get the holding for a symbol and portfolio (or create one) + $holding = Holding::firstOrNew([ + 'portfolio_id' => $this->portfolio_id, + 'symbol' => $this->symbol + ], [ + 'portfolio_id' => $this->portfolio_id, + 'symbol' => $this->symbol, + 'quantity' => $this->quantity, + 'average_cost_basis' => $this->cost_basis, + 'total_cost_basis' => $this->quantity * $this->cost_basis, + ]); + + // pull existing transaction data + $query = self::where([ + 'portfolio_id' => $this->portfolio_id, + 'symbol' => $this->symbol, + ])->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) AS `qty_purchases`') + ->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS `qty_sales`') + ->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN (quantity * cost_basis) ELSE 0 END) AS `cost_basis`') + ->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN ((sale_price - cost_basis) * quantity) ELSE 0 END) AS `realized_gains`') + ->first(); + + $total_quantity = $query->qty_purchases - $query->qty_sales; + $average_cost_basis = $query->qty_purchases > 0 + ? $query->cost_basis / $query->qty_purchases + : 0; + + // update holding + $holding->fill([ + 'quantity' => $total_quantity, + 'average_cost_basis' => $average_cost_basis, + 'total_cost_basis' => $total_quantity * $average_cost_basis, + 'realized_gain_loss_dollars' => $query->realized_gains, + ]); + + $holding->save(); + + // load market data while we're here + // $this->refreshMarketData(); + + // sync dividends to holding + // $this->syncDividendsToHolding(); + } } \ No newline at end of file diff --git a/resources/views/components/ib-skeleton-loader.blade.php b/resources/views/components/ib-skeleton-loader.blade.php new file mode 100644 index 0000000..de612c9 --- /dev/null +++ b/resources/views/components/ib-skeleton-loader.blade.php @@ -0,0 +1,9 @@ +
+
+
+
+
+
+
+ Loading... +
diff --git a/resources/views/livewire/holdings-table.blade.php b/resources/views/livewire/holdings-table.blade.php new file mode 100644 index 0000000..dd5b46b --- /dev/null +++ b/resources/views/livewire/holdings-table.blade.php @@ -0,0 +1,68 @@ + 'symbol', 'direction' => 'asc']; + + public array $headers; + + public function mount() + { + + $this->headers = [ + ['key' => 'symbol', 'label' => __('Symbol'), 'class' => ''], + ['key' => 'market_data_name', 'label' => __('Name'), 'sortable' => true], + ['key' => 'quantity', 'label' => __('Quantity')], + ['key' => 'average_cost_basis', 'label' => __('Average Cost Basis')], + ['key' => 'total_cost_basis', 'label' => __('Total Cost Basis')], + ['key' => 'market_data_market_value', 'label' => __('Market Value')], + ['key' => 'total_market_value', 'label' => __('Total Market Value')], + ['key' => 'market_gain_loss_dollars', 'label' => __('Market Gain/Loss')], + ['key' => 'market_gain_loss_percent', 'label' => __('Market Gain/Loss')], + ['key' => 'realized_gain_loss_dollars', 'label' => __('Realized Gain/Loss')], + ['key' => 'dividends_earned', 'label' => __('Dividends Earned')], + ['key' => 'market_data_fifty_two_week_low', 'label' => __('52 week low')], + ['key' => 'market_data_fifty_two_week_high', 'label' => __('52 week high')], + ['key' => 'num_transactions', 'label' => __('Number of Transactions')], + ['key' => 'market_data_updated_at', 'label' => __('Market Data Age')], + ]; + } + + public function holdings(): Collection + { + return $this->portfolio + ->holdings() + ->with(['transactions' => function ($query) { + $query->portfolio($this->portfolio->id); + }]) + ->withCount(['transactions as num_transactions' => function ($query) { + $query->portfolio($this->portfolio->id); + }]) + ->withAggregate('market_data', 'name') + ->withAggregate('market_data', 'market_value') + ->withAggregate('market_data', 'fifty_two_week_low') + ->withAggregate('market_data', 'fifty_two_week_high') + ->withAggregate('market_data', 'updated_at') + ->selectRaw('(market_data.market_value * holdings.quantity) AS total_market_value') + ->selectRaw('((market_data.market_value - holdings.average_cost_basis) * holdings.quantity) AS market_gain_loss_dollars') + ->selectRaw('(((market_data.market_value - holdings.average_cost_basis) / holdings.average_cost_basis) * 100) AS market_gain_loss_percent') + ->join('market_data', 'holdings.symbol', 'market_data.symbol') + ->orderBy(...array_values($this->sortBy)) + ->where('quantity', '>', 0) + ->get(); + } + +}; ?> + +
+ + +
\ No newline at end of file diff --git a/resources/views/livewire/manage-transaction-form.blade.php b/resources/views/livewire/manage-transaction-form.blade.php index 927cc1b..a68fe68 100644 --- a/resources/views/livewire/manage-transaction-form.blade.php +++ b/resources/views/livewire/manage-transaction-form.blade.php @@ -21,16 +21,16 @@ new class extends Component { #[Rule('required|string|in:BUY,SELL')] public String $transaction_type; - #[Rule('required|date')] + #[Rule('required|date_format:Y-m-d')] public String $date; - #[Rule('required|numeric')] + #[Rule('required|min:0|numeric')] public Float $quantity; - #[Rule('exclude_if:transaction_type,SELL|numeric')] + #[Rule('exclude_if:transaction_type,SELL|min:0|numeric')] public ?Float $cost_basis; - #[Rule('exclude_if:transaction_type,BUY|numeric')] + #[Rule('exclude_if:transaction_type,BUY|min:0|numeric')] public ?Float $sale_price; public Bool $confirmingTransactionDeletion = false; diff --git a/resources/views/livewire/transactions-list.blade.php b/resources/views/livewire/transactions-list.blade.php index 6730430..4cad9d0 100644 --- a/resources/views/livewire/transactions-list.blade.php +++ b/resources/views/livewire/transactions-list.blade.php @@ -8,7 +8,6 @@ use Livewire\Volt\Component; new class extends Component { // props - public ?Collection $transactions; public Portfolio $portfolio; public ?Transaction $editingTransaction; @@ -23,38 +22,39 @@ new class extends Component {
- @foreach($transactions as $transaction) -
- - - - {{ $transaction->date->format('M j, Y') }} - {{ $transaction->symbol }} - ({{ $transaction->quantity }} - @ {{ $transaction->transaction_type == 'BUY' - ? Number::currency($transaction->cost_basis) - : Number::currency($transaction->sale_price) }}) + @foreach($portfolio->transactions->take(10) as $transaction) + + + + + {{ $transaction->date->format('M j, Y') }} + {{ $transaction->symbol }} + ({{ $transaction->quantity }} + @ {{ $transaction->transaction_type == 'BUY' + ? Number::currency($transaction->cost_basis) + : Number::currency($transaction->sale_price) }}) + + + + - - - -
@endforeach + + + @livewire('holdings-table', [ + 'portfolio' => $portfolio + ]) + + + + {{-- @php $users = App\Models\User::take(3)->get(); @@ -93,19 +102,7 @@ @endforeach - - - - - @php - $users = App\Models\User::take(3)->get(); - @endphp - - @foreach($users as $user) - - @endforeach - - + --}} {{-- @@ -119,10 +116,9 @@ --}} - + @livewire('transactions-list', [ - 'transactions' => $portfolio->transactions, 'portfolio' => $portfolio ])