From 51c33ebec015dbf885b0047674f5b4923ddf196e Mon Sep 17 00:00:00 2001 From: hackerESQ Date: Fri, 18 Oct 2024 20:46:22 -0500 Subject: [PATCH] adds dividend re-investment feature --- app/Exports/Sheets/TransactionsSheet.php | 1 + app/Models/Dividend.php | 41 ++++++++++-- app/Models/Holding.php | 17 ++++- app/Models/Transaction.php | 2 + ..._10_18_000001_add_reinvestment_columns.php | 36 ++++++++++ lang/en.json | 7 ++ lang/es.json | 7 ++ resources/views/components/ib-card.blade.php | 2 +- resources/views/holding/show.blade.php | 24 +++++-- .../holding-dividend-options-form.blade.php | 65 +++++++++++++++++++ .../livewire/manage-portfolio-form.blade.php | 2 +- .../manage-transaction-form.blade.php | 2 +- .../livewire/transactions-table.blade.php | 2 +- tests/DividendsTest.php | 27 ++++++++ 14 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 database/migrations/2024_10_18_000001_add_reinvestment_columns.php create mode 100644 resources/views/livewire/holding-dividend-options-form.blade.php diff --git a/app/Exports/Sheets/TransactionsSheet.php b/app/Exports/Sheets/TransactionsSheet.php index afd1f6d..9abd820 100644 --- a/app/Exports/Sheets/TransactionsSheet.php +++ b/app/Exports/Sheets/TransactionsSheet.php @@ -24,6 +24,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle 'Cost Basis', 'Sale Price', 'Split', + 'Reinvested Dividend', 'Date', 'Created', 'Updated' diff --git a/app/Models/Dividend.php b/app/Models/Dividend.php index cf40078..1a0b477 100644 --- a/app/Models/Dividend.php +++ b/app/Models/Dividend.php @@ -6,6 +6,7 @@ use App\Models\Holding; use App\Models\MarketData; use App\Models\Transaction; use Illuminate\Support\Str; +use Illuminate\Support\Carbon; use Illuminate\Database\Eloquent\Model; use App\Interfaces\MarketData\MarketDataInterface; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -86,10 +87,15 @@ class Dividend extends Model (new self)->insert($dividend_data->toArray()); // sync to holdings - self::syncHoldings($dividend_data); + self::syncHoldings($symbol); + + // get market data + $market_data = MarketData::firstOrNew(['symbol' => $symbol]); + + // re-invest dividends + self::reinvestDividends($dividend_data, $market_data); // sync last dividend amount to market data table - $market_data = MarketData::firstOrNew(['symbol' => $symbol]); $market_data->last_dividend_amount = $dividend_data->sortByDesc('date')->first()['dividend_amount']; $market_data->save(); } @@ -97,10 +103,8 @@ class Dividend extends Model return $dividend_data; } - public static function syncHoldings($dividend_data): void + public static function syncHoldings(string $symbol): void { - $symbol = $dividend_data->last()['symbol']; - // group by holdings $dividends = self::select(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount']) ->selectRaw(' @@ -115,7 +119,7 @@ class Dividend extends Model ') ->join('transactions', 'transactions.symbol', '=', 'dividends.symbol') ->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id') - ->where('dividends.symbol', $dividend_data->last()['symbol']) + ->where('dividends.symbol', $symbol) ->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received') ->havingRaw('total_received > 0') ->get(); @@ -130,4 +134,29 @@ class Dividend extends Model ]); }); } + + public static function reinvestDividends(iterable $dividend_data, MarketData $market_data): void + { + // re-invest dividends + Holding::where([ + 'symbol' => $market_data->symbol, + 'reinvest_dividends' => true, + ]) + ->get() + ->each(function($holding) use ($dividend_data, $market_data) { + + foreach($dividend_data as $dividend) { + + Transaction::create([ + 'date' => $dividend['date'], + 'portfolio_id' => $holding->portfolio_id, + 'symbol' => $holding->symbol, + 'transaction_type' => "BUY", + 'reinvested_dividend' => true, + 'cost_basis' => $market_data->market_value, + 'quantity' => ($dividend['dividend_amount'] * $holding->qtyOwned(Carbon::parse($dividend['date']))) / $market_data->market_value, + ]); + } + }); + } } diff --git a/app/Models/Holding.php b/app/Models/Holding.php index 9e22242..c9ed89e 100644 --- a/app/Models/Holding.php +++ b/app/Models/Holding.php @@ -26,11 +26,13 @@ class Holding extends Model 'realized_gain_dollars', 'dividends_earned', 'splits_synced_at', + 'reinvest_dividends' ]; protected $casts = [ 'splits_synced_at' => 'datetime', - 'first_transaction_date' => 'datetime' + 'first_transaction_date' => 'datetime', + 'reinvest_dividends' => 'boolean' ]; protected $attributes = [ @@ -209,6 +211,19 @@ class Holding extends Model $this->save(); } + public function qtyOwned(\Illuminate\Support\Carbon $date = null) + { + if ($date == null) $date = now(); + + $transactions = $this->transactions->where('date', '<=', $date); + + $purchases = $transactions->where('transaction_type', 'BUY')->sum('quantity'); + + $sales = $transactions->where('transaction_type', 'SELL')->sum('quantity'); + + return $purchases - $sales; + } + public function dailyPerformance( \Illuminate\Support\Carbon $start_date = null, \Illuminate\Support\Carbon $end_date = null, diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index d03918f..7faa735 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -23,6 +23,7 @@ class Transaction extends Model 'cost_basis', 'sale_price', 'split', + 'reinvested_dividend' ]; protected $hidden = []; @@ -30,6 +31,7 @@ class Transaction extends Model protected $casts = [ 'date' => 'datetime', 'split' => 'boolean', + 'reinvested_dividend' => 'boolean' ]; protected static function boot() diff --git a/database/migrations/2024_10_18_000001_add_reinvestment_columns.php b/database/migrations/2024_10_18_000001_add_reinvestment_columns.php new file mode 100644 index 0000000..28defcc --- /dev/null +++ b/database/migrations/2024_10_18_000001_add_reinvestment_columns.php @@ -0,0 +1,36 @@ +boolean('reinvest_dividends')->nullable()->after('quantity'); + }); + + Schema::table('transactions', function (Blueprint $table) { + $table->boolean('reinvested_dividend')->nullable()->after('split'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('holdings', function (Blueprint $table) { + $table->dropColumn('reinvest_dividends'); + }); + + Schema::table('transactions', function (Blueprint $table) { + $table->dropColumn('reinvested_dividend'); + }); + } +}; diff --git a/lang/en.json b/lang/en.json index 93ee19a..a42e1a6 100644 --- a/lang/en.json +++ b/lang/en.json @@ -11,11 +11,13 @@ "Log in": "Log in", "Register": "Register", "Create": "Create", + "Update": "Update", "Cancel": "Cancel", "Save": "Save", "Close": "Close", "or": "or", "and": "and", + "Yes": "Yes", "Nothing to show here yet": "Nothing to show here yet", "Hang on! You're doing that too much.": "Hang on! You're doing that too much.", @@ -116,6 +118,11 @@ "Total Market Value": "Total Market Value", "Realized Gain/Loss": "Realized Gain/Loss", "Dividends Earned": "Dividends Earned", + "Dividends": "Dividends", + "Dividend options": "Dividend options", + "Dividend options saved": "Dividend options saved", + "Reinvest dividends": "Reinvest dividends", + "Automatically generate buy transactions for any dividends earned.": "Automatically generate buy transactions for any dividends earned.", "Split": "Split", "Splits": "Splits", "No splits for :symbol yet": "No splits for :symbol yet", diff --git a/lang/es.json b/lang/es.json index 989a22c..375bbeb 100644 --- a/lang/es.json +++ b/lang/es.json @@ -11,11 +11,13 @@ "Log in": "Iniciar sesión", "Register": "Registrarse", "Create": "Crear", + "Update": "Actualizar", "Cancel": "Cancelar", "Save": "Guardar", "Close": "Cerrar", "or": "o", "and": "y", + "Yes": "Sí", "Nothing to show here yet": "No hay nada que mostrar aquí todavía", "Hang on! You're doing that too much.": "¡Por favor espere un momento!", @@ -116,6 +118,11 @@ "Total Market Value": "Valor Total de Mercado", "Realized Gain/Loss": "Ganancia/Pérdida Realizada", "Dividends Earned": "Dividendos Ganados", + "Dividends": "Dividendos", + "Dividend options": "Opciones de dividendos", + "Dividend options saved": "Opciones de dividendos guardadas", + "Reinvest dividends": "Reinvertir dividendos", + "Automatically generate buy transactions for any dividends earned.": "Genere automáticamente transacciones de compra para cualquier dividendo obtenido.", "Split": "Division", "Splits": "Divisiones", "No splits for :symbol yet": "No hay divisiones para :symbol", diff --git a/resources/views/components/ib-card.blade.php b/resources/views/components/ib-card.blade.php index 989c4c2..05c7131 100644 --- a/resources/views/components/ib-card.blade.php +++ b/resources/views/components/ib-card.blade.php @@ -4,7 +4,7 @@ {{ $attributes->merge(['class' => 'bg-slate-100 dark:bg-base-200 rounded-lg']) }} > -

