diff --git a/app/Actions/EnsureDailyChangeIsSynced.php b/app/Actions/EnsureDailyChangeIsSynced.php new file mode 100644 index 0000000..9576d1b --- /dev/null +++ b/app/Actions/EnsureDailyChangeIsSynced.php @@ -0,0 +1,33 @@ +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); + } +} diff --git a/app/Exports/BackupExport.php b/app/Exports/BackupExport.php index c31c10f..001030b 100644 --- a/app/Exports/BackupExport.php +++ b/app/Exports/BackupExport.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Exports; +use App\Exports\Sheets\ConfigSheet; use App\Exports\Sheets\DailyChangesSheet; use App\Exports\Sheets\PortfoliosSheet; use App\Exports\Sheets\TransactionsSheet; @@ -24,6 +25,7 @@ class BackupExport implements WithMultipleSheets new PortfoliosSheet($this->empty), new TransactionsSheet($this->empty), new DailyChangesSheet($this->empty), + new ConfigSheet($this->empty), ]; } } diff --git a/app/Exports/Sheets/ConfigSheet.php b/app/Exports/Sheets/ConfigSheet.php new file mode 100644 index 0000000..f01ac32 --- /dev/null +++ b/app/Exports/Sheets/ConfigSheet.php @@ -0,0 +1,64 @@ +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'; + } +} diff --git a/app/Exports/Sheets/DailyChangesSheet.php b/app/Exports/Sheets/DailyChangesSheet.php index 442f133..a22786d 100644 --- a/app/Exports/Sheets/DailyChangesSheet.php +++ b/app/Exports/Sheets/DailyChangesSheet.php @@ -34,7 +34,7 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle */ public function collection() { - return $this->empty ? collect() : DailyChange::myDailyChanges()->get(); + return $this->empty ? collect() : DailyChange::myDailyChanges()->withDailyPerformance()->get(); } public function title(): string diff --git a/app/Exports/Sheets/TransactionsSheet.php b/app/Exports/Sheets/TransactionsSheet.php index 83fae17..f8426ba 100644 --- a/app/Exports/Sheets/TransactionsSheet.php +++ b/app/Exports/Sheets/TransactionsSheet.php @@ -25,6 +25,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle 'Quantity', 'Cost Basis', 'Sale Price', + 'Currency', 'Split', 'Reinvested Dividend', 'Date', @@ -38,7 +39,30 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle */ 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 diff --git a/app/Imports/BackupImport.php b/app/Imports/BackupImport.php index 7416f9a..8c07cc2 100644 --- a/app/Imports/BackupImport.php +++ b/app/Imports/BackupImport.php @@ -8,6 +8,7 @@ use App\Console\Commands\RefreshDividendData; use App\Console\Commands\RefreshMarketData; use App\Console\Commands\SyncDailyChange; use App\Console\Commands\SyncHoldingData; +use App\Imports\Sheets\ConfigSheet; use App\Imports\Sheets\DailyChangesSheet; use App\Imports\Sheets\PortfoliosSheet; use App\Imports\Sheets\TransactionsSheet; @@ -69,6 +70,7 @@ class BackupImport implements WithEvents, WithMultipleSheets 'Portfolios' => new PortfoliosSheet($this->backupImportModel), 'Transactions' => new TransactionsSheet($this->backupImportModel), 'Daily Changes' => new DailyChangesSheet($this->backupImportModel), + 'Config' => new ConfigSheet($this->backupImportModel), ]; } } diff --git a/app/Imports/Sheets/ConfigSheet.php b/app/Imports/Sheets/ConfigSheet.php new file mode 100644 index 0000000..8390464 --- /dev/null +++ b/app/Imports/Sheets/ConfigSheet.php @@ -0,0 +1,79 @@ + 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'], + ]; + } +} diff --git a/app/Imports/Sheets/DailyChangesSheet.php b/app/Imports/Sheets/DailyChangesSheet.php index 7cdb405..c0e4db9 100644 --- a/app/Imports/Sheets/DailyChangesSheet.php +++ b/app/Imports/Sheets/DailyChangesSheet.php @@ -31,7 +31,7 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit BeforeSheet::class => function (BeforeSheet $event) { DB::commit(); $this->backupImport->update([ - 'message' => __('Importing daily changes...'), + 'message' => __('Preparing to import daily changes...'), ]); DB::beginTransaction(); }, @@ -40,19 +40,20 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit 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->backupImport->update([ + 'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]), + ]); + // have to cast to native values $chunk = $chunk->map(function ($dailyChange) { 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'], 'portfolio_id' => $dailyChange['portfolio_id'], 'date' => Carbon::parse($dailyChange['date'])->toDateString(), @@ -63,11 +64,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit $chunk->toArray(), ['portfolio_id', 'date'], [ - 'total_market_value', - 'total_cost_basis', - 'total_gain', - 'total_dividends_earned', - 'realized_gains', 'annotation', 'portfolio_id', 'date', @@ -86,11 +82,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit return [ 'portfolio_id' => ['required', 'uuid'], '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'], ]; } diff --git a/app/Imports/Sheets/TransactionsSheet.php b/app/Imports/Sheets/TransactionsSheet.php index 2682423..700a739 100644 --- a/app/Imports/Sheets/TransactionsSheet.php +++ b/app/Imports/Sheets/TransactionsSheet.php @@ -6,6 +6,8 @@ namespace App\Imports\Sheets; use App\Imports\ValidatesPortfolioAccess; use App\Models\BackupImport; +use App\Models\Currency; +use App\Models\CurrencyRate; use App\Models\Holding; use App\Models\Transaction; use Illuminate\Support\Carbon; @@ -33,7 +35,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit BeforeSheet::class => function (BeforeSheet $event) { DB::commit(); $this->backupImport->update([ - 'message' => __('Importing transactions...'), + 'message' => __('Preparing to import transactions...'), ]); DB::beginTransaction(); }, @@ -43,13 +45,37 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit 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); // have to cast to native values $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 [ 'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(), 'symbol' => strtoupper($transaction['symbol']), @@ -58,9 +84,11 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit 'quantity' => $transaction['quantity'], 'cost_basis' => $transaction['cost_basis'] ?? 0, 'sale_price' => $transaction['sale_price'], + 'cost_basis_base' => $cost_basis_base, + 'sale_price_base' => $sale_price_base, 'split' => boolval($transaction['split']) ? 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']) ->each(function ($holding) { @@ -112,6 +140,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit 'transaction_type' => ['required', 'in:BUY,SELL'], 'date' => ['required', 'date'], 'quantity' => ['required', 'min:0', 'numeric'], + 'currency' => ['required', 'string'], 'split' => ['sometimes', 'nullable', 'boolean'], 'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'], 'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'], diff --git a/app/Imports/ValidatesPortfolioAccess.php b/app/Imports/ValidatesPortfolioAccess.php index 0336a27..a47c8cf 100644 --- a/app/Imports/ValidatesPortfolioAccess.php +++ b/app/Imports/ValidatesPortfolioAccess.php @@ -11,13 +11,13 @@ trait ValidatesPortfolioAccess public function validatePortfolioAccess($collection) { - $uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id'); - $countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id) - ->whereIn('id', $uniquePortfolios) + $importingPortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id'); + $portfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id) + ->whereIn('id', $importingPortfolios) ->count(); if ( - $countPortfoliosWithAccess < $uniquePortfolios->count() + $importingPortfolios->count() > $portfoliosWithAccess ) { throw new \Exception(__('You do not have access to that portfolio.')); } diff --git a/app/Models/DailyChange.php b/app/Models/DailyChange.php index 01127de..8adf726 100644 --- a/app/Models/DailyChange.php +++ b/app/Models/DailyChange.php @@ -149,7 +149,7 @@ class DailyChange extends Model ]); 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) { $join->on('daily_change.date', '>=', 'cost_basis_display.date') ->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id'); diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 4a7ce35..4450139 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -7,6 +7,7 @@ namespace App\Models; use App\Actions\ConvertToMarketDataCurrency; use App\Actions\CopyToBaseCurrency; use App\Actions\EnsureCostBasisAddedToSale; +use App\Actions\EnsureDailyChangeIsSynced; use App\Casts\BaseCurrency; use App\Traits\HasMarketData; use Illuminate\Contracts\Database\Eloquent\Builder; @@ -69,6 +70,12 @@ class Transaction extends Model $transaction->syncToHolding(); + $transaction = Pipeline::send($transaction) + ->through([ + EnsureDailyChangeIsSynced::class, + ]) + ->then(fn (Transaction $transaction) => $transaction); + cache()->forget('portfolio-metrics-'.$transaction->portfolio_id); }); @@ -104,6 +111,7 @@ class Transaction extends Model { return $query->withAggregate('market_data', 'name') ->withAggregate('market_data', 'market_value') + ->withAggregate('market_data', 'currency') ->withAggregate('market_data', 'fifty_two_week_low') ->withAggregate('market_data', 'fifty_two_week_high') ->withAggregate('market_data', 'updated_at') diff --git a/app/Models/User.php b/app/Models/User.php index 03d76a4..7ab7cce 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -98,4 +98,14 @@ class User extends Authenticatable implements MustVerifyEmail 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; + } } diff --git a/lang/en.json b/lang/en.json index b87ee63..a73c43b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -367,8 +367,11 @@ "Import starting...": "Import starting...", "Import is in progress...": "Import is in progress...", "Importing portfolios...": "Importing portfolios...", - "Importing transactions...": "Importing transactions...", - "Importing daily changes...": "Importing daily changes...", + "Preparing to import transactions...": "Preparing to import transactions...", + "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!", "Your import will continue in the background": "Your import will continue in the background", diff --git a/lang/es.json b/lang/es.json index aec5aaa..3236b6e 100644 --- a/lang/es.json +++ b/lang/es.json @@ -367,8 +367,11 @@ "Import starting...": "Iniciando la importación...", "Import is in progress...": "La importación está en progreso...", "Importing portfolios...": "Importando portafolios...", - "Importing transactions...": "Importando transacciones...", - "Importing daily changes...": "Importando cambios diarios...", + "Preparing to import transactions...": "Preparándose para importar transacciones...", + "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!", "Your import will continue in the background": "La importación continuará en segundo plano", diff --git a/resources/views/profile/import-portfolios-field.blade.php b/resources/views/profile/import-portfolios-field.blade.php index 6986862..da60734 100644 --- a/resources/views/profile/import-portfolios-field.blade.php +++ b/resources/views/profile/import-portfolios-field.blade.php @@ -89,13 +89,13 @@ new class extends Component {{ __('Upload or recover your Investbrain portfolio and holdings.') }} + {{ __('Download import template.') }}
-

