adds dividend re-investment feature
This commit is contained in:
@@ -24,6 +24,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
||||
'Cost Basis',
|
||||
'Sale Price',
|
||||
'Split',
|
||||
'Reinvested Dividend',
|
||||
'Date',
|
||||
'Created',
|
||||
'Updated'
|
||||
|
||||
+35
-6
@@ -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
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user