adds dividend re-investment feature

This commit is contained in:
hackerESQ
2024-10-18 20:46:22 -05:00
parent e4d45f391c
commit 51c33ebec0
14 changed files with 218 additions and 17 deletions
+1
View File
@@ -24,6 +24,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
'Cost Basis',
'Sale Price',
'Split',
'Reinvested Dividend',
'Date',
'Created',
'Updated'
+35 -6
View File
@@ -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,
]);
}
});
}
}
+16 -1
View File
@@ -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,
+2
View File
@@ -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()
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('holdings', function (Blueprint $table) {
$table->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');
});
}
};
+7
View File
@@ -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",
+7
View File
@@ -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",
+1 -1
View File
@@ -4,7 +4,7 @@
{{ $attributes->merge(['class' => 'bg-slate-100 dark:bg-base-200 rounded-lg']) }}
>
<h2 class="text-xl mb-2"> {{ $title }} </h2>
<h2 class="text-xl mb-2 flex items-center truncate"> {{ $title }} </h2>
{{ $slot }}
</x-card>
+18 -6
View File
@@ -33,10 +33,10 @@
<div class="mt-6 grid md:grid-cols-9 gap-5">
<x-ib-card class="md:col-span-5">
<x-slot:title class="pb-2">
<x-slot:title>
{{ $holding->market_data->symbol }}
<span class="text-sm"> {{ $holding->market_data->name }} </span>
<span class="text-sm ml-2"> {{ $holding->market_data->name }} </span>
</x-slot:title>
@livewire('holding-market-data', ['holding' => $holding])
@@ -100,15 +100,27 @@
</x-ib-card>
<x-ib-card title="{{ __('Dividends') }}" class="md:col-span-3">
<x-ib-card class="md:col-span-3">
<x-slot:title>
{{ __('Dividends') }}
<x-ib-flex-spacer/>
<x-button
title="{{ __('Dividend options') }}"
icon="o-ellipsis-vertical"
class="btn-circle btn-ghost btn-sm text-secondary"
@click="$dispatch('toggle-dividend-options')"
/>
</x-slot:title>
<x-ib-modal
key="dividend-options"
title="{{ __('Dividend options') }}"
>
@livewire('manage-transaction-form', [
'portfolio' => $portfolio,
'symbol' => $holding->market_data->symbol,
@livewire('holding-dividend-options-form', [
'holding' => $holding
])
</x-ib-modal>
@@ -0,0 +1,65 @@
<?php
use App\Models\Holding;
use Illuminate\Support\Collection;
use Livewire\Attributes\{Computed};
use Livewire\Volt\Component;
use Mary\Traits\Toast;
use Illuminate\Validation\Rule;
new class extends Component {
use Toast;
// props
public Holding $holding;
public Bool $reinvest_dividends = false;
// methods
public function rules()
{
return [
'reinvest_dividends' => ['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');
}
}; ?>
<div class="" x-data="{ }">
<x-ib-form wire:submit="save" class="">
<x-toggle
label="{{ __('Reinvest dividends') }}"
wire:model="reinvest_dividends"
right
hint="{{ __('Automatically generate buy transactions for any dividends earned.') }}"
/>
<x-slot:actions>
<x-button
label="{{ __('Save') }}"
type="submit"
icon="o-paper-airplane"
class="btn-primary"
spinner="save"
/>
</x-slot:actions>
</x-ib-form>
</div>
@@ -89,7 +89,7 @@ new class extends Component {
@if (!$hideCancel)
<x-button label="{{ __('Cancel') }}" link="/dashboard" />
@endif
<x-button label="{{ $portfolio ? 'Update' : 'Create' }}" type="submit" icon="o-paper-airplane" class="btn-primary" spinner="save" />
<x-button label="{{ $portfolio ? __('Update') : __('Create') }}" type="submit" icon="o-paper-airplane" class="btn-primary" spinner="save" />
</x-slot:actions>
</x-ib-form>
@@ -165,7 +165,7 @@ new class extends Component {
@endif
<x-button
label="{{ $transaction ? 'Update' : 'Create' }}"
label="{{ $transaction ? __('Update') : __('Create') }}"
type="submit"
icon="o-paper-airplane"
class="btn-primary"
@@ -88,7 +88,7 @@ new class extends Component {
{{ $row->date->format('M d, Y') }}
@endscope
@scope('cell_split', $row)
{{ $row->split ? 'Yes' : '' }}
{{ $row->split ? __('Yes') : '' }}
@endscope
@scope('cell_transaction_type', $row)
<x-badge
+27
View File
@@ -34,4 +34,31 @@ class DividendsTest extends TestCase
$this->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);
}
}