Feat: Adds multi currency to imports and exports (#89)
* Also adds ability for user to export configurations
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class EnsureDailyChangeIsSynced
|
||||||
|
{
|
||||||
|
public function __invoke(Model $model, callable $next)
|
||||||
|
{
|
||||||
|
if (config('app.env') != 'testing') {
|
||||||
|
|
||||||
|
$cacheKey = 'daily_change_synced'.$model->portfolio_id;
|
||||||
|
|
||||||
|
if (
|
||||||
|
! Cache::has($cacheKey)
|
||||||
|
&& $model->date->lessThan(now())
|
||||||
|
&& ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now())
|
||||||
|
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->min('date') ?? now())
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
defer(fn () => $model->portfolio->syncDailyChanges());
|
||||||
|
|
||||||
|
Cache::put($cacheKey, now(), now()->addMinutes(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($model);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Exports;
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Exports\Sheets\ConfigSheet;
|
||||||
use App\Exports\Sheets\DailyChangesSheet;
|
use App\Exports\Sheets\DailyChangesSheet;
|
||||||
use App\Exports\Sheets\PortfoliosSheet;
|
use App\Exports\Sheets\PortfoliosSheet;
|
||||||
use App\Exports\Sheets\TransactionsSheet;
|
use App\Exports\Sheets\TransactionsSheet;
|
||||||
@@ -24,6 +25,7 @@ class BackupExport implements WithMultipleSheets
|
|||||||
new PortfoliosSheet($this->empty),
|
new PortfoliosSheet($this->empty),
|
||||||
new TransactionsSheet($this->empty),
|
new TransactionsSheet($this->empty),
|
||||||
new DailyChangesSheet($this->empty),
|
new DailyChangesSheet($this->empty),
|
||||||
|
new ConfigSheet($this->empty),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exports\Sheets;
|
||||||
|
|
||||||
|
use App\Models\Holding;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
|
||||||
|
class ConfigSheet implements FromCollection, WithHeadings, WithTitle
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public bool $empty = false
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Key',
|
||||||
|
'Value',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function collection()
|
||||||
|
{
|
||||||
|
$configs = collect();
|
||||||
|
|
||||||
|
if ($this->empty) {
|
||||||
|
return $configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect user settings
|
||||||
|
$configs->push([
|
||||||
|
'key' => 'name',
|
||||||
|
'value' => auth()->user()->name,
|
||||||
|
], [
|
||||||
|
'key' => 'locale',
|
||||||
|
'value' => auth()->user()->getLocale(),
|
||||||
|
], [
|
||||||
|
'key' => 'display_currency',
|
||||||
|
'value' => auth()->user()->getCurrency(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// reinvested holdings
|
||||||
|
Holding::myHoldings()->where('reinvest_dividends', true)->get()->each(function ($holding) use (&$configs) {
|
||||||
|
$configs->push([
|
||||||
|
'key' => 'reinvested_dividends',
|
||||||
|
'value' => $holding->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return 'Config';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
*/
|
*/
|
||||||
public function collection()
|
public function collection()
|
||||||
{
|
{
|
||||||
return $this->empty ? collect() : DailyChange::myDailyChanges()->get();
|
return $this->empty ? collect() : DailyChange::myDailyChanges()->withDailyPerformance()->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Quantity',
|
'Quantity',
|
||||||
'Cost Basis',
|
'Cost Basis',
|
||||||
'Sale Price',
|
'Sale Price',
|
||||||
|
'Currency',
|
||||||
'Split',
|
'Split',
|
||||||
'Reinvested Dividend',
|
'Reinvested Dividend',
|
||||||
'Date',
|
'Date',
|
||||||
@@ -38,7 +39,30 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
*/
|
*/
|
||||||
public function collection()
|
public function collection()
|
||||||
{
|
{
|
||||||
return $this->empty ? collect() : Transaction::myTransactions()->get();
|
if ($this->empty) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Transaction::myTransactions()
|
||||||
|
->withMarketData()
|
||||||
|
->get()
|
||||||
|
->map(function ($transaction) {
|
||||||
|
return [
|
||||||
|
'id' => $transaction->id,
|
||||||
|
'symbol' => $transaction->symbol,
|
||||||
|
'portfolio_id' => $transaction->portfolio_id,
|
||||||
|
'transaction_type' => $transaction->transaction_type,
|
||||||
|
'quantity' => $transaction->quantity,
|
||||||
|
'cost_basis' => $transaction->cost_basis,
|
||||||
|
'sale_price' => $transaction->sale_price,
|
||||||
|
'currency' => $transaction->market_data_currency,
|
||||||
|
'split' => $transaction->split,
|
||||||
|
'reinvested_dividend' => $transaction->reinvested_dividend,
|
||||||
|
'date' => $transaction->date,
|
||||||
|
'created_at' => $transaction->created_at,
|
||||||
|
'updated_at' => $transaction->updated_at,
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Console\Commands\RefreshDividendData;
|
|||||||
use App\Console\Commands\RefreshMarketData;
|
use App\Console\Commands\RefreshMarketData;
|
||||||
use App\Console\Commands\SyncDailyChange;
|
use App\Console\Commands\SyncDailyChange;
|
||||||
use App\Console\Commands\SyncHoldingData;
|
use App\Console\Commands\SyncHoldingData;
|
||||||
|
use App\Imports\Sheets\ConfigSheet;
|
||||||
use App\Imports\Sheets\DailyChangesSheet;
|
use App\Imports\Sheets\DailyChangesSheet;
|
||||||
use App\Imports\Sheets\PortfoliosSheet;
|
use App\Imports\Sheets\PortfoliosSheet;
|
||||||
use App\Imports\Sheets\TransactionsSheet;
|
use App\Imports\Sheets\TransactionsSheet;
|
||||||
@@ -69,6 +70,7 @@ class BackupImport implements WithEvents, WithMultipleSheets
|
|||||||
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
|
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
|
||||||
'Transactions' => new TransactionsSheet($this->backupImportModel),
|
'Transactions' => new TransactionsSheet($this->backupImportModel),
|
||||||
'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
|
'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
|
||||||
|
'Config' => new ConfigSheet($this->backupImportModel),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
|
use App\Models\BackupImport;
|
||||||
|
use App\Models\Holding;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
|
||||||
|
class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public BackupImport $backupImport
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function registerEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
|
DB::commit();
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing configurations...'),
|
||||||
|
]);
|
||||||
|
DB::beginTransaction();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collection(Collection $configs)
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
foreach ($configs as $config) {
|
||||||
|
|
||||||
|
switch ($config['key']) {
|
||||||
|
case 'name':
|
||||||
|
$user->name = $config['value'];
|
||||||
|
$user->save();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'locale':
|
||||||
|
$user->setOption('locale', $config['value']);
|
||||||
|
$user->save();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'display_currency':
|
||||||
|
$user->setOption('display_currency', $config['value']);
|
||||||
|
$user->save();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'reinvest_dividends':
|
||||||
|
|
||||||
|
Holding::myHoldings()->where('id', $config['value'])->update([
|
||||||
|
'reinvest_dividends' => true,
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => ['required', 'string'],
|
||||||
|
'value' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
BeforeSheet::class => function (BeforeSheet $event) {
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
DB::commit();
|
DB::commit();
|
||||||
$this->backupImport->update([
|
$this->backupImport->update([
|
||||||
'message' => __('Importing daily changes...'),
|
'message' => __('Preparing to import daily changes...'),
|
||||||
]);
|
]);
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
},
|
},
|
||||||
@@ -40,19 +40,20 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
|
|
||||||
public function collection(Collection $dailyChanges)
|
public function collection(Collection $dailyChanges)
|
||||||
{
|
{
|
||||||
$dailyChanges->chunk($this->batchSize())->each(function ($chunk) {
|
$totalBatches = count($dailyChanges) / $this->batchSize();
|
||||||
|
|
||||||
|
$dailyChanges->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
|
||||||
|
|
||||||
$this->validatePortfolioAccess($chunk);
|
$this->validatePortfolioAccess($chunk);
|
||||||
|
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
|
||||||
|
]);
|
||||||
|
|
||||||
// have to cast to native values
|
// have to cast to native values
|
||||||
$chunk = $chunk->map(function ($dailyChange) {
|
$chunk = $chunk->map(function ($dailyChange) {
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'total_market_value' => $dailyChange['total_market_value'],
|
|
||||||
'total_cost_basis' => $dailyChange['total_cost_basis'],
|
|
||||||
'total_gain' => $dailyChange['total_gain'],
|
|
||||||
'total_dividends_earned' => $dailyChange['total_dividends_earned'],
|
|
||||||
'realized_gains' => $dailyChange['realized_gains'],
|
|
||||||
'annotation' => $dailyChange['annotation'],
|
'annotation' => $dailyChange['annotation'],
|
||||||
'portfolio_id' => $dailyChange['portfolio_id'],
|
'portfolio_id' => $dailyChange['portfolio_id'],
|
||||||
'date' => Carbon::parse($dailyChange['date'])->toDateString(),
|
'date' => Carbon::parse($dailyChange['date'])->toDateString(),
|
||||||
@@ -63,11 +64,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
$chunk->toArray(),
|
$chunk->toArray(),
|
||||||
['portfolio_id', 'date'],
|
['portfolio_id', 'date'],
|
||||||
[
|
[
|
||||||
'total_market_value',
|
|
||||||
'total_cost_basis',
|
|
||||||
'total_gain',
|
|
||||||
'total_dividends_earned',
|
|
||||||
'realized_gains',
|
|
||||||
'annotation',
|
'annotation',
|
||||||
'portfolio_id',
|
'portfolio_id',
|
||||||
'date',
|
'date',
|
||||||
@@ -86,11 +82,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
return [
|
return [
|
||||||
'portfolio_id' => ['required', 'uuid'],
|
'portfolio_id' => ['required', 'uuid'],
|
||||||
'date' => ['required', 'date'],
|
'date' => ['required', 'date'],
|
||||||
'total_market_value' => ['sometimes', 'nullable', 'numeric'],
|
|
||||||
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
|
||||||
'total_gain' => ['sometimes', 'nullable', 'numeric'],
|
|
||||||
'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
|
||||||
'realized_gains' => ['sometimes', 'nullable', 'numeric'],
|
|
||||||
'annotation' => ['sometimes', 'nullable', 'string'],
|
'annotation' => ['sometimes', 'nullable', 'string'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ namespace App\Imports\Sheets;
|
|||||||
|
|
||||||
use App\Imports\ValidatesPortfolioAccess;
|
use App\Imports\ValidatesPortfolioAccess;
|
||||||
use App\Models\BackupImport;
|
use App\Models\BackupImport;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Models\CurrencyRate;
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -33,7 +35,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
BeforeSheet::class => function (BeforeSheet $event) {
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
DB::commit();
|
DB::commit();
|
||||||
$this->backupImport->update([
|
$this->backupImport->update([
|
||||||
'message' => __('Importing transactions...'),
|
'message' => __('Preparing to import transactions...'),
|
||||||
]);
|
]);
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
},
|
},
|
||||||
@@ -43,13 +45,37 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
public function collection(Collection $transactions)
|
public function collection(Collection $transactions)
|
||||||
{
|
{
|
||||||
|
|
||||||
$transactions->chunk($this->batchSize())->each(function ($chunk) {
|
// if has any transactions not in base currency, need to sync timeseries conversion rates
|
||||||
|
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
|
||||||
|
|
||||||
|
CurrencyRate::timeSeriesRates('', $transactions->min('date'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalBatches = count($transactions) / $this->batchSize();
|
||||||
|
|
||||||
|
// chunk transactions
|
||||||
|
$transactions->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
|
||||||
|
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing transactions (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
|
||||||
|
]);
|
||||||
|
|
||||||
$this->validatePortfolioAccess($chunk);
|
$this->validatePortfolioAccess($chunk);
|
||||||
|
|
||||||
// have to cast to native values
|
// have to cast to native values
|
||||||
$chunk = $chunk->map(function ($transaction) {
|
$chunk = $chunk->map(function ($transaction) {
|
||||||
|
|
||||||
|
$date = Carbon::parse($transaction['date'])->toDateString();
|
||||||
|
|
||||||
|
// if transaction not in base currency, need to convert
|
||||||
|
if ($transaction['currency'] == config('investbrain.base_currency')) {
|
||||||
|
$cost_basis_base = $transaction['cost_basis'] ?? 0;
|
||||||
|
$sale_price_base = $transaction['sale_price'];
|
||||||
|
} else {
|
||||||
|
$cost_basis_base = Currency::convert($transaction['cost_basis'], $transaction['currency'], date: $date);
|
||||||
|
$sale_price_base = Currency::convert($transaction['sale_price'], $transaction['currency'], date: $date);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
|
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
|
||||||
'symbol' => strtoupper($transaction['symbol']),
|
'symbol' => strtoupper($transaction['symbol']),
|
||||||
@@ -58,9 +84,11 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
'quantity' => $transaction['quantity'],
|
'quantity' => $transaction['quantity'],
|
||||||
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
||||||
'sale_price' => $transaction['sale_price'],
|
'sale_price' => $transaction['sale_price'],
|
||||||
|
'cost_basis_base' => $cost_basis_base,
|
||||||
|
'sale_price_base' => $sale_price_base,
|
||||||
'split' => boolval($transaction['split']) ? 1 : 0,
|
'split' => boolval($transaction['split']) ? 1 : 0,
|
||||||
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
|
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
|
||||||
'date' => Carbon::parse($transaction['date'])->toDateString(),
|
'date' => $date,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +109,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// stub out related holdings
|
// get unique symbol/portfolio id combination and stub out related holdings
|
||||||
$chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
|
$chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
|
||||||
->each(function ($holding) {
|
->each(function ($holding) {
|
||||||
|
|
||||||
@@ -112,6 +140,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
'transaction_type' => ['required', 'in:BUY,SELL'],
|
'transaction_type' => ['required', 'in:BUY,SELL'],
|
||||||
'date' => ['required', 'date'],
|
'date' => ['required', 'date'],
|
||||||
'quantity' => ['required', 'min:0', 'numeric'],
|
'quantity' => ['required', 'min:0', 'numeric'],
|
||||||
|
'currency' => ['required', 'string'],
|
||||||
'split' => ['sometimes', 'nullable', 'boolean'],
|
'split' => ['sometimes', 'nullable', 'boolean'],
|
||||||
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
|
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
|
||||||
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ trait ValidatesPortfolioAccess
|
|||||||
public function validatePortfolioAccess($collection)
|
public function validatePortfolioAccess($collection)
|
||||||
{
|
{
|
||||||
|
|
||||||
$uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
$importingPortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
||||||
$countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
$portfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
||||||
->whereIn('id', $uniquePortfolios)
|
->whereIn('id', $importingPortfolios)
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$countPortfoliosWithAccess < $uniquePortfolios->count()
|
$importingPortfolios->count() > $portfoliosWithAccess
|
||||||
) {
|
) {
|
||||||
throw new \Exception(__('You do not have access to that portfolio.'));
|
throw new \Exception(__('You do not have access to that portfolio.'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class DailyChange extends Model
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return $query
|
return $query
|
||||||
->select(['daily_change.portfolio_id', 'daily_change.date'])
|
->select(['daily_change.date', 'daily_change.portfolio_id'])
|
||||||
->leftJoinSub($totalCostBasisSub, 'cost_basis_display', function ($join) {
|
->leftJoinSub($totalCostBasisSub, 'cost_basis_display', function ($join) {
|
||||||
$join->on('daily_change.date', '>=', 'cost_basis_display.date')
|
$join->on('daily_change.date', '>=', 'cost_basis_display.date')
|
||||||
->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id');
|
->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
|||||||
use App\Actions\ConvertToMarketDataCurrency;
|
use App\Actions\ConvertToMarketDataCurrency;
|
||||||
use App\Actions\CopyToBaseCurrency;
|
use App\Actions\CopyToBaseCurrency;
|
||||||
use App\Actions\EnsureCostBasisAddedToSale;
|
use App\Actions\EnsureCostBasisAddedToSale;
|
||||||
|
use App\Actions\EnsureDailyChangeIsSynced;
|
||||||
use App\Casts\BaseCurrency;
|
use App\Casts\BaseCurrency;
|
||||||
use App\Traits\HasMarketData;
|
use App\Traits\HasMarketData;
|
||||||
use Illuminate\Contracts\Database\Eloquent\Builder;
|
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||||||
@@ -69,6 +70,12 @@ class Transaction extends Model
|
|||||||
|
|
||||||
$transaction->syncToHolding();
|
$transaction->syncToHolding();
|
||||||
|
|
||||||
|
$transaction = Pipeline::send($transaction)
|
||||||
|
->through([
|
||||||
|
EnsureDailyChangeIsSynced::class,
|
||||||
|
])
|
||||||
|
->then(fn (Transaction $transaction) => $transaction);
|
||||||
|
|
||||||
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +111,7 @@ class Transaction extends Model
|
|||||||
{
|
{
|
||||||
return $query->withAggregate('market_data', 'name')
|
return $query->withAggregate('market_data', 'name')
|
||||||
->withAggregate('market_data', 'market_value')
|
->withAggregate('market_data', 'market_value')
|
||||||
|
->withAggregate('market_data', 'currency')
|
||||||
->withAggregate('market_data', 'fifty_two_week_low')
|
->withAggregate('market_data', 'fifty_two_week_low')
|
||||||
->withAggregate('market_data', 'fifty_two_week_high')
|
->withAggregate('market_data', 'fifty_two_week_high')
|
||||||
->withAggregate('market_data', 'updated_at')
|
->withAggregate('market_data', 'updated_at')
|
||||||
|
|||||||
@@ -98,4 +98,14 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
|
|
||||||
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
|
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setOption(mixed $key, string $value): self
|
||||||
|
{
|
||||||
|
|
||||||
|
$options = is_array($key) ? $key : [$key => $value];
|
||||||
|
|
||||||
|
$this->user->options = array_merge($this->user->options ?? [], $options);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -367,8 +367,11 @@
|
|||||||
"Import starting...": "Import starting...",
|
"Import starting...": "Import starting...",
|
||||||
"Import is in progress...": "Import is in progress...",
|
"Import is in progress...": "Import is in progress...",
|
||||||
"Importing portfolios...": "Importing portfolios...",
|
"Importing portfolios...": "Importing portfolios...",
|
||||||
"Importing transactions...": "Importing transactions...",
|
"Preparing to import transactions...": "Preparing to import transactions...",
|
||||||
"Importing daily changes...": "Importing daily changes...",
|
"Importing transactions (Batch :currentBatch of :totalBatches)...": "Importing transactions (Batch :currentBatch of :totalBatches)...",
|
||||||
|
"Preparing to import daily changes...": "Preparing to import daily changes...",
|
||||||
|
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importing daily changes (Batch :currentBatch of :totalBatches)...",
|
||||||
|
"Importing configurations...": "Importing configurations...",
|
||||||
"Import completed successfully!": "Import completed successfully!",
|
"Import completed successfully!": "Import completed successfully!",
|
||||||
"Your import will continue in the background": "Your import will continue in the background",
|
"Your import will continue in the background": "Your import will continue in the background",
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -367,8 +367,11 @@
|
|||||||
"Import starting...": "Iniciando la importación...",
|
"Import starting...": "Iniciando la importación...",
|
||||||
"Import is in progress...": "La importación está en progreso...",
|
"Import is in progress...": "La importación está en progreso...",
|
||||||
"Importing portfolios...": "Importando portafolios...",
|
"Importing portfolios...": "Importando portafolios...",
|
||||||
"Importing transactions...": "Importando transacciones...",
|
"Preparing to import transactions...": "Preparándose para importar transacciones...",
|
||||||
"Importing daily changes...": "Importando cambios diarios...",
|
"Importing transactions (Batch :currentBatch of :totalBatches)...": "Importando transacciones (Lote :currentBatch de :totalBatches)...",
|
||||||
|
"Preparing to import daily changes...": "Preparing to import cambios diarios...",
|
||||||
|
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importando cambios diarios (Lote :currentBatch de :totalBatches)...",
|
||||||
|
"Importing configurations...": "Importando configuraciones...",
|
||||||
"Import completed successfully!": "¡La importación se completó con éxito!",
|
"Import completed successfully!": "¡La importación se completó con éxito!",
|
||||||
"Your import will continue in the background": "La importación continuará en segundo plano",
|
"Your import will continue in the background": "La importación continuará en segundo plano",
|
||||||
|
|
||||||
|
|||||||
@@ -89,13 +89,13 @@ new class extends Component
|
|||||||
|
|
||||||
<x-slot name="description">
|
<x-slot name="description">
|
||||||
{{ __('Upload or recover your Investbrain portfolio and holdings.') }}
|
{{ __('Upload or recover your Investbrain portfolio and holdings.') }}
|
||||||
|
<span class="text-xs text-secondary"><a href="#" title="{{ __('Click to download import template.') }}" @click="$wire.downloadTemplate()"> {{ __('Download import template.') }}</a></span>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot:form>
|
<x-slot:form>
|
||||||
|
|
||||||
<div class="col-span-6 sm:col-span-4">
|
<div class="col-span-6 sm:col-span-4">
|
||||||
<x-file wire:model="file" label="{{ __('Select a file') }}" hint="" accept=".xlsx" required />
|
<x-file wire:model="file" label="{{ __('Select a file') }}" hint="" accept=".xlsx" required />
|
||||||
<p class="mt-4 text-xs text-secondary leading-tight"><a href="#" title="{{ __('Click to download import template.') }}" @click="$wire.downloadTemplate()"> {{ __('Download import template.') }}</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-dialog-modal wire:model.live="importStatusDialog" persistent>
|
<x-dialog-modal wire:model.live="importStatusDialog" persistent>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ new class extends Component
|
|||||||
|
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
$this->user->options = array_merge($this->user->options ?? [], [
|
$this->user->setOption([
|
||||||
'locale' => $this->locale,
|
'locale' => $this->locale,
|
||||||
'display_currency' => $this->display_currency,
|
'display_currency' => $this->display_currency,
|
||||||
]);
|
]);
|
||||||
@@ -51,7 +51,7 @@ new class extends Component
|
|||||||
|
|
||||||
$this->dispatch('saved');
|
$this->dispatch('saved');
|
||||||
|
|
||||||
//$this->js('window.location.reload();');
|
// $this->js('window.location.reload();');
|
||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
<x-forms.form-section submit="updateProfileInformation">
|
<x-forms.form-section submit="updateProfileInformation">
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -198,4 +198,52 @@ class DailyChangeTest extends TestCase
|
|||||||
|
|
||||||
$this->assertEqualsWithDelta($total_dividends, $last_dividend_change->total_dividends_earned, 0.01);
|
$this->assertEqualsWithDelta($total_dividends, $last_dividend_change->total_dividends_earned, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_daily_changes_synced_into_past_for_older_transactions(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
|
$portfolio = Portfolio::factory()->create();
|
||||||
|
|
||||||
|
// 1. test daily change will fill to the date of first transaction
|
||||||
|
$first_transaction = Transaction::factory(5)->buy()->lastMonth()->portfolio($portfolio->id)->symbol('AAPL')->create();
|
||||||
|
|
||||||
|
$portfolio->syncDailyChanges();
|
||||||
|
|
||||||
|
$first_date = DailyChange::min('date');
|
||||||
|
|
||||||
|
$this->assertEquals($first_transaction->min('date')->toDateString(), $first_date);
|
||||||
|
|
||||||
|
// 2. test daily change will fill when new transaction pre-dates earliest daily change
|
||||||
|
config()->set('app.env', 'local');
|
||||||
|
$this->withoutDefer();
|
||||||
|
|
||||||
|
$second_transaction = Transaction::create([
|
||||||
|
'symbol' => 'AAPL',
|
||||||
|
'portfolio_id' => $portfolio->id,
|
||||||
|
'date' => now()->subYears(3),
|
||||||
|
'quantity' => 1,
|
||||||
|
'cost_basis' => 39.89,
|
||||||
|
'transaction_type' => 'BUY',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$second_date = DailyChange::min('date');
|
||||||
|
|
||||||
|
$this->assertEquals($second_transaction->date->toDateString(), $second_date);
|
||||||
|
|
||||||
|
// 3. test daily change will fill when new transaction is between earliest daily change and earliest transaction
|
||||||
|
$third_transaction = Transaction::create([
|
||||||
|
'symbol' => 'AAPL',
|
||||||
|
'portfolio_id' => $portfolio->id,
|
||||||
|
'date' => now()->subYears(1),
|
||||||
|
'quantity' => 1,
|
||||||
|
'cost_basis' => 39.89,
|
||||||
|
'transaction_type' => 'BUY',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$daily_change_day_after = DailyChange::withDailyPerformance()->whereDate('daily_change.date', $third_transaction->date->addDay())->first();
|
||||||
|
$daily_change_day_before = DailyChange::withDailyPerformance()->whereDate('daily_change.date', $third_transaction->date->subDay())->first();
|
||||||
|
|
||||||
|
$this->assertEquals(39.89, $daily_change_day_after->total_cost_basis - $daily_change_day_before->total_cost_basis);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-42
@@ -6,6 +6,8 @@ namespace Tests;
|
|||||||
|
|
||||||
use App\Exports\BackupExport;
|
use App\Exports\BackupExport;
|
||||||
use App\Models\BackupImport as BackupImportModel;
|
use App\Models\BackupImport as BackupImportModel;
|
||||||
|
use App\Models\Holding;
|
||||||
|
use App\Models\Portfolio;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -15,62 +17,89 @@ class ImportExportTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
// todo: need to fix import export
|
public function test_can_create_exports(): void
|
||||||
|
{
|
||||||
|
Excel::fake();
|
||||||
|
|
||||||
// public function test_can_create_exports(): void
|
$this->actingAs($user = User::factory()->create());
|
||||||
// {
|
|
||||||
// Excel::fake();
|
|
||||||
|
|
||||||
// $this->actingAs($user = User::factory()->create());
|
Transaction::factory(5)->buy()->lastYear()->symbol('AAPL')->create();
|
||||||
|
|
||||||
// Transaction::factory(5)->buy()->lastYear()->symbol('AAPL')->create();
|
Excel::download(new BackupExport, now()->format('Y_m_d').'_investbrain_backup.xlsx');
|
||||||
|
|
||||||
// Excel::download(new BackupExport, now()->format('Y_m_d').'_investbrain_backup.xlsx');
|
Excel::assertDownloaded(now()->format('Y_m_d').'_investbrain_backup.xlsx', function (BackupExport $export) {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Excel::assertDownloaded(now()->format('Y_m_d').'_investbrain_backup.xlsx', function (BackupExport $export) {
|
public function test_import_job_completes(): void
|
||||||
// return true;
|
{
|
||||||
// });
|
$this->actingAs($user = User::factory()->create());
|
||||||
// }
|
|
||||||
|
|
||||||
// public function test_backup_job_completes(): void
|
$import_job = BackupImportModel::create([
|
||||||
// {
|
'user_id' => auth()->user()->id,
|
||||||
// $this->actingAs($user = User::factory()->create());
|
'path' => __DIR__.'/0000_00_00_import_test.xlsx',
|
||||||
|
]);
|
||||||
|
|
||||||
// $backup_job = BackupImportModel::create([
|
$import_job->refresh();
|
||||||
// 'user_id' => auth()->user()->id,
|
|
||||||
// 'path' => __DIR__.'/0000_00_00_import_test.xlsx',
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
// $backup_job->refresh();
|
$this->assertEquals('success', $import_job->status);
|
||||||
|
}
|
||||||
|
|
||||||
// $this->assertEquals('success', $backup_job->status);
|
public function test_import_job_inserts_rows(): void
|
||||||
// }
|
{
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
// public function test_backup_job_inserts_rows(): void
|
BackupImportModel::create([
|
||||||
// {
|
'user_id' => auth()->user()->id,
|
||||||
// $this->actingAs($user = User::factory()->create());
|
'path' => __DIR__.'/0000_00_00_import_test.xlsx',
|
||||||
|
]);
|
||||||
|
|
||||||
// BackupImportModel::create([
|
$this->assertEquals(3, $user->transactions->count());
|
||||||
// 'user_id' => auth()->user()->id,
|
}
|
||||||
// 'path' => __DIR__.'/0000_00_00_import_test.xlsx',
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
// $this->assertEquals(3, $user->transactions->count());
|
public function test_import_job_calculates_correct_holding_data(): void
|
||||||
// }
|
{
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
// public function test_backup_job_calculates_correct_holding_data(): void
|
BackupImportModel::create([
|
||||||
// {
|
'user_id' => auth()->user()->id,
|
||||||
// $this->actingAs($user = User::factory()->create());
|
'path' => __DIR__.'/0000_00_00_import_test.xlsx',
|
||||||
|
]);
|
||||||
|
|
||||||
// BackupImportModel::create([
|
$holding = $user->holdings->first();
|
||||||
// 'user_id' => auth()->user()->id,
|
|
||||||
// 'path' => __DIR__.'/0000_00_00_import_test.xlsx',
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
// $holding = $user->holdings->first();
|
$this->assertEquals('AAPL', $holding->symbol);
|
||||||
|
$this->assertEquals(6, $holding->quantity);
|
||||||
|
$this->assertEqualsWithDelta(233.33, $holding->average_cost_basis, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
// $this->assertEquals('AAPL', $holding->symbol);
|
public function test_configurations_are_set_on_import(): void
|
||||||
// $this->assertEquals(6, $holding->quantity);
|
{
|
||||||
// $this->assertEqualsWithDelta(233.33, $holding->average_cost_basis, 0.01);
|
$this->actingAs($user = User::factory()->create());
|
||||||
// }
|
|
||||||
|
Portfolio::create([
|
||||||
|
'id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337',
|
||||||
|
'title' => 'Test Portfolio',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$holding = Holding::create([
|
||||||
|
'id' => '9cf8a662-7347-49fb-b9de-0cc1430a8d1f',
|
||||||
|
'portfolio_id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337',
|
||||||
|
'symbol' => 'ACME',
|
||||||
|
'quantity' => 0,
|
||||||
|
'reinvest_dividends' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(false, $holding->reinvest_dividends);
|
||||||
|
|
||||||
|
BackupImportModel::create([
|
||||||
|
'user_id' => auth()->user()->id,
|
||||||
|
'path' => __DIR__.'/0000_00_00_import_configs_test.xlsx',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$holding->refresh();
|
||||||
|
|
||||||
|
$this->assertEquals(true, $holding->reinvest_dividends);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace Tests;
|
|||||||
|
|
||||||
use App\Interfaces\MarketData\FakeMarketData;
|
use App\Interfaces\MarketData\FakeMarketData;
|
||||||
use App\Interfaces\MarketData\Types\Quote;
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Models\BackupImport;
|
||||||
use App\Models\Currency;
|
use App\Models\Currency;
|
||||||
use App\Models\CurrencyRate;
|
use App\Models\CurrencyRate;
|
||||||
use App\Models\DailyChange;
|
use App\Models\DailyChange;
|
||||||
@@ -549,4 +550,46 @@ class MultiCurrencyTest extends TestCase
|
|||||||
$this->assertEqualsWithDelta($metrics->get('realized_gain_dollars'), $dailyChange->last()->realized_gain_dollars, 0.01);
|
$this->assertEqualsWithDelta($metrics->get('realized_gain_dollars'), $dailyChange->last()->realized_gain_dollars, 0.01);
|
||||||
$this->assertEqualsWithDelta($metrics->get('total_market_value') - $metrics->get('total_cost_basis'), $dailyChange->last()->total_gain, 0.01);
|
$this->assertEqualsWithDelta($metrics->get('total_market_value') - $metrics->get('total_cost_basis'), $dailyChange->last()->total_gain, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_multi_currency_import_calculates_correct_holding_data(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($user = User::factory()->create());
|
||||||
|
|
||||||
|
Frankfurter::expects('setSymbols')
|
||||||
|
->zeroOrMoreTimes()
|
||||||
|
->andReturnSelf();
|
||||||
|
Frankfurter::expects('timeSeries')
|
||||||
|
->zeroOrMoreTimes()
|
||||||
|
->andReturn(['rates' => [
|
||||||
|
now()->subDays(3)->toDateString() => [
|
||||||
|
'ZZZ' => .01,
|
||||||
|
],
|
||||||
|
now()->subDays(2)->toDateString() => [
|
||||||
|
'ZZZ' => .01,
|
||||||
|
],
|
||||||
|
now()->subDays(1)->toDateString() => [
|
||||||
|
'ZZZ' => .01,
|
||||||
|
],
|
||||||
|
now()->toDateString() => [
|
||||||
|
'ZZZ' => .01,
|
||||||
|
],
|
||||||
|
]]);
|
||||||
|
Frankfurter::expects('historical')
|
||||||
|
->zeroOrMoreTimes()
|
||||||
|
->andReturn([
|
||||||
|
'rates' => [
|
||||||
|
'GBP' => .89,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
BackupImport::create([
|
||||||
|
'user_id' => auth()->user()->id,
|
||||||
|
'path' => __DIR__.'/0000_00_00_import_multi_curr_test.xlsx',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertContains('AAPL', $user->holdings->pluck('symbol'));
|
||||||
|
$this->assertContains('BP.L', $user->holdings->pluck('symbol'));
|
||||||
|
$this->assertEquals(17, $user->holdings->sum('quantity'));
|
||||||
|
$this->assertEqualsWithDelta(371.42, $user->holdings->sum('average_cost_basis'), 0.01);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user