{{ $title }}

+

{{ $title }}

{{ $slot }} \ No newline at end of file diff --git a/resources/views/holding/show.blade.php b/resources/views/holding/show.blade.php index 007fde6..d4f1e15 100644 --- a/resources/views/holding/show.blade.php +++ b/resources/views/holding/show.blade.php @@ -33,10 +33,10 @@
- + {{ $holding->market_data->symbol }} - {{ $holding->market_data->name }} + {{ $holding->market_data->name }} @livewire('holding-market-data', ['holding' => $holding]) @@ -100,15 +100,27 @@ - + + + + {{ __('Dividends') }} + + + + + - @livewire('manage-transaction-form', [ - 'portfolio' => $portfolio, - 'symbol' => $holding->market_data->symbol, + @livewire('holding-dividend-options-form', [ + 'holding' => $holding ]) diff --git a/resources/views/livewire/holding-dividend-options-form.blade.php b/resources/views/livewire/holding-dividend-options-form.blade.php new file mode 100644 index 0000000..d24c66a --- /dev/null +++ b/resources/views/livewire/holding-dividend-options-form.blade.php @@ -0,0 +1,65 @@ + ['required', 'boolean'], + ]; + } + + public function mount() + { + + $this->reinvest_dividends = $this->holding?->reinvest_dividends ?? false; + } + + public function save() + { + $this->holding->update($this->validate()); + + $this->success(__('Dividend options saved')); + + $this->dispatch('toggle-dividend-options'); + } +}; ?> + +
+ + + + + + + + + + +
\ No newline at end of file diff --git a/resources/views/livewire/manage-portfolio-form.blade.php b/resources/views/livewire/manage-portfolio-form.blade.php index c1ac600..eb92a7d 100644 --- a/resources/views/livewire/manage-portfolio-form.blade.php +++ b/resources/views/livewire/manage-portfolio-form.blade.php @@ -89,7 +89,7 @@ new class extends Component { @if (!$hideCancel) @endif - + diff --git a/resources/views/livewire/manage-transaction-form.blade.php b/resources/views/livewire/manage-transaction-form.blade.php index b8a4a70..b48de2b 100644 --- a/resources/views/livewire/manage-transaction-form.blade.php +++ b/resources/views/livewire/manage-transaction-form.blade.php @@ -165,7 +165,7 @@ new class extends Component { @endif date->format('M d, Y') }} @endscope @scope('cell_split', $row) - {{ $row->split ? 'Yes' : '' }} + {{ $row->split ? __('Yes') : '' }} @endscope @scope('cell_transaction_type', $row) assertEquals(4.95, $holding->dividends_earned); } + + /** + */ + public function test_new_dividends_are_reinvested(): void + { + $this->actingAs($user = User::factory()->create()); + + $portfolio = Portfolio::factory()->create(); + Transaction::factory()->buy()->yearsAgo()->portfolio($portfolio->id)->symbol('ACME')->create(); + + $holding = Holding::query()->portfolio($portfolio->id)->symbol('ACME')->first(); + $holding->reinvest_dividends = true; + $holding->save(); + + $this->assertEquals(0, $holding->dividends_earned); + + Dividend::refreshDividendData('ACME'); + + $transactions = Transaction::where(['reinvested_dividend' => true])->symbol('ACME')->portfolio($portfolio->id)->get(); + + $dividendsReinvested = $transactions->reduce(function ($carry, $transaction) { + return $carry + ($transaction->cost_basis * $transaction->quantity); + }, 0); + + $this->assertCount(3, $transactions); + $this->assertEqualsWithDelta(4.95, $dividendsReinvested, 0.01); + } }