{{ __('Download import template.') }}

diff --git a/resources/views/profile/localization-form.blade.php b/resources/views/profile/localization-form.blade.php index 1af652a..2e3bffd 100644 --- a/resources/views/profile/localization-form.blade.php +++ b/resources/views/profile/localization-form.blade.php @@ -40,7 +40,7 @@ new class extends Component $this->validate(); - $this->user->options = array_merge($this->user->options ?? [], [ + $this->user->setOption([ 'locale' => $this->locale, 'display_currency' => $this->display_currency, ]); @@ -51,7 +51,7 @@ new class extends Component $this->dispatch('saved'); - //$this->js('window.location.reload();'); + // $this->js('window.location.reload();'); } }; ?> diff --git a/tests/0000_00_00_import_configs_test.xlsx b/tests/0000_00_00_import_configs_test.xlsx new file mode 100644 index 0000000..3253936 Binary files /dev/null and b/tests/0000_00_00_import_configs_test.xlsx differ diff --git a/tests/0000_00_00_import_multi_curr_test.xlsx b/tests/0000_00_00_import_multi_curr_test.xlsx new file mode 100644 index 0000000..651cc5a Binary files /dev/null and b/tests/0000_00_00_import_multi_curr_test.xlsx differ diff --git a/tests/0000_00_00_import_test.xlsx b/tests/0000_00_00_import_test.xlsx index a58b90c..36b9a1e 100644 Binary files a/tests/0000_00_00_import_test.xlsx and b/tests/0000_00_00_import_test.xlsx differ diff --git a/tests/DailyChangeTest.php b/tests/DailyChangeTest.php index 81bc9ef..d55381a 100644 --- a/tests/DailyChangeTest.php +++ b/tests/DailyChangeTest.php @@ -198,4 +198,52 @@ class DailyChangeTest extends TestCase $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); + } } diff --git a/tests/ImportExportTest.php b/tests/ImportExportTest.php index 1109d4e..9c87469 100644 --- a/tests/ImportExportTest.php +++ b/tests/ImportExportTest.php @@ -6,6 +6,8 @@ namespace Tests; use App\Exports\BackupExport; use App\Models\BackupImport as BackupImportModel; +use App\Models\Holding; +use App\Models\Portfolio; use App\Models\Transaction; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -15,62 +17,89 @@ class ImportExportTest extends TestCase { use RefreshDatabase; - // todo: need to fix import export + public function test_can_create_exports(): void + { + Excel::fake(); - // public function test_can_create_exports(): void - // { - // Excel::fake(); + $this->actingAs($user = User::factory()->create()); - // $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) { - // return true; - // }); - // } + public function test_import_job_completes(): void + { + $this->actingAs($user = User::factory()->create()); - // public function test_backup_job_completes(): void - // { - // $this->actingAs($user = User::factory()->create()); + $import_job = BackupImportModel::create([ + 'user_id' => auth()->user()->id, + 'path' => __DIR__.'/0000_00_00_import_test.xlsx', + ]); - // $backup_job = BackupImportModel::create([ - // 'user_id' => auth()->user()->id, - // 'path' => __DIR__.'/0000_00_00_import_test.xlsx', - // ]); + $import_job->refresh(); - // $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 - // { - // $this->actingAs($user = User::factory()->create()); + BackupImportModel::create([ + 'user_id' => auth()->user()->id, + 'path' => __DIR__.'/0000_00_00_import_test.xlsx', + ]); - // BackupImportModel::create([ - // 'user_id' => auth()->user()->id, - // 'path' => __DIR__.'/0000_00_00_import_test.xlsx', - // ]); + $this->assertEquals(3, $user->transactions->count()); + } - // $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 - // { - // $this->actingAs($user = User::factory()->create()); + BackupImportModel::create([ + 'user_id' => auth()->user()->id, + 'path' => __DIR__.'/0000_00_00_import_test.xlsx', + ]); - // BackupImportModel::create([ - // 'user_id' => auth()->user()->id, - // 'path' => __DIR__.'/0000_00_00_import_test.xlsx', - // ]); + $holding = $user->holdings->first(); - // $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); - // $this->assertEquals(6, $holding->quantity); - // $this->assertEqualsWithDelta(233.33, $holding->average_cost_basis, 0.01); - // } + public function test_configurations_are_set_on_import(): void + { + $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); + } } diff --git a/tests/MultiCurrencyTest.php b/tests/MultiCurrencyTest.php index 33e9d7d..621d355 100644 --- a/tests/MultiCurrencyTest.php +++ b/tests/MultiCurrencyTest.php @@ -6,6 +6,7 @@ namespace Tests; use App\Interfaces\MarketData\FakeMarketData; use App\Interfaces\MarketData\Types\Quote; +use App\Models\BackupImport; use App\Models\Currency; use App\Models\CurrencyRate; 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('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); + } }